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:
@@ -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;
|
||||
@@ -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
|
||||
`;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ?? "";
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user