구매관리 발주서관리 통합 + 폼 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:
hjjeong
2026-05-19 11:47:38 +09:00
parent aacbb62ad8
commit 6f73631c7c
8 changed files with 511 additions and 1549 deletions
@@ -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 {
+5 -1
View File
@@ -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")}`;
}
+3 -3
View File
@@ -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
+33 -17
View File
@@ -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) && (
+1 -1
View File
@@ -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[]> {