구매관리 7메뉴 신규 + M-BOM PR-B3·B5 + 발주관리 DataGrid 통일 + 생산계획&실적 라우트

구매관리 (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) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-14 17:31:12 +09:00
parent dee03f6024
commit b38f5957f2
29 changed files with 4470 additions and 40 deletions
+5
View File
@@ -120,6 +120,8 @@ import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변
import bomRoutes from "./routes/bomRoutes"; // BOM 이력/버전 관리
import productionRoutes from "./routes/productionRoutes"; // 생산계획 관리
import productionMbomRoutes from "./routes/productionMbomRoutes"; // 생산관리>M-BOM 관리 (wace_plm 도메인)
import { planResultRouter as productionPlanResultRoutes, mbomReqRouter as productionMbomRequirementRoutes } from "./routes/productionPlanResultRoutes"; // 생산관리 4개 메뉴 (생산계획&실적, 소요량)
import purchaseRoutes from "./routes/purchaseRoutes"; // 구매관리 7개 메뉴 (wace_plm 도메인)
import itemInspectionRoutes from "./routes/itemInspectionRoutes"; // 품목검사정보
import 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); // 자재현황
@@ -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<string, any>;
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 {
@@ -0,0 +1,107 @@
// ============================================================
// 생산관리 > 생산계획&실적관리 (일반/장비) + 반제품·원자재 소요량.
// wace productionplanning.xml 1:1.
//
// 라우트:
// GET /api/production/plan-result/list (메뉴1 — 일반)
// GET /api/production/plan-result/list-equip (메뉴2 — 장비)
// GET /api/production/plan-result/project-options (검색 multiple — 프로젝트번호)
// GET /api/production/plan-result/writer-options (검색 — 등록자)
//
// GET /api/production/mbom-requirement/options (M-BOM 옵션 + 품명 매핑)
// POST /api/production/mbom-requirement/semi (메뉴3 — 반제품)
// POST /api/production/mbom-requirement/raw (메뉴4 — 원자재)
// ============================================================
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import * as ppr from "../services/prodPlanResultService";
import * as req from "../services/mbomRequirementService";
import { logger } from "../utils/logger";
function parseFilter(q: Record<string, any>): ppr.ProdPlanResultFilter {
const f: ppr.ProdPlanResultFilter = { ...q };
if (q.page) f.page = Number(q.page);
if (q.page_size) f.page_size = Number(q.page_size);
return f;
}
// ─── 메뉴 1: 생산계획&실적관리 (일반) ─────────────────────────
export async function getList(reqMsg: AuthenticatedRequest, res: Response) {
try {
const data = await ppr.listGeneral(parseFilter(reqMsg.query as Record<string, any>));
return res.json({ success: true, data });
} catch (e: any) {
logger.error("생산계획&실적관리 목록 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
// ─── 메뉴 2: 생산계획&실적관리 (장비) ─────────────────────────
export async function getListEquip(reqMsg: AuthenticatedRequest, res: Response) {
try {
const data = await ppr.listEquip(parseFilter(reqMsg.query as Record<string, any>));
return res.json({ success: true, data });
} catch (e: any) {
logger.error("생산계획&실적관리(장비) 목록 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
// ─── 검색 옵션 ────────────────────────────────────────────────
export async function getProjectOptions(_req: AuthenticatedRequest, res: Response) {
try {
const data = await ppr.getProjectNoOptions();
return res.json({ success: true, data });
} catch (e: any) {
logger.error("프로젝트번호 옵션 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export async function getWriterOptions(_req: AuthenticatedRequest, res: Response) {
try {
const data = await ppr.getWriterOptions();
return res.json({ success: true, data });
} catch (e: any) {
logger.error("등록자 옵션 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
// ─── 메뉴 3 & 4: M-BOM 소요량 ─────────────────────────────────
export async function getMbomOptions(_req: AuthenticatedRequest, res: Response) {
try {
const data = await req.getMbomOptions();
return res.json({ success: true, data });
} catch (e: any) {
logger.error("M-BOM 옵션 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export async function getSemiRequirement(reqMsg: AuthenticatedRequest, res: Response) {
try {
const items = Array.isArray(reqMsg.body?.mbomItems) ? reqMsg.body.mbomItems : [];
const data = await req.getSemiRequirement(items);
return res.json({ success: true, data });
} catch (e: any) {
logger.error("반제품 소요량 조회 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export async function getRawRequirement(reqMsg: AuthenticatedRequest, res: Response) {
try {
const items = Array.isArray(reqMsg.body?.mbomItems) ? reqMsg.body.mbomItems : [];
const data = await req.getRawRequirement(items);
return res.json({ success: true, data });
} catch (e: any) {
logger.error("원자재 소요량 조회 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
@@ -0,0 +1,68 @@
// ============================================================
// 구매관리 — 7개 메뉴 그리드 컨트롤러 (purchaseService.ts orchestrator)
// ============================================================
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import * as svc from "../services/purchaseService";
import { logger } from "../utils/logger";
function parseFilter(q: Record<string, any>): svc.PurchaseListFilter {
const f: svc.PurchaseListFilter = { ...q };
if (q.page) f.page = Number(q.page);
if (q.page_size) f.page_size = Number(q.page_size);
return f;
}
async function runList(
fn: (f: svc.PurchaseListFilter) => Promise<any>,
req: AuthenticatedRequest,
res: Response,
name: string,
) {
try {
const data = await fn(parseFilter(req.query as Record<string, any>));
return res.json({ success: true, data });
} catch (e: any) {
logger.error(`${name} 실패`, { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export const getPurchaseRequest = (req: AuthenticatedRequest, res: Response) => runList(svc.listPurchaseRequest, req, res, "구매리스트관리");
export const getQuotationRequest = (req: AuthenticatedRequest, res: Response) => runList(svc.listQuotationRequest, req, res, "견적요청서관리");
export const getProposal = (req: AuthenticatedRequest, res: Response) => runList(svc.listProposal, req, res, "품의서관리");
export const getInbound = (req: AuthenticatedRequest, res: Response) => runList(svc.listInbound, req, res, "입고관리");
export const getInboundByItem = (req: AuthenticatedRequest, res: Response) => runList(svc.listInboundByItem, req, res, "품목별 입고관리");
export const getInboundByDate = (req: AuthenticatedRequest, res: Response) => runList(svc.listInboundByDate, req, res, "입고일별 입고관리");
export const getProjectStatus = (req: AuthenticatedRequest, res: Response) => runList(svc.listProjectStatus, req, res, "프로젝트별 발주/입고 현황");
export async function getSuppliers(_req: AuthenticatedRequest, res: Response) {
try {
const data = await svc.listSupplierOptions();
return res.json({ success: true, data });
} catch (e: any) {
logger.error("공급업체 옵션 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export async function getUsers(_req: AuthenticatedRequest, res: Response) {
try {
const data = await svc.listUserOptions();
return res.json({ success: true, data });
} catch (e: any) {
logger.error("작성자 옵션 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export async function getProjects(_req: AuthenticatedRequest, res: Response) {
try {
const data = await svc.listProjectOptions();
return res.json({ success: true, data });
} catch (e: any) {
logger.error("프로젝트 옵션 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
@@ -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;
@@ -0,0 +1,28 @@
// ============================================================
// 생산관리 > 생산계획&실적 + 소요량 라우트.
// app.ts:
// app.use("/api/production/plan-result", productionPlanResultRoutes)
// app.use("/api/production/mbom-requirement", productionMbomRequirementRoutes)
// 각 라우트는 동일 컨트롤러를 공유하므로 한 파일에서 두 Router 를 export.
// ============================================================
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as ctrl from "../controllers/prodPlanResultController";
const planResultRouter = Router();
planResultRouter.use(authenticateToken);
planResultRouter.get("/list", ctrl.getList); // 메뉴1
planResultRouter.get("/list-equip", ctrl.getListEquip); // 메뉴2
planResultRouter.get("/project-options", ctrl.getProjectOptions); // 공용 검색 옵션
planResultRouter.get("/writer-options", ctrl.getWriterOptions); // 메뉴1 등록자
const mbomReqRouter = Router();
mbomReqRouter.use(authenticateToken);
mbomReqRouter.get("/options", ctrl.getMbomOptions); // 메뉴3+4 공용 M-BOM 옵션
mbomReqRouter.post("/semi", ctrl.getSemiRequirement); // 메뉴3 반제품
mbomReqRouter.post("/raw", ctrl.getRawRequirement); // 메뉴4 원자재
export { planResultRouter, mbomReqRouter };
+27
View File
@@ -0,0 +1,27 @@
// ============================================================
// 구매관리 — 7개 메뉴 + 공통 옵션 라우트.
// app.ts: app.use("/api/purchase", purchaseRoutes)
// ============================================================
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as ctrl from "../controllers/purchaseController";
const router = Router();
router.use(authenticateToken);
// 그리드 7종
router.get("/purchase-request", ctrl.getPurchaseRequest); // 구매리스트관리
router.get("/quotation-request", ctrl.getQuotationRequest); // 견적요청서관리
router.get("/proposal", ctrl.getProposal); // 품의서관리
router.get("/inbound", ctrl.getInbound); // 입고관리
router.get("/inbound-by-item", ctrl.getInboundByItem); // 품목별 입고관리
router.get("/inbound-by-date", ctrl.getInboundByDate); // 입고일별 입고관리
router.get("/project-status", ctrl.getProjectStatus); // 프로젝트별 발주/입고 현황
// 공통 옵션
router.get("/options/suppliers", ctrl.getSuppliers);
router.get("/options/users", ctrl.getUsers);
router.get("/options/projects", ctrl.getProjects);
export default router;
@@ -0,0 +1,259 @@
// ============================================================
// 생산관리 > 반제품소요량 + 원자재소요량 — wace productionplanning.xml 1:1.
//
// 매퍼 매핑:
// getMbomListWithPartName → getMbomOptions() (4017)
// getMbomSemiProductItems → getSemiRequirement() (5252)
// getMbomRawMaterialItems → getRawRequirement() (5273) 1차 — 구매품
// getMbomRawSourceItems → getRawRequirement() (5298) 2차 — 원소재
//
// 서비스 로직(ProductionPlanningService.java:2030~2270) 의 LinkedHashMap 합산을
// 자바스크립트 Map 으로 1:1 이식.
// ============================================================
import { getPool } from "../database/db";
// ─── 입력 / 출력 ────────────────────────────────────────────
export interface MbomRequirementInputItem {
mbomObjid: string;
qty: number | string;
}
export interface SemiRequirementRow {
PART_NO: string;
PART_NAME: string;
CATEGORY_NAME: string;
UNIT: string;
MATERIAL: string;
SPEC: string;
REQUIRED_QTY: number;
}
export interface RawRequirementRow {
PART_NO: string;
PART_NAME: string;
CATEGORY_NAME: string; // '구매품' | '원소재'
UNIT: string;
MATERIAL: string;
SPEC: string;
REQUIRED_QTY: number | string;
RAW_MATERIAL: string;
RAW_MATERIAL_SIZE: string;
MATERIAL_PART_NO: string;
MATERIAL_REQUIRED_QTY: number | string;
}
function toInt(v: any): number {
if (v == null) return 0;
if (typeof v === "number") return Math.trunc(v);
const n = Number(String(v));
return Number.isFinite(n) ? Math.trunc(n) : 0;
}
function toNum(v: any): number {
if (v == null) return 0;
if (typeof v === "number") return v;
const n = Number(String(v));
return Number.isFinite(n) ? n : 0;
}
// ─── M-BOM 옵션 조회 (셀렉트박스용 + 품명 매핑) ────────────────
export async function getMbomOptions(): Promise<Array<{ objid: string; mbom_no: string; part_name: string }>> {
const pool = getPool();
const r = await pool.query(`
SELECT OBJID::VARCHAR AS objid,
COALESCE(MBOM_NO, '') AS mbom_no,
COALESCE(PART_NAME, '') AS part_name
FROM MBOM_HEADER
WHERE STATUS = 'Y'
ORDER BY REGDATE DESC, MBOM_NO
`);
return r.rows;
}
// ─── 메뉴 3: 반제품 소요량 ──────────────────────────────────
//
// 매퍼: getMbomSemiProductItems (5252~5270)
// 조건: MBOM_DETAIL × PART_MNG, PART_TYPE IN ('0001812', '0001813'), 1레벨 제외.
// 자바 서비스: 동일 PART_NO 합산 (입력수량 × 항목수량).
export async function getSemiRequirement(items: MbomRequirementInputItem[]): Promise<SemiRequirementRow[]> {
if (!Array.isArray(items) || items.length === 0) return [];
const pool = getPool();
// PART_NO 기준 LinkedHashMap (자바 LinkedHashMap 동일 보장)
const partMap = new Map<string, SemiRequirementRow>();
for (const it of items) {
const mbomObjid = String(it?.mbomObjid ?? "").trim();
const inputQty = toInt(it?.qty);
if (!mbomObjid || inputQty <= 0) continue;
const r = await pool.query(
`
SELECT
MD.PART_NO,
MD.PART_NAME,
COALESCE(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<RawRequirementRow[]> {
if (!Array.isArray(items) || items.length === 0) return [];
const pool = getPool();
// 운영판 1:1 — 두 갈래 LinkedHashMap (구매품 / 원소재)
const purchaseMap = new Map<string, RawRequirementRow>();
// 원소재는 소수점 합산을 위해 임시 number 보관 후 마지막에 올림 처리
const rawSourceMap = new Map<string, RawRequirementRow & { __rawSum: number }>();
for (const it of items) {
const mbomObjid = String(it?.mbomObjid ?? "").trim();
const inputQty = toInt(it?.qty);
if (!mbomObjid || inputQty <= 0) continue;
// 1) 구매품 (PART_TYPE = '0000063')
const r1 = await pool.query(
`
SELECT
MD.PART_NO,
MD.PART_NAME,
COALESCE(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;
}
+194
View File
@@ -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<AssignableEbomRow[]> {
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<MbomHistoryRow[]
return r.rows;
}
// ─── 구매리스트 생성 (PR-B3) ────────────────────────────────
//
// wace 매퍼 salesMng.xml `getNextRequestMngNo` + `insertSalesRequestMasterFromMBom` 1:1
// (서비스 SalesMngService.createPurchaseListFromMBom, 매퍼 라인 3960~3997).
//
// 검증: M-BOM 헤더 존재 + 동일 mbom_header_objid 로 이미 생성된 sales_request_master 없음.
// 입력: mbomHeaderObjid (mbom_header.objid), projectMgmtObjid (project_mgmt.objid).
// 처리: 단일 INSERT, BEGIN/COMMIT 트랜잭션. mbom_header 역링크 컬럼 없음 (운영 동일).
export interface CreateSalesRequestResult {
objid: string;
request_mng_no: string;
mbom_header_objid: string;
}
export async function createSalesRequest(
mbomHeaderObjid: string,
projectMgmtObjid: string,
sessionUserId: string,
): Promise<CreateSalesRequestResult> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const mbom = String(mbomHeaderObjid ?? "").trim();
const proj = String(projectMgmtObjid ?? "").trim();
if (!mbom) throw new Error("MBOM_HEADER_OBJID 누락");
if (!proj) throw new Error("PROJECT_MGMT_OBJID 누락");
const userId = String(sessionUserId ?? "").trim() || "system";
// 1) M-BOM 헤더 존재 확인
const mh = await client.query(
`SELECT OBJID FROM MBOM_HEADER WHERE OBJID::VARCHAR = $1 AND STATUS = 'Y' LIMIT 1`,
[mbom],
);
if (!mh.rows[0]) throw new Error("저장된 M-BOM 헤더를 찾을 수 없습니다");
// 2) 동일 MBOM_HEADER 로 이미 생성된 구매리스트 차단 (운영판 sweetalert 가드 1:1)
const dup = await client.query(
`SELECT OBJID, REQUEST_MNG_NO FROM SALES_REQUEST_MASTER
WHERE MBOM_HEADER_OBJID = $1 LIMIT 1`,
[mbom],
);
if (dup.rows[0]) {
throw new Error(`이미 생성된 구매리스트가 있습니다 (${dup.rows[0].request_mng_no})`);
}
// 3) 채번: R + YYYYMMDD + - + 3자리 (운영 getNextRequestMngNo 1:1)
const seqRes = await client.query(
`SELECT 'R' || TO_CHAR(NOW(), 'YYYYMMDD') || '-' ||
LPAD(
(COALESCE(MAX(SUBSTR(REQUEST_MNG_NO, 11, 13)), '0')::INTEGER + 1)::TEXT,
3, '0'
) AS request_mng_no
FROM SALES_REQUEST_MASTER
WHERE DOC_TYPE IN ('PURCHASE_REQUEST', 'PURCHASE_REG') OR DOC_TYPE IS NULL`,
);
const requestMngNo: string = seqRes.rows[0]?.request_mng_no;
if (!requestMngNo) throw new Error("REQUEST_MNG_NO 채번 실패");
// 4) INSERT (운영 insertSalesRequestMasterFromMBom 1:1, PROJECT_NO 컬럼에 PROJECT_MGMT.OBJID 저장)
const newObjid = createObjId();
await client.query(
`INSERT INTO SALES_REQUEST_MASTER (
OBJID, REQUEST_MNG_NO, PROJECT_NO, MBOM_HEADER_OBJID,
REQUEST_USER_ID, STATUS, WRITER, REGDATE, DOC_TYPE
) VALUES ($1, $2, $3, $4, $5, 'create', $6, NOW(), 'PURCHASE_REQUEST')`,
[newObjid, requestMngNo, proj, mbom, userId, userId],
);
await client.query("COMMIT");
return {
objid: newObjid,
request_mng_no: requestMngNo,
mbom_header_objid: mbom,
};
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
async function insertHistory(
client: any,
mbomHeaderObjid: string,
@@ -0,0 +1,400 @@
// ============================================================
// 생산관리 > 생산계획&실적관리 + 생산계획&실적관리(장비) — wace productionplanning.xml 1:1.
//
// 매퍼 매핑:
// prodPlanResultMgmtGridList → listGeneral() (productionplanning.xml:4550)
// prodPlanResultMgmtEquipGridList → listEquip() (productionplanning.xml:4887)
//
// 두 메뉴는 동일 베이스(PROJECT_MGMT + CONTRACT_MGMT + CONTRACT_ITEM + PRODUCTION_PLAN +
// PRODUCTION_RESULT 합산) 위에서 PRODUCT 코드(0000928=장비) 기준으로 갈라진다.
// ============================================================
import { getPool } from "../database/db";
// ─── 필터 ────────────────────────────────────────────────────
export interface ProdPlanResultFilter {
search_project_nos?: string; // "OBJID,OBJID,..." (multiple select)
search_product_code?: string;
search_category_code?: string;
search_production_type?: string;
search_customer_objid?: string;
search_req_del_date_from?: string;
search_req_del_date_to?: string;
search_part_no?: string;
search_part_name?: string;
search_serial_no?: string;
search_writer?: string;
search_regdate_from?: string;
search_regdate_to?: string;
page?: number;
page_size?: number;
}
function paginate(filter: { page?: number; page_size?: number }) {
const page = Math.max(1, Number(filter.page) || 1);
const pageSize = Math.min(500, Math.max(1, Number(filter.page_size) || 50));
return { limit: pageSize, offset: (page - 1) * pageSize, page, pageSize };
}
// ─── 공통 WHERE 빌더 (매퍼 <if> 1:1) ──────────────────────────
//
// 매퍼: 4684~4741 / 4985~5022. 두 메뉴 동일.
// (Equip 은 search_writer / search_regdate 없지만, 빌더는 동일 — 안 쓰면 됨.)
function buildWhere(filter: ProdPlanResultFilter, startIdx: number, includeSerial: boolean) {
const params: any[] = [];
const conds: string[] = [];
let idx = startIdx;
if (filter.search_project_nos) {
const ids = filter.search_project_nos.split(",").map((s) => s.trim()).filter(Boolean);
if (ids.length > 0) {
const placeholders = ids.map(() => `$${idx++}`).join(", ");
conds.push(`T.OBJID::VARCHAR IN (${placeholders})`);
params.push(...ids);
}
}
if (filter.search_product_code) {
conds.push(`T.PRODUCT = $${idx++}`);
params.push(filter.search_product_code);
}
if (filter.search_category_code) {
conds.push(`T.CATEGORY_CODE = $${idx++}`);
params.push(filter.search_category_code);
}
if (filter.search_production_type) {
conds.push(`T.PRODUCTION_TYPE = $${idx++}`);
params.push(filter.search_production_type);
}
if (filter.search_customer_objid) {
conds.push(
`(T.CUSTOMER_OBJID = $${idx} OR T.CUSTOMER_OBJID = REPLACE($${idx}, 'C_', '') OR REPLACE(T.CUSTOMER_OBJID, 'C_', '') = REPLACE($${idx}, 'C_', ''))`
);
params.push(filter.search_customer_objid);
idx++;
}
if (filter.search_req_del_date_from) {
conds.push(`T.REQ_DEL_DATE >= $${idx++}`);
params.push(filter.search_req_del_date_from);
}
if (filter.search_req_del_date_to) {
conds.push(`T.REQ_DEL_DATE <= $${idx++}`);
params.push(filter.search_req_del_date_to);
}
if (filter.search_part_no) {
conds.push(`UPPER(T.PART_NO) LIKE '%' || UPPER($${idx++}) || '%'`);
params.push(filter.search_part_no);
}
if (filter.search_part_name) {
conds.push(`UPPER(T.PART_NAME) LIKE '%' || UPPER($${idx++}) || '%'`);
params.push(filter.search_part_name);
}
if (filter.search_serial_no) {
// 일반: SERIAL_NO_LIST 전체 매칭 / 장비: SERIAL_NO 단일 (둘 다 동일 컬럼 alias 로 처리)
const col = includeSerial ? "T.SERIAL_NO_LIST" : "T.SERIAL_NO";
conds.push(`UPPER(${col}) LIKE '%' || UPPER($${idx++}) || '%'`);
params.push(filter.search_serial_no);
}
if (filter.search_writer) {
conds.push(`T.WRITER = $${idx++}`);
params.push(filter.search_writer);
}
if (filter.search_regdate_from) {
conds.push(`T.REGDATE >= TO_DATE($${idx++}, 'YYYY-MM-DD')`);
params.push(filter.search_regdate_from);
}
if (filter.search_regdate_to) {
conds.push(`T.REGDATE <= TO_DATE($${idx++}, 'YYYY-MM-DD')`);
params.push(filter.search_regdate_to);
}
return { sql: conds.length ? "AND " + conds.join(" AND ") : "", params };
}
// ─── 메뉴 1: 생산계획&실적관리 (일반) ─────────────────────────
//
// 매퍼 prodPlanResultMgmtGridList (4550~4743) 1:1.
// UNION ALL 두 갈래:
// 1) PROJECT_MGMT 기반 — PM.PRODUCT != '0000928' (장비 제외)
// 2) PRODUCTION_PLAN 단독 — PROJECT_OBJID 비어있는 독립 계획
const SQL_GENERAL_INNER = `
SELECT * FROM (
-- 1) 프로젝트 기반
SELECT
PM.OBJID::VARCHAR AS OBJID,
PM.PROJECT_NO,
CM.PRODUCT,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.PRODUCT LIMIT 1), '') AS PRODUCT_NAME,
CM.CATEGORY_CD AS CATEGORY_CODE,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.CATEGORY_CD LIMIT 1), CM.CATEGORY_CD) AS CATEGORY_CODE_NAME,
COALESCE(PP.PRODUCTION_TYPE, '') AS PRODUCTION_TYPE,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = PP.PRODUCTION_TYPE LIMIT 1), '') AS PRODUCTION_TYPE_NAME,
CM.CUSTOMER_OBJID,
COALESCE(
CASE WHEN CM.CUSTOMER_OBJID LIKE 'C_%'
THEN (SELECT CLIENT_NM FROM CLIENT_MNG AS C WHERE 'C_' || C.OBJID::VARCHAR = CM.CUSTOMER_OBJID LIMIT 1)
ELSE (SELECT SUPPLY_NAME FROM SUPPLY_MNG WHERE OBJID::VARCHAR = CM.CUSTOMER_OBJID::VARCHAR LIMIT 1) END,
''
) AS CUSTOMER_NAME,
COALESCE(CI.DUE_DATE, PM.DUE_DATE, CM.REQ_DEL_DATE) AS REQ_DEL_DATE,
COALESCE(CI.CUSTOMER_REQUEST, '') AS CUSTOMER_REQUEST,
COALESCE(CI.PART_NO, PM.PART_NO, '') AS PART_NO,
COALESCE(CI.PART_NAME, PM.PART_NAME, '') AS PART_NAME,
(SELECT CASE
WHEN COUNT(*) = 0 THEN ''
WHEN COUNT(*) = 1 THEN MIN(CIS.SERIAL_NO)
ELSE MIN(CIS.SERIAL_NO) || ' 외 ' || (COUNT(*) - 1)::TEXT || '건' END
FROM CONTRACT_ITEM_SERIAL CIS
WHERE CIS.ITEM_OBJID::VARCHAR = PM.CONTRACT_ITEM_OBJID
AND UPPER(CIS.STATUS) = 'ACTIVE'
AND CIS.SERIAL_NO IS NOT NULL AND CIS.SERIAL_NO != '') AS SERIAL_NO,
(SELECT STRING_AGG(CIS.SERIAL_NO, ', ' ORDER BY CIS.SERIAL_NO)
FROM CONTRACT_ITEM_SERIAL CIS
WHERE CIS.ITEM_OBJID::VARCHAR = PM.CONTRACT_ITEM_OBJID
AND UPPER(CIS.STATUS) = 'ACTIVE'
AND CIS.SERIAL_NO IS NOT NULL AND CIS.SERIAL_NO != '') AS SERIAL_NO_LIST,
COALESCE(NULLIF(PM.QUANTITY, '')::numeric, NULLIF(CI.ORDER_QUANTITY, '')::numeric, 0) AS QUANTITY,
COALESCE(NULLIF(PP.EXTRA_PROD_QTY, '')::numeric, 0) AS EXTRA_PROD_QTY,
COALESCE(NULLIF(PM.QUANTITY, '')::numeric, NULLIF(CI.ORDER_QUANTITY, '')::numeric, 0)
+ COALESCE(NULLIF(PP.EXTRA_PROD_QTY, '')::numeric, 0) AS TOTAL_PROD_QTY,
COALESCE((SELECT SUM(RESULT_QTY) FROM PRODUCTION_RESULT PR
WHERE PR.PROJECT_OBJID = PM.OBJID::VARCHAR AND PR.RESULT_TYPE = 'ASSEMBLY' AND PR.STATUS = 'active'), 0) AS ASSEMBLY_QTY,
COALESCE((SELECT SUM(RESULT_QTY) FROM PRODUCTION_RESULT PR
WHERE PR.PROJECT_OBJID = PM.OBJID::VARCHAR AND PR.RESULT_TYPE = 'INSPECTION' AND PR.STATUS = 'active'), 0) AS INSPECTION_QTY,
COALESCE((SELECT SUM(RESULT_QTY) FROM PRODUCTION_RESULT PR
WHERE PR.PROJECT_OBJID = PM.OBJID::VARCHAR AND PR.RESULT_TYPE = 'SHIP_WAIT' AND PR.STATUS = 'active'), 0) AS SHIP_WAIT_QTY,
PP.OBJID::VARCHAR AS PROD_PLAN_OBJID,
PM.REGDATE AS SORT_DATE,
PP.WRITER,
COALESCE(user_name(PP.WRITER), '') AS WRITER_NAME,
TO_CHAR(PP.REGDATE, 'YYYY-MM-DD') AS REGDATE_TITLE,
PP.REGDATE
FROM PROJECT_MGMT PM
LEFT JOIN CONTRACT_MGMT CM ON PM.CONTRACT_OBJID = CM.OBJID
LEFT OUTER JOIN CONTRACT_ITEM CI ON (
CASE
WHEN PM.CONTRACT_ITEM_OBJID IS NOT NULL THEN CI.OBJID::VARCHAR = PM.CONTRACT_ITEM_OBJID
ELSE CI.CONTRACT_OBJID = PM.CONTRACT_OBJID AND CI.PART_OBJID = PM.PART_OBJID
END
) AND CI.STATUS = 'ACTIVE'
LEFT OUTER JOIN PRODUCTION_PLAN PP ON PP.PROJECT_OBJID = PM.OBJID::VARCHAR AND PP.STATUS = 'active'
WHERE PM.PROJECT_NO IS NOT NULL AND PM.PROJECT_NO != ''
AND (CM.PRODUCT IS NULL OR CM.PRODUCT != '0000928')
UNION ALL
-- 2) 프로젝트 없이 등록한 생산계획
SELECT
PP.OBJID::VARCHAR AS OBJID,
'' AS PROJECT_NO,
PP.PRODUCT_CODE AS PRODUCT,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = PP.PRODUCT_CODE LIMIT 1), '') AS PRODUCT_NAME,
PP.CATEGORY_CODE AS CATEGORY_CODE,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = PP.CATEGORY_CODE LIMIT 1), '') AS CATEGORY_CODE_NAME,
COALESCE(PP.PRODUCTION_TYPE, '') AS PRODUCTION_TYPE,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = PP.PRODUCTION_TYPE LIMIT 1), '') AS PRODUCTION_TYPE_NAME,
PP.CUSTOMER_OBJID,
COALESCE(
CASE WHEN PP.CUSTOMER_OBJID LIKE 'C_%'
THEN (SELECT CLIENT_NM FROM CLIENT_MNG AS C WHERE 'C_' || C.OBJID::VARCHAR = PP.CUSTOMER_OBJID LIMIT 1)
ELSE (SELECT CLIENT_NM FROM CLIENT_MNG WHERE OBJID::VARCHAR = PP.CUSTOMER_OBJID LIMIT 1) END,
''
) AS CUSTOMER_NAME,
PP.REQ_DEL_DATE,
COALESCE(PP.CUSTOMER_REQUEST, '') AS CUSTOMER_REQUEST,
COALESCE(PP.PART_NO, '') AS PART_NO,
COALESCE(PP.PART_NAME, '') AS PART_NAME,
COALESCE(PP.SERIAL_NO, '') AS SERIAL_NO,
COALESCE(PP.SERIAL_NO, '') AS SERIAL_NO_LIST,
COALESCE(NULLIF(PP.ORDER_QTY, '')::numeric, 0) AS QUANTITY,
COALESCE(NULLIF(PP.EXTRA_PROD_QTY, '')::numeric, 0) AS EXTRA_PROD_QTY,
COALESCE(NULLIF(PP.TOTAL_PROD_QTY, '')::numeric, 0) AS TOTAL_PROD_QTY,
COALESCE((SELECT SUM(RESULT_QTY) FROM PRODUCTION_RESULT PR
WHERE PR.PROJECT_OBJID = PP.OBJID::VARCHAR AND PR.RESULT_TYPE = 'ASSEMBLY' AND PR.STATUS = 'active'), 0) AS ASSEMBLY_QTY,
COALESCE((SELECT SUM(RESULT_QTY) FROM PRODUCTION_RESULT PR
WHERE PR.PROJECT_OBJID = PP.OBJID::VARCHAR AND PR.RESULT_TYPE = 'INSPECTION' AND PR.STATUS = 'active'), 0) AS INSPECTION_QTY,
COALESCE((SELECT SUM(RESULT_QTY) FROM PRODUCTION_RESULT PR
WHERE PR.PROJECT_OBJID = PP.OBJID::VARCHAR AND PR.RESULT_TYPE = 'SHIP_WAIT' AND PR.STATUS = 'active'), 0) AS SHIP_WAIT_QTY,
PP.OBJID::VARCHAR AS PROD_PLAN_OBJID,
PP.REGDATE AS SORT_DATE,
PP.WRITER,
COALESCE(user_name(PP.WRITER), '') AS WRITER_NAME,
TO_CHAR(PP.REGDATE, 'YYYY-MM-DD') AS REGDATE_TITLE,
PP.REGDATE
FROM PRODUCTION_PLAN PP
WHERE PP.STATUS = 'active'
AND (PP.PROJECT_OBJID IS NULL OR PP.PROJECT_OBJID = '')
) T
WHERE 1=1
`;
export async function listGeneral(filter: ProdPlanResultFilter) {
const { limit, offset, page, pageSize } = paginate(filter);
const where = buildWhere(filter, 1, true);
const pool = getPool();
const dataSql = `
SELECT * FROM (${SQL_GENERAL_INNER} ${where.sql}) X
ORDER BY X.SORT_DATE DESC NULLS LAST, X.PROJECT_NO DESC
LIMIT $${where.params.length + 1} OFFSET $${where.params.length + 2}
`;
const countSql = `SELECT COUNT(*)::int AS cnt FROM (${SQL_GENERAL_INNER} ${where.sql}) X`;
const [dataRes, countRes] = await Promise.all([
pool.query(dataSql, [...where.params, limit, offset]),
pool.query(countSql, where.params),
]);
return {
rows: dataRes.rows,
totalCount: countRes.rows[0]?.cnt ?? 0,
page,
pageSize,
};
}
// ─── 메뉴 2: 생산계획&실적관리(장비) ────────────────────────
//
// 매퍼 prodPlanResultMgmtEquipGridList (4887~5024) 1:1.
// PM.PRODUCT = '0000928' (장비) 만. PMS_WBS_TASK 기반 진척율.
// (운영판 CONTRACT_ITEM 조인 중복 라인은 정리 — 동일 ON 조건이 2번 들어있음)
const SQL_EQUIP_INNER = `
SELECT * FROM (
SELECT
PM.OBJID::VARCHAR AS OBJID,
PM.PROJECT_NO,
CM.PRODUCT,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.PRODUCT LIMIT 1), '') AS PRODUCT_NAME,
CM.CATEGORY_CD AS CATEGORY_CODE,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.CATEGORY_CD LIMIT 1), CM.CATEGORY_CD) AS CATEGORY_CODE_NAME,
COALESCE(PP.PRODUCTION_TYPE, '') AS PRODUCTION_TYPE,
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = PP.PRODUCTION_TYPE LIMIT 1), '') AS PRODUCTION_TYPE_NAME,
CM.CUSTOMER_OBJID,
COALESCE(
CASE WHEN CM.CUSTOMER_OBJID LIKE 'C_%'
THEN (SELECT CLIENT_NM FROM CLIENT_MNG AS C WHERE 'C_' || C.OBJID::VARCHAR = CM.CUSTOMER_OBJID LIMIT 1)
ELSE (SELECT SUPPLY_NAME FROM SUPPLY_MNG WHERE OBJID::VARCHAR = CM.CUSTOMER_OBJID::VARCHAR LIMIT 1) END,
''
) AS CUSTOMER_NAME,
COALESCE(CI.DUE_DATE, PM.DUE_DATE, CM.REQ_DEL_DATE) AS REQ_DEL_DATE,
COALESCE(CI.CUSTOMER_REQUEST, '') AS CUSTOMER_REQUEST,
COALESCE(CI.PART_NO, PM.PART_NO, '') AS PART_NO,
COALESCE(CI.PART_NAME, PM.PART_NAME, '') AS PART_NAME,
(SELECT CASE
WHEN COUNT(*) = 0 THEN ''
WHEN COUNT(*) = 1 THEN MIN(CIS.SERIAL_NO)
ELSE MIN(CIS.SERIAL_NO) || ' 외 ' || (COUNT(*) - 1)::TEXT || '건' END
FROM CONTRACT_ITEM_SERIAL CIS
WHERE CIS.ITEM_OBJID = CI.OBJID
AND UPPER(CIS.STATUS) = 'ACTIVE'
AND CIS.SERIAL_NO IS NOT NULL) AS SERIAL_NO,
-- 생산WBS 건수 (WBS_TYPE='PRODUCE')
(SELECT COUNT(1)::int FROM PMS_WBS_TASK O
WHERE O.CONTRACT_OBJID = PM.OBJID::VARCHAR
AND O.WBS_TYPE = 'PRODUCE') AS PROD_WBS_CNT,
-- 생산진척율: Level1 PROGRESS 평균
CASE
WHEN (SELECT COUNT(1) FROM PMS_WBS_TASK O
WHERE O.CONTRACT_OBJID = PM.OBJID::VARCHAR
AND O.WBS_TYPE = 'PRODUCE'
AND O.TASK_LEVEL = '1') = 0 THEN 0
ELSE ROUND(
(SELECT COALESCE(AVG(CASE WHEN O.PROGRESS IS NOT NULL AND O.PROGRESS != ''
THEN CAST(O.PROGRESS AS numeric) ELSE 0 END), 0)
FROM PMS_WBS_TASK O
WHERE O.CONTRACT_OBJID = PM.OBJID::VARCHAR
AND O.WBS_TYPE = 'PRODUCE'
AND O.TASK_LEVEL = '1'), 1)
END AS PROD_PROGRESS_RATE,
-- 납품WBS 건수 (WBS_TYPE='SHIP')
(SELECT COUNT(1)::int FROM PMS_WBS_TASK O
WHERE O.CONTRACT_OBJID = PM.OBJID::VARCHAR
AND O.WBS_TYPE = 'SHIP') AS DELV_WBS_CNT,
-- 납품진척율: Level1 PROGRESS 평균
CASE
WHEN (SELECT COUNT(1) FROM PMS_WBS_TASK O
WHERE O.CONTRACT_OBJID = PM.OBJID::VARCHAR
AND O.WBS_TYPE = 'SHIP'
AND O.TASK_LEVEL = '1') = 0 THEN 0
ELSE ROUND(
(SELECT COALESCE(AVG(CASE WHEN O.PROGRESS IS NOT NULL AND O.PROGRESS != ''
THEN CAST(O.PROGRESS AS numeric) ELSE 0 END), 0)
FROM PMS_WBS_TASK O
WHERE O.CONTRACT_OBJID = PM.OBJID::VARCHAR
AND O.WBS_TYPE = 'SHIP'
AND O.TASK_LEVEL = '1'), 1)
END AS DELV_PROGRESS_RATE,
PM.REGDATE AS SORT_DATE
FROM PROJECT_MGMT PM
LEFT JOIN CONTRACT_MGMT CM ON PM.CONTRACT_OBJID = CM.OBJID
LEFT OUTER JOIN CONTRACT_ITEM CI ON (
CASE
WHEN PM.CONTRACT_ITEM_OBJID IS NOT NULL THEN CI.OBJID::VARCHAR = PM.CONTRACT_ITEM_OBJID
ELSE CI.CONTRACT_OBJID = PM.CONTRACT_OBJID AND CI.PART_OBJID = PM.PART_OBJID
END
) AND CI.STATUS = 'ACTIVE'
LEFT OUTER JOIN PRODUCTION_PLAN PP ON PP.PROJECT_OBJID = PM.OBJID::VARCHAR AND PP.STATUS = 'active'
WHERE PM.PROJECT_NO IS NOT NULL AND PM.PROJECT_NO != ''
AND CM.PRODUCT = '0000928'
) T
WHERE 1=1
`;
export async function listEquip(filter: ProdPlanResultFilter) {
const { limit, offset, page, pageSize } = paginate(filter);
const where = buildWhere(filter, 1, false);
const pool = getPool();
const dataSql = `
SELECT * FROM (${SQL_EQUIP_INNER} ${where.sql}) X
ORDER BY X.SORT_DATE DESC NULLS LAST, X.PROJECT_NO DESC
LIMIT $${where.params.length + 1} OFFSET $${where.params.length + 2}
`;
const countSql = `SELECT COUNT(*)::int AS cnt FROM (${SQL_EQUIP_INNER} ${where.sql}) X`;
const [dataRes, countRes] = await Promise.all([
pool.query(dataSql, [...where.params, limit, offset]),
pool.query(countSql, where.params),
]);
return {
rows: dataRes.rows,
totalCount: countRes.rows[0]?.cnt ?? 0,
page,
pageSize,
};
}
// ─── 검색 옵션 — 프로젝트번호 / 등록자 ──────────────────────
//
// 운영판 페이지 진입 시 모델에 담아주던 code_map.project_no / code_map.writer 대체.
export async function getProjectNoOptions() {
const pool = getPool();
const r = await pool.query(`
SELECT PM.OBJID::VARCHAR AS code, PM.PROJECT_NO AS label
FROM PROJECT_MGMT PM
WHERE PM.PROJECT_NO IS NOT NULL AND PM.PROJECT_NO != ''
ORDER BY PM.REGDATE DESC, PM.PROJECT_NO DESC
LIMIT 1000
`);
return r.rows;
}
export async function getWriterOptions() {
const pool = getPool();
// PRODUCTION_PLAN.WRITER 와 PROJECT_MGMT.REGISTER 모두 user_info 의 user_id.
// 4개 메뉴 중 메뉴1 의 search_writer 만 사용. 활성 등록자만.
const r = await pool.query(`
SELECT DISTINCT PP.WRITER AS code,
COALESCE(user_name(PP.WRITER), PP.WRITER) AS label
FROM PRODUCTION_PLAN PP
WHERE PP.WRITER IS NOT NULL AND PP.WRITER != ''
AND PP.STATUS = 'active'
ORDER BY label
`);
return r.rows;
}
@@ -0,0 +1,438 @@
// ============================================================
// 구매관리 — 7개 메뉴 그리드/옵션 서비스
//
// wace_plm 1:1 이식 베이스. 마스터 테이블만 RPS 에 존재 (2026-05-14 현재):
// ✓ sales_request_master / mbom_header / mbom_detail
// ✓ purchase_order_master (56 cols, 0 rows)
// ✓ client_mng / supply_mng / admin_supply_mng / part_mng / project_mgmt / contract_mgmt
//
// 누락 (운영DB 추출 후 신설 필요):
// ✗ sales_request_part / sales_request_detail
// ✗ quotation_request_master / quotation_received
// ✗ purchase_order_part
// ✗ arrival_plan
// ✗ inventory_mgmt / inventory_mgmt_in
// ✗ incoming_inspection / incoming_inspection_detail
//
// 정책: 누락 테이블 의존 SELECT 는 빈 그리드 + 콘솔 warning 으로 처리.
// 마스터 단독 데이터는 정상 노출 (구매리스트관리 / 품의서관리 / 발주서관리).
// ============================================================
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
export interface PurchaseListFilter {
year?: string;
customer_objid?: string;
customer_cd?: string;
project_no?: string;
part_no?: string;
part_name?: string;
part_spec?: string;
partner_objid?: string;
purchase_order_no?: string;
proposal_no?: string;
search_status?: string;
writer?: string;
request_user?: string;
purchase_type?: string;
part_type?: string;
product_cd?: string;
paid_type?: string;
mail_send_yn?: string;
delivery_status?: string;
close_status?: string;
sales_mng_user_id?: string;
regdate_start?: string;
regdate_end?: string;
receipt_date_start?: string;
receipt_date_end?: string;
delivery_start_date?: string;
delivery_end_date?: string;
reg_start_date?: string;
reg_end_date?: string;
page?: number;
page_size?: number;
}
interface ListResult<T> {
rows: T[];
totalCount: number;
page: number;
pageSize: number;
}
function clampPaging(filter: PurchaseListFilter): { limit: number; offset: number; page: number; pageSize: number } {
const page = Math.max(1, Number(filter.page ?? 1));
const pageSize = Math.max(1, Math.min(500, Number(filter.page_size ?? 50)));
return { limit: pageSize, offset: (page - 1) * pageSize, page, pageSize };
}
// ─── 1) 구매리스트관리 (wace salesMng.xml salesRequestMngRegList 매퍼 1:1 베이스) ──
//
// sales_request_master + (sales_request_part 누락 → PART_NO/PART_NAME 빈값) +
// project_mgmt + contract_mgmt + comm_code + client_mng (customer 분기).
//
// WHERE: doc_type = 'PURCHASE_REQUEST' (또는 NULL) + 동적 필터.
// ORDER: regdate DESC.
export async function listPurchaseRequest(filter: PurchaseListFilter): Promise<ListResult<any>> {
const pool = getPool();
const { limit, offset, page, pageSize } = clampPaging(filter);
const where: string[] = [`(SRM.DOC_TYPE = 'PURCHASE_REQUEST' OR SRM.DOC_TYPE IS NULL)`];
const params: any[] = [];
const addParam = (val: any) => { params.push(val); return `$${params.length}`; };
if (filter.customer_cd) where.push(`SRM.CUSTOMER_OBJID = ${addParam(filter.customer_cd)}`);
if (filter.project_no) where.push(`PM.PROJECT_NO ILIKE ${addParam(`%${filter.project_no}%`)}`);
if (filter.request_user) where.push(`SRM.REQUEST_USER_ID = ${addParam(filter.request_user)}`);
if (filter.part_type) where.push(`SRM.PRODUCT_NAME = ${addParam(filter.part_type)}`);
if (filter.regdate_start) where.push(`SRM.REGDATE::DATE >= ${addParam(filter.regdate_start)}::DATE`);
if (filter.regdate_end) where.push(`SRM.REGDATE::DATE <= ${addParam(filter.regdate_end)}::DATE`);
if (filter.part_no) where.push(`EXISTS (SELECT 1 FROM MBOM_DETAIL MD JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR=PP.OBJID::VARCHAR WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID AND PP.PART_NO ILIKE ${addParam(`%${filter.part_no}%`)})`);
if (filter.part_name) where.push(`EXISTS (SELECT 1 FROM MBOM_DETAIL MD JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR=PP.OBJID::VARCHAR WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID AND PP.PART_NAME ILIKE ${addParam(`%${filter.part_name}%`)})`);
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const dataSql = `
SELECT
SRM.OBJID AS objid,
SRM.REQUEST_MNG_NO AS request_mng_no,
SRM.DOC_TYPE AS doc_type,
SRM.STATUS AS status,
CASE SRM.STATUS
WHEN 'create' THEN '작성중'
WHEN 'approvalRequest' THEN '결재중'
WHEN 'approvalComplete' THEN '결재완료'
WHEN 'reject' THEN '반려'
WHEN 'release' THEN '진행중'
ELSE COALESCE(SRM.STATUS, '')
END AS status_title,
COALESCE(
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.PURCHASE_TYPE LIMIT 1), ''
) AS purchase_type_name,
COALESCE(
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.ORDER_TYPE LIMIT 1), ''
) AS order_type_name,
COALESCE(
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.PRODUCT_NAME LIMIT 1), ''
) AS product_name_full,
CASE WHEN SRM.PAID_TYPE = 'paid' THEN '유상'
WHEN SRM.PAID_TYPE = 'free' THEN '무상'
ELSE COALESCE(SRM.PAID_TYPE, '')
END AS paid_type_name,
PM.PROJECT_NO AS project_number,
-- 고객사 (wace 동일 — 프로젝트.contract_mgmt.customer_objid 우선)
COALESCE(
(SELECT CM2.CLIENT_NM FROM CLIENT_MNG CM2
WHERE 'C_' || CM2.OBJID::VARCHAR = CTR.CUSTOMER_OBJID LIMIT 1),
(SELECT SUPPLY_NAME FROM SUPPLY_MNG SM
WHERE SM.OBJID::VARCHAR = CTR.CUSTOMER_OBJID LIMIT 1),
''
) AS customer_name,
-- 품번/품명 (MBOM_DETAIL → PART_MNG, 다중이면 "외 N건")
COALESCE((SELECT PP.PART_NO FROM MBOM_DETAIL MD
JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR = PP.OBJID::VARCHAR
WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID
ORDER BY MD.REGDATE LIMIT 1), '') AS part_no,
COALESCE((SELECT PP.PART_NAME FROM MBOM_DETAIL MD
JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR = PP.OBJID::VARCHAR
WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID
ORDER BY MD.REGDATE LIMIT 1), '') AS part_name,
(SELECT COUNT(DISTINCT PP.PART_NO)::int - 1
FROM MBOM_DETAIL MD
JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR = PP.OBJID::VARCHAR
WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID) AS part_extra_count,
-- 견적요청서 존재여부 (quotation_request_master 누락 → 일괄 'N')
'N' AS has_quotation_request,
SRM.REQUEST_USER_ID AS request_user,
COALESCE(user_name(SRM.REQUEST_USER_ID), SRM.REQUEST_USER_ID, '') AS request_user_name,
SRM.DELIVERY_REQUEST_DATE AS delivery_request_date,
TO_CHAR(SRM.REGDATE, 'YYYY-MM-DD') AS regdate_title,
SRM.MBOM_HEADER_OBJID AS mbom_header_objid
FROM SALES_REQUEST_MASTER SRM
LEFT JOIN PROJECT_MGMT PM ON PM.OBJID::VARCHAR = SRM.PROJECT_NO
LEFT JOIN CONTRACT_MGMT CTR ON CTR.OBJID = PM.CONTRACT_OBJID
${whereSql}
ORDER BY SRM.REGDATE DESC
LIMIT ${addParam(limit)} OFFSET ${addParam(offset)}
`;
const countSql = `
SELECT COUNT(*)::int AS cnt
FROM SALES_REQUEST_MASTER SRM
LEFT JOIN PROJECT_MGMT PM ON PM.OBJID::VARCHAR = SRM.PROJECT_NO
LEFT JOIN CONTRACT_MGMT CTR ON CTR.OBJID = PM.CONTRACT_OBJID
${whereSql}
`;
try {
const [d, c] = await Promise.all([
pool.query(dataSql, params),
pool.query(countSql, params.slice(0, params.length - 2)),
]);
return { rows: d.rows, totalCount: c.rows[0]?.cnt ?? 0, page, pageSize };
} catch (e: any) {
logger.error("listPurchaseRequest 실패", { error: e.message });
return { rows: [], totalCount: 0, page, pageSize };
}
}
// ─── 2) 견적요청서관리 (wace salesMng.xml quotationRequestList) ──
// quotation_request_master 누락 → 빈 그리드.
export async function listQuotationRequest(filter: PurchaseListFilter): Promise<ListResult<any>> {
const { page, pageSize } = clampPaging(filter);
logger.warn("listQuotationRequest: quotation_request_master 테이블 미존재 — 빈 응답");
return { rows: [], totalCount: 0, page, pageSize };
}
// ─── 3) 품의서관리 (wace salesMng.xml proposalMngList) ──
// sales_request_master.doc_type='PROPOSAL'.
// 결재 우선순위: AMR.STATUS > APPROVAL.APPR_STATUS > SRM.STATUS('create'→'등록중')
export async function listProposal(filter: PurchaseListFilter): Promise<ListResult<any>> {
const pool = getPool();
const { limit, offset, page, pageSize } = clampPaging(filter);
const where: string[] = [
`SRM.STATUS IN ('create','approvalRequest','approvalComplete','reject')`,
`(SRM.DOC_TYPE = 'PROPOSAL' OR SRM.DOC_TYPE = 'PURCHASE_REG_PROPOSAL')`,
];
const params: any[] = [];
const addParam = (val: any) => { params.push(val); return `$${params.length}`; };
if (filter.proposal_no) where.push(`SRM.REQUEST_MNG_NO ILIKE ${addParam(`%${filter.proposal_no}%`)}`);
if (filter.project_no) where.push(`EXISTS (SELECT 1 FROM PROJECT_MGMT PMX WHERE PMX.OBJID::VARCHAR = SRM.PROJECT_NO AND PMX.PROJECT_NO ILIKE ${addParam(`%${filter.project_no}%`)})`);
if (filter.search_status) where.push(`SRM.STATUS = ${addParam(filter.search_status)}`);
if (filter.regdate_start) where.push(`SRM.REGDATE::DATE >= ${addParam(filter.regdate_start)}::DATE`);
if (filter.regdate_end) where.push(`SRM.REGDATE::DATE <= ${addParam(filter.regdate_end)}::DATE`);
if (filter.purchase_type) where.push(`SRM.PURCHASE_TYPE = ${addParam(filter.purchase_type)}`);
if (filter.writer) where.push(`SRM.WRITER = ${addParam(filter.writer)}`);
if (filter.part_type) where.push(`SRM.PRODUCT_NAME = ${addParam(filter.part_type)}`);
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const dataSql = `
SELECT
SRM.OBJID AS objid,
SRM.REQUEST_MNG_NO AS proposal_no,
SRM.STATUS AS status,
-- AMARANTH_STATUS 컬럼 RPS 미존재 → NULL (wace 1순위 결재상태 우선순위 향후 보완)
NULL::text AS amaranth_status,
PM.PROJECT_NO AS project_number,
COALESCE(
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.PURCHASE_TYPE LIMIT 1), ''
) AS purchase_type_name,
COALESCE(
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.ORDER_TYPE LIMIT 1), ''
) AS order_type_name,
COALESCE(
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.PRODUCT_NAME LIMIT 1), ''
) AS product_name_title,
COALESCE((SELECT PP.PART_NO FROM MBOM_DETAIL MD
JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR = PP.OBJID::VARCHAR
WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID
ORDER BY MD.REGDATE LIMIT 1), '') AS part_no,
COALESCE((SELECT PP.PART_NAME FROM MBOM_DETAIL MD
JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR = PP.OBJID::VARCHAR
WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID
ORDER BY MD.REGDATE LIMIT 1), '') AS part_name,
(SELECT COUNT(DISTINCT PP.PART_NO)::int - 1
FROM MBOM_DETAIL MD
JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR = PP.OBJID::VARCHAR
WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID) AS part_extra_count,
CASE SRM.STATUS
WHEN 'create' THEN '작성중'
WHEN 'approvalRequest' THEN '결재중'
WHEN 'approvalComplete' THEN '결재완료'
WHEN 'reject' THEN '반려'
ELSE COALESCE(SRM.STATUS, '')
END AS status_title,
TO_CHAR(SRM.REGDATE, 'YYYY-MM-DD') AS regdate_title,
SRM.WRITER AS writer,
COALESCE(user_name(SRM.WRITER), SRM.WRITER, '') AS writer_name,
SRM.MBOM_HEADER_OBJID AS mbom_header_objid
FROM SALES_REQUEST_MASTER SRM
LEFT JOIN PROJECT_MGMT PM ON PM.OBJID::VARCHAR = SRM.PROJECT_NO
${whereSql}
ORDER BY SRM.REGDATE DESC
LIMIT ${addParam(limit)} OFFSET ${addParam(offset)}
`;
const countSql = `
SELECT COUNT(*)::int AS cnt
FROM SALES_REQUEST_MASTER SRM
LEFT JOIN PROJECT_MGMT PM ON PM.OBJID::VARCHAR = SRM.PROJECT_NO
${whereSql}
`;
try {
const [d, c] = await Promise.all([
pool.query(dataSql, params),
pool.query(countSql, params.slice(0, params.length - 2)),
]);
return { rows: d.rows, totalCount: c.rows[0]?.cnt ?? 0, page, pageSize };
} catch (e: any) {
logger.error("listProposal 실패", { error: e.message });
return { rows: [], totalCount: 0, page, pageSize };
}
}
// ─── 4) 입고관리 (wace purchaseOrder.xml deliveryMngAcceptanceList) ──
// purchase_order_master + purchase_order_part(누락) + arrival_plan(누락).
// 누락 의존 — purchase_order_master 단독으로 빈 그리드 처리.
export async function listInbound(filter: PurchaseListFilter): Promise<ListResult<any>> {
const { page, pageSize } = clampPaging(filter);
logger.warn("listInbound: purchase_order_part / arrival_plan 미존재 — 빈 응답");
return { rows: [], totalCount: 0, page, pageSize };
}
// ─── 5) 품목별 입고관리 (wace deliveryMngPartList) ──
export async function listInboundByItem(filter: PurchaseListFilter): Promise<ListResult<any>> {
const { page, pageSize } = clampPaging(filter);
logger.warn("listInboundByItem: purchase_order_part / arrival_plan 미존재 — 빈 응답");
return { rows: [], totalCount: 0, page, pageSize };
}
// ─── 6) 입고일별 입고관리 (wace purchaseCloseList) ──
export async function listInboundByDate(filter: PurchaseListFilter): Promise<ListResult<any>> {
const { page, pageSize } = clampPaging(filter);
logger.warn("listInboundByDate: arrival_plan / purchase_order_part 미존재 — 빈 응답");
return { rows: [], totalCount: 0, page, pageSize };
}
// ─── 7) 프로젝트별 발주/입고 현황 (wace projectPurchaseDeliveryStatus) ──
// contract_mgmt + mbom_header/detail (전체수량/품목수) + purchase_order_master/part (발주현황) +
// arrival_plan (입고현황). 발주/입고는 누락 테이블 의존 → 0 표시.
export async function listProjectStatus(filter: PurchaseListFilter): Promise<ListResult<any>> {
const pool = getPool();
const { limit, offset, page, pageSize } = clampPaging(filter);
// wace 운영판 WHERE CTR.MAIL_SEND_DATE IS NOT NULL — RPS contract_mgmt 미존재 컬럼이라 생략 (전체 노출)
const where: string[] = [];
const params: any[] = [];
const addParam = (val: any) => { params.push(val); return `$${params.length}`; };
if (filter.customer_objid) where.push(`CTR.CUSTOMER_OBJID = ${addParam(filter.customer_objid)}`);
if (filter.project_no) where.push(`PM.PROJECT_NO ILIKE ${addParam(`%${filter.project_no}%`)}`);
if (filter.product_cd) where.push(`CTR.PRODUCT = ${addParam(filter.product_cd)}`);
if (filter.part_no) where.push(`PM.PART_NO ILIKE ${addParam(`%${filter.part_no}%`)}`);
if (filter.part_name) where.push(`PM.PART_NAME ILIKE ${addParam(`%${filter.part_name}%`)}`);
if (filter.year) where.push(`EXTRACT(YEAR FROM PM.REGDATE) = ${addParam(Number(filter.year))}`);
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const dataSql = `
SELECT
PM.OBJID AS objid,
PM.PROJECT_NO AS project_no,
COALESCE(
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CTR.PRODUCT LIMIT 1), ''
) AS product_name,
PM.PART_NO AS part_no,
PM.PART_NAME AS part_name,
COALESCE(
(SELECT CLIENT_NM FROM CLIENT_MNG CL
WHERE 'C_' || CL.OBJID::VARCHAR = CTR.CUSTOMER_OBJID LIMIT 1),
(SELECT SUPPLY_NAME FROM SUPPLY_MNG SM
WHERE SM.OBJID::VARCHAR = CTR.CUSTOMER_OBJID LIMIT 1),
''
) AS customer_name,
-- BOM 기준 (mbom_detail)
COALESCE((SELECT COUNT(DISTINCT MD.PART_OBJID)::int
FROM MBOM_DETAIL MD
JOIN MBOM_HEADER MH2 ON MD.MBOM_HEADER_OBJID = MH2.OBJID
WHERE MH2.PROJECT_OBJID = PM.OBJID::VARCHAR
AND MH2.STATUS = 'Y'), 0) AS total_item_cnt,
COALESCE((SELECT SUM(NULLIF(MD.QTY, '')::numeric)
FROM MBOM_DETAIL MD
JOIN MBOM_HEADER MH2 ON MD.MBOM_HEADER_OBJID = MH2.OBJID
WHERE MH2.PROJECT_OBJID = PM.OBJID::VARCHAR
AND MH2.STATUS = 'Y'), 0) AS total_qty,
-- 발주/입고/미발주/미입고 — purchase_order_part / arrival_plan 누락이라 모두 0
0::int AS po_item_cnt,
0::numeric AS po_qty,
COALESCE((SELECT COUNT(DISTINCT MD.PART_OBJID)::int
FROM MBOM_DETAIL MD
JOIN MBOM_HEADER MH2 ON MD.MBOM_HEADER_OBJID = MH2.OBJID
WHERE MH2.PROJECT_OBJID = PM.OBJID::VARCHAR
AND MH2.STATUS = 'Y'), 0) AS non_po_item_cnt,
COALESCE((SELECT SUM(NULLIF(MD.QTY, '')::numeric)
FROM MBOM_DETAIL MD
JOIN MBOM_HEADER MH2 ON MD.MBOM_HEADER_OBJID = MH2.OBJID
WHERE MH2.PROJECT_OBJID = PM.OBJID::VARCHAR
AND MH2.STATUS = 'Y'), 0) AS non_po_qty,
0::int AS dlv_item_cnt,
0::numeric AS dlv_qty,
0::int AS non_dlv_item_cnt,
0::numeric AS non_dlv_qty
FROM PROJECT_MGMT PM
LEFT JOIN CONTRACT_MGMT CTR ON CTR.OBJID = PM.CONTRACT_OBJID
${whereSql}
ORDER BY PM.REGDATE DESC
LIMIT ${addParam(limit)} OFFSET ${addParam(offset)}
`;
const countSql = `
SELECT COUNT(*)::int AS cnt
FROM PROJECT_MGMT PM
LEFT JOIN CONTRACT_MGMT CTR ON CTR.OBJID = PM.CONTRACT_OBJID
${whereSql}
`;
try {
const [d, c] = await Promise.all([
pool.query(dataSql, params),
pool.query(countSql, params.slice(0, params.length - 2)),
]);
return { rows: d.rows, totalCount: c.rows[0]?.cnt ?? 0, page, pageSize };
} catch (e: any) {
logger.error("listProjectStatus 실패", { error: e.message });
return { rows: [], totalCount: 0, page, pageSize };
}
}
// ─── 옵션 — 공급업체 / 작성자 (구매메뉴 공용) ──────────────────
export async function listSupplierOptions(): Promise<{ code: string; label: string }[]> {
const pool = getPool();
try {
const r = await pool.query(
`SELECT OBJID::VARCHAR AS code, SUPPLY_NAME AS label
FROM SUPPLY_MNG
WHERE COALESCE(STATUS, 'active') IN ('active', '활성')
AND SUPPLY_NAME IS NOT NULL AND SUPPLY_NAME <> ''
ORDER BY SUPPLY_NAME`,
);
return r.rows;
} catch {
return [];
}
}
export async function listUserOptions(): Promise<{ code: string; label: string }[]> {
const pool = getPool();
try {
const r = await pool.query(
`SELECT USER_ID AS code,
USER_NAME || COALESCE(' (' || DEPT_NAME || ')', '') AS label
FROM USER_INFO
WHERE COALESCE(STATUS, 'active') IN ('active', '활성', 'ACTIVE')
AND USER_NAME IS NOT NULL AND USER_NAME <> ''
ORDER BY USER_NAME`,
);
return r.rows;
} catch {
return [];
}
}
export async function listProjectOptions(): Promise<{ code: string; label: string }[]> {
const pool = getPool();
try {
const r = await pool.query(
`SELECT OBJID::VARCHAR AS code, PROJECT_NO AS label
FROM PROJECT_MGMT
WHERE PROJECT_NO IS NOT NULL AND PROJECT_NO <> ''
ORDER BY PROJECT_NO DESC
LIMIT 500`,
);
return r.rows;
} catch {
return [];
}
}
@@ -6,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<MbomRow[]>([]);
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<string | null>(null);
// PR-B3: 구매리스트 생성 — 그리드 단건 체크 + 버튼 트리거 (wace 1:1)
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [creatingPurchaseList, setCreatingPurchaseList] = useState(false);
const fetchList = useCallback(async (override?: Partial<MbomListFilter>) => {
setLoading(true);
try {
@@ -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 (
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
<PageHeader
loading={loading}
onSearch={handleSearch}
onReset={handleReset}
actions={
<Button
size="sm"
variant="default"
className="h-8 gap-1 px-2 text-xs"
onClick={handleCreatePurchaseList}
disabled={creatingPurchaseList || checkedIds.length === 0}
>
{creatingPurchaseList
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <ShoppingCart className="h-3.5 w-3.5" />}
</Button>
}
/>
<CompactFilterBar
totalText={<> {total.toLocaleString()} · PROJECT_MGMT × CONTRACT_ITEM </>}
@@ -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}
</div>
);
}
@@ -0,0 +1,201 @@
"use client";
// 생산관리 > 생산계획&실적관리(장비) — wace productionplanning/prodPlanResultMgmtEquipList.jsp 1:1
// PROJECT_MGMT WHERE CM.PRODUCT='0000928'. PMS_WBS_TASK 기반 생산/납품 진척율.
// 페이지네이션: 서버사이드.
// (WBS 할당 모달은 wace_plm 의 별 작업이라 본 페이지에서는 제외 — 본 PR-D2 범위 외)
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { CustomerSelect } from "@/components/common/CustomerSelect";
import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar";
import { PageHeader } from "@/components/common/PageHeader";
import { apiClient } from "@/lib/api/client";
import { prodPlanResultApi, ProdPlanResultFilter, ProdPlanResultEquipRow } from "@/lib/api/prodPlanResult";
import { exportToExcel } from "@/lib/utils/excelExport";
const PARENT_PRODUCT = "0000001";
const PARENT_CATEGORY = "0000167";
const PARENT_PRODUCTION_TY = "0001832";
interface CodeOpt extends SmartSelectOption { sort?: number | null }
const EMPTY_FILTER: ProdPlanResultFilter = {
search_project_nos: "",
search_product_code: "",
search_category_code: "",
search_production_type: "",
search_customer_objid: "",
search_req_del_date_from: "",
search_req_del_date_to: "",
search_part_no: "",
search_part_name: "",
search_serial_no: "",
page: 1,
page_size: 50,
};
export default function ProdPlanResultEquipPage() {
const [rows, setRows] = useState<ProdPlanResultEquipRow[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<ProdPlanResultFilter>(EMPTY_FILTER);
const [categoryOpts, setCategoryOpts] = useState<CodeOpt[]>([]);
const [productOpts, setProductOpts] = useState<CodeOpt[]>([]);
const [prodTypeOpts, setProdTypeOpts] = useState<CodeOpt[]>([]);
const [projectOpts, setProjectOpts] = useState<SmartSelectOption[]>([]);
const fetchList = useCallback(async (override?: Partial<ProdPlanResultFilter>) => {
setLoading(true);
try {
const f = { ...filter, ...override };
const res = await prodPlanResultApi.listEquip(f);
setRows(res.rows ?? []);
setTotal(res.totalCount ?? 0);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
} finally {
setLoading(false);
}
}, [filter]);
useEffect(() => {
let dead = false;
(async () => {
try {
const [c1, c2, c3, proj] = await Promise.all([
apiClient.get(`/sales/codes/${PARENT_CATEGORY}`),
apiClient.get(`/sales/codes/${PARENT_PRODUCT}`),
apiClient.get(`/sales/codes/${PARENT_PRODUCTION_TY}`),
prodPlanResultApi.getProjectOptions(),
]);
if (dead) return;
setCategoryOpts(c1.data?.data ?? []);
setProductOpts(c2.data?.data ?? []);
setProdTypeOpts(c3.data?.data ?? []);
setProjectOpts(proj);
} catch {/* ignore */}
})();
fetchList(EMPTY_FILTER);
return () => { dead = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const gridRows = useMemo(
() => rows.map((r, i) => ({ ...r, id: `${r.objid}__${i}` })),
[rows],
);
const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([
{ key: "project_no", label: "프로젝트번호", width: "w-[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 (
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
<PageHeader loading={loading} onSearch={handleSearch} onReset={handleReset} />
<CompactFilterBar totalText={<> {total.toLocaleString()} · PRODUCT=(0000928) </>}>
<CompactFilterField label="프로젝트번호" width={180}>
<SmartSelect options={projectOpts} value={filter.search_project_nos ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_project_nos: v })} />
</CompactFilterField>
<CompactFilterField label="제품구분" width={130}>
<SmartSelect options={productOpts} value={filter.search_product_code ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_product_code: v })} />
</CompactFilterField>
<CompactFilterField label="주문유형" width={130}>
<SmartSelect options={categoryOpts} value={filter.search_category_code ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_category_code: v })} />
</CompactFilterField>
<CompactFilterField label="생산유형" width={130}>
<SmartSelect options={prodTypeOpts} value={filter.search_production_type ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_production_type: v })} />
</CompactFilterField>
<CompactFilterField label="고객사" width={170}>
<CustomerSelect value={filter.search_customer_objid ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_customer_objid: v })} />
</CompactFilterField>
<CompactFilterField label="요청납기" width={280}>
<CompactDateRange
from={filter.search_req_del_date_from ?? ""} setFrom={(v) => setFilter({ ...filter, search_req_del_date_from: v })}
to={filter.search_req_del_date_to ?? ""} setTo={(v) => setFilter({ ...filter, search_req_del_date_to: v })} />
</CompactFilterField>
<CompactFilterField label="품번" width={130}>
<Input value={filter.search_part_no ?? ""} onChange={(e) => setFilter({ ...filter, search_part_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="품명" width={150}>
<Input value={filter.search_part_name ?? ""} onChange={(e) => setFilter({ ...filter, search_part_name: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="S/N" width={130}>
<Input value={filter.search_serial_no ?? ""} onChange={(e) => setFilter({ ...filter, search_serial_no: e.target.value })} />
</CompactFilterField>
</CompactFilterBar>
<DataGrid
columns={GRID_COLUMNS}
data={gridRows}
loading={loading}
showRowNumber
showCheckbox
emptyMessage="조건에 맞는 데이터가 없습니다."
gridId="production-prod-plan-result-equip"
pageSizeOptions={[25, 50, 100, 200, 500]}
paginationStyle="range"
serverPaging
serverPage={filter.page ?? 1}
serverPageSize={filter.page_size ?? 50}
serverTotalItems={total}
onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }}
onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }}
showColumnSettings
summaryStats={summary}
onRefresh={() => fetchList()}
onDownload={() => {
if (gridRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
const exportRows = gridRows.map((r: any) => {
const out: Record<string, any> = {};
GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; });
return out;
});
exportToExcel(exportRows, "생산계획_실적관리_장비.xlsx", "생산계획_실적관리_장비");
}}
showChart
/>
</div>
);
}
@@ -0,0 +1,251 @@
"use client";
// 생산관리 > 생산계획&실적관리 — wace productionplanning/prodPlanResultMgmtList.jsp 1:1
// 그리드: PROJECT_MGMT × CONTRACT_ITEM × PRODUCTION_PLAN × PRODUCTION_RESULT 합산 + 독립 PP 합본
// 필터: 프로젝트번호 / 제품구분 / 주문유형 / 생산유형 / 고객사 / 요청납기 / 품번 / 품명 / S/N / 등록자 / 등록일
// 페이지네이션: 서버사이드 (page / page_size / totalCount)
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { CustomerSelect } from "@/components/common/CustomerSelect";
import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar";
import { PageHeader } from "@/components/common/PageHeader";
import { apiClient } from "@/lib/api/client";
import { prodPlanResultApi, ProdPlanResultFilter, ProdPlanResultRow } from "@/lib/api/prodPlanResult";
import { exportToExcel } from "@/lib/utils/excelExport";
const PARENT_PRODUCT = "0000001";
const PARENT_CATEGORY = "0000167";
const PARENT_PRODUCTION_TY = "0001832";
interface CodeOpt extends SmartSelectOption { sort?: number | null }
const EMPTY_FILTER: ProdPlanResultFilter = {
search_project_nos: "",
search_product_code: "",
search_category_code: "",
search_production_type: "",
search_customer_objid: "",
search_req_del_date_from: "",
search_req_del_date_to: "",
search_part_no: "",
search_part_name: "",
search_serial_no: "",
search_writer: "",
search_regdate_from: "",
search_regdate_to: "",
page: 1,
page_size: 50,
};
export default function ProdPlanResultPage() {
const [rows, setRows] = useState<ProdPlanResultRow[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<ProdPlanResultFilter>(EMPTY_FILTER);
const [categoryOpts, setCategoryOpts] = useState<CodeOpt[]>([]);
const [productOpts, setProductOpts] = useState<CodeOpt[]>([]);
const [prodTypeOpts, setProdTypeOpts] = useState<CodeOpt[]>([]);
const [projectOpts, setProjectOpts] = useState<SmartSelectOption[]>([]);
const [writerOpts, setWriterOpts] = useState<SmartSelectOption[]>([]);
const [serialOpen, setSerialOpen] = useState(false);
const [serialList, setSerialList] = useState<string[]>([]);
const fetchList = useCallback(async (override?: Partial<ProdPlanResultFilter>) => {
setLoading(true);
try {
const f = { ...filter, ...override };
const res = await prodPlanResultApi.list(f);
setRows(res.rows ?? []);
setTotal(res.totalCount ?? 0);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
} finally {
setLoading(false);
}
}, [filter]);
useEffect(() => {
let dead = false;
(async () => {
try {
const [c1, c2, c3, proj, writers] = await Promise.all([
apiClient.get(`/sales/codes/${PARENT_CATEGORY}`),
apiClient.get(`/sales/codes/${PARENT_PRODUCT}`),
apiClient.get(`/sales/codes/${PARENT_PRODUCTION_TY}`),
prodPlanResultApi.getProjectOptions(),
prodPlanResultApi.getWriterOptions(),
]);
if (dead) return;
setCategoryOpts(c1.data?.data ?? []);
setProductOpts(c2.data?.data ?? []);
setProdTypeOpts(c3.data?.data ?? []);
setProjectOpts(proj);
setWriterOpts(writers);
} catch {/* ignore */}
})();
fetchList(EMPTY_FILTER);
return () => { dead = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const gridRows = useMemo(
() => rows.map((r, i) => ({ ...r, id: `${r.objid}__${i}` })),
[rows],
);
const openSerial = useCallback((row: any) => {
const list = String(row?.serial_no_list ?? "")
.split(",").map((s) => s.trim()).filter(Boolean);
if (list.length === 0) { toast.info("S/N 정보가 없습니다."); return; }
setSerialList(list);
setSerialOpen(true);
}, []);
const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([
{ key: "project_no", label: "프로젝트번호", width: "w-[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 (
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
<PageHeader loading={loading} onSearch={handleSearch} onReset={handleReset} />
<CompactFilterBar totalText={<> {total.toLocaleString()} · + </>}>
<CompactFilterField label="프로젝트번호" width={180}>
<SmartSelect
options={projectOpts}
value={filter.search_project_nos ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_project_nos: v })}
/>
</CompactFilterField>
<CompactFilterField label="제품구분" width={130}>
<SmartSelect options={productOpts} value={filter.search_product_code ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_product_code: v })} />
</CompactFilterField>
<CompactFilterField label="주문유형" width={130}>
<SmartSelect options={categoryOpts} value={filter.search_category_code ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_category_code: v })} />
</CompactFilterField>
<CompactFilterField label="생산유형" width={130}>
<SmartSelect options={prodTypeOpts} value={filter.search_production_type ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_production_type: v })} />
</CompactFilterField>
<CompactFilterField label="고객사" width={170}>
<CustomerSelect value={filter.search_customer_objid ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_customer_objid: v })} />
</CompactFilterField>
<CompactFilterField label="요청납기" width={280}>
<CompactDateRange
from={filter.search_req_del_date_from ?? ""} setFrom={(v) => setFilter({ ...filter, search_req_del_date_from: v })}
to={filter.search_req_del_date_to ?? ""} setTo={(v) => setFilter({ ...filter, search_req_del_date_to: v })} />
</CompactFilterField>
<CompactFilterField label="품번" width={130}>
<Input value={filter.search_part_no ?? ""} onChange={(e) => setFilter({ ...filter, search_part_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="품명" width={150}>
<Input value={filter.search_part_name ?? ""} onChange={(e) => setFilter({ ...filter, search_part_name: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="S/N" width={130}>
<Input value={filter.search_serial_no ?? ""} onChange={(e) => setFilter({ ...filter, search_serial_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="등록자" width={130}>
<SmartSelect options={writerOpts} value={filter.search_writer ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_writer: v })} />
</CompactFilterField>
<CompactFilterField label="등록일" width={280}>
<CompactDateRange
from={filter.search_regdate_from ?? ""} setFrom={(v) => setFilter({ ...filter, search_regdate_from: v })}
to={filter.search_regdate_to ?? ""} setTo={(v) => setFilter({ ...filter, search_regdate_to: v })} />
</CompactFilterField>
</CompactFilterBar>
<DataGrid
columns={GRID_COLUMNS}
data={gridRows}
loading={loading}
showRowNumber
showCheckbox
emptyMessage="조건에 맞는 데이터가 없습니다."
gridId="production-prod-plan-result"
pageSizeOptions={[25, 50, 100, 200, 500]}
paginationStyle="range"
serverPaging
serverPage={filter.page ?? 1}
serverPageSize={filter.page_size ?? 50}
serverTotalItems={total}
onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }}
onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }}
showColumnSettings
summaryStats={summary}
systemColumnKeys={["writer_name", "regdate_title"]}
onRefresh={() => fetchList()}
onDownload={() => {
if (gridRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
const exportRows = gridRows.map((r: any) => {
const out: Record<string, any> = {};
GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; });
return out;
});
exportToExcel(exportRows, "생산계획_실적관리.xlsx", "생산계획_실적관리");
}}
showChart
/>
{serialOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
onClick={() => setSerialOpen(false)}>
<div className="w-[500px] max-h-[70vh] overflow-auto rounded bg-white p-4 shadow"
onClick={(e) => e.stopPropagation()}>
<div className="mb-2 text-sm font-semibold">S/N ({serialList.length})</div>
<ol className="list-decimal pl-6 text-sm">
{serialList.map((sn, i) => <li key={i} className="py-0.5">{sn}</li>)}
</ol>
<div className="mt-3 text-right">
<button className="rounded bg-blue-600 px-3 py-1 text-xs text-white"
onClick={() => setSerialOpen(false)}></button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,206 @@
"use client";
// 생산관리 > 원자재 소요량 — wace productionplanning/rawMaterialRequirementList.jsp 1:1
// 입력: M-BOM 헤더 선택 + 입력수량 → 여러 행
// 출력: 구매품(PART_TYPE='0000063') + 원소재(RAW_MATERIAL_PART_NO) 합본
// 페이지네이션: 결과 전체. 클라이언트 페이지네이션.
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Plus, Trash2 } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { PageHeader } from "@/components/common/PageHeader";
import { mbomRequirementApi, MbomOption, RawRequirementRow } from "@/lib/api/prodPlanResult";
import { exportToExcel } from "@/lib/utils/excelExport";
interface InputRow {
id: string;
mbomObjid: string;
partName: string;
qty: number;
checked: boolean;
}
let rowSeq = 0;
const newRow = (): InputRow => ({ id: `r${++rowSeq}`, mbomObjid: "", partName: "", qty: 1, checked: false });
export default function RawMaterialRequirementPage() {
const [mbomOpts, setMbomOpts] = useState<MbomOption[]>([]);
const [inputs, setInputs] = useState<InputRow[]>([newRow()]);
const [results, setResults] = useState<RawRequirementRow[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
(async () => {
try {
const opts = await mbomRequirementApi.getOptions();
setMbomOpts(opts);
} catch (e: any) {
toast.error(e?.message ?? "M-BOM 옵션 로드 실패");
}
})();
}, []);
const partNameMap = useMemo(() => {
const m = new Map<string, string>();
mbomOpts.forEach((o) => m.set(o.objid, o.part_name));
return m;
}, [mbomOpts]);
const addRow = () => setInputs((rs) => [...rs, newRow()]);
const removeChecked = () => {
const next = inputs.filter((r) => !r.checked);
if (next.length === inputs.length) {
toast.info("삭제할 행을 선택해주세요.");
return;
}
setInputs(next.length === 0 ? [newRow()] : next);
};
const updateRow = (id: string, patch: Partial<InputRow>) => {
setInputs((rs) => rs.map((r) => (r.id === id ? { ...r, ...patch } : r)));
};
const handleSearch = useCallback(async () => {
const items = inputs
.filter((r) => r.mbomObjid && Number(r.qty) > 0)
.map((r) => ({ mbomObjid: r.mbomObjid, qty: Number(r.qty) }));
if (items.length === 0) {
toast.info("M-BOM을 선택하고 수량을 입력해주세요.");
return;
}
setLoading(true);
try {
const res = await mbomRequirementApi.getRaw(items);
setResults(res);
if (res.length === 0) toast.info("구매품 항목이 없습니다.");
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
setResults([]);
} finally {
setLoading(false);
}
}, [inputs]);
const resultRows = useMemo(
() => results.map((r, i) => ({ ...r, id: `${r.PART_NO || r.MATERIAL_PART_NO}__${i}` })),
[results],
);
const RESULT_COLUMNS: DataGridColumn[] = useMemo(() => ([
{ key: "CATEGORY_NAME", label: "구분", width: "w-[110px]", align: "center" },
{ key: "PART_NO", label: "품번", minWidth: "min-w-[180px]" },
{ key: "PART_NAME", label: "품명", minWidth: "min-w-[200px]" },
{ key: "REQUIRED_QTY", label: "소요량", width: "w-[110px]", align: "right", formatNumber: true },
{ key: "MATERIAL", label: "소재", width: "w-[120px]", align: "center" },
{ key: "SPEC", label: "사이즈", width: "w-[110px]", align: "center" },
{ key: "MATERIAL_PART_NO", label: "소재품번", minWidth: "min-w-[180px]" },
{ key: "MATERIAL_REQUIRED_QTY", label: "소재소요량", width: "w-[110px]", align: "right", formatNumber: true },
]), []);
const summary = useMemo(() => {
const purchaseCount = resultRows.filter((r: any) => r.CATEGORY_NAME !== "원소재").length;
const rawCount = resultRows.length - purchaseCount;
return [
{ label: "조회결과", value: resultRows.length.toLocaleString(), suffix: "건" },
{ label: "구매품", value: purchaseCount.toLocaleString(), suffix: "건" },
{ label: "원소재", value: rawCount.toLocaleString(), suffix: "건" },
];
}, [resultRows]);
return (
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
<PageHeader
loading={loading}
onSearch={handleSearch}
actions={
<>
<Button size="sm" variant="outline" className="h-8 gap-1 px-2 text-xs" onClick={addRow}>
<Plus className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="outline" className="h-8 gap-1 px-2 text-xs" onClick={removeChecked}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>
}
/>
<div className="rounded border bg-white p-2">
<div className="mb-1 border-l-[3px] border-blue-500 pl-2 text-xs font-semibold">
M-BOM
</div>
<div className="overflow-auto" style={{ maxHeight: 180 }}>
<table className="w-full border-collapse text-xs">
<thead className="bg-gray-50">
<tr className="border-b">
<th className="w-10 border px-1 py-1"></th>
<th className="border px-2 py-1 text-left">M-BOM</th>
<th className="border px-2 py-1 text-left"></th>
<th className="w-32 border px-2 py-1 text-right"></th>
</tr>
</thead>
<tbody>
{inputs.map((r) => (
<tr key={r.id} className="border-b">
<td className="border px-1 py-1 text-center">
<input type="checkbox" checked={r.checked}
onChange={(e) => updateRow(r.id, { checked: e.target.checked })} />
</td>
<td className="border px-1 py-1">
<select
className="h-7 w-full rounded border px-1 text-xs"
value={r.mbomObjid}
onChange={(e) => updateRow(r.id, {
mbomObjid: e.target.value,
partName: partNameMap.get(e.target.value) ?? "",
})}
>
<option value=""></option>
{mbomOpts.map((o) => (
<option key={o.objid} value={o.objid}>{o.mbom_no}</option>
))}
</select>
</td>
<td className="border px-2 py-1 text-gray-700">{r.partName}</td>
<td className="border px-1 py-1">
<Input
type="number" min={1} step={1}
className="h-7 text-right text-xs"
value={r.qty}
onChange={(e) => updateRow(r.id, { qty: Math.max(1, Number(e.target.value) || 1) })}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<DataGrid
columns={RESULT_COLUMNS}
data={resultRows}
loading={loading}
showRowNumber
emptyMessage="조회 결과가 없습니다."
gridId="production-raw-material-requirement"
pageSizeOptions={[25, 50, 100, 200, 500]}
paginationStyle="range"
showColumnSettings
summaryStats={summary}
onRefresh={handleSearch}
onDownload={() => {
if (resultRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
const exportRows = resultRows.map((r: any) => {
const out: Record<string, any> = {};
RESULT_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; });
return out;
});
exportToExcel(exportRows, "원자재_소요량.xlsx", "원자재_소요량");
}}
showChart
/>
</div>
);
}
@@ -0,0 +1,207 @@
"use client";
// 생산관리 > 반제품 소요량 — wace productionplanning/semiProductRequirementList.jsp 1:1
// 입력: M-BOM 헤더 선택(드롭다운) + 입력수량 → 여러 행
// 출력: MBOM_DETAIL × PART_MNG (PART_TYPE IN 부품/조립품) 합산
// 페이지네이션: 결과는 한 번에 전체. 클라이언트 페이지네이션.
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Plus, Trash2 } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { PageHeader } from "@/components/common/PageHeader";
import { mbomRequirementApi, MbomOption, SemiRequirementRow } from "@/lib/api/prodPlanResult";
import { exportToExcel } from "@/lib/utils/excelExport";
interface InputRow {
id: string;
mbomObjid: string;
partName: string;
qty: number;
checked: boolean;
}
let rowSeq = 0;
const newRow = (): InputRow => ({ id: `r${++rowSeq}`, mbomObjid: "", partName: "", qty: 1, checked: false });
export default function SemiProductRequirementPage() {
const [mbomOpts, setMbomOpts] = useState<MbomOption[]>([]);
const [inputs, setInputs] = useState<InputRow[]>([newRow()]);
const [results, setResults] = useState<SemiRequirementRow[]>([]);
const [loading, setLoading] = useState(false);
// M-BOM 옵션 로드
useEffect(() => {
(async () => {
try {
const opts = await mbomRequirementApi.getOptions();
setMbomOpts(opts);
} catch (e: any) {
toast.error(e?.message ?? "M-BOM 옵션 로드 실패");
}
})();
}, []);
const partNameMap = useMemo(() => {
const m = new Map<string, string>();
mbomOpts.forEach((o) => m.set(o.objid, o.part_name));
return m;
}, [mbomOpts]);
const addRow = () => setInputs((rs) => [...rs, newRow()]);
const removeChecked = () => {
const next = inputs.filter((r) => !r.checked);
if (next.length === inputs.length) {
toast.info("삭제할 행을 선택해주세요.");
return;
}
setInputs(next.length === 0 ? [newRow()] : next);
};
const updateRow = (id: string, patch: Partial<InputRow>) => {
setInputs((rs) => rs.map((r) => (r.id === id ? { ...r, ...patch } : r)));
};
const handleSearch = useCallback(async () => {
const items = inputs
.filter((r) => r.mbomObjid && Number(r.qty) > 0)
.map((r) => ({ mbomObjid: r.mbomObjid, qty: Number(r.qty) }));
if (items.length === 0) {
toast.info("M-BOM을 선택하고 수량을 입력해주세요.");
return;
}
setLoading(true);
try {
const res = await mbomRequirementApi.getSemi(items);
setResults(res);
if (res.length === 0) toast.info("부품 또는 조립품 항목이 없습니다.");
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
setResults([]);
} finally {
setLoading(false);
}
}, [inputs]);
const resultRows = useMemo(
() => results.map((r, i) => ({ ...r, id: `${r.PART_NO}__${i}` })),
[results],
);
const RESULT_COLUMNS: DataGridColumn[] = useMemo(() => ([
{ key: "CATEGORY_NAME", label: "구분", width: "w-[110px]", align: "center" },
{ key: "PART_NO", label: "품번", minWidth: "min-w-[180px]" },
{ key: "PART_NAME", label: "품명", minWidth: "min-w-[220px]" },
{ key: "UNIT", label: "단위", width: "w-[80px]", align: "center" },
{ key: "MATERIAL", label: "소재", width: "w-[120px]", align: "center" },
{ key: "SPEC", label: "규격", width: "w-[120px]", align: "center" },
{ key: "REQUIRED_QTY", label: "소요량", width: "w-[120px]", align: "right", formatNumber: true },
]), []);
const summary = useMemo(() => {
const qtySum = resultRows.reduce((a, r: any) => a + Number(r.REQUIRED_QTY || 0), 0);
return [
{ label: "조회결과", value: resultRows.length.toLocaleString(), suffix: "건" },
{ label: "총 소요량", value: qtySum.toLocaleString() },
];
}, [resultRows]);
return (
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
<PageHeader
loading={loading}
onSearch={handleSearch}
actions={
<>
<Button size="sm" variant="outline" className="h-8 gap-1 px-2 text-xs" onClick={addRow}>
<Plus className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="outline" className="h-8 gap-1 px-2 text-xs" onClick={removeChecked}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>
}
/>
{/* 입력 영역 */}
<div className="rounded border bg-white p-2">
<div className="mb-1 border-l-[3px] border-blue-500 pl-2 text-xs font-semibold">
M-BOM
</div>
<div className="overflow-auto" style={{ maxHeight: 180 }}>
<table className="w-full border-collapse text-xs">
<thead className="bg-gray-50">
<tr className="border-b">
<th className="w-10 border px-1 py-1"></th>
<th className="border px-2 py-1 text-left">M-BOM</th>
<th className="border px-2 py-1 text-left"></th>
<th className="w-32 border px-2 py-1 text-right"></th>
</tr>
</thead>
<tbody>
{inputs.map((r) => (
<tr key={r.id} className="border-b">
<td className="border px-1 py-1 text-center">
<input type="checkbox" checked={r.checked}
onChange={(e) => updateRow(r.id, { checked: e.target.checked })} />
</td>
<td className="border px-1 py-1">
<select
className="h-7 w-full rounded border px-1 text-xs"
value={r.mbomObjid}
onChange={(e) => updateRow(r.id, {
mbomObjid: e.target.value,
partName: partNameMap.get(e.target.value) ?? "",
})}
>
<option value=""></option>
{mbomOpts.map((o) => (
<option key={o.objid} value={o.objid}>{o.mbom_no}</option>
))}
</select>
</td>
<td className="border px-2 py-1 text-gray-700">{r.partName}</td>
<td className="border px-1 py-1">
<Input
type="number" min={1} step={1}
className="h-7 text-right text-xs"
value={r.qty}
onChange={(e) => updateRow(r.id, { qty: Math.max(1, Number(e.target.value) || 1) })}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* 결과 영역 */}
<DataGrid
columns={RESULT_COLUMNS}
data={resultRows}
loading={loading}
showRowNumber
emptyMessage="조회 결과가 없습니다."
gridId="production-semi-product-requirement"
pageSizeOptions={[25, 50, 100, 200, 500]}
paginationStyle="range"
showColumnSettings
summaryStats={summary}
onRefresh={handleSearch}
onDownload={() => {
if (resultRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
const exportRows = resultRows.map((r: any) => {
const out: Record<string, any> = {};
RESULT_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; });
return out;
});
exportToExcel(exportRows, "반제품_소요량.xlsx", "반제품_소요량");
}}
showChart
/>
</div>
);
}
@@ -0,0 +1,217 @@
"use client";
// 구매관리 > 입고일별 입고관리 — wace purchaseOrder/purchaseCloseList.jsp 1:1
// 검색: 년도/고객사/프로젝트/발주No/규격/품명/공급업체/구매담당자/입고일/매입마감/품번
// 그리드 26컬럼: 품의서·발주서·프로젝트·부품품번·품번·품명·공급업체·환종·입고일·담당자·등록자·
// 입고수량·입고금액·검사현황·폐기수량·확정입고수량·계정과목·국내해외·환율·과세구분·
// 세금계산서일·수출신고번호·선적일·관세·수입부가세·매입마감
// 액션: 조회 / 마감정보입력 / 매입마감
// ⚠️ arrival_plan / purchase_order_part 미존재 → 빈 그리드
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { FileEdit, Lock } from "lucide-react";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { CustomerSelect } from "@/components/common/CustomerSelect";
import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar";
import { PageHeader } from "@/components/common/PageHeader";
import { purchaseApi, PurchaseListFilter, OptionItem, getYearOptions } from "@/lib/api/purchase";
import { exportToExcel } from "@/lib/utils/excelExport";
const CLOSE_OPTS: SmartSelectOption[] = [
{ code: "N", label: "미마감" },
{ code: "Y", label: "마감" },
];
const EMPTY_FILTER: PurchaseListFilter = {
year: String(new Date().getFullYear()),
customer_cd: "", project_no: "", purchase_order_no: "",
part_spec: "", part_name: "", partner_objid: "",
sales_mng_user_id: "",
receipt_date_start: "", receipt_date_end: "",
close_status: "", part_no: "",
page: 1, page_size: 50,
};
export default function InboundByDatePage() {
const [rows, setRows] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<PurchaseListFilter>(EMPTY_FILTER);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [supplierOpts, setSupplierOpts] = useState<OptionItem[]>([]);
const [userOpts, setUserOpts] = useState<OptionItem[]>([]);
const yearOpts = useMemo(() => getYearOptions(), []);
const fetchList = useCallback(async (override?: Partial<PurchaseListFilter>) => {
setLoading(true);
try {
const f = { ...filter, ...override };
const res = await purchaseApi.listInboundByDate(f);
setRows(res.rows ?? []);
setTotal(res.totalCount ?? 0);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
} finally {
setLoading(false);
}
}, [filter]);
useEffect(() => {
let dead = false;
(async () => {
try {
const [s, u] = await Promise.all([purchaseApi.listSuppliers(), purchaseApi.listUsers()]);
if (dead) return;
setSupplierOpts(s); setUserOpts(u);
} catch { /* skip */ }
})();
fetchList(EMPTY_FILTER);
return () => { dead = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const gridRows = useMemo(() => rows.map((r, i) => ({ ...r, id: r.objid ?? `id_${i}` })), [rows]);
const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([
{ key: "proposal_no", label: "품의서 No", width: "w-[125px]", align: "center" },
{ key: "purchase_order_no", label: "발주서 No", width: "w-[125px]", align: "center" },
{ key: "project_no", label: "프로젝트번호", width: "w-[135px]", align: "center" },
{ key: "component_part_no", label: "부품품번", width: "w-[135px]" },
{ key: "part_no", label: "품번", width: "w-[135px]" },
{ key: "part_name", label: "품명", minWidth: "min-w-[180px]" },
{ key: "partner_name", label: "공급업체", minWidth: "min-w-[150px]" },
{ key: "currency_name", label: "환종", width: "w-[80px]", align: "center" },
{ key: "receipt_date", label: "입고일", width: "w-[110px]", align: "center" },
{ key: "writer_name", label: "구매담당자", width: "w-[110px]", align: "center" },
{ key: "delivery_writer_name", label: "입고등록자", width: "w-[110px]", align: "center" },
{ key: "receipt_qty", label: "입고수량", width: "w-[110px]", align: "right", formatNumber: true },
{ key: "total_delivery_price", label: "입고금액", width: "w-[120px]", align: "right", formatMoney: true },
{ key: "inspection_status", label: "검사현황", width: "w-[110px]", align: "center" },
{ key: "defect_qty", label: "폐기수량", width: "w-[110px]", align: "right", formatNumber: true },
{ key: "confirmed_qty", label: "확정입고수량", width: "w-[120px]", align: "right", formatNumber: true },
{ key: "sub_location_name", label: "계정과목", width: "w-[120px]", align: "center" },
{ key: "foreign_type_name", label: "국내/해외", width: "w-[110px]", align: "center" },
{ key: "exchange_rate", label: "환율", width: "w-[110px]", align: "right", formatNumber: true },
{ key: "tax_type_name", label: "과세구분", width: "w-[110px]", align: "center" },
{ key: "tax_invoice_date", label: "세금계산서발행일", width: "w-[140px]", align: "center" },
{ key: "export_decl_no", label: "수출신고필증신고번호", width: "w-[150px]", align: "center" },
{ key: "loading_date", label: "선적일자", width: "w-[110px]", align: "center" },
{ key: "duty", label: "관세", width: "w-[110px]", align: "right", formatMoney: true },
{ key: "import_vat", label: "수입부가세", width: "w-[110px]", align: "right", formatMoney: true },
{ key: "purchase_close_date", label: "매입마감", width: "w-[110px]", align: "center" },
]), []);
const summary = useMemo(() => [
{ label: "전체 건수", value: total.toLocaleString(), suffix: "건" },
{ label: "선택", value: checkedIds.length.toLocaleString(), suffix: "건" },
], [total, checkedIds]);
const handleSearch = () => { setFilter(f => ({ ...f, page: 1 })); fetchList({ page: 1 }); };
const handleReset = () => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); };
return (
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
<PageHeader
loading={loading} onSearch={handleSearch} onReset={handleReset}
actions={<>
<Button size="sm" variant="outline" className="h-8 gap-1 px-2 text-xs"
disabled={checkedIds.length === 0}
onClick={() => toast.info("마감정보입력 — arrival_plan 신설 후 활성")}>
<FileEdit className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="default" className="h-8 gap-1 px-2 text-xs"
disabled={checkedIds.length === 0}
onClick={() => toast.info("매입마감 — arrival_plan 신설 후 활성")}>
<Lock className="h-3.5 w-3.5" />
</Button>
</>}
/>
<CompactFilterBar totalText={<> {total.toLocaleString()}</>}>
<CompactFilterField label="년도" width={100}>
<SmartSelect options={yearOpts} value={filter.year ?? ""}
onValueChange={(v) => setFilter({ ...filter, year: v })} />
</CompactFilterField>
<CompactFilterField label="고객사" width={170}>
<CustomerSelect value={filter.customer_cd ?? ""}
onValueChange={(v) => setFilter({ ...filter, customer_cd: v })} />
</CompactFilterField>
<CompactFilterField label="프로젝트번호" width={160}>
<Input value={filter.project_no ?? ""}
onChange={(e) => setFilter({ ...filter, project_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="발주No" width={140}>
<Input value={filter.purchase_order_no ?? ""}
onChange={(e) => setFilter({ ...filter, purchase_order_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="규격" width={130}>
<Input value={filter.part_spec ?? ""}
onChange={(e) => setFilter({ ...filter, part_spec: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="품명" width={150}>
<Input value={filter.part_name ?? ""}
onChange={(e) => setFilter({ ...filter, part_name: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="공급업체" width={170}>
<SmartSelect options={supplierOpts} value={filter.partner_objid ?? ""}
onValueChange={(v) => setFilter({ ...filter, partner_objid: v })} />
</CompactFilterField>
<CompactFilterField label="구매담당자" width={150}>
<SmartSelect options={userOpts} value={filter.sales_mng_user_id ?? ""}
onValueChange={(v) => setFilter({ ...filter, sales_mng_user_id: v })} />
</CompactFilterField>
<CompactFilterField label="입고일" width={280}>
<CompactDateRange
from={filter.receipt_date_start ?? ""} setFrom={(v) => setFilter({ ...filter, receipt_date_start: v })}
to={filter.receipt_date_end ?? ""} setTo={(v) => setFilter({ ...filter, receipt_date_end: v })}
/>
</CompactFilterField>
<CompactFilterField label="매입마감" width={120}>
<SmartSelect options={CLOSE_OPTS} value={filter.close_status ?? ""}
onValueChange={(v) => setFilter({ ...filter, close_status: v })} />
</CompactFilterField>
<CompactFilterField label="품번" width={140}>
<Input value={filter.part_no ?? ""}
onChange={(e) => setFilter({ ...filter, part_no: e.target.value })} />
</CompactFilterField>
</CompactFilterBar>
<DataGrid
columns={GRID_COLUMNS}
data={gridRows}
loading={loading}
showCheckbox
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"}
gridId="purchase-inbound-by-date"
pageSizeOptions={[10, 15, 20, 50, 100]}
paginationStyle="range"
serverPaging
serverPage={filter.page ?? 1}
serverPageSize={filter.page_size ?? 50}
serverTotalItems={total}
onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }}
onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }}
showColumnSettings
summaryStats={summary}
systemColumnKeys={["delivery_writer_name", "purchase_close_date"]}
onRefresh={() => fetchList()}
onDownload={() => {
if (gridRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
const exportRows = gridRows.map((r: any) => {
const out: Record<string, any> = {};
GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; });
return out;
});
exportToExcel(exportRows, "입고일별_입고관리.xlsx", "입고일별");
}}
showChart
/>
</div>
);
}
@@ -0,0 +1,198 @@
"use client";
// 구매관리 > 품목별 입고관리 — wace purchaseOrder/deliveryMngAcceptancePartList.jsp 1:1
// 검색: 입고관리와 거의 동일 + 부품품번 추가
// 그리드: 품의서/발주서/프로젝트/부품품번/품번/품명/공급업체/환종/입고요청일/담당자/입고등록자/일/
// 발주·입고·미입고 수량+금액 / 검사현황 / 폐기수량 / 확정입고수량
// 액션: 조회만 (입고등록·매입마감은 비활성)
// ⚠️ purchase_order_part / arrival_plan 미존재 → 빈 그리드
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { CustomerSelect } from "@/components/common/CustomerSelect";
import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar";
import { PageHeader } from "@/components/common/PageHeader";
import { purchaseApi, PurchaseListFilter, OptionItem, getYearOptions } from "@/lib/api/purchase";
import { exportToExcel } from "@/lib/utils/excelExport";
const DELIVERY_STATUS_OPTS: SmartSelectOption[] = [
{ code: "입고중", label: "입고중" },
{ code: "입고완료", label: "입고완료" },
{ code: "지연", label: "지연" },
];
const EMPTY_FILTER: PurchaseListFilter = {
year: String(new Date().getFullYear()),
customer_cd: "", project_no: "", purchase_order_no: "",
part_spec: "", part_name: "", partner_objid: "",
sales_mng_user_id: "",
delivery_start_date: "", delivery_end_date: "",
reg_start_date: "", reg_end_date: "",
delivery_status: "", part_no: "",
page: 1, page_size: 50,
};
export default function InboundByItemPage() {
const [rows, setRows] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<PurchaseListFilter>(EMPTY_FILTER);
const [supplierOpts, setSupplierOpts] = useState<OptionItem[]>([]);
const [userOpts, setUserOpts] = useState<OptionItem[]>([]);
const yearOpts = useMemo(() => getYearOptions(), []);
const fetchList = useCallback(async (override?: Partial<PurchaseListFilter>) => {
setLoading(true);
try {
const f = { ...filter, ...override };
const res = await purchaseApi.listInboundByItem(f);
setRows(res.rows ?? []);
setTotal(res.totalCount ?? 0);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
} finally {
setLoading(false);
}
}, [filter]);
useEffect(() => {
let dead = false;
(async () => {
try {
const [s, u] = await Promise.all([purchaseApi.listSuppliers(), purchaseApi.listUsers()]);
if (dead) return;
setSupplierOpts(s); setUserOpts(u);
} catch { /* skip */ }
})();
fetchList(EMPTY_FILTER);
return () => { dead = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const gridRows = useMemo(() => rows.map((r, i) => ({ ...r, id: r.objid ?? `ii_${i}` })), [rows]);
const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([
{ key: "proposal_no", label: "품의서 No", width: "w-[125px]", align: "center" },
{ key: "purchase_order_no", label: "발주서 No", width: "w-[125px]", align: "center" },
{ key: "project_no", label: "프로젝트번호", width: "w-[135px]", align: "center" },
{ key: "component_part_no", label: "부품품번", width: "w-[140px]" },
{ key: "part_no", label: "품번", width: "w-[140px]" },
{ key: "part_name", label: "품명", minWidth: "min-w-[180px]" },
{ key: "partner_name", label: "공급업체", minWidth: "min-w-[150px]" },
{ key: "currency_name", label: "환종", width: "w-[80px]", align: "center" },
{ key: "delivery_request_date", label: "입고요청일", width: "w-[115px]", align: "center" },
{ key: "writer_name", label: "구매담당자", width: "w-[110px]", align: "center" },
{ key: "delivery_writer_name", label: "입고등록자", width: "w-[110px]", align: "center" },
{ key: "delivery_regdate", label: "입고등록일", width: "w-[115px]", align: "center" },
{ key: "order_qty", label: "발주수량", width: "w-[110px]", align: "right", formatNumber: true },
{ key: "delivery_qty", label: "입고수량", width: "w-[110px]", align: "right", formatNumber: true },
{ key: "non_delivery_qty", label: "미입고수량", width: "w-[110px]", align: "right", formatNumber: true },
{ key: "total_supply_price", label: "발주금액", width: "w-[120px]", align: "right", formatMoney: true },
{ key: "total_delivery_price", label: "입고금액", width: "w-[120px]", align: "right", formatMoney: true },
{ key: "total_not_delivery_price", label: "미입고금액", width: "w-[120px]", align: "right", formatMoney: true },
{ key: "inspection_status", label: "검사현황", width: "w-[110px]", align: "center" },
{ key: "defect_qty", label: "폐기수량", width: "w-[110px]", align: "right", formatNumber: true },
{ key: "confirmed_qty", label: "확정입고수량", width: "w-[120px]", align: "right", formatNumber: true },
]), []);
const summary = useMemo(() => [
{ label: "전체 건수", value: total.toLocaleString(), suffix: "건" },
], [total]);
const handleSearch = () => { setFilter(f => ({ ...f, page: 1 })); fetchList({ page: 1 }); };
const handleReset = () => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); };
return (
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
<PageHeader loading={loading} onSearch={handleSearch} onReset={handleReset} />
<CompactFilterBar totalText={<> {total.toLocaleString()}</>}>
<CompactFilterField label="년도" width={100}>
<SmartSelect options={yearOpts} value={filter.year ?? ""}
onValueChange={(v) => setFilter({ ...filter, year: v })} />
</CompactFilterField>
<CompactFilterField label="고객사" width={170}>
<CustomerSelect value={filter.customer_cd ?? ""}
onValueChange={(v) => setFilter({ ...filter, customer_cd: v })} />
</CompactFilterField>
<CompactFilterField label="프로젝트번호" width={160}>
<Input value={filter.project_no ?? ""}
onChange={(e) => setFilter({ ...filter, project_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="발주No" width={140}>
<Input value={filter.purchase_order_no ?? ""}
onChange={(e) => setFilter({ ...filter, purchase_order_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="규격" width={130}>
<Input value={filter.part_spec ?? ""}
onChange={(e) => setFilter({ ...filter, part_spec: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="품명" width={150}>
<Input value={filter.part_name ?? ""}
onChange={(e) => setFilter({ ...filter, part_name: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="공급업체" width={170}>
<SmartSelect options={supplierOpts} value={filter.partner_objid ?? ""}
onValueChange={(v) => setFilter({ ...filter, partner_objid: v })} />
</CompactFilterField>
<CompactFilterField label="구매담당자" width={150}>
<SmartSelect options={userOpts} value={filter.sales_mng_user_id ?? ""}
onValueChange={(v) => setFilter({ ...filter, sales_mng_user_id: v })} />
</CompactFilterField>
<CompactFilterField label="입고요청일" width={280}>
<CompactDateRange
from={filter.delivery_start_date ?? ""} setFrom={(v) => setFilter({ ...filter, delivery_start_date: v })}
to={filter.delivery_end_date ?? ""} setTo={(v) => setFilter({ ...filter, delivery_end_date: v })}
/>
</CompactFilterField>
<CompactFilterField label="발주일" width={280}>
<CompactDateRange
from={filter.reg_start_date ?? ""} setFrom={(v) => setFilter({ ...filter, reg_start_date: v })}
to={filter.reg_end_date ?? ""} setTo={(v) => setFilter({ ...filter, reg_end_date: v })}
/>
</CompactFilterField>
<CompactFilterField label="입고결과" width={120}>
<SmartSelect options={DELIVERY_STATUS_OPTS} value={filter.delivery_status ?? ""}
onValueChange={(v) => setFilter({ ...filter, delivery_status: v })} />
</CompactFilterField>
<CompactFilterField label="품번" width={140}>
<Input value={filter.part_no ?? ""}
onChange={(e) => setFilter({ ...filter, part_no: e.target.value })} />
</CompactFilterField>
</CompactFilterBar>
<DataGrid
columns={GRID_COLUMNS}
data={gridRows}
loading={loading}
emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"}
gridId="purchase-inbound-by-item"
pageSizeOptions={[10, 15, 20, 50, 100]}
paginationStyle="range"
serverPaging
serverPage={filter.page ?? 1}
serverPageSize={filter.page_size ?? 50}
serverTotalItems={total}
onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }}
onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }}
showColumnSettings
summaryStats={summary}
systemColumnKeys={["delivery_writer_name", "delivery_regdate"]}
onRefresh={() => fetchList()}
onDownload={() => {
if (gridRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
const exportRows = gridRows.map((r: any) => {
const out: Record<string, any> = {};
GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; });
return out;
});
exportToExcel(exportRows, "품목별_입고관리.xlsx", "품목별 입고");
}}
showChart
/>
</div>
);
}
@@ -0,0 +1,216 @@
"use client";
// 구매관리 > 입고관리 — wace purchaseOrder/deliveryMngAcceptanceList.jsp 1:1
// 검색: 년도/고객사/프로젝트번호/발주No/규격/품명/공급업체/구매담당자/입고요청일/발주일/입고결과/품번
// 그리드: 18컬럼 (품의서/발주서No, 프로젝트, 품번/품명/공급업체, 환종, 담당자, 입고등록자/일,
// 발주/입고/미입고 수량+금액, 업체성적서, 입고결과)
// 액션: 조회 / 입고등록
// ⚠️ 백엔드 purchase_order_part / arrival_plan 미존재 → 빈 그리드 (UI 만 제공)
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { PackagePlus } from "lucide-react";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { CustomerSelect } from "@/components/common/CustomerSelect";
import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar";
import { PageHeader } from "@/components/common/PageHeader";
import { purchaseApi, PurchaseListFilter, OptionItem, getYearOptions } from "@/lib/api/purchase";
import { exportToExcel } from "@/lib/utils/excelExport";
const DELIVERY_STATUS_OPTS: SmartSelectOption[] = [
{ code: "입고중", label: "입고중" },
{ code: "입고완료", label: "입고완료" },
{ code: "지연", label: "지연" },
];
const EMPTY_FILTER: PurchaseListFilter = {
year: String(new Date().getFullYear()),
customer_cd: "", project_no: "", purchase_order_no: "",
part_spec: "", part_name: "", partner_objid: "",
sales_mng_user_id: "",
delivery_start_date: "", delivery_end_date: "",
reg_start_date: "", reg_end_date: "",
delivery_status: "", part_no: "",
page: 1, page_size: 50,
};
export default function InboundPage() {
const [rows, setRows] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<PurchaseListFilter>(EMPTY_FILTER);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [supplierOpts, setSupplierOpts] = useState<OptionItem[]>([]);
const [userOpts, setUserOpts] = useState<OptionItem[]>([]);
const yearOpts = useMemo(() => getYearOptions(), []);
const fetchList = useCallback(async (override?: Partial<PurchaseListFilter>) => {
setLoading(true);
try {
const f = { ...filter, ...override };
const res = await purchaseApi.listInbound(f);
setRows(res.rows ?? []);
setTotal(res.totalCount ?? 0);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
} finally {
setLoading(false);
}
}, [filter]);
useEffect(() => {
let dead = false;
(async () => {
try {
const [s, u] = await Promise.all([
purchaseApi.listSuppliers(),
purchaseApi.listUsers(),
]);
if (dead) return;
setSupplierOpts(s);
setUserOpts(u);
} catch { /* skip */ }
})();
fetchList(EMPTY_FILTER);
return () => { dead = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const gridRows = useMemo(() => rows.map((r, i) => ({ ...r, id: r.objid ?? `i_${i}` })), [rows]);
const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([
{ key: "proposal_no", label: "품의서 No", width: "w-[125px]", align: "center" },
{ key: "purchase_order_no", label: "발주서 No", width: "w-[125px]", align: "center" },
{ key: "project_no", label: "프로젝트번호", width: "w-[135px]", align: "center" },
{ key: "part_no", label: "품번", width: "w-[140px]" },
{ key: "part_name", label: "품명", minWidth: "min-w-[180px]" },
{ key: "partner_name", label: "공급업체", minWidth: "min-w-[150px]" },
{ key: "currency_name", label: "환종", width: "w-[80px]", align: "center" },
{ key: "writer_name", label: "구매담당자", width: "w-[110px]", align: "center" },
{ key: "delivery_writer_name", label: "입고등록자", width: "w-[110px]", align: "center" },
{ key: "delivery_regdate", label: "입고등록일", width: "w-[115px]", align: "center" },
{ key: "total_po_qty", label: "발주수량", width: "w-[110px]", align: "right", formatNumber: true },
{ key: "total_delivery_qty", label: "입고수량", width: "w-[110px]", align: "right", formatNumber: true },
{ key: "non_delivery_qty", label: "미입고수량", width: "w-[110px]", align: "right", formatNumber: true },
{ key: "total_supply_price", label: "발주금액", width: "w-[120px]", align: "right", formatMoney: true },
{ key: "total_delivery_price", label: "입고금액", width: "w-[120px]", align: "right", formatMoney: true },
{ key: "total_not_delivery_price", label: "미입고금액", width: "w-[120px]", align: "right", formatMoney: true },
{ key: "inspection_file_cnt", label: "업체성적서", width: "w-[110px]", align: "center", renderType: "clip" },
{ key: "delivery_status", label: "입고결과", width: "w-[110px]", align: "center" },
]), []);
const summary = useMemo(() => [
{ label: "전체 건수", value: total.toLocaleString(), suffix: "건" },
{ label: "선택", value: checkedIds.length.toLocaleString(), suffix: "건" },
], [total, checkedIds]);
const handleSearch = () => { setFilter(f => ({ ...f, page: 1 })); fetchList({ page: 1 }); };
const handleReset = () => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); };
return (
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
<PageHeader
loading={loading} onSearch={handleSearch} onReset={handleReset}
actions={
<Button size="sm" variant="default" className="h-8 gap-1 px-2 text-xs"
disabled={checkedIds.length !== 1}
onClick={() => toast.info("입고등록 — purchase_order_part / arrival_plan 신설 후 활성")}>
<PackagePlus className="h-3.5 w-3.5" />
</Button>
}
/>
<CompactFilterBar totalText={<> {total.toLocaleString()}</>}>
<CompactFilterField label="년도" width={100}>
<SmartSelect options={yearOpts} value={filter.year ?? ""}
onValueChange={(v) => setFilter({ ...filter, year: v })} />
</CompactFilterField>
<CompactFilterField label="고객사" width={170}>
<CustomerSelect value={filter.customer_cd ?? ""}
onValueChange={(v) => setFilter({ ...filter, customer_cd: v })} />
</CompactFilterField>
<CompactFilterField label="프로젝트번호" width={160}>
<Input value={filter.project_no ?? ""}
onChange={(e) => setFilter({ ...filter, project_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="발주No" width={140}>
<Input value={filter.purchase_order_no ?? ""}
onChange={(e) => setFilter({ ...filter, purchase_order_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="규격" width={130}>
<Input value={filter.part_spec ?? ""}
onChange={(e) => setFilter({ ...filter, part_spec: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="품명" width={150}>
<Input value={filter.part_name ?? ""}
onChange={(e) => setFilter({ ...filter, part_name: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="공급업체" width={170}>
<SmartSelect options={supplierOpts} value={filter.partner_objid ?? ""}
onValueChange={(v) => setFilter({ ...filter, partner_objid: v })} />
</CompactFilterField>
<CompactFilterField label="구매담당자" width={150}>
<SmartSelect options={userOpts} value={filter.sales_mng_user_id ?? ""}
onValueChange={(v) => setFilter({ ...filter, sales_mng_user_id: v })} />
</CompactFilterField>
<CompactFilterField label="입고요청일" width={280}>
<CompactDateRange
from={filter.delivery_start_date ?? ""} setFrom={(v) => setFilter({ ...filter, delivery_start_date: v })}
to={filter.delivery_end_date ?? ""} setTo={(v) => setFilter({ ...filter, delivery_end_date: v })}
/>
</CompactFilterField>
<CompactFilterField label="발주일" width={280}>
<CompactDateRange
from={filter.reg_start_date ?? ""} setFrom={(v) => setFilter({ ...filter, reg_start_date: v })}
to={filter.reg_end_date ?? ""} setTo={(v) => setFilter({ ...filter, reg_end_date: v })}
/>
</CompactFilterField>
<CompactFilterField label="입고결과" width={120}>
<SmartSelect options={DELIVERY_STATUS_OPTS} value={filter.delivery_status ?? ""}
onValueChange={(v) => setFilter({ ...filter, delivery_status: v })} />
</CompactFilterField>
<CompactFilterField label="품번" width={140}>
<Input value={filter.part_no ?? ""}
onChange={(e) => setFilter({ ...filter, part_no: e.target.value })} />
</CompactFilterField>
</CompactFilterBar>
<DataGrid
columns={GRID_COLUMNS}
data={gridRows}
loading={loading}
showCheckbox
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"}
gridId="purchase-inbound"
pageSizeOptions={[10, 15, 20, 50, 100]}
paginationStyle="range"
serverPaging
serverPage={filter.page ?? 1}
serverPageSize={filter.page_size ?? 50}
serverTotalItems={total}
onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }}
onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }}
showColumnSettings
summaryStats={summary}
systemColumnKeys={["delivery_writer_name", "delivery_regdate"]}
onRefresh={() => fetchList()}
onDownload={() => {
if (gridRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
const exportRows = gridRows.map((r: any) => {
const out: Record<string, any> = {};
GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; });
return out;
});
exportToExcel(exportRows, "입고관리.xlsx", "입고");
}}
showChart
/>
</div>
);
}
@@ -0,0 +1,173 @@
"use client";
// 구매관리 > 구매리스트관리 — wace salesMng/salesRequestMngRegList.jsp 1:1
// 그리드: sales_request_master + mbom_detail/part_mng (품번/품명 1건 + 외 N건) + comm_code 변환
// 검색: 품번 / 품명 / 작성일 / 고객사 / 작성자 / 제품구분 / 프로젝트번호
// 액션: 조회 / 초기화 / [구매리스트 생성→M-BOM 페이지에서 처리] (해당 화면은 조회 전용)
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { CustomerSelect } from "@/components/common/CustomerSelect";
import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar";
import { PageHeader } from "@/components/common/PageHeader";
import { apiClient } from "@/lib/api/client";
import { purchaseApi, PurchaseListFilter, OptionItem } from "@/lib/api/purchase";
import { exportToExcel } from "@/lib/utils/excelExport";
const PARENT_PRODUCT = "0000001"; // 제품구분 comm_code parent_code_id
const EMPTY_FILTER: PurchaseListFilter = {
part_no: "", part_name: "", regdate_start: "", regdate_end: "",
customer_cd: "", request_user: "", part_type: "", project_no: "",
page: 1, page_size: 50,
};
export default function PurchaseListPage() {
const [rows, setRows] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<PurchaseListFilter>(EMPTY_FILTER);
const [productOpts, setProductOpts] = useState<SmartSelectOption[]>([]);
const [userOpts, setUserOpts] = useState<OptionItem[]>([]);
const fetchList = useCallback(async (override?: Partial<PurchaseListFilter>) => {
setLoading(true);
try {
const f = { ...filter, ...override };
const res = await purchaseApi.listPurchaseRequest(f);
setRows(res.rows ?? []);
setTotal(res.totalCount ?? 0);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
} finally {
setLoading(false);
}
}, [filter]);
useEffect(() => {
let dead = false;
(async () => {
try {
const [p, u] = await Promise.all([
apiClient.get(`/sales/codes/${PARENT_PRODUCT}`),
purchaseApi.listUsers(),
]);
if (dead) return;
setProductOpts(p.data?.data ?? []);
setUserOpts(u);
} catch { /* skip */ }
})();
fetchList(EMPTY_FILTER);
return () => { dead = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const gridRows = useMemo(
() => rows.map((r, i) => ({
...r,
id: `${r.objid}__${i}`,
part_display: r.part_extra_count > 0 ? `${r.part_no}${r.part_extra_count}` : r.part_no,
part_name_display: r.part_extra_count > 0 ? `${r.part_name}${r.part_extra_count}` : r.part_name,
})),
[rows],
);
const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([
{ key: "request_mng_no", label: "요청번호", width: "w-[140px]", align: "center" },
{ key: "purchase_type_name", label: "구매유형", width: "w-[115px]", align: "center" },
{ key: "project_number", label: "프로젝트번호", width: "w-[140px]", align: "center" },
{ key: "order_type_name", label: "주문유형", width: "w-[115px]", align: "center" },
{ key: "product_name_full", label: "제품구분", width: "w-[115px]", align: "center" },
{ key: "customer_name", label: "고객사", minWidth: "min-w-[180px]" },
{ key: "paid_type_name", label: "유/무상", width: "w-[100px]", align: "center" },
{ key: "part_display", label: "품번", width: "w-[160px]" },
{ key: "part_name_display", label: "품명", minWidth: "min-w-[200px]" },
{ key: "has_quotation_request", label: "견적요청서", width: "w-[115px]", align: "center" },
{ key: "request_user_name", label: "작성자", width: "w-[115px]", align: "center" },
{ key: "delivery_request_date", label: "입고요청일", width: "w-[115px]", align: "center" },
{ key: "regdate_title", label: "작성일", width: "w-[115px]", align: "center" },
]), []);
const summary = useMemo(() => {
const pageCnt = gridRows.length;
return [
{ label: "전체 건수", value: total.toLocaleString(), suffix: "건" },
{ label: "페이지 건수", value: pageCnt.toLocaleString(), suffix: "건" },
];
}, [gridRows, total]);
const handleSearch = () => { setFilter(f => ({ ...f, page: 1 })); fetchList({ page: 1 }); };
const handleReset = () => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); };
return (
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
<PageHeader loading={loading} onSearch={handleSearch} onReset={handleReset} />
<CompactFilterBar totalText={<> {total.toLocaleString()}</>}>
<CompactFilterField label="품번" width={150}>
<Input value={filter.part_no ?? ""}
onChange={(e) => setFilter({ ...filter, part_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="품명" width={150}>
<Input value={filter.part_name ?? ""}
onChange={(e) => setFilter({ ...filter, part_name: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="작성일" width={280}>
<CompactDateRange
from={filter.regdate_start ?? ""} setFrom={(v) => setFilter({ ...filter, regdate_start: v })}
to={filter.regdate_end ?? ""} setTo={(v) => setFilter({ ...filter, regdate_end: v })}
/>
</CompactFilterField>
<CompactFilterField label="고객사" width={170}>
<CustomerSelect value={filter.customer_cd ?? ""}
onValueChange={(v) => setFilter({ ...filter, customer_cd: v })} />
</CompactFilterField>
<CompactFilterField label="작성자" width={150}>
<SmartSelect options={userOpts} value={filter.request_user ?? ""}
onValueChange={(v) => setFilter({ ...filter, request_user: v })} />
</CompactFilterField>
<CompactFilterField label="제품구분" width={130}>
<SmartSelect options={productOpts} value={filter.part_type ?? ""}
onValueChange={(v) => setFilter({ ...filter, part_type: v })} />
</CompactFilterField>
<CompactFilterField label="프로젝트번호" width={170}>
<Input value={filter.project_no ?? ""}
onChange={(e) => setFilter({ ...filter, project_no: e.target.value })} />
</CompactFilterField>
</CompactFilterBar>
<DataGrid
columns={GRID_COLUMNS}
data={gridRows}
loading={loading}
emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"}
gridId="purchase-list"
pageSizeOptions={[10, 15, 20, 50, 100]}
paginationStyle="range"
serverPaging
serverPage={filter.page ?? 1}
serverPageSize={filter.page_size ?? 50}
serverTotalItems={total}
onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }}
onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }}
showColumnSettings
summaryStats={summary}
systemColumnKeys={["request_user_name", "regdate_title"]}
onRefresh={() => fetchList()}
onDownload={() => {
if (gridRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
const exportRows = gridRows.map((r: any) => {
const out: Record<string, any> = {};
GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; });
return out;
});
exportToExcel(exportRows, "구매리스트관리.xlsx", "구매리스트");
}}
showChart
/>
</div>
);
}
@@ -31,7 +31,7 @@ import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { SmartSelect } from "@/components/common/SmartSelect";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
const MASTER_TABLE = "purchase_order_mng";
const DETAIL_TABLE = "purchase_detail";
@@ -167,30 +167,21 @@ export default function PurchaseOrderPage() {
// 테이블 설정
const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG);
// 발주 목록 컬럼 정의 (EDataTable) — 헤더별 Popover 체크박스 필터 내장
const orderTableColumns = useMemo<EDataTableColumn[]>(() => {
const numCols = new Set(["order_qty", "received_qty", "remain_qty", "unit_price", "amount"]);
// 발주 목록 컬럼 정의 — 영업관리 4메뉴와 일관성 (DataGrid + logicstudio props)
// 날짜/숫자는 데이터 매핑 단계에서 pre-format. status 는 plain text (영업메뉴 동일).
const orderTableColumns = useMemo<DataGridColumn[]>(() => {
const numCols = new Set(["order_qty", "received_qty", "remain_qty"]);
const moneyCols = new Set(["unit_price", "amount"]);
return ts.visibleColumns.map((col) => {
const base: EDataTableColumn = {
const base: DataGridColumn = {
key: col.key,
label: col.label,
align: numCols.has(col.key) ? "right" : col.key === "status" ? "center" : undefined,
align: numCols.has(col.key) || moneyCols.has(col.key)
? "right"
: col.key === "status" ? "center" : undefined,
};
if (col.key === "status") {
base.render = (_v: any, row: any) => row.status ? (
<span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold", STATUS_BADGE_CLASS[row.status] || "")}>
{row.status}
</span>
) : "-";
} else if (col.key === "order_date" || col.key === "due_date") {
const k = col.key;
base.render = (_v: any, row: any) => row[k] ? new Date(row[k]).toLocaleDateString("ko-KR") : "-";
} else if (numCols.has(col.key)) {
const k = col.key;
base.render = (_v: any, row: any) => (
<span className="font-mono">{row[k] ? Number(row[k]).toLocaleString() : ""}</span>
);
}
if (numCols.has(col.key)) base.formatNumber = true;
if (moneyCols.has(col.key)) base.formatMoney = true;
return base;
});
}, [ts.visibleColumns]);
@@ -376,6 +367,9 @@ export default function PurchaseOrderPage() {
const item = itemMap[row.item_code];
const master = masterMap[row.purchase_no];
const rawUnit = row.unit || item?.inventory_unit || "";
const fmtDate = (v: any) => v ? new Date(v).toLocaleDateString("ko-KR") : "";
const od = master?.order_date || "";
const dd = row.due_date || "";
return {
...row,
item_name: row.item_name || item?.item_name || "",
@@ -383,7 +377,8 @@ export default function PurchaseOrderPage() {
unit: resolveLabel("item_inventory_unit", rawUnit) || rawUnit,
status: master?.status || "",
supplier_name: master?.supplier_name || "",
order_date: master?.order_date || "",
order_date: od ? fmtDate(od) : "",
due_date: dd ? fmtDate(dd) : "",
memo: row.memo || master?.memo || "",
};
});
@@ -868,22 +863,33 @@ export default function PurchaseOrderPage() {
</div>
</div>
{/* 데이터 테이블 — 플랫 리스트 (EDataTable: 컬럼별 Popover 체크박스 필터 + 정렬 내장) */}
<div className="flex flex-1 min-h-0 flex-col overflow-hidden border rounded-lg bg-card">
<EDataTable
{/* 데이터 테이블 — 영업관리 4개 메뉴와 동일한 DataGrid + logicstudio props */}
<DataGrid
gridId="c16-purchase-order-main"
columns={orderTableColumns}
data={orders}
loading={loading}
emptyMessage="등록된 발주가 없어요"
showCheckbox
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
onRowDoubleClick={(row) => openEditModal(row.purchase_no)}
showPagination
draggableColumns={false}
columnOrderKey="c16-purchase-order-main"
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
/>
</div>
{/* 발주 등록/수정 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
@@ -0,0 +1,166 @@
"use client";
// 구매관리 > 프로젝트별 발주/입고 현황 — wace purchaseOrder/projectPurchaseDeliveryStatus.jsp 1:1
// 검색: 년도/고객사/프로젝트번호/제품구분/품번/품명
// 그리드: 프로젝트정보(5) + 전체(2) + 발주(2) + 미발주(2) + 입고(2) + 미입고(2) = 15컬럼
// 액션: 조회만
// 데이터 출처:
// - 프로젝트정보 / 전체(BOM기준) — project_mgmt + contract_mgmt + mbom_header/detail ✓
// - 발주/입고/미발주/미입고 — purchase_order_part / arrival_plan 누락 → 0 표시
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { CustomerSelect } from "@/components/common/CustomerSelect";
import { CompactFilterBar, CompactFilterField } from "@/components/common/CompactFilterBar";
import { PageHeader } from "@/components/common/PageHeader";
import { apiClient } from "@/lib/api/client";
import { purchaseApi, PurchaseListFilter, getYearOptions } from "@/lib/api/purchase";
import { exportToExcel } from "@/lib/utils/excelExport";
const PARENT_PRODUCT = "0000001";
const EMPTY_FILTER: PurchaseListFilter = {
year: String(new Date().getFullYear()),
customer_objid: "", project_no: "", product_cd: "",
part_no: "", part_name: "",
page: 1, page_size: 50,
};
export default function ProjectStatusPage() {
const [rows, setRows] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<PurchaseListFilter>(EMPTY_FILTER);
const [productOpts, setProductOpts] = useState<SmartSelectOption[]>([]);
const yearOpts = useMemo(() => getYearOptions(), []);
const fetchList = useCallback(async (override?: Partial<PurchaseListFilter>) => {
setLoading(true);
try {
const f = { ...filter, ...override };
const res = await purchaseApi.listProjectStatus(f);
setRows(res.rows ?? []);
setTotal(res.totalCount ?? 0);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
} finally {
setLoading(false);
}
}, [filter]);
useEffect(() => {
let dead = false;
(async () => {
try {
const r = await apiClient.get(`/sales/codes/${PARENT_PRODUCT}`);
if (dead) return;
setProductOpts(r.data?.data ?? []);
} catch { /* skip */ }
})();
fetchList(EMPTY_FILTER);
return () => { dead = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const gridRows = useMemo(() => rows.map((r, i) => ({ ...r, id: r.objid ?? `s_${i}` })), [rows]);
const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([
// 프로젝트정보
{ key: "project_no", label: "프로젝트번호", width: "w-[140px]", align: "center", frozen: true },
{ key: "product_name", label: "제품구분", width: "w-[115px]", align: "center" },
{ key: "part_no", label: "품번", width: "w-[150px]" },
{ key: "part_name", label: "품명", minWidth: "min-w-[200px]" },
{ key: "customer_name", label: "고객사", minWidth: "min-w-[180px]" },
// 전체 (BOM기준)
{ key: "total_item_cnt", label: "전체품목수", width: "w-[115px]", align: "right", formatNumber: true },
{ key: "total_qty", label: "전체수량", width: "w-[115px]", align: "right", formatNumber: true },
// 발주현황
{ key: "po_item_cnt", label: "발주품목수", width: "w-[115px]", align: "right", formatNumber: true },
{ key: "po_qty", label: "발주수량", width: "w-[115px]", align: "right", formatNumber: true },
// 미발주현황
{ key: "non_po_item_cnt", label: "미발주품목수", width: "w-[125px]", align: "right", formatNumber: true },
{ key: "non_po_qty", label: "미발주수량", width: "w-[115px]", align: "right", formatNumber: true },
// 입고현황
{ key: "dlv_item_cnt", label: "입고품목수", width: "w-[115px]", align: "right", formatNumber: true },
{ key: "dlv_qty", label: "입고수량", width: "w-[115px]", align: "right", formatNumber: true },
// 미입고현황
{ key: "non_dlv_item_cnt", label: "미입고품목수", width: "w-[125px]", align: "right", formatNumber: true },
{ key: "non_dlv_qty", label: "미입고수량", width: "w-[115px]", align: "right", formatNumber: true },
]), []);
const summary = useMemo(() => {
const pageQty = gridRows.reduce((acc, r: any) => acc + Number(r.total_qty || 0), 0);
return [
{ label: "전체 건수", value: total.toLocaleString(), suffix: "건" },
{ label: "페이지 전체수량 합계", value: pageQty.toLocaleString() },
];
}, [gridRows, total]);
const handleSearch = () => { setFilter(f => ({ ...f, page: 1 })); fetchList({ page: 1 }); };
const handleReset = () => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); };
return (
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
<PageHeader loading={loading} onSearch={handleSearch} onReset={handleReset} />
<CompactFilterBar totalText={<> {total.toLocaleString()}</>}>
<CompactFilterField label="년도" width={100}>
<SmartSelect options={yearOpts} value={filter.year ?? ""}
onValueChange={(v) => setFilter({ ...filter, year: v })} />
</CompactFilterField>
<CompactFilterField label="고객사" width={170}>
<CustomerSelect value={filter.customer_objid ?? ""}
onValueChange={(v) => setFilter({ ...filter, customer_objid: v })} />
</CompactFilterField>
<CompactFilterField label="프로젝트번호" width={170}>
<Input value={filter.project_no ?? ""}
onChange={(e) => setFilter({ ...filter, project_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="제품구분" width={130}>
<SmartSelect options={productOpts} value={filter.product_cd ?? ""}
onValueChange={(v) => setFilter({ ...filter, product_cd: v })} />
</CompactFilterField>
<CompactFilterField label="품번" width={150}>
<Input value={filter.part_no ?? ""}
onChange={(e) => setFilter({ ...filter, part_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="품명" width={170}>
<Input value={filter.part_name ?? ""}
onChange={(e) => setFilter({ ...filter, part_name: e.target.value })} />
</CompactFilterField>
</CompactFilterBar>
<DataGrid
columns={GRID_COLUMNS}
data={gridRows}
loading={loading}
emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"}
gridId="purchase-project-status"
pageSizeOptions={[10, 15, 20, 50, 100]}
paginationStyle="range"
serverPaging
serverPage={filter.page ?? 1}
serverPageSize={filter.page_size ?? 50}
serverTotalItems={total}
onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }}
onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }}
showColumnSettings
summaryStats={summary}
onRefresh={() => fetchList()}
onDownload={() => {
if (gridRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
const exportRows = gridRows.map((r: any) => {
const out: Record<string, any> = {};
GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; });
return out;
});
exportToExcel(exportRows, "프로젝트별_발주_입고현황.xlsx", "프로젝트현황");
}}
showChart
/>
</div>
);
}
@@ -0,0 +1,199 @@
"use client";
// 구매관리 > 품의서관리 — wace salesMng/proposalMngList.jsp 1:1
// 그리드: sales_request_master(doc_type='PROPOSAL') + mbom 품번/품명
// 검색: 품의서No / 프로젝트번호 / 결재상태 / 작성일 / 구매유형(multi) / 작성자 / 제품구분
// 액션: 조회 / 결재상신 / 발주서생성
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Send, ClipboardCheck } from "lucide-react";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar";
import { PageHeader } from "@/components/common/PageHeader";
import { apiClient } from "@/lib/api/client";
import { purchaseApi, PurchaseListFilter, OptionItem } from "@/lib/api/purchase";
import { exportToExcel } from "@/lib/utils/excelExport";
const PARENT_PURCHASE_TYPE = "0001814"; // 구매유형 comm_code
const PARENT_PART_TYPE = "0000001"; // 제품구분 comm_code
const STATUS_OPTS: SmartSelectOption[] = [
{ code: "create", label: "작성중" },
{ code: "approvalRequest", label: "결재중" },
{ code: "approvalComplete", label: "결재완료" },
{ code: "reject", label: "반려" },
];
const EMPTY_FILTER: PurchaseListFilter = {
proposal_no: "", project_no: "", search_status: "",
regdate_start: "", regdate_end: "",
purchase_type: "", writer: "", part_type: "",
page: 1, page_size: 50,
};
export default function ProposalPage() {
const [rows, setRows] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<PurchaseListFilter>(EMPTY_FILTER);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [purchaseTypeOpts, setPurchaseTypeOpts] = useState<SmartSelectOption[]>([]);
const [partTypeOpts, setPartTypeOpts] = useState<SmartSelectOption[]>([]);
const [userOpts, setUserOpts] = useState<OptionItem[]>([]);
const fetchList = useCallback(async (override?: Partial<PurchaseListFilter>) => {
setLoading(true);
try {
const f = { ...filter, ...override };
const res = await purchaseApi.listProposal(f);
setRows(res.rows ?? []);
setTotal(res.totalCount ?? 0);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
} finally {
setLoading(false);
}
}, [filter]);
useEffect(() => {
let dead = false;
(async () => {
try {
const [pt, ptt, u] = await Promise.all([
apiClient.get(`/sales/codes/${PARENT_PURCHASE_TYPE}`),
apiClient.get(`/sales/codes/${PARENT_PART_TYPE}`),
purchaseApi.listUsers(),
]);
if (dead) return;
setPurchaseTypeOpts(pt.data?.data ?? []);
setPartTypeOpts(ptt.data?.data ?? []);
setUserOpts(u);
} catch { /* skip */ }
})();
fetchList(EMPTY_FILTER);
return () => { dead = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const gridRows = useMemo(() => rows.map((r, i) => ({
...r,
id: r.objid ?? `p_${i}`,
part_display: r.part_extra_count > 0 ? `${r.part_no}${r.part_extra_count}` : r.part_no,
part_name_display: r.part_extra_count > 0 ? `${r.part_name}${r.part_extra_count}` : r.part_name,
})), [rows]);
const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([
{ key: "proposal_no", label: "품의서 No", width: "w-[140px]", align: "center" },
{ key: "project_number", label: "프로젝트번호", width: "w-[150px]", align: "center" },
{ key: "purchase_type_name", label: "구매유형", width: "w-[115px]", align: "center" },
{ key: "order_type_name", label: "주문유형", width: "w-[115px]", align: "center" },
{ key: "product_name_title", label: "제품구분", width: "w-[115px]", align: "center" },
{ key: "part_display", label: "품번", width: "w-[160px]" },
{ key: "part_name_display", label: "품명", minWidth: "min-w-[200px]" },
{ key: "status_title", label: "결재상태", width: "w-[115px]", align: "center" },
{ key: "regdate_title", label: "작성일", width: "w-[115px]", align: "center" },
{ key: "writer_name", label: "작성자", width: "w-[115px]", align: "center" },
]), []);
const summary = useMemo(() => {
const approved = gridRows.filter((r: any) => r.status === "approvalComplete").length;
return [
{ label: "전체 건수", value: total.toLocaleString(), suffix: "건" },
{ label: "결재완료(페이지)", value: approved.toLocaleString(), suffix: "건" },
{ label: "선택", value: checkedIds.length.toLocaleString(), suffix: "건" },
];
}, [gridRows, total, checkedIds]);
const handleSearch = () => { setFilter(f => ({ ...f, page: 1 })); fetchList({ page: 1 }); };
const handleReset = () => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); };
return (
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
<PageHeader
loading={loading} onSearch={handleSearch} onReset={handleReset}
actions={<>
<Button size="sm" variant="outline" className="h-8 gap-1 px-2 text-xs"
disabled={checkedIds.length !== 1}
onClick={() => toast.info("결재상신 — Amaranth10 SSO 연동 후 활성")}>
<Send className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="default" className="h-8 gap-1 px-2 text-xs"
disabled={checkedIds.length !== 1}
onClick={() => toast.info("발주서생성 — purchase_order_part 신설 후 활성")}>
<ClipboardCheck className="h-3.5 w-3.5" />
</Button>
</>}
/>
<CompactFilterBar totalText={<> {total.toLocaleString()}</>}>
<CompactFilterField label="품의서 No" width={150}>
<Input value={filter.proposal_no ?? ""}
onChange={(e) => setFilter({ ...filter, proposal_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="프로젝트번호" width={170}>
<Input value={filter.project_no ?? ""}
onChange={(e) => setFilter({ ...filter, project_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="결재상태" width={130}>
<SmartSelect options={STATUS_OPTS} value={filter.search_status ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_status: v })} />
</CompactFilterField>
<CompactFilterField label="작성일" width={280}>
<CompactDateRange
from={filter.regdate_start ?? ""} setFrom={(v) => setFilter({ ...filter, regdate_start: v })}
to={filter.regdate_end ?? ""} setTo={(v) => setFilter({ ...filter, regdate_end: v })}
/>
</CompactFilterField>
<CompactFilterField label="구매유형" width={130}>
<SmartSelect options={purchaseTypeOpts} value={filter.purchase_type ?? ""}
onValueChange={(v) => setFilter({ ...filter, purchase_type: v })} />
</CompactFilterField>
<CompactFilterField label="작성자" width={150}>
<SmartSelect options={userOpts} value={filter.writer ?? ""}
onValueChange={(v) => setFilter({ ...filter, writer: v })} />
</CompactFilterField>
<CompactFilterField label="제품구분" width={130}>
<SmartSelect options={partTypeOpts} value={filter.part_type ?? ""}
onValueChange={(v) => setFilter({ ...filter, part_type: v })} />
</CompactFilterField>
</CompactFilterBar>
<DataGrid
columns={GRID_COLUMNS}
data={gridRows}
loading={loading}
showCheckbox
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"}
gridId="purchase-proposal"
pageSizeOptions={[10, 15, 20, 50, 100]}
paginationStyle="range"
serverPaging
serverPage={filter.page ?? 1}
serverPageSize={filter.page_size ?? 50}
serverTotalItems={total}
onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }}
onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }}
showColumnSettings
summaryStats={summary}
systemColumnKeys={["writer_name", "regdate_title"]}
onRefresh={() => fetchList()}
onDownload={() => {
if (gridRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
const exportRows = gridRows.map((r: any) => {
const out: Record<string, any> = {};
GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; });
return out;
});
exportToExcel(exportRows, "품의서관리.xlsx", "품의서");
}}
showChart
/>
</div>
);
}
@@ -0,0 +1,190 @@
"use client";
// 구매관리 > 견적요청서관리 — wace salesMng/quotationRequestList.jsp 1:1
// 검색: 년도 / 프로젝트번호 / 견적요청서No / 공급업체 / 메일발송 / 작성자 / 제품구분
// 그리드: 13컬럼 (견적번호 / 요청번호 / 구매유형 / 프로젝트번호 / 주문유형 / 제품구분 / 품번 / 품명 / 공급업체 / 견적요청서(파일) / 메일발송 / 수신견적서 / 작성자)
// 액션: 메일발송 / 삭제 / 조회
// ⚠️ 백엔드 quotation_request_master 미존재 → 빈 그리드 (UI 만 제공)
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Mail, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { CompactFilterBar, CompactFilterField } from "@/components/common/CompactFilterBar";
import { PageHeader } from "@/components/common/PageHeader";
import { apiClient } from "@/lib/api/client";
import { purchaseApi, PurchaseListFilter, OptionItem, getYearOptions } from "@/lib/api/purchase";
import { exportToExcel } from "@/lib/utils/excelExport";
const PARENT_PRODUCT = "0000001";
const MAIL_SEND_OPTS: SmartSelectOption[] = [
{ code: "N", label: "미발송" },
{ code: "Y", label: "발송" },
];
const EMPTY_FILTER: PurchaseListFilter = {
year: String(new Date().getFullYear()),
project_no: "", proposal_no: "", partner_objid: "",
mail_send_yn: "", writer: "", product_cd: "",
page: 1, page_size: 50,
};
export default function QuoteRequestPage() {
const [rows, setRows] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<PurchaseListFilter>(EMPTY_FILTER);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [productOpts, setProductOpts] = useState<SmartSelectOption[]>([]);
const [supplierOpts, setSupplierOpts] = useState<OptionItem[]>([]);
const [userOpts, setUserOpts] = useState<OptionItem[]>([]);
const yearOpts = useMemo(() => getYearOptions(), []);
const fetchList = useCallback(async (override?: Partial<PurchaseListFilter>) => {
setLoading(true);
try {
const f = { ...filter, ...override };
const res = await purchaseApi.listQuotationRequest(f);
setRows(res.rows ?? []);
setTotal(res.totalCount ?? 0);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
} finally {
setLoading(false);
}
}, [filter]);
useEffect(() => {
let dead = false;
(async () => {
try {
const [p, s, u] = await Promise.all([
apiClient.get(`/sales/codes/${PARENT_PRODUCT}`),
purchaseApi.listSuppliers(),
purchaseApi.listUsers(),
]);
if (dead) return;
setProductOpts(p.data?.data ?? []);
setSupplierOpts(s);
setUserOpts(u);
} catch { /* skip */ }
})();
fetchList(EMPTY_FILTER);
return () => { dead = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const gridRows = useMemo(() => rows.map((r, i) => ({ ...r, id: r.objid ?? `q_${i}` })), [rows]);
const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([
{ key: "quotation_request_no", label: "견적번호", width: "w-[140px]", align: "center" },
{ key: "request_mng_no", label: "요청번호", width: "w-[140px]", align: "center" },
{ key: "purchase_type_name", label: "구매유형", width: "w-[115px]", align: "center" },
{ key: "project_number", label: "프로젝트번호", width: "w-[140px]", align: "center" },
{ key: "order_type_name", label: "주문유형", width: "w-[115px]", align: "center" },
{ key: "product_name_title", label: "제품구분", width: "w-[115px]", align: "center" },
{ key: "part_no", label: "품번", width: "w-[150px]" },
{ key: "part_name", label: "품명", minWidth: "min-w-[180px]" },
{ key: "vendor_name", label: "공급업체", minWidth: "min-w-[150px]" },
{ key: "quotation_file", label: "견적요청서", width: "w-[115px]", align: "center", renderType: "clip" },
{ key: "mail_send_date_title", label: "메일발송", width: "w-[125px]", align: "center" },
{ key: "attach_file_cnt", label: "수신견적서", width: "w-[115px]", align: "center" },
{ key: "writer_name", label: "작성자", width: "w-[115px]", align: "center" },
]), []);
const summary = useMemo(() => [
{ label: "전체 건수", value: total.toLocaleString(), suffix: "건" },
{ label: "선택", value: checkedIds.length.toLocaleString(), suffix: "건" },
], [total, checkedIds]);
const handleSearch = () => { setFilter(f => ({ ...f, page: 1 })); fetchList({ page: 1 }); };
const handleReset = () => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); };
return (
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
<PageHeader
loading={loading} onSearch={handleSearch} onReset={handleReset}
actions={<>
<Button size="sm" variant="outline" className="h-8 gap-1 px-2 text-xs"
disabled={checkedIds.length === 0}
onClick={() => toast.info("메일발송 — 운영DB quotation_request_master 신설 후 활성")}>
<Mail className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="destructive" className="h-8 gap-1 px-2 text-xs"
disabled={checkedIds.length === 0}
onClick={() => toast.info("삭제 — 운영DB quotation_request_master 신설 후 활성")}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>}
/>
<CompactFilterBar totalText={<> {total.toLocaleString()}</>}>
<CompactFilterField label="년도" width={100}>
<SmartSelect options={yearOpts} value={filter.year ?? ""}
onValueChange={(v) => setFilter({ ...filter, year: v })} />
</CompactFilterField>
<CompactFilterField label="프로젝트번호" width={170}>
<Input value={filter.project_no ?? ""}
onChange={(e) => setFilter({ ...filter, project_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="견적요청서No" width={150}>
<Input value={filter.proposal_no ?? ""}
onChange={(e) => setFilter({ ...filter, proposal_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="공급업체" width={180}>
<SmartSelect options={supplierOpts} value={filter.partner_objid ?? ""}
onValueChange={(v) => setFilter({ ...filter, partner_objid: v })} />
</CompactFilterField>
<CompactFilterField label="메일발송" width={120}>
<SmartSelect options={MAIL_SEND_OPTS} value={filter.mail_send_yn ?? ""}
onValueChange={(v) => setFilter({ ...filter, mail_send_yn: v })} />
</CompactFilterField>
<CompactFilterField label="작성자" width={150}>
<SmartSelect options={userOpts} value={filter.writer ?? ""}
onValueChange={(v) => setFilter({ ...filter, writer: v })} />
</CompactFilterField>
<CompactFilterField label="제품구분" width={130}>
<SmartSelect options={productOpts} value={filter.product_cd ?? ""}
onValueChange={(v) => setFilter({ ...filter, product_cd: v })} />
</CompactFilterField>
</CompactFilterBar>
<DataGrid
columns={GRID_COLUMNS}
data={gridRows}
loading={loading}
showCheckbox
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"}
gridId="purchase-quote-request"
pageSizeOptions={[10, 15, 20, 50, 100]}
paginationStyle="range"
serverPaging
serverPage={filter.page ?? 1}
serverPageSize={filter.page_size ?? 50}
serverTotalItems={total}
onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }}
onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }}
showColumnSettings
summaryStats={summary}
onRefresh={() => fetchList()}
onDownload={() => {
if (gridRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
const exportRows = gridRows.map((r: any) => {
const out: Record<string, any> = {};
GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; });
return out;
});
exportToExcel(exportRows, "견적요청서관리.xlsx", "견적요청서");
}}
showChart
/>
</div>
);
}
@@ -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<AssignableEbomRow[]>([]);
const [loading, setLoading] = useState(false);
const [selectedObjid, setSelectedObjid] = useState<string | null>(null);
const [assigning, setAssigning] = useState(false);
const search = (override?: Partial<typeof filter>) => {
const f = { ...filter, ...override };
setLoading(true);
mbomApi.searchAssignableEboms(f)
.then(setRows)
.catch((e: any) => toast.error(e?.response?.data?.message ?? e?.message ?? "E-BOM 조회 실패"))
.finally(() => setLoading(false));
};
useEffect(() => {
if (!open) {
setRows([]); 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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[1100px] w-[95vw] max-h-[85vh] flex flex-col p-0 overflow-hidden">
<DialogHeader className="bg-blue-600 px-4 py-3">
<DialogTitle className="text-white flex items-center gap-2">
<LinkIcon className="w-4 h-4" />
BOM E-BOM
{currentSourceEbomObjid && (
<span className="text-xs font-normal opacity-90">· : {currentSourceEbomObjid}</span>
)}
</DialogTitle>
</DialogHeader>
{/* 검색 */}
<div className="flex items-center gap-2 border-b bg-muted/30 px-4 py-2">
<span className="text-xs text-muted-foreground"></span>
<Input
className="h-7 text-xs w-[150px]"
value={filter.search_part_no}
onChange={(e) => setFilter({ ...filter, search_part_no: e.target.value })}
onKeyDown={(e) => { if (e.key === "Enter") search(); }}
/>
<span className="text-xs text-muted-foreground"></span>
<Input
className="h-7 text-xs w-[180px]"
value={filter.search_part_name}
onChange={(e) => setFilter({ ...filter, search_part_name: e.target.value })}
onKeyDown={(e) => { if (e.key === "Enter") search(); }}
/>
<span className="text-xs text-muted-foreground"></span>
<Input
className="h-7 text-xs w-[120px]"
value={filter.search_material}
onChange={(e) => setFilter({ ...filter, search_material: e.target.value })}
onKeyDown={(e) => { if (e.key === "Enter") search(); }}
/>
<Button size="sm" onClick={() => search()} disabled={loading}>
{loading ? <Loader2 className="w-3 h-3 mr-1 animate-spin" /> : <Search className="w-3 h-3 mr-1" />}
</Button>
<div className="ml-auto text-xs text-muted-foreground">
{rows.length.toLocaleString()} ( 100)
</div>
</div>
{/* 그리드 */}
<div className="flex-1 min-h-0 overflow-auto">
<table className="text-xs border-collapse w-full">
<thead className="bg-yellow-100 dark:bg-yellow-900/30 sticky top-0">
<tr>
<th className="border px-2 py-1.5 w-[40px] text-center"></th>
<th className="border px-2 py-1.5 w-[90px] text-center"></th>
<th className="border px-2 py-1.5 w-[150px] text-left"></th>
<th className="border px-2 py-1.5 text-left"></th>
<th className="border px-2 py-1.5 w-[100px] text-left"></th>
<th className="border px-2 py-1.5 w-[100px] text-left"></th>
<th className="border px-2 py-1.5 w-[80px] text-center"></th>
<th className="border px-2 py-1.5 w-[100px] text-center"></th>
<th className="border px-2 py-1.5 w-[80px] text-center"></th>
</tr>
</thead>
<tbody>
{loading && rows.length === 0 ? (
<tr><td colSpan={9} className="py-12 text-center"><Loader2 className="inline w-5 h-5 animate-spin" /></td></tr>
) : rows.length === 0 ? (
<tr><td colSpan={9} className="py-8 text-center text-muted-foreground"> E-BOM .</td></tr>
) : rows.map(r => {
const isSel = selectedObjid === r.objid;
const isCurrent = currentSourceEbomObjid === r.objid;
return (
<tr key={r.objid}
className={cn("cursor-pointer hover:bg-muted/30",
isSel && "bg-blue-100 dark:bg-blue-950/40",
!isSel && isCurrent && "bg-emerald-50 dark:bg-emerald-950/20")}
onClick={() => setSelectedObjid(r.objid)}>
<td className="border px-2 py-1 text-center">
<input type="radio" checked={isSel} onChange={() => setSelectedObjid(r.objid)} />
</td>
<td className="border px-2 py-1 text-center">{r.product_name ?? ""}</td>
<td className="border px-2 py-1 whitespace-nowrap">{r.part_no}
{isCurrent && <span className="ml-1 text-[10px] text-emerald-600">()</span>}
</td>
<td className="border px-2 py-1">{r.part_name}</td>
<td className="border px-2 py-1">{r.material}</td>
<td className="border px-2 py-1">{r.supplier}</td>
<td className="border px-2 py-1 text-center">{r.revision ?? ""}</td>
<td className="border px-2 py-1 text-center tabular-nums">{r.reg_date ?? ""}</td>
<td className="border px-2 py-1 text-center">{r.writer_name ?? ""}</td>
</tr>
);
})}
</tbody>
</table>
</div>
<DialogFooter className="border-t bg-muted/20 px-4 py-3 sm:justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}></Button>
<Button onClick={handleAssign} disabled={!selectedObjid || assigning}>
{assigning ? <Loader2 className="w-3 h-3 mr-1 animate-spin" /> : <LinkIcon className="w-3 h-3 mr-1" />}
{currentSourceEbomObjid ? "할당 변경" : "할당"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -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<Set<string>>(new Set());
const loadTree = (objid: string) => {
@@ -313,6 +315,12 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid, onSaved }:
<History className="w-3 h-3 mr-1" />
</Button>
)}
{!editMode && (
<Button size="sm" variant="outline" onClick={() => setAssignOpen(true)} disabled={loading || !projectObjid}>
<LinkIcon className="w-3 h-3 mr-1" />
{bomDataType === "NONE" ? "BOM 할당" : "할당 변경"}
</Button>
)}
{canEdit && !editMode && (
<Button size="sm" variant="outline" onClick={handleEditToggle} disabled={loading}>
<Pencil className="w-3 h-3 mr-1" />
@@ -459,6 +467,21 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid, onSaved }:
projectObjid={projectObjid}
/>
<MbomAssignDialog
open={assignOpen}
onOpenChange={setAssignOpen}
projectObjid={projectObjid}
currentSourceEbomObjid={
(detail as any)?.source_bom_type === "EBOM"
? (detail as any)?.source_ebom_objid
: null
}
onAssigned={() => {
if (projectObjid) void loadTree(projectObjid);
onSaved?.();
}}
/>
<MbomAddPartDialog
open={addPartOpen}
onOpenChange={setAddPartOpen}
+66
View File
@@ -215,6 +215,21 @@ export interface MbomSaveResult {
deleted: number;
}
// ─── 구매리스트 생성 (PR-B3) ────────────────────────────────
// 운영판 createPurchaseListFromMBom.do 1:1. SALES_REQUEST_MASTER 단건 생성.
// 동일 mbom_header_objid 로 이미 생성된 SRM 있으면 400 에러.
export interface CreateSalesRequestPayload {
mbom_header_objid: string;
project_mgmt_objid: string;
}
export interface CreateSalesRequestResult {
objid: string;
request_mng_no: string;
mbom_header_objid: string;
}
export const mbomApi = {
async list(filter: MbomListFilter = {}): Promise<MbomListResponse> {
const res = await apiClient.get("/production/mbom/list", { params: filter });
@@ -236,8 +251,59 @@ export const mbomApi = {
const res = await apiClient.get(`/production/mbom/history/${encodeURIComponent(projectObjid)}`);
return (res.data?.data ?? []) as MbomHistoryRow[];
},
async createSalesRequest(payload: CreateSalesRequestPayload): Promise<CreateSalesRequestResult> {
const res = await apiClient.post("/production/mbom/sales-request", payload);
return res.data?.data as CreateSalesRequestResult;
},
async searchAssignableEboms(filter: AssignableEbomFilter = {}): Promise<AssignableEbomRow[]> {
const res = await apiClient.get("/production/mbom/assignable-eboms", { params: filter });
return (res.data?.data ?? []) as AssignableEbomRow[];
},
async assignBom(payload: AssignBomPayload): Promise<AssignBomResult> {
const res = await apiClient.post("/production/mbom/assign", payload);
return res.data?.data as AssignBomResult;
},
};
// ─── BOM 할당 (PR-B5) ───────────────────────────────────────
// 운영판 mBomEbomSelectPopup.do + saveBomAssignment.do 1:1.
// project_mgmt.source_bom_type='EBOM' + source_ebom_objid 만 우선 지원.
export interface AssignableEbomFilter {
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 interface AssignBomPayload {
project_obj_id: string;
source_bom_type: "EBOM" | "MBOM";
source_bom_obj_id: string;
}
export interface AssignBomResult {
success: boolean;
source_bom_type: string;
source_obj_id: string;
}
// ─── 변경이력 (PR-B4) ───────────────────────────────────────
export interface MbomHistoryRow {
+166
View File
@@ -0,0 +1,166 @@
import { apiClient } from "./client";
// ============================================================
// 생산관리 4개 메뉴 — wace productionplanning.xml 1:1
// 1) 생산계획&실적관리 (일반) GET /api/production/plan-result/list
// 2) 생산계획&실적관리 (장비) GET /api/production/plan-result/list-equip
// 3) 반제품 소요량 POST /api/production/mbom-requirement/semi
// 4) 원자재 소요량 POST /api/production/mbom-requirement/raw
// ============================================================
export interface ProdPlanResultFilter {
search_project_nos?: string; // multiple — "OBJID,OBJID,..."
search_product_code?: string;
search_category_code?: string;
search_production_type?: string;
search_customer_objid?: string;
search_req_del_date_from?: string;
search_req_del_date_to?: string;
search_part_no?: string;
search_part_name?: string;
search_serial_no?: string;
search_writer?: string;
search_regdate_from?: string;
search_regdate_to?: string;
page?: number;
page_size?: number;
}
export interface ProdPlanResultRow {
objid: string;
project_no: string | null;
product: string | null;
product_name: string | null;
category_code: string | null;
category_code_name: string | null;
production_type: string | null;
production_type_name: string | null;
customer_objid: string | null;
customer_name: string | null;
req_del_date: string | null;
customer_request: string | null;
part_no: string | null;
part_name: string | null;
serial_no: string | null;
serial_no_list: string | null;
quantity: number | string | null;
extra_prod_qty: number | string | null;
total_prod_qty: number | string | null;
assembly_qty: number | string | null;
inspection_qty: number | string | null;
ship_wait_qty: number | string | null;
prod_plan_objid: string | null;
writer: string | null;
writer_name: string | null;
regdate_title: string | null;
}
export interface ProdPlanResultEquipRow {
objid: string;
project_no: string | null;
product: string | null;
product_name: string | null;
category_code: string | null;
category_code_name: string | null;
production_type: string | null;
production_type_name: string | null;
customer_objid: string | null;
customer_name: string | null;
req_del_date: string | null;
customer_request: string | null;
part_no: string | null;
part_name: string | null;
serial_no: string | null;
prod_wbs_cnt: number | null;
prod_progress_rate: number | string | null;
delv_wbs_cnt: number | null;
delv_progress_rate: number | string | null;
}
export interface ProdPlanResultListResponse<T> {
rows: T[];
totalCount: number;
page: number;
pageSize: number;
}
export interface ProjectOption {
code: string;
label: string;
}
export interface WriterOption {
code: string;
label: string;
}
export const prodPlanResultApi = {
async list(filter: ProdPlanResultFilter = {}): Promise<ProdPlanResultListResponse<ProdPlanResultRow>> {
const res = await apiClient.get("/production/plan-result/list", { params: filter });
return res.data?.data as ProdPlanResultListResponse<ProdPlanResultRow>;
},
async listEquip(filter: ProdPlanResultFilter = {}): Promise<ProdPlanResultListResponse<ProdPlanResultEquipRow>> {
const res = await apiClient.get("/production/plan-result/list-equip", { params: filter });
return res.data?.data as ProdPlanResultListResponse<ProdPlanResultEquipRow>;
},
async getProjectOptions(): Promise<ProjectOption[]> {
const res = await apiClient.get("/production/plan-result/project-options");
return (res.data?.data ?? []) as ProjectOption[];
},
async getWriterOptions(): Promise<WriterOption[]> {
const res = await apiClient.get("/production/plan-result/writer-options");
return (res.data?.data ?? []) as WriterOption[];
},
};
// ─── 반제품/원자재 소요량 ───────────────────────────────────
export interface MbomOption {
objid: string;
mbom_no: string;
part_name: string;
}
export interface MbomRequirementInputItem {
mbomObjid: string;
qty: number;
}
export interface SemiRequirementRow {
PART_NO: string;
PART_NAME: string;
CATEGORY_NAME: string;
UNIT: string;
MATERIAL: string;
SPEC: string;
REQUIRED_QTY: number;
}
export interface RawRequirementRow {
PART_NO: string;
PART_NAME: string;
CATEGORY_NAME: string;
UNIT: string;
MATERIAL: string;
SPEC: string;
REQUIRED_QTY: number | string;
RAW_MATERIAL: string;
RAW_MATERIAL_SIZE: string;
MATERIAL_PART_NO: string;
MATERIAL_REQUIRED_QTY: number | string;
}
export const mbomRequirementApi = {
async getOptions(): Promise<MbomOption[]> {
const res = await apiClient.get("/production/mbom-requirement/options");
return (res.data?.data ?? []) as MbomOption[];
},
async getSemi(items: MbomRequirementInputItem[]): Promise<SemiRequirementRow[]> {
const res = await apiClient.post("/production/mbom-requirement/semi", { mbomItems: items });
return (res.data?.data ?? []) as SemiRequirementRow[];
},
async getRaw(items: MbomRequirementInputItem[]): Promise<RawRequirementRow[]> {
const res = await apiClient.post("/production/mbom-requirement/raw", { mbomItems: items });
return (res.data?.data ?? []) as RawRequirementRow[];
},
};
+92
View File
@@ -0,0 +1,92 @@
// ============================================================
// 구매관리 — 7개 메뉴 그리드 API.
// 백엔드: /api/purchase/{menu-path}, /api/purchase/options/{suppliers|users|projects}
// ============================================================
import { apiClient } from "./client";
export interface PurchaseListFilter {
year?: string;
customer_objid?: string;
customer_cd?: string;
project_no?: string;
part_no?: string;
part_name?: string;
part_spec?: string;
partner_objid?: string;
purchase_order_no?: string;
proposal_no?: string;
search_status?: string;
writer?: string;
request_user?: string;
purchase_type?: string;
part_type?: string;
product_cd?: string;
paid_type?: string;
mail_send_yn?: string;
delivery_status?: string;
close_status?: string;
sales_mng_user_id?: string;
regdate_start?: string;
regdate_end?: string;
receipt_date_start?: string;
receipt_date_end?: string;
delivery_start_date?: string;
delivery_end_date?: string;
reg_start_date?: string;
reg_end_date?: string;
page?: number;
page_size?: number;
}
export interface PurchaseListResponse<T = any> {
rows: T[];
totalCount: number;
page: number;
pageSize: number;
}
export interface OptionItem {
code: string;
label: string;
}
async function getList<T = any>(path: string, filter: PurchaseListFilter): Promise<PurchaseListResponse<T>> {
const res = await apiClient.get(`/purchase/${path}`, { params: filter });
return res.data?.data as PurchaseListResponse<T>;
}
export const purchaseApi = {
// 그리드 7종
listPurchaseRequest: (f: PurchaseListFilter = {}) => getList("purchase-request", f),
listQuotationRequest: (f: PurchaseListFilter = {}) => getList("quotation-request", f),
listProposal: (f: PurchaseListFilter = {}) => getList("proposal", f),
listInbound: (f: PurchaseListFilter = {}) => getList("inbound", f),
listInboundByItem: (f: PurchaseListFilter = {}) => getList("inbound-by-item", f),
listInboundByDate: (f: PurchaseListFilter = {}) => getList("inbound-by-date", f),
listProjectStatus: (f: PurchaseListFilter = {}) => getList("project-status", f),
// 공통 옵션
async listSuppliers(): Promise<OptionItem[]> {
const r = await apiClient.get("/purchase/options/suppliers");
return (r.data?.data ?? []) as OptionItem[];
},
async listUsers(): Promise<OptionItem[]> {
const r = await apiClient.get("/purchase/options/users");
return (r.data?.data ?? []) as OptionItem[];
},
async listProjects(): Promise<OptionItem[]> {
const r = await apiClient.get("/purchase/options/projects");
return (r.data?.data ?? []) as OptionItem[];
},
};
/** 년도 옵션 — wace 운영판 동일 (현재년도 ±4) */
export function getYearOptions(): OptionItem[] {
const y = new Date().getFullYear();
const out: OptionItem[] = [];
for (let i = y + 4; i >= y - 4; i--) {
out.push({ code: String(i), label: String(i) });
}
return out;
}