Merge pull request 'hjjeong' (#11) from hjjeong into main

Reviewed-on: https://g.wace.me/chpark/vexplor_rps/pulls/11
This commit is contained in:
hjjeong
2026-05-14 00:28:57 +00:00
33 changed files with 3265 additions and 945 deletions
+2
View File
@@ -119,6 +119,7 @@ import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
import bomRoutes from "./routes/bomRoutes"; // BOM 이력/버전 관리
import productionRoutes from "./routes/productionRoutes"; // 생산계획 관리
import productionMbomRoutes from "./routes/productionMbomRoutes"; // 생산관리>M-BOM 관리 (wace_plm 도메인)
import itemInspectionRoutes from "./routes/itemInspectionRoutes"; // 품목검사정보
import crawlRoutes from "./routes/crawlRoutes"; // 웹 크롤링
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
@@ -380,6 +381,7 @@ app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리
app.use("/api/production", productionRoutes); // 생산계획 관리
app.use("/api/production/mbom", productionMbomRoutes); // 생산관리>M-BOM 관리 (wace_plm 도메인)
app.use("/api/item-inspection", itemInspectionRoutes); // 품목검사정보 (그룹 페이징)
app.use("/api/crawl", crawlRoutes); // 웹 크롤링
app.use("/api/material-status", materialStatusRoutes); // 자재현황
@@ -0,0 +1,54 @@
// ============================================================
// 생산관리 > M-BOM 관리 — wace productionplanning.xml 1:1 이식.
// 라우트:
// GET /api/production/mbom/list 그리드 (PROJECT_MGMT × CONTRACT_ITEM 펼침)
// GET /api/production/mbom/detail/:objid 단건 상세 (mBomHeaderPopup.do 1:1)
// GET /api/production/mbom/tree/:objid read-only 트리 4분기 자동 판별 (mBomPopupLeft.do 1:1)
// ============================================================
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import * as svc from "../services/mbomService";
import { logger } from "../utils/logger";
function parseFilter(q: Record<string, any>): svc.MbomListFilter {
const filter: svc.MbomListFilter = { ...q };
if (q.page) filter.page = Number(q.page);
if (q.page_size) filter.page_size = Number(q.page_size);
return filter;
}
export async function getList(req: AuthenticatedRequest, res: Response) {
try {
const data = await svc.list(parseFilter(req.query as Record<string, any>));
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 });
}
}
export async function getDetail(req: AuthenticatedRequest, res: Response) {
try {
const objid = String(req.params.objid ?? "").trim();
if (!objid) return res.status(400).json({ success: false, message: "objid 누락" });
const data = await svc.getDetail(objid);
if (!data) return res.status(404).json({ success: false, message: "프로젝트를 찾을 수 없습니다" });
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 });
}
}
export async function getTree(req: AuthenticatedRequest, res: Response) {
try {
const objid = String(req.params.objid ?? "").trim();
if (!objid) return res.status(400).json({ success: false, message: "objid 누락" });
const data = await svc.getTree(objid);
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 });
}
}
@@ -0,0 +1,17 @@
// ============================================================
// 생산관리 > M-BOM 관리 라우트.
// app.ts: app.use("/api/production/mbom", productionMbomRoutes)
// ============================================================
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as ctrl from "../controllers/mbomController";
const router = Router();
router.use(authenticateToken);
router.get("/list", ctrl.getList);
router.get("/detail/:objid", ctrl.getDetail);
router.get("/tree/:objid", ctrl.getTree);
export default router;
+866
View File
@@ -0,0 +1,866 @@
// ============================================================
// 생산관리 > M-BOM 관리 — wace productionplanning.xml 1:1 이식.
//
// 매퍼 매핑 (원본: wace_plm/src/com/pms/mapper/productionplanning.xml,
// wace_plm/src/com/pms/mapper/partMng.xml):
// mBomMgmtGridList → list() (PR-A1, ~3119)
// getProjectMgmtDetail → getDetail() (PR-A2, ~3218)
// getLatestMbomByProjectId → getLatestSavedMbom() (PR-A2, ~3570)
// getLatestMbomTemplateByPartNo → getLatestTemplate() (PR-A2, ~3591)
// getSavedMbomTreeList → getSavedTree() (PR-A2, ~4359)
// getMbomStructureOnly → getStructureOnly() (PR-A2, ~4538)
// getMbomTemplateDetails → getTemplateDetails() (PR-A2, ~3794)
// partMng.getBOMTreeList → getEbomWorkingTree() (PR-A2, partMng.xml ~3549)
//
// 트리 분기 (mBomPopupLeft.do 1:1):
// 1) SAVED — mbom_header 에 status='Y' 가 있으면 그 트리
// 2) ASSIGNED_EBOM — project_mgmt.source_bom_type='EBOM' + source_ebom_objid
// 3) ASSIGNED_MBOM — project_mgmt.source_bom_type='MBOM' + source_mbom_objid
// 4) TEMPLATE — Machine 이외(product != '0000928') + part_no 있으면 동일 품번 mbom_header
// 5) NONE — 빈 트리
// ============================================================
import { getPool } from "../database/db";
// ─── 필터/페이지 타입 ──────────────────────────────────────────
export interface MbomListFilter {
search_category_cd?: string;
search_product_cd?: string;
search_area_cd?: string;
search_customer_objid?: string;
search_paid_type?: string;
search_serial_no?: string;
search_part_no?: string;
search_part_name?: string;
search_receipt_date_from?: string;
search_receipt_date_to?: string;
search_req_del_date_from?: string;
search_req_del_date_to?: string;
page?: number;
page_size?: number;
}
function paginate(filter: { page?: number; page_size?: number }) {
const page = Math.max(1, Number(filter.page) || 1);
const pageSize = Math.min(500, Math.max(1, Number(filter.page_size) || 50));
return { limit: pageSize, offset: (page - 1) * pageSize, page, pageSize };
}
// ─── WHERE 절 빌더 (매퍼 mBomMgmtGridList <if> 조건 1:1) ──────
function buildWhere(filter: MbomListFilter, startIdx: number) {
const params: any[] = [];
const conds: string[] = [];
let idx = startIdx;
if (filter.search_category_cd) {
conds.push(`CM.CATEGORY_CD = $${idx++}`);
params.push(filter.search_category_cd);
}
if (filter.search_product_cd) {
conds.push(`CM.PRODUCT = $${idx++}`);
params.push(filter.search_product_cd);
}
if (filter.search_area_cd) {
// 운영판: CODE_NAME(CM.AREA_CD) = '국내'/'해외' (사람이 보는 값으로 검색)
conds.push(`(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.AREA_CD LIMIT 1) = $${idx++}`);
params.push(filter.search_area_cd);
}
if (filter.search_receipt_date_from) {
conds.push(`PM.REGDATE >= TO_DATE($${idx++}, 'YYYY-MM-DD')`);
params.push(filter.search_receipt_date_from);
}
if (filter.search_receipt_date_to) {
conds.push(`PM.REGDATE < TO_DATE($${idx++}, 'YYYY-MM-DD') + INTERVAL '1 day'`);
params.push(filter.search_receipt_date_to);
}
if (filter.search_customer_objid) {
// 운영판 3-way 매칭 (C_xxx 양방향) 1:1
conds.push(
`(CM.CUSTOMER_OBJID = $${idx} OR CM.CUSTOMER_OBJID = REPLACE($${idx}, 'C_', '') OR 'C_' || CM.CUSTOMER_OBJID = $${idx})`
);
params.push(filter.search_customer_objid);
idx++;
}
if (filter.search_paid_type) {
conds.push(`CM.PAID_TYPE = $${idx++}`);
params.push(filter.search_paid_type);
}
if (filter.search_serial_no) {
conds.push(
`EXISTS (SELECT 1 FROM CONTRACT_ITEM_SERIAL CIS
WHERE CIS.ITEM_OBJID::VARCHAR = PM.CONTRACT_ITEM_OBJID
AND UPPER(CIS.STATUS) = 'ACTIVE'
AND UPPER(CIS.SERIAL_NO) LIKE '%' || UPPER($${idx++}) || '%')`
);
params.push(filter.search_serial_no);
}
if (filter.search_req_del_date_from) {
conds.push(`COALESCE(CI.DUE_DATE, PM.DUE_DATE, CM.REQ_DEL_DATE) >= $${idx++}`);
params.push(filter.search_req_del_date_from);
}
if (filter.search_req_del_date_to) {
conds.push(`COALESCE(CI.DUE_DATE, PM.DUE_DATE, CM.REQ_DEL_DATE) <= $${idx++}`);
params.push(filter.search_req_del_date_to);
}
if (filter.search_part_no) {
conds.push(
`(UPPER(PM.PART_NO) LIKE '%' || UPPER($${idx}) || '%' OR UPPER(CI.PART_NO) LIKE '%' || UPPER($${idx}) || '%')`
);
params.push(filter.search_part_no);
idx++;
}
if (filter.search_part_name) {
conds.push(
`(UPPER(PM.PART_NAME) LIKE '%' || UPPER($${idx}) || '%' OR UPPER(CI.PART_NAME) LIKE '%' || UPPER($${idx}) || '%')`
);
params.push(filter.search_part_name);
idx++;
}
return { sql: conds.length ? "AND " + conds.join(" AND ") : "", params };
}
// ─── M-BOM 관리 그리드 ──────────────────────────────────────────
//
// 매퍼 productionplanning.xml mBomMgmtGridList (라인 2874~3119) 1:1 이식.
export async function list(filter: MbomListFilter) {
const { limit, offset, page, pageSize } = paginate(filter);
const where = buildWhere(filter, 1);
const pool = getPool();
const baseSql = `
FROM PROJECT_MGMT PM
LEFT JOIN CONTRACT_MGMT CM ON PM.CONTRACT_OBJID = CM.OBJID
LEFT OUTER JOIN CONTRACT_ITEM CI ON (
CASE
WHEN PM.CONTRACT_ITEM_OBJID IS NOT NULL THEN CI.OBJID::VARCHAR = PM.CONTRACT_ITEM_OBJID
ELSE CI.CONTRACT_OBJID = PM.CONTRACT_OBJID AND CI.PART_OBJID = PM.PART_OBJID
END
)
AND CI.STATUS = 'ACTIVE'
WHERE 1=1
AND PM.PROJECT_NO IS NOT NULL
AND PM.PROJECT_NO != ''
${where.sql}
`;
const dataSql = `
SELECT
PM.OBJID,
PM.CONTRACT_OBJID,
PM.PROJECT_NO,
CM.CATEGORY_CD,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.CATEGORY_CD LIMIT 1), '') AS CATEGORY_NAME,
CM.PRODUCT,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.PRODUCT LIMIT 1), '') AS PRODUCT_NAME,
CM.AREA_CD,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.AREA_CD LIMIT 1), '') AS AREA_NAME,
TO_CHAR(PM.REGDATE, 'YYYY-MM-DD') AS RECEIPT_DATE,
(SELECT user_name(MH.WRITER)
FROM MBOM_HEADER MH
WHERE MH.PROJECT_OBJID = PM.OBJID::VARCHAR
AND MH.STATUS = 'Y'
ORDER BY MH.REGDATE DESC
LIMIT 1) AS WRITER_NAME,
CM.CUSTOMER_OBJID,
COALESCE(
CASE WHEN CM.CUSTOMER_OBJID LIKE 'C_%'
THEN (SELECT CLIENT_NM FROM CLIENT_MNG AS C
WHERE 'C_' || C.OBJID::VARCHAR = CM.CUSTOMER_OBJID LIMIT 1)
ELSE (SELECT SUPPLY_NAME FROM SUPPLY_MNG
WHERE OBJID::VARCHAR = CM.CUSTOMER_OBJID::VARCHAR LIMIT 1) END,
''
) AS CUSTOMER_NAME,
CM.PAID_TYPE,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.PAID_TYPE LIMIT 1),
CASE
WHEN CM.PAID_TYPE = 'paid' THEN '유상'
WHEN CM.PAID_TYPE = 'free' THEN '무상'
ELSE '' END
) AS PAID_TYPE_NAME,
-- 품목 정보: CONTRACT_ITEM 우선, 없으면 PM
COALESCE(CI.PART_NO, PM.PART_NO, '') AS PART_NO,
COALESCE(CI.PART_NAME, PM.PART_NAME, '') AS PART_NAME,
CI.PART_OBJID,
-- S/N 표시: "첫S/N 외 N건"
(SELECT
CASE
WHEN COUNT(*) = 0 THEN ''
WHEN COUNT(*) = 1 THEN MIN(CIS.SERIAL_NO)
ELSE MIN(CIS.SERIAL_NO) || ' 외 ' || (COUNT(*) - 1)::TEXT || '건' END
FROM CONTRACT_ITEM_SERIAL CIS
WHERE CIS.ITEM_OBJID::VARCHAR = PM.CONTRACT_ITEM_OBJID
AND UPPER(CIS.STATUS) = 'ACTIVE'
AND CIS.SERIAL_NO IS NOT NULL
AND CIS.SERIAL_NO != ''
) AS SERIAL_NO,
-- S/N 전체 (콤마 리스트) — 팝업용
(SELECT STRING_AGG(CIS.SERIAL_NO, ', ' ORDER BY CIS.SERIAL_NO)
FROM CONTRACT_ITEM_SERIAL CIS
WHERE CIS.ITEM_OBJID::VARCHAR = PM.CONTRACT_ITEM_OBJID
AND UPPER(CIS.STATUS) = 'ACTIVE'
AND CIS.SERIAL_NO IS NOT NULL
AND CIS.SERIAL_NO != ''
) AS SERIAL_NO_LIST,
COALESCE(NULLIF(PM.QUANTITY, '')::numeric, NULLIF(CI.ORDER_QUANTITY, '')::numeric, 0) AS QUANTITY,
COALESCE(CI.DUE_DATE, PM.DUE_DATE, CM.REQ_DEL_DATE) AS REQ_DEL_DATE,
COALESCE(CI.CUSTOMER_REQUEST, '') AS CUSTOMER_REQUEST,
-- E-BOM 정보
COALESCE(CI.PART_OBJID, PM.PART_OBJID) AS BOM_REPORT_OBJID,
COALESCE((SELECT PBR.STATUS FROM PART_BOM_REPORT PBR
WHERE PBR.OBJID::VARCHAR = COALESCE(CI.PART_OBJID, PM.PART_OBJID) LIMIT 1), '') AS EBOM_STATUS,
COALESCE((SELECT TO_CHAR(PBR.REGDATE, 'YYYY-MM-DD') FROM PART_BOM_REPORT PBR
WHERE PBR.OBJID::VARCHAR = COALESCE(CI.PART_OBJID, PM.PART_OBJID) LIMIT 1), '') AS EBOM_REGDATE,
-- M-BOM HEADER OBJID
(SELECT MH.OBJID::VARCHAR FROM MBOM_HEADER MH
WHERE MH.PROJECT_OBJID = PM.OBJID::VARCHAR AND MH.STATUS = 'Y'
ORDER BY MH.REGDATE DESC LIMIT 1) AS MBOM_HEADER_OBJID,
-- 구매리스트 OBJID + 생성일
(SELECT SRM.OBJID::VARCHAR FROM SALES_REQUEST_MASTER SRM
WHERE SRM.MBOM_HEADER_OBJID = (
SELECT MH.OBJID::VARCHAR FROM MBOM_HEADER MH
WHERE MH.PROJECT_OBJID = PM.OBJID::VARCHAR AND MH.STATUS = 'Y'
ORDER BY MH.REGDATE DESC LIMIT 1
) LIMIT 1) AS PURCHASE_LIST_OBJID,
(SELECT TO_CHAR(SRM.REGDATE, 'YYYY-MM-DD') FROM SALES_REQUEST_MASTER SRM
WHERE SRM.MBOM_HEADER_OBJID = (
SELECT MH.OBJID::VARCHAR FROM MBOM_HEADER MH
WHERE MH.PROJECT_OBJID = PM.OBJID::VARCHAR AND MH.STATUS = 'Y'
ORDER BY MH.REGDATE DESC LIMIT 1
) LIMIT 1) AS PURCHASE_LIST_DATE,
-- M-BOM 상태 ('Y' 있으면 Y, 아니면 PM.mbom_status raw)
COALESCE(
(SELECT CASE WHEN COUNT(*) > 0 THEN 'Y' ELSE COALESCE(PM.MBOM_STATUS, '') END
FROM MBOM_HEADER MH
WHERE MH.PROJECT_OBJID = PM.OBJID::VARCHAR AND MH.STATUS = 'Y' LIMIT 1),
COALESCE(PM.MBOM_STATUS, '')
) AS MBOM_STATUS,
-- M-BOM 품번
COALESCE(
(SELECT MH.MBOM_NO FROM MBOM_HEADER MH
WHERE MH.PROJECT_OBJID = PM.OBJID::VARCHAR AND MH.STATUS = 'Y'
ORDER BY MH.REGDATE DESC LIMIT 1), ''
) AS MBOM_PART_NO,
-- M-BOM 저장일 (수정일 우선, 없으면 등록일)
(SELECT TO_CHAR(COALESCE(MH.EDIT_DATE, MH.REGDATE), 'YYYY-MM-DD')
FROM MBOM_HEADER MH
WHERE MH.PROJECT_OBJID = PM.OBJID::VARCHAR AND MH.STATUS = 'Y'
ORDER BY COALESCE(MH.EDIT_DATE, MH.REGDATE) DESC LIMIT 1) AS MBOM_REGDATE,
-- M-BOM 작성자/수정자
(SELECT user_name(COALESCE(MH.EDITER, MH.WRITER))
FROM MBOM_HEADER MH
WHERE MH.PROJECT_OBJID = PM.OBJID::VARCHAR AND MH.STATUS = 'Y'
ORDER BY COALESCE(MH.EDITER, MH.WRITER) DESC LIMIT 1) AS MBOM_EDITOR,
-- M-BOM 변경이력 카운트 (0이면 NULL)
NULLIF(
(SELECT COUNT(1)::INTEGER FROM MBOM_HISTORY MHI
WHERE MHI.MBOM_HEADER_OBJID = (
SELECT MH.OBJID::VARCHAR FROM MBOM_HEADER MH
WHERE MH.PROJECT_OBJID = PM.OBJID::VARCHAR AND MH.STATUS = 'Y'
ORDER BY MH.REGDATE DESC LIMIT 1
)), 0
) AS MBOM_VERSION
${baseSql}
ORDER BY PM.REGDATE DESC, CI.PART_NO
LIMIT $${where.params.length + 1} OFFSET $${where.params.length + 2}
`;
const countSql = `SELECT COUNT(*)::int AS cnt ${baseSql}`;
const dataParams = [...where.params, limit, offset];
const [dataRes, countRes] = await Promise.all([
pool.query(dataSql, dataParams),
pool.query(countSql, where.params),
]);
return {
rows: dataRes.rows,
totalCount: countRes.rows[0]?.cnt ?? 0,
page,
pageSize,
};
}
// ─── 단건 상세 (getProjectMgmtDetail) ──────────────────────────
//
// 매퍼 productionplanning.xml getProjectMgmtDetail (라인 3150~3218) 1:1.
// TOTAL_PROD_QTY = production_plan 우선 → PM.QUANTITY fallback.
export async function getDetail(objid: string) {
const pool = getPool();
const sql = `
SELECT
PM.OBJID::VARCHAR AS objid,
PM.CONTRACT_OBJID AS contract_objid,
PM.PROJECT_NO AS project_no,
PM.BOM_REPORT_OBJID AS bom_report_objid,
PM.PART_OBJID AS part_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,
PM.QUANTITY AS quantity,
COALESCE(
(SELECT NULLIF(PP.TOTAL_PROD_QTY, '')::numeric
FROM PRODUCTION_PLAN PP
WHERE PP.PROJECT_OBJID = PM.OBJID::VARCHAR
AND UPPER(PP.STATUS) = 'ACTIVE'
LIMIT 1),
COALESCE(NULLIF(PM.QUANTITY, '')::numeric, 0)
) AS total_prod_qty,
COALESCE(
(SELECT PBR.PART_NO FROM PART_BOM_REPORT PBR
WHERE PBR.OBJID::VARCHAR = PM.BOM_REPORT_OBJID
AND PM.MBOM_STATUS = 'Y'
LIMIT 1), ''
) AS mbom_part_no,
CM.CATEGORY_CD AS category_cd,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.CATEGORY_CD LIMIT 1), '') AS category_name,
CM.PRODUCT AS product,
CM.PRODUCT AS product_code,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.PRODUCT LIMIT 1), '') AS product_name,
CM.AREA_CD AS area_cd,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.AREA_CD LIMIT 1), '') AS area_name,
CM.CUSTOMER_OBJID AS customer_objid,
COALESCE(
CASE WHEN CM.CUSTOMER_OBJID LIKE 'C_%'
THEN (SELECT CLIENT_NM FROM CLIENT_MNG AS C
WHERE 'C_' || C.OBJID::VARCHAR = CM.CUSTOMER_OBJID LIMIT 1)
ELSE (SELECT SUPPLY_NAME FROM SUPPLY_MNG
WHERE OBJID::VARCHAR = CM.CUSTOMER_OBJID::VARCHAR LIMIT 1) END,
''
) AS customer_name,
CM.PAID_TYPE AS paid_type,
CM.REQ_DEL_DATE AS req_del_date,
TO_CHAR(PM.REGDATE, 'YYYY-MM-DD') AS receipt_date,
COALESCE(
(SELECT TO_CHAR(PBR.REGDATE, 'YYYY-MM-DD')
FROM PART_BOM_REPORT PBR
WHERE PBR.OBJID::VARCHAR = PM.BOM_REPORT_OBJID
AND PM.MBOM_STATUS = 'Y'
LIMIT 1),
TO_CHAR(PM.REGDATE, 'YYYY-MM-DD')
) AS mbom_regdate
FROM PROJECT_MGMT PM
INNER JOIN CONTRACT_MGMT CM ON PM.CONTRACT_OBJID = CM.OBJID
WHERE PM.OBJID::VARCHAR = $1
LIMIT 1
`;
const r = await pool.query(sql, [objid]);
return r.rows[0] ?? null;
}
// ─── 분기 진입점 (mBomPopupLeft.do 1:1) ────────────────────────
//
// 운영판 ProductionPlanningController:1113~1276 의 분기 로직:
// 1) 저장된 M-BOM (mbom_header.status='Y') 우선
// 2) 없으면 source_bom_type 으로 EBOM/MBOM 분기
// 3) 그래도 없으면 Machine 이외 + part_no 매칭으로 템플릿
// 4) 모두 없으면 빈 트리
export type BomDataType = "SAVED" | "ASSIGNED_EBOM" | "ASSIGNED_MBOM" | "TEMPLATE" | "NONE";
export interface MbomTreeResult {
bom_data_type: BomDataType;
bom_report_objid: string | null;
max_level: number;
rows: any[];
}
export async function getTree(objid: string): Promise<MbomTreeResult> {
const detail = await getDetail(objid);
if (!detail) {
return { bom_data_type: "NONE", bom_report_objid: null, max_level: 1, rows: [] };
}
// 1) SAVED — getLatestMbomByProjectId
const saved = await getLatestSavedMbom(objid);
if (saved && saved.objid) {
const rows = await getSavedTree(saved.objid);
return finalize("SAVED", saved.objid, rows);
}
// 2) ASSIGNED_EBOM
if (detail.source_bom_type === "EBOM" && detail.source_ebom_objid) {
const rows = await getEbomWorkingTree(detail.source_ebom_objid);
return finalize("ASSIGNED_EBOM", detail.source_ebom_objid, rows);
}
// 3) ASSIGNED_MBOM
if (detail.source_bom_type === "MBOM" && detail.source_mbom_objid) {
const rows = await getStructureOnly(detail.source_mbom_objid);
return finalize("ASSIGNED_MBOM", detail.source_mbom_objid, rows);
}
// 4) TEMPLATE — Machine 이외 + part_no
if (detail.product_code !== "0000928" && detail.part_no) {
const tpl = await getLatestTemplate(detail.part_no);
if (tpl && tpl.template_header_objid) {
const rows = await getTemplateDetails(tpl.template_header_objid);
return finalize("TEMPLATE", tpl.template_header_objid, rows);
}
}
// 5) NONE
return { bom_data_type: "NONE", bom_report_objid: detail.bom_report_objid ?? null, max_level: 1, rows: [] };
}
function finalize(type: BomDataType, bomReportObjid: string, rows: any[]): MbomTreeResult {
let maxLevel = 1;
for (const r of rows) {
const lv = Number(r.level ?? r.LEVEL ?? 1);
if (lv > maxLevel) maxLevel = lv;
}
return { bom_data_type: type, bom_report_objid: bomReportObjid, max_level: maxLevel, rows };
}
// ─── 분기 1) SAVED 진입 ──────────────────────────────────────
//
// 매퍼 getLatestMbomByProjectId (productionplanning.xml:3555~3570) 1:1.
async function getLatestSavedMbom(projectObjId: string) {
const pool = getPool();
const r = await pool.query(
`SELECT OBJID::VARCHAR AS objid, MBOM_NO AS mbom_no,
SOURCE_BOM_TYPE AS source_bom_type,
SOURCE_EBOM_OBJID AS source_ebom_objid,
SOURCE_MBOM_OBJID AS source_mbom_objid,
PROJECT_OBJID AS project_objid, STATUS AS status, REGDATE AS regdate
FROM MBOM_HEADER
WHERE PROJECT_OBJID = $1 AND STATUS = 'Y'
ORDER BY REGDATE DESC LIMIT 1`,
[projectObjId],
);
return r.rows[0] ?? null;
}
// ─── 분기 4) TEMPLATE 진입 ───────────────────────────────────
//
// 매퍼 getLatestMbomTemplateByPartNo (productionplanning.xml:3573~3591) 1:1.
async function getLatestTemplate(partNo: string) {
const pool = getPool();
const r = await pool.query(
`SELECT MH.OBJID::VARCHAR AS template_header_objid,
MH.MBOM_NO AS template_mbom_no,
MH.PART_NO AS part_no,
MH.PART_NAME AS part_name,
MH.SOURCE_BOM_TYPE AS source_bom_type,
MH.SOURCE_EBOM_OBJID AS source_ebom_objid,
MH.SOURCE_MBOM_OBJID AS source_mbom_objid,
TO_CHAR(MH.REGDATE, 'YYYY-MM-DD HH24:MI:SS') AS regdate
FROM MBOM_HEADER MH
INNER JOIN PROJECT_MGMT PM ON MH.PROJECT_OBJID = PM.OBJID::VARCHAR
INNER JOIN CONTRACT_MGMT CM ON PM.CONTRACT_OBJID = CM.OBJID
WHERE MH.PART_NO = $1
AND MH.STATUS = 'Y'
AND CM.PRODUCT != '0000928'
ORDER BY MH.REGDATE DESC LIMIT 1`,
[partNo],
);
return r.rows[0] ?? null;
}
// ─── 분기 1-SAVED 트리 ───────────────────────────────────────
//
// 매퍼 getSavedMbomTreeList (productionplanning.xml:4114~4359) 1:1.
// RECURSIVE CTE + PART_MNG 조인 + ATTACH_FILE_INFO 카운트 (CU01/02/03_CNT) + 소재소요량.
async function getSavedTree(mbomHeaderObjid: string) {
const pool = getPool();
const r = await pool.query(SAVED_TREE_SQL, [mbomHeaderObjid]);
return r.rows;
}
// ─── 분기 3-ASSIGNED_MBOM 구조만 ─────────────────────────────
//
// 매퍼 getMbomStructureOnly (productionplanning.xml:4362~4538) 1:1.
// 생산 정보는 NULL — 구조만 표시.
async function getStructureOnly(mbomHeaderObjid: string) {
const pool = getPool();
const r = await pool.query(STRUCTURE_ONLY_SQL, [mbomHeaderObjid]);
return r.rows;
}
// ─── 분기 4-TEMPLATE 트리 ────────────────────────────────────
//
// 매퍼 getMbomTemplateDetails (productionplanning.xml:3594~3794) 1:1.
// ORDER_QTY/PRODUCTION_QTY 가 빠진 점만 SAVED 와 다름.
async function getTemplateDetails(mbomHeaderObjid: string) {
const pool = getPool();
const r = await pool.query(TEMPLATE_TREE_SQL, [mbomHeaderObjid]);
return r.rows;
}
// ─── 분기 2-ASSIGNED_EBOM 트리 ───────────────────────────────
//
// 매퍼 partMng.getBOMTreeList (partMng.xml:3289~3549) - search_type='working' 1:1.
// bom_part_qty RECURSIVE CTE + PART_MNG 조인.
async function getEbomWorkingTree(bomReportObjid: string) {
const pool = getPool();
const r = await pool.query(EBOM_WORKING_TREE_SQL, [bomReportObjid]);
return r.rows;
}
// ─── 트리 SELECT 본문 (매퍼 4종 1:1, lowercase alias) ──────
const SAVED_TREE_SQL = `
WITH RECURSIVE VIEW_BOM(
MBOM_HEADER_OBJID, OBJID, PARENT_OBJID, CHILD_OBJID,
PART_OBJID, PART_NO, PART_NAME, QTY, ITEM_QTY, QTY_TEMP,
REGDATE, SEQ, STATUS, LEV, PATH, PATH2, CYCLE,
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, WRITER, EDITER, EDIT_DATE, REMARK
) AS (
SELECT A.MBOM_HEADER_OBJID, A.OBJID, A.PARENT_OBJID, A.CHILD_OBJID,
A.PART_OBJID, A.PART_NO, A.PART_NAME, A.QTY, A.ITEM_QTY, A.QTY,
A.REGDATE, A.SEQ, A.STATUS, 1,
ARRAY [A.CHILD_OBJID::TEXT],
ARRAY [LPAD(A.SEQ::TEXT, 10, '0')],
FALSE,
A.UNIT, A.SUPPLY_TYPE, A.MAKE_OR_BUY,
A.RAW_MATERIAL_PART_NO, A.RAW_MATERIAL_SPEC, A.RAW_MATERIAL, A.RAW_MATERIAL_SIZE,
A.PROCESSING_VENDOR, A.PROCESSING_DEADLINE, A.GRINDING_DEADLINE,
A.REQUIRED_QTY, A.ORDER_QTY, A.PRODUCTION_QTY, A.STOCK_QTY, A.SHORTAGE_QTY,
A.VENDOR, A.UNIT_PRICE, A.PROCESSING_UNIT_PRICE, A.TOTAL_PRICE, A.CURRENCY,
A.LEAD_TIME, A.MIN_ORDER_QTY, A.WRITER, A.EDITER, A.EDIT_DATE, A.REMARK
FROM MBOM_DETAIL A
WHERE 1=1
AND (A.PARENT_OBJID IS NULL OR A.PARENT_OBJID = '')
AND A.MBOM_HEADER_OBJID = $1
AND A.STATUS = 'ACTIVE'
UNION ALL
SELECT B.MBOM_HEADER_OBJID, B.OBJID, B.PARENT_OBJID, B.CHILD_OBJID,
B.PART_OBJID, B.PART_NO, B.PART_NAME, B.QTY, B.ITEM_QTY, B.QTY,
B.REGDATE, B.SEQ, B.STATUS, LEV + 1,
PATH || B.CHILD_OBJID::TEXT,
PATH2 || LPAD(B.SEQ::TEXT, 10, '0'),
B.PARENT_OBJID = ANY(PATH),
B.UNIT, B.SUPPLY_TYPE, B.MAKE_OR_BUY,
B.RAW_MATERIAL_PART_NO, B.RAW_MATERIAL_SPEC, B.RAW_MATERIAL, B.RAW_MATERIAL_SIZE,
B.PROCESSING_VENDOR, B.PROCESSING_DEADLINE, B.GRINDING_DEADLINE,
B.REQUIRED_QTY, B.ORDER_QTY, B.PRODUCTION_QTY, B.STOCK_QTY, B.SHORTAGE_QTY,
B.VENDOR, B.UNIT_PRICE, B.PROCESSING_UNIT_PRICE, B.TOTAL_PRICE, B.CURRENCY,
B.LEAD_TIME, B.MIN_ORDER_QTY, B.WRITER, B.EDITER, B.EDIT_DATE, B.REMARK
FROM MBOM_DETAIL B
JOIN VIEW_BOM ON B.PARENT_OBJID = VIEW_BOM.CHILD_OBJID
AND VIEW_BOM.MBOM_HEADER_OBJID = B.MBOM_HEADER_OBJID
AND B.STATUS = 'ACTIVE'
)
SELECT
V.MBOM_HEADER_OBJID AS bom_report_objid,
V.OBJID AS objid,
V.PARENT_OBJID AS parent_objid,
V.CHILD_OBJID AS child_objid,
V.PART_OBJID AS part_objid,
V.PART_NO AS part_no,
V.PART_NAME AS part_name,
V.QTY AS qty,
V.ITEM_QTY AS item_qty,
V.QTY_TEMP AS qty_temp,
V.LEV AS level,
(SELECT COUNT(*) FROM MBOM_DETAIL WHERE PARENT_OBJID = V.CHILD_OBJID) AS sub_part_cnt,
V.SEQ AS seq,
V.STATUS AS status,
V.UNIT AS unit,
V.SUPPLY_TYPE AS supply_type,
V.MAKE_OR_BUY AS make_or_buy,
V.RAW_MATERIAL_PART_NO AS raw_material_no,
V.RAW_MATERIAL_SPEC AS raw_material_spec,
V.RAW_MATERIAL AS raw_material,
V.RAW_MATERIAL_SIZE AS size,
V.PROCESSING_VENDOR AS processing_vendor,
(SELECT CLIENT_NM FROM CLIENT_MNG WHERE OBJID::VARCHAR = V.PROCESSING_VENDOR) AS processing_vendor_name,
V.PROCESSING_DEADLINE AS processing_deadline,
V.GRINDING_DEADLINE AS grinding_deadline,
V.REQUIRED_QTY AS required_qty,
V.ORDER_QTY AS order_qty,
V.PRODUCTION_QTY AS production_qty,
V.STOCK_QTY AS stock_qty,
V.SHORTAGE_QTY AS shortage_qty,
V.VENDOR AS vendor,
(SELECT CLIENT_NM FROM CLIENT_MNG WHERE OBJID::VARCHAR = V.VENDOR) AS vendor_name,
V.UNIT_PRICE AS unit_price,
V.PROCESSING_UNIT_PRICE AS processing_unit_price,
V.TOTAL_PRICE AS total_price,
V.CURRENCY AS currency,
V.LEAD_TIME AS lead_time,
V.MIN_ORDER_QTY AS min_order_qty,
V.WRITER AS writer,
TO_CHAR(V.REGDATE, 'YYYY-MM-DD HH24:MI:SS') AS regdate,
V.EDITER AS editer,
CASE WHEN V.EDIT_DATE IS NOT NULL THEN TO_CHAR(V.EDIT_DATE, 'YYYY-MM-DD HH24:MI:SS') END AS edit_date,
V.REMARK AS remark,
CASE WHEN V.LEV = 1 THEN V.OBJID END AS root_objid,
CASE WHEN V.LEV = 1 THEN V.OBJID END AS sub_root_objid,
1 AS leaf,
P.SPEC, P.MATERIAL, P.WEIGHT, P.PART_TYPE, P.REVISION, P.MAKER,
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 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,
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
INNER JOIN PART_MNG P ON P.OBJID = V.PART_OBJID
ORDER BY V.PATH2
`;
const STRUCTURE_ONLY_SQL = `
WITH RECURSIVE VIEW_BOM(
MBOM_HEADER_OBJID, OBJID, PARENT_OBJID, CHILD_OBJID,
PART_OBJID, PART_NO, PART_NAME, QTY, ITEM_QTY, QTY_TEMP,
REGDATE, SEQ, STATUS, LEV, PATH, PATH2, CYCLE,
UNIT, WRITER, RAW_MATERIAL_PART_NO
) AS (
SELECT A.MBOM_HEADER_OBJID, A.OBJID, A.PARENT_OBJID, A.CHILD_OBJID,
A.PART_OBJID, A.PART_NO, A.PART_NAME, A.QTY, A.QTY, A.QTY,
A.REGDATE, A.SEQ, A.STATUS, 1,
ARRAY [A.CHILD_OBJID::TEXT],
ARRAY [LPAD(A.SEQ::TEXT, 10, '0')],
FALSE,
A.UNIT, A.WRITER, A.RAW_MATERIAL_PART_NO
FROM MBOM_DETAIL A
WHERE 1=1
AND (A.PARENT_OBJID IS NULL OR A.PARENT_OBJID = '')
AND A.MBOM_HEADER_OBJID = $1
AND A.STATUS = 'ACTIVE'
UNION ALL
SELECT B.MBOM_HEADER_OBJID, B.OBJID, B.PARENT_OBJID, B.CHILD_OBJID,
B.PART_OBJID, B.PART_NO, B.PART_NAME, B.QTY, B.QTY, B.QTY,
B.REGDATE, B.SEQ, B.STATUS, LEV + 1,
PATH || B.CHILD_OBJID::TEXT,
PATH2 || LPAD(B.SEQ::TEXT, 10, '0'),
B.PARENT_OBJID = ANY(PATH),
B.UNIT, B.WRITER, B.RAW_MATERIAL_PART_NO
FROM MBOM_DETAIL B
JOIN VIEW_BOM ON B.PARENT_OBJID = VIEW_BOM.CHILD_OBJID
AND VIEW_BOM.MBOM_HEADER_OBJID = B.MBOM_HEADER_OBJID
AND B.STATUS = 'ACTIVE'
)
SELECT
V.MBOM_HEADER_OBJID AS bom_report_objid,
V.OBJID AS objid,
V.PARENT_OBJID AS parent_objid,
V.CHILD_OBJID AS child_objid,
V.PART_OBJID AS part_objid,
V.PART_NO AS part_no,
V.PART_NAME AS part_name,
V.QTY AS qty,
V.ITEM_QTY AS item_qty,
V.QTY_TEMP AS qty_temp,
V.LEV AS level,
(SELECT COUNT(*) FROM MBOM_DETAIL WHERE PARENT_OBJID = V.CHILD_OBJID) AS sub_part_cnt,
V.SEQ, V.STATUS, V.UNIT, V.WRITER,
TO_CHAR(V.REGDATE, 'YYYY-MM-DD HH24:MI:SS') AS regdate,
NULL::text AS supply_type, NULL::text AS make_or_buy,
NULL::text AS raw_material_no, NULL::text AS raw_material_spec,
NULL::text AS raw_material, NULL::text AS size,
NULL::text AS processing_vendor, NULL::text AS processing_deadline, NULL::text AS grinding_deadline,
NULL::numeric AS required_qty, NULL::numeric AS order_qty, NULL::numeric AS production_qty,
NULL::numeric AS stock_qty, NULL::numeric AS shortage_qty,
NULL::text AS vendor, NULL::numeric AS unit_price, NULL::numeric AS processing_unit_price,
NULL::numeric AS total_price, NULL::text AS currency,
NULL::int AS lead_time, NULL::numeric AS min_order_qty,
NULL::text AS editer, NULL::text AS edit_date, NULL::text AS remark,
CASE WHEN V.LEV = 1 THEN V.OBJID END AS root_objid,
CASE WHEN V.LEV = 1 THEN V.OBJID END AS sub_root_objid,
1 AS leaf,
P.SPEC, P.MATERIAL, P.WEIGHT, P.PART_TYPE, P.REVISION, P.MAKER,
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 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,
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
INNER JOIN PART_MNG P ON P.OBJID = V.PART_OBJID
ORDER BY V.PATH2
`;
// TEMPLATE 트리는 SAVED 와 동일한 CTE — 운영판도 mbom_detail 한 테이블에서 가져옴.
// 차이: TEMPLATE 은 ORDER_QTY/PRODUCTION_QTY 를 결과에서 표시하지 않음 (재계산 대상).
const TEMPLATE_TREE_SQL = `
WITH RECURSIVE VIEW_BOM(
MBOM_HEADER_OBJID, OBJID, PARENT_OBJID, CHILD_OBJID,
PART_OBJID, PART_NO, PART_NAME, QTY, ITEM_QTY, QTY_TEMP,
REGDATE, SEQ, STATUS, LEV, PATH, PATH2, CYCLE,
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, WRITER, EDITER, EDIT_DATE, REMARK
) AS (
SELECT A.MBOM_HEADER_OBJID, A.OBJID, A.PARENT_OBJID, A.CHILD_OBJID,
A.PART_OBJID, A.PART_NO, A.PART_NAME, A.QTY, A.QTY, A.QTY,
A.REGDATE, A.SEQ, A.STATUS, 1,
ARRAY [A.CHILD_OBJID::TEXT],
ARRAY [LPAD(A.SEQ::TEXT, 10, '0')],
FALSE,
A.UNIT, A.SUPPLY_TYPE, A.MAKE_OR_BUY,
A.RAW_MATERIAL_PART_NO, A.RAW_MATERIAL_SPEC, A.RAW_MATERIAL, A.RAW_MATERIAL_SIZE,
A.PROCESSING_VENDOR, A.PROCESSING_DEADLINE, A.GRINDING_DEADLINE,
A.REQUIRED_QTY, A.WRITER, A.EDITER, A.EDIT_DATE, A.REMARK
FROM MBOM_DETAIL A
WHERE 1=1
AND (A.PARENT_OBJID IS NULL OR A.PARENT_OBJID = '')
AND A.MBOM_HEADER_OBJID = $1
AND A.STATUS = 'ACTIVE'
UNION ALL
SELECT B.MBOM_HEADER_OBJID, B.OBJID, B.PARENT_OBJID, B.CHILD_OBJID,
B.PART_OBJID, B.PART_NO, B.PART_NAME, B.QTY, B.QTY, B.QTY,
B.REGDATE, B.SEQ, B.STATUS, LEV + 1,
PATH || B.CHILD_OBJID::TEXT,
PATH2 || LPAD(B.SEQ::TEXT, 10, '0'),
B.PARENT_OBJID = ANY(PATH),
B.UNIT, B.SUPPLY_TYPE, B.MAKE_OR_BUY,
B.RAW_MATERIAL_PART_NO, B.RAW_MATERIAL_SPEC, B.RAW_MATERIAL, B.RAW_MATERIAL_SIZE,
B.PROCESSING_VENDOR, B.PROCESSING_DEADLINE, B.GRINDING_DEADLINE,
B.REQUIRED_QTY, B.WRITER, B.EDITER, B.EDIT_DATE, B.REMARK
FROM MBOM_DETAIL B
JOIN VIEW_BOM ON B.PARENT_OBJID = VIEW_BOM.CHILD_OBJID
AND VIEW_BOM.MBOM_HEADER_OBJID = B.MBOM_HEADER_OBJID
AND B.STATUS = 'ACTIVE'
)
SELECT
V.MBOM_HEADER_OBJID AS bom_report_objid,
V.OBJID, V.PARENT_OBJID AS parent_objid, V.CHILD_OBJID AS child_objid,
V.PART_OBJID AS part_objid, V.PART_NO AS part_no, V.PART_NAME AS part_name,
V.QTY AS qty, V.ITEM_QTY AS item_qty, V.QTY_TEMP AS qty_temp,
V.LEV AS level,
(SELECT COUNT(*) FROM MBOM_DETAIL WHERE PARENT_OBJID = V.CHILD_OBJID) AS sub_part_cnt,
V.SEQ AS seq, V.STATUS AS status,
V.UNIT, V.SUPPLY_TYPE AS supply_type, V.MAKE_OR_BUY AS make_or_buy,
V.RAW_MATERIAL_PART_NO AS raw_material_no,
V.RAW_MATERIAL_SPEC AS raw_material_spec,
V.RAW_MATERIAL AS raw_material,
V.RAW_MATERIAL_SIZE AS size,
V.PROCESSING_VENDOR AS processing_vendor,
V.PROCESSING_DEADLINE AS processing_deadline,
V.GRINDING_DEADLINE AS grinding_deadline,
V.REQUIRED_QTY AS required_qty,
NULL::numeric AS order_qty, NULL::numeric AS production_qty,
V.WRITER AS writer,
TO_CHAR(V.REGDATE, 'YYYY-MM-DD HH24:MI:SS') AS regdate,
V.EDITER AS editer,
CASE WHEN V.EDIT_DATE IS NOT NULL THEN TO_CHAR(V.EDIT_DATE, 'YYYY-MM-DD HH24:MI:SS') END AS edit_date,
V.REMARK AS remark,
CASE WHEN V.LEV = 1 THEN V.OBJID END AS root_objid,
CASE WHEN V.LEV = 1 THEN V.OBJID END AS sub_root_objid,
1 AS leaf,
P.SPEC, P.MATERIAL, P.WEIGHT, P.PART_TYPE, P.REVISION, P.MAKER,
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 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,
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
LEFT JOIN PART_MNG P ON V.PART_OBJID = P.OBJID
ORDER BY V.PATH2
`;
// 매퍼 partMng.getBOMTreeList search_type='working' 1:1.
// E-BOM 호환 — part_no 컬럼명 충돌 회피 위해 운영판처럼 V.PART_NO 는 PART_OBJID 로 alias.
const EBOM_WORKING_TREE_SQL = `
WITH RECURSIVE VIEW_BOM(
BOM_REPORT_OBJID, OBJID, PARENT_OBJID, CHILD_OBJID,
PARENT_PART_NO, PART_NO, LAST_PART_OBJID,
QTY, ITEM_QTY, QTY_TEMP, REGDATE, SEQ, STATUS,
PART_MNG_NO, PARENT_PART_MNG_NO, LEV, PATH, PATH2, CYCLE
) AS (
SELECT A.BOM_REPORT_OBJID, A.OBJID, A.PARENT_OBJID, A.CHILD_OBJID,
A.PARENT_PART_NO, A.PART_NO, A.LAST_PART_OBJID,
A.QTY, A.ITEM_QTY, A.QTY_TEMP, A.REGDATE, A.SEQ, A.STATUS,
(SELECT PART_NO FROM PART_MNG P WHERE P.OBJID::varchar = A.PART_NO) AS PART_MNG_NO,
(SELECT PART_NO FROM PART_MNG P WHERE P.OBJID::varchar = A.PARENT_PART_NO) AS PARENT_PART_MNG_NO,
1,
ARRAY [A.CHILD_OBJID::TEXT],
ARRAY [LPAD(A.SEQ::TEXT, 10, '0')],
FALSE
FROM BOM_PART_QTY A
WHERE 1=1
AND (A.PARENT_OBJID IS NULL OR A.PARENT_OBJID = '')
AND A.BOM_REPORT_OBJID = $1
AND (A.STATUS NOT IN ('deleting', 'deleted') OR A.STATUS IS NULL)
UNION ALL
SELECT B.BOM_REPORT_OBJID, B.OBJID, B.PARENT_OBJID, B.CHILD_OBJID,
B.PARENT_PART_NO, B.PART_NO, B.LAST_PART_OBJID,
B.QTY, B.ITEM_QTY, B.QTY_TEMP, B.REGDATE, B.SEQ, B.STATUS,
(SELECT PART_NO FROM PART_MNG P WHERE P.OBJID::varchar = B.PART_NO) AS PART_MNG_NO,
(SELECT PART_NO FROM PART_MNG P WHERE P.OBJID::varchar = B.PARENT_PART_NO) AS PARENT_PART_MNG_NO,
LEV + 1,
PATH || B.CHILD_OBJID::TEXT,
PATH2 || LPAD(B.SEQ::TEXT, 10, '0'),
B.PARENT_OBJID = ANY(PATH)
FROM BOM_PART_QTY B
JOIN VIEW_BOM ON B.PARENT_OBJID = VIEW_BOM.CHILD_OBJID
AND VIEW_BOM.BOM_REPORT_OBJID = B.BOM_REPORT_OBJID
AND (B.STATUS NOT IN ('deleting', 'deleted') OR B.STATUS IS NULL)
)
SELECT
V.BOM_REPORT_OBJID AS bom_report_objid,
V.OBJID AS objid,
V.PARENT_OBJID AS parent_objid,
V.CHILD_OBJID AS child_objid,
V.PARENT_PART_NO AS parent_part_no,
V.PART_NO AS part_objid,
V.LAST_PART_OBJID AS bom_last_part_objid,
V.QTY AS qty,
V.ITEM_QTY AS item_qty,
(CASE WHEN V.STATUS = 'deploy' THEN V.QTY
WHEN V.STATUS = 'beforeEdit' THEN V.QTY
WHEN V.STATUS != 'editing' AND (V.QTY_TEMP IS NULL OR V.QTY_TEMP = '') THEN V.QTY
ELSE COALESCE(V.QTY_TEMP, V.QTY) END) AS qty_temp,
V.LEV AS level,
(SELECT COUNT(*) FROM BOM_PART_QTY WHERE PARENT_OBJID = V.CHILD_OBJID) AS sub_part_cnt,
V.SEQ AS seq, V.STATUS AS status,
P.OBJID AS last_part_objid,
P.PART_NAME AS part_name,
P.PART_NO AS part_no,
(SELECT CODE_NAME FROM COMM_CODE CC WHERE CODE_ID = P.UNIT) AS unit_title,
P.SPEC, P.MATERIAL, P.WEIGHT, P.REVISION, P.MAKER,
(SELECT CODE_NAME FROM COMM_CODE CC WHERE CODE_ID = P.PART_TYPE) AS part_type_title,
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,
-- 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,
NULL::text AS raw_material, NULL::text AS size,
NULL::text AS processing_vendor, NULL::text AS processing_vendor_name,
NULL::text AS processing_deadline, NULL::text AS grinding_deadline,
NULL::numeric AS required_qty, NULL::numeric AS order_qty, NULL::numeric AS production_qty,
NULL::numeric AS stock_qty, NULL::numeric AS shortage_qty,
NULL::text AS vendor, NULL::text AS vendor_name,
NULL::numeric AS unit_price, NULL::numeric AS processing_unit_price,
NULL::numeric AS total_price, NULL::text AS currency,
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)
ORDER BY V.PATH2
`;
+25
View File
@@ -0,0 +1,25 @@
-- ============================================================
-- 메뉴 menu_desc 보강 (PageHeader 자동 매칭용)
-- 2026-05-13
-- 사용자 화면에 표시되는 텍스트 — 개발 메모(wace ... 1:1)는 포함하지 않는다.
-- ============================================================
-- 영업관리
UPDATE menu_info SET menu_desc='고객사 견적 작성 · 발송 · 승인' WHERE objid=100002;
UPDATE menu_info SET menu_desc='고객사 주문서 등록 · 항목 관리' WHERE objid=100003;
UPDATE menu_info SET menu_desc='출하 · 판매 처리 및 시리얼 · 송장 관리' WHERE objid=100004;
UPDATE menu_info SET menu_desc='매출 등록 · 세금계산서 · 수금 관리' WHERE objid=100005;
-- 프로젝트관리
UPDATE menu_info SET menu_desc='제품구분 별 WBS 템플릿 + 표준 작업' WHERE objid=100007;
UPDATE menu_info SET menu_desc='프로젝트 진행 현황' WHERE objid=100008;
-- 개발관리
UPDATE menu_info SET menu_desc='PART 마스터 등록 · 수정' WHERE objid=100010;
UPDATE menu_info SET menu_desc='PART 마스터 조회' WHERE objid=100011;
UPDATE menu_info SET menu_desc='E-BOM 등록 · CSV Import' WHERE objid=100012;
UPDATE menu_info SET menu_desc='E-BOM 트리 정/역전개 조회 + Excel 다운로드' WHERE objid=100013;
UPDATE menu_info SET menu_desc='설계변경 이력 조회' WHERE objid=100014;
-- 생산관리/구매관리 공용
UPDATE menu_info SET menu_desc='생산용 BOM 트리 + read-only 조회' WHERE objid IN (100016, 100032);
@@ -0,0 +1,63 @@
-- ============================================================
-- M-BOM 운영 sample 데이터 → RPS 이관
-- 운영: 211.115.91.141:11133/waceplm (mbom_header 3건, mbom_detail 95건)
-- 대상: 211.115.91.141:11134/vexplor_rps
--
-- 함정:
-- 1) mbom_header.source_ebom_objid → RPS part_bom_report 에 없는 OBJID 는 NULL 처리
-- 2) mbom_detail.part_objid varchar → RPS bigint (FK part_mng.objid bigint 호환)
-- 3) RPS part_mng 에 없는 part_objid 도 NULL 처리
--
-- 실행 전: /tmp/mbom_header.csv, /tmp/mbom_detail.csv 준비 (운영DB \copy TO)
-- ============================================================
-- ── mbom_header ───────────────────────────────────────────────
DROP TABLE IF EXISTS mbom_header_stage;
CREATE TABLE mbom_header_stage (LIKE mbom_header INCLUDING DEFAULTS);
ALTER TABLE mbom_header_stage DROP CONSTRAINT IF EXISTS mbom_header_pkey;
ALTER TABLE mbom_header_stage DROP CONSTRAINT IF EXISTS mbom_header_mbom_no_key;
ALTER TABLE mbom_header_stage DROP CONSTRAINT IF EXISTS fk_mbom_source_ebom;
ALTER TABLE mbom_header_stage DROP CONSTRAINT IF EXISTS fk_mbom_source_mbom;
\copy mbom_header_stage FROM '/tmp/mbom_header.csv' WITH CSV HEADER
-- RPS part_bom_report 에 없는 source_ebom_objid 는 NULL
UPDATE mbom_header_stage SET source_ebom_objid = NULL
WHERE source_ebom_objid IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM part_bom_report WHERE objid = mbom_header_stage.source_ebom_objid);
-- self FK source_mbom_objid: 자기 자신 OBJID 셋에 있으면 OK, 아니면 NULL
UPDATE mbom_header_stage SET source_mbom_objid = NULL
WHERE source_mbom_objid IS NOT NULL
AND source_mbom_objid NOT IN (SELECT objid FROM mbom_header_stage);
INSERT INTO mbom_header SELECT * FROM mbom_header_stage;
DROP TABLE mbom_header_stage;
-- ── mbom_detail ────────────────────────────────────────────────
DROP TABLE IF EXISTS mbom_detail_stage;
CREATE TABLE mbom_detail_stage (LIKE mbom_detail INCLUDING DEFAULTS);
ALTER TABLE mbom_detail_stage DROP CONSTRAINT IF EXISTS mbom_detail_pkey;
ALTER TABLE mbom_detail_stage DROP CONSTRAINT IF EXISTS fk_mbom_detail_header;
ALTER TABLE mbom_detail_stage DROP CONSTRAINT IF EXISTS fk_mbom_detail_part;
ALTER TABLE mbom_detail_stage ALTER COLUMN part_objid TYPE varchar(64) USING part_objid::text;
\copy mbom_detail_stage FROM '/tmp/mbom_detail.csv' WITH CSV HEADER
-- RPS part_mng 에 없는 part_objid 는 NULL (bigint 형변환 안전)
UPDATE mbom_detail_stage SET part_objid = NULL
WHERE part_objid IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM part_mng WHERE objid::text = mbom_detail_stage.part_objid);
-- RPS mbom_header 에 없는 mbom_header_objid 행은 import 제외
DELETE FROM mbom_detail_stage
WHERE NOT EXISTS (SELECT 1 FROM mbom_header WHERE objid = mbom_detail_stage.mbom_header_objid);
-- bigint 형변환 후 본 테이블 INSERT
ALTER TABLE mbom_detail_stage ALTER COLUMN part_objid TYPE bigint USING part_objid::bigint;
INSERT INTO mbom_detail SELECT * FROM mbom_detail_stage;
DROP TABLE mbom_detail_stage;
-- ── 결과 ──────────────────────────────────────────────────────
SELECT 'mbom_header' AS t, COUNT(*) FROM mbom_header
UNION ALL SELECT 'mbom_detail', COUNT(*) FROM mbom_detail;
@@ -0,0 +1,66 @@
-- ============================================================
-- M-BOM 의존 테이블 운영 → RPS 데이터 이관 (PR-A0)
-- 운영: 211.115.91.141:11133/waceplm
-- 대상: 211.115.91.141:11134/vexplor_rps
--
-- 함정:
-- 1) mbom_history.mbom_header_objid FK → 매칭 없는 행은 import 제외 (CASCADE 와 일치)
-- 2) sales_request_master.mbom_header_objid 는 FK 없음 (NULL 허용)
-- 3) client_mng 는 운영 8,946건 전량 이관 (그리드 CUSTOMER_NAME 매칭용)
--
-- 실행 전 export (운영에서):
-- PGPASSWORD='waceplm0909!!' psql -h 211.115.91.141 -p 11133 -U postgres -d waceplm \
-- -c "\copy (SELECT * FROM mbom_history) TO '/tmp/mbom_history.csv' WITH CSV HEADER"
-- PGPASSWORD='waceplm0909!!' psql -h 211.115.91.141 -p 11133 -U postgres -d waceplm \
-- -c "\copy (SELECT * FROM sales_request_master) TO '/tmp/sales_request_master.csv' WITH CSV HEADER"
-- PGPASSWORD='waceplm0909!!' psql -h 211.115.91.141 -p 11133 -U postgres -d waceplm \
-- -c "\copy (SELECT * FROM client_mng) TO '/tmp/client_mng.csv' WITH CSV HEADER"
-- ============================================================
-- ── 1. mbom_history ────────────────────────────────────────────
DROP TABLE IF EXISTS mbom_history_stage;
CREATE TABLE mbom_history_stage (LIKE mbom_history INCLUDING DEFAULTS);
ALTER TABLE mbom_history_stage DROP CONSTRAINT IF EXISTS mbom_history_pkey;
ALTER TABLE mbom_history_stage DROP CONSTRAINT IF EXISTS fk_mbom_history_header;
\copy mbom_history_stage FROM '/tmp/mbom_history.csv' WITH CSV HEADER
-- RPS mbom_header 에 없는 mbom_header_objid 행은 import 제외 (FK CASCADE 호환)
DELETE FROM mbom_history_stage
WHERE NOT EXISTS (SELECT 1 FROM mbom_header WHERE objid = mbom_history_stage.mbom_header_objid);
INSERT INTO mbom_history SELECT * FROM mbom_history_stage;
DROP TABLE mbom_history_stage;
-- ── 2. sales_request_master ────────────────────────────────────
DROP TABLE IF EXISTS sales_request_master_stage;
CREATE TABLE sales_request_master_stage (LIKE sales_request_master INCLUDING DEFAULTS);
ALTER TABLE sales_request_master_stage DROP CONSTRAINT IF EXISTS sales_request_master_pkey;
\copy sales_request_master_stage FROM '/tmp/sales_request_master.csv' WITH CSV HEADER
-- mbom_header_objid 매칭 안 되는 경우 NULL (FK 는 없지만 그리드 매칭 시 NULL fallback)
UPDATE sales_request_master_stage SET mbom_header_objid = NULL
WHERE mbom_header_objid IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM mbom_header WHERE objid = sales_request_master_stage.mbom_header_objid);
INSERT INTO sales_request_master SELECT * FROM sales_request_master_stage;
DROP TABLE sales_request_master_stage;
-- ── 3. client_mng ──────────────────────────────────────────────
DROP TABLE IF EXISTS client_mng_stage;
CREATE TABLE client_mng_stage (LIKE client_mng INCLUDING DEFAULTS);
ALTER TABLE client_mng_stage DROP CONSTRAINT IF EXISTS uk_client_mng_client_cd;
\copy client_mng_stage FROM '/tmp/client_mng.csv' WITH CSV HEADER
INSERT INTO client_mng SELECT * FROM client_mng_stage;
DROP TABLE client_mng_stage;
-- ── 결과 ──────────────────────────────────────────────────────
SELECT 'mbom_history' AS t, COUNT(*) FROM mbom_history
UNION ALL SELECT 'sales_request_master', COUNT(*) FROM sales_request_master
UNION ALL SELECT 'client_mng', COUNT(*) FROM client_mng;
@@ -0,0 +1,26 @@
-- ============================================================
-- M-BOM 메뉴 양쪽 노출 (PR-A1)
-- 운영판 wace 분류는 "생산관리_M-BOM관리" 단독이지만,
-- 사용자 요청 (2026-05-13): 구매관리 메뉴 트리에도 동일 M-BOM 메뉴 노출.
--
-- menu_info 100016 (/COMPANY_16/purchase/mbom) — 구매관리 하위
-- menu_info 100032 (/COMPANY_16/production/mbom) — 생산관리 하위
-- 두 URL 은 동일한 page.tsx 화면 (production/mbom 의 re-export).
--
-- 본 스크립트는 menu_info 두 항목 모두 active 보장만 한다.
--
-- 실행:
-- PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d vexplor_rps \
-- -f 03_mbom_menu_dedup.sql
-- ============================================================
UPDATE menu_info
SET status = 'active'
WHERE objid IN (100016, 100032)
AND status <> 'active';
-- 확인
SELECT objid, parent_obj_id, menu_name_kor, menu_url, status
FROM menu_info
WHERE objid IN (100016, 100032)
ORDER BY objid;
@@ -0,0 +1,25 @@
-- ============================================================
-- 운영판 wace 211.115.91.141:11133.production_plan → RPS 11134.production_plan
-- 2건 1:1 이관 (PR-A2 단건 상세 TOTAL_PROD_QTY 검증용)
-- ============================================================
-- 운영판 sample 1: W/M ASSY (project_objid=-1752090174, total_prod_qty=4)
-- 운영판 sample 2: O-RING (project_objid=1157387151, total_prod_qty=5)
INSERT INTO production_plan
(objid, project_objid, product_code, category_code, production_type, customer_objid,
req_del_date, part_no, part_name, serial_no,
order_qty, extra_prod_qty, total_prod_qty,
customer_request, status, regdate, writer, moddate, modifier)
VALUES
('1058002488', '-1752090174', '0001807', '0001792', '0001833', '0000007555',
'2026-04-06', '11030-0059', 'W/M ASSY (RWMR1070-NO07 LH)', NULL,
'2', '2', '4',
'납기 준수 必', 'active', '2026-03-19 06:36:13.087597', 'jsm1014', NULL, NULL),
('1439133152', '1157387151', '0001539', '0001791', '0001833', '0000010054',
'2026-03-24', '000AN033000', 'O-RING',
'item-001001, item-001002, item-001003, item-001004, item-001005, item-001006, item-001007, item-001008, item-001009, item-001010',
'1', '4', '5',
NULL, 'active', '2026-03-24 01:52:10.332136', 'plm_admin',
'2026-03-24 01:53:47.912482', 'plm_admin')
ON CONFLICT (objid) DO NOTHING;
@@ -0,0 +1,8 @@
-- ============================================================
-- M-BOM 관리 메뉴 menu_desc 보강 (PageHeader 자동 매칭용)
-- 2026-05-13
-- ============================================================
UPDATE menu_info
SET menu_desc = '생산용 BOM 트리 + read-only 조회 (운영판 mBomMgmtList 1:1)'
WHERE objid IN (100016, 100032);
@@ -0,0 +1,125 @@
-- ============================================================
-- M-BOM (Manufacturing BOM) — 구매관리/생산관리 공유 마스터
-- 원본: 운영DB 211.115.91.141:11133/waceplm
-- 추출일: 2026-05-13
-- 적용대상: vexplor_rps (11134)
--
-- 의존성:
-- mbom_header.source_ebom_objid → part_bom_report.objid (개발관리 E-BOM, varchar)
-- mbom_detail.mbom_header_objid → mbom_header.objid (varchar, CASCADE)
-- mbom_detail.part_objid → part_mng.objid (개발관리 PART, **RPS 에서 bigint**, SET NULL)
--
-- 운영DB ↔ RPS 타입 차이 (feedback_createobjid_pattern.md):
-- 운영DB: part_mng.objid varchar(64) → mbom_detail.part_objid varchar(64)
-- RPS: part_mng.objid bigint → mbom_detail.part_objid bigint (FK 호환)
-- 운영 데이터 임포트 시 part_objid::bigint cast 필요.
--
-- 비즈니스 흐름:
-- M-BOM 생성 → 구매리스트(sales_request_master + mbom_detail) →
-- 견적요청서 / 품의서(sales_request_detail) →
-- 발주서(purchase_order_master + purchase_order_part) →
-- 입고(arrival_plan + inventory_mgmt + inventory_mgmt_in)
-- ============================================================
-- ── 1. mbom_header (M-BOM 마스터 헤더) ────────────────────────
CREATE TABLE IF NOT EXISTS mbom_header (
objid varchar(64) NOT NULL,
mbom_no varchar(100) NOT NULL,
source_bom_type varchar(20),
source_ebom_objid varchar(64),
source_mbom_objid varchar(64),
project_objid varchar(64),
contract_objid varchar(64),
part_no varchar(100),
part_name varchar(200),
revision varchar(50),
status varchar(20) DEFAULT 'Y',
mbom_status varchar(20) DEFAULT 'DRAFT',
production_type varchar(50),
total_cost numeric(15,2),
writer varchar(50),
regdate timestamp DEFAULT CURRENT_TIMESTAMP,
editer varchar(50),
edit_date timestamp,
approver varchar(50),
approve_date timestamp,
remark text,
CONSTRAINT mbom_header_pkey PRIMARY KEY (objid),
CONSTRAINT mbom_header_mbom_no_key UNIQUE (mbom_no),
CONSTRAINT fk_mbom_source_ebom FOREIGN KEY (source_ebom_objid)
REFERENCES part_bom_report(objid) ON DELETE SET NULL,
CONSTRAINT fk_mbom_source_mbom FOREIGN KEY (source_mbom_objid)
REFERENCES mbom_header(objid) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_mbom_header_mbom_no ON mbom_header (mbom_no);
CREATE INDEX IF NOT EXISTS idx_mbom_header_project_objid ON mbom_header (project_objid);
CREATE INDEX IF NOT EXISTS idx_mbom_header_source_ebom ON mbom_header (source_ebom_objid);
CREATE INDEX IF NOT EXISTS idx_mbom_header_source_mbom ON mbom_header (source_mbom_objid);
CREATE INDEX IF NOT EXISTS idx_mbom_header_source_type ON mbom_header (source_bom_type);
CREATE INDEX IF NOT EXISTS idx_mbom_header_status ON mbom_header (status);
-- ── 2. mbom_detail (M-BOM 상세 트리: 부모-자식 + qty/단가/공급처) ──
CREATE TABLE IF NOT EXISTS mbom_detail (
objid varchar(64) NOT NULL,
mbom_header_objid varchar(64) NOT NULL,
parent_objid varchar(64),
child_objid varchar(64),
seq integer,
level integer,
part_objid bigint,
part_no varchar(100),
part_name varchar(200),
qty numeric(15,4),
unit varchar(20),
supply_type varchar(50),
make_or_buy varchar(20),
raw_material_part_no varchar(100),
raw_material_spec varchar(200),
raw_material varchar(100),
raw_material_size varchar(100),
processing_vendor varchar(100),
processing_deadline varchar(10),
grinding_deadline varchar(10),
required_qty numeric(15,4),
order_qty numeric(15,4),
production_qty numeric(15,4),
stock_qty numeric(15,4),
shortage_qty numeric(15,4),
vendor varchar(100),
unit_price numeric(15,2),
total_price numeric(15,2),
currency varchar(10) DEFAULT 'KRW',
lead_time integer,
min_order_qty numeric(15,4),
status varchar(20) DEFAULT 'ACTIVE',
regdate timestamp DEFAULT CURRENT_TIMESTAMP,
writer varchar(50),
edit_date timestamp,
editer varchar(50),
remark text,
use_yn varchar(1) DEFAULT 'Y',
net_qty numeric DEFAULT 0,
po_qty numeric DEFAULT 0,
proposal_date date,
processing_unit_price numeric(15,2),
processing_total_price numeric(15,2),
grand_total_price numeric(15,2),
processing_proposal_date date,
delivery_request_date varchar(10),
item_qty numeric(15,4),
CONSTRAINT mbom_detail_pkey PRIMARY KEY (objid),
CONSTRAINT fk_mbom_detail_header FOREIGN KEY (mbom_header_objid)
REFERENCES mbom_header(objid) ON DELETE CASCADE,
CONSTRAINT fk_mbom_detail_part FOREIGN KEY (part_objid)
REFERENCES part_mng(objid) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_mbom_detail_header_objid ON mbom_detail (mbom_header_objid);
CREATE INDEX IF NOT EXISTS idx_mbom_detail_parent_objid ON mbom_detail (parent_objid);
CREATE INDEX IF NOT EXISTS idx_mbom_detail_part_no ON mbom_detail (part_no);
CREATE INDEX IF NOT EXISTS idx_mbom_detail_part_objid ON mbom_detail (part_objid);
CREATE INDEX IF NOT EXISTS idx_mbom_detail_proposal_date ON mbom_detail (proposal_date) WHERE proposal_date IS NULL;
COMMENT ON TABLE mbom_header IS 'M-BOM 마스터 헤더 (제조용 BOM, E-BOM 파생/템플릿/복사)';
COMMENT ON TABLE mbom_detail IS 'M-BOM 상세 트리 (부모-자식 구조 + 수량/단가/공급처/원자재)';
@@ -0,0 +1,239 @@
-- ============================================================
-- M-BOM 관리 화면 의존 테이블 신설 (PR-A0)
-- 운영: 211.115.91.141:11133/waceplm
-- 대상: 211.115.91.141:11134/vexplor_rps
--
-- 1) mbom_history — M-BOM 변경 이력 (8 cols, FK→mbom_header)
-- 2) sales_request_master — 구매요청서 마스터 (27 cols, FK→mbom_header via mbom_header_objid)
-- 3) client_mng — 거래처 마스터 (117 cols) — 그리드 CUSTOMER_NAME 매칭용
--
-- 생성 명령:
-- PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d vexplor_rps -f 401_mbom_dependencies.sql
-- ============================================================
SET statement_timeout = 0;
SET client_encoding = 'UTF8';
-- ── 1. mbom_history ────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.mbom_history (
objid character varying(64) NOT NULL,
mbom_header_objid character varying(64) NOT NULL,
change_type character varying(50),
change_description text,
before_data jsonb,
after_data jsonb,
change_user character varying(50),
change_date timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT mbom_history_pkey PRIMARY KEY (objid),
CONSTRAINT fk_mbom_history_header FOREIGN KEY (mbom_header_objid)
REFERENCES public.mbom_header(objid) ON DELETE CASCADE
);
COMMENT ON TABLE public.mbom_history IS 'M-BOM 변경 이력 테이블';
CREATE INDEX IF NOT EXISTS idx_mbom_history_header_objid ON public.mbom_history (mbom_header_objid);
CREATE INDEX IF NOT EXISTS idx_mbom_history_change_date ON public.mbom_history (change_date);
-- ── 2. sales_request_master ────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.sales_request_master (
objid character varying NOT NULL,
request_mng_no character varying,
request_cd character varying,
project_no character varying,
release_date character varying,
request_reasons character varying,
request_user_id character varying,
delivery_request_date character varying,
unit_name character varying,
status character varying,
receipt_user_id character varying,
receipt_date character varying,
writer character varying,
regdate timestamp without time zone,
remark character varying,
purchase_type character varying(50),
order_type character varying(50),
product_name character varying(50),
area_cd character varying(50),
customer_objid character varying(50),
paid_type character varying(20),
mbom_header_objid character varying(50),
doc_type character varying(50),
recipient_ref character varying(500),
executor character varying(100),
execution_date date,
title character varying(500),
CONSTRAINT sales_request_master_pkey PRIMARY KEY (objid)
);
COMMENT ON TABLE public.sales_request_master IS '구매요청서 마스터';
COMMENT ON COLUMN public.sales_request_master.request_mng_no IS '요청번호';
COMMENT ON COLUMN public.sales_request_master.request_cd IS '구분';
COMMENT ON COLUMN public.sales_request_master.project_no IS '프로젝트번호';
COMMENT ON COLUMN public.sales_request_master.release_date IS '출고일';
COMMENT ON COLUMN public.sales_request_master.request_reasons IS '요청사유';
COMMENT ON COLUMN public.sales_request_master.request_user_id IS '요청인';
COMMENT ON COLUMN public.sales_request_master.delivery_request_date IS '입고요청일';
COMMENT ON COLUMN public.sales_request_master.unit_name IS '유닛명';
COMMENT ON COLUMN public.sales_request_master.status IS '상태';
COMMENT ON COLUMN public.sales_request_master.receipt_user_id IS '접수자';
COMMENT ON COLUMN public.sales_request_master.receipt_date IS '접수일';
COMMENT ON COLUMN public.sales_request_master.writer IS '작성자';
COMMENT ON COLUMN public.sales_request_master.regdate IS '작성일';
COMMENT ON COLUMN public.sales_request_master.remark IS '비고';
COMMENT ON COLUMN public.sales_request_master.mbom_header_objid IS 'M-BOM 헤더 OBJID (NULL: 수동작성, 값 있음: M-BOM 자동생성)';
COMMENT ON COLUMN public.sales_request_master.doc_type IS '문서유형 (PURCHASE_REQUEST: 구매요청서, PROPOSAL: 품의서)';
COMMENT ON COLUMN public.sales_request_master.recipient_ref IS '수신및참조';
COMMENT ON COLUMN public.sales_request_master.executor IS '시행자';
COMMENT ON COLUMN public.sales_request_master.execution_date IS '시행일자';
COMMENT ON COLUMN public.sales_request_master.title IS '제목';
CREATE INDEX IF NOT EXISTS idx_sales_request_master_doc_type ON public.sales_request_master (doc_type);
CREATE INDEX IF NOT EXISTS idx_sales_request_master_mbom_header ON public.sales_request_master (mbom_header_objid);
-- ── 3. client_mng ──────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.client_mng (
comp_code character varying(50),
client_cd character varying(50),
client_nm character varying(200),
tr_nmk character varying(200),
client_nmk character varying(200),
attr_nmk character varying(100),
client_type character varying(50),
bus_reg_no character varying(50),
resident_no character varying(50),
ceo_nm character varying(100),
ceo_nmk character varying(100),
bus_type character varying(100),
bus_item character varying(100),
post_no character varying(20),
addr1 character varying(300),
addr2 character varying(300),
addr_fg character varying(50),
tel_no character varying(50),
fax_no character varying(50),
homepage character varying(200),
email character varying(100),
liq_rs character varying(100),
tr_fg character varying(100),
country_nm character varying(100),
class_cd character varying(50),
class_nm character varying(100),
grade_cd character varying(50),
grade_nm character varying(100),
collect_client_cd character varying(50),
collect_client_nm character varying(200),
region_cd character varying(50),
region_nm character varying(100),
trade_start_dt character varying(20),
trade_end_dt character varying(20),
use_yn character varying(10),
contract_start_dt character varying(20),
contract_end_dt character varying(20),
trade_type character varying(50),
discount_rate character varying(20),
contract_amt character varying(20),
monthly_fee character varying(20),
payment_term character varying(200),
rcp_tp character varying(50),
credit_limit character varying(20),
limit_return_day character varying(20),
pur_bank_cd character varying(50),
pur_bank_nm character varying(200),
pur_branch_nm character varying(200),
pur_account_no character varying(100),
pur_account_holder character varying(100),
pur_pay_plan character varying(200),
pur_slip_type character varying(50),
pur_tax_type character varying(50),
sale_bank_cd character varying(50),
sale_bank_nm character varying(200),
sale_branch_nm character varying(200),
sale_account_no character varying(100),
sale_collect_plan character varying(200),
sale_slip_type character varying(50),
sale_tax_type character varying(50),
vendor_dept_nm character varying(100),
vendor_position character varying(50),
vendor_duty character varying(100),
vendor_manager_nm character varying(100),
vendor_tel character varying(50),
vendor_ext character varying(20),
vendor_mobile character varying(50),
vendor_email character varying(100),
mgr_dept_cd character varying(50),
mgr_dept_nm character varying(100),
mgr_position character varying(50),
mgr_duty character varying(100),
mgr_emp_cd character varying(50),
mgr_emp_nm character varying(100),
mgr_tel character varying(50),
mgr_ext character varying(20),
mgr_mobile character varying(50),
mgr_email character varying(100),
mgr_remark text,
rec_remark text,
rec_post_no character varying(20),
rec_addr1 character varying(300),
rec_addr2 character varying(300),
rec_addr_fg character varying(50),
rec_tel character varying(50),
rec_fax character varying(50),
project_cd character varying(50),
project_nm character varying(200),
pjt_nmk character varying(200),
ext_data_cd character varying(100),
e_tax_yn character varying(10),
unit_report_client character varying(200),
sub_bus_no character varying(50),
procurement_yn character varying(10),
user_def_dc1 character varying(200),
user_def_dc2 character varying(200),
use_fg character varying(50),
use_nm character varying(100),
bizcon_fg character varying(50),
bizcon_nm character varying(100),
ship_tp character varying(50),
ship_nm character varying(100),
plan_day_type character varying(50),
plan_day character varying(20),
purpose_type character varying(50),
for_yn character varying(10),
check_data character varying(200),
check_state character varying(100),
check_order character varying(100),
fixed_order character varying(100),
insert_id character varying(50),
insert_ip character varying(50),
insert_dt timestamp without time zone DEFAULT now(),
modify_id character varying(50),
modify_ip character varying(50),
modify_dt timestamp without time zone,
objid character varying,
CONSTRAINT uk_client_mng_client_cd UNIQUE (client_cd)
);
CREATE INDEX IF NOT EXISTS idx_client_mng_client_cd ON public.client_mng (client_cd);
CREATE INDEX IF NOT EXISTS idx_client_mng_client_nm ON public.client_mng (client_nm);
CREATE INDEX IF NOT EXISTS idx_client_mng_use_yn ON public.client_mng (use_yn);
-- ── 4. user_name() 함수 ─────────────────────────────────────────
-- 그리드 WRITER_NAME / MBOM_EDITOR 표시용 (운영 1:1).
-- 매퍼 productionplanning.mBomMgmtGridList: user_name(MH.WRITER) / user_name(COALESCE(MH.EDITER, MH.WRITER))
CREATE OR REPLACE FUNCTION public.user_name(v_user_id character varying)
RETURNS character varying
LANGUAGE plpgsql
AS $function$
DECLARE
v_user_name varchar;
BEGIN
SELECT user_name INTO v_user_name
FROM user_info
WHERE user_id = v_user_id;
RETURN v_user_name;
END;
$function$;
@@ -0,0 +1,40 @@
-- ============================================================
-- 운영판 wace 211.115.91.141:11133 production_plan 1:1 추출
-- 추출일: 2026-05-13
-- 추출 명령: psql ... -c "\d production_plan"
--
-- 용도: 생산관리>M-BOM 관리 PR-A2 의 getProjectMgmtDetail.TOTAL_PROD_QTY 서브쿼리 의존
-- COALESCE((SELECT NULLIF(PP.TOTAL_PROD_QTY,'')::numeric
-- FROM PRODUCTION_PLAN PP
-- WHERE PP.PROJECT_OBJID = PM.OBJID
-- AND UPPER(PP.STATUS) = 'ACTIVE' LIMIT 1),
-- COALESCE(NULLIF(PM.QUANTITY,'')::numeric, 0)) AS TOTAL_PROD_QTY
-- ============================================================
CREATE TABLE IF NOT EXISTS public.production_plan (
objid character varying(50) NOT NULL,
project_objid character varying(50),
product_code character varying(50),
category_code character varying(50),
production_type character varying(50),
customer_objid character varying(50),
req_del_date character varying(20),
part_no character varying(100),
part_name character varying(200),
serial_no character varying(500),
order_qty character varying(20) DEFAULT '0',
extra_prod_qty character varying(20) DEFAULT '0',
total_prod_qty character varying(20) DEFAULT '0',
customer_request character varying(500),
status character varying(20) DEFAULT 'active',
regdate timestamp without time zone DEFAULT now(),
writer character varying(50),
moddate timestamp without time zone,
modifier character varying(50),
CONSTRAINT production_plan_pkey PRIMARY KEY (objid)
);
CREATE INDEX IF NOT EXISTS idx_production_plan_customer ON public.production_plan USING btree (customer_objid);
CREATE INDEX IF NOT EXISTS idx_production_plan_project ON public.production_plan USING btree (project_objid);
CREATE INDEX IF NOT EXISTS idx_production_plan_regdate ON public.production_plan USING btree (regdate);
CREATE INDEX IF NOT EXISTS idx_production_plan_status ON public.production_plan USING btree (status);
@@ -6,13 +6,13 @@
// 참조: docs/migration/development/03-eo-history.md
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Search, Loader2, RotateCcw } from "lucide-react";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { PageHeader } from "@/components/common/PageHeader";
import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar";
import { devEoHistoryApi, EoHistoryListFilter, EoHistoryRow } from "@/lib/api/devEoHistory";
import { PartHisDetailDialog } from "@/components/development/PartHisDetailDialog";
@@ -21,10 +21,10 @@ const GROUP_PART_TYPE = "0000062";
// change_type/change_option은 wace 운영판 그룹 ID가 명확하지 않으므로 text input으로 우선 운영.
// (시드 후 그룹 ID 확인되면 SmartSelect 전환)
const YEAR_OPTIONS = (() => {
const YEAR_OPTIONS: SmartSelectOption[] = (() => {
const cur = new Date().getFullYear();
const arr: string[] = [];
for (let y = cur + 4; y >= cur - 8; y--) arr.push(String(y));
const arr: SmartSelectOption[] = [];
for (let y = cur + 4; y >= cur - 8; y--) arr.push({ code: String(y), label: String(y) });
return arr;
})();
@@ -91,73 +91,61 @@ export default function EoHistoryPage() {
);
return (
<div className="flex h-full flex-col">
<div className="border-b bg-card px-4 py-3">
<div className="grid grid-cols-4 gap-3 text-sm">
<Field label="년도">
<select className="h-9 w-full rounded-md border bg-background px-2 text-sm"
value={filter.Year ?? ""}
onChange={(e) => setFilter({ ...filter, Year: e.target.value })}>
<option value=""></option>
{YEAR_OPTIONS.map((y) => <option key={y} value={y}>{y}</option>)}
</select>
</Field>
<Field label="프로젝트 OBJID">
<Input value={filter.contract_objid ?? ""}
onChange={(e) => setFilter({ ...filter, contract_objid: e.target.value })}
placeholder="project_mgmt.objid" />
</Field>
<Field label="품번">
<Input value={filter.part_no ?? ""}
onChange={(e) => setFilter({ ...filter, part_no: e.target.value })}
placeholder="part_no LIKE" />
</Field>
<Field label="품명">
<Input value={filter.part_name ?? ""}
onChange={(e) => setFilter({ ...filter, part_name: e.target.value })}
placeholder="part_name LIKE" />
</Field>
<div className="flex h-full flex-col gap-2 p-2">
<PageHeader
loading={loading}
onSearch={() => fetchList()}
onReset={() => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); }}
/>
<Field label="EO Date 시작">
<Input type="date" value={filter.eo_start_date ?? ""}
onChange={(e) => setFilter({ ...filter, eo_start_date: e.target.value })} />
</Field>
<Field label="EO Date 종료">
<Input type="date" value={filter.eo_end_date ?? ""}
onChange={(e) => setFilter({ ...filter, eo_end_date: e.target.value })} />
</Field>
<Field label="PART구분">
<CommCodeSelect groupId={GROUP_PART_TYPE}
value={filter.part_type ?? ""}
onValueChange={(v) => setFilter({ ...filter, part_type: v })} />
</Field>
<Field label="EO구분 / EO사유 (code_id)">
<div className="flex items-center gap-1">
<Input value={filter.change_type ?? ""}
onChange={(e) => setFilter({ ...filter, change_type: e.target.value })}
placeholder="EO구분 code_id" />
<Input value={filter.change_option ?? ""}
onChange={(e) => setFilter({ ...filter, change_option: e.target.value })}
placeholder="EO사유 code_id" />
</div>
</Field>
</div>
<div className="mt-3 flex items-center justify-between">
<div className="text-xs text-muted-foreground"> {total.toLocaleString()} (read-only)</div>
<div className="flex items-end gap-2">
<Button variant="outline" size="sm"
onClick={() => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); }}>
<RotateCcw className="h-4 w-4" /><span className="ml-1"></span>
</Button>
<Button size="sm" onClick={() => fetchList()} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
<span className="ml-1"></span>
</Button>
</div>
</div>
</div>
<CompactFilterBar totalText={<> {total.toLocaleString()} (read-only)</>}>
<CompactFilterField label="년도" width={100}>
<SmartSelect
options={YEAR_OPTIONS}
value={filter.Year ?? ""}
onValueChange={(v) => setFilter({ ...filter, Year: v })}
placeholder="전체"
/>
</CompactFilterField>
<CompactFilterField label="프로젝트 OBJID" width={180}>
<Input value={filter.contract_objid ?? ""}
onChange={(e) => setFilter({ ...filter, contract_objid: e.target.value })}
placeholder="project_mgmt.objid" />
</CompactFilterField>
<CompactFilterField label="품번" width={140}>
<Input value={filter.part_no ?? ""}
onChange={(e) => setFilter({ ...filter, part_no: e.target.value })}
placeholder="part_no LIKE" />
</CompactFilterField>
<CompactFilterField label="품명" width={150}>
<Input value={filter.part_name ?? ""}
onChange={(e) => setFilter({ ...filter, part_name: e.target.value })}
placeholder="part_name LIKE" />
</CompactFilterField>
<CompactFilterField label="EO Date" width={280}>
<CompactDateRange
from={filter.eo_start_date ?? ""}
setFrom={(v) => setFilter({ ...filter, eo_start_date: v })}
to={filter.eo_end_date ?? ""}
setTo={(v) => setFilter({ ...filter, eo_end_date: v })}
/>
</CompactFilterField>
<CompactFilterField label="PART구분" width={140}>
<CommCodeSelect groupId={GROUP_PART_TYPE}
value={filter.part_type ?? ""}
onValueChange={(v) => setFilter({ ...filter, part_type: v })} />
</CompactFilterField>
<CompactFilterField label="EO구분 code_id" width={140}>
<Input value={filter.change_type ?? ""}
onChange={(e) => setFilter({ ...filter, change_type: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="EO사유 code_id" width={140}>
<Input value={filter.change_option ?? ""}
onChange={(e) => setFilter({ ...filter, change_option: e.target.value })} />
</CompactFilterField>
</CompactFilterBar>
<div className="min-h-0 flex-1 p-2">
<div className="min-h-0 flex-1">
<DataGrid
columns={columns}
data={rows}
@@ -177,11 +165,3 @@ export default function EoHistoryPage() {
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<Label className="mb-1 block text-xs text-muted-foreground">{label}</Label>
{children}
</div>
);
}
@@ -7,14 +7,13 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Search, Loader2, RotateCcw, Trash2, Settings, FileSpreadsheet,
} from "lucide-react";
import { Trash2, Settings, FileSpreadsheet } from "lucide-react";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { PageHeader } from "@/components/common/PageHeader";
import { CompactFilterBar, CompactFilterField } from "@/components/common/CompactFilterBar";
import { devBomApi, BomReportListFilter, BomReportRow } from "@/lib/api/devBom";
import { BomReportStatusDialog } from "@/components/development/BomReportStatusDialog";
import { DevPartSelect } from "@/components/development/DevPartSelect";
@@ -23,7 +22,7 @@ import { BomReportTreeDialog } from "@/components/development/BomReportTreeDialo
const PRODUCT_GROUP = "0000001"; // 제품구분 (vexplor 공용)
const STATUS_OPTIONS = [
const STATUS_OPTIONS: SmartSelectOption[] = [
{ code: "create", label: "등록중" },
{ code: "changeDesign", label: "설계변경미배포" },
{ code: "deploy", label: "배포완료" },
@@ -115,75 +114,65 @@ export default function EbomRegistPage() {
const gridRows = useMemo(() => rows.map((r) => ({ ...r, id: r.objid })), [rows]);
return (
<div className="flex h-full flex-col">
<div className="border-b bg-card px-4 py-3">
<div className="grid grid-cols-4 gap-3 text-sm">
<Field label="제품구분">
<CommCodeSelect
groupId={PRODUCT_GROUP}
value={filter.product_cd ?? ""}
onValueChange={(v) => setFilter({ ...filter, product_cd: v })}
/>
</Field>
<Field label="상태">
<select
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
value={filter.status ?? ""}
onChange={(e) => setFilter({ ...filter, status: e.target.value })}
>
<option value=""></option>
{STATUS_OPTIONS.map((o) =>
<option key={o.code} value={o.code}>{o.label}</option>)}
</select>
</Field>
{/* wace structureList.jsp 1:1 — select2-part 자동완성 (양방향 동기) */}
<Field label="품번">
<DevPartSelect mode="partNo"
value={filter.search_part_no ?? ""}
onValueChange={(v, row) => setFilter((prev) => ({
...prev,
search_part_no: v,
search_part_name: row?.part_name ?? prev.search_part_name,
}))} />
</Field>
<Field label="품명">
<DevPartSelect mode="partName"
value={filter.search_part_name ?? ""}
onValueChange={(v, row) => setFilter((prev) => ({
...prev,
search_part_name: v,
search_part_no: row?.part_no ?? prev.search_part_no,
}))} />
</Field>
</div>
<div className="mt-3 flex items-center justify-between">
<div className="text-xs text-muted-foreground"> {total.toLocaleString()}</div>
<div className="flex items-end gap-2">
<Button variant="outline" size="sm"
onClick={() => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); }}>
<RotateCcw className="h-4 w-4" /><span className="ml-1"></span>
</Button>
<Button size="sm" onClick={() => fetchList()} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
<span className="ml-1"></span>
</Button>
<Button size="sm" onClick={() => setExcelOpen(true)}
className="bg-emerald-600 hover:bg-emerald-700 text-white">
<FileSpreadsheet className="h-4 w-4" /><span className="ml-1">E-BOM (Excel)</span>
</Button>
<Button size="sm" variant="secondary" onClick={handleStatusChange}
disabled={checkedIds.length !== 1}>
<Settings className="h-4 w-4" /><span className="ml-1"></span>
</Button>
<Button size="sm" variant="destructive" onClick={handleDelete}
disabled={checkedIds.length === 0}>
<Trash2 className="h-4 w-4" /><span className="ml-1"></span>
</Button>
</div>
</div>
</div>
<div className="flex h-full flex-col gap-2 p-2">
<PageHeader
loading={loading}
onSearch={() => fetchList()}
onReset={() => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); }}
actions={
<>
<Button size="sm" onClick={() => setExcelOpen(true)}
className="h-8 gap-1 bg-emerald-600 hover:bg-emerald-700 text-white text-xs">
<FileSpreadsheet className="h-3.5 w-3.5" />E-BOM (Excel)
</Button>
<Button size="sm" variant="secondary" className="h-8 gap-1 text-xs" onClick={handleStatusChange}
disabled={checkedIds.length !== 1}>
<Settings className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="destructive" className="h-8 gap-1 text-xs" onClick={handleDelete}
disabled={checkedIds.length === 0}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>
} />
<div className="min-h-0 flex-1 p-2">
<CompactFilterBar totalText={<> {total.toLocaleString()}</>}>
<CompactFilterField label="제품구분" width={160}>
<CommCodeSelect
groupId={PRODUCT_GROUP}
value={filter.product_cd ?? ""}
onValueChange={(v) => setFilter({ ...filter, product_cd: v })}
/>
</CompactFilterField>
<CompactFilterField label="상태" width={140}>
<SmartSelect
options={STATUS_OPTIONS}
value={filter.status ?? ""}
onValueChange={(v) => setFilter({ ...filter, status: v })}
placeholder="전체"
/>
</CompactFilterField>
<CompactFilterField label="품번" width={200}>
<DevPartSelect mode="partNo"
value={filter.search_part_no ?? ""}
onValueChange={(v, row) => setFilter((prev) => ({
...prev,
search_part_no: v,
search_part_name: row?.part_name ?? prev.search_part_name,
}))} />
</CompactFilterField>
<CompactFilterField label="품명" width={220}>
<DevPartSelect mode="partName"
value={filter.search_part_name ?? ""}
onValueChange={(v, row) => setFilter((prev) => ({
...prev,
search_part_name: v,
search_part_no: row?.part_no ?? prev.search_part_no,
}))} />
</CompactFilterField>
</CompactFilterBar>
<div className="min-h-0 flex-1">
<DataGrid
columns={columns}
data={gridRows}
@@ -218,11 +207,3 @@ export default function EbomRegistPage() {
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<Label className="mb-1 block text-xs text-muted-foreground">{label}</Label>
{children}
</div>
);
}
@@ -6,15 +6,22 @@
import React, { useCallback, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Search, Loader2, RotateCcw, ChevronsRight, ChevronsLeft, FileSpreadsheet,
} from "lucide-react";
import { PageHeader } from "@/components/common/PageHeader";
import { CompactFilterBar, CompactFilterField } from "@/components/common/CompactFilterBar";
import { Loader2, ChevronsRight, ChevronsLeft, FileSpreadsheet } from "lucide-react";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { devBomApi, BomTreeFilter, BomTreeRow } from "@/lib/api/devBom";
import { DevPartSelect } from "@/components/development/DevPartSelect";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
const LEVEL_OPTIONS: SmartSelectOption[] = [
{ code: "1", label: "1레벨" },
{ code: "2", label: "2레벨" },
{ code: "3", label: "3레벨" },
{ code: "4", label: "4레벨" },
{ code: "5", label: "5레벨" },
];
import { PartDetailDialog } from "@/components/development/PartDetailDialog";
type Direction = "ascending" | "descending";
@@ -141,9 +148,9 @@ export default function EbomSearchPage() {
{ key: "pm_part_name", label: "품명", minWidth: "min-w-[200px]" },
{ key: "qty", label: "수량", width: "w-[70px]", align: "right", formatNumber: true },
{ key: "p_qty", label: "항목수량", width: "w-[80px]", align: "right", formatNumber: true },
{ key: "cu01_cnt", label: "3D", width: "w-[60px]", align: "center", renderType: "folder" },
{ key: "cu02_cnt", label: "2D", width: "w-[60px]", align: "center", renderType: "folder" },
{ key: "cu03_cnt", label: "PDF", width: "w-[60px]", align: "center", renderType: "folder" },
{ key: "cu01_cnt", label: "3D", width: "w-[70px]", align: "center", renderType: "folder" },
{ key: "cu02_cnt", label: "2D", width: "w-[70px]", align: "center", renderType: "folder" },
{ key: "cu03_cnt", label: "PDF", width: "w-[70px]", align: "center", renderType: "folder" },
{ key: "material", label: "재료", width: "w-[100px]" },
{ key: "heat_treatment_hardness", label: "열처리경도", width: "w-[110px]" },
{ key: "heat_treatment_method", label: "열처리방법", width: "w-[110px]" },
@@ -177,87 +184,80 @@ export default function EbomSearchPage() {
}, [rows, maxLevel, hasChildSet, ancestorsByChildId, collapsedChildIds]);
return (
<div className="flex h-full flex-col">
<div className="border-b bg-card px-4 py-3">
{/* 운영판 wace structureAscendingList.jsp 1:1 — 노출 검색 필드 3개
(고객사/프로젝트번호/유닛명 은 운영판에서도 주석 처리되어 노출 안 됨) */}
<div className="grid grid-cols-3 gap-3 text-sm">
<Field label="품번">
<DevPartSelect mode="partNo"
value={filter.search_part_no ?? ""}
onValueChange={(v, row) => setFilter((prev) => ({
...prev,
search_part_no: v,
// 품번 선택 시 품명 자동 채움 (wace select2-part 1:1)
search_part_name: row?.part_name ?? prev.search_part_name,
}))} />
</Field>
<Field label="품명">
<DevPartSelect mode="partName"
value={filter.search_part_name ?? ""}
onValueChange={(v, row) => setFilter((prev) => ({
...prev,
search_part_name: v,
// 품명 선택 시 품번 자동 채움
search_part_no: row?.part_no ?? prev.search_part_no,
}))} />
</Field>
<Field label="표시 레벨">
<select
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
value={String(filter.search_level ?? "")}
onChange={(e) => setFilter({ ...filter, search_level: e.target.value })}
>
<option value=""></option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
</select>
</Field>
</div>
<div className="mt-3 flex items-center justify-between">
<div className="text-xs text-muted-foreground">
: {direction === "ascending" ? "정전개 (루트 → 리프)" : "역전개 (리프 → 부모)"} · {rows.length.toLocaleString()} · MAX_LEVEL = {maxLevel}
</div>
<div className="flex items-end gap-2">
<Button variant="outline" size="sm"
onClick={() => { setFilter(EMPTY_FILTER); setRows([]); setMaxLevel(0); }}>
<RotateCcw className="h-4 w-4" /><span className="ml-1"></span>
</Button>
<Button size="sm" onClick={() => runQuery("ascending")} disabled={loading}
variant={direction === "ascending" ? "default" : "secondary"}>
{loading && direction === "ascending"
? <Loader2 className="h-4 w-4 animate-spin" />
: <ChevronsRight className="h-4 w-4" />}
<span className="ml-1"> </span>
</Button>
<Button size="sm" onClick={() => runQuery("descending")} disabled={loading}
variant={direction === "descending" ? "default" : "secondary"}>
{loading && direction === "descending"
? <Loader2 className="h-4 w-4 animate-spin" />
: <ChevronsLeft className="h-4 w-4" />}
<span className="ml-1"> </span>
</Button>
<Button size="sm" variant="outline" onClick={() => downloadExcel("ascending")} disabled={exporting}>
{exporting ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileSpreadsheet className="h-4 w-4" />}
<span className="ml-1"> </span>
</Button>
<Button size="sm" variant="outline" onClick={() => downloadExcel("descending")} disabled={exporting}>
{exporting ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileSpreadsheet className="h-4 w-4" />}
<span className="ml-1"> </span>
</Button>
</div>
</div>
{direction === "descending" && (
<div className="mt-2 text-xs text-amber-600">
.
</div>
)}
</div>
<div className="flex h-full flex-col gap-2 p-2">
<PageHeader
onReset={() => { setFilter(EMPTY_FILTER); setRows([]); setMaxLevel(0); }}
actions={
<>
<Button size="sm" onClick={() => runQuery("ascending")} disabled={loading}
variant={direction === "ascending" ? "default" : "secondary"}
className="h-8 gap-1 text-xs">
{loading && direction === "ascending"
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <ChevronsRight className="h-3.5 w-3.5" />}
</Button>
<Button size="sm" onClick={() => runQuery("descending")} disabled={loading}
variant={direction === "descending" ? "default" : "secondary"}
className="h-8 gap-1 text-xs">
{loading && direction === "descending"
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <ChevronsLeft className="h-3.5 w-3.5" />}
</Button>
<Button size="sm" variant="outline" className="h-8 gap-1 text-xs" onClick={() => downloadExcel("ascending")} disabled={exporting}>
{exporting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FileSpreadsheet className="h-3.5 w-3.5" />}
</Button>
<Button size="sm" variant="outline" className="h-8 gap-1 text-xs" onClick={() => downloadExcel("descending")} disabled={exporting}>
{exporting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FileSpreadsheet className="h-3.5 w-3.5" />}
</Button>
</>
} />
<div className="min-h-0 flex-1 p-2">
{/* 운영판 wace structureAscendingList.jsp 1:1 — 노출 검색 필드 3개
(고객사/프로젝트번호/유닛명 은 운영판에서도 주석 처리되어 노출 안 됨) */}
<CompactFilterBar
totalText={<>: {direction === "ascending" ? "정전개 (루트 → 리프)" : "역전개 (리프 → 부모)"} · {rows.length.toLocaleString()} · MAX_LEVEL = {maxLevel}</>}
>
<CompactFilterField label="품번" width={200}>
<DevPartSelect mode="partNo"
value={filter.search_part_no ?? ""}
onValueChange={(v, row) => setFilter((prev) => ({
...prev,
search_part_no: v,
// 품번 선택 시 품명 자동 채움 (wace select2-part 1:1)
search_part_name: row?.part_name ?? prev.search_part_name,
}))} />
</CompactFilterField>
<CompactFilterField label="품명" width={220}>
<DevPartSelect mode="partName"
value={filter.search_part_name ?? ""}
onValueChange={(v, row) => setFilter((prev) => ({
...prev,
search_part_name: v,
// 품명 선택 시 품번 자동 채움
search_part_no: row?.part_no ?? prev.search_part_no,
}))} />
</CompactFilterField>
<CompactFilterField label="표시 레벨" width={120}>
<SmartSelect
options={LEVEL_OPTIONS}
value={String(filter.search_level ?? "")}
onValueChange={(v) => setFilter({ ...filter, search_level: v })}
placeholder="전체"
/>
</CompactFilterField>
</CompactFilterBar>
{direction === "descending" && (
<div className="text-xs text-amber-600 px-2">
.
</div>
)}
<div className="min-h-0 flex-1">
<DataGrid
columns={columns}
data={gridData}
@@ -277,11 +277,3 @@ export default function EbomSearchPage() {
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<Label className="mb-1 block text-xs text-muted-foreground">{label}</Label>
{children}
</div>
);
}
@@ -7,10 +7,10 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PageHeader } from "@/components/common/PageHeader";
import { CompactFilterBar, CompactFilterField } from "@/components/common/CompactFilterBar";
import {
Search, Loader2, RotateCcw, Plus, Pencil, Trash2, CheckSquare, FileSpreadsheet,
Plus, Pencil, Trash2, CheckSquare, FileSpreadsheet,
} from "lucide-react";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
@@ -25,9 +25,9 @@ import { DevPartSelect } from "@/components/development/DevPartSelect";
const GRID_COLUMNS: DataGridColumn[] = [
{ key: "part_no", label: "품번", width: "w-[140px]", frozen: true },
{ key: "part_name", label: "품명", minWidth: "min-w-[220px]" },
{ key: "cu01_cnt", label: "3D", width: "w-[60px]", align: "right", formatNumber: true },
{ key: "cu02_cnt", label: "2D", width: "w-[60px]", align: "right", formatNumber: true },
{ key: "cu03_cnt", label: "PDF", width: "w-[60px]", align: "right", formatNumber: true },
{ key: "cu01_cnt", label: "3D", width: "w-[70px]", align: "center", renderType: "folder" },
{ key: "cu02_cnt", label: "2D", width: "w-[70px]", align: "center", renderType: "folder" },
{ key: "cu03_cnt", label: "PDF", width: "w-[70px]", align: "center", renderType: "folder" },
{ key: "material", label: "재료", width: "w-[100px]" },
{ key: "heat_treatment_hardness", label: "열처리경도", width: "w-[110px]" },
{ key: "heat_treatment_method", label: "열처리방법", width: "w-[110px]" },
@@ -155,71 +155,61 @@ export default function PartRegistPage() {
};
return (
<div className="flex h-full flex-col">
{/* 검색폼 — wace partMngTempList.jsp 활성 2필드 */}
<div className="border-b bg-card px-4 py-3">
<div className="flex flex-wrap items-end gap-4">
{/* wace partMngTempList.jsp 1:1 — select2-part 자동완성 (양방향 동기) */}
<div className="min-w-[220px]">
<Label className="mb-1 block text-xs text-muted-foreground"></Label>
<DevPartSelect mode="partNo"
value={filter.search_part_no ?? ""}
onValueChange={(v, row) => setFilter((prev) => ({
...prev,
search_part_no: v,
search_part_name: row?.part_name ?? prev.search_part_name,
}))} />
</div>
<div className="min-w-[220px]">
<Label className="mb-1 block text-xs text-muted-foreground"></Label>
<DevPartSelect mode="partName"
value={filter.search_part_name ?? ""}
onValueChange={(v, row) => setFilter((prev) => ({
...prev,
search_part_name: v,
search_part_no: row?.part_no ?? prev.search_part_no,
}))} />
</div>
<div className="ml-auto flex items-end gap-2">
<Button variant="outline" size="sm"
onClick={() => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); }}>
<RotateCcw className="h-4 w-4" /><span className="ml-1"></span>
</Button>
<Button size="sm" onClick={() => fetchList()} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
<span className="ml-1"></span>
</Button>
<Button size="sm" variant="default" onClick={handleCreate}>
<Plus className="h-4 w-4" /><span className="ml-1"></span>
</Button>
<Button size="sm" variant="secondary" onClick={handleEdit}
disabled={checkedIds.length !== 1}>
<Pencil className="h-4 w-4" /><span className="ml-1"></span>
</Button>
<Button size="sm" variant="destructive" onClick={handleDelete}
disabled={checkedIds.length === 0}>
<Trash2 className="h-4 w-4" /><span className="ml-1"></span>
</Button>
<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">
<CheckSquare className="h-4 w-4" /><span className="ml-1"></span>
</Button>
</div>
</div>
<div className="mt-2 text-xs text-muted-foreground">
{total.toLocaleString()} (M1: status 'release')
</div>
</div>
<div className="flex h-full flex-col gap-2 p-2">
<PageHeader
loading={loading}
onSearch={() => fetchList()}
onReset={() => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); }}
actions={
<>
<Button size="sm" className="h-8 gap-1 text-xs" onClick={handleCreate}>
<Plus className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="secondary" className="h-8 gap-1 text-xs" onClick={handleEdit}
disabled={checkedIds.length !== 1}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="destructive" className="h-8 gap-1 text-xs" onClick={handleDelete}
disabled={checkedIds.length === 0}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="outline" className="h-8 gap-1 text-xs" onClick={() => setExcelOpen(true)}>
<FileSpreadsheet className="h-3.5 w-3.5" />Excel Upload
</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="h-8 gap-1 bg-emerald-600 hover:bg-emerald-700 text-white text-xs">
<CheckSquare className="h-3.5 w-3.5" />
</Button>
</>
} />
<div className="min-h-0 flex-1 p-2">
<CompactFilterBar totalText={<> {total.toLocaleString()} (M1: status 'release')</>}>
<CompactFilterField label="품번" width={220}>
<DevPartSelect mode="partNo"
value={filter.search_part_no ?? ""}
onValueChange={(v, row) => setFilter((prev) => ({
...prev,
search_part_no: v,
search_part_name: row?.part_name ?? prev.search_part_name,
}))} />
</CompactFilterField>
<CompactFilterField label="품명" width={220}>
<DevPartSelect mode="partName"
value={filter.search_part_name ?? ""}
onValueChange={(v, row) => setFilter((prev) => ({
...prev,
search_part_name: v,
search_part_no: row?.part_no ?? prev.search_part_no,
}))} />
</CompactFilterField>
</CompactFilterBar>
<div className="min-h-0 flex-1">
<DataGrid
columns={columns}
data={gridRows}
@@ -7,10 +7,10 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PageHeader } from "@/components/common/PageHeader";
import { CompactFilterBar, CompactFilterField } from "@/components/common/CompactFilterBar";
import {
Search, Loader2, RotateCcw, Plus, Pencil, Trash2, FileSpreadsheet,
Plus, Pencil, Trash2, FileSpreadsheet,
} from "lucide-react";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
@@ -24,9 +24,9 @@ import { DevPartSelect } from "@/components/development/DevPartSelect";
const GRID_COLUMNS: DataGridColumn[] = [
{ key: "part_no", label: "품번", width: "w-[140px]", frozen: true },
{ key: "part_name", label: "품명", minWidth: "min-w-[220px]" },
{ key: "cu01_cnt", label: "3D", width: "w-[60px]", align: "right", formatNumber: true },
{ key: "cu02_cnt", label: "2D", width: "w-[60px]", align: "right", formatNumber: true },
{ key: "cu03_cnt", label: "PDF", width: "w-[60px]", align: "right", formatNumber: true },
{ key: "cu01_cnt", label: "3D", width: "w-[70px]", align: "center", renderType: "folder" },
{ key: "cu02_cnt", label: "2D", width: "w-[70px]", align: "center", renderType: "folder" },
{ key: "cu03_cnt", label: "PDF", width: "w-[70px]", align: "center", renderType: "folder" },
{ key: "material", label: "재료", width: "w-[100px]" },
{ key: "heat_treatment_hardness", label: "열처리경도", width: "w-[110px]" },
{ key: "heat_treatment_method", label: "열처리방법", width: "w-[110px]" },
@@ -124,63 +124,54 @@ export default function PartSearchPage() {
};
return (
<div className="flex h-full flex-col">
<div className="border-b bg-card px-4 py-3">
<div className="flex flex-wrap items-end gap-4">
{/* wace partMngList.jsp 1:1 — select2-part 자동완성 (양방향 동기) */}
<div className="min-w-[220px]">
<Label className="mb-1 block text-xs text-muted-foreground"></Label>
<DevPartSelect mode="partNo"
value={filter.search_part_no ?? ""}
onValueChange={(v, row) => setFilter((prev) => ({
...prev,
search_part_no: v,
search_part_name: row?.part_name ?? prev.search_part_name,
}))} />
</div>
<div className="min-w-[220px]">
<Label className="mb-1 block text-xs text-muted-foreground"></Label>
<DevPartSelect mode="partName"
value={filter.search_part_name ?? ""}
onValueChange={(v, row) => setFilter((prev) => ({
...prev,
search_part_name: v,
search_part_no: row?.part_no ?? prev.search_part_no,
}))} />
</div>
<div className="ml-auto flex items-end gap-2">
<Button variant="outline" size="sm"
onClick={() => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); }}>
<RotateCcw className="h-4 w-4" /><span className="ml-1"></span>
</Button>
<Button size="sm" onClick={() => fetchList()} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
<span className="ml-1"></span>
</Button>
<Button size="sm" variant="default" onClick={handleCreate}>
<Plus className="h-4 w-4" /><span className="ml-1"></span>
</Button>
<Button size="sm" variant="secondary" onClick={handleEdit}
disabled={checkedIds.length !== 1}>
<Pencil className="h-4 w-4" /><span className="ml-1"></span>
</Button>
<Button size="sm" variant="destructive" onClick={handleDelete}
disabled={checkedIds.length === 0}>
<Trash2 className="h-4 w-4" /><span className="ml-1"></span>
</Button>
<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">
{total.toLocaleString()} (M2: status = 'release')
</div>
</div>
<div className="flex h-full flex-col gap-2 p-2">
<PageHeader
loading={loading}
onSearch={() => fetchList()}
onReset={() => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); }}
actions={
<>
<Button size="sm" className="h-8 gap-1 text-xs" onClick={handleCreate}>
<Plus className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="secondary" className="h-8 gap-1 text-xs" onClick={handleEdit}
disabled={checkedIds.length !== 1}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="destructive" className="h-8 gap-1 text-xs" onClick={handleDelete}
disabled={checkedIds.length === 0}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="outline" className="h-8 gap-1 text-xs" onClick={() => setExcelOpen(true)}>
<FileSpreadsheet className="h-3.5 w-3.5" />Excel Upload
</Button>
{/* M2 조회 — partNoList 미전달: IS_LAST='1' 전체 part_mng 매칭 (페이지 밖도 허용) */}
<PartDrawingMultiUploadButton onUploaded={() => fetchList()} />
</>
} />
<div className="min-h-0 flex-1 p-2">
<CompactFilterBar totalText={<> {total.toLocaleString()} (M2: status = 'release')</>}>
<CompactFilterField label="품번" width={220}>
<DevPartSelect mode="partNo"
value={filter.search_part_no ?? ""}
onValueChange={(v, row) => setFilter((prev) => ({
...prev,
search_part_no: v,
search_part_name: row?.part_name ?? prev.search_part_name,
}))} />
</CompactFilterField>
<CompactFilterField label="품명" width={220}>
<DevPartSelect mode="partName"
value={filter.search_part_name ?? ""}
onValueChange={(v, row) => setFilter((prev) => ({
...prev,
search_part_name: v,
search_part_no: row?.part_no ?? prev.search_part_no,
}))} />
</CompactFilterField>
</CompactFilterBar>
<div className="min-h-0 flex-1">
<DataGrid
columns={columns}
data={gridRows}
@@ -0,0 +1,246 @@
"use client";
// 생산관리 > M-BOM 관리 — wace productionplanning/mBomMgmtList.jsp 1:1
// 그리드: PROJECT_MGMT × CONTRACT_ITEM 펼침 (1 프로젝트 = 1+ 행) + M-BOM 상태/저장일
// 검색: 주문유형 / 제품구분 / 국내해외 / 고객사 / 유무상 / S/N / 품번 / 품명 / 접수일 / 요청납기
// 액션:
// PR-A1: 조회 / 초기화 / 페이지
// PR-A2: 행 더블클릭 → MbomDetailDialog (헤더 + read-only 트리 4분기)
// ※ BOM 복사 / 구매리스트 생성 / M-BOM 본 편집 — PR-B 분리.
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { CustomerSelect } from "@/components/common/CustomerSelect";
import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar";
import { PageHeader } from "@/components/common/PageHeader";
import { apiClient } from "@/lib/api/client";
import { mbomApi, MbomListFilter, MbomRow } from "@/lib/api/mbom";
import { MbomDetailDialog } from "@/components/production/MbomDetailDialog";
const PARENT_CATEGORY = "0000167"; // 주문유형 comm_code parent_code_id
const PARENT_PRODUCT = "0000001"; // 제품구분 comm_code parent_code_id
const PARENT_PAID = "0001782"; // 유/무상 (참고: 빈 결과여도 raw paid/free 매칭으로 fallback)
interface CodeOpt extends SmartSelectOption { sort?: number | null }
const AREA_OPTS: SmartSelectOption[] = [
{ code: "국내", label: "국내" },
{ code: "해외", label: "해외" },
];
// 운영판 1:1 — paid/free raw 매칭이 기본. comm_code 응답이 비어있을 때 사용.
const PAID_FALLBACK_OPTS: SmartSelectOption[] = [
{ code: "paid", label: "유상" },
{ code: "free", label: "무상" },
];
const EMPTY_FILTER: MbomListFilter = {
search_category_cd: "",
search_product_cd: "",
search_area_cd: "",
search_customer_objid: "",
search_paid_type: "",
search_serial_no: "",
search_part_no: "",
search_part_name: "",
search_receipt_date_from: "",
search_receipt_date_to: "",
search_req_del_date_from: "",
search_req_del_date_to: "",
page: 1,
page_size: 50,
};
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" },
];
export default function MbomMgmtPage() {
const [rows, setRows] = useState<MbomRow[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<MbomListFilter>(EMPTY_FILTER);
const [categoryOpts, setCategoryOpts] = useState<CodeOpt[]>([]);
const [productOpts, setProductOpts] = useState<CodeOpt[]>([]);
const [paidOpts, setPaidOpts] = useState<CodeOpt[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogObjid, setDialogObjid] = useState<string | null>(null);
const fetchList = useCallback(async (override?: Partial<MbomListFilter>) => {
setLoading(true);
try {
const f = { ...filter, ...override };
const res = await mbomApi.list(f);
setRows(res.rows ?? []);
setTotal(res.totalCount ?? 0);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
} finally {
setLoading(false);
}
}, [filter]);
// 초기 옵션 + 첫 조회
useEffect(() => {
let dead = false;
(async () => {
try {
const [c1, c2, c3] = await Promise.all([
apiClient.get(`/sales/codes/${PARENT_CATEGORY}`),
apiClient.get(`/sales/codes/${PARENT_PRODUCT}`),
apiClient.get(`/sales/codes/${PARENT_PAID}`),
]);
if (dead) return;
setCategoryOpts(c1.data?.data ?? []);
setProductOpts(c2.data?.data ?? []);
setPaidOpts(c3.data?.data ?? []);
} catch {
/* 옵션 로드 실패는 무시 — 그리드는 조회 가능 */
}
})();
fetchList(EMPTY_FILTER);
return () => { dead = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// DataGrid 키 부여 (objid + part_no 조합 — 같은 프로젝트 다중 행 unique)
const gridRows = useMemo(
() => rows.map((r, i) => ({ ...r, id: `${r.objid}__${r.part_no ?? ""}__${i}` })),
[rows]
);
const handleSearch = () => {
setFilter((f) => ({ ...f, page: 1 }));
fetchList({ page: 1 });
};
const handleReset = () => {
setFilter(EMPTY_FILTER);
fetchList(EMPTY_FILTER);
};
return (
<div className="flex h-full flex-col gap-2 p-2">
<PageHeader
loading={loading}
onSearch={handleSearch}
onReset={handleReset}
/>
<CompactFilterBar
totalText={<> {total.toLocaleString()} · PROJECT_MGMT × CONTRACT_ITEM </>}
>
<CompactFilterField label="주문유형" width={130}>
<SmartSelect
options={categoryOpts}
value={filter.search_category_cd ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_category_cd: v })}
/>
</CompactFilterField>
<CompactFilterField label="제품구분" width={130}>
<SmartSelect
options={productOpts}
value={filter.search_product_cd ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_product_cd: v })}
/>
</CompactFilterField>
<CompactFilterField label="국내/해외" width={100}>
<SmartSelect
options={AREA_OPTS}
value={filter.search_area_cd ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_area_cd: v })}
/>
</CompactFilterField>
<CompactFilterField label="고객사" width={160}>
<CustomerSelect
value={filter.search_customer_objid ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_customer_objid: v })}
/>
</CompactFilterField>
<CompactFilterField label="유/무상" width={110}>
<SmartSelect
options={paidOpts.length > 0 ? paidOpts : PAID_FALLBACK_OPTS}
value={filter.search_paid_type ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_paid_type: v })}
/>
</CompactFilterField>
<CompactFilterField label="S/N" width={120}>
<Input
value={filter.search_serial_no ?? ""}
onChange={(e) => setFilter({ ...filter, search_serial_no: e.target.value })}
/>
</CompactFilterField>
<CompactFilterField label="품번" width={130}>
<Input
value={filter.search_part_no ?? ""}
onChange={(e) => setFilter({ ...filter, search_part_no: e.target.value })}
/>
</CompactFilterField>
<CompactFilterField label="품명" width={150}>
<Input
value={filter.search_part_name ?? ""}
onChange={(e) => setFilter({ ...filter, search_part_name: e.target.value })}
/>
</CompactFilterField>
<CompactFilterField label="접수일" width={280}>
<CompactDateRange
from={filter.search_receipt_date_from ?? ""}
setFrom={(v) => setFilter({ ...filter, search_receipt_date_from: v })}
to={filter.search_receipt_date_to ?? ""}
setTo={(v) => setFilter({ ...filter, search_receipt_date_to: v })}
/>
</CompactFilterField>
<CompactFilterField label="요청납기" width={280}>
<CompactDateRange
from={filter.search_req_del_date_from ?? ""}
setFrom={(v) => setFilter({ ...filter, search_req_del_date_from: v })}
to={filter.search_req_del_date_to ?? ""}
setTo={(v) => setFilter({ ...filter, search_req_del_date_to: v })}
/>
</CompactFilterField>
</CompactFilterBar>
<div className="min-h-0 flex-1">
<DataGrid
columns={GRID_COLUMNS}
data={gridRows}
loading={loading}
showRowNumber
emptyMessage="조건에 맞는 프로젝트가 없습니다."
gridId="production-mbom-mgmt"
onRowDoubleClick={(row: any) => {
if (!row?.objid) return;
setDialogObjid(String(row.objid));
setDialogOpen(true);
}}
/>
</div>
<MbomDetailDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
projectObjid={dialogObjid}
/>
</div>
);
}
@@ -11,16 +11,15 @@
// 행 클릭: P1.5에서 영업관리 OrderRegistDialog 재사용 검토 — 현재 미연결
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Search, Loader2, RotateCcw } from "lucide-react";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
import { CustomerSelect } from "@/components/common/CustomerSelect";
import { PartSelect } from "@/components/common/PartSelect";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { PageHeader } from "@/components/common/PageHeader";
import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar";
import { ProjectInfoDialog, ProjectInfoData } from "@/components/project/ProjectInfoDialog";
import { projectMgmtApi, ProgressListFilter, ProgressRow } from "@/lib/api/projectMgmt";
@@ -78,13 +77,23 @@ const CATEGORY_GROUP = "0000167"; // 주문유형
const PRODUCT_GROUP = "0000001"; // 제품구분
// wace L229: 시스템년도 ±4 (운영판은 sysYear-4 ~ sysYear). RPS는 sysYear±4로 여유.
const YEAR_OPTIONS = (() => {
const YEAR_OPTIONS: SmartSelectOption[] = (() => {
const cur = new Date().getFullYear();
const arr: string[] = [];
for (let y = cur + 4; y >= cur - 4; y--) arr.push(String(y));
const arr: SmartSelectOption[] = [];
for (let y = cur + 4; y >= cur - 4; y--) arr.push({ code: String(y), label: String(y) });
return arr;
})();
const AREA_OPTIONS: SmartSelectOption[] = [
{ code: "국내", label: "국내" },
{ code: "해외", label: "해외" },
];
const PAID_OPTIONS: SmartSelectOption[] = [
{ code: "유상", label: "유상" },
{ code: "무상", label: "무상" },
];
const EMPTY_FILTER: ProgressListFilter = {
Year: "", project_nos: "", category_cd: "", customer_objid: "", product: "",
contract_start_date: "", contract_end_date: "",
@@ -137,117 +146,98 @@ export default function ProjectProgressPage() {
};
return (
<div className="flex flex-col h-full">
{/* 검색폼 — wace projectMgmtWbsList3.jsp:222-313 활성 11필드 */}
<div className="border-b bg-card px-4 py-3">
<div className="grid grid-cols-6 gap-3 text-sm">
{/* 1행 */}
<Field label="년도">
<select
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
value={filter.Year ?? ""}
onChange={(e) => setFilter({ ...filter, Year: e.target.value })}
>
<option value=""></option>
{YEAR_OPTIONS.map((y) => <option key={y} value={y}>{y}</option>)}
</select>
</Field>
<Field label="프로젝트번호">
<SmartSelect
options={projectNoOptions}
value={filter.project_nos ?? ""}
onValueChange={(v) => setFilter({ ...filter, project_nos: v })}
placeholder="전체"
/>
</Field>
<Field label="주문유형">
<CommCodeSelect
groupId={CATEGORY_GROUP}
value={filter.category_cd ?? ""}
onValueChange={(v) => setFilter({ ...filter, category_cd: v })}
/>
</Field>
<Field label="고객사">
<CustomerSelect
value={filter.customer_objid ?? ""}
onValueChange={(v) => setFilter({ ...filter, customer_objid: v })}
/>
</Field>
<Field label="제품구분">
<CommCodeSelect
groupId={PRODUCT_GROUP}
value={filter.product ?? ""}
onValueChange={(v) => setFilter({ ...filter, product: v })}
/>
</Field>
<Field label="요청납기일">
<div className="flex items-center gap-1">
<Input type="date" value={filter.contract_start_date ?? ""} onChange={(e) => setFilter({ ...filter, contract_start_date: e.target.value })} />
<span className="text-xs text-muted-foreground">~</span>
<Input type="date" value={filter.contract_end_date ?? ""} onChange={(e) => setFilter({ ...filter, contract_end_date: e.target.value })} />
</div>
</Field>
<div className="flex flex-col h-full gap-2 p-2">
<PageHeader
loading={loading}
onSearch={fetchList}
onReset={handleReset}
/>
{/* 2행 */}
<Field label="국내/해외">
<select
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
value={filter.area_cd ?? ""}
onChange={(e) => setFilter({ ...filter, area_cd: e.target.value })}
>
<option value=""></option>
<option value="국내"></option>
<option value="해외"></option>
</select>
</Field>
<Field label="유/무상">
<select
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
value={filter.free_of_charge ?? ""}
onChange={(e) => setFilter({ ...filter, free_of_charge: e.target.value })}
>
<option value=""></option>
<option value="유상"></option>
<option value="무상"></option>
</select>
</Field>
<Field label="품번">
<PartSelect
mode="partNo"
value={filter.search_partObjId ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_partObjId: v })}
/>
</Field>
<Field label="품명">
<PartSelect
mode="partName"
value={filter.search_partObjId ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_partObjId: v })}
/>
</Field>
<Field label="S/N">
<Input
value={filter.serial_no ?? ""}
onChange={(e) => setFilter({ ...filter, serial_no: e.target.value })}
placeholder="S/N LIKE"
/>
</Field>
<CompactFilterBar totalText={<> {rows.length.toLocaleString()}</>}>
<CompactFilterField label="년도" width={100}>
<SmartSelect
options={YEAR_OPTIONS}
value={filter.Year ?? ""}
onValueChange={(v) => setFilter({ ...filter, Year: v })}
placeholder="전체"
/>
</CompactFilterField>
<CompactFilterField label="프로젝트번호" width={160}>
<SmartSelect
options={projectNoOptions}
value={filter.project_nos ?? ""}
onValueChange={(v) => setFilter({ ...filter, project_nos: v })}
placeholder="전체"
/>
</CompactFilterField>
<CompactFilterField label="주문유형" width={130}>
<CommCodeSelect
groupId={CATEGORY_GROUP}
value={filter.category_cd ?? ""}
onValueChange={(v) => setFilter({ ...filter, category_cd: v })}
/>
</CompactFilterField>
<CompactFilterField label="고객사" width={160}>
<CustomerSelect
value={filter.customer_objid ?? ""}
onValueChange={(v) => setFilter({ ...filter, customer_objid: v })}
/>
</CompactFilterField>
<CompactFilterField label="제품구분" width={130}>
<CommCodeSelect
groupId={PRODUCT_GROUP}
value={filter.product ?? ""}
onValueChange={(v) => setFilter({ ...filter, product: v })}
/>
</CompactFilterField>
<CompactFilterField label="국내/해외" width={100}>
<SmartSelect
options={AREA_OPTIONS}
value={filter.area_cd ?? ""}
onValueChange={(v) => setFilter({ ...filter, area_cd: v })}
placeholder="전체"
/>
</CompactFilterField>
<CompactFilterField label="유/무상" width={100}>
<SmartSelect
options={PAID_OPTIONS}
value={filter.free_of_charge ?? ""}
onValueChange={(v) => setFilter({ ...filter, free_of_charge: v })}
placeholder="전체"
/>
</CompactFilterField>
<CompactFilterField label="품번" width={130}>
<PartSelect
mode="partNo"
value={filter.search_partObjId ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_partObjId: v })}
/>
</CompactFilterField>
<CompactFilterField label="품명" width={150}>
<PartSelect
mode="partName"
value={filter.search_partObjId ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_partObjId: v })}
/>
</CompactFilterField>
<CompactFilterField label="S/N" width={120}>
<Input
value={filter.serial_no ?? ""}
onChange={(e) => setFilter({ ...filter, serial_no: e.target.value })}
placeholder="S/N LIKE"
/>
</CompactFilterField>
<CompactFilterField label="요청납기일" width={280}>
<CompactDateRange
from={filter.contract_start_date ?? ""}
setFrom={(v) => setFilter({ ...filter, contract_start_date: v })}
to={filter.contract_end_date ?? ""}
setTo={(v) => setFilter({ ...filter, contract_end_date: v })}
/>
</CompactFilterField>
</CompactFilterBar>
{/* 액션 */}
<div className="flex items-end justify-end gap-2">
<Button variant="outline" size="sm" onClick={handleReset}>
<RotateCcw className="h-4 w-4" /><span className="ml-1"></span>
</Button>
<Button size="sm" onClick={fetchList} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
<span className="ml-1"></span>
</Button>
</div>
</div>
</div>
{/* 그리드 (8그룹 18셀 평탄화) */}
<div className="flex-1 min-h-0 p-2">
<div className="flex-1 min-h-0">
<DataGrid
columns={columns}
data={rows}
@@ -262,12 +252,3 @@ export default function ProjectProgressPage() {
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<Label className="mb-1 block text-xs text-muted-foreground">{label}</Label>
{children}
</div>
);
}
@@ -12,11 +12,12 @@
import React, { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Search, Loader2, RotateCcw, Plus, Trash2 } from "lucide-react";
import { Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
import { Label } from "@/components/ui/label";
import { PageHeader } from "@/components/common/PageHeader";
import { CompactFilterBar, CompactFilterField } from "@/components/common/CompactFilterBar";
import { wbsTemplateApi, TemplateRow } from "@/lib/api/wbsTemplate";
import { WbsTemplateDialog } from "@/components/project/WbsTemplateDialog";
@@ -110,43 +111,33 @@ export default function WbsTemplatePage() {
);
return (
<div className="flex flex-col h-full">
{/* 검색폼 — wace wbsTemplateMngList.jsp:361-371 (제품구분 1필드) */}
<div className="border-b bg-card px-4 py-3">
<div className="flex items-end gap-4">
<div className="min-w-[260px]">
<Label className="mb-1 block text-xs text-muted-foreground"></Label>
<CommCodeSelect
groupId={PRODUCT_GROUP}
value={filterProduct}
onValueChange={setFilterProduct}
/>
</div>
<div className="ml-auto flex items-end gap-2">
<Button variant="outline" size="sm" onClick={handleReset}>
<RotateCcw className="h-4 w-4" /><span className="ml-1"></span>
</Button>
<Button size="sm" onClick={handleSearch} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
<span className="ml-1"></span>
</Button>
<Button size="sm" variant="default" onClick={handleRegist}>
<Plus className="h-4 w-4" /><span className="ml-1"></span>
</Button>
<Button
size="sm"
variant="destructive"
onClick={handleDelete}
disabled={checkedIds.length === 0}
>
<Trash2 className="h-4 w-4" /><span className="ml-1"></span>
</Button>
</div>
</div>
</div>
<div className="flex flex-col h-full gap-2 p-2">
<PageHeader
loading={loading}
onSearch={handleSearch}
onReset={handleReset}
actions={
<>
<Button size="sm" className="h-8 gap-1 text-xs" onClick={handleRegist}>
<Plus className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="destructive" className="h-8 gap-1 text-xs" onClick={handleDelete} disabled={checkedIds.length === 0}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>
} />
{/* 그리드 (5컬럼) */}
<div className="flex-1 min-h-0 p-2">
<CompactFilterBar totalText={<> {rows.length.toLocaleString()}</>}>
<CompactFilterField label="제품구분" width={200}>
<CommCodeSelect
groupId={PRODUCT_GROUP}
value={filterProduct}
onValueChange={setFilterProduct}
/>
</CompactFilterField>
</CompactFilterBar>
<div className="flex-1 min-h-0">
<DataGrid
columns={columns}
data={rows}
@@ -0,0 +1,7 @@
// 구매관리 > M-BOM 관리 — production/mbom 페이지 re-export.
// 사용자 요청 (2026-05-13): wace 운영판은 "생산관리_M-BOM관리" 1:1 이지만,
// 구매관리 메뉴 트리에서도 동일 화면 진입을 허용한다.
//
// menu_info: 100016 (purchase/mbom) + 100032 (production/mbom) — 둘 다 active.
export { default } from "../../production/mbom/page";
@@ -19,6 +19,9 @@ import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { CustomerSelect } from "@/components/common/CustomerSelect";
import { PartSelect } from "@/components/common/PartSelect";
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { PageHeader } from "@/components/common/PageHeader";
import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar";
import { ItemSearchDialog, ItemRow } from "@/components/common/ItemSearchDialog";
import { AttachmentDialog } from "@/components/common/AttachmentDialog";
import { EstimateMailDialog } from "@/components/sales/EstimateMailDialog";
@@ -472,112 +475,95 @@ export default function SalesEstimatePage() {
// ─── 렌더 ───────────────────────────────────────────────────
const apprStatusOpts: SmartSelectOption[] = [
{ code: "작성중", label: "작성중" },
{ code: "결재중", label: "결재중" },
{ code: "결재완료", label: "결재완료" },
{ code: "반려", label: "반려" },
{ code: "결재불필요", label: "결재불필요" },
];
const handleReset = () => setSearchForm({
category_cd: "", customer_objid: "",
search_partObjId: "", search_partName: "", search_serialNo: "",
appr_status: "",
receipt_start_date: "", receipt_end_date: "",
});
return (
<div className="flex h-full flex-col overflow-hidden p-4 gap-4">
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
{ConfirmDialogComponent}
{/* 헤더 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold"> _ </h1>
<p className="text-sm text-muted-foreground"> {rows.length}</p>
</div>
<div className="flex gap-2 flex-wrap">
<Button variant="outline" size="sm" onClick={fetchList} disabled={loading}>
{loading ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : <Search className="w-4 h-4 mr-1" />}
<PageHeader
loading={loading}
onSearch={fetchList}
onReset={handleReset}
actions={
<>
<Button size="sm" variant="destructive" className="h-8 gap-1 text-xs" onClick={handleDelete} disabled={!selected}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="destructive" onClick={handleDelete} disabled={!selected}>
<Trash2 className="w-4 h-4 mr-1" />
</Button>
<Button size="sm" onClick={() => { if (selected) openEdit(); else openCreate(); }}>
{selected ? <Pencil className="w-4 h-4 mr-1" /> : <Plus className="w-4 h-4 mr-1" />}
<Button size="sm" className="h-8 gap-1 text-xs" onClick={() => { if (selected) openEdit(); else openCreate(); }}>
{selected ? <Pencil className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
{selected ? "견적요청수정" : "견적요청등록"}
</Button>
<Button size="sm" variant="outline" onClick={openTemplateChoice} disabled={!selected}>
<Pencil className="w-4 h-4 mr-1" />
<Button size="sm" variant="outline" className="h-8 gap-1 text-xs" onClick={openTemplateChoice} disabled={!selected}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button size="sm" className="bg-sky-600 hover:bg-sky-700 text-white"
<Button size="sm" className="h-8 gap-1 bg-sky-600 hover:bg-sky-700 text-white text-xs"
onClick={handleAmaranthApproval} disabled={!selected}>
<Send className="w-4 h-4 mr-1" />
<Send className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="outline" disabled={!selected}
onClick={openMailDialog}>
<Button size="sm" variant="outline" className="h-8 gap-1 text-xs" disabled={!selected} onClick={openMailDialog}>
</Button>
<Button size="sm" variant="ghost"
onClick={() => setSearchForm({
category_cd: "", customer_objid: "",
search_partObjId: "", search_partName: "", search_serialNo: "",
appr_status: "",
receipt_start_date: "", receipt_end_date: "",
})}>
</Button>
</div>
</div>
</>
} />
{/* 검색 폼 — wace 원본 estimateList_new.jsp 활성 7개 */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-x-2 gap-y-1.5 p-2 border rounded-md bg-muted/30">
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<CompactFilterBar
totalText={<> {rows.length.toLocaleString()}</>}
>
<CompactFilterField label="주문유형" width={130}>
<CommCodeSelect groupId="0000167"
value={searchForm.category_cd}
onValueChange={(v) => setSearchForm({ ...searchForm, category_cd: v })}
className="h-8 text-xs" />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
onValueChange={(v) => setSearchForm({ ...searchForm, category_cd: v })} />
</CompactFilterField>
<CompactFilterField label="고객사" width={160}>
<CustomerSelect
value={searchForm.customer_objid}
onValueChange={(v) => setSearchForm({ ...searchForm, customer_objid: v })}
className="h-8 text-xs" />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
onValueChange={(v) => setSearchForm({ ...searchForm, customer_objid: v })} />
</CompactFilterField>
<CompactFilterField label="품번" width={130}>
<PartSelect mode="partNo"
value={searchForm.search_partObjId}
onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })}
className="h-8 text-xs" />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })} />
</CompactFilterField>
<CompactFilterField label="품명" width={150}>
<PartSelect mode="partName"
value={searchForm.search_partObjId}
onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })}
className="h-8 text-xs" />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground">S/N</Label>
<Input className="h-8 text-xs" value={searchForm.search_serialNo}
onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })} />
</CompactFilterField>
<CompactFilterField label="S/N" width={120}>
<Input value={searchForm.search_serialNo}
onChange={(e) => setSearchForm({ ...searchForm, search_serialNo: e.target.value })} />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<Select value={searchForm.appr_status || "all"}
onValueChange={(v) => setSearchForm({ ...searchForm, appr_status: v === "all" ? "" : v })}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="작성중"></SelectItem>
<SelectItem value="결재중"></SelectItem>
<SelectItem value="결재완료"></SelectItem>
<SelectItem value="반려"></SelectItem>
<SelectItem value="결재불필요"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<div className="flex gap-0.5 items-center">
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.receipt_start_date}
onChange={(e) => setSearchForm({ ...searchForm, receipt_start_date: e.target.value })} />
<span className="text-[11px] text-muted-foreground">~</span>
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.receipt_end_date}
onChange={(e) => setSearchForm({ ...searchForm, receipt_end_date: e.target.value })} />
</div>
</div>
</div>
</CompactFilterField>
<CompactFilterField label="결재상태" width={120}>
<SmartSelect
options={apprStatusOpts}
value={searchForm.appr_status}
onValueChange={(v) => setSearchForm({ ...searchForm, appr_status: v })}
placeholder="전체"
/>
</CompactFilterField>
<CompactFilterField label="접수일" width={280}>
<CompactDateRange
from={searchForm.receipt_start_date}
setFrom={(v) => setSearchForm({ ...searchForm, receipt_start_date: v })}
to={searchForm.receipt_end_date}
setTo={(v) => setSearchForm({ ...searchForm, receipt_end_date: v })}
/>
</CompactFilterField>
</CompactFilterBar>
{/* 그리드 — 첫 컬럼 체크박스 (행 아무 셀 클릭으로 단일 선택, 클립/폴더 등 팝업 컬럼은 stopPropagation으로 제외) */}
<DataGrid
@@ -19,6 +19,8 @@ import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { CustomerSelect } from "@/components/common/CustomerSelect";
import { PartSelect } from "@/components/common/PartSelect";
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
import { PageHeader } from "@/components/common/PageHeader";
import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar";
import { ItemSearchDialog, ItemRow } from "@/components/common/ItemSearchDialog";
import { AttachmentDialog } from "@/components/common/AttachmentDialog";
import { OrderFormViewDialog } from "@/components/sales/OrderFormViewDialog";
@@ -524,120 +526,97 @@ export default function SalesOrderPage() {
}), { qty: 0, supply: 0, vat: 0, total: 0 });
}, [form.items]);
const handleReset = () => setSearchForm({
category_cd: "", search_poNo: "", customer_objid: "",
search_partObjId: "", search_partName: "", search_serialNo: "", contract_result: "",
order_start_date: "", order_end_date: "",
due_start_date: "", due_end_date: "",
});
return (
<div className="flex h-full flex-col overflow-hidden p-4 gap-4">
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
{ConfirmDialogComponent}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="text-sm text-muted-foreground"> {rows.length}</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={fetchList} disabled={loading}>
{loading ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : <Search className="w-4 h-4 mr-1" />}
</Button>
<Button size="sm" onClick={() => { if (selected) openEdit(); else openCreate(); }}>
{selected ? <Pencil className="w-4 h-4 mr-1" /> : <Plus className="w-4 h-4 mr-1" />}
<PageHeader
loading={loading}
onSearch={fetchList}
onReset={handleReset}
actions={
<>
<Button size="sm" className="h-8 gap-1 text-xs" onClick={() => { if (selected) openEdit(); else openCreate(); }}>
{selected ? <Pencil className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
{selected ? "수주수정" : "수주입력"}
</Button>
<Button size="sm" className="bg-emerald-600 hover:bg-emerald-700 text-white" onClick={handleConfirmOrder} disabled={!selected}>
<CheckCircle2 className="w-4 h-4 mr-1" />
<Button size="sm" className="h-8 gap-1 bg-emerald-600 hover:bg-emerald-700 text-white text-xs" onClick={handleConfirmOrder} disabled={!selected}>
<CheckCircle2 className="h-3.5 w-3.5" />
</Button>
<Button size="sm" className="bg-rose-600 hover:bg-rose-700 text-white" onClick={handleCancelOrder} disabled={!selected}>
<XCircle className="w-4 h-4 mr-1" />
<Button size="sm" className="h-8 gap-1 bg-rose-600 hover:bg-rose-700 text-white text-xs" onClick={handleCancelOrder} disabled={!selected}>
<XCircle className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="outline" onClick={handleCopyOrder} disabled={!selected}>
<Copy className="w-4 h-4 mr-1" />
<Button size="sm" variant="outline" className="h-8 gap-1 text-xs" onClick={handleCopyOrder} disabled={!selected}>
<Copy className="h-3.5 w-3.5" />
</Button>
<Button size="sm" className="bg-sky-600 hover:bg-sky-700 text-white" onClick={handleAmaranthApproval} disabled={!selected}>
<Send className="w-4 h-4 mr-1" />
<Button size="sm" className="h-8 gap-1 bg-sky-600 hover:bg-sky-700 text-white text-xs" onClick={handleAmaranthApproval} disabled={!selected}>
<Send className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="destructive" onClick={handleDelete} disabled={!selected}>
<Trash2 className="w-4 h-4 mr-1" />
<Button size="sm" variant="destructive" className="h-8 gap-1 text-xs" onClick={handleDelete} disabled={!selected}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="ghost"
onClick={() => setSearchForm({
category_cd: "", search_poNo: "", customer_objid: "",
search_partObjId: "", search_partName: "", search_serialNo: "", contract_result: "",
order_start_date: "", order_end_date: "",
due_start_date: "", due_end_date: "",
})}>
</Button>
</div>
</div>
</>
} />
{/* 검색 폼 — wace 원본 orderMgmtList.jsp 활성 9개 */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-x-2 gap-y-1.5 p-2 border rounded-md bg-muted/30">
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<CompactFilterBar totalText={<> {rows.length.toLocaleString()}</>}>
<CompactFilterField label="주문유형" width={130}>
<CommCodeSelect groupId="0000167"
value={searchForm.category_cd}
onValueChange={(v) => setSearchForm({ ...searchForm, category_cd: v })}
className="h-8 text-xs" />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<Input className="h-8 text-xs" placeholder="발주번호 검색"
onValueChange={(v) => setSearchForm({ ...searchForm, category_cd: v })} />
</CompactFilterField>
<CompactFilterField label="발주번호" width={140}>
<Input placeholder="발주번호 검색"
value={searchForm.search_poNo}
onChange={(e) => setSearchForm({ ...searchForm, search_poNo: e.target.value })} />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
</CompactFilterField>
<CompactFilterField label="고객사" width={160}>
<CustomerSelect
value={searchForm.customer_objid}
onValueChange={(v) => setSearchForm({ ...searchForm, customer_objid: v })}
className="h-8 text-xs" />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
onValueChange={(v) => setSearchForm({ ...searchForm, customer_objid: v })} />
</CompactFilterField>
<CompactFilterField label="품번" width={130}>
<PartSelect mode="partNo"
value={searchForm.search_partObjId}
onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })}
className="h-8 text-xs" />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })} />
</CompactFilterField>
<CompactFilterField label="품명" width={150}>
<PartSelect mode="partName"
value={searchForm.search_partObjId}
onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })}
className="h-8 text-xs" />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground">S/N</Label>
<Input className="h-8 text-xs" value={searchForm.search_serialNo}
onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })} />
</CompactFilterField>
<CompactFilterField label="S/N" width={120}>
<Input value={searchForm.search_serialNo}
onChange={(e) => setSearchForm({ ...searchForm, search_serialNo: e.target.value })} />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
</CompactFilterField>
<CompactFilterField label="수주상태" width={130}>
<CommCodeSelect groupId="0000963"
value={searchForm.contract_result}
onValueChange={(v) => setSearchForm({ ...searchForm, contract_result: v })}
className="h-8 text-xs" />
</div>
{/* 2줄 */}
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<div className="flex gap-0.5 items-center">
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.order_start_date}
onChange={(e) => setSearchForm({ ...searchForm, order_start_date: e.target.value })} />
<span className="text-[11px] text-muted-foreground">~</span>
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.order_end_date}
onChange={(e) => setSearchForm({ ...searchForm, order_end_date: e.target.value })} />
</div>
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<div className="flex gap-0.5 items-center">
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.due_start_date}
onChange={(e) => setSearchForm({ ...searchForm, due_start_date: e.target.value })} />
<span className="text-[11px] text-muted-foreground">~</span>
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.due_end_date}
onChange={(e) => setSearchForm({ ...searchForm, due_end_date: e.target.value })} />
</div>
</div>
</div>
onValueChange={(v) => setSearchForm({ ...searchForm, contract_result: v })} />
</CompactFilterField>
<CompactFilterField label="발주일" width={280}>
<CompactDateRange
from={searchForm.order_start_date}
setFrom={(v) => setSearchForm({ ...searchForm, order_start_date: v })}
to={searchForm.order_end_date}
setTo={(v) => setSearchForm({ ...searchForm, order_end_date: v })}
/>
</CompactFilterField>
<CompactFilterField label="요청납기" width={280}>
<CompactDateRange
from={searchForm.due_start_date}
setFrom={(v) => setSearchForm({ ...searchForm, due_start_date: v })}
to={searchForm.due_end_date}
setTo={(v) => setSearchForm({ ...searchForm, due_end_date: v })}
/>
</CompactFilterField>
</CompactFilterBar>
<DataGrid
columns={gridColumns}
@@ -19,6 +19,8 @@ import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { CustomerSelect } from "@/components/common/CustomerSelect";
import { PartSelect } from "@/components/common/PartSelect";
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
import { PageHeader } from "@/components/common/PageHeader";
import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar";
import { ProjectInfoDialog, ProjectInfoData } from "@/components/project/ProjectInfoDialog";
import { salesSaleApi, RevenueListRow, DeadlineInfoBody } from "@/lib/api/salesSale";
@@ -176,127 +178,99 @@ export default function SalesRevenuePage() {
}
};
const handleReset = () => setSearchForm({
orderType: "", poNo: "", customer_objid: "",
productType: "", search_partObjId: "", nation: "",
serialNo: "",
salesDeadlineFrom: "", salesDeadlineTo: "",
orderDateFrom: "", orderDateTo: "",
shippingDateFrom: "", shippingDateTo: "",
});
return (
<div className="flex h-full flex-col overflow-hidden p-4 gap-4">
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
{ConfirmDialogComponent}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="text-sm text-muted-foreground"> {rows.length} (/ )</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={fetchList} disabled={loading}>
{loading ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : <Search className="w-4 h-4 mr-1" />}
<PageHeader
loading={loading}
onSearch={fetchList}
onReset={handleReset}
actions={
<>
<Button size="sm" className="h-8 gap-1 bg-blue-600 hover:bg-blue-700 text-white text-xs" onClick={openDeadline} disabled={!selected}>
<FileCheck2 className="h-3.5 w-3.5" />
</Button>
<Button size="sm" className="bg-blue-600 hover:bg-blue-700 text-white" onClick={openDeadline} disabled={!selected}>
<FileCheck2 className="w-4 h-4 mr-1" />
<Button size="sm" className="h-8 gap-1 bg-emerald-600 hover:bg-emerald-700 text-white text-xs" onClick={handleConfirmDeadline} disabled={checkedIds.length === 0}>
<CheckCircle2 className="h-3.5 w-3.5" />
</Button>
<Button size="sm" className="bg-emerald-600 hover:bg-emerald-700 text-white" onClick={handleConfirmDeadline} disabled={checkedIds.length === 0}>
<CheckCircle2 className="w-4 h-4 mr-1" />
</Button>
<Button size="sm" variant="ghost"
onClick={() => setSearchForm({
orderType: "", poNo: "", customer_objid: "",
productType: "", search_partObjId: "", nation: "",
serialNo: "",
salesDeadlineFrom: "", salesDeadlineTo: "",
orderDateFrom: "", orderDateTo: "",
shippingDateFrom: "", shippingDateTo: "",
})}>
</Button>
</div>
</div>
</>
} />
{/* 검색 폼 — wace 원본 revenueMgmtList.jsp 활성 11개 */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-x-2 gap-y-1.5 p-2 border rounded-md bg-muted/30">
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<CompactFilterBar totalText={<> {rows.length.toLocaleString()} (/ )</>}>
<CompactFilterField label="주문유형" width={130}>
<CommCodeSelect groupId="0000167"
value={searchForm.orderType}
onValueChange={(v) => setSearchForm({ ...searchForm, orderType: v })}
className="h-8 text-xs" />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<Input className="h-8 text-xs" placeholder="발주번호 검색"
onValueChange={(v) => setSearchForm({ ...searchForm, orderType: v })} />
</CompactFilterField>
<CompactFilterField label="발주번호" width={140}>
<Input placeholder="발주번호 검색"
value={searchForm.poNo}
onChange={(e) => setSearchForm({ ...searchForm, poNo: e.target.value })} />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
</CompactFilterField>
<CompactFilterField label="고객사" width={160}>
<CustomerSelect
value={searchForm.customer_objid}
onValueChange={(v) => setSearchForm({ ...searchForm, customer_objid: v })}
className="h-8 text-xs" />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
onValueChange={(v) => setSearchForm({ ...searchForm, customer_objid: v })} />
</CompactFilterField>
<CompactFilterField label="제품구분" width={130}>
<CommCodeSelect groupId="0000001"
value={searchForm.productType}
onValueChange={(v) => setSearchForm({ ...searchForm, productType: v })}
className="h-8 text-xs" />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
onValueChange={(v) => setSearchForm({ ...searchForm, productType: v })} />
</CompactFilterField>
<CompactFilterField label="품번" width={130}>
<PartSelect mode="partNo"
value={searchForm.search_partObjId}
onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })}
className="h-8 text-xs" />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })} />
</CompactFilterField>
<CompactFilterField label="품명" width={150}>
<PartSelect mode="partName"
value={searchForm.search_partObjId}
onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })}
className="h-8 text-xs" />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground">/</Label>
onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })} />
</CompactFilterField>
<CompactFilterField label="국내/해외" width={120}>
<CommCodeSelect groupId="0001219"
value={searchForm.nation}
onValueChange={(v) => setSearchForm({ ...searchForm, nation: v })}
className="h-8 text-xs" />
</div>
{/* 2줄 */}
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground">S/N</Label>
<Input className="h-8 text-xs"
value={searchForm.serialNo}
onValueChange={(v) => setSearchForm({ ...searchForm, nation: v })} />
</CompactFilterField>
<CompactFilterField label="S/N" width={120}>
<Input value={searchForm.serialNo}
onChange={(e) => setSearchForm({ ...searchForm, serialNo: e.target.value })} />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<div className="flex gap-0.5 items-center">
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.salesDeadlineFrom}
onChange={(e) => setSearchForm({ ...searchForm, salesDeadlineFrom: e.target.value })} />
<span className="text-[11px] text-muted-foreground">~</span>
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.salesDeadlineTo}
onChange={(e) => setSearchForm({ ...searchForm, salesDeadlineTo: e.target.value })} />
</div>
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<div className="flex gap-0.5 items-center">
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.orderDateFrom}
onChange={(e) => setSearchForm({ ...searchForm, orderDateFrom: e.target.value })} />
<span className="text-[11px] text-muted-foreground">~</span>
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.orderDateTo}
onChange={(e) => setSearchForm({ ...searchForm, orderDateTo: e.target.value })} />
</div>
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<div className="flex gap-0.5 items-center">
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.shippingDateFrom}
onChange={(e) => setSearchForm({ ...searchForm, shippingDateFrom: e.target.value })} />
<span className="text-[11px] text-muted-foreground">~</span>
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.shippingDateTo}
onChange={(e) => setSearchForm({ ...searchForm, shippingDateTo: e.target.value })} />
</div>
</div>
</div>
</CompactFilterField>
<CompactFilterField label="매출마감" width={280}>
<CompactDateRange
from={searchForm.salesDeadlineFrom}
setFrom={(v) => setSearchForm({ ...searchForm, salesDeadlineFrom: v })}
to={searchForm.salesDeadlineTo}
setTo={(v) => setSearchForm({ ...searchForm, salesDeadlineTo: v })}
/>
</CompactFilterField>
<CompactFilterField label="발주일" width={280}>
<CompactDateRange
from={searchForm.orderDateFrom}
setFrom={(v) => setSearchForm({ ...searchForm, orderDateFrom: v })}
to={searchForm.orderDateTo}
setTo={(v) => setSearchForm({ ...searchForm, orderDateTo: v })}
/>
</CompactFilterField>
<CompactFilterField label="출하일" width={280}>
<CompactDateRange
from={searchForm.shippingDateFrom}
setFrom={(v) => setSearchForm({ ...searchForm, shippingDateFrom: v })}
to={searchForm.shippingDateTo}
setTo={(v) => setSearchForm({ ...searchForm, shippingDateTo: v })}
/>
</CompactFilterField>
</CompactFilterBar>
<DataGrid
columns={columns}
@@ -17,6 +17,9 @@ import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { CustomerSelect } from "@/components/common/CustomerSelect";
import { PartSelect } from "@/components/common/PartSelect";
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { PageHeader } from "@/components/common/PageHeader";
import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar";
import { ProjectInfoDialog, ProjectInfoData } from "@/components/project/ProjectInfoDialog";
import { salesSaleApi, SaleListRow, SaleRegisterBody } from "@/lib/api/salesSale";
@@ -167,116 +170,91 @@ export default function SalesSalePage() {
} finally { setSaving(false); }
};
return (
<div className="flex h-full flex-col overflow-hidden p-4 gap-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="text-sm text-muted-foreground"> {rows.length} ( )</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={fetchList} disabled={loading}>
{loading ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : <Search className="w-4 h-4 mr-1" />}
</Button>
<Button size="sm" className="bg-emerald-600 hover:bg-emerald-700 text-white" onClick={openRegister} disabled={!selected}>
<Truck className="w-4 h-4 mr-1" />/
</Button>
<Button size="sm" variant="ghost"
onClick={() => setSearchForm({
orderType: "", poNo: "", customer_objid: "", search_partObjId: "",
serialNo: "", shippingStatus: "", salesStatus: "",
orderDateFrom: "", orderDateTo: "",
shippingDateFrom: "", shippingDateTo: "",
})}>
</Button>
</div>
</div>
const shippingStatusOpts: SmartSelectOption[] = [
{ code: "PENDING", label: "대기" },
{ code: "COMPLETED", label: "완료" },
{ code: "CANCELLED", label: "취소" },
];
{/* 검색 폼 — wace 원본 salesMgmtList.jsp 재현 (1줄 7개 / 2줄 3개) */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-x-2 gap-y-1.5 p-2 border rounded-md bg-muted/30">
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
const handleReset = () => setSearchForm({
orderType: "", poNo: "", customer_objid: "", search_partObjId: "",
serialNo: "", shippingStatus: "", salesStatus: "",
orderDateFrom: "", orderDateTo: "",
shippingDateFrom: "", shippingDateTo: "",
});
return (
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
<PageHeader
loading={loading}
onSearch={fetchList}
onReset={handleReset}
actions={
<Button size="sm" className="h-8 gap-1 bg-emerald-600 hover:bg-emerald-700 text-white text-xs" onClick={openRegister} disabled={!selected}>
<Truck className="h-3.5 w-3.5" />/
</Button>
} />
<CompactFilterBar totalText={<> {rows.length.toLocaleString()} ( )</>}>
<CompactFilterField label="주문유형" width={130}>
<CommCodeSelect groupId="0000167"
value={searchForm.orderType}
onValueChange={(v) => setSearchForm({ ...searchForm, orderType: v })}
className="h-8 text-xs" />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<Input className="h-8 text-xs" placeholder="발주번호 검색"
onValueChange={(v) => setSearchForm({ ...searchForm, orderType: v })} />
</CompactFilterField>
<CompactFilterField label="발주번호" width={140}>
<Input placeholder="발주번호 검색"
value={searchForm.poNo}
onChange={(e) => setSearchForm({ ...searchForm, poNo: e.target.value })} />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
</CompactFilterField>
<CompactFilterField label="고객사" width={160}>
<CustomerSelect
value={searchForm.customer_objid}
onValueChange={(v) => setSearchForm({ ...searchForm, customer_objid: v })}
className="h-8 text-xs" />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
onValueChange={(v) => setSearchForm({ ...searchForm, customer_objid: v })} />
</CompactFilterField>
<CompactFilterField label="품번" width={130}>
<PartSelect mode="partNo"
value={searchForm.search_partObjId}
onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })}
className="h-8 text-xs" />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })} />
</CompactFilterField>
<CompactFilterField label="품명" width={150}>
<PartSelect mode="partName"
value={searchForm.search_partObjId}
onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })}
className="h-8 text-xs" />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground">S/N</Label>
<Input className="h-8 text-xs"
value={searchForm.serialNo}
onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })} />
</CompactFilterField>
<CompactFilterField label="S/N" width={120}>
<Input value={searchForm.serialNo}
onChange={(e) => setSearchForm({ ...searchForm, serialNo: e.target.value })} />
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<Select value={searchForm.shippingStatus || "all"}
onValueChange={(v) => setSearchForm({ ...searchForm, shippingStatus: v === "all" ? "" : v })}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="PENDING"></SelectItem>
<SelectItem value="COMPLETED"></SelectItem>
<SelectItem value="CANCELLED"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 2줄 */}
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<div className="flex gap-0.5 items-center">
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.orderDateFrom}
onChange={(e) => setSearchForm({ ...searchForm, orderDateFrom: e.target.value })} />
<span className="text-[11px] text-muted-foreground">~</span>
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.orderDateTo}
onChange={(e) => setSearchForm({ ...searchForm, orderDateTo: e.target.value })} />
</div>
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
<div className="flex gap-0.5 items-center">
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.shippingDateFrom}
onChange={(e) => setSearchForm({ ...searchForm, shippingDateFrom: e.target.value })} />
<span className="text-[11px] text-muted-foreground">~</span>
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.shippingDateTo}
onChange={(e) => setSearchForm({ ...searchForm, shippingDateTo: e.target.value })} />
</div>
</div>
<div>
<Label className="text-[11px] mb-0.5 block text-muted-foreground"></Label>
</CompactFilterField>
<CompactFilterField label="출하지시상태" width={130}>
<SmartSelect
options={shippingStatusOpts}
value={searchForm.shippingStatus}
onValueChange={(v) => setSearchForm({ ...searchForm, shippingStatus: v })}
placeholder="전체"
/>
</CompactFilterField>
<CompactFilterField label="판매상태" width={130}>
<CommCodeSelect groupId="0900207"
value={searchForm.salesStatus}
onValueChange={(v) => setSearchForm({ ...searchForm, salesStatus: v })}
className="h-8 text-xs" />
</div>
</div>
onValueChange={(v) => setSearchForm({ ...searchForm, salesStatus: v })} />
</CompactFilterField>
<CompactFilterField label="발주일" width={280}>
<CompactDateRange
from={searchForm.orderDateFrom}
setFrom={(v) => setSearchForm({ ...searchForm, orderDateFrom: v })}
to={searchForm.orderDateTo}
setTo={(v) => setSearchForm({ ...searchForm, orderDateTo: v })}
/>
</CompactFilterField>
<CompactFilterField label="출하일" width={280}>
<CompactDateRange
from={searchForm.shippingDateFrom}
setFrom={(v) => setSearchForm({ ...searchForm, shippingDateFrom: v })}
to={searchForm.shippingDateTo}
setTo={(v) => setSearchForm({ ...searchForm, shippingDateTo: v })}
/>
</CompactFilterField>
</CompactFilterBar>
<DataGrid
columns={columns}
@@ -0,0 +1,120 @@
"use client";
/**
* CompactFilterBar — 컴팩트 검색 필터바 공용 컴포넌트.
*
* customer-cs/cs 페이지 패턴 1:1 추출:
* - 외곽 `rounded-md border bg-muted/20 p-2` + flex-wrap (좁아도 자동 줄바꿈)
* - 컨트롤 높이 h-7, 폰트 text-xs (기존 h-9 보다 컴팩트)
* - 우측에 검색/초기화 버튼 + 합계 텍스트
*
* 사용 예:
* <CompactFilterBar
* onSearch={() => fetchList()}
* onReset={() => handleReset()}
* totalText={`총 ${total}건`}
* >
* <CompactFilterField label="고객사" width={140}>
* <CustomerSelect ... />
* </CompactFilterField>
* <CompactFilterField label="품번" width={120}>
* <Input ... className="h-7 text-xs" />
* </CompactFilterField>
* </CompactFilterBar>
*
* 원칙:
* - 모든 RPS 메뉴의 검색 폼은 이 컴포넌트를 사용. 자체 검색 폼 구성 금지.
* - SmartSelect / CustomerSelect / CommCodeSelect / Input 모두 h-7 + text-xs 자동 적용.
*/
import React from "react";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
interface CompactFilterBarProps {
children: React.ReactNode;
/** 우측에 표시할 합계/통계 텍스트 (예: "총 12,345건 · 합계 12,000,000원") */
totalText?: React.ReactNode;
className?: string;
}
export function CompactFilterBar({ children, totalText, className }: CompactFilterBarProps) {
// 검색/초기화 버튼은 PageHeader 의 우측 액션 영역으로 통합.
// CompactFilterBar 는 필드 컨테이너 + 합계 텍스트만 담당.
return (
<div
className={cn(
"flex flex-shrink-0 flex-wrap items-end gap-2 rounded-md border bg-muted/20 p-2",
className,
)}
>
{children}
{totalText != null && (
<span className="ml-auto text-[11px] text-muted-foreground">{totalText}</span>
)}
</div>
);
}
interface CompactFilterFieldProps {
label: string;
/** 컨트롤 박스 폭(px). 기본 120. */
width?: number;
/** 폭 자동 (자식이 100% 폭을 차지하지 않게 할 때 유용) */
flex?: boolean;
children: React.ReactNode;
className?: string;
}
export function CompactFilterField({
label, width = 120, flex, children, className,
}: CompactFilterFieldProps) {
return (
<div className={cn("space-y-1", className)} style={flex ? undefined : { width }}>
<Label className="text-[11px] text-muted-foreground">{label}</Label>
<div className="[&_input]:h-7 [&_input]:text-xs [&_button[role=combobox]]:h-7 [&_button[role=combobox]]:text-xs [&_[data-slot=select-trigger]]:h-7 [&_[data-slot=select-trigger]]:text-xs">
{children}
</div>
</div>
);
}
/**
* 날짜 범위 입력 (CompactFilterField 자식으로 사용).
*
* <CompactFilterField label="접수일" width={280}>
* <CompactDateRange
* from={fromDate} setFrom={setFromDate}
* to={toDate} setTo={setToDate}
* />
* </CompactFilterField>
*/
export function CompactDateRange({
from, setFrom, to, setTo, disabled,
}: {
from: string;
setFrom: (v: string) => void;
to: string;
setTo: (v: string) => void;
disabled?: boolean;
}) {
return (
<div className="flex items-center gap-1">
<input
type="date"
className="h-7 w-[125px] rounded-md border bg-background px-2 text-xs disabled:cursor-not-allowed disabled:opacity-50"
value={from}
onChange={(e) => setFrom(e.target.value)}
disabled={disabled}
/>
<span className="text-xs text-muted-foreground">~</span>
<input
type="date"
className="h-7 w-[125px] rounded-md border bg-background px-2 text-xs disabled:cursor-not-allowed disabled:opacity-50"
value={to}
onChange={(e) => setTo(e.target.value)}
disabled={disabled}
/>
</div>
);
}
+134
View File
@@ -0,0 +1,134 @@
"use client";
/**
* PageHeader — 페이지 상단 메뉴명 + 설명 + 액션 슬롯.
*
* customer-cs/cs 페이지 패턴 1:1 추출. 모든 RPS 메뉴 페이지의 상단에 의무 배치.
*
* 자동 매칭 (탭 시스템 대응):
* - RPS 는 탭 기반이라 usePathname() 이 /main 으로 고정됨.
* - useTabStore 의 활성 탭 adminUrl → /COMPANY_NN prefix 제거 → menu_info.menu_url 매칭.
* - useCurrent2ndLevelMenuObjid 와 동일 패턴.
*
* 명시 지정:
* <PageHeader title="M-BOM 관리" description="생산용 BOM 트리" actions={...} />
*
* 원칙:
* - 모든 page.tsx 의 최상위 자식으로 <PageHeader /> 를 배치한다.
* - menu_info 에 등록만 되어 있으면 props 없이도 자동 매칭.
*/
import React from "react";
import { usePathname } from "next/navigation";
import { useMenu } from "@/contexts/MenuContext";
import { useTabStore, selectTabs, selectActiveTabId } from "@/stores/tabStore";
import type { MenuItem } from "@/lib/api/menu";
import { Button } from "@/components/ui/button";
import { Search, Loader2, RotateCcw } from "lucide-react";
import { cn } from "@/lib/utils";
interface PageHeaderProps {
title?: string;
description?: string;
/** 업무 액션 슬롯 (등록/삭제/상신 등). 검색·초기화는 onSearch/onReset 로 전달. */
actions?: React.ReactNode;
/** 검색 핸들러. 지정 시 우측에 검색 버튼 자동 렌더. */
onSearch?: () => void;
/** 초기화 핸들러. 지정 시 우측에 초기화 버튼 자동 렌더. */
onReset?: () => void;
/** 검색 중 로딩 표시 */
loading?: boolean;
searchLabel?: string;
resetLabel?: string;
className?: string;
}
function stripCompanyPrefix(p: string): string {
return p.replace(/^\/COMPANY_\d+/, "") || "/";
}
function findByUrl(menus: MenuItem[], strippedUrl: string): MenuItem | null {
// menu_info.menu_url 이 /COMPANY_16/... 으로 저장되어 있으므로 양쪽 비교
for (const m of menus) {
if (!m.menu_url) continue;
if (m.menu_url === strippedUrl) return m;
if (stripCompanyPrefix(m.menu_url) === strippedUrl) return m;
}
let best: MenuItem | null = null;
let bestLen = 0;
for (const m of menus) {
if (!m.menu_url) continue;
const stripped = stripCompanyPrefix(m.menu_url);
if (strippedUrl.startsWith(stripped) && stripped.length > bestLen) {
best = m;
bestLen = stripped.length;
}
}
return best;
}
export function PageHeader({
title, description, actions, onSearch, onReset, loading,
searchLabel = "검색", resetLabel = "초기화", className,
}: PageHeaderProps) {
const pathname = usePathname() ?? "";
const tabs = useTabStore(selectTabs);
const activeTabId = useTabStore(selectActiveTabId);
let menu: MenuItem | null = null;
try {
const { userMenus, adminMenus } = useMenu();
// RPS 탭 시스템: pathname=/main 이면 활성 탭의 adminUrl 사용
let targetUrl = stripCompanyPrefix(pathname);
const isRootLike = pathname === "/main" || pathname === "/" || pathname === "";
if (isRootLike) {
const activeTab = tabs.find((t: any) => t.id === activeTabId);
if (activeTab?.adminUrl) {
targetUrl = stripCompanyPrefix(activeTab.adminUrl);
}
}
menu = findByUrl(userMenus as MenuItem[], targetUrl) ?? findByUrl(adminMenus as MenuItem[], targetUrl);
} catch {
/* Provider 밖 — 자동 매칭 생략 */
}
const resolvedTitle = title ?? menu?.menu_name_kor ?? "";
const resolvedDesc = description ?? menu?.menu_desc ?? "";
const hasSearchButtons = !!(onSearch || onReset);
if (!resolvedTitle && !resolvedDesc && !actions && !hasSearchButtons) return null;
return (
<div className={cn("flex flex-shrink-0 items-end justify-between gap-3 border-b pb-3", className)}>
<div>
{resolvedTitle && (
<h1 className="text-xl font-bold tracking-tight">{resolvedTitle}</h1>
)}
{resolvedDesc && (
<p className="text-xs text-muted-foreground">{resolvedDesc}</p>
)}
</div>
{(actions || hasSearchButtons) && (
<div className="flex items-center gap-1.5">
{actions}
{hasSearchButtons && (
<>
{onReset && (
<Button size="sm" variant="outline" className="h-8 gap-1 px-2 text-xs" onClick={onReset}>
<RotateCcw className="h-3.5 w-3.5" />
{resetLabel}
</Button>
)}
{onSearch && (
<Button size="sm" className="h-8 gap-1 px-2 text-xs" onClick={onSearch} disabled={loading}>
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Search className="h-3.5 w-3.5" />}
{searchLabel}
</Button>
)}
</>
)}
</div>
)}
</div>
);
}
@@ -20,6 +20,7 @@ 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 { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { Download, Upload, Save, Loader2, FileX, Copy } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
@@ -261,18 +262,17 @@ export function BomReportExcelImportDialog({ open, onOpenChange, editObjid, init
<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>
<div className="min-w-[280px]">
<SmartSelect
options={copyOptions.map<SmartSelectOption>((o) => ({
code: o.objid,
label: `${o.part_no} / ${o.part_name}${o.revision ? ` (v${o.revision})` : ""}${o.regdate ? ` - ${o.regdate}` : ""}`,
}))}
value={copySelect}
onValueChange={setCopySelect}
placeholder="선택"
/>
</div>
<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>
@@ -14,7 +14,7 @@ import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Loader2, FileSpreadsheet } from "lucide-react";
import { Loader2, FileSpreadsheet, Folder } from "lucide-react";
import { toast } from "sonner";
import { devBomApi, BomReportRow, BomTreeFullRow } from "@/lib/api/devBom";
import { cn } from "@/lib/utils";
@@ -158,9 +158,9 @@ export function BomReportTreeDialog({ open, onOpenChange, bomReport }: Props) {
<td className="border px-2 py-0.5">{r.pm_part_name}</td>
<td className="border px-2 py-0.5 text-right">{r.qty}</td>
<td className="border px-2 py-0.5 text-right">{r.p_qty}</td>
<td className="border px-2 py-0.5 text-center">{Number(r.cu01_cnt ?? 0) > 0 ? "Y" : ""}</td>
<td className="border px-2 py-0.5 text-center">{Number(r.cu02_cnt ?? 0) > 0 ? "Y" : ""}</td>
<td className="border px-2 py-0.5 text-center">{Number(r.cu03_cnt ?? 0) > 0 ? "Y" : ""}</td>
<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.material}</td>
<td className="border px-2 py-0.5">{r.heat_treatment_hardness}</td>
<td className="border px-2 py-0.5">{r.heat_treatment_method}</td>
@@ -184,6 +184,16 @@ export function BomReportTreeDialog({ open, onOpenChange, bomReport }: Props) {
);
}
function FolderCell({ n }: { n: any }) {
const has = Number(n ?? 0) > 0;
return (
<span className="inline-flex items-center justify-center">
<Folder className={cn("w-4 h-4",
has ? "fill-[#1a73e8] text-[#1a73e8]" : "fill-white text-muted-foreground/40")} />
</span>
);
}
function MetaRow({ label, value }: { label: string; value: any }) {
return (
<div className="flex items-baseline gap-2">
@@ -31,6 +31,7 @@ import { Label } from "@/components/ui/label";
import { Loader2, Save } from "lucide-react";
import { toast } from "sonner";
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
import { SmartSelect } from "@/components/common/SmartSelect";
import { devPartApi, PartCreateBody, PartUpdateBody, PartRow } from "@/lib/api/devPart";
import { cn } from "@/lib/utils";
import { createObjId } from "@/lib/utils/objidUtil";
@@ -462,15 +463,14 @@ function BasicSelect({
options: { v: string; t: string }[];
onChange: (v: string) => void;
}) {
// SmartSelect 로 위임 (옵션 5+ 면 자동 검색, 미만이면 일반 Select 모드)
return (
<select
className={cn("h-9 w-full rounded-md border bg-background px-2 text-sm")}
<SmartSelect
options={options.map((o) => ({ code: o.v, label: o.t }))}
value={value}
onChange={(e) => onChange(e.target.value)}
>
<option value=""></option>
{options.map((o) => <option key={o.v} value={o.v}>{o.t}</option>)}
</select>
onValueChange={onChange}
placeholder="선택"
/>
);
}
// ─── PartRow → FormState ────────────────────────────────────
@@ -0,0 +1,242 @@
"use client";
// 생산관리 > M-BOM 관리 — 단건 상세 + read-only 트리 다이얼로그.
//
// 운영판 통합:
// wace mBomHeaderPopup.jsp (헤더 메타)
// + wace mBomPopupLeft.jsp (read-only 트리 — 4분기 자동)
//
// 4분기 (운영판 mBomPopupLeft.do):
// SAVED 저장된 mbom_header.status='Y' 의 트리 (생산정보 포함)
// ASSIGNED_EBOM source_bom_type='EBOM' + source_ebom_objid → bom_part_qty 트리
// ASSIGNED_MBOM source_bom_type='MBOM' + source_mbom_objid → mbom_detail 구조만
// TEMPLATE Machine 이외 + 동일 part_no 의 mbom_header 템플릿
// NONE 빈 트리
//
// 본 편집 / BOM 복사 / 구매리스트 생성 / 변경이력 — PR-B 분리.
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 { toast } from "sonner";
import { cn } from "@/lib/utils";
import { mbomApi, MbomDetail, MbomTreeResponse, MbomBomDataType, MbomTreeRow } from "@/lib/api/mbom";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
projectObjid: string | null;
}
const BOM_DATA_TYPE_LABEL: Record<MbomBomDataType, { text: string; color: string }> = {
SAVED: { text: "저장된 M-BOM", color: "bg-emerald-600" },
ASSIGNED_EBOM: { text: "할당된 E-BOM", color: "bg-sky-600" },
ASSIGNED_MBOM: { text: "할당된 M-BOM", color: "bg-indigo-600" },
TEMPLATE: { text: "M-BOM 템플릿", color: "bg-amber-600" },
NONE: { text: "BOM 없음", color: "bg-slate-500" },
};
export function MbomDetailDialog({ open, onOpenChange, projectObjid }: Props) {
const [detail, setDetail] = useState<MbomDetail | null>(null);
const [tree, setTree] = useState<MbomTreeResponse | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open || !projectObjid) {
setDetail(null); setTree(null);
return;
}
let alive = true;
setLoading(true);
Promise.all([
mbomApi.getDetail(projectObjid),
mbomApi.getTree(projectObjid),
])
.then(([d, t]) => {
if (!alive) return;
setDetail(d);
setTree(t);
})
.catch((e: any) => {
toast.error(e?.response?.data?.message ?? e?.message ?? "M-BOM 조회 실패");
})
.finally(() => { if (alive) setLoading(false); });
return () => { alive = false; };
}, [open, projectObjid]);
const maxLevel = Math.max(1, tree?.max_level ?? 1);
const rows: MbomTreeRow[] = tree?.rows ?? [];
const bomDataType: MbomBomDataType = tree?.bom_data_type ?? "NONE";
const meta = BOM_DATA_TYPE_LABEL[bomDataType];
const levelHeaders = useMemo(() => {
const h: number[] = [];
for (let i = 1; i <= maxLevel; i++) h.push(i);
return h;
}, [maxLevel]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<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 className={cn("rounded px-2 py-0.5 text-xs font-semibold", meta.color)}>{meta.text}</span>
</DialogTitle>
</DialogHeader>
{/* 헤더 메타 (운영판 mBomHeaderPopup.jsp 1:1) */}
{detail && (
<div className="grid grid-cols-4 gap-x-6 gap-y-1.5 border-b px-4 py-3 text-xs">
<MetaRow label="프로젝트번호" value={detail.project_no} />
<MetaRow label="주문유형" value={detail.category_name} />
<MetaRow label="제품구분" value={detail.product_name} />
<MetaRow label="국내/해외" value={detail.area_name} />
<MetaRow label="고객사" value={detail.customer_name} />
<MetaRow label="유/무상" value={paidLabel(detail.paid_type)} />
<MetaRow label="품번" value={detail.part_no} />
<MetaRow label="품명" value={detail.part_name} />
<MetaRow label="수주수량" value={fmtNum(detail.quantity)} numeric />
<MetaRow label="총생산수량" value={fmtNum(detail.total_prod_qty)} numeric />
<MetaRow label="요청납기" value={detail.req_del_date} />
<MetaRow label="접수일" value={detail.receipt_date} />
<MetaRow label="M-BOM 품번" value={detail.mbom_part_no || "—"} />
<MetaRow label="M-BOM 저장일" value={detail.mbom_regdate || "—"} />
</div>
)}
<div className="flex items-center justify-between border-b px-4 py-2">
<div className="text-xs text-muted-foreground">
{rows.length.toLocaleString()} · MAX_LEVEL = {maxLevel}
{tree?.bom_report_objid && (
<span className="ml-2 text-muted-foreground/70">BOM_OBJID = {tree.bom_report_objid}</span>
)}
</div>
</div>
<div className="flex-1 min-h-0 overflow-auto">
{loading ? (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : (
<table className="text-xs border-collapse w-max min-w-full">
<thead className="bg-yellow-100 dark:bg-yellow-900/30 sticky top-0">
<tr>
{levelHeaders.map((i) => (
<th key={`l${i}`} className="border px-2 py-1 w-[36px] text-center font-bold">{i}</th>
))}
<th className="border px-2 py-1 min-w-[150px] text-left"></th>
<th className="border px-2 py-1 min-w-[180px] text-left"></th>
<th className="border px-2 py-1 min-w-[60px] text-right"></th>
<th className="border px-2 py-1 min-w-[70px] text-right"></th>
<th className="border px-2 py-1 min-w-[60px] text-center"></th>
<th className="border px-2 py-1 min-w-[70px] text-center">/</th>
<th className="border px-2 py-1 min-w-[80px] text-center">Make/Buy</th>
<th className="border px-2 py-1 min-w-[120px] text-left"></th>
<th className="border px-2 py-1 min-w-[120px] text-left"></th>
<th className="border px-2 py-1 min-w-[100px] text-left"></th>
<th className="border px-2 py-1 min-w-[80px] text-right"></th>
<th className="border px-2 py-1 min-w-[80px] text-right"></th>
<th className="border px-2 py-1 min-w-[80px] text-right"></th>
<th className="border px-2 py-1 min-w-[110px] text-left"></th>
<th className="border px-2 py-1 min-w-[100px] text-center"></th>
<th className="border px-2 py-1 min-w-[100px] text-center"></th>
<th className="border px-2 py-1 min-w-[40px] text-center">3D</th>
<th className="border px-2 py-1 min-w-[40px] text-center">2D</th>
<th className="border px-2 py-1 min-w-[40px] text-center">PDF</th>
<th className="border px-2 py-1 min-w-[120px] text-left"></th>
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr>
<td colSpan={levelHeaders.length + 19} className="py-8 text-center text-muted-foreground">
{bomDataType === "NONE" ? "표시할 BOM이 없습니다." : "트리가 비어있습니다."}
</td>
</tr>
)}
{rows.map((r, idx) => {
const lv = Number(r.level ?? 1);
return (
<tr key={`${r.objid}_${idx}`} className="hover:bg-muted/30">
{levelHeaders.map((i) => (
<td key={`lc${i}`} className={cn("border px-1 py-0.5 text-center", i === lv && "font-bold")}>
{i === lv ? "*" : ""}
</td>
))}
<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>
<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>
<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>
</tr>
);
})}
</tbody>
</table>
)}
</div>
<DialogFooter className="border-t bg-muted/20 px-4 py-3 sm:justify-center">
<Button variant="outline" onClick={() => onOpenChange(false)}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function FolderCell({ n }: { n: any }) {
const has = Number(n ?? 0) > 0;
return (
<span className="inline-flex items-center justify-center">
<Folder className={cn("w-4 h-4",
has ? "fill-[#1a73e8] text-[#1a73e8]" : "fill-white text-muted-foreground/40")} />
</span>
);
}
function MetaRow({ label, value, numeric }: { label: string; value: any; numeric?: boolean }) {
return (
<div className="flex items-baseline gap-2">
<span className="text-muted-foreground w-[80px] shrink-0">{label}</span>
<span className={cn("font-medium", numeric && "tabular-nums")}>
{value != null && value !== "" ? value : "—"}
</span>
</div>
);
}
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 });
}
function paidLabel(v: string | null | undefined): string {
if (v === "paid") return "유상";
if (v === "free") return "무상";
return v ?? "";
}
+182
View File
@@ -0,0 +1,182 @@
import { apiClient } from "./client";
// ============================================================
// 생산관리 > M-BOM 관리 — wace productionplanning.xml 1:1
// 라우트:
// GET /api/production/mbom/list (PR-A1, 그리드)
// GET /api/production/mbom/detail/:objid (PR-A2, 단건 상세)
// GET /api/production/mbom/tree/:objid (PR-A2, read-only 트리 4분기)
// ============================================================
export interface MbomListFilter {
search_category_cd?: string;
search_product_cd?: string;
search_area_cd?: string;
search_customer_objid?: string;
search_paid_type?: string;
search_serial_no?: string;
search_part_no?: string;
search_part_name?: string;
search_receipt_date_from?: string;
search_receipt_date_to?: string;
search_req_del_date_from?: string;
search_req_del_date_to?: string;
page?: number;
page_size?: number;
}
export interface MbomRow {
objid: string;
contract_objid: string | null;
project_no: string | null;
category_cd: string | null;
category_name: string | null;
product: string | null;
product_name: string | null;
area_cd: string | null;
area_name: string | null;
receipt_date: string | null;
writer_name: string | null;
customer_objid: string | null;
customer_name: string | null;
paid_type: string | null;
paid_type_name: string | null;
part_no: string | null;
part_name: string | null;
part_objid: string | null;
serial_no: string | null;
serial_no_list: string | null;
quantity: string | number | null;
req_del_date: string | null;
customer_request: string | null;
bom_report_objid: string | null;
ebom_status: string | null;
ebom_regdate: string | null;
mbom_header_objid: string | null;
purchase_list_objid: string | null;
purchase_list_date: string | null;
mbom_status: string | null;
mbom_part_no: string | null;
mbom_regdate: string | null;
mbom_editor: string | null;
mbom_version: number | null;
}
export interface MbomListResponse {
rows: MbomRow[];
totalCount: number;
page: number;
pageSize: number;
}
// ─── 단건 상세 (PR-A2) ──────────────────────────────────────
export interface MbomDetail {
objid: string;
contract_objid: string | null;
project_no: string | null;
bom_report_objid: string | null;
part_objid: string | null;
part_no: string | null;
part_name: string | null;
source_bom_type: string | null;
source_ebom_objid: string | null;
source_mbom_objid: string | null;
quantity: string | number | null;
total_prod_qty: string | number | null;
mbom_part_no: string | null;
category_cd: string | null;
category_name: string | null;
product: string | null;
product_code: string | null;
product_name: string | null;
area_cd: string | null;
area_name: string | null;
customer_objid: string | null;
customer_name: string | null;
paid_type: string | null;
req_del_date: string | null;
receipt_date: string | null;
mbom_regdate: string | null;
}
// ─── read-only 트리 (PR-A2) ─────────────────────────────────
// 운영판 mBomPopupLeft.do 4분기 자동 판별:
// SAVED — mbom_header.status='Y' 최신
// ASSIGNED_EBOM — source_bom_type='EBOM' + source_ebom_objid
// ASSIGNED_MBOM — source_bom_type='MBOM' + source_mbom_objid
// TEMPLATE — Machine 이외 + 동일 part_no 의 mbom_header
// NONE — 빈 트리
export type MbomBomDataType = "SAVED" | "ASSIGNED_EBOM" | "ASSIGNED_MBOM" | "TEMPLATE" | "NONE";
export interface MbomTreeRow {
objid: string;
parent_objid: string | null;
child_objid: string | null;
part_objid: string | null;
part_no: string | null;
part_name: string | null;
qty: string | number | null;
item_qty: string | number | null;
qty_temp: string | number | null;
level: number;
sub_part_cnt: number;
seq: number;
status: string | null;
unit: string | null;
unit_title: 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;
processing_vendor: string | null;
processing_vendor_name: string | null;
processing_deadline: string | null;
grinding_deadline: string | null;
required_qty: string | number | null;
order_qty: string | number | null;
production_qty: string | number | null;
vendor: string | null;
vendor_name: string | null;
unit_price: string | number | null;
total_price: string | number | null;
currency: string | null;
writer: string | null;
regdate: string | null;
editer: string | null;
edit_date: string | null;
remark: string | null;
spec: string | null;
material: string | null;
weight: string | number | null;
revision: string | null;
cu01_cnt: number;
cu02_cnt: number;
cu03_cnt: number;
[key: string]: any;
}
export interface MbomTreeResponse {
bom_data_type: MbomBomDataType;
bom_report_objid: string | null;
max_level: number;
rows: MbomTreeRow[];
}
export const mbomApi = {
async list(filter: MbomListFilter = {}): Promise<MbomListResponse> {
const res = await apiClient.get("/production/mbom/list", { params: filter });
return res.data?.data as MbomListResponse;
},
async getDetail(objid: string): Promise<MbomDetail> {
const res = await apiClient.get(`/production/mbom/detail/${encodeURIComponent(objid)}`);
return res.data?.data as MbomDetail;
},
async getTree(objid: string): Promise<MbomTreeResponse> {
const res = await apiClient.get(`/production/mbom/tree/${encodeURIComponent(objid)}`);
return res.data?.data as MbomTreeResponse;
},
};