구매관리 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:
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[];
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user