From 7e7c6a0ac03d7ece5afa10d8f9e39470ccc84763 Mon Sep 17 00:00:00 2001 From: hjjeong Date: Fri, 15 May 2026 10:04:37 +0900 Subject: [PATCH] =?UTF-8?q?=EC=98=81=EC=97=85=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EA=B5=AC=EB=A7=A4=EC=9A=94=EC=B2=AD=EC=84=9C=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=C2=B7=ED=92=88=EC=9D=98=EC=84=9C=EA=B4=80=EB=A6=AC=20=EC=8B=A0?= =?UTF-8?q?=EA=B7=9C=202=EB=A9=94=EB=89=B4=20(wace=5Fplm=201:1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend-node/src/app.ts | 2 + .../src/routes/salesPurchaseRequestRoutes.ts | 42 +++ .../services/salesPurchaseRequestService.ts | 296 ++++++++++++++++++ .../sales/purchase-proposal/page.tsx | 219 +++++++++++++ .../sales/purchase-request/page.tsx | 204 ++++++++++++ frontend/lib/api/salesPurchaseRequest.ts | 41 +++ 6 files changed, 804 insertions(+) create mode 100644 backend-node/src/routes/salesPurchaseRequestRoutes.ts create mode 100644 backend-node/src/services/salesPurchaseRequestService.ts create mode 100644 frontend/app/(main)/COMPANY_16/sales/purchase-proposal/page.tsx create mode 100644 frontend/app/(main)/COMPANY_16/sales/purchase-request/page.tsx create mode 100644 frontend/lib/api/salesPurchaseRequest.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index f5c1022d..b4e393ad 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -179,6 +179,7 @@ import salesEstimateRoutes from "./routes/salesEstimateRoutes"; // 영업관리> import salesOrderMgmtRoutes from "./routes/salesOrderMgmtRoutes"; // 영업관리>주문서 (wace_plm 도메인 이식) import salesSaleRoutes from "./routes/salesSaleRoutes"; // 영업관리>판매+매출 (wace_plm 도메인) import salesCommonRoutes from "./routes/salesCommonRoutes"; // 영업관리 4개 메뉴 공통 옵션 (codes/parts/customers) +import salesPurchaseRequestRoutes from "./routes/salesPurchaseRequestRoutes"; // 영업관리>구매요청서관리·품의서관리 (wace_plm) import projectMgmtRoutes from "./routes/projectMgmtRoutes"; // 프로젝트관리>진행관리 (wace_plm 도메인 이식) import wbsTemplateRoutes from "./routes/wbsTemplateRoutes"; // 프로젝트관리>제품구분_WBS관리 (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", salesSaleRoutes); // 영업관리>판매+매출 (wace_plm 도메인) 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/wbs-template", wbsTemplateRoutes); // 프로젝트관리>제품구분_WBS관리 (wace_plm 도메인) app.use("/api/development", devPartRoutes); // 개발관리>PART 등록/조회 (wace_plm 도메인) diff --git a/backend-node/src/routes/salesPurchaseRequestRoutes.ts b/backend-node/src/routes/salesPurchaseRequestRoutes.ts new file mode 100644 index 00000000..c7332071 --- /dev/null +++ b/backend-node/src/routes/salesPurchaseRequestRoutes.ts @@ -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): 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, + req: AuthenticatedRequest, + res: Response, + name: string, +) { + try { + const data = await fn(parseFilter(req.query as Record)); + 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; diff --git a/backend-node/src/services/salesPurchaseRequestService.ts b/backend-node/src/services/salesPurchaseRequestService.ts new file mode 100644 index 00000000..8d43c60e --- /dev/null +++ b/backend-node/src/services/salesPurchaseRequestService.ts @@ -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 { + 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> { + 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> { + 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 }; + } +} diff --git a/frontend/app/(main)/COMPANY_16/sales/purchase-proposal/page.tsx b/frontend/app/(main)/COMPANY_16/sales/purchase-proposal/page.tsx new file mode 100644 index 00000000..f713bd58 --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/sales/purchase-proposal/page.tsx @@ -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([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [filter, setFilter] = useState(EMPTY_FILTER); + const [checkedIds, setCheckedIds] = useState([]); + + const [purchaseTypeOpts, setPurchaseTypeOpts] = useState([]); + const [partTypeOpts, setPartTypeOpts] = useState([]); + const [userOpts, setUserOpts] = useState([]); + + const fetchList = useCallback(async (override?: Partial) => { + 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 ( +
+ + + } + /> + 총 {total.toLocaleString()}건}> + + setFilter({ ...filter, proposal_no: e.target.value })} /> + + + setFilter({ ...filter, project_no: e.target.value })} /> + + + setFilter({ ...filter, search_status: v })} /> + + + setFilter({ ...filter, regdate_start: v })} + to={filter.regdate_end ?? ""} setTo={(v) => setFilter({ ...filter, regdate_end: v })} + /> + + + setFilter({ ...filter, purchase_type: v })} /> + + + setFilter({ ...filter, writer: v })} /> + + + setFilter({ ...filter, part_type: v })} /> + + + + { 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 = {}; + GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "영업_품의서관리.xlsx", "품의서"); + }} + showChart + /> +
+ ); +} diff --git a/frontend/app/(main)/COMPANY_16/sales/purchase-request/page.tsx b/frontend/app/(main)/COMPANY_16/sales/purchase-request/page.tsx new file mode 100644 index 00000000..b682e616 --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/sales/purchase-request/page.tsx @@ -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([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [filter, setFilter] = useState(EMPTY_FILTER); + const [checkedIds, setCheckedIds] = useState([]); + + const [purchaseTypeOpts, setPurchaseTypeOpts] = useState([]); + const [partTypeOpts, setPartTypeOpts] = useState([]); + const [userOpts, setUserOpts] = useState([]); + + const fetchList = useCallback(async (override?: Partial) => { + 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 ( +
+ + + + } + /> + 총 {total.toLocaleString()}건}> + + setFilter({ ...filter, part_no: e.target.value })} /> + + + setFilter({ ...filter, part_name: e.target.value })} /> + + + setFilter({ ...filter, project_no: e.target.value })} /> + + + setFilter({ ...filter, regdate_start: v })} + to={filter.regdate_end ?? ""} setTo={(v) => setFilter({ ...filter, regdate_end: v })} + /> + + + setFilter({ ...filter, purchase_type: v })} /> + + + setFilter({ ...filter, writer: v })} /> + + + setFilter({ ...filter, part_type: v })} /> + + + + { 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 = {}; + GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "구매요청서관리.xlsx", "구매요청서"); + }} + showChart + /> +
+ ); +} diff --git a/frontend/lib/api/salesPurchaseRequest.ts b/frontend/lib/api/salesPurchaseRequest.ts new file mode 100644 index 00000000..a64a2d56 --- /dev/null +++ b/frontend/lib/api/salesPurchaseRequest.ts @@ -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 { + rows: T[]; + totalCount: number; + page: number; + pageSize: number; +} + +async function getList( + path: string, + filter: SalesPurchaseRequestFilter, +): Promise> { + const res = await apiClient.get(`/sales/${path}`, { params: filter }); + return res.data?.data as SalesPurchaseRequestListResponse; +} + +export const salesPurchaseRequestApi = { + listPurchaseRequestReg: (f: SalesPurchaseRequestFilter = {}) => getList("purchase-request", f), + listPurchaseRegProposal: (f: SalesPurchaseRequestFilter = {}) => getList("purchase-proposal", f), +};