영업관리 구매요청서관리·품의서관리 신규 2메뉴 (wace_plm 1:1)
backend
- services/salesPurchaseRequestService.ts: listPurchaseRequestReg(DOC_TYPE='PURCHASE_REG') + listPurchaseRegProposal(DOC_TYPE='PURCHASE_REG_PROPOSAL')
· 구매요청서 상태 CASE: PURCHASE_REG_PROPOSAL 자식 존재 시 '품의서생성' → '확정'/'작성중' (wace 매퍼 1:1)
· 품의서 결재상태: amaranth_approval(target_type='PROPOSAL') LEFT JOIN 우선순위
· sales_request_part 누락 → MBOM_DETAIL+PART_MNG fallback (구매관리 패턴 동일)
- routes/salesPurchaseRequestRoutes.ts + app.ts: /api/sales/purchase-request, /api/sales/purchase-proposal
frontend
- lib/api/salesPurchaseRequest.ts
- sales/purchase-request/page.tsx — 14컬럼, 구매요청서작성/품의서생성 액션 (placeholder 토스트)
- sales/purchase-proposal/page.tsx — 10컬럼, 결재상신 액션 (placeholder 토스트)
- PageHeader+CompactFilterBar+SmartSelect+DataGrid logicstudio 6종 패턴 일관 적용
구매관리>품의서관리 vs 영업관리>품의서관리 차이
- 구매관리: DOC_TYPE in ('PROPOSAL', 'PURCHASE_REG_PROPOSAL'(결재완료만)) → 발주서 생성 풀
- 영업관리: DOC_TYPE='PURCHASE_REG_PROPOSAL' 전용 → 결재상신 화면 (결재완료 시 구매관리로 자동 노출)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -179,6 +179,7 @@ import salesEstimateRoutes from "./routes/salesEstimateRoutes"; // 영업관리>
|
|||||||
import salesOrderMgmtRoutes from "./routes/salesOrderMgmtRoutes"; // 영업관리>주문서 (wace_plm 도메인 이식)
|
import salesOrderMgmtRoutes from "./routes/salesOrderMgmtRoutes"; // 영업관리>주문서 (wace_plm 도메인 이식)
|
||||||
import salesSaleRoutes from "./routes/salesSaleRoutes"; // 영업관리>판매+매출 (wace_plm 도메인)
|
import salesSaleRoutes from "./routes/salesSaleRoutes"; // 영업관리>판매+매출 (wace_plm 도메인)
|
||||||
import salesCommonRoutes from "./routes/salesCommonRoutes"; // 영업관리 4개 메뉴 공통 옵션 (codes/parts/customers)
|
import salesCommonRoutes from "./routes/salesCommonRoutes"; // 영업관리 4개 메뉴 공통 옵션 (codes/parts/customers)
|
||||||
|
import salesPurchaseRequestRoutes from "./routes/salesPurchaseRequestRoutes"; // 영업관리>구매요청서관리·품의서관리 (wace_plm)
|
||||||
import projectMgmtRoutes from "./routes/projectMgmtRoutes"; // 프로젝트관리>진행관리 (wace_plm 도메인 이식)
|
import projectMgmtRoutes from "./routes/projectMgmtRoutes"; // 프로젝트관리>진행관리 (wace_plm 도메인 이식)
|
||||||
import wbsTemplateRoutes from "./routes/wbsTemplateRoutes"; // 프로젝트관리>제품구분_WBS관리 (wace_plm 도메인 이식)
|
import wbsTemplateRoutes from "./routes/wbsTemplateRoutes"; // 프로젝트관리>제품구분_WBS관리 (wace_plm 도메인 이식)
|
||||||
import devPartRoutes from "./routes/devPartRoutes"; // 개발관리>PART 등록/조회 (wace_plm 도메인 이식)
|
import devPartRoutes from "./routes/devPartRoutes"; // 개발관리>PART 등록/조회 (wace_plm 도메인 이식)
|
||||||
@@ -433,6 +434,7 @@ app.use("/api/sales/estimate", salesEstimateRoutes); // 영업관리>견적 (wac
|
|||||||
app.use("/api/sales/order-mgmt", salesOrderMgmtRoutes); // 영업관리>주문서 (wace_plm 도메인)
|
app.use("/api/sales/order-mgmt", salesOrderMgmtRoutes); // 영업관리>주문서 (wace_plm 도메인)
|
||||||
app.use("/api/sales", salesSaleRoutes); // 영업관리>판매+매출 (wace_plm 도메인)
|
app.use("/api/sales", salesSaleRoutes); // 영업관리>판매+매출 (wace_plm 도메인)
|
||||||
app.use("/api/sales", salesCommonRoutes); // 영업관리 공통 옵션 (codes/parts/customers)
|
app.use("/api/sales", salesCommonRoutes); // 영업관리 공통 옵션 (codes/parts/customers)
|
||||||
|
app.use("/api/sales", salesPurchaseRequestRoutes); // 영업관리>구매요청서관리·품의서관리 (wace_plm)
|
||||||
app.use("/api/project/progress", projectMgmtRoutes); // 프로젝트관리>진행관리 (wace_plm 도메인)
|
app.use("/api/project/progress", projectMgmtRoutes); // 프로젝트관리>진행관리 (wace_plm 도메인)
|
||||||
app.use("/api/project/wbs-template", wbsTemplateRoutes); // 프로젝트관리>제품구분_WBS관리 (wace_plm 도메인)
|
app.use("/api/project/wbs-template", wbsTemplateRoutes); // 프로젝트관리>제품구분_WBS관리 (wace_plm 도메인)
|
||||||
app.use("/api/development", devPartRoutes); // 개발관리>PART 등록/조회 (wace_plm 도메인)
|
app.use("/api/development", devPartRoutes); // 개발관리>PART 등록/조회 (wace_plm 도메인)
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 영업관리 > 구매요청서관리 / 품의서관리 라우트
|
||||||
|
// app.ts: app.use("/api/sales", salesPurchaseRequestRoutes)
|
||||||
|
// GET /api/sales/purchase-request — DOC_TYPE='PURCHASE_REG' 그리드
|
||||||
|
// GET /api/sales/purchase-proposal — DOC_TYPE='PURCHASE_REG_PROPOSAL' 그리드
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { Router, Response } from "express";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
|
import * as svc from "../services/salesPurchaseRequestService";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
function parseFilter(q: Record<string, any>): svc.SalesPurchaseRequestFilter {
|
||||||
|
const f: svc.SalesPurchaseRequestFilter = { ...q };
|
||||||
|
if (q.page) f.page = Number(q.page);
|
||||||
|
if (q.page_size) f.page_size = Number(q.page_size);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(
|
||||||
|
fn: (f: svc.SalesPurchaseRequestFilter) => 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("/purchase-request", (req, res) => run(svc.listPurchaseRequestReg, req as AuthenticatedRequest, res, "구매요청서관리"));
|
||||||
|
router.get("/purchase-proposal", (req, res) => run(svc.listPurchaseRegProposal, req as AuthenticatedRequest, res, "영업>품의서관리"));
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 영업관리 > 구매요청서관리 / 품의서관리 — wace_plm 1:1
|
||||||
|
//
|
||||||
|
// wace 매핑:
|
||||||
|
// /salesMng/purchaseRequestRegList.do
|
||||||
|
// → salesMng.getSalesRequestMasterGridList (DOC_TYPE_FILTER='PURCHASE_REG')
|
||||||
|
// /salesMng/purchaseRegProposalMngList.do
|
||||||
|
// → salesMng.getPurchaseRegProposalMngGridList (DOC_TYPE='PURCHASE_REG_PROPOSAL')
|
||||||
|
//
|
||||||
|
// 데이터 소스(RPS 존재 테이블 기준):
|
||||||
|
// ✓ sales_request_master, project_mgmt, contract_mgmt
|
||||||
|
// ✓ comm_code, client_mng, supply_mng
|
||||||
|
// ✓ mbom_header / mbom_detail / part_mng (품번/품명 fallback)
|
||||||
|
// ✓ amaranth_approval (품의서 결재상태)
|
||||||
|
// ✗ sales_request_part (운영DB 추출 후 신설 필요 — PART_NO fallback: MBOM_DETAIL)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { getPool } from "../database/db";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
export interface SalesPurchaseRequestFilter {
|
||||||
|
project_no?: string;
|
||||||
|
part_no?: string;
|
||||||
|
part_name?: string;
|
||||||
|
purchase_type?: string;
|
||||||
|
writer?: string;
|
||||||
|
part_type?: string;
|
||||||
|
search_status?: string;
|
||||||
|
proposal_no?: string;
|
||||||
|
regdate_start?: string;
|
||||||
|
regdate_end?: string;
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListResult<T> {
|
||||||
|
rows: T[];
|
||||||
|
totalCount: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampPaging(f: SalesPurchaseRequestFilter) {
|
||||||
|
const page = Math.max(1, Number(f.page ?? 1));
|
||||||
|
const pageSize = Math.max(1, Math.min(500, Number(f.page_size ?? 50)));
|
||||||
|
return { limit: pageSize, offset: (page - 1) * pageSize, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 1) 구매요청서관리 ───────────────────────────────────────────
|
||||||
|
// wace: salesMng.getSalesRequestMasterGridList (DOC_TYPE_FILTER='PURCHASE_REG')
|
||||||
|
//
|
||||||
|
// 상태: 품의서생성/확정/작성중 (PURCHASE_REG_PROPOSAL 자식 존재 → '품의서생성')
|
||||||
|
// wace CASE: PROPOSAL 타입 SRM.PROJECT_NO=SRM.OBJID 존재 시 '품의서생성'
|
||||||
|
// RPS 보정: 동일 부모-자식 연결을 PURCHASE_REG_PROPOSAL.PROJECT_NO 로 추적
|
||||||
|
export async function listPurchaseRequestReg(
|
||||||
|
filter: SalesPurchaseRequestFilter,
|
||||||
|
): Promise<ListResult<any>> {
|
||||||
|
const pool = getPool();
|
||||||
|
const { limit, offset, page, pageSize } = clampPaging(filter);
|
||||||
|
|
||||||
|
const where: string[] = [`SRM.DOC_TYPE = 'PURCHASE_REG'`];
|
||||||
|
const params: any[] = [];
|
||||||
|
const p = (v: any) => { params.push(v); return `$${params.length}`; };
|
||||||
|
|
||||||
|
if (filter.project_no) where.push(`PM.PROJECT_NO ILIKE ${p(`%${filter.project_no}%`)}`);
|
||||||
|
if (filter.purchase_type) where.push(`SRM.PURCHASE_TYPE = ${p(filter.purchase_type)}`);
|
||||||
|
if (filter.writer) {
|
||||||
|
const ph = p(filter.writer);
|
||||||
|
where.push(`(SRM.REQUEST_USER_ID = ${ph} OR SRM.WRITER = ${ph})`);
|
||||||
|
}
|
||||||
|
if (filter.part_type) where.push(`SRM.PRODUCT_NAME = ${p(filter.part_type)}`);
|
||||||
|
if (filter.regdate_start) where.push(`SRM.REGDATE::DATE >= ${p(filter.regdate_start)}::DATE`);
|
||||||
|
if (filter.regdate_end) where.push(`SRM.REGDATE::DATE <= ${p(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 ${p(`%${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 ${p(`%${filter.part_name}%`)})`);
|
||||||
|
|
||||||
|
const whereSql = `WHERE ${where.join(" AND ")}`;
|
||||||
|
|
||||||
|
const dataSql = `
|
||||||
|
SELECT
|
||||||
|
SRM.OBJID AS objid,
|
||||||
|
SRM.REQUEST_MNG_NO AS request_mng_no,
|
||||||
|
SRM.STATUS AS status,
|
||||||
|
SRM.PURCHASE_TYPE AS purchase_type,
|
||||||
|
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.PURCHASE_TYPE LIMIT 1), '')
|
||||||
|
AS purchase_type_name,
|
||||||
|
-- 주문유형: 프로젝트.CATEGORY_CD 우선
|
||||||
|
COALESCE(PM.CATEGORY_CD, SRM.ORDER_TYPE) AS order_type,
|
||||||
|
COALESCE(
|
||||||
|
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = COALESCE(PM.CATEGORY_CD, SRM.ORDER_TYPE) LIMIT 1),
|
||||||
|
''
|
||||||
|
) AS order_type_name,
|
||||||
|
-- 제품구분: 프로젝트→계약 PRODUCT 우선
|
||||||
|
COALESCE(
|
||||||
|
(SELECT CC.CODE_NAME
|
||||||
|
FROM CONTRACT_MGMT CM
|
||||||
|
LEFT JOIN COMM_CODE CC ON CC.CODE_ID = CM.PRODUCT
|
||||||
|
WHERE CM.OBJID = PM.CONTRACT_OBJID LIMIT 1),
|
||||||
|
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.PRODUCT_NAME LIMIT 1),
|
||||||
|
''
|
||||||
|
) AS product_name_full,
|
||||||
|
-- 고객사: 프로젝트→계약 CUSTOMER 우선
|
||||||
|
COALESCE(
|
||||||
|
(SELECT CASE WHEN CM.CUSTOMER_OBJID LIKE 'C_%'
|
||||||
|
THEN (SELECT CLIENT_NM FROM CLIENT_MNG WHERE '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
|
||||||
|
FROM CONTRACT_MGMT CM WHERE CM.OBJID = PM.CONTRACT_OBJID LIMIT 1),
|
||||||
|
''
|
||||||
|
) AS customer_name,
|
||||||
|
-- 유/무상
|
||||||
|
CASE COALESCE(
|
||||||
|
(SELECT CM.PAID_TYPE FROM CONTRACT_MGMT CM WHERE CM.OBJID = PM.CONTRACT_OBJID LIMIT 1),
|
||||||
|
SRM.PAID_TYPE)
|
||||||
|
WHEN 'paid' THEN '유상'
|
||||||
|
WHEN 'free' THEN '무상'
|
||||||
|
ELSE ''
|
||||||
|
END AS paid_type_name,
|
||||||
|
PM.PROJECT_NO AS project_number,
|
||||||
|
-- 품번/품명 (MBOM 우선)
|
||||||
|
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,
|
||||||
|
GREATEST(
|
||||||
|
(SELECT COUNT(DISTINCT PP.PART_NO)::int
|
||||||
|
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) - 1,
|
||||||
|
0
|
||||||
|
) AS part_extra_count,
|
||||||
|
-- 구매요청서 작성여부 (MBOM_HEADER 존재 시 Y)
|
||||||
|
CASE WHEN SRM.MBOM_HEADER_OBJID IS NOT NULL THEN 'Y' ELSE 'N' END
|
||||||
|
AS has_purchase_request,
|
||||||
|
-- 상태 라벨 (wace CASE 1:1)
|
||||||
|
CASE
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1 FROM SALES_REQUEST_MASTER P
|
||||||
|
WHERE P.DOC_TYPE = 'PURCHASE_REG_PROPOSAL'
|
||||||
|
AND P.PROJECT_NO = SRM.OBJID::VARCHAR
|
||||||
|
) THEN '품의서생성'
|
||||||
|
WHEN SRM.STATUS = 'confirmed' THEN '확정'
|
||||||
|
WHEN SRM.STATUS = 'create' THEN '작성중'
|
||||||
|
ELSE COALESCE(SRM.STATUS, '')
|
||||||
|
END AS status_title,
|
||||||
|
COALESCE(SRM.REQUEST_USER_ID, SRM.WRITER) AS request_user_id,
|
||||||
|
COALESCE(user_name(COALESCE(SRM.REQUEST_USER_ID, SRM.WRITER)),
|
||||||
|
COALESCE(SRM.REQUEST_USER_ID, SRM.WRITER), '')
|
||||||
|
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
|
||||||
|
${whereSql}
|
||||||
|
ORDER BY SRM.REGDATE DESC
|
||||||
|
LIMIT ${p(limit)} OFFSET ${p(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("listPurchaseRequestReg 실패", { error: e.message });
|
||||||
|
return { rows: [], totalCount: 0, page, pageSize };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 2) 영업관리 > 품의서관리 (구매요청서 → 품의서) ─────────────
|
||||||
|
// wace: salesMng.getPurchaseRegProposalMngGridList (DOC_TYPE='PURCHASE_REG_PROPOSAL')
|
||||||
|
//
|
||||||
|
// 결재상태 우선순위: AMR.STATUS > 내부 결재 > SRM.STATUS('create'→'등록중')
|
||||||
|
export async function listPurchaseRegProposal(
|
||||||
|
filter: SalesPurchaseRequestFilter,
|
||||||
|
): 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 = 'PURCHASE_REG_PROPOSAL'`,
|
||||||
|
];
|
||||||
|
const params: any[] = [];
|
||||||
|
const p = (v: any) => { params.push(v); return `$${params.length}`; };
|
||||||
|
|
||||||
|
if (filter.proposal_no) where.push(`SRM.REQUEST_MNG_NO ILIKE ${p(`%${filter.proposal_no}%`)}`);
|
||||||
|
if (filter.project_no) where.push(`PM.PROJECT_NO ILIKE ${p(`%${filter.project_no}%`)}`);
|
||||||
|
if (filter.purchase_type) where.push(`SRM.PURCHASE_TYPE = ${p(filter.purchase_type)}`);
|
||||||
|
if (filter.writer) where.push(`SRM.WRITER = ${p(filter.writer)}`);
|
||||||
|
if (filter.part_type) where.push(`SRM.PRODUCT_NAME = ${p(filter.part_type)}`);
|
||||||
|
if (filter.regdate_start) where.push(`SRM.REGDATE::DATE >= ${p(filter.regdate_start)}::DATE`);
|
||||||
|
if (filter.regdate_end) where.push(`SRM.REGDATE::DATE <= ${p(filter.regdate_end)}::DATE`);
|
||||||
|
if (filter.search_status) {
|
||||||
|
// amaranth/내부 결재 종합 status = SEARCH_STATUS
|
||||||
|
where.push(`(
|
||||||
|
CASE
|
||||||
|
WHEN AMR.STATUS = 'complete' THEN 'approvalComplete'
|
||||||
|
WHEN AMR.STATUS = 'inProcess' THEN 'inProcess'
|
||||||
|
WHEN AMR.STATUS = 'reject' THEN 'reject'
|
||||||
|
ELSE SRM.STATUS
|
||||||
|
END
|
||||||
|
) = ${p(filter.search_status)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereSql = `WHERE ${where.join(" AND ")}`;
|
||||||
|
|
||||||
|
const dataSql = `
|
||||||
|
SELECT
|
||||||
|
SRM.OBJID AS objid,
|
||||||
|
SRM.REQUEST_MNG_NO AS proposal_no,
|
||||||
|
PM.PROJECT_NO AS project_number,
|
||||||
|
SRM.PURCHASE_TYPE AS purchase_type,
|
||||||
|
COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.PURCHASE_TYPE LIMIT 1), '')
|
||||||
|
AS purchase_type_name,
|
||||||
|
COALESCE(PM.CATEGORY_CD, SRM.ORDER_TYPE) AS order_type,
|
||||||
|
COALESCE(
|
||||||
|
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = COALESCE(PM.CATEGORY_CD, SRM.ORDER_TYPE) LIMIT 1),
|
||||||
|
''
|
||||||
|
) AS order_type_name,
|
||||||
|
SRM.PRODUCT_NAME AS product_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,
|
||||||
|
GREATEST(
|
||||||
|
(SELECT COUNT(DISTINCT PP.PART_NO)::int
|
||||||
|
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) - 1,
|
||||||
|
0
|
||||||
|
) AS part_extra_count,
|
||||||
|
CASE
|
||||||
|
WHEN AMR.STATUS = 'complete' THEN 'approvalComplete'
|
||||||
|
WHEN AMR.STATUS = 'inProcess' THEN 'inProcess'
|
||||||
|
WHEN AMR.STATUS = 'reject' THEN 'reject'
|
||||||
|
ELSE SRM.STATUS
|
||||||
|
END AS status,
|
||||||
|
CASE
|
||||||
|
WHEN AMR.STATUS = 'complete' THEN '결재완료'
|
||||||
|
WHEN AMR.STATUS = 'inProcess' THEN '결재 상신중'
|
||||||
|
WHEN AMR.STATUS = 'reject' THEN '반려'
|
||||||
|
ELSE '등록중'
|
||||||
|
END AS status_title,
|
||||||
|
COALESCE(AMR.STATUS, '') AS amaranth_status,
|
||||||
|
SRM.WRITER AS writer,
|
||||||
|
COALESCE(user_name(SRM.WRITER), SRM.WRITER, '') AS writer_name,
|
||||||
|
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 AMARANTH_APPROVAL AMR
|
||||||
|
ON SRM.OBJID::VARCHAR = AMR.TARGET_OBJID
|
||||||
|
AND AMR.TARGET_TYPE = 'PROPOSAL'
|
||||||
|
${whereSql}
|
||||||
|
ORDER BY SRM.REGDATE DESC
|
||||||
|
LIMIT ${p(limit)} OFFSET ${p(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 AMARANTH_APPROVAL AMR
|
||||||
|
ON SRM.OBJID::VARCHAR = AMR.TARGET_OBJID
|
||||||
|
AND AMR.TARGET_TYPE = 'PROPOSAL'
|
||||||
|
${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("listPurchaseRegProposal 실패", { error: e.message });
|
||||||
|
return { rows: [], totalCount: 0, page, pageSize };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// 영업관리 > 품의서관리 — wace salesMng/purchaseRegProposalMngList.jsp 1:1
|
||||||
|
// 그리드: sales_request_master (doc_type='PURCHASE_REG_PROPOSAL') + mbom 품번/품명
|
||||||
|
// 검색: 품의서No / 프로젝트번호 / 결재상태 / 작성일 / 구매유형 / 작성자 / 제품구분
|
||||||
|
// 액션: 조회 / 결재상신 (Amaranth10 SSO 연동 — 기존 견적 패턴 재사용 예정)
|
||||||
|
//
|
||||||
|
// 구매관리>품의서관리 vs 영업관리>품의서관리:
|
||||||
|
// - 구매관리: DOC_TYPE in ('PROPOSAL', 'PURCHASE_REG_PROPOSAL'(결재완료만)) → 발주서 생성 풀
|
||||||
|
// - 영업관리: DOC_TYPE = 'PURCHASE_REG_PROPOSAL' 만 → 구매요청서 → 품의서 결재상신 화면
|
||||||
|
// 결재완료 시 구매관리>품의서관리에 자동 노출됨.
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Send } 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, OptionItem } from "@/lib/api/purchase";
|
||||||
|
import {
|
||||||
|
salesPurchaseRequestApi,
|
||||||
|
SalesPurchaseRequestFilter,
|
||||||
|
} from "@/lib/api/salesPurchaseRequest";
|
||||||
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
|
||||||
|
const PARENT_PURCHASE_TYPE = "0001814";
|
||||||
|
const PARENT_PART_TYPE = "0000001";
|
||||||
|
|
||||||
|
const STATUS_OPTS: SmartSelectOption[] = [
|
||||||
|
{ code: "create", label: "작성중" },
|
||||||
|
{ code: "inProcess", label: "결재중" },
|
||||||
|
{ code: "approvalComplete", label: "결재완료" },
|
||||||
|
{ code: "reject", label: "반려" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EMPTY_FILTER: SalesPurchaseRequestFilter = {
|
||||||
|
proposal_no: "", project_no: "", search_status: "",
|
||||||
|
purchase_type: "", writer: "", part_type: "",
|
||||||
|
regdate_start: "", regdate_end: "",
|
||||||
|
page: 1, page_size: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PurchaseRegProposalPage() {
|
||||||
|
const [rows, setRows] = useState<any[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [filter, setFilter] = useState<SalesPurchaseRequestFilter>(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<SalesPurchaseRequestFilter>) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const f = { ...filter, ...override };
|
||||||
|
const res = await salesPurchaseRequestApi.listPurchaseRegProposal(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 ?? `prp_${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-[120px]", align: "center" },
|
||||||
|
{ key: "regdate_title", label: "작성일", width: "w-[115px]", align: "center" },
|
||||||
|
{ key: "writer_name", label: "작성자", width: "w-[120px]", align: "center" },
|
||||||
|
]), []);
|
||||||
|
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
const approved = gridRows.filter((r: any) => r.status === "approvalComplete").length;
|
||||||
|
const inProc = gridRows.filter((r: any) => r.status === "inProcess").length;
|
||||||
|
return [
|
||||||
|
{ label: "전체 건수", value: total.toLocaleString(), suffix: "건" },
|
||||||
|
{ label: "결재완료(페이지)", value: approved.toLocaleString(), suffix: "건" },
|
||||||
|
{ label: "결재중(페이지)", value: inProc.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); };
|
||||||
|
|
||||||
|
const onApproval = () => {
|
||||||
|
if (checkedIds.length !== 1) {
|
||||||
|
toast.info("결재상신할 1건을 선택해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sel = gridRows.find((r: any) => r.id === checkedIds[0]);
|
||||||
|
if (!sel) return;
|
||||||
|
if (sel.status === "inProcess") return toast.info("결재 진행중인 건은 상신할 수 없습니다.");
|
||||||
|
if (sel.status === "approvalComplete") return toast.info("결재 완료된 건은 상신할 수 없습니다.");
|
||||||
|
toast.info("결재상신 — Amaranth10 SSO 연동 후 활성 (sales/purchase-proposal/:id/amaranth-approval)");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
|
||||||
|
<PageHeader
|
||||||
|
title="품의서관리"
|
||||||
|
description="구매요청서 → 품의서 결재상신 — wace purchaseRegProposalMngList 1:1"
|
||||||
|
loading={loading} onSearch={handleSearch} onReset={handleReset}
|
||||||
|
actions={<>
|
||||||
|
<Button size="sm" variant="default" className="h-8 gap-1 px-2 text-xs bg-cyan-600 hover:bg-cyan-700"
|
||||||
|
disabled={checkedIds.length !== 1}
|
||||||
|
onClick={onApproval}>
|
||||||
|
<Send 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={150}>
|
||||||
|
<SmartSelect options={purchaseTypeOpts} value={filter.purchase_type ?? ""}
|
||||||
|
onValueChange={(v) => setFilter({ ...filter, purchase_type: v })} />
|
||||||
|
</CompactFilterField>
|
||||||
|
<CompactFilterField label="작성자" width={170}>
|
||||||
|
<SmartSelect options={userOpts} value={filter.writer ?? ""}
|
||||||
|
onValueChange={(v) => setFilter({ ...filter, writer: v })} />
|
||||||
|
</CompactFilterField>
|
||||||
|
<CompactFilterField label="제품구분" width={150}>
|
||||||
|
<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="sales-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,204 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// 영업관리 > 구매요청서관리 — wace salesMng/purchaseRequestRegList.jsp 1:1
|
||||||
|
// 그리드: sales_request_master (doc_type='PURCHASE_REG') + mbom 품번/품명
|
||||||
|
// 검색: 품번 / 품명 / 작성일 / 구매유형(single) / 작성자 / 제품구분
|
||||||
|
// 액션: 조회 / 구매요청서작성(예정) / 품의서생성(예정)
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { FilePlus, 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, OptionItem } from "@/lib/api/purchase";
|
||||||
|
import {
|
||||||
|
salesPurchaseRequestApi,
|
||||||
|
SalesPurchaseRequestFilter,
|
||||||
|
} from "@/lib/api/salesPurchaseRequest";
|
||||||
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
|
||||||
|
const PARENT_PURCHASE_TYPE = "0001814"; // 구매유형
|
||||||
|
const PARENT_PART_TYPE = "0000001"; // 제품구분
|
||||||
|
|
||||||
|
const EMPTY_FILTER: SalesPurchaseRequestFilter = {
|
||||||
|
project_no: "", part_no: "", part_name: "",
|
||||||
|
purchase_type: "", writer: "", part_type: "",
|
||||||
|
regdate_start: "", regdate_end: "",
|
||||||
|
page: 1, page_size: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PurchaseRequestRegPage() {
|
||||||
|
const [rows, setRows] = useState<any[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [filter, setFilter] = useState<SalesPurchaseRequestFilter>(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<SalesPurchaseRequestFilter>) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const f = { ...filter, ...override };
|
||||||
|
const res = await salesPurchaseRequestApi.listPurchaseRequestReg(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 ?? `pr_${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,
|
||||||
|
has_purchase_request_label: r.has_purchase_request === "Y" ? "작성" : "미작성",
|
||||||
|
})), [rows]);
|
||||||
|
|
||||||
|
const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([
|
||||||
|
{ key: "request_mng_no", label: "요청번호", width: "w-[150px]", align: "center" },
|
||||||
|
{ key: "purchase_type_name", label: "구매유형", width: "w-[110px]", align: "center" },
|
||||||
|
{ key: "project_number", label: "프로젝트번호", width: "w-[150px]", align: "center" },
|
||||||
|
{ key: "order_type_name", label: "주문유형", width: "w-[110px]", align: "center" },
|
||||||
|
{ key: "product_name_full", label: "제품구분", width: "w-[110px]", align: "center" },
|
||||||
|
{ key: "customer_name", label: "고객사", width: "w-[160px]" },
|
||||||
|
{ key: "paid_type_name", label: "유/무상", width: "w-[90px]", align: "center" },
|
||||||
|
{ key: "part_display", label: "품번", width: "w-[160px]" },
|
||||||
|
{ key: "part_name_display", label: "품명", minWidth: "min-w-[200px]" },
|
||||||
|
{ key: "has_purchase_request_label",label: "구매요청서", width: "w-[110px]", align: "center" },
|
||||||
|
{ key: "request_user_name", label: "작성자", width: "w-[120px]", align: "center" },
|
||||||
|
{ key: "delivery_request_date", label: "입고요청일", width: "w-[120px]", align: "center" },
|
||||||
|
{ key: "regdate_title", label: "작성일", width: "w-[110px]", align: "center" },
|
||||||
|
{ key: "status_title", label: "상태", width: "w-[110px]", align: "center" },
|
||||||
|
]), []);
|
||||||
|
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
const proposed = gridRows.filter((r: any) => r.status_title === "품의서생성").length;
|
||||||
|
const confirmed = gridRows.filter((r: any) => r.status_title === "확정").length;
|
||||||
|
return [
|
||||||
|
{ label: "전체 건수", value: total.toLocaleString(), suffix: "건" },
|
||||||
|
{ label: "품의서생성(페이지)", value: proposed.toLocaleString(), suffix: "건" },
|
||||||
|
{ label: "확정(페이지)", value: confirmed.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
|
||||||
|
title="구매요청서관리"
|
||||||
|
description="구매요청서 작성 → 품의서 생성 — wace purchaseRequestRegList 1:1"
|
||||||
|
loading={loading} onSearch={handleSearch} onReset={handleReset}
|
||||||
|
actions={<>
|
||||||
|
<Button size="sm" variant="outline" className="h-8 gap-1 px-2 text-xs"
|
||||||
|
onClick={() => toast.info("구매요청서작성 — 작성 다이얼로그 신설 후 활성")}>
|
||||||
|
<FilePlus 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("품의서생성 — sales_request_part 신설 후 활성")}>
|
||||||
|
<ClipboardCheck className="h-3.5 w-3.5" /> 품의서생성
|
||||||
|
</Button>
|
||||||
|
</>}
|
||||||
|
/>
|
||||||
|
<CompactFilterBar totalText={<>총 {total.toLocaleString()}건</>}>
|
||||||
|
<CompactFilterField label="품번" width={160}>
|
||||||
|
<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>
|
||||||
|
<CompactFilterField label="프로젝트번호" width={170}>
|
||||||
|
<Input value={filter.project_no ?? ""}
|
||||||
|
onChange={(e) => setFilter({ ...filter, project_no: 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={150}>
|
||||||
|
<SmartSelect options={purchaseTypeOpts} value={filter.purchase_type ?? ""}
|
||||||
|
onValueChange={(v) => setFilter({ ...filter, purchase_type: v })} />
|
||||||
|
</CompactFilterField>
|
||||||
|
<CompactFilterField label="작성자" width={170}>
|
||||||
|
<SmartSelect options={userOpts} value={filter.writer ?? ""}
|
||||||
|
onValueChange={(v) => setFilter({ ...filter, writer: v })} />
|
||||||
|
</CompactFilterField>
|
||||||
|
<CompactFilterField label="제품구분" width={150}>
|
||||||
|
<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="sales-purchase-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}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 영업관리 > 구매요청서관리 / 품의서관리 (wace_plm 1:1)
|
||||||
|
// 백엔드: /api/sales/purchase-request, /api/sales/purchase-proposal
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
export interface SalesPurchaseRequestFilter {
|
||||||
|
project_no?: string;
|
||||||
|
part_no?: string;
|
||||||
|
part_name?: string;
|
||||||
|
purchase_type?: string;
|
||||||
|
writer?: string;
|
||||||
|
part_type?: string;
|
||||||
|
search_status?: string;
|
||||||
|
proposal_no?: string;
|
||||||
|
regdate_start?: string;
|
||||||
|
regdate_end?: string;
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesPurchaseRequestListResponse<T = any> {
|
||||||
|
rows: T[];
|
||||||
|
totalCount: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getList<T = any>(
|
||||||
|
path: string,
|
||||||
|
filter: SalesPurchaseRequestFilter,
|
||||||
|
): Promise<SalesPurchaseRequestListResponse<T>> {
|
||||||
|
const res = await apiClient.get(`/sales/${path}`, { params: filter });
|
||||||
|
return res.data?.data as SalesPurchaseRequestListResponse<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const salesPurchaseRequestApi = {
|
||||||
|
listPurchaseRequestReg: (f: SalesPurchaseRequestFilter = {}) => getList("purchase-request", f),
|
||||||
|
listPurchaseRegProposal: (f: SalesPurchaseRequestFilter = {}) => getList("purchase-proposal", f),
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user