구매관리 발주서관리 통합 + 폼 GET API + PageHeader 컨벤션
발주서관리 리스트:
- /purchase/order-wace 임시 라우트 → /purchase/order 통합 (기존 vexplor
변형판 대체). order-wace 폴더 삭제.
- 백엔드 라우트 /order-wace → /order-list, 함수 listPurchaseOrderWace →
listPurchaseOrderList, API 클라이언트 listOrderWace → listOrder.
발주서 폼 (general 양식) GET API:
- services/purchaseOrderFormService.ts 신규 (getPurchaseOrderFormInit,
getPurchaseOrderForm). 품의서 자동채움 = salesMng.getProposalPartList
매퍼 1:1 → 발주 그리드 형식 변환. 발주번호 채번 RPS{YY}-{MMDD}-{NN}.
- 컨트롤러/라우트: GET /api/purchase/order-form/init?proposal_objid=...
+ /api/purchase/order-form/:objid.
- RPS는 OBJID가 varchar라 wace numeric 캐스트 모두 제거.
PageHeader 컨벤션 일괄 변경:
- 자동매칭이 매칭된 menu의 parent_obj_id로 부모를 찾아
"{부모}_{자식}" 형식 표기 (wace 컨벤션). 부모가 루트 그룹이면 자식만.
- description prop과 렌더링 완전 제거 (사용처 없음 확인).
- 모든 메뉴 페이지에 일괄 적용.
DB(별도): menu_info 9857401373575 + rel_menu_auth 3건 제거.
저장/삭제 API + 프론트 다이얼로그는 다음 세션.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import * as svc from "../services/purchaseService";
|
||||
import * as formSvc from "../services/purchaseOrderFormService";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
function parseFilter(q: Record<string, any>): svc.PurchaseListFilter {
|
||||
@@ -36,7 +37,41 @@ export const getInbound = (req: AuthenticatedRequest, res: Response)
|
||||
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 const getPurchaseOrderWace = (req: AuthenticatedRequest, res: Response) => runList(svc.listPurchaseOrderWace, req, res, "발주서관리");
|
||||
export const getPurchaseOrderList = (req: AuthenticatedRequest, res: Response) => runList(svc.listPurchaseOrderList, req, res, "발주서관리");
|
||||
|
||||
// ─── 발주서 폼 (general 양식) ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/purchase/order-form/init?proposal_objid=...
|
||||
* 품의서에서 발주서 등록 폼 데이터 자동 채움.
|
||||
*/
|
||||
export async function getPurchaseOrderFormInit(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const proposalObjid = String(req.query.proposal_objid ?? "").trim();
|
||||
const data = await formSvc.getPurchaseOrderFormInit(proposalObjid);
|
||||
return res.json({ success: true, data });
|
||||
} catch (e: any) {
|
||||
logger.error("발주서 폼 초기화 실패", { error: e.message });
|
||||
return res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/purchase/order-form/:objid
|
||||
* 발주서 마스터 + 파트 조회 (수정/조회 모드).
|
||||
*/
|
||||
export async function getPurchaseOrderForm(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const objid = String(req.params.objid ?? "").trim();
|
||||
if (!objid) return res.status(400).json({ success: false, message: "objid required" });
|
||||
const data = await formSvc.getPurchaseOrderForm(objid);
|
||||
if (!data) return res.status(404).json({ success: false, message: "발주서를 찾을 수 없어요" });
|
||||
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 getSuppliers(_req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
|
||||
@@ -18,7 +18,11 @@ 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("/order-wace", ctrl.getPurchaseOrderWace); // 발주서관리 (wace 1:1, 기존 /order 보존)
|
||||
router.get("/order-list", ctrl.getPurchaseOrderList); // 발주서관리 (wace purchaseOrderMasterList_new 1:1)
|
||||
|
||||
// 발주서 폼 (general 양식, wace purchaseOrderFormPopup_general.do 1:1)
|
||||
router.get("/order-form/init", ctrl.getPurchaseOrderFormInit); // 품의서에서 자동 채움
|
||||
router.get("/order-form/:objid", ctrl.getPurchaseOrderForm); // 수정/조회
|
||||
|
||||
// 공통 옵션
|
||||
router.get("/options/suppliers", ctrl.getSuppliers);
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
// ============================================================
|
||||
// 발주서관리 — 등록/수정 폼 (general 양식) 서비스
|
||||
//
|
||||
// wace_plm 1:1 이식 베이스:
|
||||
// - controller: purchaseOrder/purchaseOrderFormPopup_general.do
|
||||
// purchaseOrder/purchaseOrderFormPopup_generalSave.do
|
||||
// - service: PurchaseOrderService.savePurchaseOrder_new (1472-1817)
|
||||
// - mapper: purchaseOrder.xml mergePurchaseOrderMaster (530-714) +
|
||||
// mergePurchaseOrderPartInfo (1205-1325) +
|
||||
// getPurchaseOrderMasterInfo (1343-1556) +
|
||||
// getPURCHASE_ORDER_PART
|
||||
// salesMng.xml getProposalPartList (5012-5125) +
|
||||
// getProposalInfo (4919-)
|
||||
//
|
||||
// 운영 핵심 흐름: 품의서(/purchase/proposal)에서 "발주서생성" → general 다이얼로그 →
|
||||
// 품의서 품목 자동 채움 → 마스터 입력 → 저장.
|
||||
//
|
||||
// 본 모듈은 form-init / form-get 두 GET 엔드포인트를 제공.
|
||||
// (save / delete 는 다음 단계)
|
||||
// ============================================================
|
||||
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export interface OrderFormInitResult {
|
||||
master: Record<string, any>;
|
||||
parts: Record<string, any>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/purchase/order-form/init?proposal_objid=...
|
||||
*
|
||||
* 품의서(sales_request_master)에서 발주서 등록 폼을 채울 데이터를 반환.
|
||||
* wace controller `purchaseOrderFormPopup_general.do` 의 신규 등록 분기와 1:1.
|
||||
*
|
||||
* master 기본값:
|
||||
* - PROPOSAL_OBJID / SALES_REQUEST_OBJID = proposal_objid
|
||||
* - CONTRACT_MGMT_OBJID = proposal.PROJECT_NO
|
||||
* - PURCHASE_ORDER_NO = "RPS{YY}-{MMDD}-{NN}" (NN: 당일 발주 카운트+1)
|
||||
* - PURCHASE_DATE / ORDER_DATE = 오늘
|
||||
* - STATUS = "create" / FORM_TYPE = "general"
|
||||
* parts: salesMng.getProposalPartList SQL 1:1 → 발주서 그리드 형식 변환
|
||||
* (ORDER_QTY=QTY, PARTNER_PRICE=UNIT_PRICE, SUPPLY_UNIT_PRICE=QTY*UNIT_PRICE 등)
|
||||
*/
|
||||
export async function getPurchaseOrderFormInit(proposalObjid: string): Promise<OrderFormInitResult> {
|
||||
const pool = getPool();
|
||||
|
||||
// 1) 품의서 마스터 정보 (PROJECT_NO 등)
|
||||
let proposal: Record<string, any> | null = null;
|
||||
if (proposalObjid) {
|
||||
try {
|
||||
const r = await pool.query(
|
||||
`SELECT OBJID, PROJECT_NO, MBOM_HEADER_OBJID, TITLE, REQUEST_USER_ID, PURCHASE_TYPE
|
||||
FROM SALES_REQUEST_MASTER WHERE OBJID = $1`,
|
||||
[proposalObjid],
|
||||
);
|
||||
proposal = r.rows[0] ?? null;
|
||||
} catch (e: any) {
|
||||
logger.warn("getProposalInfo 실패", { error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 발주번호 채번 (wace mergePurchaseOrderMaster INSERT 절 1:1)
|
||||
let purchaseOrderNo = "";
|
||||
try {
|
||||
const r = await pool.query(
|
||||
`SELECT 'RPS' || TO_CHAR(NOW(),'YY') || '-' || TO_CHAR(NOW(),'MMDD') || '-' ||
|
||||
LPAD((COALESCE(MAX(CASE WHEN PURCHASE_ORDER_NO LIKE 'RPS' || TO_CHAR(NOW(),'YY-MMDD') || '-%'
|
||||
THEN SPLIT_PART(PURCHASE_ORDER_NO, '-', 3) ELSE '0' END)::INTEGER, 0) + 1)::TEXT, 2, '0')
|
||||
AS po_no
|
||||
FROM PURCHASE_ORDER_MASTER`,
|
||||
);
|
||||
purchaseOrderNo = r.rows[0]?.po_no ?? "";
|
||||
} catch (e: any) {
|
||||
logger.warn("발주번호 채번 실패", { error: e.message });
|
||||
}
|
||||
|
||||
const todayIso = new Date().toISOString().slice(0, 10);
|
||||
const master: Record<string, any> = {
|
||||
objid: "", // 신규 — 클라이언트가 채워 보내거나 save 시 채번
|
||||
purchase_order_no: purchaseOrderNo,
|
||||
purchase_date: todayIso, // 발주일
|
||||
order_date_kor: formatKorDate(new Date()), // 발주일자 한글 표기 (wace ORDER_DATE)
|
||||
status: "create",
|
||||
form_type: "general",
|
||||
sales_request_objid: proposalObjid || "",
|
||||
proposal_objid: proposalObjid || "",
|
||||
contract_mgmt_objid: proposal?.project_no ?? "",
|
||||
title: proposal?.title ?? "",
|
||||
// wace controller _general 기본 담당자 (RPS 운영 고정값)
|
||||
manager_name: "안동윤",
|
||||
manager_position: "팀장",
|
||||
manager_phone: "010-2313-2702",
|
||||
manager_email: "ady1225@rps-korea.com",
|
||||
manager_name2: "서동민",
|
||||
manager_position2: "주임",
|
||||
manager_phone2: "010-9538-9513",
|
||||
manager_email2: "sdm0927@rps-korea.com",
|
||||
};
|
||||
|
||||
// 3) 품의서 품목 → 발주서 파트 변환 (salesMng.getProposalPartList 1:1)
|
||||
const parts: Record<string, any>[] = [];
|
||||
if (proposalObjid) {
|
||||
try {
|
||||
const r = await pool.query(
|
||||
`SELECT
|
||||
ROW_NUMBER() OVER(ORDER BY SRP.REGDATE) AS rnum,
|
||||
SRP.OBJID AS srp_objid,
|
||||
SRP.PART_OBJID AS part_objid,
|
||||
PM.PART_NO AS part_no,
|
||||
PM.PART_NAME AS part_name,
|
||||
PM.SPEC AS spec,
|
||||
PM.MATERIAL AS material,
|
||||
COALESCE(NULLIF(SRP.UNIT, ''), PM.UNIT_DC) AS unit,
|
||||
COALESCE(
|
||||
(SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = NULLIF(SRP.UNIT, '')),
|
||||
(SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = PM.UNIT_DC)
|
||||
) AS unit_title,
|
||||
SRP.QTY AS qty,
|
||||
COALESCE(SRP.UNIT_PRICE, 0) AS unit_price,
|
||||
CASE WHEN COALESCE(SRP.TOTAL_PRICE::NUMERIC, 0) > 0
|
||||
THEN SRP.TOTAL_PRICE::NUMERIC
|
||||
ELSE COALESCE(SRP.QTY::NUMERIC, 0) * COALESCE(SRP.UNIT_PRICE::NUMERIC, 0)
|
||||
END AS total_price,
|
||||
SRP.VENDOR_PM AS vendor_pm,
|
||||
(SELECT CLIENT_NM FROM CLIENT_MNG
|
||||
WHERE OBJID::VARCHAR = SRP.VENDOR_PM) AS vendor_name,
|
||||
SRP.REMARK AS remark,
|
||||
SRP.DELIVERY_REQUEST_DATE AS delivery_request_date,
|
||||
COALESCE(SRP.MATERIAL_YN, 'N') AS material_yn,
|
||||
(SELECT PJ.PART_NAME FROM PROJECT_MGMT PJ
|
||||
WHERE PJ.OBJID::VARCHAR = SRM.PROJECT_NO) AS project_product_name,
|
||||
PM.PART_NAME AS component_part_name,
|
||||
SRP.CURRENCY AS currency,
|
||||
(SELECT CC.CODE_NAME FROM COMM_CODE CC
|
||||
WHERE CC.CODE_ID = NULLIF(SRP.CURRENCY, '')) AS currency_name
|
||||
FROM SALES_REQUEST_PART SRP
|
||||
LEFT JOIN PART_MNG PM
|
||||
ON SRP.PART_OBJID::VARCHAR = PM.OBJID::VARCHAR
|
||||
LEFT JOIN SALES_REQUEST_MASTER SRM
|
||||
ON SRP.SALES_REQUEST_MASTER_OBJID = SRM.OBJID
|
||||
WHERE SRP.SALES_REQUEST_MASTER_OBJID = $1
|
||||
ORDER BY SRP.REGDATE`,
|
||||
[proposalObjid],
|
||||
);
|
||||
|
||||
for (const row of r.rows) {
|
||||
const qty = toNum(row.qty);
|
||||
const unitPrice = toNum(row.unit_price);
|
||||
const projectName = (row.project_product_name ?? "").toString().trim();
|
||||
const componentName = (row.component_part_name ?? "").toString().trim();
|
||||
const remark = projectName && componentName ? `${projectName} / ${componentName}`
|
||||
: projectName || componentName || (row.remark ?? "");
|
||||
|
||||
parts.push({
|
||||
// 발주서 그리드 형식 (wace controller _general 변환 1:1)
|
||||
objid: "", // 신규 row
|
||||
part_objid: row.part_objid ?? "",
|
||||
row_num: row.rnum,
|
||||
part_no: row.part_no ?? "",
|
||||
part_name: row.part_name ?? "",
|
||||
spec: row.spec ?? "",
|
||||
material: row.material ?? "",
|
||||
order_qty: qty,
|
||||
unit: row.unit || "0001400", // wace 기본값 EA
|
||||
unit_title: row.unit_title ?? "",
|
||||
part_delivery_place: "RPS",
|
||||
partner_price: unitPrice,
|
||||
supply_unit_price: qty * unitPrice,
|
||||
remark,
|
||||
delivery_request_date: row.delivery_request_date ?? "",
|
||||
currency: row.currency ?? "",
|
||||
currency_name: row.currency_name ?? "",
|
||||
// 추적용 (저장 시 신규 row 임을 구분)
|
||||
_src: "proposal",
|
||||
_src_objid: row.srp_objid,
|
||||
});
|
||||
}
|
||||
} catch (e: any) {
|
||||
logger.error("getProposalPartList 실패", { error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
return { master, parts };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/purchase/order-form/:objid
|
||||
*
|
||||
* 발주서 마스터 + 파트 조회 (수정/조회 모드).
|
||||
* wace `getPurchaseOrderMasterInfo` (1343-1556) + `getPURCHASE_ORDER_PART` 의 RPS 압축판.
|
||||
*/
|
||||
export async function getPurchaseOrderForm(objid: string): Promise<OrderFormInitResult | null> {
|
||||
const pool = getPool();
|
||||
try {
|
||||
const m = await pool.query(
|
||||
`SELECT POM.*,
|
||||
(SELECT CLIENT_NM FROM CLIENT_MNG
|
||||
WHERE OBJID::VARCHAR = POM.PARTNER_OBJID) AS partner_name,
|
||||
(SELECT USER_NAME FROM USER_INFO
|
||||
WHERE USER_ID = POM.SALES_MNG_USER_ID) AS sales_mng_user_name,
|
||||
(SELECT USER_NAME FROM USER_INFO
|
||||
WHERE USER_ID = POM.WRITER) AS writer_name,
|
||||
CM.PROJECT_NO AS project_no,
|
||||
SRM.REQUEST_MNG_NO AS proposal_no
|
||||
FROM PURCHASE_ORDER_MASTER POM
|
||||
LEFT JOIN PROJECT_MGMT CM ON POM.CONTRACT_MGMT_OBJID = CM.OBJID
|
||||
LEFT JOIN SALES_REQUEST_MASTER SRM ON POM.SALES_REQUEST_OBJID = SRM.OBJID
|
||||
WHERE POM.OBJID = $1`,
|
||||
[objid],
|
||||
);
|
||||
if (m.rows.length === 0) return null;
|
||||
|
||||
const p = await pool.query(
|
||||
`SELECT POP.*,
|
||||
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = POP.UNIT) AS unit_title,
|
||||
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = POP.CURRENCY) AS currency_name
|
||||
FROM PURCHASE_ORDER_PART POP
|
||||
WHERE POP.PURCHASE_ORDER_MASTER_OBJID = $1
|
||||
ORDER BY POP.REGDATE`,
|
||||
[objid],
|
||||
);
|
||||
|
||||
return { master: m.rows[0], parts: p.rows };
|
||||
} catch (e: any) {
|
||||
logger.error("getPurchaseOrderForm 실패", { error: e.message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function toNum(v: any): number {
|
||||
if (v == null || v === "") return 0;
|
||||
const s = String(v).replace(/,/g, "");
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function formatKorDate(d: Date): string {
|
||||
return `${d.getFullYear()}년 ${String(d.getMonth() + 1).padStart(2, "0")}월 ${String(d.getDate()).padStart(2, "0")}일`;
|
||||
}
|
||||
@@ -873,13 +873,13 @@ export async function listProjectStatus(filter: PurchaseListFilter): Promise<Lis
|
||||
//
|
||||
// 매퍼 본문: wace_plm/src/com/pms/mapper/purchaseOrder.xml:3295-3589
|
||||
// 화면: wace_plm/.../purchaseOrder/purchaseOrderList_new.jsp (1085 lines)
|
||||
// 라우트: /api/purchase-wace/order-list (기존 /purchase/order page.tsx 보존 + 새 라우트)
|
||||
// 라우트: GET /api/purchase/order-list
|
||||
//
|
||||
// 컬럼: 품의서No · 발주서No · 프로젝트번호 · 구매유형 · 주문유형 · 제품구분 ·
|
||||
// 품번 · 품명 · 공급업체 · 환종 · 총액 · 메일발송 · 발주일 · 구매담당자 · 작성일
|
||||
// 검색: 년도/고객사/프로젝트(CSV)/발주No/공급업체/품번/품명/입고요청일/발주일/
|
||||
// 주문유형/제품구분/구매유형/구매담당자/메일발송
|
||||
export async function listPurchaseOrderWace(filter: PurchaseListFilter): Promise<ListResult<any>> {
|
||||
export async function listPurchaseOrderList(filter: PurchaseListFilter): Promise<ListResult<any>> {
|
||||
const pool = getPool();
|
||||
const { limit, offset, page, pageSize } = clampPaging(filter);
|
||||
|
||||
@@ -1054,7 +1054,7 @@ export async function listPurchaseOrderWace(filter: PurchaseListFilter): Promise
|
||||
]);
|
||||
return { rows: d.rows, totalCount: c.rows[0]?.cnt ?? 0, page, pageSize };
|
||||
} catch (e: any) {
|
||||
logger.error("listPurchaseOrderWace 실패", { error: e.message });
|
||||
logger.error("listPurchaseOrderList 실패", { error: e.message });
|
||||
return { rows: [], totalCount: 0, page, pageSize };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// 구매관리 > 발주서관리 — wace_plm purchaseOrder/purchaseOrderList_new.jsp 1:1 이식
|
||||
// 매퍼: wace_plm/src/com/pms/mapper/purchaseOrder.xml purchaseOrderMasterList_new
|
||||
// 검색: 년도/고객사/프로젝트번호/발주No/공급업체/품번/품명 (1행) +
|
||||
// 입고요청일/발주일/주문유형/제품구분/구매유형/구매담당자/메일발송 (2행)
|
||||
// 그리드: 품의서No / 발주서No / 프로젝트번호 / 구매유형 / 주문유형 / 제품구분 /
|
||||
// 품번 / 품명 / 공급업체 / 환종 / 총액 / 메일발송 / 발주일 / 구매담당자 / 작성일
|
||||
// 액션: 조회만 (이번 세션 — 등록/수정/메일/인쇄는 후속)
|
||||
// 기존 /purchase/order(page.tsx)는 보존, 신규 라우트로 분리
|
||||
|
||||
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 { apiClient } from "@/lib/api/client";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
|
||||
const MAIL_SEND_OPTS: SmartSelectOption[] = [
|
||||
{ code: "Y", label: "발송완료" },
|
||||
{ code: "N", label: "미발송" },
|
||||
{ code: "orderCancel", label: "발주취소" },
|
||||
];
|
||||
|
||||
const EMPTY_FILTER: PurchaseListFilter = {
|
||||
year: String(new Date().getFullYear()),
|
||||
customer_cd: "", project_no: "", purchase_order_no: "",
|
||||
partner_objid: "", part_no: "", part_name: "",
|
||||
delivery_start_date: "", delivery_end_date: "",
|
||||
reg_start_date: "", reg_end_date: "",
|
||||
category_cd: "", product_cd: "", purchase_type: "", writer: "",
|
||||
mail_send_yn: "",
|
||||
page: 1, page_size: 50,
|
||||
};
|
||||
|
||||
// wace 코드그룹 ID (PurchaseOrderController.matermgmtList 기준)
|
||||
const CODE_GROUP = {
|
||||
CATEGORY: "0000167", // 주문유형
|
||||
PRODUCT: "0000001", // 제품구분
|
||||
PURCHASE: "0001814", // 구매유형
|
||||
} as const;
|
||||
|
||||
async function loadCodes(groupId: string): Promise<OptionItem[]> {
|
||||
try {
|
||||
const r = await apiClient.get(`/sales/codes/${groupId}`);
|
||||
return (r.data?.data ?? []) as OptionItem[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default function PurchaseOrderWacePage() {
|
||||
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 [vendorOpts, setVendorOpts] = useState<OptionItem[]>([]);
|
||||
const [userOpts, setUserOpts] = useState<OptionItem[]>([]);
|
||||
const [categoryOpts, setCategoryOpts] = useState<OptionItem[]>([]);
|
||||
const [productOpts, setProductOpts] = useState<OptionItem[]>([]);
|
||||
const [purchaseOpts, setPurchaseOpts] = useState<OptionItem[]>([]);
|
||||
|
||||
const yearOpts = useMemo(() => getYearOptions(), []);
|
||||
|
||||
const fetchList = useCallback(async (override?: Partial<PurchaseListFilter>) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const f = { ...filter, ...override };
|
||||
const res = await purchaseApi.listOrderWace(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 [v, u, cat, prod, pur] = await Promise.all([
|
||||
purchaseApi.listVendors(),
|
||||
purchaseApi.listUsers(),
|
||||
loadCodes(CODE_GROUP.CATEGORY),
|
||||
loadCodes(CODE_GROUP.PRODUCT),
|
||||
loadCodes(CODE_GROUP.PURCHASE),
|
||||
]);
|
||||
if (dead) return;
|
||||
setVendorOpts(v);
|
||||
setUserOpts(u);
|
||||
setCategoryOpts(cat);
|
||||
setProductOpts(prod);
|
||||
setPurchaseOpts(pur);
|
||||
} 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 ?? `r_${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-[140px]", align: "center" },
|
||||
{ key: "purchase_type_name", label: "구매유형", width: "w-[110px]", align: "center" },
|
||||
{ key: "category_name", label: "주문유형", width: "w-[110px]", align: "center" },
|
||||
{ key: "product_name", label: "제품구분", width: "w-[110px]", align: "center" },
|
||||
{ key: "part_no", label: "품번", width: "w-[170px]" },
|
||||
{ key: "part_name", label: "품명", minWidth: "min-w-[280px]" },
|
||||
{ key: "partner_name", label: "공급업체", minWidth: "min-w-[170px]" },
|
||||
{ key: "currency_name", label: "환종", width: "w-[80px]", align: "center" },
|
||||
{ key: "total_supply_price", label: "총액", width: "w-[140px]", align: "right", formatMoney: true },
|
||||
{ key: "mail_send_yn", label: "메일발송", width: "w-[100px]", align: "center" },
|
||||
{ key: "mail_send_date", label: "발주일", width: "w-[115px]", align: "center" },
|
||||
{ key: "writer_name", label: "구매담당자", width: "w-[110px]", align: "center" },
|
||||
{ key: "regdate", 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} />
|
||||
|
||||
<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={180}>
|
||||
<SmartSelect options={vendorOpts} value={filter.partner_objid ?? ""}
|
||||
onValueChange={(v) => setFilter({ ...filter, partner_objid: v })} />
|
||||
</CompactFilterField>
|
||||
<CompactFilterField label="품번" width={140}>
|
||||
<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.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={130}>
|
||||
<SmartSelect options={categoryOpts} value={filter.category_cd ?? ""}
|
||||
onValueChange={(v) => setFilter({ ...filter, category_cd: v })} />
|
||||
</CompactFilterField>
|
||||
<CompactFilterField label="제품구분" width={130}>
|
||||
<SmartSelect options={productOpts} value={filter.product_cd ?? ""}
|
||||
onValueChange={(v) => setFilter({ ...filter, product_cd: v })} />
|
||||
</CompactFilterField>
|
||||
<CompactFilterField label="구매유형" width={130}>
|
||||
<SmartSelect options={purchaseOpts} 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={MAIL_SEND_OPTS} value={filter.mail_send_yn ?? ""}
|
||||
onValueChange={(v) => setFilter({ ...filter, mail_send_yn: v })} />
|
||||
</CompactFilterField>
|
||||
</CompactFilterBar>
|
||||
|
||||
<DataGrid
|
||||
columns={GRID_COLUMNS}
|
||||
data={gridRows}
|
||||
loading={loading}
|
||||
showCheckbox
|
||||
checkedIds={checkedIds}
|
||||
onCheckedChange={setCheckedIds}
|
||||
emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"}
|
||||
gridId="purchase-order-wace"
|
||||
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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,20 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PageHeader — 페이지 상단 메뉴명 + 설명 + 액션 슬롯.
|
||||
* PageHeader — 페이지 상단 "대메뉴_중메뉴" 제목 + 액션/검색 슬롯.
|
||||
*
|
||||
* customer-cs/cs 페이지 패턴 1:1 추출. 모든 RPS 메뉴 페이지의 상단에 의무 배치.
|
||||
* 모든 RPS 메뉴 페이지의 상단에 의무 배치.
|
||||
*
|
||||
* 자동 매칭 (탭 시스템 대응):
|
||||
* - RPS 는 탭 기반이라 usePathname() 이 /main 으로 고정됨.
|
||||
* - useTabStore 의 활성 탭 adminUrl → /COMPANY_NN prefix 제거 → menu_info.menu_url 매칭.
|
||||
* - useCurrent2ndLevelMenuObjid 와 동일 패턴.
|
||||
* - 매칭된 menu 의 parent_obj_id 로 부모 메뉴를 찾아 "{부모}_{자식}" 으로 표기 (wace 컨벤션).
|
||||
* - 루트 그룹(parent_obj_id 가 0뎁스)이면 자식만 단독 표기.
|
||||
*
|
||||
* 명시 지정:
|
||||
* <PageHeader title="M-BOM 관리" description="생산용 BOM 트리" actions={...} />
|
||||
* <PageHeader title="M-BOM 관리" actions={...} />
|
||||
*
|
||||
* 원칙:
|
||||
* - 모든 page.tsx 의 최상위 자식으로 <PageHeader /> 를 배치한다.
|
||||
* - menu_info 에 등록만 되어 있으면 props 없이도 자동 매칭.
|
||||
* 원칙: menu_info 에 등록만 되어 있으면 props 없이도 자동 매칭.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
@@ -29,7 +28,6 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
interface PageHeaderProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
/** 업무 액션 슬롯 (등록/삭제/상신 등). 검색·초기화는 onSearch/onReset 로 전달. */
|
||||
actions?: React.ReactNode;
|
||||
/** 검색 핸들러. 지정 시 우측에 검색 버튼 자동 렌더. */
|
||||
@@ -47,6 +45,17 @@ function stripCompanyPrefix(p: string): string {
|
||||
return p.replace(/^\/COMPANY_\d+/, "") || "/";
|
||||
}
|
||||
|
||||
function findParentMenu(menus: MenuItem[], menu: MenuItem | null): MenuItem | null {
|
||||
if (!menu) return null;
|
||||
const pid = menu.parent_obj_id ?? menu.PARENT_OBJ_ID;
|
||||
if (!pid) return null;
|
||||
for (const m of menus) {
|
||||
const oid = m.objid ?? m.OBJID;
|
||||
if (oid && String(oid) === String(pid)) return m;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findByUrl(menus: MenuItem[], strippedUrl: string): MenuItem | null {
|
||||
// menu_info.menu_url 이 /COMPANY_16/... 으로 저장되어 있으므로 양쪽 비교
|
||||
for (const m of menus) {
|
||||
@@ -68,7 +77,7 @@ function findByUrl(menus: MenuItem[], strippedUrl: string): MenuItem | null {
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
title, description, actions, onSearch, onReset, loading,
|
||||
title, actions, onSearch, onReset, loading,
|
||||
searchLabel = "검색", resetLabel = "초기화", className,
|
||||
}: PageHeaderProps) {
|
||||
const pathname = usePathname() ?? "";
|
||||
@@ -76,6 +85,7 @@ export function PageHeader({
|
||||
const activeTabId = useTabStore(selectActiveTabId);
|
||||
|
||||
let menu: MenuItem | null = null;
|
||||
let parentMenu: MenuItem | null = null;
|
||||
try {
|
||||
const { userMenus, adminMenus } = useMenu();
|
||||
// RPS 탭 시스템: pathname=/main 이면 활성 탭의 adminUrl 사용
|
||||
@@ -87,25 +97,31 @@ export function PageHeader({
|
||||
targetUrl = stripCompanyPrefix(activeTab.adminUrl);
|
||||
}
|
||||
}
|
||||
const allMenus = [...(userMenus as MenuItem[]), ...(adminMenus as MenuItem[])];
|
||||
menu = findByUrl(userMenus as MenuItem[], targetUrl) ?? findByUrl(adminMenus as MenuItem[], targetUrl);
|
||||
parentMenu = findParentMenu(allMenus, menu);
|
||||
} catch {
|
||||
/* Provider 밖 — 자동 매칭 생략 */
|
||||
}
|
||||
|
||||
const resolvedTitle = title ?? menu?.menu_name_kor ?? "";
|
||||
const resolvedDesc = description ?? menu?.menu_desc ?? "";
|
||||
// wace 컨벤션: "대메뉴_중메뉴" (parent_obj_id 가 루트 그룹이면 단독 표기)
|
||||
const parentName = parentMenu?.menu_name_kor ?? parentMenu?.MENU_NAME_KOR ?? "";
|
||||
const ownName = menu?.menu_name_kor ?? menu?.MENU_NAME_KOR ?? "";
|
||||
const parentParentPid = parentMenu?.parent_obj_id ?? parentMenu?.PARENT_OBJ_ID;
|
||||
// 부모의 부모가 있어야 (즉, 부모가 1뎁스 그룹) "부모_자식" 표기. 부모 없거나 부모가 루트이면 자식만.
|
||||
const autoTitle = parentName && parentParentPid && ownName
|
||||
? `${parentName}_${ownName}`
|
||||
: ownName;
|
||||
const resolvedTitle = title ?? autoTitle;
|
||||
|
||||
const hasSearchButtons = !!(onSearch || onReset);
|
||||
if (!resolvedTitle && !resolvedDesc && !actions && !hasSearchButtons) return null;
|
||||
if (!resolvedTitle && !actions && !hasSearchButtons) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-shrink-0 items-end justify-between gap-3 border-b pb-3", className)}>
|
||||
<div className={cn("flex flex-shrink-0 items-center justify-between gap-3 border-b pb-2", className)}>
|
||||
<div>
|
||||
{resolvedTitle && (
|
||||
<h1 className="text-xl font-bold tracking-tight">{resolvedTitle}</h1>
|
||||
)}
|
||||
{resolvedDesc && (
|
||||
<p className="text-xs text-muted-foreground">{resolvedDesc}</p>
|
||||
<h1 className="text-lg font-bold tracking-tight">{resolvedTitle}</h1>
|
||||
)}
|
||||
</div>
|
||||
{(actions || hasSearchButtons) && (
|
||||
|
||||
@@ -66,7 +66,7 @@ export const purchaseApi = {
|
||||
listInboundByItem: (f: PurchaseListFilter = {}) => getList("inbound-by-item", f),
|
||||
listInboundByDate: (f: PurchaseListFilter = {}) => getList("inbound-by-date", f),
|
||||
listProjectStatus: (f: PurchaseListFilter = {}) => getList("project-status", f),
|
||||
listOrderWace: (f: PurchaseListFilter = {}) => getList("order-wace", f),
|
||||
listOrder: (f: PurchaseListFilter = {}) => getList("order-list", f),
|
||||
|
||||
// 공통 옵션
|
||||
async listSuppliers(): Promise<OptionItem[]> {
|
||||
|
||||
Reference in New Issue
Block a user