From b38f5957f290e14208838463c030f295b64c29b5 Mon Sep 17 00:00:00 2001 From: hjjeong Date: Thu, 14 May 2026 17:31:12 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B5=AC=EB=A7=A4=EA=B4=80=EB=A6=AC=207?= =?UTF-8?q?=EB=A9=94=EB=89=B4=20=EC=8B=A0=EA=B7=9C=20+=20M-BOM=20PR-B3?= =?UTF-8?q?=C2=B7B5=20+=20=EB=B0=9C=EC=A3=BC=EA=B4=80=EB=A6=AC=20DataGrid?= =?UTF-8?q?=20=ED=86=B5=EC=9D=BC=20+=20=EC=83=9D=EC=82=B0=EA=B3=84?= =?UTF-8?q?=ED=9A=8D&=EC=8B=A4=EC=A0=81=20=EB=9D=BC=EC=9A=B0=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 구매관리 (wace 1:1) - backend: services/purchaseService.ts (7 list + 옵션 3종) + controllers/purchaseController.ts + routes/purchaseRoutes.ts (/api/purchase 마운트) - frontend: lib/api/purchase.ts + 7 page.tsx (list/quote-request/proposal/inbound/inbound-by-item/inbound-by-date/project-status) - 영업관리 4메뉴 DataGrid 패턴 통일 — pageSizeOptions=[10,15,20,50,100], emptyMessage, showColumnSettings/summaryStats/onRefresh/onDownload/showChart - 마스터단독 데이터(sales_request_master, project_mgmt+mbom_detail) 노출, detail/part 누락 테이블 의존은 빈 그리드 + UI 발주관리 (purchase/order/page.tsx) - EDataTable → DataGrid 교체 + logicstudio 6종 props + 날짜/숫자 pre-format M-BOM PR-B3 — 구매리스트 생성 (wace createPurchaseListFromMBom.do 1:1) - mbomService.createSalesRequest + controller + route POST /api/production/mbom/sales-request - 단건 체크 + 1:1 강제 + R-YYYYMMDD-NNN 채번 + sales_request_master 단건 INSERT - production/mbom/page.tsx 에 [구매리스트 생성] 버튼 M-BOM PR-B5 — BOM 할당 (mBomEbomSelectPopup.do) - mbomService.searchAssignableEboms/assignBom + controller + routes - MbomAssignDialog 신규, MbomDetailDialog 통합 생산관리 4메뉴 라우트 (생산계획&실적, 소요량) - prodPlanResultService/Controller + productionPlanResultRoutes (planResult/mbomReq) - mbomRequirementService + 4 page.tsx (prod-plan-result, prod-plan-result-equip, raw-material-requirement, semi-product-requirement) - lib/api/prodPlanResult.ts Co-Authored-By: Claude Opus 4.7 (1M context) --- backend-node/src/app.ts | 5 + .../src/controllers/mbomController.ts | 62 +++ .../controllers/prodPlanResultController.ts | 107 +++++ .../src/controllers/purchaseController.ts | 68 +++ .../src/routes/productionMbomRoutes.ts | 3 + .../src/routes/productionPlanResultRoutes.ts | 28 ++ backend-node/src/routes/purchaseRoutes.ts | 27 ++ .../src/services/mbomRequirementService.ts | 259 +++++++++++ backend-node/src/services/mbomService.ts | 194 ++++++++ .../src/services/prodPlanResultService.ts | 400 ++++++++++++++++ backend-node/src/services/purchaseService.ts | 438 ++++++++++++++++++ .../COMPANY_16/production/mbom/page.tsx | 74 ++- .../prod-plan-result-equip/page.tsx | 201 ++++++++ .../production/prod-plan-result/page.tsx | 251 ++++++++++ .../raw-material-requirement/page.tsx | 206 ++++++++ .../semi-product-requirement/page.tsx | 207 +++++++++ .../purchase/inbound-by-date/page.tsx | 217 +++++++++ .../purchase/inbound-by-item/page.tsx | 198 ++++++++ .../COMPANY_16/purchase/inbound/page.tsx | 216 +++++++++ .../(main)/COMPANY_16/purchase/list/page.tsx | 173 +++++++ .../(main)/COMPANY_16/purchase/order/page.tsx | 82 ++-- .../purchase/project-status/page.tsx | 166 +++++++ .../COMPANY_16/purchase/proposal/page.tsx | 199 ++++++++ .../purchase/quote-request/page.tsx | 190 ++++++++ .../production/MbomAssignDialog.tsx | 190 ++++++++ .../production/MbomDetailDialog.tsx | 25 +- frontend/lib/api/mbom.ts | 66 +++ frontend/lib/api/prodPlanResult.ts | 166 +++++++ frontend/lib/api/purchase.ts | 92 ++++ 29 files changed, 4470 insertions(+), 40 deletions(-) create mode 100644 backend-node/src/controllers/prodPlanResultController.ts create mode 100644 backend-node/src/controllers/purchaseController.ts create mode 100644 backend-node/src/routes/productionPlanResultRoutes.ts create mode 100644 backend-node/src/routes/purchaseRoutes.ts create mode 100644 backend-node/src/services/mbomRequirementService.ts create mode 100644 backend-node/src/services/prodPlanResultService.ts create mode 100644 backend-node/src/services/purchaseService.ts create mode 100644 frontend/app/(main)/COMPANY_16/production/prod-plan-result-equip/page.tsx create mode 100644 frontend/app/(main)/COMPANY_16/production/prod-plan-result/page.tsx create mode 100644 frontend/app/(main)/COMPANY_16/production/raw-material-requirement/page.tsx create mode 100644 frontend/app/(main)/COMPANY_16/production/semi-product-requirement/page.tsx create mode 100644 frontend/app/(main)/COMPANY_16/purchase/inbound-by-date/page.tsx create mode 100644 frontend/app/(main)/COMPANY_16/purchase/inbound-by-item/page.tsx create mode 100644 frontend/app/(main)/COMPANY_16/purchase/inbound/page.tsx create mode 100644 frontend/app/(main)/COMPANY_16/purchase/list/page.tsx create mode 100644 frontend/app/(main)/COMPANY_16/purchase/project-status/page.tsx create mode 100644 frontend/app/(main)/COMPANY_16/purchase/proposal/page.tsx create mode 100644 frontend/app/(main)/COMPANY_16/purchase/quote-request/page.tsx create mode 100644 frontend/components/production/MbomAssignDialog.tsx create mode 100644 frontend/lib/api/prodPlanResult.ts create mode 100644 frontend/lib/api/purchase.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 9353ccd2..7a776902 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -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 crawlRoutes from "./routes/crawlRoutes"; // 웹 크롤링 import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리 @@ -382,6 +384,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/crawl", crawlRoutes); // 웹 크롤링 app.use("/api/material-status", materialStatusRoutes); // 자재현황 diff --git a/backend-node/src/controllers/mbomController.ts b/backend-node/src/controllers/mbomController.ts index 3dad8ed5..787f9217 100644 --- a/backend-node/src/controllers/mbomController.ts +++ b/backend-node/src/controllers/mbomController.ts @@ -53,6 +53,44 @@ 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; + const data = await svc.searchAssignableEboms({ + search_part_no: String(q.search_part_no ?? "").trim() || undefined, + search_part_name: String(q.search_part_name ?? "").trim() || undefined, + search_material: String(q.search_material ?? "").trim() || undefined, + search_supplier: String(q.search_supplier ?? "").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 — 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 { @@ -66,6 +104,30 @@ export async function getHistory(req: AuthenticatedRequest, res: Response) { } } +// 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 { diff --git a/backend-node/src/controllers/prodPlanResultController.ts b/backend-node/src/controllers/prodPlanResultController.ts new file mode 100644 index 00000000..c7e49006 --- /dev/null +++ b/backend-node/src/controllers/prodPlanResultController.ts @@ -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): 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)); + 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)); + 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 }); + } +} diff --git a/backend-node/src/controllers/purchaseController.ts b/backend-node/src/controllers/purchaseController.ts new file mode 100644 index 00000000..af91a227 --- /dev/null +++ b/backend-node/src/controllers/purchaseController.ts @@ -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): 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, + req: AuthenticatedRequest, + res: Response, + name: string, +) { + try { + const data = await fn(parseFilter(req.query as Record)); + 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 }); + } +} diff --git a/backend-node/src/routes/productionMbomRoutes.ts b/backend-node/src/routes/productionMbomRoutes.ts index 58a98af4..554c6008 100644 --- a/backend-node/src/routes/productionMbomRoutes.ts +++ b/backend-node/src/routes/productionMbomRoutes.ts @@ -15,5 +15,8 @@ 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.post("/assign", ctrl.assignBom); // PR-B5 BOM 할당 export default router; diff --git a/backend-node/src/routes/productionPlanResultRoutes.ts b/backend-node/src/routes/productionPlanResultRoutes.ts new file mode 100644 index 00000000..c306a09e --- /dev/null +++ b/backend-node/src/routes/productionPlanResultRoutes.ts @@ -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 }; diff --git a/backend-node/src/routes/purchaseRoutes.ts b/backend-node/src/routes/purchaseRoutes.ts new file mode 100644 index 00000000..16f6083c --- /dev/null +++ b/backend-node/src/routes/purchaseRoutes.ts @@ -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; diff --git a/backend-node/src/services/mbomRequirementService.ts b/backend-node/src/services/mbomRequirementService.ts new file mode 100644 index 00000000..602bedc2 --- /dev/null +++ b/backend-node/src/services/mbomRequirementService.ts @@ -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> { + 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 { + if (!Array.isArray(items) || items.length === 0) return []; + const pool = getPool(); + + // PART_NO 기준 LinkedHashMap (자바 LinkedHashMap 동일 보장) + const partMap = new Map(); + + 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(NULLIF(MD.QTY, '')::INTEGER, 1) 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::VARCHAR = 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 { + if (!Array.isArray(items) || items.length === 0) return []; + const pool = getPool(); + + // 운영판 1:1 — 두 갈래 LinkedHashMap (구매품 / 원소재) + const purchaseMap = new Map(); + // 원소재는 소수점 합산을 위해 임시 number 보관 후 마지막에 올림 처리 + const rawSourceMap = new Map(); + + 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(NULLIF(MD.QTY, '')::INTEGER, 1) 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::VARCHAR = 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(NULLIF(MD.REQUIRED_QTY, '')::NUMERIC, 0) 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; +} diff --git a/backend-node/src/services/mbomService.ts b/backend-node/src/services/mbomService.ts index c8113900..9e093e67 100644 --- a/backend-node/src/services/mbomService.ts +++ b/backend-node/src/services/mbomService.ts @@ -1228,6 +1228,115 @@ 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 { + search_part_no?: string; + search_part_name?: string; + search_material?: string; + search_supplier?: 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 { + const pool = getPool(); + const conds: string[] = []; + const params: any[] = []; + let idx = 1; + 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_material) { + conds.push(`UPPER(PM.MATERIAL) LIKE '%' || UPPER($${idx++}) || '%'`); + params.push(filter.search_material); + } + if (filter.search_supplier) { + conds.push(`UPPER(PM.MAKER) LIKE '%' || UPPER($${idx++}) || '%'`); + params.push(filter.search_supplier); + } + 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; +} + +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. @@ -1270,6 +1379,91 @@ export async function getHistory(projectObjid: string): Promise { + 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, diff --git a/backend-node/src/services/prodPlanResultService.ts b/backend-node/src/services/prodPlanResultService.ts new file mode 100644 index 00000000..77a7e1d8 --- /dev/null +++ b/backend-node/src/services/prodPlanResultService.ts @@ -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 빌더 (매퍼 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; +} diff --git a/backend-node/src/services/purchaseService.ts b/backend-node/src/services/purchaseService.ts new file mode 100644 index 00000000..3c7be7ab --- /dev/null +++ b/backend-node/src/services/purchaseService.ts @@ -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 { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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 []; + } +} diff --git a/frontend/app/(main)/COMPANY_16/production/mbom/page.tsx b/frontend/app/(main)/COMPANY_16/production/mbom/page.tsx index 8f43a2f4..0ae507d4 100644 --- a/frontend/app/(main)/COMPANY_16/production/mbom/page.tsx +++ b/frontend/app/(main)/COMPANY_16/production/mbom/page.tsx @@ -6,16 +6,19 @@ // 액션: // 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"; @@ -58,6 +61,7 @@ const EMPTY_FILTER: MbomListFilter = { // 그리드 컬럼은 useMemo 로 컴포넌트 내부에서 생성 — onClick(openDialog) 캡처 위해. export default function MbomMgmtPage() { + const { confirm, ConfirmDialogComponent } = useConfirmDialog(); const [rows, setRows] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); @@ -70,6 +74,10 @@ export default function MbomMgmtPage() { const [dialogOpen, setDialogOpen] = useState(false); const [dialogObjid, setDialogObjid] = useState(null); + // PR-B3: 구매리스트 생성 — 그리드 단건 체크 + 버튼 트리거 (wace 1:1) + const [checkedIds, setCheckedIds] = useState([]); + const [creatingPurchaseList, setCreatingPurchaseList] = useState(false); + const fetchList = useCallback(async (override?: Partial) => { setLoading(true); try { @@ -171,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 (
+ {creatingPurchaseList + ? + : } + 구매리스트 생성 + + } /> 총 {total.toLocaleString()}건 · PROJECT_MGMT × CONTRACT_ITEM 펼침} @@ -256,6 +323,9 @@ export default function MbomMgmtPage() { data={gridRows} loading={loading} showRowNumber + showCheckbox + checkedIds={checkedIds} + onCheckedChange={setCheckedIds} emptyMessage="조건에 맞는 프로젝트가 없습니다." gridId="production-mbom-mgmt" pageSizeOptions={[25, 50, 100, 200, 500]} @@ -294,6 +364,8 @@ export default function MbomMgmtPage() { projectObjid={dialogObjid} onSaved={fetchList} /> + + {ConfirmDialogComponent}
); } diff --git a/frontend/app/(main)/COMPANY_16/production/prod-plan-result-equip/page.tsx b/frontend/app/(main)/COMPANY_16/production/prod-plan-result-equip/page.tsx new file mode 100644 index 00000000..b1eaf102 --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/production/prod-plan-result-equip/page.tsx @@ -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([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [filter, setFilter] = useState(EMPTY_FILTER); + + const [categoryOpts, setCategoryOpts] = useState([]); + const [productOpts, setProductOpts] = useState([]); + const [prodTypeOpts, setProdTypeOpts] = useState([]); + const [projectOpts, setProjectOpts] = useState([]); + + const fetchList = useCallback(async (override?: Partial) => { + 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-[130px]" }, + { key: "product_name", label: "제품구분", width: "w-[100px]", align: "center" }, + { key: "category_code_name", label: "주문유형", width: "w-[100px]", align: "center" }, + { key: "production_type_name", label: "생산유형", width: "w-[100px]", align: "center" }, + { key: "customer_name", label: "고객사", minWidth: "min-w-[160px]" }, + { key: "req_del_date", label: "요청납기", width: "w-[110px]", 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-[90px]", align: "right", formatNumber: true }, + { key: "prod_progress_rate", label: "생산진척율(%)", width: "w-[120px]", align: "right" }, + { key: "delv_wbs_cnt", label: "납품WBS", width: "w-[90px]", align: "right", formatNumber: true }, + { key: "delv_progress_rate", label: "납품진척율(%)", width: "w-[120px]", 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 ( +
+ + 총 {total.toLocaleString()}건 · PRODUCT=장비(0000928) 만}> + + setFilter({ ...filter, search_project_nos: v })} /> + + + setFilter({ ...filter, search_product_code: v })} /> + + + setFilter({ ...filter, search_category_code: v })} /> + + + setFilter({ ...filter, search_production_type: v })} /> + + + setFilter({ ...filter, search_customer_objid: 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 })} /> + + + setFilter({ ...filter, search_part_no: e.target.value })} /> + + + setFilter({ ...filter, search_part_name: e.target.value })} /> + + + setFilter({ ...filter, search_serial_no: e.target.value })} /> + + + + { 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 = {}; + GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "생산계획_실적관리_장비.xlsx", "생산계획_실적관리_장비"); + }} + showChart + /> +
+ ); +} diff --git a/frontend/app/(main)/COMPANY_16/production/prod-plan-result/page.tsx b/frontend/app/(main)/COMPANY_16/production/prod-plan-result/page.tsx new file mode 100644 index 00000000..527e5450 --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/production/prod-plan-result/page.tsx @@ -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([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [filter, setFilter] = useState(EMPTY_FILTER); + + const [categoryOpts, setCategoryOpts] = useState([]); + const [productOpts, setProductOpts] = useState([]); + const [prodTypeOpts, setProdTypeOpts] = useState([]); + const [projectOpts, setProjectOpts] = useState([]); + const [writerOpts, setWriterOpts] = useState([]); + + const [serialOpen, setSerialOpen] = useState(false); + const [serialList, setSerialList] = useState([]); + + const fetchList = useCallback(async (override?: Partial) => { + 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-[130px]" }, + { key: "product_name", label: "제품구분", width: "w-[100px]", align: "center" }, + { key: "category_code_name", label: "주문유형", width: "w-[100px]", align: "center" }, + { key: "production_type_name",label: "생산유형", width: "w-[100px]", align: "center" }, + { key: "customer_name", label: "고객사", minWidth: "min-w-[160px]" }, + { key: "req_del_date", label: "요청납기", width: "w-[110px]", align: "center" }, + { key: "customer_request", label: "고객사요청사항", minWidth: "min-w-[180px]" }, + { 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-[90px]", align: "right", formatNumber: true }, + { key: "extra_prod_qty", label: "추가생산수량", width: "w-[110px]", align: "right", formatNumber: true }, + { key: "total_prod_qty", label: "총생산수량", width: "w-[100px]", align: "right", formatNumber: true }, + { key: "assembly_qty", label: "완조립", width: "w-[80px]", align: "right", formatNumber: true }, + { key: "inspection_qty", label: "검사", width: "w-[80px]", align: "right", formatNumber: true }, + { key: "ship_wait_qty", label: "포장", width: "w-[80px]", align: "right", formatNumber: true }, + { key: "writer_name", label: "등록자", width: "w-[80px]", align: "center" }, + { key: "regdate_title", label: "등록일", width: "w-[100px]", 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 ( +
+ + 총 {total.toLocaleString()}건 · 프로젝트+독립계획 합본}> + + setFilter({ ...filter, search_project_nos: v })} + /> + + + setFilter({ ...filter, search_product_code: v })} /> + + + setFilter({ ...filter, search_category_code: v })} /> + + + setFilter({ ...filter, search_production_type: v })} /> + + + setFilter({ ...filter, search_customer_objid: 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 })} /> + + + setFilter({ ...filter, search_part_no: e.target.value })} /> + + + setFilter({ ...filter, search_part_name: e.target.value })} /> + + + setFilter({ ...filter, search_serial_no: e.target.value })} /> + + + setFilter({ ...filter, search_writer: v })} /> + + + setFilter({ ...filter, search_regdate_from: v })} + to={filter.search_regdate_to ?? ""} setTo={(v) => setFilter({ ...filter, search_regdate_to: v })} /> + + + + { 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 = {}; + GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "생산계획_실적관리.xlsx", "생산계획_실적관리"); + }} + showChart + /> + + {serialOpen && ( +
setSerialOpen(false)}> +
e.stopPropagation()}> +
S/N 목록 ({serialList.length}건)
+
    + {serialList.map((sn, i) =>
  1. {sn}
  2. )} +
+
+ +
+
+
+ )} +
+ ); +} diff --git a/frontend/app/(main)/COMPANY_16/production/raw-material-requirement/page.tsx b/frontend/app/(main)/COMPANY_16/production/raw-material-requirement/page.tsx new file mode 100644 index 00000000..1f66806e --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/production/raw-material-requirement/page.tsx @@ -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([]); + const [inputs, setInputs] = useState([newRow()]); + const [results, setResults] = useState([]); + 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(); + 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) => { + 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 ( +
+ + + + + } + /> + +
+
+ M-BOM 선택 및 수량 입력 +
+
+ + + + + + + + + + + {inputs.map((r) => ( + + + + + + + ))} + +
선택M-BOM품명수량
+ updateRow(r.id, { checked: e.target.checked })} /> + + + {r.partName} + updateRow(r.id, { qty: Math.max(1, Number(e.target.value) || 1) })} + /> +
+
+
+ + { + if (resultRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; } + const exportRows = resultRows.map((r: any) => { + const out: Record = {}; + RESULT_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "원자재_소요량.xlsx", "원자재_소요량"); + }} + showChart + /> +
+ ); +} diff --git a/frontend/app/(main)/COMPANY_16/production/semi-product-requirement/page.tsx b/frontend/app/(main)/COMPANY_16/production/semi-product-requirement/page.tsx new file mode 100644 index 00000000..92f7dfb6 --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/production/semi-product-requirement/page.tsx @@ -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([]); + const [inputs, setInputs] = useState([newRow()]); + const [results, setResults] = useState([]); + 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(); + 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) => { + 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 ( +
+ + + + + } + /> + + {/* 입력 영역 */} +
+
+ M-BOM 선택 및 수량 입력 +
+
+ + + + + + + + + + + {inputs.map((r) => ( + + + + + + + ))} + +
선택M-BOM품명수량
+ updateRow(r.id, { checked: e.target.checked })} /> + + + {r.partName} + updateRow(r.id, { qty: Math.max(1, Number(e.target.value) || 1) })} + /> +
+
+
+ + {/* 결과 영역 */} + { + if (resultRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; } + const exportRows = resultRows.map((r: any) => { + const out: Record = {}; + RESULT_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "반제품_소요량.xlsx", "반제품_소요량"); + }} + showChart + /> +
+ ); +} diff --git a/frontend/app/(main)/COMPANY_16/purchase/inbound-by-date/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/inbound-by-date/page.tsx new file mode 100644 index 00000000..38703282 --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/purchase/inbound-by-date/page.tsx @@ -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([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [filter, setFilter] = useState(EMPTY_FILTER); + const [checkedIds, setCheckedIds] = useState([]); + + const [supplierOpts, setSupplierOpts] = useState([]); + const [userOpts, setUserOpts] = useState([]); + const yearOpts = useMemo(() => getYearOptions(), []); + + const fetchList = useCallback(async (override?: Partial) => { + 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 ( +
+ + + + } + /> + 총 {total.toLocaleString()}건}> + + setFilter({ ...filter, year: v })} /> + + + setFilter({ ...filter, customer_cd: v })} /> + + + setFilter({ ...filter, project_no: e.target.value })} /> + + + setFilter({ ...filter, purchase_order_no: e.target.value })} /> + + + setFilter({ ...filter, part_spec: e.target.value })} /> + + + setFilter({ ...filter, part_name: e.target.value })} /> + + + setFilter({ ...filter, partner_objid: v })} /> + + + setFilter({ ...filter, sales_mng_user_id: v })} /> + + + setFilter({ ...filter, receipt_date_start: v })} + to={filter.receipt_date_end ?? ""} setTo={(v) => setFilter({ ...filter, receipt_date_end: v })} + /> + + + setFilter({ ...filter, close_status: v })} /> + + + setFilter({ ...filter, part_no: e.target.value })} /> + + + + { 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 = {}; + GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "입고일별_입고관리.xlsx", "입고일별"); + }} + showChart + /> +
+ ); +} diff --git a/frontend/app/(main)/COMPANY_16/purchase/inbound-by-item/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/inbound-by-item/page.tsx new file mode 100644 index 00000000..3e9953d9 --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/purchase/inbound-by-item/page.tsx @@ -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([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [filter, setFilter] = useState(EMPTY_FILTER); + + const [supplierOpts, setSupplierOpts] = useState([]); + const [userOpts, setUserOpts] = useState([]); + const yearOpts = useMemo(() => getYearOptions(), []); + + const fetchList = useCallback(async (override?: Partial) => { + 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 ( +
+ + 총 {total.toLocaleString()}건}> + + setFilter({ ...filter, year: v })} /> + + + setFilter({ ...filter, customer_cd: v })} /> + + + setFilter({ ...filter, project_no: e.target.value })} /> + + + setFilter({ ...filter, purchase_order_no: e.target.value })} /> + + + setFilter({ ...filter, part_spec: e.target.value })} /> + + + setFilter({ ...filter, part_name: e.target.value })} /> + + + setFilter({ ...filter, partner_objid: v })} /> + + + setFilter({ ...filter, sales_mng_user_id: v })} /> + + + setFilter({ ...filter, delivery_start_date: v })} + to={filter.delivery_end_date ?? ""} setTo={(v) => setFilter({ ...filter, delivery_end_date: v })} + /> + + + setFilter({ ...filter, reg_start_date: v })} + to={filter.reg_end_date ?? ""} setTo={(v) => setFilter({ ...filter, reg_end_date: v })} + /> + + + setFilter({ ...filter, delivery_status: v })} /> + + + setFilter({ ...filter, part_no: e.target.value })} /> + + + + { 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 = {}; + GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "품목별_입고관리.xlsx", "품목별 입고"); + }} + showChart + /> +
+ ); +} diff --git a/frontend/app/(main)/COMPANY_16/purchase/inbound/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/inbound/page.tsx new file mode 100644 index 00000000..d83db373 --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/purchase/inbound/page.tsx @@ -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([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [filter, setFilter] = useState(EMPTY_FILTER); + const [checkedIds, setCheckedIds] = useState([]); + + const [supplierOpts, setSupplierOpts] = useState([]); + const [userOpts, setUserOpts] = useState([]); + + const yearOpts = useMemo(() => getYearOptions(), []); + + const fetchList = useCallback(async (override?: Partial) => { + 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 ( +
+ toast.info("입고등록 — purchase_order_part / arrival_plan 신설 후 활성")}> + 입고등록 + + } + /> + 총 {total.toLocaleString()}건}> + + setFilter({ ...filter, year: v })} /> + + + setFilter({ ...filter, customer_cd: v })} /> + + + setFilter({ ...filter, project_no: e.target.value })} /> + + + setFilter({ ...filter, purchase_order_no: e.target.value })} /> + + + setFilter({ ...filter, part_spec: e.target.value })} /> + + + setFilter({ ...filter, part_name: e.target.value })} /> + + + setFilter({ ...filter, partner_objid: v })} /> + + + setFilter({ ...filter, sales_mng_user_id: v })} /> + + + setFilter({ ...filter, delivery_start_date: v })} + to={filter.delivery_end_date ?? ""} setTo={(v) => setFilter({ ...filter, delivery_end_date: v })} + /> + + + setFilter({ ...filter, reg_start_date: v })} + to={filter.reg_end_date ?? ""} setTo={(v) => setFilter({ ...filter, reg_end_date: v })} + /> + + + setFilter({ ...filter, delivery_status: v })} /> + + + setFilter({ ...filter, part_no: e.target.value })} /> + + + + { 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 = {}; + GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "입고관리.xlsx", "입고"); + }} + showChart + /> +
+ ); +} diff --git a/frontend/app/(main)/COMPANY_16/purchase/list/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/list/page.tsx new file mode 100644 index 00000000..930f0724 --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/purchase/list/page.tsx @@ -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([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [filter, setFilter] = useState(EMPTY_FILTER); + + const [productOpts, setProductOpts] = useState([]); + const [userOpts, setUserOpts] = useState([]); + + const fetchList = useCallback(async (override?: Partial) => { + 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 ( +
+ + 총 {total.toLocaleString()}건}> + + setFilter({ ...filter, part_no: e.target.value })} /> + + + setFilter({ ...filter, part_name: e.target.value })} /> + + + setFilter({ ...filter, regdate_start: v })} + to={filter.regdate_end ?? ""} setTo={(v) => setFilter({ ...filter, regdate_end: v })} + /> + + + setFilter({ ...filter, customer_cd: v })} /> + + + setFilter({ ...filter, request_user: v })} /> + + + setFilter({ ...filter, part_type: v })} /> + + + setFilter({ ...filter, project_no: e.target.value })} /> + + + + { 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 = {}; + GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "구매리스트관리.xlsx", "구매리스트"); + }} + showChart + /> +
+ ); +} diff --git a/frontend/app/(main)/COMPANY_16/purchase/order/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/order/page.tsx index 3145a27e..a599267a 100644 --- a/frontend/app/(main)/COMPANY_16/purchase/order/page.tsx +++ b/frontend/app/(main)/COMPANY_16/purchase/order/page.tsx @@ -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(() => { - 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(() => { + 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 ? ( - - {row.status} - - ) : "-"; - } 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) => ( - {row[k] ? Number(row[k]).toLocaleString() : ""} - ); - } + 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() { - {/* 데이터 테이블 — 플랫 리스트 (EDataTable: 컬럼별 Popover 체크박스 필터 + 정렬 내장) */} -
- openEditModal(row.purchase_no)} - showPagination - draggableColumns={false} - columnOrderKey="c16-purchase-order-main" - /> -
+ {/* 데이터 테이블 — 영업관리 4개 메뉴와 동일한 DataGrid + logicstudio props */} + 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 + /> + {/* 발주 등록/수정 모달 */} diff --git a/frontend/app/(main)/COMPANY_16/purchase/project-status/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/project-status/page.tsx new file mode 100644 index 00000000..6767855c --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/purchase/project-status/page.tsx @@ -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([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [filter, setFilter] = useState(EMPTY_FILTER); + + const [productOpts, setProductOpts] = useState([]); + const yearOpts = useMemo(() => getYearOptions(), []); + + const fetchList = useCallback(async (override?: Partial) => { + 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 ( +
+ + 총 {total.toLocaleString()}건}> + + setFilter({ ...filter, year: v })} /> + + + setFilter({ ...filter, customer_objid: v })} /> + + + setFilter({ ...filter, project_no: e.target.value })} /> + + + setFilter({ ...filter, product_cd: v })} /> + + + setFilter({ ...filter, part_no: e.target.value })} /> + + + setFilter({ ...filter, part_name: e.target.value })} /> + + + + { 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 = {}; + GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "프로젝트별_발주_입고현황.xlsx", "프로젝트현황"); + }} + showChart + /> +
+ ); +} diff --git a/frontend/app/(main)/COMPANY_16/purchase/proposal/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/proposal/page.tsx new file mode 100644 index 00000000..c659c89b --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/purchase/proposal/page.tsx @@ -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([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [filter, setFilter] = useState(EMPTY_FILTER); + const [checkedIds, setCheckedIds] = useState([]); + + const [purchaseTypeOpts, setPurchaseTypeOpts] = useState([]); + const [partTypeOpts, setPartTypeOpts] = useState([]); + const [userOpts, setUserOpts] = useState([]); + + const fetchList = useCallback(async (override?: Partial) => { + 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 ( +
+ + + + } + /> + 총 {total.toLocaleString()}건}> + + setFilter({ ...filter, proposal_no: e.target.value })} /> + + + setFilter({ ...filter, project_no: e.target.value })} /> + + + setFilter({ ...filter, search_status: v })} /> + + + setFilter({ ...filter, regdate_start: v })} + to={filter.regdate_end ?? ""} setTo={(v) => setFilter({ ...filter, regdate_end: v })} + /> + + + setFilter({ ...filter, purchase_type: v })} /> + + + setFilter({ ...filter, writer: v })} /> + + + setFilter({ ...filter, part_type: v })} /> + + + + { 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 = {}; + GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "품의서관리.xlsx", "품의서"); + }} + showChart + /> +
+ ); +} diff --git a/frontend/app/(main)/COMPANY_16/purchase/quote-request/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/quote-request/page.tsx new file mode 100644 index 00000000..c5837bc6 --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/purchase/quote-request/page.tsx @@ -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([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [filter, setFilter] = useState(EMPTY_FILTER); + const [checkedIds, setCheckedIds] = useState([]); + + const [productOpts, setProductOpts] = useState([]); + const [supplierOpts, setSupplierOpts] = useState([]); + const [userOpts, setUserOpts] = useState([]); + + const yearOpts = useMemo(() => getYearOptions(), []); + + const fetchList = useCallback(async (override?: Partial) => { + 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 ( +
+ + + + } + /> + 총 {total.toLocaleString()}건}> + + setFilter({ ...filter, year: v })} /> + + + setFilter({ ...filter, project_no: e.target.value })} /> + + + setFilter({ ...filter, proposal_no: e.target.value })} /> + + + setFilter({ ...filter, partner_objid: v })} /> + + + setFilter({ ...filter, mail_send_yn: v })} /> + + + setFilter({ ...filter, writer: v })} /> + + + setFilter({ ...filter, product_cd: v })} /> + + + + { 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 = {}; + GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "견적요청서관리.xlsx", "견적요청서"); + }} + showChart + /> +
+ ); +} diff --git a/frontend/components/production/MbomAssignDialog.tsx b/frontend/components/production/MbomAssignDialog.tsx new file mode 100644 index 00000000..2d6fad4c --- /dev/null +++ b/frontend/components/production/MbomAssignDialog.tsx @@ -0,0 +1,190 @@ +"use client"; + +// 생산관리 > M-BOM 관리 — BOM 할당 다이얼로그 (PR-B5). +// +// 운영판 mBomEbomSelectPopup.jsp + assignEbomToMbom.do 1:1. +// 프로젝트에 source E-BOM (part_bom_report) 을 지정 → project_mgmt.source_bom_type='EBOM'. +// 할당 후 M-BOM 본 다이얼로그 트리가 ASSIGNED_EBOM 분기로 자동 표시됨. +// +// (M-BOM 할당은 PR-B5 v2 예정 — 우선 E-BOM 만) + +import React, { useEffect, 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, Link as LinkIcon } from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { mbomApi, AssignableEbomRow } from "@/lib/api/mbom"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + projectObjid: string | null; + currentSourceEbomObjid?: string | null; // 이미 할당돼있으면 그 행 하이라이트 + onAssigned: () => void; // 할당 성공 후 부모가 트리 재조회 +} + +export function MbomAssignDialog({ + open, onOpenChange, projectObjid, currentSourceEbomObjid, onAssigned, +}: Props) { + const [filter, setFilter] = useState({ + search_part_no: "", search_part_name: "", search_material: "", + }); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedObjid, setSelectedObjid] = useState(null); + const [assigning, setAssigning] = useState(false); + + const search = (override?: Partial) => { + 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([]); setSelectedObjid(null); + setFilter({ search_part_no: "", search_part_name: "", search_material: "" }); + return; + } + setSelectedObjid(currentSourceEbomObjid ?? null); + search(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, currentSourceEbomObjid]); + + const handleAssign = async () => { + if (!projectObjid) return; + if (!selectedObjid) { toast.error("E-BOM 한 건을 선택해주세요"); return; } + if (selectedObjid === currentSourceEbomObjid) { + toast.info("현재 할당된 E-BOM 과 동일합니다"); + return; + } + setAssigning(true); + try { + await mbomApi.assignBom({ + project_obj_id: projectObjid, + source_bom_type: "EBOM", + source_bom_obj_id: selectedObjid, + }); + toast.success("E-BOM 이 할당되었습니다"); + onAssigned(); + onOpenChange(false); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "할당 실패"); + } finally { + setAssigning(false); + } + }; + + return ( + + + + + + BOM 할당 — E-BOM 선택 + {currentSourceEbomObjid && ( + · 현재 할당: {currentSourceEbomObjid} + )} + + + + {/* 검색 */} +
+ 품번 + setFilter({ ...filter, search_part_no: e.target.value })} + onKeyDown={(e) => { if (e.key === "Enter") search(); }} + /> + 품명 + setFilter({ ...filter, search_part_name: e.target.value })} + onKeyDown={(e) => { if (e.key === "Enter") search(); }} + /> + 재료 + setFilter({ ...filter, search_material: e.target.value })} + onKeyDown={(e) => { if (e.key === "Enter") search(); }} + /> + +
+ 총 {rows.length.toLocaleString()}건 (최대 100건) +
+
+ + {/* 그리드 */} +
+ + + + + + + + + + + + + + + + {loading && rows.length === 0 ? ( + + ) : rows.length === 0 ? ( + + ) : rows.map(r => { + const isSel = selectedObjid === r.objid; + const isCurrent = currentSourceEbomObjid === r.objid; + return ( + setSelectedObjid(r.objid)}> + + + + + + + + + + + ); + })} + +
선택제품구분품번품명재료메이커개정등록일작성자
조회된 E-BOM 이 없습니다.
+ setSelectedObjid(r.objid)} /> + {r.product_name ?? ""}{r.part_no} + {isCurrent && (현재)} + {r.part_name}{r.material}{r.supplier}{r.revision ?? ""}{r.reg_date ?? ""}{r.writer_name ?? ""}
+
+ + + + + +
+
+ ); +} diff --git a/frontend/components/production/MbomDetailDialog.tsx b/frontend/components/production/MbomDetailDialog.tsx index 2b0a56f9..2c9b5ef3 100644 --- a/frontend/components/production/MbomDetailDialog.tsx +++ b/frontend/components/production/MbomDetailDialog.tsx @@ -22,12 +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, History, Plus, Trash2 } 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; @@ -62,6 +63,7 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid, onSaved }: const [dirty, setDirty] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); const [addPartOpen, setAddPartOpen] = useState(false); + const [assignOpen, setAssignOpen] = useState(false); const [selectedChildObjids, setSelectedChildObjids] = useState>(new Set()); const loadTree = (objid: string) => { @@ -313,6 +315,12 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid, onSaved }: 변경이력 )} + {!editMode && ( + + )} {canEdit && !editMode && (