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

Reviewed-on: https://g.wace.me/chpark/vexplor_rps/pulls/14
This commit is contained in:
hjjeong
2026-05-15 00:25:12 +00:00
32 changed files with 5312 additions and 82 deletions
+5
View File
@@ -120,6 +120,8 @@ 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 { planResultRouter as productionPlanResultRoutes, mbomReqRouter as productionMbomRequirementRoutes } from "./routes/productionPlanResultRoutes"; // 생산관리 4개 메뉴 (생산계획&실적, 소요량)
import purchaseRoutes from "./routes/purchaseRoutes"; // 구매관리 7개 메뉴 (wace_plm 도메인)
import itemInspectionRoutes from "./routes/itemInspectionRoutes"; // 품목검사정보
import qualityRoutes from "./routes/qualityRoutes"; // 품질관리 — 수입/공정/반제품 검사 (wace_plm 이식 1단계)
import crawlRoutes from "./routes/crawlRoutes"; // 웹 크롤링
@@ -383,6 +385,9 @@ 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/production/plan-result", productionPlanResultRoutes); // 생산계획&실적관리 (메뉴1+2)
app.use("/api/production/mbom-requirement", productionMbomRequirementRoutes); // 반제품/원자재 소요량 (메뉴3+4)
app.use("/api/purchase", purchaseRoutes); // 구매관리 7개 메뉴 (wace_plm 도메인)
app.use("/api/item-inspection", itemInspectionRoutes); // 품목검사정보 (그룹 페이징)
app.use("/api/quality", qualityRoutes); // 품질관리 — 수입/공정/반제품 검사
app.use("/api/crawl", crawlRoutes); // 웹 크롤링
@@ -53,6 +53,96 @@ export async function getTree(req: AuthenticatedRequest, res: Response) {
}
}
// PR-B5 — 할당 가능한 E-BOM 검색 (운영 mBomEbomSelectPopup.do getEbomList 1:1)
export async function searchAssignableEboms(req: AuthenticatedRequest, res: Response) {
try {
const q = req.query as Record<string, any>;
const data = await svc.searchAssignableEboms({
objid: String(q.objid ?? "").trim() || undefined,
product_cd: String(q.product_cd ?? "").trim() || undefined,
search_part_no: String(q.search_part_no ?? "").trim() || undefined,
search_part_name: String(q.search_part_name ?? "").trim() || undefined,
search_from_date: String(q.search_from_date ?? "").trim() || undefined,
search_to_date: String(q.search_to_date ?? "").trim() || undefined,
limit: q.limit ? Number(q.limit) : undefined,
});
return res.json({ success: true, data });
} catch (e: any) {
logger.error("할당 가능 E-BOM 조회 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
// PR-B5 — E-BOM 미리보기 트리 (운영 mBomEbomSelectPopup.jsp iframe 대체)
export async function previewEbomTree(req: AuthenticatedRequest, res: Response) {
try {
const bomReportObjid = String(req.params.bomReportObjid ?? "").trim();
if (!bomReportObjid) return res.status(400).json({ success: false, message: "bomReportObjid 누락" });
const data = await svc.previewEbomTree(bomReportObjid);
return res.json({ success: true, data });
} catch (e: any) {
logger.error("E-BOM 미리보기 조회 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
// PR-B5 — BOM 할당 (운영 saveBomAssignment.do 1:1)
export async function assignBom(req: AuthenticatedRequest, res: Response) {
try {
const projectObjId = String(req.body?.project_obj_id ?? req.body?.projectObjId ?? "").trim();
const sourceBomType = String(req.body?.source_bom_type ?? req.body?.sourceBomType ?? "").trim().toUpperCase();
const sourceBomObjId = String(req.body?.source_bom_obj_id ?? req.body?.sourceBomObjId ?? "").trim();
if (!projectObjId) return res.status(400).json({ success: false, message: "project_obj_id 누락" });
if (sourceBomType !== "EBOM" && sourceBomType !== "MBOM") {
return res.status(400).json({ success: false, message: "source_bom_type 은 EBOM 또는 MBOM" });
}
if (!sourceBomObjId) return res.status(400).json({ success: false, message: "source_bom_obj_id 누락" });
const userId = req.user?.userId ?? "system";
const data = await svc.assignBom(projectObjId, sourceBomType as svc.AssignSourceType, sourceBomObjId, userId);
return res.json({ success: true, data });
} catch (e: any) {
logger.error("BOM 할당 실패", { error: e.message });
return res.status(400).json({ success: false, message: e.message });
}
}
// PR-B4 — 변경이력 조회 (운영 getMbomHistory.do 1:1)
export async function getHistory(req: AuthenticatedRequest, res: Response) {
try {
const projectObjid = String(req.params.projectObjid ?? "").trim();
if (!projectObjid) return res.status(400).json({ success: false, message: "projectObjid 누락" });
const data = await svc.getHistory(projectObjid);
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 });
}
}
// PR-B3 — 구매리스트(SALES_REQUEST_MASTER) 생성 (운영 createPurchaseListFromMBom.do 1:1)
export async function createSalesRequest(req: AuthenticatedRequest, res: Response) {
try {
const mbomHeaderObjid = String(
req.body?.mbom_header_objid ?? req.body?.MBOM_HEADER_OBJID ?? "",
).trim();
const projectMgmtObjid = String(
req.body?.project_mgmt_objid ?? req.body?.PROJECT_MGMT_OBJID ?? "",
).trim();
if (!mbomHeaderObjid) {
return res.status(400).json({ success: false, message: "mbom_header_objid 누락" });
}
if (!projectMgmtObjid) {
return res.status(400).json({ success: false, message: "project_mgmt_objid 누락" });
}
const userId = req.user?.userId ?? "system";
const data = await svc.createSalesRequest(mbomHeaderObjid, projectMgmtObjid, userId);
return res.json({ success: true, data });
} catch (e: any) {
logger.error("구매리스트 생성 실패", { error: e.message });
return res.status(400).json({ success: false, message: e.message });
}
}
// PR-B1 — 본 편집 저장 (운영 saveMbom.do 1:1)
export async function save(req: AuthenticatedRequest, res: Response) {
try {
@@ -0,0 +1,107 @@
// ============================================================
// 생산관리 > 생산계획&실적관리 (일반/장비) + 반제품·원자재 소요량.
// wace productionplanning.xml 1:1.
//
// 라우트:
// GET /api/production/plan-result/list (메뉴1 — 일반)
// GET /api/production/plan-result/list-equip (메뉴2 — 장비)
// GET /api/production/plan-result/project-options (검색 multiple — 프로젝트번호)
// GET /api/production/plan-result/writer-options (검색 — 등록자)
//
// GET /api/production/mbom-requirement/options (M-BOM 옵션 + 품명 매핑)
// POST /api/production/mbom-requirement/semi (메뉴3 — 반제품)
// POST /api/production/mbom-requirement/raw (메뉴4 — 원자재)
// ============================================================
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import * as ppr from "../services/prodPlanResultService";
import * as req from "../services/mbomRequirementService";
import { logger } from "../utils/logger";
function parseFilter(q: Record<string, any>): ppr.ProdPlanResultFilter {
const f: ppr.ProdPlanResultFilter = { ...q };
if (q.page) f.page = Number(q.page);
if (q.page_size) f.page_size = Number(q.page_size);
return f;
}
// ─── 메뉴 1: 생산계획&실적관리 (일반) ─────────────────────────
export async function getList(reqMsg: AuthenticatedRequest, res: Response) {
try {
const data = await ppr.listGeneral(parseFilter(reqMsg.query as Record<string, any>));
return res.json({ success: true, data });
} catch (e: any) {
logger.error("생산계획&실적관리 목록 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
// ─── 메뉴 2: 생산계획&실적관리 (장비) ─────────────────────────
export async function getListEquip(reqMsg: AuthenticatedRequest, res: Response) {
try {
const data = await ppr.listEquip(parseFilter(reqMsg.query as Record<string, any>));
return res.json({ success: true, data });
} catch (e: any) {
logger.error("생산계획&실적관리(장비) 목록 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
// ─── 검색 옵션 ────────────────────────────────────────────────
export async function getProjectOptions(_req: AuthenticatedRequest, res: Response) {
try {
const data = await ppr.getProjectNoOptions();
return res.json({ success: true, data });
} catch (e: any) {
logger.error("프로젝트번호 옵션 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export async function getWriterOptions(_req: AuthenticatedRequest, res: Response) {
try {
const data = await ppr.getWriterOptions();
return res.json({ success: true, data });
} catch (e: any) {
logger.error("등록자 옵션 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
// ─── 메뉴 3 & 4: M-BOM 소요량 ─────────────────────────────────
export async function getMbomOptions(_req: AuthenticatedRequest, res: Response) {
try {
const data = await req.getMbomOptions();
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 getSemiRequirement(reqMsg: AuthenticatedRequest, res: Response) {
try {
const items = Array.isArray(reqMsg.body?.mbomItems) ? reqMsg.body.mbomItems : [];
const data = await req.getSemiRequirement(items);
return res.json({ success: true, data });
} catch (e: any) {
logger.error("반제품 소요량 조회 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export async function getRawRequirement(reqMsg: AuthenticatedRequest, res: Response) {
try {
const items = Array.isArray(reqMsg.body?.mbomItems) ? reqMsg.body.mbomItems : [];
const data = await req.getRawRequirement(items);
return res.json({ success: true, data });
} catch (e: any) {
logger.error("원자재 소요량 조회 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
@@ -0,0 +1,68 @@
// ============================================================
// 구매관리 — 7개 메뉴 그리드 컨트롤러 (purchaseService.ts orchestrator)
// ============================================================
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import * as svc from "../services/purchaseService";
import { logger } from "../utils/logger";
function parseFilter(q: Record<string, any>): svc.PurchaseListFilter {
const f: svc.PurchaseListFilter = { ...q };
if (q.page) f.page = Number(q.page);
if (q.page_size) f.page_size = Number(q.page_size);
return f;
}
async function runList(
fn: (f: svc.PurchaseListFilter) => Promise<any>,
req: AuthenticatedRequest,
res: Response,
name: string,
) {
try {
const data = await fn(parseFilter(req.query as Record<string, any>));
return res.json({ success: true, data });
} catch (e: any) {
logger.error(`${name} 실패`, { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export const getPurchaseRequest = (req: AuthenticatedRequest, res: Response) => runList(svc.listPurchaseRequest, req, res, "구매리스트관리");
export const getQuotationRequest = (req: AuthenticatedRequest, res: Response) => runList(svc.listQuotationRequest, req, res, "견적요청서관리");
export const getProposal = (req: AuthenticatedRequest, res: Response) => runList(svc.listProposal, req, res, "품의서관리");
export const getInbound = (req: AuthenticatedRequest, res: Response) => runList(svc.listInbound, req, res, "입고관리");
export const getInboundByItem = (req: AuthenticatedRequest, res: Response) => runList(svc.listInboundByItem, req, res, "품목별 입고관리");
export const getInboundByDate = (req: AuthenticatedRequest, res: Response) => runList(svc.listInboundByDate, req, res, "입고일별 입고관리");
export const getProjectStatus = (req: AuthenticatedRequest, res: Response) => runList(svc.listProjectStatus, req, res, "프로젝트별 발주/입고 현황");
export async function getSuppliers(_req: AuthenticatedRequest, res: Response) {
try {
const data = await svc.listSupplierOptions();
return res.json({ success: true, data });
} catch (e: any) {
logger.error("공급업체 옵션 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export async function getUsers(_req: AuthenticatedRequest, res: Response) {
try {
const data = await svc.listUserOptions();
return res.json({ success: true, data });
} catch (e: any) {
logger.error("작성자 옵션 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export async function getProjects(_req: AuthenticatedRequest, res: Response) {
try {
const data = await svc.listProjectOptions();
return res.json({ success: true, data });
} catch (e: any) {
logger.error("프로젝트 옵션 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
@@ -14,5 +14,10 @@ router.get("/list", ctrl.getList);
router.get("/detail/:objid", ctrl.getDetail);
router.get("/tree/:objid", ctrl.getTree);
router.post("/save", ctrl.save); // PR-B1 본 편집 저장
router.get("/history/:projectObjid", ctrl.getHistory); // PR-B4 변경이력 조회
router.post("/sales-request", ctrl.createSalesRequest); // PR-B3 구매리스트 생성
router.get("/assignable-eboms", ctrl.searchAssignableEboms); // PR-B5 할당 가능 E-BOM 검색
router.get("/ebom-preview/:bomReportObjid", ctrl.previewEbomTree); // PR-B5 E-BOM 미리보기
router.post("/assign", ctrl.assignBom); // PR-B5 BOM 할당
export default router;
@@ -0,0 +1,28 @@
// ============================================================
// 생산관리 > 생산계획&실적 + 소요량 라우트.
// app.ts:
// app.use("/api/production/plan-result", productionPlanResultRoutes)
// app.use("/api/production/mbom-requirement", productionMbomRequirementRoutes)
// 각 라우트는 동일 컨트롤러를 공유하므로 한 파일에서 두 Router 를 export.
// ============================================================
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as ctrl from "../controllers/prodPlanResultController";
const planResultRouter = Router();
planResultRouter.use(authenticateToken);
planResultRouter.get("/list", ctrl.getList); // 메뉴1
planResultRouter.get("/list-equip", ctrl.getListEquip); // 메뉴2
planResultRouter.get("/project-options", ctrl.getProjectOptions); // 공용 검색 옵션
planResultRouter.get("/writer-options", ctrl.getWriterOptions); // 메뉴1 등록자
const mbomReqRouter = Router();
mbomReqRouter.use(authenticateToken);
mbomReqRouter.get("/options", ctrl.getMbomOptions); // 메뉴3+4 공용 M-BOM 옵션
mbomReqRouter.post("/semi", ctrl.getSemiRequirement); // 메뉴3 반제품
mbomReqRouter.post("/raw", ctrl.getRawRequirement); // 메뉴4 원자재
export { planResultRouter, mbomReqRouter };
+27
View File
@@ -0,0 +1,27 @@
// ============================================================
// 구매관리 — 7개 메뉴 + 공통 옵션 라우트.
// app.ts: app.use("/api/purchase", purchaseRoutes)
// ============================================================
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as ctrl from "../controllers/purchaseController";
const router = Router();
router.use(authenticateToken);
// 그리드 7종
router.get("/purchase-request", ctrl.getPurchaseRequest); // 구매리스트관리
router.get("/quotation-request", ctrl.getQuotationRequest); // 견적요청서관리
router.get("/proposal", ctrl.getProposal); // 품의서관리
router.get("/inbound", ctrl.getInbound); // 입고관리
router.get("/inbound-by-item", ctrl.getInboundByItem); // 품목별 입고관리
router.get("/inbound-by-date", ctrl.getInboundByDate); // 입고일별 입고관리
router.get("/project-status", ctrl.getProjectStatus); // 프로젝트별 발주/입고 현황
// 공통 옵션
router.get("/options/suppliers", ctrl.getSuppliers);
router.get("/options/users", ctrl.getUsers);
router.get("/options/projects", ctrl.getProjects);
export default router;
@@ -0,0 +1,259 @@
// ============================================================
// 생산관리 > 반제품소요량 + 원자재소요량 — wace productionplanning.xml 1:1.
//
// 매퍼 매핑:
// getMbomListWithPartName → getMbomOptions() (4017)
// getMbomSemiProductItems → getSemiRequirement() (5252)
// getMbomRawMaterialItems → getRawRequirement() (5273) 1차 — 구매품
// getMbomRawSourceItems → getRawRequirement() (5298) 2차 — 원소재
//
// 서비스 로직(ProductionPlanningService.java:2030~2270) 의 LinkedHashMap 합산을
// 자바스크립트 Map 으로 1:1 이식.
// ============================================================
import { getPool } from "../database/db";
// ─── 입력 / 출력 ────────────────────────────────────────────
export interface MbomRequirementInputItem {
mbomObjid: string;
qty: number | string;
}
export interface SemiRequirementRow {
PART_NO: string;
PART_NAME: string;
CATEGORY_NAME: string;
UNIT: string;
MATERIAL: string;
SPEC: string;
REQUIRED_QTY: number;
}
export interface RawRequirementRow {
PART_NO: string;
PART_NAME: string;
CATEGORY_NAME: string; // '구매품' | '원소재'
UNIT: string;
MATERIAL: string;
SPEC: string;
REQUIRED_QTY: number | string;
RAW_MATERIAL: string;
RAW_MATERIAL_SIZE: string;
MATERIAL_PART_NO: string;
MATERIAL_REQUIRED_QTY: number | string;
}
function toInt(v: any): number {
if (v == null) return 0;
if (typeof v === "number") return Math.trunc(v);
const n = Number(String(v));
return Number.isFinite(n) ? Math.trunc(n) : 0;
}
function toNum(v: any): number {
if (v == null) return 0;
if (typeof v === "number") return v;
const n = Number(String(v));
return Number.isFinite(n) ? n : 0;
}
// ─── M-BOM 옵션 조회 (셀렉트박스용 + 품명 매핑) ────────────────
export async function getMbomOptions(): Promise<Array<{ objid: string; mbom_no: string; part_name: string }>> {
const pool = getPool();
const r = await pool.query(`
SELECT OBJID::VARCHAR AS objid,
COALESCE(MBOM_NO, '') AS mbom_no,
COALESCE(PART_NAME, '') AS part_name
FROM MBOM_HEADER
WHERE STATUS = 'Y'
ORDER BY REGDATE DESC, MBOM_NO
`);
return r.rows;
}
// ─── 메뉴 3: 반제품 소요량 ──────────────────────────────────
//
// 매퍼: getMbomSemiProductItems (5252~5270)
// 조건: MBOM_DETAIL × PART_MNG, PART_TYPE IN ('0001812', '0001813'), 1레벨 제외.
// 자바 서비스: 동일 PART_NO 합산 (입력수량 × 항목수량).
export async function getSemiRequirement(items: MbomRequirementInputItem[]): Promise<SemiRequirementRow[]> {
if (!Array.isArray(items) || items.length === 0) return [];
const pool = getPool();
// PART_NO 기준 LinkedHashMap (자바 LinkedHashMap 동일 보장)
const partMap = new Map<string, SemiRequirementRow>();
for (const it of items) {
const mbomObjid = String(it?.mbomObjid ?? "").trim();
const inputQty = toInt(it?.qty);
if (!mbomObjid || inputQty <= 0) continue;
const r = await pool.query(
`
SELECT
MD.PART_NO,
MD.PART_NAME,
COALESCE(MD.QTY, 1)::INTEGER AS ITEM_QTY,
P.PART_TYPE,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = P.PART_TYPE LIMIT 1), '') AS CATEGORY_NAME,
COALESCE(P.UNIT, '') AS UNIT,
COALESCE(P.MATERIAL, '') AS MATERIAL,
COALESCE(P.SPEC, '') AS SPEC
FROM MBOM_DETAIL MD
INNER JOIN PART_MNG P ON P.OBJID = MD.PART_OBJID
WHERE MD.MBOM_HEADER_OBJID = $1
AND MD.STATUS = 'ACTIVE'
AND (MD.PARENT_OBJID IS NOT NULL AND MD.PARENT_OBJID != '')
AND P.PART_TYPE IN ('0001812', '0001813')
ORDER BY P.PART_TYPE, MD.PART_NO
`,
[mbomObjid],
);
for (const row of r.rows) {
const partNo = String(row.part_no ?? "").trim();
if (!partNo) continue;
const itemQty = toInt(row.item_qty);
const required = inputQty * itemQty;
const existing = partMap.get(partNo);
if (existing) {
existing.REQUIRED_QTY += required;
} else {
partMap.set(partNo, {
PART_NO: partNo,
PART_NAME: row.part_name ?? "",
CATEGORY_NAME: row.category_name ?? "",
UNIT: row.unit ?? "",
MATERIAL: row.material ?? "",
SPEC: row.spec ?? "",
REQUIRED_QTY: required,
});
}
}
}
return Array.from(partMap.values());
}
// ─── 메뉴 4: 원자재 소요량 ──────────────────────────────────
//
// 매퍼: getMbomRawMaterialItems (5273~5295) + getMbomRawSourceItems (5298~5318)
// 자바 서비스: 구매품/원소재 두 LinkedHashMap. 원소재는 소수점 합산 후 올림.
export async function getRawRequirement(items: MbomRequirementInputItem[]): Promise<RawRequirementRow[]> {
if (!Array.isArray(items) || items.length === 0) return [];
const pool = getPool();
// 운영판 1:1 — 두 갈래 LinkedHashMap (구매품 / 원소재)
const purchaseMap = new Map<string, RawRequirementRow>();
// 원소재는 소수점 합산을 위해 임시 number 보관 후 마지막에 올림 처리
const rawSourceMap = new Map<string, RawRequirementRow & { __rawSum: number }>();
for (const it of items) {
const mbomObjid = String(it?.mbomObjid ?? "").trim();
const inputQty = toInt(it?.qty);
if (!mbomObjid || inputQty <= 0) continue;
// 1) 구매품 (PART_TYPE = '0000063')
const r1 = await pool.query(
`
SELECT
MD.PART_NO,
MD.PART_NAME,
COALESCE(MD.QTY, 1)::INTEGER AS ITEM_QTY,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = P.PART_TYPE LIMIT 1), '') AS CATEGORY_NAME,
COALESCE(P.UNIT, '') AS UNIT
FROM MBOM_DETAIL MD
INNER JOIN PART_MNG P ON P.OBJID = MD.PART_OBJID
WHERE MD.MBOM_HEADER_OBJID = $1
AND MD.STATUS = 'ACTIVE'
AND (MD.PARENT_OBJID IS NOT NULL AND MD.PARENT_OBJID != '')
AND P.PART_TYPE = '0000063'
ORDER BY MD.PART_NO
`,
[mbomObjid],
);
for (const row of r1.rows) {
const partNo = String(row.part_no ?? "").trim();
if (!partNo) continue;
const itemQty = toInt(row.item_qty);
const required = inputQty * itemQty;
const existing = purchaseMap.get(partNo);
if (existing) {
existing.REQUIRED_QTY = toInt(existing.REQUIRED_QTY) + required;
} else {
purchaseMap.set(partNo, {
PART_NO: partNo,
PART_NAME: row.part_name ?? "",
CATEGORY_NAME: row.category_name ?? "",
UNIT: row.unit ?? "",
MATERIAL: "",
SPEC: "",
REQUIRED_QTY: required,
RAW_MATERIAL: "",
RAW_MATERIAL_SIZE: "",
MATERIAL_PART_NO: "",
MATERIAL_REQUIRED_QTY: "",
});
}
}
// 2) 원소재 (RAW_MATERIAL_PART_NO 가 있는 항목)
const r2 = await pool.query(
`
SELECT
MD.RAW_MATERIAL_PART_NO AS PART_NO,
MD.RAW_MATERIAL AS PART_NAME,
COALESCE(MD.REQUIRED_QTY, 0)::NUMERIC AS ITEM_QTY,
MD.RAW_MATERIAL,
MD.RAW_MATERIAL_SIZE
FROM MBOM_DETAIL MD
WHERE MD.MBOM_HEADER_OBJID = $1
AND MD.STATUS = 'ACTIVE'
AND MD.RAW_MATERIAL_PART_NO IS NOT NULL
AND MD.RAW_MATERIAL_PART_NO != ''
ORDER BY MD.RAW_MATERIAL_PART_NO
`,
[mbomObjid],
);
for (const row of r2.rows) {
const materialPartNo = String(row.part_no ?? "").trim();
if (!materialPartNo) continue;
const itemQty = toNum(row.item_qty);
const required = inputQty * itemQty;
const existing = rawSourceMap.get(materialPartNo);
if (existing) {
existing.__rawSum += required;
} else {
rawSourceMap.set(materialPartNo, {
PART_NO: "",
PART_NAME: "",
CATEGORY_NAME: "원소재",
UNIT: "",
MATERIAL: row.raw_material ?? "",
SPEC: row.raw_material_size ?? "",
REQUIRED_QTY: "",
RAW_MATERIAL: row.raw_material ?? "",
RAW_MATERIAL_SIZE: row.raw_material_size ?? "",
MATERIAL_PART_NO: materialPartNo,
MATERIAL_REQUIRED_QTY: "",
__rawSum: required,
});
}
}
}
// 구매품 먼저, 원소재(올림) 뒤
const result: RawRequirementRow[] = [];
for (const v of purchaseMap.values()) result.push(v);
for (const v of rawSourceMap.values()) {
const ceilQty = Math.ceil(v.__rawSum);
const { __rawSum, ...rest } = v;
result.push({ ...rest, MATERIAL_REQUIRED_QTY: ceilQty });
}
return result;
}
+280 -4
View File
@@ -1106,20 +1106,40 @@ export async function save(payload: MbomSavePayload, sessionUserId: string): Pro
const existIds = new Set<string>(existRes.rows.map((r: any) => r.objid));
const incomingIds = new Set<string>();
// 신규 행의 client temp- child_objid → 서버 발급 createObjId 매핑
// (객체 ID 안정성 + DB 에 temp- 잔존 방지)
const tempChildMap = new Map<string, string>();
for (const row of payload.rows ?? []) {
if (!s(row.objid)) {
const newId = createObjId();
const tempChild = s(row.child_objid);
if (tempChild) tempChildMap.set(tempChild, newId);
// 임시로 row 자체에 새 objid 부여 — 이어지는 루프에서 다시 읽지 않도록
(row as any).__newObjid = newId;
}
}
// UPSERT
for (const row of payload.rows ?? []) {
let objid = s(row.objid) ?? "";
if (!objid) objid = createObjId();
let childObjid = s(row.child_objid) ?? objid;
const isNew = !objid;
if (isNew) objid = (row as any).__newObjid as string;
// 새 행이면 child_objid 도 새 ID 로 통일, 기존 행이면 그대로
let childObjid = isNew ? objid : (s(row.child_objid) ?? objid);
// parent_objid: temp- 참조면 신규 발급 ID 로 remap, 아니면 그대로
const rawParent = s(row.parent_objid);
const parentObjid = rawParent && tempChildMap.has(rawParent)
? tempChildMap.get(rawParent)!
: rawParent;
incomingIds.add(objid);
if (existIds.has(objid)) {
await client.query(DETAIL_UPDATE_SQL, detailUpdateParams({ ...row, child_objid: childObjid }, objid, userId));
await client.query(DETAIL_UPDATE_SQL, detailUpdateParams({ ...row, parent_objid: parentObjid }, objid, userId));
updated++;
} else {
await client.query(
DETAIL_INSERT_SQL,
detailInsertParams(row, objid, mbomHeaderObjid, childObjid, s(row.parent_objid), userId),
detailInsertParams(row, objid, mbomHeaderObjid, childObjid, parentObjid, userId),
);
inserted++;
}
@@ -1208,6 +1228,262 @@ export async function save(payload: MbomSavePayload, sessionUserId: string): Pro
}
}
// ─── BOM 할당 (PR-B5) ───────────────────────────────────────
//
// 매퍼 productionplanning.getEbomList (3221~3265) 1:1 — 할당 가능한 E-BOM 검색.
// 매퍼 productionplanning.saveBomAssignment (3545~3553) 1:1 — project_mgmt.source_bom_type/source_*_objid 업데이트.
//
// 운영판 흐름 (mBomEbomSelectPopup.do + assignEbomToMbom.do):
// 1) 사용자가 BOM 할당 다이얼로그 오픈 → E-BOM 검색 (part_bom_report)
// 2) 한 건 선택 → assign(projectObjid, 'EBOM', bomReportObjid) 호출
// 3) project_mgmt.source_bom_type='EBOM' + source_ebom_objid 저장
// 4) M-BOM 다이얼로그 재조회 → ASSIGNED_EBOM 트리 자동 표시
// (M-BOM 할당은 PR-B5 v2 — 우선 E-BOM 만)
export interface AssignableEbomFilter {
objid?: string; // 단건 정확매칭 (현재 할당 E-BOM 정보 조회용)
product_cd?: string;
search_part_no?: string;
search_part_name?: string;
search_from_date?: string; // YYYY-MM-DD
search_to_date?: string;
limit?: number;
}
export interface AssignableEbomRow {
objid: string;
product_cd: string | null;
product_name: string | null;
part_no: string | null;
part_name: string | null;
status: string | null;
revision: string | null;
reg_date: string | null;
writer_name: string | null;
dept_name: string | null;
material: string | null;
supplier: string | null;
}
export async function searchAssignableEboms(filter: AssignableEbomFilter): Promise<AssignableEbomRow[]> {
const pool = getPool();
const conds: string[] = [];
const params: any[] = [];
let idx = 1;
if (filter.objid) {
conds.push(`T.OBJID::VARCHAR = $${idx++}`);
params.push(filter.objid);
}
if (filter.product_cd) {
conds.push(`T.PRODUCT_CD = $${idx++}`);
params.push(filter.product_cd);
}
if (filter.search_part_no) {
conds.push(`UPPER(T.PART_NO) LIKE '%' || UPPER($${idx++}) || '%'`);
params.push(filter.search_part_no);
}
if (filter.search_part_name) {
conds.push(`UPPER(T.PART_NAME) LIKE '%' || UPPER($${idx++}) || '%'`);
params.push(filter.search_part_name);
}
if (filter.search_from_date) {
conds.push(`T.REGDATE >= TO_DATE($${idx++}, 'YYYY-MM-DD')`);
params.push(filter.search_from_date);
}
if (filter.search_to_date) {
conds.push(`T.REGDATE < TO_DATE($${idx++}, 'YYYY-MM-DD') + INTERVAL '1 day'`);
params.push(filter.search_to_date);
}
const limit = Math.min(500, Math.max(1, Number(filter.limit) || 100));
const sql = `
SELECT
T.OBJID::VARCHAR AS objid,
T.PRODUCT_CD AS product_cd,
COALESCE((SELECT CODE_NAME FROM COMM_CODE
WHERE CODE_ID = T.PRODUCT_CD LIMIT 1), '') AS product_name,
T.PART_NO AS part_no,
T.PART_NAME AS part_name,
T.STATUS AS status,
T.REVISION AS revision,
TO_CHAR(T.REGDATE, 'YYYY-MM-DD') AS reg_date,
UI.USER_NAME AS writer_name,
UI.DEPT_NAME AS dept_name,
COALESCE(PM.MATERIAL, '') AS material,
COALESCE(PM.MAKER, '') AS supplier
FROM PART_BOM_REPORT T
LEFT JOIN USER_INFO UI ON UI.USER_ID = T.WRITER
LEFT JOIN PART_MNG PM ON PM.PART_NO = T.PART_NO AND PM.STATUS = 'release'
WHERE 1=1
AND T.PART_NO IS NOT NULL AND TRIM(T.PART_NO) != ''
AND T.PART_NAME IS NOT NULL AND TRIM(T.PART_NAME) != ''
${conds.length ? "AND " + conds.join(" AND ") : ""}
ORDER BY T.REGDATE DESC
LIMIT ${limit}
`;
const r = await pool.query(sql, params);
return r.rows;
}
// E-BOM 미리보기 트리 — bom_report_objid 만으로 EBOM_WORKING_TREE_SQL 호출.
// 운영판 mBomEbomSelectPopup.jsp 의 iframe 미리보기 대체.
export async function previewEbomTree(bomReportObjid: string): Promise<MbomTreeResult> {
if (!bomReportObjid) {
return { bom_data_type: "NONE", bom_report_objid: null, max_level: 1, rows: [] };
}
const rows = await getEbomWorkingTree(bomReportObjid);
return finalize("ASSIGNED_EBOM", bomReportObjid, rows);
}
export type AssignSourceType = "EBOM" | "MBOM";
export async function assignBom(
projectObjId: string,
sourceBomType: AssignSourceType,
sourceBomObjId: string,
_userId: string,
): Promise<{ success: boolean; source_bom_type: string; source_obj_id: string }> {
const pool = getPool();
// 매퍼 saveBomAssignment 1:1.
// EBOM 이면 source_ebom_objid, MBOM 이면 source_mbom_objid 만 세팅 (다른 쪽 NULL).
const sql = `
UPDATE PROJECT_MGMT
SET SOURCE_BOM_TYPE = $1,
SOURCE_EBOM_OBJID = CASE WHEN $1 = 'EBOM' THEN $2 ELSE NULL END,
SOURCE_MBOM_OBJID = CASE WHEN $1 = 'MBOM' THEN $2 ELSE NULL END
WHERE OBJID::VARCHAR = $3
`;
const r = await pool.query(sql, [sourceBomType, sourceBomObjId, projectObjId]);
if (r.rowCount === 0) throw new Error("프로젝트를 찾을 수 없습니다");
return { success: true, source_bom_type: sourceBomType, source_obj_id: sourceBomObjId };
}
// ─── 변경이력 조회 (PR-B4) ──────────────────────────────────
//
// 매퍼 productionplanning.getMbomHistory (3448~3470) 1:1.
// project_objid 로 그 프로젝트의 모든 mbom_header 변경이력 시간순 (최신 우선).
export interface MbomHistoryRow {
objid: string;
mbom_header_objid: string;
change_type: string;
change_description: string | null;
change_user: string | null;
change_user_name: string | null;
change_date: string;
mbom_part_no: string | null;
mbom_regdate: string | null;
}
export async function getHistory(projectObjid: string): Promise<MbomHistoryRow[]> {
const pool = getPool();
const sql = `
SELECT
MH.OBJID AS objid,
MH.MBOM_HEADER_OBJID AS mbom_header_objid,
MH.CHANGE_TYPE AS change_type,
MH.CHANGE_DESCRIPTION AS change_description,
MH.CHANGE_USER AS change_user,
COALESCE(
(SELECT USER_NAME FROM USER_INFO WHERE USER_ID = MH.CHANGE_USER LIMIT 1),
MH.CHANGE_USER
) AS change_user_name,
TO_CHAR(MH.CHANGE_DATE, 'YYYY-MM-DD HH24:MI:SS') AS change_date,
MHD.MBOM_NO AS mbom_part_no,
TO_CHAR(MHD.REGDATE, 'YYYY-MM-DD HH24:MI:SS') AS mbom_regdate
FROM MBOM_HISTORY MH
INNER JOIN MBOM_HEADER MHD ON MH.MBOM_HEADER_OBJID = MHD.OBJID
WHERE MHD.PROJECT_OBJID = $1
ORDER BY MH.CHANGE_DATE DESC
`;
const r = await pool.query(sql, [projectObjid]);
return r.rows;
}
// ─── 구매리스트 생성 (PR-B3) ────────────────────────────────
//
// wace 매퍼 salesMng.xml `getNextRequestMngNo` + `insertSalesRequestMasterFromMBom` 1:1
// (서비스 SalesMngService.createPurchaseListFromMBom, 매퍼 라인 3960~3997).
//
// 검증: M-BOM 헤더 존재 + 동일 mbom_header_objid 로 이미 생성된 sales_request_master 없음.
// 입력: mbomHeaderObjid (mbom_header.objid), projectMgmtObjid (project_mgmt.objid).
// 처리: 단일 INSERT, BEGIN/COMMIT 트랜잭션. mbom_header 역링크 컬럼 없음 (운영 동일).
export interface CreateSalesRequestResult {
objid: string;
request_mng_no: string;
mbom_header_objid: string;
}
export async function createSalesRequest(
mbomHeaderObjid: string,
projectMgmtObjid: string,
sessionUserId: string,
): Promise<CreateSalesRequestResult> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const mbom = String(mbomHeaderObjid ?? "").trim();
const proj = String(projectMgmtObjid ?? "").trim();
if (!mbom) throw new Error("MBOM_HEADER_OBJID 누락");
if (!proj) throw new Error("PROJECT_MGMT_OBJID 누락");
const userId = String(sessionUserId ?? "").trim() || "system";
// 1) M-BOM 헤더 존재 확인
const mh = await client.query(
`SELECT OBJID FROM MBOM_HEADER WHERE OBJID::VARCHAR = $1 AND STATUS = 'Y' LIMIT 1`,
[mbom],
);
if (!mh.rows[0]) throw new Error("저장된 M-BOM 헤더를 찾을 수 없습니다");
// 2) 동일 MBOM_HEADER 로 이미 생성된 구매리스트 차단 (운영판 sweetalert 가드 1:1)
const dup = await client.query(
`SELECT OBJID, REQUEST_MNG_NO FROM SALES_REQUEST_MASTER
WHERE MBOM_HEADER_OBJID = $1 LIMIT 1`,
[mbom],
);
if (dup.rows[0]) {
throw new Error(`이미 생성된 구매리스트가 있습니다 (${dup.rows[0].request_mng_no})`);
}
// 3) 채번: R + YYYYMMDD + - + 3자리 (운영 getNextRequestMngNo 1:1)
const seqRes = await client.query(
`SELECT 'R' || TO_CHAR(NOW(), 'YYYYMMDD') || '-' ||
LPAD(
(COALESCE(MAX(SUBSTR(REQUEST_MNG_NO, 11, 13)), '0')::INTEGER + 1)::TEXT,
3, '0'
) AS request_mng_no
FROM SALES_REQUEST_MASTER
WHERE DOC_TYPE IN ('PURCHASE_REQUEST', 'PURCHASE_REG') OR DOC_TYPE IS NULL`,
);
const requestMngNo: string = seqRes.rows[0]?.request_mng_no;
if (!requestMngNo) throw new Error("REQUEST_MNG_NO 채번 실패");
// 4) INSERT (운영 insertSalesRequestMasterFromMBom 1:1, PROJECT_NO 컬럼에 PROJECT_MGMT.OBJID 저장)
const newObjid = createObjId();
await client.query(
`INSERT INTO SALES_REQUEST_MASTER (
OBJID, REQUEST_MNG_NO, PROJECT_NO, MBOM_HEADER_OBJID,
REQUEST_USER_ID, STATUS, WRITER, REGDATE, DOC_TYPE
) VALUES ($1, $2, $3, $4, $5, 'create', $6, NOW(), 'PURCHASE_REQUEST')`,
[newObjid, requestMngNo, proj, mbom, userId, userId],
);
await client.query("COMMIT");
return {
objid: newObjid,
request_mng_no: requestMngNo,
mbom_header_objid: mbom,
};
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
async function insertHistory(
client: any,
mbomHeaderObjid: string,
@@ -0,0 +1,400 @@
// ============================================================
// 생산관리 > 생산계획&실적관리 + 생산계획&실적관리(장비) — wace productionplanning.xml 1:1.
//
// 매퍼 매핑:
// prodPlanResultMgmtGridList → listGeneral() (productionplanning.xml:4550)
// prodPlanResultMgmtEquipGridList → listEquip() (productionplanning.xml:4887)
//
// 두 메뉴는 동일 베이스(PROJECT_MGMT + CONTRACT_MGMT + CONTRACT_ITEM + PRODUCTION_PLAN +
// PRODUCTION_RESULT 합산) 위에서 PRODUCT 코드(0000928=장비) 기준으로 갈라진다.
// ============================================================
import { getPool } from "../database/db";
// ─── 필터 ────────────────────────────────────────────────────
export interface ProdPlanResultFilter {
search_project_nos?: string; // "OBJID,OBJID,..." (multiple select)
search_product_code?: string;
search_category_code?: string;
search_production_type?: string;
search_customer_objid?: string;
search_req_del_date_from?: string;
search_req_del_date_to?: string;
search_part_no?: string;
search_part_name?: string;
search_serial_no?: string;
search_writer?: string;
search_regdate_from?: string;
search_regdate_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 빌더 (매퍼 <if> 1:1) ──────────────────────────
//
// 매퍼: 4684~4741 / 4985~5022. 두 메뉴 동일.
// (Equip 은 search_writer / search_regdate 없지만, 빌더는 동일 — 안 쓰면 됨.)
function buildWhere(filter: ProdPlanResultFilter, startIdx: number, includeSerial: boolean) {
const params: any[] = [];
const conds: string[] = [];
let idx = startIdx;
if (filter.search_project_nos) {
const ids = filter.search_project_nos.split(",").map((s) => s.trim()).filter(Boolean);
if (ids.length > 0) {
const placeholders = ids.map(() => `$${idx++}`).join(", ");
conds.push(`T.OBJID::VARCHAR IN (${placeholders})`);
params.push(...ids);
}
}
if (filter.search_product_code) {
conds.push(`T.PRODUCT = $${idx++}`);
params.push(filter.search_product_code);
}
if (filter.search_category_code) {
conds.push(`T.CATEGORY_CODE = $${idx++}`);
params.push(filter.search_category_code);
}
if (filter.search_production_type) {
conds.push(`T.PRODUCTION_TYPE = $${idx++}`);
params.push(filter.search_production_type);
}
if (filter.search_customer_objid) {
conds.push(
`(T.CUSTOMER_OBJID = $${idx} OR T.CUSTOMER_OBJID = REPLACE($${idx}, 'C_', '') OR REPLACE(T.CUSTOMER_OBJID, 'C_', '') = REPLACE($${idx}, 'C_', ''))`
);
params.push(filter.search_customer_objid);
idx++;
}
if (filter.search_req_del_date_from) {
conds.push(`T.REQ_DEL_DATE >= $${idx++}`);
params.push(filter.search_req_del_date_from);
}
if (filter.search_req_del_date_to) {
conds.push(`T.REQ_DEL_DATE <= $${idx++}`);
params.push(filter.search_req_del_date_to);
}
if (filter.search_part_no) {
conds.push(`UPPER(T.PART_NO) LIKE '%' || UPPER($${idx++}) || '%'`);
params.push(filter.search_part_no);
}
if (filter.search_part_name) {
conds.push(`UPPER(T.PART_NAME) LIKE '%' || UPPER($${idx++}) || '%'`);
params.push(filter.search_part_name);
}
if (filter.search_serial_no) {
// 일반: SERIAL_NO_LIST 전체 매칭 / 장비: SERIAL_NO 단일 (둘 다 동일 컬럼 alias 로 처리)
const col = includeSerial ? "T.SERIAL_NO_LIST" : "T.SERIAL_NO";
conds.push(`UPPER(${col}) LIKE '%' || UPPER($${idx++}) || '%'`);
params.push(filter.search_serial_no);
}
if (filter.search_writer) {
conds.push(`T.WRITER = $${idx++}`);
params.push(filter.search_writer);
}
if (filter.search_regdate_from) {
conds.push(`T.REGDATE >= TO_DATE($${idx++}, 'YYYY-MM-DD')`);
params.push(filter.search_regdate_from);
}
if (filter.search_regdate_to) {
conds.push(`T.REGDATE <= TO_DATE($${idx++}, 'YYYY-MM-DD')`);
params.push(filter.search_regdate_to);
}
return { sql: conds.length ? "AND " + conds.join(" AND ") : "", params };
}
// ─── 메뉴 1: 생산계획&실적관리 (일반) ─────────────────────────
//
// 매퍼 prodPlanResultMgmtGridList (4550~4743) 1:1.
// UNION ALL 두 갈래:
// 1) PROJECT_MGMT 기반 — PM.PRODUCT != '0000928' (장비 제외)
// 2) PRODUCTION_PLAN 단독 — PROJECT_OBJID 비어있는 독립 계획
const SQL_GENERAL_INNER = `
SELECT * FROM (
-- 1) 프로젝트 기반
SELECT
PM.OBJID::VARCHAR AS OBJID,
PM.PROJECT_NO,
CM.PRODUCT,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.PRODUCT LIMIT 1), '') AS PRODUCT_NAME,
CM.CATEGORY_CD AS CATEGORY_CODE,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.CATEGORY_CD LIMIT 1), CM.CATEGORY_CD) AS CATEGORY_CODE_NAME,
COALESCE(PP.PRODUCTION_TYPE, '') AS PRODUCTION_TYPE,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = PP.PRODUCTION_TYPE LIMIT 1), '') AS PRODUCTION_TYPE_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,
COALESCE(CI.DUE_DATE, PM.DUE_DATE, CM.REQ_DEL_DATE) AS REQ_DEL_DATE,
COALESCE(CI.CUSTOMER_REQUEST, '') AS CUSTOMER_REQUEST,
COALESCE(CI.PART_NO, PM.PART_NO, '') AS PART_NO,
COALESCE(CI.PART_NAME, PM.PART_NAME, '') AS PART_NAME,
(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,
(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(NULLIF(PP.EXTRA_PROD_QTY, '')::numeric, 0) AS EXTRA_PROD_QTY,
COALESCE(NULLIF(PM.QUANTITY, '')::numeric, NULLIF(CI.ORDER_QUANTITY, '')::numeric, 0)
+ COALESCE(NULLIF(PP.EXTRA_PROD_QTY, '')::numeric, 0) AS TOTAL_PROD_QTY,
COALESCE((SELECT SUM(RESULT_QTY) FROM PRODUCTION_RESULT PR
WHERE PR.PROJECT_OBJID = PM.OBJID::VARCHAR AND PR.RESULT_TYPE = 'ASSEMBLY' AND PR.STATUS = 'active'), 0) AS ASSEMBLY_QTY,
COALESCE((SELECT SUM(RESULT_QTY) FROM PRODUCTION_RESULT PR
WHERE PR.PROJECT_OBJID = PM.OBJID::VARCHAR AND PR.RESULT_TYPE = 'INSPECTION' AND PR.STATUS = 'active'), 0) AS INSPECTION_QTY,
COALESCE((SELECT SUM(RESULT_QTY) FROM PRODUCTION_RESULT PR
WHERE PR.PROJECT_OBJID = PM.OBJID::VARCHAR AND PR.RESULT_TYPE = 'SHIP_WAIT' AND PR.STATUS = 'active'), 0) AS SHIP_WAIT_QTY,
PP.OBJID::VARCHAR AS PROD_PLAN_OBJID,
PM.REGDATE AS SORT_DATE,
PP.WRITER,
COALESCE(user_name(PP.WRITER), '') AS WRITER_NAME,
TO_CHAR(PP.REGDATE, 'YYYY-MM-DD') AS REGDATE_TITLE,
PP.REGDATE
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'
LEFT OUTER JOIN PRODUCTION_PLAN PP ON PP.PROJECT_OBJID = PM.OBJID::VARCHAR AND PP.STATUS = 'active'
WHERE PM.PROJECT_NO IS NOT NULL AND PM.PROJECT_NO != ''
AND (CM.PRODUCT IS NULL OR CM.PRODUCT != '0000928')
UNION ALL
-- 2) 프로젝트 없이 등록한 생산계획
SELECT
PP.OBJID::VARCHAR AS OBJID,
'' AS PROJECT_NO,
PP.PRODUCT_CODE AS PRODUCT,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = PP.PRODUCT_CODE LIMIT 1), '') AS PRODUCT_NAME,
PP.CATEGORY_CODE AS CATEGORY_CODE,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = PP.CATEGORY_CODE LIMIT 1), '') AS CATEGORY_CODE_NAME,
COALESCE(PP.PRODUCTION_TYPE, '') AS PRODUCTION_TYPE,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = PP.PRODUCTION_TYPE LIMIT 1), '') AS PRODUCTION_TYPE_NAME,
PP.CUSTOMER_OBJID,
COALESCE(
CASE WHEN PP.CUSTOMER_OBJID LIKE 'C_%'
THEN (SELECT CLIENT_NM FROM CLIENT_MNG AS C WHERE 'C_' || C.OBJID::VARCHAR = PP.CUSTOMER_OBJID LIMIT 1)
ELSE (SELECT CLIENT_NM FROM CLIENT_MNG WHERE OBJID::VARCHAR = PP.CUSTOMER_OBJID LIMIT 1) END,
''
) AS CUSTOMER_NAME,
PP.REQ_DEL_DATE,
COALESCE(PP.CUSTOMER_REQUEST, '') AS CUSTOMER_REQUEST,
COALESCE(PP.PART_NO, '') AS PART_NO,
COALESCE(PP.PART_NAME, '') AS PART_NAME,
COALESCE(PP.SERIAL_NO, '') AS SERIAL_NO,
COALESCE(PP.SERIAL_NO, '') AS SERIAL_NO_LIST,
COALESCE(NULLIF(PP.ORDER_QTY, '')::numeric, 0) AS QUANTITY,
COALESCE(NULLIF(PP.EXTRA_PROD_QTY, '')::numeric, 0) AS EXTRA_PROD_QTY,
COALESCE(NULLIF(PP.TOTAL_PROD_QTY, '')::numeric, 0) AS TOTAL_PROD_QTY,
COALESCE((SELECT SUM(RESULT_QTY) FROM PRODUCTION_RESULT PR
WHERE PR.PROJECT_OBJID = PP.OBJID::VARCHAR AND PR.RESULT_TYPE = 'ASSEMBLY' AND PR.STATUS = 'active'), 0) AS ASSEMBLY_QTY,
COALESCE((SELECT SUM(RESULT_QTY) FROM PRODUCTION_RESULT PR
WHERE PR.PROJECT_OBJID = PP.OBJID::VARCHAR AND PR.RESULT_TYPE = 'INSPECTION' AND PR.STATUS = 'active'), 0) AS INSPECTION_QTY,
COALESCE((SELECT SUM(RESULT_QTY) FROM PRODUCTION_RESULT PR
WHERE PR.PROJECT_OBJID = PP.OBJID::VARCHAR AND PR.RESULT_TYPE = 'SHIP_WAIT' AND PR.STATUS = 'active'), 0) AS SHIP_WAIT_QTY,
PP.OBJID::VARCHAR AS PROD_PLAN_OBJID,
PP.REGDATE AS SORT_DATE,
PP.WRITER,
COALESCE(user_name(PP.WRITER), '') AS WRITER_NAME,
TO_CHAR(PP.REGDATE, 'YYYY-MM-DD') AS REGDATE_TITLE,
PP.REGDATE
FROM PRODUCTION_PLAN PP
WHERE PP.STATUS = 'active'
AND (PP.PROJECT_OBJID IS NULL OR PP.PROJECT_OBJID = '')
) T
WHERE 1=1
`;
export async function listGeneral(filter: ProdPlanResultFilter) {
const { limit, offset, page, pageSize } = paginate(filter);
const where = buildWhere(filter, 1, true);
const pool = getPool();
const dataSql = `
SELECT * FROM (${SQL_GENERAL_INNER} ${where.sql}) X
ORDER BY X.SORT_DATE DESC NULLS LAST, X.PROJECT_NO DESC
LIMIT $${where.params.length + 1} OFFSET $${where.params.length + 2}
`;
const countSql = `SELECT COUNT(*)::int AS cnt FROM (${SQL_GENERAL_INNER} ${where.sql}) X`;
const [dataRes, countRes] = await Promise.all([
pool.query(dataSql, [...where.params, limit, offset]),
pool.query(countSql, where.params),
]);
return {
rows: dataRes.rows,
totalCount: countRes.rows[0]?.cnt ?? 0,
page,
pageSize,
};
}
// ─── 메뉴 2: 생산계획&실적관리(장비) ────────────────────────
//
// 매퍼 prodPlanResultMgmtEquipGridList (4887~5024) 1:1.
// PM.PRODUCT = '0000928' (장비) 만. PMS_WBS_TASK 기반 진척율.
// (운영판 CONTRACT_ITEM 조인 중복 라인은 정리 — 동일 ON 조건이 2번 들어있음)
const SQL_EQUIP_INNER = `
SELECT * FROM (
SELECT
PM.OBJID::VARCHAR AS OBJID,
PM.PROJECT_NO,
CM.PRODUCT,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.PRODUCT LIMIT 1), '') AS PRODUCT_NAME,
CM.CATEGORY_CD AS CATEGORY_CODE,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.CATEGORY_CD LIMIT 1), CM.CATEGORY_CD) AS CATEGORY_CODE_NAME,
COALESCE(PP.PRODUCTION_TYPE, '') AS PRODUCTION_TYPE,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = PP.PRODUCTION_TYPE LIMIT 1), '') AS PRODUCTION_TYPE_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,
COALESCE(CI.DUE_DATE, PM.DUE_DATE, CM.REQ_DEL_DATE) AS REQ_DEL_DATE,
COALESCE(CI.CUSTOMER_REQUEST, '') AS CUSTOMER_REQUEST,
COALESCE(CI.PART_NO, PM.PART_NO, '') AS PART_NO,
COALESCE(CI.PART_NAME, PM.PART_NAME, '') AS PART_NAME,
(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 = CI.OBJID
AND UPPER(CIS.STATUS) = 'ACTIVE'
AND CIS.SERIAL_NO IS NOT NULL) AS SERIAL_NO,
-- 생산WBS 건수 (WBS_TYPE='PRODUCE')
(SELECT COUNT(1)::int FROM PMS_WBS_TASK O
WHERE O.CONTRACT_OBJID = PM.OBJID::VARCHAR
AND O.WBS_TYPE = 'PRODUCE') AS PROD_WBS_CNT,
-- 생산진척율: Level1 PROGRESS 평균
CASE
WHEN (SELECT COUNT(1) FROM PMS_WBS_TASK O
WHERE O.CONTRACT_OBJID = PM.OBJID::VARCHAR
AND O.WBS_TYPE = 'PRODUCE'
AND O.TASK_LEVEL = '1') = 0 THEN 0
ELSE ROUND(
(SELECT COALESCE(AVG(CASE WHEN O.PROGRESS IS NOT NULL AND O.PROGRESS != ''
THEN CAST(O.PROGRESS AS numeric) ELSE 0 END), 0)
FROM PMS_WBS_TASK O
WHERE O.CONTRACT_OBJID = PM.OBJID::VARCHAR
AND O.WBS_TYPE = 'PRODUCE'
AND O.TASK_LEVEL = '1'), 1)
END AS PROD_PROGRESS_RATE,
-- 납품WBS 건수 (WBS_TYPE='SHIP')
(SELECT COUNT(1)::int FROM PMS_WBS_TASK O
WHERE O.CONTRACT_OBJID = PM.OBJID::VARCHAR
AND O.WBS_TYPE = 'SHIP') AS DELV_WBS_CNT,
-- 납품진척율: Level1 PROGRESS 평균
CASE
WHEN (SELECT COUNT(1) FROM PMS_WBS_TASK O
WHERE O.CONTRACT_OBJID = PM.OBJID::VARCHAR
AND O.WBS_TYPE = 'SHIP'
AND O.TASK_LEVEL = '1') = 0 THEN 0
ELSE ROUND(
(SELECT COALESCE(AVG(CASE WHEN O.PROGRESS IS NOT NULL AND O.PROGRESS != ''
THEN CAST(O.PROGRESS AS numeric) ELSE 0 END), 0)
FROM PMS_WBS_TASK O
WHERE O.CONTRACT_OBJID = PM.OBJID::VARCHAR
AND O.WBS_TYPE = 'SHIP'
AND O.TASK_LEVEL = '1'), 1)
END AS DELV_PROGRESS_RATE,
PM.REGDATE AS SORT_DATE
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'
LEFT OUTER JOIN PRODUCTION_PLAN PP ON PP.PROJECT_OBJID = PM.OBJID::VARCHAR AND PP.STATUS = 'active'
WHERE PM.PROJECT_NO IS NOT NULL AND PM.PROJECT_NO != ''
AND CM.PRODUCT = '0000928'
) T
WHERE 1=1
`;
export async function listEquip(filter: ProdPlanResultFilter) {
const { limit, offset, page, pageSize } = paginate(filter);
const where = buildWhere(filter, 1, false);
const pool = getPool();
const dataSql = `
SELECT * FROM (${SQL_EQUIP_INNER} ${where.sql}) X
ORDER BY X.SORT_DATE DESC NULLS LAST, X.PROJECT_NO DESC
LIMIT $${where.params.length + 1} OFFSET $${where.params.length + 2}
`;
const countSql = `SELECT COUNT(*)::int AS cnt FROM (${SQL_EQUIP_INNER} ${where.sql}) X`;
const [dataRes, countRes] = await Promise.all([
pool.query(dataSql, [...where.params, limit, offset]),
pool.query(countSql, where.params),
]);
return {
rows: dataRes.rows,
totalCount: countRes.rows[0]?.cnt ?? 0,
page,
pageSize,
};
}
// ─── 검색 옵션 — 프로젝트번호 / 등록자 ──────────────────────
//
// 운영판 페이지 진입 시 모델에 담아주던 code_map.project_no / code_map.writer 대체.
export async function getProjectNoOptions() {
const pool = getPool();
const r = await pool.query(`
SELECT PM.OBJID::VARCHAR AS code, PM.PROJECT_NO AS label
FROM PROJECT_MGMT PM
WHERE PM.PROJECT_NO IS NOT NULL AND PM.PROJECT_NO != ''
ORDER BY PM.REGDATE DESC, PM.PROJECT_NO DESC
LIMIT 1000
`);
return r.rows;
}
export async function getWriterOptions() {
const pool = getPool();
// PRODUCTION_PLAN.WRITER 와 PROJECT_MGMT.REGISTER 모두 user_info 의 user_id.
// 4개 메뉴 중 메뉴1 의 search_writer 만 사용. 활성 등록자만.
const r = await pool.query(`
SELECT DISTINCT PP.WRITER AS code,
COALESCE(user_name(PP.WRITER), PP.WRITER) AS label
FROM PRODUCTION_PLAN PP
WHERE PP.WRITER IS NOT NULL AND PP.WRITER != ''
AND PP.STATUS = 'active'
ORDER BY label
`);
return r.rows;
}
@@ -0,0 +1,438 @@
// ============================================================
// 구매관리 — 7개 메뉴 그리드/옵션 서비스
//
// wace_plm 1:1 이식 베이스. 마스터 테이블만 RPS 에 존재 (2026-05-14 현재):
// ✓ sales_request_master / mbom_header / mbom_detail
// ✓ purchase_order_master (56 cols, 0 rows)
// ✓ client_mng / supply_mng / admin_supply_mng / part_mng / project_mgmt / contract_mgmt
//
// 누락 (운영DB 추출 후 신설 필요):
// ✗ sales_request_part / sales_request_detail
// ✗ quotation_request_master / quotation_received
// ✗ purchase_order_part
// ✗ arrival_plan
// ✗ inventory_mgmt / inventory_mgmt_in
// ✗ incoming_inspection / incoming_inspection_detail
//
// 정책: 누락 테이블 의존 SELECT 는 빈 그리드 + 콘솔 warning 으로 처리.
// 마스터 단독 데이터는 정상 노출 (구매리스트관리 / 품의서관리 / 발주서관리).
// ============================================================
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
export interface PurchaseListFilter {
year?: string;
customer_objid?: string;
customer_cd?: string;
project_no?: string;
part_no?: string;
part_name?: string;
part_spec?: string;
partner_objid?: string;
purchase_order_no?: string;
proposal_no?: string;
search_status?: string;
writer?: string;
request_user?: string;
purchase_type?: string;
part_type?: string;
product_cd?: string;
paid_type?: string;
mail_send_yn?: string;
delivery_status?: string;
close_status?: string;
sales_mng_user_id?: string;
regdate_start?: string;
regdate_end?: string;
receipt_date_start?: string;
receipt_date_end?: string;
delivery_start_date?: string;
delivery_end_date?: string;
reg_start_date?: string;
reg_end_date?: string;
page?: number;
page_size?: number;
}
interface ListResult<T> {
rows: T[];
totalCount: number;
page: number;
pageSize: number;
}
function clampPaging(filter: PurchaseListFilter): { limit: number; offset: number; page: number; pageSize: number } {
const page = Math.max(1, Number(filter.page ?? 1));
const pageSize = Math.max(1, Math.min(500, Number(filter.page_size ?? 50)));
return { limit: pageSize, offset: (page - 1) * pageSize, page, pageSize };
}
// ─── 1) 구매리스트관리 (wace salesMng.xml salesRequestMngRegList 매퍼 1:1 베이스) ──
//
// sales_request_master + (sales_request_part 누락 → PART_NO/PART_NAME 빈값) +
// project_mgmt + contract_mgmt + comm_code + client_mng (customer 분기).
//
// WHERE: doc_type = 'PURCHASE_REQUEST' (또는 NULL) + 동적 필터.
// ORDER: regdate DESC.
export async function listPurchaseRequest(filter: PurchaseListFilter): Promise<ListResult<any>> {
const pool = getPool();
const { limit, offset, page, pageSize } = clampPaging(filter);
const where: string[] = [`(SRM.DOC_TYPE = 'PURCHASE_REQUEST' OR SRM.DOC_TYPE IS NULL)`];
const params: any[] = [];
const addParam = (val: any) => { params.push(val); return `$${params.length}`; };
if (filter.customer_cd) where.push(`SRM.CUSTOMER_OBJID = ${addParam(filter.customer_cd)}`);
if (filter.project_no) where.push(`PM.PROJECT_NO ILIKE ${addParam(`%${filter.project_no}%`)}`);
if (filter.request_user) where.push(`SRM.REQUEST_USER_ID = ${addParam(filter.request_user)}`);
if (filter.part_type) where.push(`SRM.PRODUCT_NAME = ${addParam(filter.part_type)}`);
if (filter.regdate_start) where.push(`SRM.REGDATE::DATE >= ${addParam(filter.regdate_start)}::DATE`);
if (filter.regdate_end) where.push(`SRM.REGDATE::DATE <= ${addParam(filter.regdate_end)}::DATE`);
if (filter.part_no) where.push(`EXISTS (SELECT 1 FROM MBOM_DETAIL MD JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR=PP.OBJID::VARCHAR WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID AND PP.PART_NO ILIKE ${addParam(`%${filter.part_no}%`)})`);
if (filter.part_name) where.push(`EXISTS (SELECT 1 FROM MBOM_DETAIL MD JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR=PP.OBJID::VARCHAR WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID AND PP.PART_NAME ILIKE ${addParam(`%${filter.part_name}%`)})`);
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const dataSql = `
SELECT
SRM.OBJID AS objid,
SRM.REQUEST_MNG_NO AS request_mng_no,
SRM.DOC_TYPE AS doc_type,
SRM.STATUS AS status,
CASE SRM.STATUS
WHEN 'create' THEN '작성중'
WHEN 'approvalRequest' THEN '결재중'
WHEN 'approvalComplete' THEN '결재완료'
WHEN 'reject' THEN '반려'
WHEN 'release' THEN '진행중'
ELSE COALESCE(SRM.STATUS, '')
END AS status_title,
COALESCE(
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.PURCHASE_TYPE LIMIT 1), ''
) AS purchase_type_name,
COALESCE(
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.ORDER_TYPE LIMIT 1), ''
) AS order_type_name,
COALESCE(
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.PRODUCT_NAME LIMIT 1), ''
) AS product_name_full,
CASE WHEN SRM.PAID_TYPE = 'paid' THEN '유상'
WHEN SRM.PAID_TYPE = 'free' THEN '무상'
ELSE COALESCE(SRM.PAID_TYPE, '')
END AS paid_type_name,
PM.PROJECT_NO AS project_number,
-- 고객사 (wace 동일 — 프로젝트.contract_mgmt.customer_objid 우선)
COALESCE(
(SELECT CM2.CLIENT_NM FROM CLIENT_MNG CM2
WHERE 'C_' || CM2.OBJID::VARCHAR = CTR.CUSTOMER_OBJID LIMIT 1),
(SELECT SUPPLY_NAME FROM SUPPLY_MNG SM
WHERE SM.OBJID::VARCHAR = CTR.CUSTOMER_OBJID LIMIT 1),
''
) AS customer_name,
-- 품번/품명 (MBOM_DETAIL → PART_MNG, 다중이면 "외 N건")
COALESCE((SELECT PP.PART_NO FROM MBOM_DETAIL MD
JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR = PP.OBJID::VARCHAR
WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID
ORDER BY MD.REGDATE LIMIT 1), '') AS part_no,
COALESCE((SELECT PP.PART_NAME FROM MBOM_DETAIL MD
JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR = PP.OBJID::VARCHAR
WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID
ORDER BY MD.REGDATE LIMIT 1), '') AS part_name,
(SELECT COUNT(DISTINCT PP.PART_NO)::int - 1
FROM MBOM_DETAIL MD
JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR = PP.OBJID::VARCHAR
WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID) AS part_extra_count,
-- 견적요청서 존재여부 (quotation_request_master 누락 → 일괄 'N')
'N' AS has_quotation_request,
SRM.REQUEST_USER_ID AS request_user,
COALESCE(user_name(SRM.REQUEST_USER_ID), SRM.REQUEST_USER_ID, '') AS request_user_name,
SRM.DELIVERY_REQUEST_DATE AS delivery_request_date,
TO_CHAR(SRM.REGDATE, 'YYYY-MM-DD') AS regdate_title,
SRM.MBOM_HEADER_OBJID AS mbom_header_objid
FROM SALES_REQUEST_MASTER SRM
LEFT JOIN PROJECT_MGMT PM ON PM.OBJID::VARCHAR = SRM.PROJECT_NO
LEFT JOIN CONTRACT_MGMT CTR ON CTR.OBJID = PM.CONTRACT_OBJID
${whereSql}
ORDER BY SRM.REGDATE DESC
LIMIT ${addParam(limit)} OFFSET ${addParam(offset)}
`;
const countSql = `
SELECT COUNT(*)::int AS cnt
FROM SALES_REQUEST_MASTER SRM
LEFT JOIN PROJECT_MGMT PM ON PM.OBJID::VARCHAR = SRM.PROJECT_NO
LEFT JOIN CONTRACT_MGMT CTR ON CTR.OBJID = PM.CONTRACT_OBJID
${whereSql}
`;
try {
const [d, c] = await Promise.all([
pool.query(dataSql, params),
pool.query(countSql, params.slice(0, params.length - 2)),
]);
return { rows: d.rows, totalCount: c.rows[0]?.cnt ?? 0, page, pageSize };
} catch (e: any) {
logger.error("listPurchaseRequest 실패", { error: e.message });
return { rows: [], totalCount: 0, page, pageSize };
}
}
// ─── 2) 견적요청서관리 (wace salesMng.xml quotationRequestList) ──
// quotation_request_master 누락 → 빈 그리드.
export async function listQuotationRequest(filter: PurchaseListFilter): Promise<ListResult<any>> {
const { page, pageSize } = clampPaging(filter);
logger.warn("listQuotationRequest: quotation_request_master 테이블 미존재 — 빈 응답");
return { rows: [], totalCount: 0, page, pageSize };
}
// ─── 3) 품의서관리 (wace salesMng.xml proposalMngList) ──
// sales_request_master.doc_type='PROPOSAL'.
// 결재 우선순위: AMR.STATUS > APPROVAL.APPR_STATUS > SRM.STATUS('create'→'등록중')
export async function listProposal(filter: PurchaseListFilter): Promise<ListResult<any>> {
const pool = getPool();
const { limit, offset, page, pageSize } = clampPaging(filter);
const where: string[] = [
`SRM.STATUS IN ('create','approvalRequest','approvalComplete','reject')`,
`(SRM.DOC_TYPE = 'PROPOSAL' OR SRM.DOC_TYPE = 'PURCHASE_REG_PROPOSAL')`,
];
const params: any[] = [];
const addParam = (val: any) => { params.push(val); return `$${params.length}`; };
if (filter.proposal_no) where.push(`SRM.REQUEST_MNG_NO ILIKE ${addParam(`%${filter.proposal_no}%`)}`);
if (filter.project_no) where.push(`EXISTS (SELECT 1 FROM PROJECT_MGMT PMX WHERE PMX.OBJID::VARCHAR = SRM.PROJECT_NO AND PMX.PROJECT_NO ILIKE ${addParam(`%${filter.project_no}%`)})`);
if (filter.search_status) where.push(`SRM.STATUS = ${addParam(filter.search_status)}`);
if (filter.regdate_start) where.push(`SRM.REGDATE::DATE >= ${addParam(filter.regdate_start)}::DATE`);
if (filter.regdate_end) where.push(`SRM.REGDATE::DATE <= ${addParam(filter.regdate_end)}::DATE`);
if (filter.purchase_type) where.push(`SRM.PURCHASE_TYPE = ${addParam(filter.purchase_type)}`);
if (filter.writer) where.push(`SRM.WRITER = ${addParam(filter.writer)}`);
if (filter.part_type) where.push(`SRM.PRODUCT_NAME = ${addParam(filter.part_type)}`);
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const dataSql = `
SELECT
SRM.OBJID AS objid,
SRM.REQUEST_MNG_NO AS proposal_no,
SRM.STATUS AS status,
-- AMARANTH_STATUS 컬럼 RPS 미존재 → NULL (wace 1순위 결재상태 우선순위 향후 보완)
NULL::text AS amaranth_status,
PM.PROJECT_NO AS project_number,
COALESCE(
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.PURCHASE_TYPE LIMIT 1), ''
) AS purchase_type_name,
COALESCE(
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.ORDER_TYPE LIMIT 1), ''
) AS order_type_name,
COALESCE(
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.PRODUCT_NAME LIMIT 1), ''
) AS product_name_title,
COALESCE((SELECT PP.PART_NO FROM MBOM_DETAIL MD
JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR = PP.OBJID::VARCHAR
WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID
ORDER BY MD.REGDATE LIMIT 1), '') AS part_no,
COALESCE((SELECT PP.PART_NAME FROM MBOM_DETAIL MD
JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR = PP.OBJID::VARCHAR
WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID
ORDER BY MD.REGDATE LIMIT 1), '') AS part_name,
(SELECT COUNT(DISTINCT PP.PART_NO)::int - 1
FROM MBOM_DETAIL MD
JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR = PP.OBJID::VARCHAR
WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID) AS part_extra_count,
CASE SRM.STATUS
WHEN 'create' THEN '작성중'
WHEN 'approvalRequest' THEN '결재중'
WHEN 'approvalComplete' THEN '결재완료'
WHEN 'reject' THEN '반려'
ELSE COALESCE(SRM.STATUS, '')
END AS status_title,
TO_CHAR(SRM.REGDATE, 'YYYY-MM-DD') AS regdate_title,
SRM.WRITER AS writer,
COALESCE(user_name(SRM.WRITER), SRM.WRITER, '') AS writer_name,
SRM.MBOM_HEADER_OBJID AS mbom_header_objid
FROM SALES_REQUEST_MASTER SRM
LEFT JOIN PROJECT_MGMT PM ON PM.OBJID::VARCHAR = SRM.PROJECT_NO
${whereSql}
ORDER BY SRM.REGDATE DESC
LIMIT ${addParam(limit)} OFFSET ${addParam(offset)}
`;
const countSql = `
SELECT COUNT(*)::int AS cnt
FROM SALES_REQUEST_MASTER SRM
LEFT JOIN PROJECT_MGMT PM ON PM.OBJID::VARCHAR = SRM.PROJECT_NO
${whereSql}
`;
try {
const [d, c] = await Promise.all([
pool.query(dataSql, params),
pool.query(countSql, params.slice(0, params.length - 2)),
]);
return { rows: d.rows, totalCount: c.rows[0]?.cnt ?? 0, page, pageSize };
} catch (e: any) {
logger.error("listProposal 실패", { error: e.message });
return { rows: [], totalCount: 0, page, pageSize };
}
}
// ─── 4) 입고관리 (wace purchaseOrder.xml deliveryMngAcceptanceList) ──
// purchase_order_master + purchase_order_part(누락) + arrival_plan(누락).
// 누락 의존 — purchase_order_master 단독으로 빈 그리드 처리.
export async function listInbound(filter: PurchaseListFilter): Promise<ListResult<any>> {
const { page, pageSize } = clampPaging(filter);
logger.warn("listInbound: purchase_order_part / arrival_plan 미존재 — 빈 응답");
return { rows: [], totalCount: 0, page, pageSize };
}
// ─── 5) 품목별 입고관리 (wace deliveryMngPartList) ──
export async function listInboundByItem(filter: PurchaseListFilter): Promise<ListResult<any>> {
const { page, pageSize } = clampPaging(filter);
logger.warn("listInboundByItem: purchase_order_part / arrival_plan 미존재 — 빈 응답");
return { rows: [], totalCount: 0, page, pageSize };
}
// ─── 6) 입고일별 입고관리 (wace purchaseCloseList) ──
export async function listInboundByDate(filter: PurchaseListFilter): Promise<ListResult<any>> {
const { page, pageSize } = clampPaging(filter);
logger.warn("listInboundByDate: arrival_plan / purchase_order_part 미존재 — 빈 응답");
return { rows: [], totalCount: 0, page, pageSize };
}
// ─── 7) 프로젝트별 발주/입고 현황 (wace projectPurchaseDeliveryStatus) ──
// contract_mgmt + mbom_header/detail (전체수량/품목수) + purchase_order_master/part (발주현황) +
// arrival_plan (입고현황). 발주/입고는 누락 테이블 의존 → 0 표시.
export async function listProjectStatus(filter: PurchaseListFilter): Promise<ListResult<any>> {
const pool = getPool();
const { limit, offset, page, pageSize } = clampPaging(filter);
// wace 운영판 WHERE CTR.MAIL_SEND_DATE IS NOT NULL — RPS contract_mgmt 미존재 컬럼이라 생략 (전체 노출)
const where: string[] = [];
const params: any[] = [];
const addParam = (val: any) => { params.push(val); return `$${params.length}`; };
if (filter.customer_objid) where.push(`CTR.CUSTOMER_OBJID = ${addParam(filter.customer_objid)}`);
if (filter.project_no) where.push(`PM.PROJECT_NO ILIKE ${addParam(`%${filter.project_no}%`)}`);
if (filter.product_cd) where.push(`CTR.PRODUCT = ${addParam(filter.product_cd)}`);
if (filter.part_no) where.push(`PM.PART_NO ILIKE ${addParam(`%${filter.part_no}%`)}`);
if (filter.part_name) where.push(`PM.PART_NAME ILIKE ${addParam(`%${filter.part_name}%`)}`);
if (filter.year) where.push(`EXTRACT(YEAR FROM PM.REGDATE) = ${addParam(Number(filter.year))}`);
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const dataSql = `
SELECT
PM.OBJID AS objid,
PM.PROJECT_NO AS project_no,
COALESCE(
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CTR.PRODUCT LIMIT 1), ''
) AS product_name,
PM.PART_NO AS part_no,
PM.PART_NAME AS part_name,
COALESCE(
(SELECT CLIENT_NM FROM CLIENT_MNG CL
WHERE 'C_' || CL.OBJID::VARCHAR = CTR.CUSTOMER_OBJID LIMIT 1),
(SELECT SUPPLY_NAME FROM SUPPLY_MNG SM
WHERE SM.OBJID::VARCHAR = CTR.CUSTOMER_OBJID LIMIT 1),
''
) AS customer_name,
-- BOM 기준 (mbom_detail)
COALESCE((SELECT COUNT(DISTINCT MD.PART_OBJID)::int
FROM MBOM_DETAIL MD
JOIN MBOM_HEADER MH2 ON MD.MBOM_HEADER_OBJID = MH2.OBJID
WHERE MH2.PROJECT_OBJID = PM.OBJID::VARCHAR
AND MH2.STATUS = 'Y'), 0) AS total_item_cnt,
COALESCE((SELECT SUM(NULLIF(MD.QTY, '')::numeric)
FROM MBOM_DETAIL MD
JOIN MBOM_HEADER MH2 ON MD.MBOM_HEADER_OBJID = MH2.OBJID
WHERE MH2.PROJECT_OBJID = PM.OBJID::VARCHAR
AND MH2.STATUS = 'Y'), 0) AS total_qty,
-- 발주/입고/미발주/미입고 — purchase_order_part / arrival_plan 누락이라 모두 0
0::int AS po_item_cnt,
0::numeric AS po_qty,
COALESCE((SELECT COUNT(DISTINCT MD.PART_OBJID)::int
FROM MBOM_DETAIL MD
JOIN MBOM_HEADER MH2 ON MD.MBOM_HEADER_OBJID = MH2.OBJID
WHERE MH2.PROJECT_OBJID = PM.OBJID::VARCHAR
AND MH2.STATUS = 'Y'), 0) AS non_po_item_cnt,
COALESCE((SELECT SUM(NULLIF(MD.QTY, '')::numeric)
FROM MBOM_DETAIL MD
JOIN MBOM_HEADER MH2 ON MD.MBOM_HEADER_OBJID = MH2.OBJID
WHERE MH2.PROJECT_OBJID = PM.OBJID::VARCHAR
AND MH2.STATUS = 'Y'), 0) AS non_po_qty,
0::int AS dlv_item_cnt,
0::numeric AS dlv_qty,
0::int AS non_dlv_item_cnt,
0::numeric AS non_dlv_qty
FROM PROJECT_MGMT PM
LEFT JOIN CONTRACT_MGMT CTR ON CTR.OBJID = PM.CONTRACT_OBJID
${whereSql}
ORDER BY PM.REGDATE DESC
LIMIT ${addParam(limit)} OFFSET ${addParam(offset)}
`;
const countSql = `
SELECT COUNT(*)::int AS cnt
FROM PROJECT_MGMT PM
LEFT JOIN CONTRACT_MGMT CTR ON CTR.OBJID = PM.CONTRACT_OBJID
${whereSql}
`;
try {
const [d, c] = await Promise.all([
pool.query(dataSql, params),
pool.query(countSql, params.slice(0, params.length - 2)),
]);
return { rows: d.rows, totalCount: c.rows[0]?.cnt ?? 0, page, pageSize };
} catch (e: any) {
logger.error("listProjectStatus 실패", { error: e.message });
return { rows: [], totalCount: 0, page, pageSize };
}
}
// ─── 옵션 — 공급업체 / 작성자 (구매메뉴 공용) ──────────────────
export async function listSupplierOptions(): Promise<{ code: string; label: string }[]> {
const pool = getPool();
try {
const r = await pool.query(
`SELECT OBJID::VARCHAR AS code, SUPPLY_NAME AS label
FROM SUPPLY_MNG
WHERE COALESCE(STATUS, 'active') IN ('active', '활성')
AND SUPPLY_NAME IS NOT NULL AND SUPPLY_NAME <> ''
ORDER BY SUPPLY_NAME`,
);
return r.rows;
} catch {
return [];
}
}
export async function listUserOptions(): Promise<{ code: string; label: string }[]> {
const pool = getPool();
try {
const r = await pool.query(
`SELECT USER_ID AS code,
USER_NAME || COALESCE(' (' || DEPT_NAME || ')', '') AS label
FROM USER_INFO
WHERE COALESCE(STATUS, 'active') IN ('active', '활성', 'ACTIVE')
AND USER_NAME IS NOT NULL AND USER_NAME <> ''
ORDER BY USER_NAME`,
);
return r.rows;
} catch {
return [];
}
}
export async function listProjectOptions(): Promise<{ code: string; label: string }[]> {
const pool = getPool();
try {
const r = await pool.query(
`SELECT OBJID::VARCHAR AS code, PROJECT_NO AS label
FROM PROJECT_MGMT
WHERE PROJECT_NO IS NOT NULL AND PROJECT_NO <> ''
ORDER BY PROJECT_NO DESC
LIMIT 500`,
);
return r.rows;
} catch {
return [];
}
}
@@ -6,19 +6,23 @@
// 액션:
// PR-A1: 조회 / 초기화 / 페이지
// PR-A2: 행 더블클릭 → MbomDetailDialog (헤더 + read-only 트리 4분기)
// ※ BOM 복사 / 구매리스트 생성 / M-BOM 본 편집 — PR-B 분리.
// PR-B3: [구매리스트 생성] 버튼 — wace fn_openPurchaseListPopup 1:1 (단건 체크 + 1:1 강제)
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ShoppingCart, Loader2 } from "lucide-react";
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 { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { apiClient } from "@/lib/api/client";
import { mbomApi, MbomListFilter, MbomRow } from "@/lib/api/mbom";
import { MbomDetailDialog } from "@/components/production/MbomDetailDialog";
import { exportToExcel } from "@/lib/utils/excelExport";
const PARENT_CATEGORY = "0000167"; // 주문유형 comm_code parent_code_id
const PARENT_PRODUCT = "0000001"; // 제품구분 comm_code parent_code_id
@@ -57,6 +61,7 @@ const EMPTY_FILTER: MbomListFilter = {
// 그리드 컬럼은 useMemo 로 컴포넌트 내부에서 생성 — onClick(openDialog) 캡처 위해.
export default function MbomMgmtPage() {
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
const [rows, setRows] = useState<MbomRow[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
@@ -69,6 +74,10 @@ export default function MbomMgmtPage() {
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogObjid, setDialogObjid] = useState<string | null>(null);
// PR-B3: 구매리스트 생성 — 그리드 단건 체크 + 버튼 트리거 (wace 1:1)
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [creatingPurchaseList, setCreatingPurchaseList] = useState(false);
const fetchList = useCallback(async (override?: Partial<MbomListFilter>) => {
setLoading(true);
try {
@@ -125,25 +134,41 @@ export default function MbomMgmtPage() {
const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([
{ key: "project_no", label: "프로젝트번호", width: "w-[140px]" },
{ key: "category_name", label: "주문유형", width: "w-[100px]", align: "center" },
{ key: "product_name", label: "제품구분", width: "w-[90px]", align: "center" },
{ key: "area_name", label: "국내/해외", width: "w-[90px]", align: "center" },
{ key: "receipt_date", label: "접수일", width: "w-[100px]", align: "center" },
{ key: "writer_name", label: "작성자", width: "w-[90px]", align: "center" },
{ key: "category_name", label: "주문유형", width: "w-[115px]", align: "center" },
{ key: "product_name", label: "제품구분", width: "w-[115px]", align: "center" },
{ key: "area_name", label: "국내/해외", width: "w-[115px]", align: "center" },
{ key: "receipt_date", label: "접수일", width: "w-[115px]", align: "center" },
{ key: "writer_name", label: "작성자", width: "w-[115px]", align: "center" },
{ key: "customer_name", label: "고객사", minWidth: "min-w-[160px]" },
{ key: "paid_type_name", label: "유/무상", width: "w-[80px]", align: "center" },
{ key: "paid_type_name", label: "유/무상", width: "w-[100px]", 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: "serial_no", label: "S/N", width: "w-[115px]", align: "center" },
{ key: "quantity", label: "수주수량", width: "w-[115px]", align: "right", formatNumber: true },
{ key: "req_del_date", label: "요청납기", width: "w-[115px]", align: "center" },
{ key: "customer_request", label: "고객사요청사항", minWidth: "min-w-[200px]" },
// M-BOM 컬럼 — 폴더 아이콘 (저장됨=파랑, 미저장=흰색). 클릭 시 본 편집 다이얼로그.
{ key: "mbom_has", label: "M-BOM", width: "w-[80px]", align: "center",
{ key: "mbom_has", label: "M-BOM", width: "w-[100px]", align: "center",
renderType: "folder", onClick: openMbomDialog },
{ key: "mbom_regdate", label: "최종저장일", width: "w-[100px]", align: "center" },
{ key: "mbom_regdate", label: "최종저장일", width: "w-[125px]", align: "center" },
]), [openMbomDialog]);
// ─── 하단 통계 ──────────────────────────────────────────────
// 전체 건수(서버 total) / 현재 페이지 건수 / 수주수량 합계(페이지) / M-BOM 저장 비율(페이지)
const mbomSummary = useMemo(() => {
const pageCount = gridRows.length;
const qtySum = gridRows.reduce((acc, r: any) => acc + Number(r.quantity || 0), 0);
const hasMbom = gridRows.reduce((acc, r: any) => acc + (Number(r.mbom_has || 0) > 0 ? 1 : 0), 0);
const rate = pageCount === 0 ? 0 : (hasMbom / pageCount) * 100;
const intFmt = (n: number) => n.toLocaleString();
return [
{ label: "전체 건수", value: intFmt(total), suffix: "건" },
{ label: "페이지 건수", value: intFmt(pageCount), suffix: "건" },
{ label: "수주수량 합계", value: intFmt(qtySum) },
{ label: "M-BOM 저장", value: `${intFmt(hasMbom)} / ${intFmt(pageCount)}`, suffix: `(${rate.toFixed(1)}%)` },
];
}, [gridRows, total]);
const handleSearch = () => {
setFilter((f) => ({ ...f, page: 1 }));
fetchList({ page: 1 });
@@ -154,12 +179,71 @@ export default function MbomMgmtPage() {
fetchList(EMPTY_FILTER);
};
// PR-B3 — 구매리스트 생성 (wace fn_openPurchaseListPopup 1:1)
// 검증 순서: 1행 선택 → MBOM_HEADER_OBJID 존재 → PURCHASE_LIST_OBJID 없음 → confirm → POST
const handleCreatePurchaseList = useCallback(async () => {
if (checkedIds.length === 0) {
toast.info("구매리스트를 생성할 프로젝트를 선택해주세요.");
return;
}
if (checkedIds.length > 1) {
toast.info("한 번에 하나의 프로젝트만 선택해주세요.");
return;
}
const row = gridRows.find((r: any) => r.id === checkedIds[0]) as any;
if (!row?.objid) {
toast.error("프로젝트 OBJID를 찾을 수 없습니다.");
return;
}
if (!row.mbom_header_objid) {
toast.warning("M-BOM이 생성되지 않았습니다.\n먼저 M-BOM을 생성해주세요.");
return;
}
if (row.purchase_list_objid) {
toast.info("이미 생성된 구매리스트가 있습니다.\n구매리스트관리 화면에서 확인하세요.");
return;
}
const ok = await confirm("구매리스트를 생성하시겠어요?", {
description: `프로젝트 ${row.project_no ?? ""} 의 M-BOM 으로 구매리스트(SALES_REQUEST)를 생성합니다.`,
confirmText: "생성",
});
if (!ok) return;
setCreatingPurchaseList(true);
try {
const res = await mbomApi.createSalesRequest({
mbom_header_objid: String(row.mbom_header_objid),
project_mgmt_objid: String(row.objid),
});
toast.success(`구매리스트가 생성되었어요. (${res.request_mng_no})`);
setCheckedIds([]);
fetchList();
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "구매리스트 생성 실패");
} finally {
setCreatingPurchaseList(false);
}
}, [checkedIds, gridRows, confirm, fetchList]);
return (
<div className="flex h-full flex-col gap-2 p-2">
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
<PageHeader
loading={loading}
onSearch={handleSearch}
onReset={handleReset}
actions={
<Button
size="sm"
variant="default"
className="h-8 gap-1 px-2 text-xs"
onClick={handleCreatePurchaseList}
disabled={creatingPurchaseList || checkedIds.length === 0}
>
{creatingPurchaseList
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <ShoppingCart className="h-3.5 w-3.5" />}
</Button>
}
/>
<CompactFilterBar
totalText={<> {total.toLocaleString()} · PROJECT_MGMT × CONTRACT_ITEM </>}
@@ -234,29 +318,45 @@ export default function MbomMgmtPage() {
</CompactFilterField>
</CompactFilterBar>
<div className="min-h-0 flex-1">
<DataGrid
columns={GRID_COLUMNS}
data={gridRows}
loading={loading}
showRowNumber
emptyMessage="조건에 맞는 프로젝트가 없습니다."
gridId="production-mbom-mgmt"
pageSizeOptions={[25, 50, 100, 200, 500]}
paginationStyle="range"
serverPaging
serverPage={filter.page ?? 1}
serverPageSize={filter.page_size ?? 50}
serverTotalItems={total}
onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }}
onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }}
onRowDoubleClick={(row: any) => {
if (!row?.objid) return;
setDialogObjid(String(row.objid));
setDialogOpen(true);
}}
/>
</div>
<DataGrid
columns={GRID_COLUMNS}
data={gridRows}
loading={loading}
showRowNumber
showCheckbox
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
emptyMessage="조건에 맞는 프로젝트가 없습니다."
gridId="production-mbom-mgmt"
pageSizeOptions={[25, 50, 100, 200, 500]}
paginationStyle="range"
serverPaging
serverPage={filter.page ?? 1}
serverPageSize={filter.page_size ?? 50}
serverTotalItems={total}
onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }}
onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }}
onRowDoubleClick={(row: any) => {
if (!row?.objid) return;
setDialogObjid(String(row.objid));
setDialogOpen(true);
}}
showColumnSettings
summaryStats={mbomSummary}
systemColumnKeys={["writer_name", "mbom_regdate"]}
onRefresh={() => fetchList()}
onDownload={() => {
if (gridRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
// 서버 페이지네이션 — 현재 페이지 행만 export. 전체 export 는 별 endpoint 필요시 추가.
const exportRows = gridRows.map((r: any) => {
const out: Record<string, any> = {};
GRID_COLUMNS.forEach((col) => { out[col.label] = r[col.key] ?? ""; });
return out;
});
exportToExcel(exportRows, "M-BOM_관리.xlsx", "M-BOM_관리");
}}
showChart
/>
<MbomDetailDialog
open={dialogOpen}
@@ -264,6 +364,8 @@ export default function MbomMgmtPage() {
projectObjid={dialogObjid}
onSaved={fetchList}
/>
{ConfirmDialogComponent}
</div>
);
}
@@ -0,0 +1,201 @@
"use client";
// 생산관리 > 생산계획&실적관리(장비) — wace productionplanning/prodPlanResultMgmtEquipList.jsp 1:1
// PROJECT_MGMT WHERE CM.PRODUCT='0000928'. PMS_WBS_TASK 기반 생산/납품 진척율.
// 페이지네이션: 서버사이드.
// (WBS 할당 모달은 wace_plm 의 별 작업이라 본 페이지에서는 제외 — 본 PR-D2 범위 외)
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 { prodPlanResultApi, ProdPlanResultFilter, ProdPlanResultEquipRow } from "@/lib/api/prodPlanResult";
import { exportToExcel } from "@/lib/utils/excelExport";
const PARENT_PRODUCT = "0000001";
const PARENT_CATEGORY = "0000167";
const PARENT_PRODUCTION_TY = "0001832";
interface CodeOpt extends SmartSelectOption { sort?: number | null }
const EMPTY_FILTER: ProdPlanResultFilter = {
search_project_nos: "",
search_product_code: "",
search_category_code: "",
search_production_type: "",
search_customer_objid: "",
search_req_del_date_from: "",
search_req_del_date_to: "",
search_part_no: "",
search_part_name: "",
search_serial_no: "",
page: 1,
page_size: 50,
};
export default function ProdPlanResultEquipPage() {
const [rows, setRows] = useState<ProdPlanResultEquipRow[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<ProdPlanResultFilter>(EMPTY_FILTER);
const [categoryOpts, setCategoryOpts] = useState<CodeOpt[]>([]);
const [productOpts, setProductOpts] = useState<CodeOpt[]>([]);
const [prodTypeOpts, setProdTypeOpts] = useState<CodeOpt[]>([]);
const [projectOpts, setProjectOpts] = useState<SmartSelectOption[]>([]);
const fetchList = useCallback(async (override?: Partial<ProdPlanResultFilter>) => {
setLoading(true);
try {
const f = { ...filter, ...override };
const res = await prodPlanResultApi.listEquip(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, proj] = await Promise.all([
apiClient.get(`/sales/codes/${PARENT_CATEGORY}`),
apiClient.get(`/sales/codes/${PARENT_PRODUCT}`),
apiClient.get(`/sales/codes/${PARENT_PRODUCTION_TY}`),
prodPlanResultApi.getProjectOptions(),
]);
if (dead) return;
setCategoryOpts(c1.data?.data ?? []);
setProductOpts(c2.data?.data ?? []);
setProdTypeOpts(c3.data?.data ?? []);
setProjectOpts(proj);
} catch {/* ignore */}
})();
fetchList(EMPTY_FILTER);
return () => { dead = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const gridRows = useMemo(
() => rows.map((r, i) => ({ ...r, id: `${r.objid}__${i}` })),
[rows],
);
const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([
{ key: "project_no", label: "프로젝트번호", width: "w-[140px]" },
{ key: "product_name", label: "제품구분", width: "w-[110px]", align: "center" },
{ key: "category_code_name", label: "주문유형", width: "w-[110px]", align: "center" },
{ key: "production_type_name", label: "생산유형", width: "w-[110px]", align: "center" },
{ key: "customer_name", label: "고객사", minWidth: "min-w-[160px]" },
{ key: "req_del_date", label: "요청납기", width: "w-[115px]", align: "center" },
{ key: "customer_request", label: "고객사요청사항", minWidth: "min-w-[200px]" },
{ key: "part_no", label: "품번", width: "w-[140px]" },
{ key: "part_name", label: "품명", minWidth: "min-w-[180px]" },
{ key: "serial_no", label: "S/N", width: "w-[110px]", align: "center" },
{ key: "prod_wbs_cnt", label: "생산WBS", width: "w-[110px]", align: "right", formatNumber: true },
{ key: "prod_progress_rate", label: "생산진척율(%)", width: "w-[140px]", align: "right" },
{ key: "delv_wbs_cnt", label: "납품WBS", width: "w-[110px]", align: "right", formatNumber: true },
{ key: "delv_progress_rate", label: "납품진척율(%)", width: "w-[140px]", align: "right" },
]), []);
const summary = useMemo(() => {
const pageCount = gridRows.length;
const avg = (key: string) => {
if (pageCount === 0) return 0;
const sum = gridRows.reduce((a, r: any) => a + Number(r[key] || 0), 0);
return sum / pageCount;
};
const intFmt = (n: number) => n.toLocaleString();
return [
{ label: "전체 건수", value: intFmt(total), suffix: "건" },
{ label: "페이지 건수", value: intFmt(pageCount), suffix: "건" },
{ label: "생산진척율 평균", value: avg("prod_progress_rate").toFixed(1), suffix: "%" },
{ label: "납품진척율 평균", value: avg("delv_progress_rate").toFixed(1), suffix: "%" },
];
}, [gridRows, total]);
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 overflow-hidden p-2 gap-2">
<PageHeader loading={loading} onSearch={handleSearch} onReset={handleReset} />
<CompactFilterBar totalText={<> {total.toLocaleString()} · PRODUCT=(0000928) </>}>
<CompactFilterField label="프로젝트번호" width={180}>
<SmartSelect options={projectOpts} value={filter.search_project_nos ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_project_nos: v })} />
</CompactFilterField>
<CompactFilterField label="제품구분" width={130}>
<SmartSelect options={productOpts} value={filter.search_product_code ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_product_code: v })} />
</CompactFilterField>
<CompactFilterField label="주문유형" width={130}>
<SmartSelect options={categoryOpts} value={filter.search_category_code ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_category_code: v })} />
</CompactFilterField>
<CompactFilterField label="생산유형" width={130}>
<SmartSelect options={prodTypeOpts} value={filter.search_production_type ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_production_type: v })} />
</CompactFilterField>
<CompactFilterField label="고객사" width={170}>
<CustomerSelect value={filter.search_customer_objid ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_customer_objid: 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>
<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="S/N" width={130}>
<Input value={filter.search_serial_no ?? ""} onChange={(e) => setFilter({ ...filter, search_serial_no: e.target.value })} />
</CompactFilterField>
</CompactFilterBar>
<DataGrid
columns={GRID_COLUMNS}
data={gridRows}
loading={loading}
showRowNumber
showCheckbox
emptyMessage="조건에 맞는 데이터가 없습니다."
gridId="production-plan-result-equip"
pageSizeOptions={[25, 50, 100, 200, 500]}
paginationStyle="range"
serverPaging
serverPage={filter.page ?? 1}
serverPageSize={filter.page_size ?? 50}
serverTotalItems={total}
onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }}
onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }}
showColumnSettings
summaryStats={summary}
onRefresh={() => fetchList()}
onDownload={() => {
if (gridRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
const exportRows = gridRows.map((r: any) => {
const out: Record<string, any> = {};
GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; });
return out;
});
exportToExcel(exportRows, "생산계획_실적관리_장비.xlsx", "생산계획_실적관리_장비");
}}
showChart
/>
</div>
);
}
@@ -0,0 +1,251 @@
"use client";
// 생산관리 > 생산계획&실적관리 — wace productionplanning/prodPlanResultMgmtList.jsp 1:1
// 그리드: PROJECT_MGMT × CONTRACT_ITEM × PRODUCTION_PLAN × PRODUCTION_RESULT 합산 + 독립 PP 합본
// 필터: 프로젝트번호 / 제품구분 / 주문유형 / 생산유형 / 고객사 / 요청납기 / 품번 / 품명 / S/N / 등록자 / 등록일
// 페이지네이션: 서버사이드 (page / page_size / totalCount)
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 { prodPlanResultApi, ProdPlanResultFilter, ProdPlanResultRow } from "@/lib/api/prodPlanResult";
import { exportToExcel } from "@/lib/utils/excelExport";
const PARENT_PRODUCT = "0000001";
const PARENT_CATEGORY = "0000167";
const PARENT_PRODUCTION_TY = "0001832";
interface CodeOpt extends SmartSelectOption { sort?: number | null }
const EMPTY_FILTER: ProdPlanResultFilter = {
search_project_nos: "",
search_product_code: "",
search_category_code: "",
search_production_type: "",
search_customer_objid: "",
search_req_del_date_from: "",
search_req_del_date_to: "",
search_part_no: "",
search_part_name: "",
search_serial_no: "",
search_writer: "",
search_regdate_from: "",
search_regdate_to: "",
page: 1,
page_size: 50,
};
export default function ProdPlanResultPage() {
const [rows, setRows] = useState<ProdPlanResultRow[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<ProdPlanResultFilter>(EMPTY_FILTER);
const [categoryOpts, setCategoryOpts] = useState<CodeOpt[]>([]);
const [productOpts, setProductOpts] = useState<CodeOpt[]>([]);
const [prodTypeOpts, setProdTypeOpts] = useState<CodeOpt[]>([]);
const [projectOpts, setProjectOpts] = useState<SmartSelectOption[]>([]);
const [writerOpts, setWriterOpts] = useState<SmartSelectOption[]>([]);
const [serialOpen, setSerialOpen] = useState(false);
const [serialList, setSerialList] = useState<string[]>([]);
const fetchList = useCallback(async (override?: Partial<ProdPlanResultFilter>) => {
setLoading(true);
try {
const f = { ...filter, ...override };
const res = await prodPlanResultApi.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, proj, writers] = await Promise.all([
apiClient.get(`/sales/codes/${PARENT_CATEGORY}`),
apiClient.get(`/sales/codes/${PARENT_PRODUCT}`),
apiClient.get(`/sales/codes/${PARENT_PRODUCTION_TY}`),
prodPlanResultApi.getProjectOptions(),
prodPlanResultApi.getWriterOptions(),
]);
if (dead) return;
setCategoryOpts(c1.data?.data ?? []);
setProductOpts(c2.data?.data ?? []);
setProdTypeOpts(c3.data?.data ?? []);
setProjectOpts(proj);
setWriterOpts(writers);
} catch {/* ignore */}
})();
fetchList(EMPTY_FILTER);
return () => { dead = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const gridRows = useMemo(
() => rows.map((r, i) => ({ ...r, id: `${r.objid}__${i}` })),
[rows],
);
const openSerial = useCallback((row: any) => {
const list = String(row?.serial_no_list ?? "")
.split(",").map((s) => s.trim()).filter(Boolean);
if (list.length === 0) { toast.info("S/N 정보가 없습니다."); return; }
setSerialList(list);
setSerialOpen(true);
}, []);
const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([
{ key: "project_no", label: "프로젝트번호", width: "w-[140px]" },
{ key: "product_name", label: "제품구분", width: "w-[110px]", align: "center" },
{ key: "category_code_name", label: "주문유형", width: "w-[110px]", align: "center" },
{ key: "production_type_name",label: "생산유형", width: "w-[110px]", align: "center" },
{ key: "customer_name", label: "고객사", minWidth: "min-w-[160px]" },
{ key: "req_del_date", label: "요청납기", width: "w-[115px]", align: "center" },
{ key: "customer_request", label: "고객사요청사항", minWidth: "min-w-[190px]" },
{ key: "part_no", label: "품번", width: "w-[140px]" },
{ key: "part_name", label: "품명", minWidth: "min-w-[180px]" },
{ key: "serial_no", label: "S/N", width: "w-[110px]", align: "center", onClick: openSerial },
{ key: "quantity", label: "수주수량", width: "w-[110px]", align: "right", formatNumber: true },
{ key: "extra_prod_qty", label: "추가생산수량", width: "w-[130px]", align: "right", formatNumber: true },
{ key: "total_prod_qty", label: "총생산수량", width: "w-[120px]", align: "right", formatNumber: true },
{ key: "assembly_qty", label: "완조립", width: "w-[95px]", align: "right", formatNumber: true },
{ key: "inspection_qty", label: "검사", width: "w-[85px]", align: "right", formatNumber: true },
{ key: "ship_wait_qty", label: "포장", width: "w-[85px]", align: "right", formatNumber: true },
{ key: "writer_name", label: "등록자", width: "w-[95px]", align: "center" },
{ key: "regdate_title", label: "등록일", width: "w-[105px]", align: "center" },
]), [openSerial]);
const summary = useMemo(() => {
const pageCount = gridRows.length;
const qtySum = gridRows.reduce((a, r: any) => a + Number(r.quantity || 0), 0);
const totalSum = gridRows.reduce((a, r: any) => a + Number(r.total_prod_qty || 0), 0);
const asmSum = gridRows.reduce((a, r: any) => a + Number(r.assembly_qty || 0), 0);
const intFmt = (n: number) => n.toLocaleString();
return [
{ label: "전체 건수", value: intFmt(total), suffix: "건" },
{ label: "페이지 건수", value: intFmt(pageCount), suffix: "건" },
{ label: "수주수량 합", value: intFmt(qtySum) },
{ label: "총생산수량 합", value: intFmt(totalSum) },
{ label: "완조립 합", value: intFmt(asmSum) },
];
}, [gridRows, total]);
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 overflow-hidden p-2 gap-2">
<PageHeader loading={loading} onSearch={handleSearch} onReset={handleReset} />
<CompactFilterBar totalText={<> {total.toLocaleString()} · + </>}>
<CompactFilterField label="프로젝트번호" width={180}>
<SmartSelect
options={projectOpts}
value={filter.search_project_nos ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_project_nos: v })}
/>
</CompactFilterField>
<CompactFilterField label="제품구분" width={130}>
<SmartSelect options={productOpts} value={filter.search_product_code ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_product_code: v })} />
</CompactFilterField>
<CompactFilterField label="주문유형" width={130}>
<SmartSelect options={categoryOpts} value={filter.search_category_code ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_category_code: v })} />
</CompactFilterField>
<CompactFilterField label="생산유형" width={130}>
<SmartSelect options={prodTypeOpts} value={filter.search_production_type ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_production_type: v })} />
</CompactFilterField>
<CompactFilterField label="고객사" width={170}>
<CustomerSelect value={filter.search_customer_objid ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_customer_objid: 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>
<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="S/N" width={130}>
<Input value={filter.search_serial_no ?? ""} onChange={(e) => setFilter({ ...filter, search_serial_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="등록자" width={130}>
<SmartSelect options={writerOpts} value={filter.search_writer ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_writer: v })} />
</CompactFilterField>
<CompactFilterField label="등록일" width={280}>
<CompactDateRange
from={filter.search_regdate_from ?? ""} setFrom={(v) => setFilter({ ...filter, search_regdate_from: v })}
to={filter.search_regdate_to ?? ""} setTo={(v) => setFilter({ ...filter, search_regdate_to: v })} />
</CompactFilterField>
</CompactFilterBar>
<DataGrid
columns={GRID_COLUMNS}
data={gridRows}
loading={loading}
showRowNumber
showCheckbox
emptyMessage="조건에 맞는 데이터가 없습니다."
gridId="production-plan-result"
pageSizeOptions={[25, 50, 100, 200, 500]}
paginationStyle="range"
serverPaging
serverPage={filter.page ?? 1}
serverPageSize={filter.page_size ?? 50}
serverTotalItems={total}
onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }}
onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }}
showColumnSettings
summaryStats={summary}
systemColumnKeys={["writer_name", "regdate_title"]}
onRefresh={() => fetchList()}
onDownload={() => {
if (gridRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
const exportRows = gridRows.map((r: any) => {
const out: Record<string, any> = {};
GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; });
return out;
});
exportToExcel(exportRows, "생산계획_실적관리.xlsx", "생산계획_실적관리");
}}
showChart
/>
{serialOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
onClick={() => setSerialOpen(false)}>
<div className="w-[500px] max-h-[70vh] overflow-auto rounded bg-white p-4 shadow"
onClick={(e) => e.stopPropagation()}>
<div className="mb-2 text-sm font-semibold">S/N ({serialList.length})</div>
<ol className="list-decimal pl-6 text-sm">
{serialList.map((sn, i) => <li key={i} className="py-0.5">{sn}</li>)}
</ol>
<div className="mt-3 text-right">
<button className="rounded bg-blue-600 px-3 py-1 text-xs text-white"
onClick={() => setSerialOpen(false)}></button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,206 @@
"use client";
// 생산관리 > 원자재 소요량 — wace productionplanning/rawMaterialRequirementList.jsp 1:1
// 입력: M-BOM 헤더 선택 + 입력수량 → 여러 행
// 출력: 구매품(PART_TYPE='0000063') + 원소재(RAW_MATERIAL_PART_NO) 합본
// 페이지네이션: 결과 전체. 클라이언트 페이지네이션.
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Plus, Trash2 } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { PageHeader } from "@/components/common/PageHeader";
import { mbomRequirementApi, MbomOption, RawRequirementRow } from "@/lib/api/prodPlanResult";
import { exportToExcel } from "@/lib/utils/excelExport";
interface InputRow {
id: string;
mbomObjid: string;
partName: string;
qty: number;
checked: boolean;
}
let rowSeq = 0;
const newRow = (): InputRow => ({ id: `r${++rowSeq}`, mbomObjid: "", partName: "", qty: 1, checked: false });
export default function RawMaterialRequirementPage() {
const [mbomOpts, setMbomOpts] = useState<MbomOption[]>([]);
const [inputs, setInputs] = useState<InputRow[]>([newRow()]);
const [results, setResults] = useState<RawRequirementRow[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
(async () => {
try {
const opts = await mbomRequirementApi.getOptions();
setMbomOpts(opts);
} catch (e: any) {
toast.error(e?.message ?? "M-BOM 옵션 로드 실패");
}
})();
}, []);
const partNameMap = useMemo(() => {
const m = new Map<string, string>();
mbomOpts.forEach((o) => m.set(o.objid, o.part_name));
return m;
}, [mbomOpts]);
const addRow = () => setInputs((rs) => [...rs, newRow()]);
const removeChecked = () => {
const next = inputs.filter((r) => !r.checked);
if (next.length === inputs.length) {
toast.info("삭제할 행을 선택해주세요.");
return;
}
setInputs(next.length === 0 ? [newRow()] : next);
};
const updateRow = (id: string, patch: Partial<InputRow>) => {
setInputs((rs) => rs.map((r) => (r.id === id ? { ...r, ...patch } : r)));
};
const handleSearch = useCallback(async () => {
const items = inputs
.filter((r) => r.mbomObjid && Number(r.qty) > 0)
.map((r) => ({ mbomObjid: r.mbomObjid, qty: Number(r.qty) }));
if (items.length === 0) {
toast.info("M-BOM을 선택하고 수량을 입력해주세요.");
return;
}
setLoading(true);
try {
const res = await mbomRequirementApi.getRaw(items);
setResults(res);
if (res.length === 0) toast.info("구매품 항목이 없습니다.");
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
setResults([]);
} finally {
setLoading(false);
}
}, [inputs]);
const resultRows = useMemo(
() => results.map((r, i) => ({ ...r, id: `${r.PART_NO || r.MATERIAL_PART_NO}__${i}` })),
[results],
);
const RESULT_COLUMNS: DataGridColumn[] = useMemo(() => ([
{ key: "CATEGORY_NAME", label: "구분", width: "w-[110px]", align: "center" },
{ key: "PART_NO", label: "품번", minWidth: "min-w-[180px]" },
{ key: "PART_NAME", label: "품명", minWidth: "min-w-[200px]" },
{ key: "REQUIRED_QTY", label: "소요량", width: "w-[110px]", align: "right", formatNumber: true },
{ key: "MATERIAL", label: "소재", width: "w-[120px]", align: "center" },
{ key: "SPEC", label: "사이즈", width: "w-[110px]", align: "center" },
{ key: "MATERIAL_PART_NO", label: "소재품번", minWidth: "min-w-[180px]" },
{ key: "MATERIAL_REQUIRED_QTY", label: "소재소요량", width: "w-[110px]", align: "right", formatNumber: true },
]), []);
const summary = useMemo(() => {
const purchaseCount = resultRows.filter((r: any) => r.CATEGORY_NAME !== "원소재").length;
const rawCount = resultRows.length - purchaseCount;
return [
{ label: "조회결과", value: resultRows.length.toLocaleString(), suffix: "건" },
{ label: "구매품", value: purchaseCount.toLocaleString(), suffix: "건" },
{ label: "원소재", value: rawCount.toLocaleString(), suffix: "건" },
];
}, [resultRows]);
return (
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
<PageHeader
loading={loading}
onSearch={handleSearch}
actions={
<>
<Button size="sm" variant="outline" className="h-8 gap-1 px-2 text-xs" onClick={addRow}>
<Plus className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="outline" className="h-8 gap-1 px-2 text-xs" onClick={removeChecked}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>
}
/>
<div className="rounded border bg-white p-2">
<div className="mb-1 border-l-[3px] border-blue-500 pl-2 text-xs font-semibold">
M-BOM
</div>
<div className="overflow-auto" style={{ maxHeight: 180 }}>
<table className="w-full border-collapse text-xs">
<thead className="bg-gray-50">
<tr className="border-b">
<th className="w-10 border px-1 py-1"></th>
<th className="border px-2 py-1 text-left">M-BOM</th>
<th className="border px-2 py-1 text-left"></th>
<th className="w-32 border px-2 py-1 text-right"></th>
</tr>
</thead>
<tbody>
{inputs.map((r) => (
<tr key={r.id} className="border-b">
<td className="border px-1 py-1 text-center">
<input type="checkbox" checked={r.checked}
onChange={(e) => updateRow(r.id, { checked: e.target.checked })} />
</td>
<td className="border px-1 py-1">
<select
className="h-7 w-full rounded border px-1 text-xs"
value={r.mbomObjid}
onChange={(e) => updateRow(r.id, {
mbomObjid: e.target.value,
partName: partNameMap.get(e.target.value) ?? "",
})}
>
<option value=""></option>
{mbomOpts.map((o) => (
<option key={o.objid} value={o.objid}>{o.mbom_no}</option>
))}
</select>
</td>
<td className="border px-2 py-1 text-gray-700">{r.partName}</td>
<td className="border px-1 py-1">
<Input
type="number" min={1} step={1}
className="h-7 text-right text-xs"
value={r.qty}
onChange={(e) => updateRow(r.id, { qty: Math.max(1, Number(e.target.value) || 1) })}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<DataGrid
columns={RESULT_COLUMNS}
data={resultRows}
loading={loading}
showRowNumber
emptyMessage="조회 결과가 없습니다."
gridId="production-raw-material-req"
pageSizeOptions={[25, 50, 100, 200, 500]}
paginationStyle="range"
showColumnSettings
summaryStats={summary}
onRefresh={handleSearch}
onDownload={() => {
if (resultRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
const exportRows = resultRows.map((r: any) => {
const out: Record<string, any> = {};
RESULT_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; });
return out;
});
exportToExcel(exportRows, "원자재_소요량.xlsx", "원자재_소요량");
}}
showChart
/>
</div>
);
}
@@ -0,0 +1,207 @@
"use client";
// 생산관리 > 반제품 소요량 — wace productionplanning/semiProductRequirementList.jsp 1:1
// 입력: M-BOM 헤더 선택(드롭다운) + 입력수량 → 여러 행
// 출력: MBOM_DETAIL × PART_MNG (PART_TYPE IN 부품/조립품) 합산
// 페이지네이션: 결과는 한 번에 전체. 클라이언트 페이지네이션.
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Plus, Trash2 } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { PageHeader } from "@/components/common/PageHeader";
import { mbomRequirementApi, MbomOption, SemiRequirementRow } from "@/lib/api/prodPlanResult";
import { exportToExcel } from "@/lib/utils/excelExport";
interface InputRow {
id: string;
mbomObjid: string;
partName: string;
qty: number;
checked: boolean;
}
let rowSeq = 0;
const newRow = (): InputRow => ({ id: `r${++rowSeq}`, mbomObjid: "", partName: "", qty: 1, checked: false });
export default function SemiProductRequirementPage() {
const [mbomOpts, setMbomOpts] = useState<MbomOption[]>([]);
const [inputs, setInputs] = useState<InputRow[]>([newRow()]);
const [results, setResults] = useState<SemiRequirementRow[]>([]);
const [loading, setLoading] = useState(false);
// M-BOM 옵션 로드
useEffect(() => {
(async () => {
try {
const opts = await mbomRequirementApi.getOptions();
setMbomOpts(opts);
} catch (e: any) {
toast.error(e?.message ?? "M-BOM 옵션 로드 실패");
}
})();
}, []);
const partNameMap = useMemo(() => {
const m = new Map<string, string>();
mbomOpts.forEach((o) => m.set(o.objid, o.part_name));
return m;
}, [mbomOpts]);
const addRow = () => setInputs((rs) => [...rs, newRow()]);
const removeChecked = () => {
const next = inputs.filter((r) => !r.checked);
if (next.length === inputs.length) {
toast.info("삭제할 행을 선택해주세요.");
return;
}
setInputs(next.length === 0 ? [newRow()] : next);
};
const updateRow = (id: string, patch: Partial<InputRow>) => {
setInputs((rs) => rs.map((r) => (r.id === id ? { ...r, ...patch } : r)));
};
const handleSearch = useCallback(async () => {
const items = inputs
.filter((r) => r.mbomObjid && Number(r.qty) > 0)
.map((r) => ({ mbomObjid: r.mbomObjid, qty: Number(r.qty) }));
if (items.length === 0) {
toast.info("M-BOM을 선택하고 수량을 입력해주세요.");
return;
}
setLoading(true);
try {
const res = await mbomRequirementApi.getSemi(items);
setResults(res);
if (res.length === 0) toast.info("부품 또는 조립품 항목이 없습니다.");
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
setResults([]);
} finally {
setLoading(false);
}
}, [inputs]);
const resultRows = useMemo(
() => results.map((r, i) => ({ ...r, id: `${r.PART_NO}__${i}` })),
[results],
);
const RESULT_COLUMNS: DataGridColumn[] = useMemo(() => ([
{ key: "CATEGORY_NAME", label: "구분", width: "w-[110px]", align: "center" },
{ key: "PART_NO", label: "품번", minWidth: "min-w-[180px]" },
{ key: "PART_NAME", label: "품명", minWidth: "min-w-[220px]" },
{ key: "UNIT", label: "단위", width: "w-[80px]", align: "center" },
{ key: "MATERIAL", label: "소재", width: "w-[120px]", align: "center" },
{ key: "SPEC", label: "규격", width: "w-[120px]", align: "center" },
{ key: "REQUIRED_QTY", label: "소요량", width: "w-[120px]", align: "right", formatNumber: true },
]), []);
const summary = useMemo(() => {
const qtySum = resultRows.reduce((a, r: any) => a + Number(r.REQUIRED_QTY || 0), 0);
return [
{ label: "조회결과", value: resultRows.length.toLocaleString(), suffix: "건" },
{ label: "총 소요량", value: qtySum.toLocaleString() },
];
}, [resultRows]);
return (
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
<PageHeader
loading={loading}
onSearch={handleSearch}
actions={
<>
<Button size="sm" variant="outline" className="h-8 gap-1 px-2 text-xs" onClick={addRow}>
<Plus className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="outline" className="h-8 gap-1 px-2 text-xs" onClick={removeChecked}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>
}
/>
{/* 입력 영역 */}
<div className="rounded border bg-white p-2">
<div className="mb-1 border-l-[3px] border-blue-500 pl-2 text-xs font-semibold">
M-BOM
</div>
<div className="overflow-auto" style={{ maxHeight: 180 }}>
<table className="w-full border-collapse text-xs">
<thead className="bg-gray-50">
<tr className="border-b">
<th className="w-10 border px-1 py-1"></th>
<th className="border px-2 py-1 text-left">M-BOM</th>
<th className="border px-2 py-1 text-left"></th>
<th className="w-32 border px-2 py-1 text-right"></th>
</tr>
</thead>
<tbody>
{inputs.map((r) => (
<tr key={r.id} className="border-b">
<td className="border px-1 py-1 text-center">
<input type="checkbox" checked={r.checked}
onChange={(e) => updateRow(r.id, { checked: e.target.checked })} />
</td>
<td className="border px-1 py-1">
<select
className="h-7 w-full rounded border px-1 text-xs"
value={r.mbomObjid}
onChange={(e) => updateRow(r.id, {
mbomObjid: e.target.value,
partName: partNameMap.get(e.target.value) ?? "",
})}
>
<option value=""></option>
{mbomOpts.map((o) => (
<option key={o.objid} value={o.objid}>{o.mbom_no}</option>
))}
</select>
</td>
<td className="border px-2 py-1 text-gray-700">{r.partName}</td>
<td className="border px-1 py-1">
<Input
type="number" min={1} step={1}
className="h-7 text-right text-xs"
value={r.qty}
onChange={(e) => updateRow(r.id, { qty: Math.max(1, Number(e.target.value) || 1) })}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* 결과 영역 */}
<DataGrid
columns={RESULT_COLUMNS}
data={resultRows}
loading={loading}
showRowNumber
emptyMessage="조회 결과가 없습니다."
gridId="production-semi-product-req"
pageSizeOptions={[25, 50, 100, 200, 500]}
paginationStyle="range"
showColumnSettings
summaryStats={summary}
onRefresh={handleSearch}
onDownload={() => {
if (resultRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
const exportRows = resultRows.map((r: any) => {
const out: Record<string, any> = {};
RESULT_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; });
return out;
});
exportToExcel(exportRows, "반제품_소요량.xlsx", "반제품_소요량");
}}
showChart
/>
</div>
);
}
@@ -0,0 +1,217 @@
"use client";
// 구매관리 > 입고일별 입고관리 — wace purchaseOrder/purchaseCloseList.jsp 1:1
// 검색: 년도/고객사/프로젝트/발주No/규격/품명/공급업체/구매담당자/입고일/매입마감/품번
// 그리드 26컬럼: 품의서·발주서·프로젝트·부품품번·품번·품명·공급업체·환종·입고일·담당자·등록자·
// 입고수량·입고금액·검사현황·폐기수량·확정입고수량·계정과목·국내해외·환율·과세구분·
// 세금계산서일·수출신고번호·선적일·관세·수입부가세·매입마감
// 액션: 조회 / 마감정보입력 / 매입마감
// ⚠️ arrival_plan / purchase_order_part 미존재 → 빈 그리드
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { FileEdit, Lock } from "lucide-react";
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 { purchaseApi, PurchaseListFilter, OptionItem, getYearOptions } from "@/lib/api/purchase";
import { exportToExcel } from "@/lib/utils/excelExport";
const CLOSE_OPTS: SmartSelectOption[] = [
{ code: "N", label: "미마감" },
{ code: "Y", label: "마감" },
];
const EMPTY_FILTER: PurchaseListFilter = {
year: String(new Date().getFullYear()),
customer_cd: "", project_no: "", purchase_order_no: "",
part_spec: "", part_name: "", partner_objid: "",
sales_mng_user_id: "",
receipt_date_start: "", receipt_date_end: "",
close_status: "", part_no: "",
page: 1, page_size: 50,
};
export default function InboundByDatePage() {
const [rows, setRows] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<PurchaseListFilter>(EMPTY_FILTER);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [supplierOpts, setSupplierOpts] = useState<OptionItem[]>([]);
const [userOpts, setUserOpts] = useState<OptionItem[]>([]);
const yearOpts = useMemo(() => getYearOptions(), []);
const fetchList = useCallback(async (override?: Partial<PurchaseListFilter>) => {
setLoading(true);
try {
const f = { ...filter, ...override };
const res = await purchaseApi.listInboundByDate(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 [s, u] = await Promise.all([purchaseApi.listSuppliers(), purchaseApi.listUsers()]);
if (dead) return;
setSupplierOpts(s); setUserOpts(u);
} catch { /* skip */ }
})();
fetchList(EMPTY_FILTER);
return () => { dead = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const gridRows = useMemo(() => rows.map((r, i) => ({ ...r, id: r.objid ?? `id_${i}` })), [rows]);
const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([
{ key: "proposal_no", label: "품의서 No", width: "w-[125px]", align: "center" },
{ key: "purchase_order_no", label: "발주서 No", width: "w-[125px]", align: "center" },
{ key: "project_no", label: "프로젝트번호", width: "w-[135px]", align: "center" },
{ key: "component_part_no", label: "부품품번", width: "w-[135px]" },
{ key: "part_no", label: "품번", width: "w-[135px]" },
{ key: "part_name", label: "품명", minWidth: "min-w-[180px]" },
{ key: "partner_name", label: "공급업체", minWidth: "min-w-[150px]" },
{ key: "currency_name", label: "환종", width: "w-[80px]", align: "center" },
{ key: "receipt_date", label: "입고일", width: "w-[110px]", align: "center" },
{ key: "writer_name", label: "구매담당자", width: "w-[110px]", align: "center" },
{ key: "delivery_writer_name", label: "입고등록자", width: "w-[110px]", align: "center" },
{ key: "receipt_qty", label: "입고수량", width: "w-[110px]", align: "right", formatNumber: true },
{ key: "total_delivery_price", label: "입고금액", width: "w-[120px]", align: "right", formatMoney: true },
{ key: "inspection_status", label: "검사현황", width: "w-[110px]", align: "center" },
{ key: "defect_qty", label: "폐기수량", width: "w-[110px]", align: "right", formatNumber: true },
{ key: "confirmed_qty", label: "확정입고수량", width: "w-[120px]", align: "right", formatNumber: true },
{ key: "sub_location_name", label: "계정과목", width: "w-[120px]", align: "center" },
{ key: "foreign_type_name", label: "국내/해외", width: "w-[110px]", align: "center" },
{ key: "exchange_rate", label: "환율", width: "w-[110px]", align: "right", formatNumber: true },
{ key: "tax_type_name", label: "과세구분", width: "w-[110px]", align: "center" },
{ key: "tax_invoice_date", label: "세금계산서발행일", width: "w-[140px]", align: "center" },
{ key: "export_decl_no", label: "수출신고필증신고번호", width: "w-[150px]", align: "center" },
{ key: "loading_date", label: "선적일자", width: "w-[110px]", align: "center" },
{ key: "duty", label: "관세", width: "w-[110px]", align: "right", formatMoney: true },
{ key: "import_vat", label: "수입부가세", width: "w-[110px]", align: "right", formatMoney: true },
{ key: "purchase_close_date", label: "매입마감", width: "w-[110px]", align: "center" },
]), []);
const summary = useMemo(() => [
{ label: "전체 건수", value: total.toLocaleString(), suffix: "건" },
{ label: "선택", value: checkedIds.length.toLocaleString(), suffix: "건" },
], [total, checkedIds]);
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 overflow-hidden p-2 gap-2">
<PageHeader
loading={loading} onSearch={handleSearch} onReset={handleReset}
actions={<>
<Button size="sm" variant="outline" className="h-8 gap-1 px-2 text-xs"
disabled={checkedIds.length === 0}
onClick={() => toast.info("마감정보입력 — arrival_plan 신설 후 활성")}>
<FileEdit className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="default" className="h-8 gap-1 px-2 text-xs"
disabled={checkedIds.length === 0}
onClick={() => toast.info("매입마감 — arrival_plan 신설 후 활성")}>
<Lock className="h-3.5 w-3.5" />
</Button>
</>}
/>
<CompactFilterBar totalText={<> {total.toLocaleString()}</>}>
<CompactFilterField label="년도" width={100}>
<SmartSelect options={yearOpts} value={filter.year ?? ""}
onValueChange={(v) => setFilter({ ...filter, year: v })} />
</CompactFilterField>
<CompactFilterField label="고객사" width={170}>
<CustomerSelect value={filter.customer_cd ?? ""}
onValueChange={(v) => setFilter({ ...filter, customer_cd: v })} />
</CompactFilterField>
<CompactFilterField label="프로젝트번호" width={160}>
<Input value={filter.project_no ?? ""}
onChange={(e) => setFilter({ ...filter, project_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="발주No" width={140}>
<Input value={filter.purchase_order_no ?? ""}
onChange={(e) => setFilter({ ...filter, purchase_order_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="규격" width={130}>
<Input value={filter.part_spec ?? ""}
onChange={(e) => setFilter({ ...filter, part_spec: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="품명" width={150}>
<Input value={filter.part_name ?? ""}
onChange={(e) => setFilter({ ...filter, part_name: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="공급업체" width={170}>
<SmartSelect options={supplierOpts} value={filter.partner_objid ?? ""}
onValueChange={(v) => setFilter({ ...filter, partner_objid: v })} />
</CompactFilterField>
<CompactFilterField label="구매담당자" width={150}>
<SmartSelect options={userOpts} value={filter.sales_mng_user_id ?? ""}
onValueChange={(v) => setFilter({ ...filter, sales_mng_user_id: v })} />
</CompactFilterField>
<CompactFilterField label="입고일" width={280}>
<CompactDateRange
from={filter.receipt_date_start ?? ""} setFrom={(v) => setFilter({ ...filter, receipt_date_start: v })}
to={filter.receipt_date_end ?? ""} setTo={(v) => setFilter({ ...filter, receipt_date_end: v })}
/>
</CompactFilterField>
<CompactFilterField label="매입마감" width={120}>
<SmartSelect options={CLOSE_OPTS} value={filter.close_status ?? ""}
onValueChange={(v) => setFilter({ ...filter, close_status: v })} />
</CompactFilterField>
<CompactFilterField label="품번" width={140}>
<Input value={filter.part_no ?? ""}
onChange={(e) => setFilter({ ...filter, part_no: e.target.value })} />
</CompactFilterField>
</CompactFilterBar>
<DataGrid
columns={GRID_COLUMNS}
data={gridRows}
loading={loading}
showCheckbox
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"}
gridId="purchase-inbound-by-date"
pageSizeOptions={[10, 15, 20, 50, 100]}
paginationStyle="range"
serverPaging
serverPage={filter.page ?? 1}
serverPageSize={filter.page_size ?? 50}
serverTotalItems={total}
onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }}
onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }}
showColumnSettings
summaryStats={summary}
systemColumnKeys={["delivery_writer_name", "purchase_close_date"]}
onRefresh={() => fetchList()}
onDownload={() => {
if (gridRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
const exportRows = gridRows.map((r: any) => {
const out: Record<string, any> = {};
GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; });
return out;
});
exportToExcel(exportRows, "입고일별_입고관리.xlsx", "입고일별");
}}
showChart
/>
</div>
);
}
@@ -0,0 +1,198 @@
"use client";
// 구매관리 > 품목별 입고관리 — wace purchaseOrder/deliveryMngAcceptancePartList.jsp 1:1
// 검색: 입고관리와 거의 동일 + 부품품번 추가
// 그리드: 품의서/발주서/프로젝트/부품품번/품번/품명/공급업체/환종/입고요청일/담당자/입고등록자/일/
// 발주·입고·미입고 수량+금액 / 검사현황 / 폐기수량 / 확정입고수량
// 액션: 조회만 (입고등록·매입마감은 비활성)
// ⚠️ purchase_order_part / arrival_plan 미존재 → 빈 그리드
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 { purchaseApi, PurchaseListFilter, OptionItem, getYearOptions } from "@/lib/api/purchase";
import { exportToExcel } from "@/lib/utils/excelExport";
const DELIVERY_STATUS_OPTS: SmartSelectOption[] = [
{ code: "입고중", label: "입고중" },
{ code: "입고완료", label: "입고완료" },
{ code: "지연", label: "지연" },
];
const EMPTY_FILTER: PurchaseListFilter = {
year: String(new Date().getFullYear()),
customer_cd: "", project_no: "", purchase_order_no: "",
part_spec: "", part_name: "", partner_objid: "",
sales_mng_user_id: "",
delivery_start_date: "", delivery_end_date: "",
reg_start_date: "", reg_end_date: "",
delivery_status: "", part_no: "",
page: 1, page_size: 50,
};
export default function InboundByItemPage() {
const [rows, setRows] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<PurchaseListFilter>(EMPTY_FILTER);
const [supplierOpts, setSupplierOpts] = useState<OptionItem[]>([]);
const [userOpts, setUserOpts] = useState<OptionItem[]>([]);
const yearOpts = useMemo(() => getYearOptions(), []);
const fetchList = useCallback(async (override?: Partial<PurchaseListFilter>) => {
setLoading(true);
try {
const f = { ...filter, ...override };
const res = await purchaseApi.listInboundByItem(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 [s, u] = await Promise.all([purchaseApi.listSuppliers(), purchaseApi.listUsers()]);
if (dead) return;
setSupplierOpts(s); setUserOpts(u);
} catch { /* skip */ }
})();
fetchList(EMPTY_FILTER);
return () => { dead = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const gridRows = useMemo(() => rows.map((r, i) => ({ ...r, id: r.objid ?? `ii_${i}` })), [rows]);
const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([
{ key: "proposal_no", label: "품의서 No", width: "w-[125px]", align: "center" },
{ key: "purchase_order_no", label: "발주서 No", width: "w-[125px]", align: "center" },
{ key: "project_no", label: "프로젝트번호", width: "w-[135px]", align: "center" },
{ key: "component_part_no", label: "부품품번", width: "w-[140px]" },
{ key: "part_no", label: "품번", width: "w-[140px]" },
{ key: "part_name", label: "품명", minWidth: "min-w-[180px]" },
{ key: "partner_name", label: "공급업체", minWidth: "min-w-[150px]" },
{ key: "currency_name", label: "환종", width: "w-[80px]", align: "center" },
{ key: "delivery_request_date", label: "입고요청일", width: "w-[115px]", align: "center" },
{ key: "writer_name", label: "구매담당자", width: "w-[110px]", align: "center" },
{ key: "delivery_writer_name", label: "입고등록자", width: "w-[110px]", align: "center" },
{ key: "delivery_regdate", label: "입고등록일", width: "w-[115px]", align: "center" },
{ key: "order_qty", label: "발주수량", width: "w-[110px]", align: "right", formatNumber: true },
{ key: "delivery_qty", label: "입고수량", width: "w-[110px]", align: "right", formatNumber: true },
{ key: "non_delivery_qty", label: "미입고수량", width: "w-[110px]", align: "right", formatNumber: true },
{ key: "total_supply_price", label: "발주금액", width: "w-[120px]", align: "right", formatMoney: true },
{ key: "total_delivery_price", label: "입고금액", width: "w-[120px]", align: "right", formatMoney: true },
{ key: "total_not_delivery_price", label: "미입고금액", width: "w-[120px]", align: "right", formatMoney: true },
{ key: "inspection_status", label: "검사현황", width: "w-[110px]", align: "center" },
{ key: "defect_qty", label: "폐기수량", width: "w-[110px]", align: "right", formatNumber: true },
{ key: "confirmed_qty", label: "확정입고수량", width: "w-[120px]", align: "right", formatNumber: true },
]), []);
const summary = useMemo(() => [
{ label: "전체 건수", value: total.toLocaleString(), suffix: "건" },
], [total]);
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 overflow-hidden p-2 gap-2">
<PageHeader loading={loading} onSearch={handleSearch} onReset={handleReset} />
<CompactFilterBar totalText={<> {total.toLocaleString()}</>}>
<CompactFilterField label="년도" width={100}>
<SmartSelect options={yearOpts} value={filter.year ?? ""}
onValueChange={(v) => setFilter({ ...filter, year: v })} />
</CompactFilterField>
<CompactFilterField label="고객사" width={170}>
<CustomerSelect value={filter.customer_cd ?? ""}
onValueChange={(v) => setFilter({ ...filter, customer_cd: v })} />
</CompactFilterField>
<CompactFilterField label="프로젝트번호" width={160}>
<Input value={filter.project_no ?? ""}
onChange={(e) => setFilter({ ...filter, project_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="발주No" width={140}>
<Input value={filter.purchase_order_no ?? ""}
onChange={(e) => setFilter({ ...filter, purchase_order_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="규격" width={130}>
<Input value={filter.part_spec ?? ""}
onChange={(e) => setFilter({ ...filter, part_spec: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="품명" width={150}>
<Input value={filter.part_name ?? ""}
onChange={(e) => setFilter({ ...filter, part_name: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="공급업체" width={170}>
<SmartSelect options={supplierOpts} value={filter.partner_objid ?? ""}
onValueChange={(v) => setFilter({ ...filter, partner_objid: v })} />
</CompactFilterField>
<CompactFilterField label="구매담당자" width={150}>
<SmartSelect options={userOpts} value={filter.sales_mng_user_id ?? ""}
onValueChange={(v) => setFilter({ ...filter, sales_mng_user_id: v })} />
</CompactFilterField>
<CompactFilterField label="입고요청일" width={280}>
<CompactDateRange
from={filter.delivery_start_date ?? ""} setFrom={(v) => setFilter({ ...filter, delivery_start_date: v })}
to={filter.delivery_end_date ?? ""} setTo={(v) => setFilter({ ...filter, delivery_end_date: v })}
/>
</CompactFilterField>
<CompactFilterField label="발주일" width={280}>
<CompactDateRange
from={filter.reg_start_date ?? ""} setFrom={(v) => setFilter({ ...filter, reg_start_date: v })}
to={filter.reg_end_date ?? ""} setTo={(v) => setFilter({ ...filter, reg_end_date: v })}
/>
</CompactFilterField>
<CompactFilterField label="입고결과" width={120}>
<SmartSelect options={DELIVERY_STATUS_OPTS} value={filter.delivery_status ?? ""}
onValueChange={(v) => setFilter({ ...filter, delivery_status: v })} />
</CompactFilterField>
<CompactFilterField label="품번" width={140}>
<Input value={filter.part_no ?? ""}
onChange={(e) => setFilter({ ...filter, part_no: e.target.value })} />
</CompactFilterField>
</CompactFilterBar>
<DataGrid
columns={GRID_COLUMNS}
data={gridRows}
loading={loading}
emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"}
gridId="purchase-inbound-by-item"
pageSizeOptions={[10, 15, 20, 50, 100]}
paginationStyle="range"
serverPaging
serverPage={filter.page ?? 1}
serverPageSize={filter.page_size ?? 50}
serverTotalItems={total}
onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }}
onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }}
showColumnSettings
summaryStats={summary}
systemColumnKeys={["delivery_writer_name", "delivery_regdate"]}
onRefresh={() => fetchList()}
onDownload={() => {
if (gridRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
const exportRows = gridRows.map((r: any) => {
const out: Record<string, any> = {};
GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; });
return out;
});
exportToExcel(exportRows, "품목별_입고관리.xlsx", "품목별 입고");
}}
showChart
/>
</div>
);
}
@@ -0,0 +1,216 @@
"use client";
// 구매관리 > 입고관리 — wace purchaseOrder/deliveryMngAcceptanceList.jsp 1:1
// 검색: 년도/고객사/프로젝트번호/발주No/규격/품명/공급업체/구매담당자/입고요청일/발주일/입고결과/품번
// 그리드: 18컬럼 (품의서/발주서No, 프로젝트, 품번/품명/공급업체, 환종, 담당자, 입고등록자/일,
// 발주/입고/미입고 수량+금액, 업체성적서, 입고결과)
// 액션: 조회 / 입고등록
// ⚠️ 백엔드 purchase_order_part / arrival_plan 미존재 → 빈 그리드 (UI 만 제공)
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { PackagePlus } from "lucide-react";
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 { purchaseApi, PurchaseListFilter, OptionItem, getYearOptions } from "@/lib/api/purchase";
import { exportToExcel } from "@/lib/utils/excelExport";
const DELIVERY_STATUS_OPTS: SmartSelectOption[] = [
{ code: "입고중", label: "입고중" },
{ code: "입고완료", label: "입고완료" },
{ code: "지연", label: "지연" },
];
const EMPTY_FILTER: PurchaseListFilter = {
year: String(new Date().getFullYear()),
customer_cd: "", project_no: "", purchase_order_no: "",
part_spec: "", part_name: "", partner_objid: "",
sales_mng_user_id: "",
delivery_start_date: "", delivery_end_date: "",
reg_start_date: "", reg_end_date: "",
delivery_status: "", part_no: "",
page: 1, page_size: 50,
};
export default function InboundPage() {
const [rows, setRows] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<PurchaseListFilter>(EMPTY_FILTER);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [supplierOpts, setSupplierOpts] = useState<OptionItem[]>([]);
const [userOpts, setUserOpts] = useState<OptionItem[]>([]);
const yearOpts = useMemo(() => getYearOptions(), []);
const fetchList = useCallback(async (override?: Partial<PurchaseListFilter>) => {
setLoading(true);
try {
const f = { ...filter, ...override };
const res = await purchaseApi.listInbound(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 [s, u] = await Promise.all([
purchaseApi.listSuppliers(),
purchaseApi.listUsers(),
]);
if (dead) return;
setSupplierOpts(s);
setUserOpts(u);
} catch { /* skip */ }
})();
fetchList(EMPTY_FILTER);
return () => { dead = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const gridRows = useMemo(() => rows.map((r, i) => ({ ...r, id: r.objid ?? `i_${i}` })), [rows]);
const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([
{ key: "proposal_no", label: "품의서 No", width: "w-[125px]", align: "center" },
{ key: "purchase_order_no", label: "발주서 No", width: "w-[125px]", align: "center" },
{ key: "project_no", label: "프로젝트번호", width: "w-[135px]", align: "center" },
{ key: "part_no", label: "품번", width: "w-[140px]" },
{ key: "part_name", label: "품명", minWidth: "min-w-[180px]" },
{ key: "partner_name", label: "공급업체", minWidth: "min-w-[150px]" },
{ key: "currency_name", label: "환종", width: "w-[80px]", align: "center" },
{ key: "writer_name", label: "구매담당자", width: "w-[110px]", align: "center" },
{ key: "delivery_writer_name", label: "입고등록자", width: "w-[110px]", align: "center" },
{ key: "delivery_regdate", label: "입고등록일", width: "w-[115px]", align: "center" },
{ key: "total_po_qty", label: "발주수량", width: "w-[110px]", align: "right", formatNumber: true },
{ key: "total_delivery_qty", label: "입고수량", width: "w-[110px]", align: "right", formatNumber: true },
{ key: "non_delivery_qty", label: "미입고수량", width: "w-[110px]", align: "right", formatNumber: true },
{ key: "total_supply_price", label: "발주금액", width: "w-[120px]", align: "right", formatMoney: true },
{ key: "total_delivery_price", label: "입고금액", width: "w-[120px]", align: "right", formatMoney: true },
{ key: "total_not_delivery_price", label: "미입고금액", width: "w-[120px]", align: "right", formatMoney: true },
{ key: "inspection_file_cnt", label: "업체성적서", width: "w-[110px]", align: "center", renderType: "clip" },
{ key: "delivery_status", label: "입고결과", width: "w-[110px]", align: "center" },
]), []);
const summary = useMemo(() => [
{ label: "전체 건수", value: total.toLocaleString(), suffix: "건" },
{ label: "선택", value: checkedIds.length.toLocaleString(), suffix: "건" },
], [total, checkedIds]);
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 overflow-hidden p-2 gap-2">
<PageHeader
loading={loading} onSearch={handleSearch} onReset={handleReset}
actions={
<Button size="sm" variant="default" className="h-8 gap-1 px-2 text-xs"
disabled={checkedIds.length !== 1}
onClick={() => toast.info("입고등록 — purchase_order_part / arrival_plan 신설 후 활성")}>
<PackagePlus className="h-3.5 w-3.5" />
</Button>
}
/>
<CompactFilterBar totalText={<> {total.toLocaleString()}</>}>
<CompactFilterField label="년도" width={100}>
<SmartSelect options={yearOpts} value={filter.year ?? ""}
onValueChange={(v) => setFilter({ ...filter, year: v })} />
</CompactFilterField>
<CompactFilterField label="고객사" width={170}>
<CustomerSelect value={filter.customer_cd ?? ""}
onValueChange={(v) => setFilter({ ...filter, customer_cd: v })} />
</CompactFilterField>
<CompactFilterField label="프로젝트번호" width={160}>
<Input value={filter.project_no ?? ""}
onChange={(e) => setFilter({ ...filter, project_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="발주No" width={140}>
<Input value={filter.purchase_order_no ?? ""}
onChange={(e) => setFilter({ ...filter, purchase_order_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="규격" width={130}>
<Input value={filter.part_spec ?? ""}
onChange={(e) => setFilter({ ...filter, part_spec: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="품명" width={150}>
<Input value={filter.part_name ?? ""}
onChange={(e) => setFilter({ ...filter, part_name: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="공급업체" width={170}>
<SmartSelect options={supplierOpts} value={filter.partner_objid ?? ""}
onValueChange={(v) => setFilter({ ...filter, partner_objid: v })} />
</CompactFilterField>
<CompactFilterField label="구매담당자" width={150}>
<SmartSelect options={userOpts} value={filter.sales_mng_user_id ?? ""}
onValueChange={(v) => setFilter({ ...filter, sales_mng_user_id: v })} />
</CompactFilterField>
<CompactFilterField label="입고요청일" width={280}>
<CompactDateRange
from={filter.delivery_start_date ?? ""} setFrom={(v) => setFilter({ ...filter, delivery_start_date: v })}
to={filter.delivery_end_date ?? ""} setTo={(v) => setFilter({ ...filter, delivery_end_date: v })}
/>
</CompactFilterField>
<CompactFilterField label="발주일" width={280}>
<CompactDateRange
from={filter.reg_start_date ?? ""} setFrom={(v) => setFilter({ ...filter, reg_start_date: v })}
to={filter.reg_end_date ?? ""} setTo={(v) => setFilter({ ...filter, reg_end_date: v })}
/>
</CompactFilterField>
<CompactFilterField label="입고결과" width={120}>
<SmartSelect options={DELIVERY_STATUS_OPTS} value={filter.delivery_status ?? ""}
onValueChange={(v) => setFilter({ ...filter, delivery_status: v })} />
</CompactFilterField>
<CompactFilterField label="품번" width={140}>
<Input value={filter.part_no ?? ""}
onChange={(e) => setFilter({ ...filter, part_no: e.target.value })} />
</CompactFilterField>
</CompactFilterBar>
<DataGrid
columns={GRID_COLUMNS}
data={gridRows}
loading={loading}
showCheckbox
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"}
gridId="purchase-inbound"
pageSizeOptions={[10, 15, 20, 50, 100]}
paginationStyle="range"
serverPaging
serverPage={filter.page ?? 1}
serverPageSize={filter.page_size ?? 50}
serverTotalItems={total}
onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }}
onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }}
showColumnSettings
summaryStats={summary}
systemColumnKeys={["delivery_writer_name", "delivery_regdate"]}
onRefresh={() => fetchList()}
onDownload={() => {
if (gridRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
const exportRows = gridRows.map((r: any) => {
const out: Record<string, any> = {};
GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; });
return out;
});
exportToExcel(exportRows, "입고관리.xlsx", "입고");
}}
showChart
/>
</div>
);
}
@@ -0,0 +1,173 @@
"use client";
// 구매관리 > 구매리스트관리 — wace salesMng/salesRequestMngRegList.jsp 1:1
// 그리드: sales_request_master + mbom_detail/part_mng (품번/품명 1건 + 외 N건) + comm_code 변환
// 검색: 품번 / 품명 / 작성일 / 고객사 / 작성자 / 제품구분 / 프로젝트번호
// 액션: 조회 / 초기화 / [구매리스트 생성→M-BOM 페이지에서 처리] (해당 화면은 조회 전용)
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 { purchaseApi, PurchaseListFilter, OptionItem } from "@/lib/api/purchase";
import { exportToExcel } from "@/lib/utils/excelExport";
const PARENT_PRODUCT = "0000001"; // 제품구분 comm_code parent_code_id
const EMPTY_FILTER: PurchaseListFilter = {
part_no: "", part_name: "", regdate_start: "", regdate_end: "",
customer_cd: "", request_user: "", part_type: "", project_no: "",
page: 1, page_size: 50,
};
export default function PurchaseListPage() {
const [rows, setRows] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<PurchaseListFilter>(EMPTY_FILTER);
const [productOpts, setProductOpts] = useState<SmartSelectOption[]>([]);
const [userOpts, setUserOpts] = useState<OptionItem[]>([]);
const fetchList = useCallback(async (override?: Partial<PurchaseListFilter>) => {
setLoading(true);
try {
const f = { ...filter, ...override };
const res = await purchaseApi.listPurchaseRequest(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 [p, u] = await Promise.all([
apiClient.get(`/sales/codes/${PARENT_PRODUCT}`),
purchaseApi.listUsers(),
]);
if (dead) return;
setProductOpts(p.data?.data ?? []);
setUserOpts(u);
} catch { /* skip */ }
})();
fetchList(EMPTY_FILTER);
return () => { dead = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const gridRows = useMemo(
() => rows.map((r, i) => ({
...r,
id: `${r.objid}__${i}`,
part_display: r.part_extra_count > 0 ? `${r.part_no}${r.part_extra_count}` : r.part_no,
part_name_display: r.part_extra_count > 0 ? `${r.part_name}${r.part_extra_count}` : r.part_name,
})),
[rows],
);
const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([
{ key: "request_mng_no", label: "요청번호", width: "w-[140px]", align: "center" },
{ key: "purchase_type_name", label: "구매유형", width: "w-[115px]", align: "center" },
{ key: "project_number", label: "프로젝트번호", width: "w-[140px]", align: "center" },
{ key: "order_type_name", label: "주문유형", width: "w-[115px]", align: "center" },
{ key: "product_name_full", label: "제품구분", width: "w-[115px]", align: "center" },
{ key: "customer_name", label: "고객사", minWidth: "min-w-[180px]" },
{ key: "paid_type_name", label: "유/무상", width: "w-[100px]", align: "center" },
{ key: "part_display", label: "품번", width: "w-[160px]" },
{ key: "part_name_display", label: "품명", minWidth: "min-w-[200px]" },
{ key: "has_quotation_request", label: "견적요청서", width: "w-[115px]", align: "center" },
{ key: "request_user_name", label: "작성자", width: "w-[115px]", align: "center" },
{ key: "delivery_request_date", label: "입고요청일", width: "w-[115px]", align: "center" },
{ key: "regdate_title", label: "작성일", width: "w-[115px]", align: "center" },
]), []);
const summary = useMemo(() => {
const pageCnt = gridRows.length;
return [
{ label: "전체 건수", value: total.toLocaleString(), suffix: "건" },
{ label: "페이지 건수", value: pageCnt.toLocaleString(), suffix: "건" },
];
}, [gridRows, total]);
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 overflow-hidden p-2 gap-2">
<PageHeader loading={loading} onSearch={handleSearch} onReset={handleReset} />
<CompactFilterBar totalText={<> {total.toLocaleString()}</>}>
<CompactFilterField label="품번" width={150}>
<Input value={filter.part_no ?? ""}
onChange={(e) => setFilter({ ...filter, part_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="품명" width={150}>
<Input value={filter.part_name ?? ""}
onChange={(e) => setFilter({ ...filter, part_name: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="작성일" width={280}>
<CompactDateRange
from={filter.regdate_start ?? ""} setFrom={(v) => setFilter({ ...filter, regdate_start: v })}
to={filter.regdate_end ?? ""} setTo={(v) => setFilter({ ...filter, regdate_end: v })}
/>
</CompactFilterField>
<CompactFilterField label="고객사" width={170}>
<CustomerSelect value={filter.customer_cd ?? ""}
onValueChange={(v) => setFilter({ ...filter, customer_cd: v })} />
</CompactFilterField>
<CompactFilterField label="작성자" width={150}>
<SmartSelect options={userOpts} value={filter.request_user ?? ""}
onValueChange={(v) => setFilter({ ...filter, request_user: v })} />
</CompactFilterField>
<CompactFilterField label="제품구분" width={130}>
<SmartSelect options={productOpts} value={filter.part_type ?? ""}
onValueChange={(v) => setFilter({ ...filter, part_type: v })} />
</CompactFilterField>
<CompactFilterField label="프로젝트번호" width={170}>
<Input value={filter.project_no ?? ""}
onChange={(e) => setFilter({ ...filter, project_no: e.target.value })} />
</CompactFilterField>
</CompactFilterBar>
<DataGrid
columns={GRID_COLUMNS}
data={gridRows}
loading={loading}
emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"}
gridId="purchase-list"
pageSizeOptions={[10, 15, 20, 50, 100]}
paginationStyle="range"
serverPaging
serverPage={filter.page ?? 1}
serverPageSize={filter.page_size ?? 50}
serverTotalItems={total}
onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }}
onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }}
showColumnSettings
summaryStats={summary}
systemColumnKeys={["request_user_name", "regdate_title"]}
onRefresh={() => fetchList()}
onDownload={() => {
if (gridRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
const exportRows = gridRows.map((r: any) => {
const out: Record<string, any> = {};
GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; });
return out;
});
exportToExcel(exportRows, "구매리스트관리.xlsx", "구매리스트");
}}
showChart
/>
</div>
);
}
@@ -31,7 +31,7 @@ import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { SmartSelect } from "@/components/common/SmartSelect";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
const MASTER_TABLE = "purchase_order_mng";
const DETAIL_TABLE = "purchase_detail";
@@ -167,30 +167,21 @@ export default function PurchaseOrderPage() {
// 테이블 설정
const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG);
// 발주 목록 컬럼 정의 (EDataTable) — 헤더별 Popover 체크박스 필터 내장
const orderTableColumns = useMemo<EDataTableColumn[]>(() => {
const numCols = new Set(["order_qty", "received_qty", "remain_qty", "unit_price", "amount"]);
// 발주 목록 컬럼 정의 — 영업관리 4메뉴와 일관성 (DataGrid + logicstudio props)
// 날짜/숫자는 데이터 매핑 단계에서 pre-format. status 는 plain text (영업메뉴 동일).
const orderTableColumns = useMemo<DataGridColumn[]>(() => {
const numCols = new Set(["order_qty", "received_qty", "remain_qty"]);
const moneyCols = new Set(["unit_price", "amount"]);
return ts.visibleColumns.map((col) => {
const base: EDataTableColumn = {
const base: DataGridColumn = {
key: col.key,
label: col.label,
align: numCols.has(col.key) ? "right" : col.key === "status" ? "center" : undefined,
align: numCols.has(col.key) || moneyCols.has(col.key)
? "right"
: col.key === "status" ? "center" : undefined,
};
if (col.key === "status") {
base.render = (_v: any, row: any) => row.status ? (
<span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold", STATUS_BADGE_CLASS[row.status] || "")}>
{row.status}
</span>
) : "-";
} else if (col.key === "order_date" || col.key === "due_date") {
const k = col.key;
base.render = (_v: any, row: any) => row[k] ? new Date(row[k]).toLocaleDateString("ko-KR") : "-";
} else if (numCols.has(col.key)) {
const k = col.key;
base.render = (_v: any, row: any) => (
<span className="font-mono">{row[k] ? Number(row[k]).toLocaleString() : ""}</span>
);
}
if (numCols.has(col.key)) base.formatNumber = true;
if (moneyCols.has(col.key)) base.formatMoney = true;
return base;
});
}, [ts.visibleColumns]);
@@ -376,6 +367,9 @@ export default function PurchaseOrderPage() {
const item = itemMap[row.item_code];
const master = masterMap[row.purchase_no];
const rawUnit = row.unit || item?.inventory_unit || "";
const fmtDate = (v: any) => v ? new Date(v).toLocaleDateString("ko-KR") : "";
const od = master?.order_date || "";
const dd = row.due_date || "";
return {
...row,
item_name: row.item_name || item?.item_name || "",
@@ -383,7 +377,8 @@ export default function PurchaseOrderPage() {
unit: resolveLabel("item_inventory_unit", rawUnit) || rawUnit,
status: master?.status || "",
supplier_name: master?.supplier_name || "",
order_date: master?.order_date || "",
order_date: od ? fmtDate(od) : "",
due_date: dd ? fmtDate(dd) : "",
memo: row.memo || master?.memo || "",
};
});
@@ -868,22 +863,33 @@ export default function PurchaseOrderPage() {
</div>
</div>
{/* 데이터 테이블 — 플랫 리스트 (EDataTable: 컬럼별 Popover 체크박스 필터 + 정렬 내장) */}
<div className="flex flex-1 min-h-0 flex-col overflow-hidden border rounded-lg bg-card">
<EDataTable
columns={orderTableColumns}
data={orders}
loading={loading}
emptyMessage="등록된 발주가 없어요"
showCheckbox
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
onRowDoubleClick={(row) => openEditModal(row.purchase_no)}
showPagination
draggableColumns={false}
columnOrderKey="c16-purchase-order-main"
/>
</div>
{/* 데이터 테이블 — 영업관리 4개 메뉴와 동일한 DataGrid + logicstudio props */}
<DataGrid
gridId="c16-purchase-order-main"
columns={orderTableColumns}
data={orders}
loading={loading}
showCheckbox
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
onRowDoubleClick={(row) => openEditModal(row.purchase_no)}
emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"}
showColumnSettings
paginationStyle="range"
pageSizeOptions={[10, 15, 20, 50, 100]}
summaryStats={[
{ label: "건수", value: totalCount.toLocaleString(), suffix: "건" },
{ label: "선택", value: checkedIds.length.toLocaleString(), suffix: "건" },
{
label: "금액 합계",
value: orders.reduce((acc, o: any) => acc + Number(o.amount || 0), 0).toLocaleString(),
},
]}
onRefresh={fetchOrders}
onDownload={handleExcelDownload}
showChart
/>
{/* 발주 등록/수정 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
@@ -0,0 +1,166 @@
"use client";
// 구매관리 > 프로젝트별 발주/입고 현황 — wace purchaseOrder/projectPurchaseDeliveryStatus.jsp 1:1
// 검색: 년도/고객사/프로젝트번호/제품구분/품번/품명
// 그리드: 프로젝트정보(5) + 전체(2) + 발주(2) + 미발주(2) + 입고(2) + 미입고(2) = 15컬럼
// 액션: 조회만
// 데이터 출처:
// - 프로젝트정보 / 전체(BOM기준) — project_mgmt + contract_mgmt + mbom_header/detail ✓
// - 발주/입고/미발주/미입고 — purchase_order_part / arrival_plan 누락 → 0 표시
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 } from "@/components/common/CompactFilterBar";
import { PageHeader } from "@/components/common/PageHeader";
import { apiClient } from "@/lib/api/client";
import { purchaseApi, PurchaseListFilter, getYearOptions } from "@/lib/api/purchase";
import { exportToExcel } from "@/lib/utils/excelExport";
const PARENT_PRODUCT = "0000001";
const EMPTY_FILTER: PurchaseListFilter = {
year: String(new Date().getFullYear()),
customer_objid: "", project_no: "", product_cd: "",
part_no: "", part_name: "",
page: 1, page_size: 50,
};
export default function ProjectStatusPage() {
const [rows, setRows] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<PurchaseListFilter>(EMPTY_FILTER);
const [productOpts, setProductOpts] = useState<SmartSelectOption[]>([]);
const yearOpts = useMemo(() => getYearOptions(), []);
const fetchList = useCallback(async (override?: Partial<PurchaseListFilter>) => {
setLoading(true);
try {
const f = { ...filter, ...override };
const res = await purchaseApi.listProjectStatus(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 r = await apiClient.get(`/sales/codes/${PARENT_PRODUCT}`);
if (dead) return;
setProductOpts(r.data?.data ?? []);
} catch { /* skip */ }
})();
fetchList(EMPTY_FILTER);
return () => { dead = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const gridRows = useMemo(() => rows.map((r, i) => ({ ...r, id: r.objid ?? `s_${i}` })), [rows]);
const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([
// 프로젝트정보
{ key: "project_no", label: "프로젝트번호", width: "w-[140px]", align: "center", frozen: true },
{ key: "product_name", label: "제품구분", width: "w-[115px]", align: "center" },
{ key: "part_no", label: "품번", width: "w-[150px]" },
{ key: "part_name", label: "품명", minWidth: "min-w-[200px]" },
{ key: "customer_name", label: "고객사", minWidth: "min-w-[180px]" },
// 전체 (BOM기준)
{ key: "total_item_cnt", label: "전체품목수", width: "w-[115px]", align: "right", formatNumber: true },
{ key: "total_qty", label: "전체수량", width: "w-[115px]", align: "right", formatNumber: true },
// 발주현황
{ key: "po_item_cnt", label: "발주품목수", width: "w-[115px]", align: "right", formatNumber: true },
{ key: "po_qty", label: "발주수량", width: "w-[115px]", align: "right", formatNumber: true },
// 미발주현황
{ key: "non_po_item_cnt", label: "미발주품목수", width: "w-[125px]", align: "right", formatNumber: true },
{ key: "non_po_qty", label: "미발주수량", width: "w-[115px]", align: "right", formatNumber: true },
// 입고현황
{ key: "dlv_item_cnt", label: "입고품목수", width: "w-[115px]", align: "right", formatNumber: true },
{ key: "dlv_qty", label: "입고수량", width: "w-[115px]", align: "right", formatNumber: true },
// 미입고현황
{ key: "non_dlv_item_cnt", label: "미입고품목수", width: "w-[125px]", align: "right", formatNumber: true },
{ key: "non_dlv_qty", label: "미입고수량", width: "w-[115px]", align: "right", formatNumber: true },
]), []);
const summary = useMemo(() => {
const pageQty = gridRows.reduce((acc, r: any) => acc + Number(r.total_qty || 0), 0);
return [
{ label: "전체 건수", value: total.toLocaleString(), suffix: "건" },
{ label: "페이지 전체수량 합계", value: pageQty.toLocaleString() },
];
}, [gridRows, total]);
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 overflow-hidden p-2 gap-2">
<PageHeader loading={loading} onSearch={handleSearch} onReset={handleReset} />
<CompactFilterBar totalText={<> {total.toLocaleString()}</>}>
<CompactFilterField label="년도" width={100}>
<SmartSelect options={yearOpts} value={filter.year ?? ""}
onValueChange={(v) => setFilter({ ...filter, year: v })} />
</CompactFilterField>
<CompactFilterField label="고객사" width={170}>
<CustomerSelect value={filter.customer_objid ?? ""}
onValueChange={(v) => setFilter({ ...filter, customer_objid: v })} />
</CompactFilterField>
<CompactFilterField label="프로젝트번호" width={170}>
<Input value={filter.project_no ?? ""}
onChange={(e) => setFilter({ ...filter, project_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="제품구분" width={130}>
<SmartSelect options={productOpts} value={filter.product_cd ?? ""}
onValueChange={(v) => setFilter({ ...filter, product_cd: v })} />
</CompactFilterField>
<CompactFilterField label="품번" width={150}>
<Input value={filter.part_no ?? ""}
onChange={(e) => setFilter({ ...filter, part_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="품명" width={170}>
<Input value={filter.part_name ?? ""}
onChange={(e) => setFilter({ ...filter, part_name: e.target.value })} />
</CompactFilterField>
</CompactFilterBar>
<DataGrid
columns={GRID_COLUMNS}
data={gridRows}
loading={loading}
emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"}
gridId="purchase-project-status"
pageSizeOptions={[10, 15, 20, 50, 100]}
paginationStyle="range"
serverPaging
serverPage={filter.page ?? 1}
serverPageSize={filter.page_size ?? 50}
serverTotalItems={total}
onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }}
onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }}
showColumnSettings
summaryStats={summary}
onRefresh={() => fetchList()}
onDownload={() => {
if (gridRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
const exportRows = gridRows.map((r: any) => {
const out: Record<string, any> = {};
GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; });
return out;
});
exportToExcel(exportRows, "프로젝트별_발주_입고현황.xlsx", "프로젝트현황");
}}
showChart
/>
</div>
);
}
@@ -0,0 +1,199 @@
"use client";
// 구매관리 > 품의서관리 — wace salesMng/proposalMngList.jsp 1:1
// 그리드: sales_request_master(doc_type='PROPOSAL') + mbom 품번/품명
// 검색: 품의서No / 프로젝트번호 / 결재상태 / 작성일 / 구매유형(multi) / 작성자 / 제품구분
// 액션: 조회 / 결재상신 / 발주서생성
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Send, ClipboardCheck } from "lucide-react";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar";
import { PageHeader } from "@/components/common/PageHeader";
import { apiClient } from "@/lib/api/client";
import { purchaseApi, PurchaseListFilter, OptionItem } from "@/lib/api/purchase";
import { exportToExcel } from "@/lib/utils/excelExport";
const PARENT_PURCHASE_TYPE = "0001814"; // 구매유형 comm_code
const PARENT_PART_TYPE = "0000001"; // 제품구분 comm_code
const STATUS_OPTS: SmartSelectOption[] = [
{ code: "create", label: "작성중" },
{ code: "approvalRequest", label: "결재중" },
{ code: "approvalComplete", label: "결재완료" },
{ code: "reject", label: "반려" },
];
const EMPTY_FILTER: PurchaseListFilter = {
proposal_no: "", project_no: "", search_status: "",
regdate_start: "", regdate_end: "",
purchase_type: "", writer: "", part_type: "",
page: 1, page_size: 50,
};
export default function ProposalPage() {
const [rows, setRows] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<PurchaseListFilter>(EMPTY_FILTER);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [purchaseTypeOpts, setPurchaseTypeOpts] = useState<SmartSelectOption[]>([]);
const [partTypeOpts, setPartTypeOpts] = useState<SmartSelectOption[]>([]);
const [userOpts, setUserOpts] = useState<OptionItem[]>([]);
const fetchList = useCallback(async (override?: Partial<PurchaseListFilter>) => {
setLoading(true);
try {
const f = { ...filter, ...override };
const res = await purchaseApi.listProposal(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 [pt, ptt, u] = await Promise.all([
apiClient.get(`/sales/codes/${PARENT_PURCHASE_TYPE}`),
apiClient.get(`/sales/codes/${PARENT_PART_TYPE}`),
purchaseApi.listUsers(),
]);
if (dead) return;
setPurchaseTypeOpts(pt.data?.data ?? []);
setPartTypeOpts(ptt.data?.data ?? []);
setUserOpts(u);
} catch { /* skip */ }
})();
fetchList(EMPTY_FILTER);
return () => { dead = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const gridRows = useMemo(() => rows.map((r, i) => ({
...r,
id: r.objid ?? `p_${i}`,
part_display: r.part_extra_count > 0 ? `${r.part_no}${r.part_extra_count}` : r.part_no,
part_name_display: r.part_extra_count > 0 ? `${r.part_name}${r.part_extra_count}` : r.part_name,
})), [rows]);
const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([
{ key: "proposal_no", label: "품의서 No", width: "w-[140px]", align: "center" },
{ key: "project_number", label: "프로젝트번호", width: "w-[150px]", align: "center" },
{ key: "purchase_type_name", label: "구매유형", width: "w-[115px]", align: "center" },
{ key: "order_type_name", label: "주문유형", width: "w-[115px]", align: "center" },
{ key: "product_name_title", label: "제품구분", width: "w-[115px]", align: "center" },
{ key: "part_display", label: "품번", width: "w-[160px]" },
{ key: "part_name_display", label: "품명", minWidth: "min-w-[200px]" },
{ key: "status_title", label: "결재상태", width: "w-[115px]", align: "center" },
{ key: "regdate_title", label: "작성일", width: "w-[115px]", align: "center" },
{ key: "writer_name", label: "작성자", width: "w-[115px]", align: "center" },
]), []);
const summary = useMemo(() => {
const approved = gridRows.filter((r: any) => r.status === "approvalComplete").length;
return [
{ label: "전체 건수", value: total.toLocaleString(), suffix: "건" },
{ label: "결재완료(페이지)", value: approved.toLocaleString(), suffix: "건" },
{ label: "선택", value: checkedIds.length.toLocaleString(), suffix: "건" },
];
}, [gridRows, total, checkedIds]);
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 overflow-hidden p-2 gap-2">
<PageHeader
loading={loading} onSearch={handleSearch} onReset={handleReset}
actions={<>
<Button size="sm" variant="outline" className="h-8 gap-1 px-2 text-xs"
disabled={checkedIds.length !== 1}
onClick={() => toast.info("결재상신 — Amaranth10 SSO 연동 후 활성")}>
<Send className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="default" className="h-8 gap-1 px-2 text-xs"
disabled={checkedIds.length !== 1}
onClick={() => toast.info("발주서생성 — purchase_order_part 신설 후 활성")}>
<ClipboardCheck className="h-3.5 w-3.5" />
</Button>
</>}
/>
<CompactFilterBar totalText={<> {total.toLocaleString()}</>}>
<CompactFilterField label="품의서 No" width={150}>
<Input value={filter.proposal_no ?? ""}
onChange={(e) => setFilter({ ...filter, proposal_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="프로젝트번호" width={170}>
<Input value={filter.project_no ?? ""}
onChange={(e) => setFilter({ ...filter, project_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="결재상태" width={130}>
<SmartSelect options={STATUS_OPTS} value={filter.search_status ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_status: v })} />
</CompactFilterField>
<CompactFilterField label="작성일" width={280}>
<CompactDateRange
from={filter.regdate_start ?? ""} setFrom={(v) => setFilter({ ...filter, regdate_start: v })}
to={filter.regdate_end ?? ""} setTo={(v) => setFilter({ ...filter, regdate_end: v })}
/>
</CompactFilterField>
<CompactFilterField label="구매유형" width={130}>
<SmartSelect options={purchaseTypeOpts} value={filter.purchase_type ?? ""}
onValueChange={(v) => setFilter({ ...filter, purchase_type: v })} />
</CompactFilterField>
<CompactFilterField label="작성자" width={150}>
<SmartSelect options={userOpts} value={filter.writer ?? ""}
onValueChange={(v) => setFilter({ ...filter, writer: v })} />
</CompactFilterField>
<CompactFilterField label="제품구분" width={130}>
<SmartSelect options={partTypeOpts} value={filter.part_type ?? ""}
onValueChange={(v) => setFilter({ ...filter, part_type: v })} />
</CompactFilterField>
</CompactFilterBar>
<DataGrid
columns={GRID_COLUMNS}
data={gridRows}
loading={loading}
showCheckbox
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"}
gridId="purchase-proposal"
pageSizeOptions={[10, 15, 20, 50, 100]}
paginationStyle="range"
serverPaging
serverPage={filter.page ?? 1}
serverPageSize={filter.page_size ?? 50}
serverTotalItems={total}
onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }}
onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }}
showColumnSettings
summaryStats={summary}
systemColumnKeys={["writer_name", "regdate_title"]}
onRefresh={() => fetchList()}
onDownload={() => {
if (gridRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
const exportRows = gridRows.map((r: any) => {
const out: Record<string, any> = {};
GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; });
return out;
});
exportToExcel(exportRows, "품의서관리.xlsx", "품의서");
}}
showChart
/>
</div>
);
}
@@ -0,0 +1,190 @@
"use client";
// 구매관리 > 견적요청서관리 — wace salesMng/quotationRequestList.jsp 1:1
// 검색: 년도 / 프로젝트번호 / 견적요청서No / 공급업체 / 메일발송 / 작성자 / 제품구분
// 그리드: 13컬럼 (견적번호 / 요청번호 / 구매유형 / 프로젝트번호 / 주문유형 / 제품구분 / 품번 / 품명 / 공급업체 / 견적요청서(파일) / 메일발송 / 수신견적서 / 작성자)
// 액션: 메일발송 / 삭제 / 조회
// ⚠️ 백엔드 quotation_request_master 미존재 → 빈 그리드 (UI 만 제공)
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Mail, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { CompactFilterBar, CompactFilterField } from "@/components/common/CompactFilterBar";
import { PageHeader } from "@/components/common/PageHeader";
import { apiClient } from "@/lib/api/client";
import { purchaseApi, PurchaseListFilter, OptionItem, getYearOptions } from "@/lib/api/purchase";
import { exportToExcel } from "@/lib/utils/excelExport";
const PARENT_PRODUCT = "0000001";
const MAIL_SEND_OPTS: SmartSelectOption[] = [
{ code: "N", label: "미발송" },
{ code: "Y", label: "발송" },
];
const EMPTY_FILTER: PurchaseListFilter = {
year: String(new Date().getFullYear()),
project_no: "", proposal_no: "", partner_objid: "",
mail_send_yn: "", writer: "", product_cd: "",
page: 1, page_size: 50,
};
export default function QuoteRequestPage() {
const [rows, setRows] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<PurchaseListFilter>(EMPTY_FILTER);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [productOpts, setProductOpts] = useState<SmartSelectOption[]>([]);
const [supplierOpts, setSupplierOpts] = useState<OptionItem[]>([]);
const [userOpts, setUserOpts] = useState<OptionItem[]>([]);
const yearOpts = useMemo(() => getYearOptions(), []);
const fetchList = useCallback(async (override?: Partial<PurchaseListFilter>) => {
setLoading(true);
try {
const f = { ...filter, ...override };
const res = await purchaseApi.listQuotationRequest(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 [p, s, u] = await Promise.all([
apiClient.get(`/sales/codes/${PARENT_PRODUCT}`),
purchaseApi.listSuppliers(),
purchaseApi.listUsers(),
]);
if (dead) return;
setProductOpts(p.data?.data ?? []);
setSupplierOpts(s);
setUserOpts(u);
} catch { /* skip */ }
})();
fetchList(EMPTY_FILTER);
return () => { dead = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const gridRows = useMemo(() => rows.map((r, i) => ({ ...r, id: r.objid ?? `q_${i}` })), [rows]);
const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([
{ key: "quotation_request_no", label: "견적번호", width: "w-[140px]", align: "center" },
{ key: "request_mng_no", label: "요청번호", width: "w-[140px]", align: "center" },
{ key: "purchase_type_name", label: "구매유형", width: "w-[115px]", align: "center" },
{ key: "project_number", label: "프로젝트번호", width: "w-[140px]", align: "center" },
{ key: "order_type_name", label: "주문유형", width: "w-[115px]", align: "center" },
{ key: "product_name_title", label: "제품구분", width: "w-[115px]", align: "center" },
{ key: "part_no", label: "품번", width: "w-[150px]" },
{ key: "part_name", label: "품명", minWidth: "min-w-[180px]" },
{ key: "vendor_name", label: "공급업체", minWidth: "min-w-[150px]" },
{ key: "quotation_file", label: "견적요청서", width: "w-[115px]", align: "center", renderType: "clip" },
{ key: "mail_send_date_title", label: "메일발송", width: "w-[125px]", align: "center" },
{ key: "attach_file_cnt", label: "수신견적서", width: "w-[115px]", align: "center" },
{ key: "writer_name", label: "작성자", width: "w-[115px]", align: "center" },
]), []);
const summary = useMemo(() => [
{ label: "전체 건수", value: total.toLocaleString(), suffix: "건" },
{ label: "선택", value: checkedIds.length.toLocaleString(), suffix: "건" },
], [total, checkedIds]);
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 overflow-hidden p-2 gap-2">
<PageHeader
loading={loading} onSearch={handleSearch} onReset={handleReset}
actions={<>
<Button size="sm" variant="outline" className="h-8 gap-1 px-2 text-xs"
disabled={checkedIds.length === 0}
onClick={() => toast.info("메일발송 — 운영DB quotation_request_master 신설 후 활성")}>
<Mail className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="destructive" className="h-8 gap-1 px-2 text-xs"
disabled={checkedIds.length === 0}
onClick={() => toast.info("삭제 — 운영DB quotation_request_master 신설 후 활성")}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>}
/>
<CompactFilterBar totalText={<> {total.toLocaleString()}</>}>
<CompactFilterField label="년도" width={100}>
<SmartSelect options={yearOpts} value={filter.year ?? ""}
onValueChange={(v) => setFilter({ ...filter, year: v })} />
</CompactFilterField>
<CompactFilterField label="프로젝트번호" width={170}>
<Input value={filter.project_no ?? ""}
onChange={(e) => setFilter({ ...filter, project_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="견적요청서No" width={150}>
<Input value={filter.proposal_no ?? ""}
onChange={(e) => setFilter({ ...filter, proposal_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="공급업체" width={180}>
<SmartSelect options={supplierOpts} value={filter.partner_objid ?? ""}
onValueChange={(v) => setFilter({ ...filter, partner_objid: v })} />
</CompactFilterField>
<CompactFilterField label="메일발송" width={120}>
<SmartSelect options={MAIL_SEND_OPTS} value={filter.mail_send_yn ?? ""}
onValueChange={(v) => setFilter({ ...filter, mail_send_yn: v })} />
</CompactFilterField>
<CompactFilterField label="작성자" width={150}>
<SmartSelect options={userOpts} value={filter.writer ?? ""}
onValueChange={(v) => setFilter({ ...filter, writer: v })} />
</CompactFilterField>
<CompactFilterField label="제품구분" width={130}>
<SmartSelect options={productOpts} value={filter.product_cd ?? ""}
onValueChange={(v) => setFilter({ ...filter, product_cd: v })} />
</CompactFilterField>
</CompactFilterBar>
<DataGrid
columns={GRID_COLUMNS}
data={gridRows}
loading={loading}
showCheckbox
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"}
gridId="purchase-quote-request"
pageSizeOptions={[10, 15, 20, 50, 100]}
paginationStyle="range"
serverPaging
serverPage={filter.page ?? 1}
serverPageSize={filter.page_size ?? 50}
serverTotalItems={total}
onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }}
onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }}
showColumnSettings
summaryStats={summary}
onRefresh={() => fetchList()}
onDownload={() => {
if (gridRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
const exportRows = gridRows.map((r: any) => {
const out: Record<string, any> = {};
GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; });
return out;
});
exportToExcel(exportRows, "견적요청서관리.xlsx", "견적요청서");
}}
showChart
/>
</div>
);
}
+1 -1
View File
@@ -188,7 +188,7 @@ function SortableHeaderCell({
if (col.sortable !== false) onSort(col.key);
}}
>
<span className="text-xs font-medium truncate">{col.label}</span>
<span className="text-xs font-medium whitespace-nowrap" title={col.label}>{col.label}</span>
{isSorted && (
<span className="text-primary text-xs shrink-0">{sortDir === "asc" ? "↑" : "↓"}</span>
)}
@@ -0,0 +1,193 @@
"use client";
// 생산관리 > M-BOM 관리 — 행 추가 시 PART 검색 다이얼로그 (PR-B2).
//
// 운영판 mBomPopupRight.jsp + mBomCenterBtnPopup.jsp 의 우측 패널/추가 흐름 단순화 버전.
// part_mng (개발관리 M2) 를 검색 → multi-select → 부모 컴포넌트로 반환.
import React, { useEffect, useMemo, useState } from "react";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Loader2, Search } from "lucide-react";
import { toast } from "sonner";
import { devPartApi, PartRow } from "@/lib/api/devPart";
export interface PickedPart {
objid: string; // part_mng.objid (bigint, 문자열로)
part_no: string;
part_name: string;
unit: string | null;
}
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (parts: PickedPart[]) => void;
parentLabel?: string; // "선택된 부모: XYZ" 표시용
}
export function MbomAddPartDialog({ open, onOpenChange, onConfirm, parentLabel }: Props) {
const [filter, setFilter] = useState({ search_part_no: "", search_part_name: "" });
const [rows, setRows] = useState<PartRow[]>([]);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize] = useState(50);
const [total, setTotal] = useState(0);
const [checked, setChecked] = useState<Set<string>>(new Set());
const search = (override?: Partial<typeof filter> & { page?: number }) => {
const p = override?.page ?? 1;
const f = { ...filter, ...override, page: p, page_size: pageSize };
setLoading(true);
devPartApi.list(f)
.then(res => {
setRows(res.rows ?? []);
setTotal(res.total ?? 0);
setPage(p);
})
.catch((e: any) => toast.error(e?.response?.data?.message ?? e?.message ?? "PART 조회 실패"))
.finally(() => setLoading(false));
};
useEffect(() => {
if (!open) { setRows([]); setChecked(new Set()); setFilter({ search_part_no: "", search_part_name: "" }); return; }
search({ page: 1 });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const toggleAll = () => {
if (checked.size === rows.length) setChecked(new Set());
else setChecked(new Set(rows.map(r => String(r.objid))));
};
const toggleOne = (id: string) => {
setChecked(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
};
const handleConfirm = () => {
if (checked.size === 0) {
toast.error("추가할 PART 를 선택해주세요");
return;
}
const picked: PickedPart[] = rows
.filter(r => checked.has(String(r.objid)))
.map(r => ({
objid: String(r.objid),
part_no: r.part_no ?? "",
part_name: r.part_name ?? "",
unit: r.unit ?? null,
}));
onConfirm(picked);
onOpenChange(false);
};
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[900px] w-[95vw] max-h-[85vh] flex flex-col p-0 overflow-hidden">
<DialogHeader className="bg-blue-600 px-4 py-3">
<DialogTitle className="text-white flex items-center gap-2">
<Search className="w-4 h-4" />
PART M-BOM
{parentLabel && <span className="text-xs font-normal opacity-90">· : {parentLabel}</span>}
</DialogTitle>
</DialogHeader>
{/* 검색 */}
<div className="flex items-center gap-2 border-b bg-muted/30 px-4 py-2">
<span className="text-xs text-muted-foreground"></span>
<Input
className="h-7 text-xs w-[150px]"
value={filter.search_part_no}
onChange={(e) => setFilter({ ...filter, search_part_no: e.target.value })}
onKeyDown={(e) => { if (e.key === "Enter") search({ page: 1 }); }}
/>
<span className="text-xs text-muted-foreground"></span>
<Input
className="h-7 text-xs w-[180px]"
value={filter.search_part_name}
onChange={(e) => setFilter({ ...filter, search_part_name: e.target.value })}
onKeyDown={(e) => { if (e.key === "Enter") search({ page: 1 }); }}
/>
<Button size="sm" onClick={() => search({ page: 1 })} disabled={loading}>
{loading ? <Loader2 className="w-3 h-3 mr-1 animate-spin" /> : <Search className="w-3 h-3 mr-1" />}
</Button>
<div className="ml-auto text-xs text-muted-foreground">
{total.toLocaleString()} · {checked.size}
</div>
</div>
{/* 그리드 */}
<div className="flex-1 min-h-0 overflow-auto">
<table className="text-xs border-collapse w-full">
<thead className="bg-yellow-100 dark:bg-yellow-900/30 sticky top-0">
<tr>
<th className="border px-2 py-1.5 w-[36px] text-center">
<input
type="checkbox"
checked={rows.length > 0 && checked.size === rows.length}
onChange={toggleAll}
/>
</th>
<th className="border px-2 py-1.5 w-[140px] text-left"></th>
<th className="border px-2 py-1.5 text-left"></th>
<th className="border px-2 py-1.5 w-[100px] text-left"></th>
<th className="border px-2 py-1.5 w-[100px] text-left"></th>
<th className="border px-2 py-1.5 w-[60px] text-center"></th>
<th className="border px-2 py-1.5 w-[70px] text-center"></th>
</tr>
</thead>
<tbody>
{loading && rows.length === 0 ? (
<tr><td colSpan={7} className="py-12 text-center"><Loader2 className="inline w-5 h-5 animate-spin" /></td></tr>
) : rows.length === 0 ? (
<tr><td colSpan={7} className="py-8 text-center text-muted-foreground"> PART .</td></tr>
) : rows.map(r => {
const id = String(r.objid);
const isChecked = checked.has(id);
return (
<tr key={id} className={`hover:bg-muted/30 cursor-pointer ${isChecked ? "bg-blue-50" : ""}`}
onClick={() => toggleOne(id)}>
<td className="border px-2 py-1 text-center">
<input type="checkbox" checked={isChecked} onChange={() => toggleOne(id)} onClick={e => e.stopPropagation()} />
</td>
<td className="border px-2 py-1 whitespace-nowrap">{r.part_no}</td>
<td className="border px-2 py-1">{r.part_name}</td>
<td className="border px-2 py-1">{r.material ?? ""}</td>
<td className="border px-2 py-1">{r.spec ?? ""}</td>
<td className="border px-2 py-1 text-center">{r.unit_title ?? r.unit ?? ""}</td>
<td className="border px-2 py-1 text-center">{r.revision ?? ""}</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* 페이지네이션 */}
{total > pageSize && (
<div className="flex items-center justify-center gap-1 border-t px-3 py-2 text-xs">
<Button size="sm" variant="ghost" disabled={page <= 1} onClick={() => search({ page: page - 1 })}></Button>
<span className="px-2">{page} / {totalPages}</span>
<Button size="sm" variant="ghost" disabled={page >= totalPages} onClick={() => search({ page: page + 1 })}></Button>
</div>
)}
<DialogFooter className="border-t bg-muted/20 px-4 py-3 sm:justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}></Button>
<Button onClick={handleConfirm} disabled={checked.size === 0}>
({checked.size})
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,386 @@
"use client";
// 생산관리 > M-BOM 관리 — BOM 할당 다이얼로그 (PR-B5).
//
// 운영판 mBomEbomSelectPopup.jsp (324 lines) 1:1 재구성.
// 레이아웃 (상→하):
// 1) 헤더: "E-BOM 선택" 또는 "E-BOM 상세 및 변경" + 닫기/변경 토글
// 2) 현재 할당된 E-BOM 정보 카드 (할당된 경우만, 2×3 table)
// 3) 리스트 섹션 (할당 안 됐거나 변경 토글 ON 일 때 표시):
// - 검색폼: 제품구분(select) + 품번 + 품명 + 등록일(범위)
// - 헤더: "E-BOM List (상태: Y)" + 우측 [조회][E-BOM 할당] 버튼
// - 그리드 (제품구분/품번/품명/Ver/등록일/작성자)
// 4) 미리보기: 선택한 E-BOM 의 트리 (운영판 iframe → 직접 렌더)
//
// (M-BOM 할당은 PR-B5 v2 — 우선 E-BOM 만)
import React, { useEffect, useMemo, useState } from "react";
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Loader2, Search, Link as LinkIcon, Eye, RefreshCw, Folder } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { mbomApi, AssignableEbomRow, MbomTreeResponse, MbomTreeRow } from "@/lib/api/mbom";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { apiClient } from "@/lib/api/client";
const PARENT_PRODUCT = "0000001"; // 제품구분 comm_code parent (운영 동일)
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
projectObjid: string | null;
currentEbomObjid?: string | null; // 이미 할당된 E-BOM (있으면 상단 카드 표시)
onAssigned: () => void;
}
export function MbomAssignDialog({
open, onOpenChange, projectObjid, currentEbomObjid, onAssigned,
}: Props) {
// 현재 할당된 E-BOM 의 상세 정보 (objid 로 검색해서 자체 로드)
const [currentEbom, setCurrentEbom] = useState<AssignableEbomRow | null>(null);
// 검색 폼
const [filter, setFilter] = useState({
product_cd: "", search_part_no: "", search_part_name: "",
search_from_date: "", search_to_date: "",
});
const [productOpts, setProductOpts] = useState<SmartSelectOption[]>([]);
// 리스트 / 선택
const [rows, setRows] = useState<AssignableEbomRow[]>([]);
const [loading, setLoading] = useState(false);
const [selectedRow, setSelectedRow] = useState<AssignableEbomRow | null>(null);
// 미리보기 트리
const [preview, setPreview] = useState<MbomTreeResponse | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
// 변경 모드 토글 — 할당된 경우 false 가 디폴트, 변경 버튼 클릭 시 true
const [changeMode, setChangeMode] = useState(false);
const isAssigned = !!currentEbom;
const showList = !isAssigned || changeMode;
// 제품구분 옵션 로드
useEffect(() => {
if (!open) return;
apiClient.get(`/sales/codes/${PARENT_PRODUCT}`)
.then(r => setProductOpts(r.data?.data ?? []))
.catch(() => { /* 옵션 실패 무시 */ });
}, [open]);
const search = (override?: Partial<typeof filter>) => {
const f = { ...filter, ...override };
setLoading(true);
mbomApi.searchAssignableEboms(f)
.then(setRows)
.catch((e: any) => toast.error(e?.response?.data?.message ?? e?.message ?? "E-BOM 조회 실패"))
.finally(() => setLoading(false));
};
// 다이얼로그 오픈/리셋
useEffect(() => {
if (!open) {
setRows([]); setSelectedRow(null); setPreview(null);
setChangeMode(false); setCurrentEbom(null);
setFilter({ product_cd: "", search_part_no: "", search_part_name: "", search_from_date: "", search_to_date: "" });
return;
}
// 현재 할당된 E-BOM 상세 로드
if (currentEbomObjid) {
mbomApi.searchAssignableEboms({ objid: currentEbomObjid, limit: 1 })
.then(r => setCurrentEbom(r[0] ?? null))
.catch(() => setCurrentEbom(null));
} else {
// 할당 안 된 경우: 리스트 자동 조회
search();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, currentEbomObjid]);
// 변경 모드 진입 시 리스트 자동 조회
useEffect(() => {
if (changeMode) search();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [changeMode]);
// 미리보기 — 선택 변경 시 트리 로드
useEffect(() => {
if (!selectedRow?.objid) { setPreview(null); return; }
let alive = true;
setPreviewLoading(true);
mbomApi.previewEbomTree(selectedRow.objid)
.then(t => { if (alive) setPreview(t); })
.catch((e: any) => toast.error(e?.response?.data?.message ?? e?.message ?? "미리보기 실패"))
.finally(() => { if (alive) setPreviewLoading(false); });
return () => { alive = false; };
}, [selectedRow?.objid]);
const handleAssign = async () => {
if (!projectObjid) return;
if (!selectedRow?.objid) { toast.error("E-BOM 한 건을 선택해주세요"); return; }
if (selectedRow.objid === currentEbom?.objid) {
toast.info("현재 할당된 E-BOM 과 동일합니다");
return;
}
if (!window.confirm(isAssigned
? `선택한 E-BOM(${selectedRow.part_no}) 으로 변경하시겠습니까?`
: `선택한 E-BOM(${selectedRow.part_no}) 을 할당하시겠습니까?`)) return;
try {
await mbomApi.assignBom({
project_obj_id: projectObjid,
source_bom_type: "EBOM",
source_bom_obj_id: selectedRow.objid,
});
toast.success(isAssigned ? "E-BOM 이 변경되었습니다" : "E-BOM 이 할당되었습니다");
onAssigned();
onOpenChange(false);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "할당 실패");
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[1200px] 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-2">
<LinkIcon className="w-4 h-4" />
{isAssigned ? "E-BOM 상세 및 변경" : "E-BOM 선택"}
{isAssigned && !changeMode && (
<Button
size="sm" variant="secondary" className="ml-auto h-7"
onClick={() => setChangeMode(true)}
>
<RefreshCw className="w-3 h-3 mr-1" />
E-BOM
</Button>
)}
</DialogTitle>
</DialogHeader>
<div className="flex-1 min-h-0 overflow-auto">
{/* 1. 현재 할당된 E-BOM 정보 카드 */}
{isAssigned && currentEbom && (
<div className="border-b bg-emerald-50/50 dark:bg-emerald-950/20 px-4 py-3">
<div className="text-xs font-semibold mb-2 flex items-center gap-1">
📋 E-BOM
</div>
<table className="text-xs w-full max-w-[800px]">
<tbody>
<tr>
<th className="bg-muted/50 px-2 py-1 text-left w-[100px]"></th>
<td className="px-2 py-1">{currentEbom.product_name ?? ""}</td>
<th className="bg-muted/50 px-2 py-1 text-left w-[80px]"></th>
<td className="px-2 py-1 font-medium">{currentEbom.part_no ?? ""}</td>
</tr>
<tr>
<th className="bg-muted/50 px-2 py-1 text-left"></th>
<td className="px-2 py-1">{currentEbom.part_name ?? ""}</td>
<th className="bg-muted/50 px-2 py-1 text-left">Version</th>
<td className="px-2 py-1">{currentEbom.revision ?? ""}</td>
</tr>
<tr>
<th className="bg-muted/50 px-2 py-1 text-left"></th>
<td className="px-2 py-1 tabular-nums">{currentEbom.reg_date ?? ""}</td>
<th className="bg-muted/50 px-2 py-1 text-left"></th>
<td className="px-2 py-1">{currentEbom.dept_name ?? ""} / {currentEbom.writer_name ?? ""}</td>
</tr>
</tbody>
</table>
{!changeMode && (
<p className="text-xs text-muted-foreground mt-2">
E-BOM <strong>"E-BOM 변경"</strong> .
</p>
)}
</div>
)}
{/* 2. 리스트 섹션 (검색 + 그리드) */}
{showList && (
<div className="border-b">
{/* 검색 폼 */}
<div className="grid grid-cols-[80px_1fr_80px_1fr_80px_1fr_80px_1fr] gap-x-3 gap-y-2 px-4 py-3 items-center text-xs bg-muted/30 border-b">
<label className="text-right"></label>
<SmartSelect
options={productOpts}
value={filter.product_cd}
onValueChange={(v) => setFilter({ ...filter, product_cd: v })}
/>
<label className="text-right"></label>
<Input className="h-7 text-xs"
value={filter.search_part_no}
onChange={(e) => setFilter({ ...filter, search_part_no: e.target.value })}
onKeyDown={(e) => { if (e.key === "Enter") search(); }} />
<label className="text-right"></label>
<Input className="h-7 text-xs"
value={filter.search_part_name}
onChange={(e) => setFilter({ ...filter, search_part_name: e.target.value })}
onKeyDown={(e) => { if (e.key === "Enter") search(); }} />
<label className="text-right"></label>
<div className="flex items-center gap-1">
<Input type="date" className="h-7 text-xs w-[140px]"
value={filter.search_from_date}
onChange={(e) => setFilter({ ...filter, search_from_date: e.target.value })} />
<span>~</span>
<Input type="date" className="h-7 text-xs w-[140px]"
value={filter.search_to_date}
onChange={(e) => setFilter({ ...filter, search_to_date: e.target.value })} />
</div>
</div>
{/* 리스트 헤더 + 액션 버튼 (운영판: 버튼이 리스트 우상단) */}
<div className="flex items-center justify-between px-4 py-2 bg-muted/10 border-b">
<h3 className="text-sm font-semibold">E-BOM List <span className="text-xs text-muted-foreground">(상태: Y · 100 · {rows.length})</span></h3>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => search()} disabled={loading}>
{loading ? <Loader2 className="w-3 h-3 mr-1 animate-spin" /> : <Search className="w-3 h-3 mr-1" />}
</Button>
<Button
size="sm"
className="bg-emerald-600 hover:bg-emerald-700 text-white"
onClick={handleAssign}
disabled={!selectedRow}
>
<LinkIcon className="w-3 h-3 mr-1" />
E-BOM {isAssigned ? "변경" : "할당"}
</Button>
</div>
</div>
{/* 그리드 */}
<div className="max-h-[320px] overflow-auto">
<table className="text-xs border-collapse w-full">
<thead className="bg-yellow-100 dark:bg-yellow-900/30 sticky top-0">
<tr>
<th className="border px-2 py-1.5 w-[36px] text-center"></th>
<th className="border px-2 py-1.5 w-[100px] text-center"></th>
<th className="border px-2 py-1.5 w-[150px] text-left"></th>
<th className="border px-2 py-1.5 text-left"></th>
<th className="border px-2 py-1.5 w-[70px] text-center">Ver</th>
<th className="border px-2 py-1.5 w-[100px] text-center"></th>
<th className="border px-2 py-1.5 w-[140px] text-center"></th>
</tr>
</thead>
<tbody>
{loading && rows.length === 0 ? (
<tr><td colSpan={7} className="py-12 text-center"><Loader2 className="inline w-5 h-5 animate-spin" /></td></tr>
) : rows.length === 0 ? (
<tr><td colSpan={7} className="py-8 text-center text-muted-foreground"> E-BOM .</td></tr>
) : rows.map(r => {
const isSel = selectedRow?.objid === r.objid;
const isCurrent = currentEbom?.objid === r.objid;
return (
<tr key={r.objid}
className={cn("cursor-pointer hover:bg-muted/30",
isSel && "bg-blue-100 dark:bg-blue-950/40",
!isSel && isCurrent && "bg-emerald-50 dark:bg-emerald-950/20")}
onClick={() => setSelectedRow(r)}>
<td className="border px-2 py-1 text-center">
<input type="radio" checked={isSel} onChange={() => setSelectedRow(r)} />
</td>
<td className="border px-2 py-1 text-center">{r.product_name ?? ""}</td>
<td className="border px-2 py-1 whitespace-nowrap">
{r.part_no}
{isCurrent && <span className="ml-1 text-[10px] text-emerald-600">()</span>}
</td>
<td className="border px-2 py-1">{r.part_name}</td>
<td className="border px-2 py-1 text-center">{r.revision ?? ""}</td>
<td className="border px-2 py-1 text-center tabular-nums">{r.reg_date ?? ""}</td>
<td className="border px-2 py-1 text-center">
{r.dept_name && r.writer_name
? `${r.dept_name}/${r.writer_name}`
: (r.writer_name ?? "")}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{/* 3. 미리보기 트리 */}
{selectedRow && (
<div className="p-3">
<div className="text-xs font-semibold mb-2 flex items-center gap-1">
<Eye className="w-3 h-3" />
E-BOM ( ) {selectedRow.part_no} / {selectedRow.part_name}
</div>
<div className="border rounded max-h-[320px] overflow-auto">
{previewLoading ? (
<div className="flex h-32 items-center justify-center"><Loader2 className="w-5 h-5 animate-spin" /></div>
) : (
<PreviewTree tree={preview} />
)}
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
// 미리보기 트리 — 운영판 iframe 대체. 간단한 read-only 표시.
function PreviewTree({ tree }: { tree: MbomTreeResponse | null }) {
const maxLevel = Math.max(1, tree?.max_level ?? 1);
const rows: MbomTreeRow[] = tree?.rows ?? [];
const levels = useMemo(() => Array.from({ length: maxLevel }, (_, i) => i + 1), [maxLevel]);
if (rows.length === 0) {
return <div className="py-6 text-center text-xs text-muted-foreground"> .</div>;
}
return (
<table className="text-xs border-collapse w-full">
<thead className="bg-muted sticky top-0">
<tr>
{levels.map((lv) => (
<th key={`l${lv}`} className="border px-1 py-1 w-[30px] text-center font-bold">{lv}</th>
))}
<th className="border px-2 py-1 min-w-[120px] text-left"></th>
<th className="border px-2 py-1 min-w-[180px] text-left"></th>
<th className="border px-2 py-1 w-[60px] text-right"></th>
<th className="border px-2 py-1 w-[60px] text-center"></th>
<th className="border px-2 py-1 w-[40px] text-center">3D</th>
<th className="border px-2 py-1 w-[40px] text-center">2D</th>
<th className="border px-2 py-1 w-[40px] text-center">PDF</th>
</tr>
</thead>
<tbody>
{rows.map((r, idx) => {
const lv = Number(r.level ?? 1);
return (
<tr key={`${r.objid}_${idx}`} className="hover:bg-muted/30">
{levels.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 tabular-nums">{r.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"><FolderMini n={r.cu01_cnt} /></td>
<td className="border px-2 py-0.5 text-center"><FolderMini n={r.cu02_cnt} /></td>
<td className="border px-2 py-0.5 text-center"><FolderMini n={r.cu03_cnt} /></td>
</tr>
);
})}
</tbody>
</table>
);
}
function FolderMini({ n }: { n: any }) {
const has = Number(n ?? 0) > 0;
return (
<Folder className={cn("inline w-3.5 h-3.5",
has ? "fill-[#1a73e8] text-[#1a73e8]" : "fill-white text-muted-foreground/40")} />
);
}
@@ -22,10 +22,13 @@ import {
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Loader2, Folder, Pencil, Save, X } from "lucide-react";
import { Loader2, Folder, Pencil, Save, X, History, Plus, Trash2, Link as LinkIcon } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { mbomApi, MbomDetail, MbomTreeResponse, MbomBomDataType, MbomTreeRow, MbomSaveRow } from "@/lib/api/mbom";
import { MbomHistoryDialog } from "./MbomHistoryDialog";
import { MbomAddPartDialog, PickedPart } from "./MbomAddPartDialog";
import { MbomAssignDialog } from "./MbomAssignDialog";
interface Props {
open: boolean;
@@ -58,6 +61,10 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid, onSaved }:
const [saving, setSaving] = useState(false);
const [editableRows, setEditableRows] = useState<MbomTreeRow[]>([]);
const [dirty, setDirty] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [addPartOpen, setAddPartOpen] = useState(false);
const [assignOpen, setAssignOpen] = useState(false);
const [selectedChildObjids, setSelectedChildObjids] = useState<Set<string>>(new Set());
const loadTree = (objid: string) => {
setLoading(true);
@@ -81,11 +88,17 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid, onSaved }:
if (!open || !projectObjid) {
setDetail(null); setTree(null); setEditableRows([]);
setEditMode(false); setDirty(false);
setSelectedChildObjids(new Set());
return;
}
void loadTree(projectObjid);
}, [open, projectObjid]);
// 편집 모드 off 시 선택 해제
useEffect(() => {
if (!editMode) setSelectedChildObjids(new Set());
}, [editMode]);
const maxLevel = Math.max(1, tree?.max_level ?? 1);
const rows = editMode ? editableRows : (tree?.rows ?? []);
const bomDataType: MbomBomDataType = tree?.bom_data_type ?? "NONE";
@@ -107,6 +120,89 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid, onSaved }:
setDirty(true);
};
// 체크박스 토글
const toggleSelect = (childObjid: string | null | undefined) => {
if (!childObjid) return;
setSelectedChildObjids(prev => {
const next = new Set(prev);
if (next.has(childObjid)) next.delete(childObjid);
else next.add(childObjid);
return next;
});
};
const toggleSelectAll = () => {
if (selectedChildObjids.size === editableRows.length) setSelectedChildObjids(new Set());
else setSelectedChildObjids(new Set(editableRows.map(r => String(r.child_objid ?? r.objid))));
};
// BFS — 주어진 child_objid 의 모든 하위 노드 child_objid 집합
const getDescendants = (rootChildObjid: string): Set<string> => {
const result = new Set<string>();
const queue = [rootChildObjid];
while (queue.length) {
const parent = queue.shift()!;
for (const r of editableRows) {
const c = String(r.child_objid ?? "");
if (!c) continue;
if (r.parent_objid === parent && !result.has(c)) {
result.add(c);
queue.push(c);
}
}
}
return result;
};
// 행 추가 — PART 검색 다이얼로그에서 반환된 parts 를 editableRows 에 append.
// 부모 = 선택된 행 1개 (없으면 root, 2개 이상이면 첫 번째).
const handleAddParts = (parts: PickedPart[]) => {
const firstSelected = selectedChildObjids.size > 0 ? Array.from(selectedChildObjids)[0] : null;
const parentRow = firstSelected ? editableRows.find(r => String(r.child_objid) === firstSelected) : null;
const parentChildObjid = parentRow ? String(parentRow.child_objid) : null;
const parentLevel = parentRow ? Number(parentRow.level ?? 1) : 0;
const base = Date.now();
const newRows: MbomTreeRow[] = parts.map((p, i) => ({
objid: "" as any, // 빈값 → 백엔드 createObjId
parent_objid: parentChildObjid,
child_objid: `temp-${base}-${i}` as any, // 클라이언트 임시 ID (백엔드 그대로 저장)
seq: 999,
level: parentLevel + 1,
part_objid: p.objid as any,
part_no: p.part_no,
part_name: p.part_name,
qty: 1,
item_qty: 1,
unit: p.unit,
unit_title: p.unit,
supply_type: null, make_or_buy: null,
raw_material_no: null, raw_material: null, raw_material_size: null,
required_qty: null, order_qty: null, production_qty: null,
processing_vendor: null, processing_deadline: null, grinding_deadline: null,
cu01_cnt: 0, cu02_cnt: 0, cu03_cnt: 0,
remark: null,
status: "ACTIVE",
sub_part_cnt: 0,
processing_vendor_name: null,
} as any));
setEditableRows(prev => [...prev, ...newRows]);
setDirty(true);
toast.success(`${parts.length}개 행 추가${parentChildObjid ? ` (부모: ${parentRow?.part_no ?? ""})` : " (root)"}`);
};
// 선택 삭제 — cascade. 선택된 child_objid + 하위 트리 전부 제거.
const handleDeleteSelected = () => {
if (selectedChildObjids.size === 0) return;
const allToRemove = new Set<string>();
for (const sel of selectedChildObjids) {
allToRemove.add(sel);
for (const d of getDescendants(sel)) allToRemove.add(d);
}
if (!window.confirm(`${allToRemove.size}개 행을 삭제합니다 (선택 ${selectedChildObjids.size} + 하위 ${allToRemove.size - selectedChildObjids.size}). 계속할까요?`)) return;
setEditableRows(prev => prev.filter(r => !allToRemove.has(String(r.child_objid ?? r.objid))));
setSelectedChildObjids(new Set());
setDirty(true);
};
const handleEditToggle = () => {
if (editMode && dirty) {
if (!window.confirm("편집 중인 변경사항이 사라집니다. 취소하시겠습니까?")) return;
@@ -214,6 +310,17 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid, onSaved }:
)}
</div>
<div className="flex items-center gap-2">
{!editMode && (
<Button size="sm" variant="outline" onClick={() => setHistoryOpen(true)} disabled={loading || !projectObjid}>
<History className="w-3 h-3 mr-1" />
</Button>
)}
{!editMode && (
<Button size="sm" variant="outline" onClick={() => setAssignOpen(true)} disabled={loading || !projectObjid}>
<LinkIcon className="w-3 h-3 mr-1" />
{bomDataType === "NONE" ? "BOM 할당" : "할당 변경"}
</Button>
)}
{canEdit && !editMode && (
<Button size="sm" variant="outline" onClick={handleEditToggle} disabled={loading}>
<Pencil className="w-3 h-3 mr-1" />
@@ -221,6 +328,12 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid, onSaved }:
)}
{editMode && (
<>
<Button size="sm" variant="outline" onClick={() => setAddPartOpen(true)} disabled={saving}>
<Plus className="w-3 h-3 mr-1" />
</Button>
<Button size="sm" variant="outline" onClick={handleDeleteSelected} disabled={saving || selectedChildObjids.size === 0}>
<Trash2 className="w-3 h-3 mr-1" /> {selectedChildObjids.size > 0 && `(${selectedChildObjids.size})`}
</Button>
<Button size="sm" variant="outline" onClick={handleEditToggle} disabled={saving}>
<X className="w-3 h-3 mr-1" />
</Button>
@@ -242,6 +355,13 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid, onSaved }:
<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>
{editMode && (
<th className="border px-1 py-1 w-[32px] text-center">
<input type="checkbox"
checked={editableRows.length > 0 && selectedChildObjids.size === editableRows.length}
onChange={toggleSelectAll} />
</th>
)}
{levelHeaders.map((i) => (
<th key={`l${i}`} className="border px-2 py-1 w-[36px] text-center font-bold">{i}</th>
))}
@@ -270,15 +390,27 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid, onSaved }:
<tbody>
{rows.length === 0 && (
<tr>
<td colSpan={levelHeaders.length + 19} className="py-8 text-center text-muted-foreground">
<td colSpan={levelHeaders.length + 19 + (editMode ? 1 : 0)} className="py-8 text-center text-muted-foreground">
{bomDataType === "NONE" ? "표시할 BOM이 없습니다." : "트리가 비어있습니다."}
</td>
</tr>
)}
{rows.map((r, idx) => {
const lv = Number(r.level ?? 1);
const cid = String(r.child_objid ?? r.objid ?? "");
const isSelected = editMode && cid && selectedChildObjids.has(cid);
const isNew = editMode && String(cid).startsWith("temp-");
return (
<tr key={`${r.objid}_${idx}`} className="hover:bg-muted/30">
<tr key={`${r.objid}_${cid}_${idx}`} className={cn("hover:bg-muted/30",
isSelected && "bg-blue-50 dark:bg-blue-950/30",
isNew && "bg-emerald-50 dark:bg-emerald-950/20")}>
{editMode && (
<td className="border px-1 py-0.5 text-center">
<input type="checkbox"
checked={!!isSelected}
onChange={() => toggleSelect(cid)} />
</td>
)}
{levelHeaders.map((i) => (
<td key={`lc${i}`} className={cn("border px-1 py-0.5 text-center", i === lv && "font-bold")}>
{i === lv ? "*" : ""}
@@ -328,6 +460,38 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid, onSaved }:
<Button variant="outline" onClick={() => onOpenChange(false)}></Button>
</DialogFooter>
</DialogContent>
<MbomHistoryDialog
open={historyOpen}
onOpenChange={setHistoryOpen}
projectObjid={projectObjid}
/>
<MbomAssignDialog
open={assignOpen}
onOpenChange={setAssignOpen}
projectObjid={projectObjid}
currentEbomObjid={
detail?.source_bom_type === "EBOM" ? detail.source_ebom_objid : null
}
onAssigned={() => {
if (projectObjid) void loadTree(projectObjid);
onSaved?.();
}}
/>
<MbomAddPartDialog
open={addPartOpen}
onOpenChange={setAddPartOpen}
onConfirm={handleAddParts}
parentLabel={
selectedChildObjids.size === 1
? editableRows.find(r => String(r.child_objid) === Array.from(selectedChildObjids)[0])?.part_no ?? undefined
: selectedChildObjids.size > 1
? `${Array.from(selectedChildObjids).length}개 선택 — 첫 항목 부모`
: "root (최상위)"
}
/>
</Dialog>
);
}
@@ -0,0 +1,104 @@
"use client";
// 생산관리 > M-BOM 관리 — 변경이력 다이얼로그 (PR-B4).
//
// 운영판 매퍼 productionplanning.getMbomHistory (3448~3470) 1:1.
// 프로젝트의 모든 mbom_header 변경이력 시간순(최신 우선) 표시.
// CREATE / UPDATE + 설명 + 변경자 + 변경일 + M-BOM 품번.
import React, { useEffect, useState } from "react";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Loader2, History } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { mbomApi, MbomHistoryRow } from "@/lib/api/mbom";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
projectObjid: string | null;
}
const CHANGE_TYPE_BADGE: Record<string, { text: string; color: string }> = {
CREATE: { text: "생성", color: "bg-blue-600" },
UPDATE: { text: "수정", color: "bg-amber-600" },
};
export function MbomHistoryDialog({ open, onOpenChange, projectObjid }: Props) {
const [rows, setRows] = useState<MbomHistoryRow[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open || !projectObjid) { setRows([]); return; }
let alive = true;
setLoading(true);
mbomApi.getHistory(projectObjid)
.then(data => { if (alive) setRows(data); })
.catch((e: any) => toast.error(e?.response?.data?.message ?? e?.message ?? "변경이력 조회 실패"))
.finally(() => { if (alive) setLoading(false); });
return () => { alive = false; };
}, [open, projectObjid]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[900px] w-[95vw] max-h-[85vh] flex flex-col p-0 overflow-hidden">
<DialogHeader className="bg-blue-600 px-4 py-3">
<DialogTitle className="text-white flex items-center gap-2">
<History className="w-4 h-4" />
M-BOM
<span className="text-xs font-normal opacity-80"> {rows.length.toLocaleString()}</span>
</DialogTitle>
</DialogHeader>
<div className="flex-1 min-h-0 overflow-auto">
{loading ? (
<div className="flex h-48 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : rows.length === 0 ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
.
</div>
) : (
<table className="text-xs border-collapse w-full">
<thead className="bg-yellow-100 dark:bg-yellow-900/30 sticky top-0">
<tr>
<th className="border px-2 py-1.5 w-[140px] text-center"></th>
<th className="border px-2 py-1.5 w-[70px] text-center"></th>
<th className="border px-2 py-1.5 text-left"> </th>
<th className="border px-2 py-1.5 w-[110px] text-center"></th>
<th className="border px-2 py-1.5 w-[180px] text-left">M-BOM </th>
</tr>
</thead>
<tbody>
{rows.map((r, idx) => {
const badge = CHANGE_TYPE_BADGE[r.change_type] ?? { text: r.change_type, color: "bg-slate-500" };
return (
<tr key={`${r.objid}_${idx}`} className="hover:bg-muted/30">
<td className="border px-2 py-1 text-center whitespace-nowrap tabular-nums">{r.change_date}</td>
<td className="border px-2 py-1 text-center">
<span className={cn("inline-block rounded px-1.5 py-0.5 text-[10px] font-semibold text-white", badge.color)}>
{badge.text}
</span>
</td>
<td className="border px-2 py-1">{r.change_description ?? ""}</td>
<td className="border px-2 py-1 text-center">{r.change_user_name ?? r.change_user ?? ""}</td>
<td className="border px-2 py-1 font-mono text-[11px]">{r.mbom_part_no ?? ""}</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>
);
}
+90
View File
@@ -215,6 +215,21 @@ export interface MbomSaveResult {
deleted: number;
}
// ─── 구매리스트 생성 (PR-B3) ────────────────────────────────
// 운영판 createPurchaseListFromMBom.do 1:1. SALES_REQUEST_MASTER 단건 생성.
// 동일 mbom_header_objid 로 이미 생성된 SRM 있으면 400 에러.
export interface CreateSalesRequestPayload {
mbom_header_objid: string;
project_mgmt_objid: string;
}
export interface CreateSalesRequestResult {
objid: string;
request_mng_no: string;
mbom_header_objid: string;
}
export const mbomApi = {
async list(filter: MbomListFilter = {}): Promise<MbomListResponse> {
const res = await apiClient.get("/production/mbom/list", { params: filter });
@@ -232,4 +247,79 @@ export const mbomApi = {
const res = await apiClient.post("/production/mbom/save", payload);
return res.data?.data as MbomSaveResult;
},
async getHistory(projectObjid: string): Promise<MbomHistoryRow[]> {
const res = await apiClient.get(`/production/mbom/history/${encodeURIComponent(projectObjid)}`);
return (res.data?.data ?? []) as MbomHistoryRow[];
},
async createSalesRequest(payload: CreateSalesRequestPayload): Promise<CreateSalesRequestResult> {
const res = await apiClient.post("/production/mbom/sales-request", payload);
return res.data?.data as CreateSalesRequestResult;
},
async searchAssignableEboms(filter: AssignableEbomFilter = {}): Promise<AssignableEbomRow[]> {
const res = await apiClient.get("/production/mbom/assignable-eboms", { params: filter });
return (res.data?.data ?? []) as AssignableEbomRow[];
},
async previewEbomTree(bomReportObjid: string): Promise<MbomTreeResponse> {
const res = await apiClient.get(`/production/mbom/ebom-preview/${encodeURIComponent(bomReportObjid)}`);
return res.data?.data as MbomTreeResponse;
},
async assignBom(payload: AssignBomPayload): Promise<AssignBomResult> {
const res = await apiClient.post("/production/mbom/assign", payload);
return res.data?.data as AssignBomResult;
},
};
// ─── BOM 할당 (PR-B5) ───────────────────────────────────────
// 운영판 mBomEbomSelectPopup.do + saveBomAssignment.do 1:1.
// project_mgmt.source_bom_type='EBOM' + source_ebom_objid 만 우선 지원.
export interface AssignableEbomFilter {
objid?: string;
product_cd?: string;
search_part_no?: string;
search_part_name?: string;
search_from_date?: string;
search_to_date?: string;
limit?: number;
}
export interface AssignableEbomRow {
objid: string;
product_cd: string | null;
product_name: string | null;
part_no: string | null;
part_name: string | null;
status: string | null;
revision: string | null;
reg_date: string | null;
writer_name: string | null;
dept_name: string | null;
material: string | null;
supplier: string | null;
}
export interface AssignBomPayload {
project_obj_id: string;
source_bom_type: "EBOM" | "MBOM";
source_bom_obj_id: string;
}
export interface AssignBomResult {
success: boolean;
source_bom_type: string;
source_obj_id: string;
}
// ─── 변경이력 (PR-B4) ───────────────────────────────────────
export interface MbomHistoryRow {
objid: string;
mbom_header_objid: string;
change_type: string; // CREATE | UPDATE
change_description: string | null;
change_user: string | null;
change_user_name: string | null;
change_date: string;
mbom_part_no: string | null;
mbom_regdate: string | null;
}
+166
View File
@@ -0,0 +1,166 @@
import { apiClient } from "./client";
// ============================================================
// 생산관리 4개 메뉴 — wace productionplanning.xml 1:1
// 1) 생산계획&실적관리 (일반) GET /api/production/plan-result/list
// 2) 생산계획&실적관리 (장비) GET /api/production/plan-result/list-equip
// 3) 반제품 소요량 POST /api/production/mbom-requirement/semi
// 4) 원자재 소요량 POST /api/production/mbom-requirement/raw
// ============================================================
export interface ProdPlanResultFilter {
search_project_nos?: string; // multiple — "OBJID,OBJID,..."
search_product_code?: string;
search_category_code?: string;
search_production_type?: string;
search_customer_objid?: string;
search_req_del_date_from?: string;
search_req_del_date_to?: string;
search_part_no?: string;
search_part_name?: string;
search_serial_no?: string;
search_writer?: string;
search_regdate_from?: string;
search_regdate_to?: string;
page?: number;
page_size?: number;
}
export interface ProdPlanResultRow {
objid: string;
project_no: string | null;
product: string | null;
product_name: string | null;
category_code: string | null;
category_code_name: string | null;
production_type: string | null;
production_type_name: string | null;
customer_objid: string | null;
customer_name: string | null;
req_del_date: string | null;
customer_request: string | null;
part_no: string | null;
part_name: string | null;
serial_no: string | null;
serial_no_list: string | null;
quantity: number | string | null;
extra_prod_qty: number | string | null;
total_prod_qty: number | string | null;
assembly_qty: number | string | null;
inspection_qty: number | string | null;
ship_wait_qty: number | string | null;
prod_plan_objid: string | null;
writer: string | null;
writer_name: string | null;
regdate_title: string | null;
}
export interface ProdPlanResultEquipRow {
objid: string;
project_no: string | null;
product: string | null;
product_name: string | null;
category_code: string | null;
category_code_name: string | null;
production_type: string | null;
production_type_name: string | null;
customer_objid: string | null;
customer_name: string | null;
req_del_date: string | null;
customer_request: string | null;
part_no: string | null;
part_name: string | null;
serial_no: string | null;
prod_wbs_cnt: number | null;
prod_progress_rate: number | string | null;
delv_wbs_cnt: number | null;
delv_progress_rate: number | string | null;
}
export interface ProdPlanResultListResponse<T> {
rows: T[];
totalCount: number;
page: number;
pageSize: number;
}
export interface ProjectOption {
code: string;
label: string;
}
export interface WriterOption {
code: string;
label: string;
}
export const prodPlanResultApi = {
async list(filter: ProdPlanResultFilter = {}): Promise<ProdPlanResultListResponse<ProdPlanResultRow>> {
const res = await apiClient.get("/production/plan-result/list", { params: filter });
return res.data?.data as ProdPlanResultListResponse<ProdPlanResultRow>;
},
async listEquip(filter: ProdPlanResultFilter = {}): Promise<ProdPlanResultListResponse<ProdPlanResultEquipRow>> {
const res = await apiClient.get("/production/plan-result/list-equip", { params: filter });
return res.data?.data as ProdPlanResultListResponse<ProdPlanResultEquipRow>;
},
async getProjectOptions(): Promise<ProjectOption[]> {
const res = await apiClient.get("/production/plan-result/project-options");
return (res.data?.data ?? []) as ProjectOption[];
},
async getWriterOptions(): Promise<WriterOption[]> {
const res = await apiClient.get("/production/plan-result/writer-options");
return (res.data?.data ?? []) as WriterOption[];
},
};
// ─── 반제품/원자재 소요량 ───────────────────────────────────
export interface MbomOption {
objid: string;
mbom_no: string;
part_name: string;
}
export interface MbomRequirementInputItem {
mbomObjid: string;
qty: number;
}
export interface SemiRequirementRow {
PART_NO: string;
PART_NAME: string;
CATEGORY_NAME: string;
UNIT: string;
MATERIAL: string;
SPEC: string;
REQUIRED_QTY: number;
}
export interface RawRequirementRow {
PART_NO: string;
PART_NAME: string;
CATEGORY_NAME: string;
UNIT: string;
MATERIAL: string;
SPEC: string;
REQUIRED_QTY: number | string;
RAW_MATERIAL: string;
RAW_MATERIAL_SIZE: string;
MATERIAL_PART_NO: string;
MATERIAL_REQUIRED_QTY: number | string;
}
export const mbomRequirementApi = {
async getOptions(): Promise<MbomOption[]> {
const res = await apiClient.get("/production/mbom-requirement/options");
return (res.data?.data ?? []) as MbomOption[];
},
async getSemi(items: MbomRequirementInputItem[]): Promise<SemiRequirementRow[]> {
const res = await apiClient.post("/production/mbom-requirement/semi", { mbomItems: items });
return (res.data?.data ?? []) as SemiRequirementRow[];
},
async getRaw(items: MbomRequirementInputItem[]): Promise<RawRequirementRow[]> {
const res = await apiClient.post("/production/mbom-requirement/raw", { mbomItems: items });
return (res.data?.data ?? []) as RawRequirementRow[];
},
};
+92
View File
@@ -0,0 +1,92 @@
// ============================================================
// 구매관리 — 7개 메뉴 그리드 API.
// 백엔드: /api/purchase/{menu-path}, /api/purchase/options/{suppliers|users|projects}
// ============================================================
import { apiClient } from "./client";
export interface PurchaseListFilter {
year?: string;
customer_objid?: string;
customer_cd?: string;
project_no?: string;
part_no?: string;
part_name?: string;
part_spec?: string;
partner_objid?: string;
purchase_order_no?: string;
proposal_no?: string;
search_status?: string;
writer?: string;
request_user?: string;
purchase_type?: string;
part_type?: string;
product_cd?: string;
paid_type?: string;
mail_send_yn?: string;
delivery_status?: string;
close_status?: string;
sales_mng_user_id?: string;
regdate_start?: string;
regdate_end?: string;
receipt_date_start?: string;
receipt_date_end?: string;
delivery_start_date?: string;
delivery_end_date?: string;
reg_start_date?: string;
reg_end_date?: string;
page?: number;
page_size?: number;
}
export interface PurchaseListResponse<T = any> {
rows: T[];
totalCount: number;
page: number;
pageSize: number;
}
export interface OptionItem {
code: string;
label: string;
}
async function getList<T = any>(path: string, filter: PurchaseListFilter): Promise<PurchaseListResponse<T>> {
const res = await apiClient.get(`/purchase/${path}`, { params: filter });
return res.data?.data as PurchaseListResponse<T>;
}
export const purchaseApi = {
// 그리드 7종
listPurchaseRequest: (f: PurchaseListFilter = {}) => getList("purchase-request", f),
listQuotationRequest: (f: PurchaseListFilter = {}) => getList("quotation-request", f),
listProposal: (f: PurchaseListFilter = {}) => getList("proposal", f),
listInbound: (f: PurchaseListFilter = {}) => getList("inbound", f),
listInboundByItem: (f: PurchaseListFilter = {}) => getList("inbound-by-item", f),
listInboundByDate: (f: PurchaseListFilter = {}) => getList("inbound-by-date", f),
listProjectStatus: (f: PurchaseListFilter = {}) => getList("project-status", f),
// 공통 옵션
async listSuppliers(): Promise<OptionItem[]> {
const r = await apiClient.get("/purchase/options/suppliers");
return (r.data?.data ?? []) as OptionItem[];
},
async listUsers(): Promise<OptionItem[]> {
const r = await apiClient.get("/purchase/options/users");
return (r.data?.data ?? []) as OptionItem[];
},
async listProjects(): Promise<OptionItem[]> {
const r = await apiClient.get("/purchase/options/projects");
return (r.data?.data ?? []) as OptionItem[];
},
};
/** 년도 옵션 — wace 운영판 동일 (현재년도 ±4) */
export function getYearOptions(): OptionItem[] {
const y = new Date().getFullYear();
const out: OptionItem[] = [];
for (let i = y + 4; i >= y - 4; i--) {
out.push({ code: String(i), label: String(i) });
}
return out;
}