diff --git a/backend-node/src/controllers/purchaseController.ts b/backend-node/src/controllers/purchaseController.ts index d55f2d35..6ca10c11 100644 --- a/backend-node/src/controllers/purchaseController.ts +++ b/backend-node/src/controllers/purchaseController.ts @@ -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): 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 { diff --git a/backend-node/src/routes/purchaseRoutes.ts b/backend-node/src/routes/purchaseRoutes.ts index b874541c..a382029f 100644 --- a/backend-node/src/routes/purchaseRoutes.ts +++ b/backend-node/src/routes/purchaseRoutes.ts @@ -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); diff --git a/backend-node/src/services/purchaseOrderFormService.ts b/backend-node/src/services/purchaseOrderFormService.ts new file mode 100644 index 00000000..444b146c --- /dev/null +++ b/backend-node/src/services/purchaseOrderFormService.ts @@ -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; + parts: Record[]; +} + +/** + * 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 { + const pool = getPool(); + + // 1) 품의서 마스터 정보 (PROJECT_NO 등) + let proposal: Record | 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 = { + 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[] = []; + 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 { + 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")}일`; +} diff --git a/backend-node/src/services/purchaseService.ts b/backend-node/src/services/purchaseService.ts index 5242b6df..66b60cec 100644 --- a/backend-node/src/services/purchaseService.ts +++ b/backend-node/src/services/purchaseService.ts @@ -873,13 +873,13 @@ export async function listProjectStatus(filter: PurchaseListFilter): Promise> { +export async function listPurchaseOrderList(filter: PurchaseListFilter): Promise> { 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 }; } } diff --git a/frontend/app/(main)/COMPANY_16/purchase/order-wace/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/order-wace/page.tsx deleted file mode 100644 index da44af53..00000000 --- a/frontend/app/(main)/COMPANY_16/purchase/order-wace/page.tsx +++ /dev/null @@ -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 { - 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([]); - const [total, setTotal] = useState(0); - const [loading, setLoading] = useState(false); - const [filter, setFilter] = useState(EMPTY_FILTER); - const [checkedIds, setCheckedIds] = useState([]); - - const [vendorOpts, setVendorOpts] = useState([]); - const [userOpts, setUserOpts] = useState([]); - const [categoryOpts, setCategoryOpts] = useState([]); - const [productOpts, setProductOpts] = useState([]); - const [purchaseOpts, setPurchaseOpts] = useState([]); - - const yearOpts = useMemo(() => getYearOptions(), []); - - const fetchList = useCallback(async (override?: Partial) => { - 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 ( -
- - - 총 {total.toLocaleString()}건}> - - setFilter({ ...filter, year: v })} /> - - - setFilter({ ...filter, customer_cd: v })} /> - - - setFilter({ ...filter, project_no: e.target.value })} /> - - - setFilter({ ...filter, purchase_order_no: e.target.value })} /> - - - setFilter({ ...filter, partner_objid: v })} /> - - - setFilter({ ...filter, part_no: e.target.value })} /> - - - setFilter({ ...filter, part_name: e.target.value })} /> - - - setFilter({ ...filter, delivery_start_date: v })} - to={filter.delivery_end_date ?? ""} setTo={(v) => setFilter({ ...filter, delivery_end_date: v })} - /> - - - setFilter({ ...filter, reg_start_date: v })} - to={filter.reg_end_date ?? ""} setTo={(v) => setFilter({ ...filter, reg_end_date: v })} - /> - - - setFilter({ ...filter, category_cd: v })} /> - - - setFilter({ ...filter, product_cd: v })} /> - - - setFilter({ ...filter, purchase_type: v })} /> - - - setFilter({ ...filter, writer: v })} /> - - - setFilter({ ...filter, mail_send_yn: 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} - 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/purchase/order/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/order/page.tsx index a599267a..e1f75167 100644 --- a/frontend/app/(main)/COMPANY_16/purchase/order/page.tsx +++ b/frontend/app/(main)/COMPANY_16/purchase/order/page.tsx @@ -1,1331 +1,239 @@ "use client"; -import React, { useState, useEffect, useCallback, useMemo } from "react"; -import { Button } from "@/components/ui/button"; +// 구매관리 > 발주서관리 — wace_plm purchaseOrder/purchaseOrderList_new.jsp 1:1 +// 매퍼: wace_plm/src/com/pms/mapper/purchaseOrder.xml purchaseOrderMasterList_new +// 검색 2행: 년도/고객사/프로젝트번호/발주No/공급업체/품번/품명 + +// 입고요청일/발주일/주문유형/제품구분/구매유형/구매담당자/메일발송 +// 그리드 15컬럼: 품의서No/발주서No/프로젝트번호/구매유형/주문유형/제품구분/ +// 품번/품명/공급업체/환종/총액/메일발송/발주일/구매담당자/작성일 + +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Input } from "@/components/ui/input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { - Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, -} from "@/components/ui/dialog"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Badge } from "@/components/ui/badge"; -import { Label } from "@/components/ui/label"; -import { - Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, - ClipboardList, Pencil, Search, X, Package, - ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, - Settings2, GripVertical, -} from "lucide-react"; -import { cn } from "@/lib/utils"; -import { DndContext, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core"; -import { SortableContext, arrayMove, horizontalListSortingStrategy, useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { apiClient } from "@/lib/api/client"; -import { useConfirmDialog } from "@/components/common/ConfirmDialog"; -import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; -import { exportToExcel } from "@/lib/utils/excelExport"; -import { useAuth } from "@/hooks/useAuth"; import { toast } from "sonner"; -import { useTableSettings } from "@/hooks/useTableSettings"; -import { TableSettingsModal } from "@/components/common/TableSettingsModal"; -import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; -import { SmartSelect } from "@/components/common/SmartSelect"; import { 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 MASTER_TABLE = "purchase_order_mng"; -const DETAIL_TABLE = "purchase_detail"; - -const formatNumber = (val: string) => { - const num = val.replace(/[^\d.-]/g, ""); - if (!num) return ""; - const parts = num.split("."); - parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ","); - return parts.join("."); -}; -const parseNumber = (val: string) => val.replace(/,/g, ""); - -const STATUS_OPTIONS = [ - { code: "작성중", label: "작성중" }, - { code: "발주확정", label: "발주확정" }, - { code: "입고완료", label: "입고완료" }, - { code: "취소", label: "취소" }, +const MAIL_SEND_OPTS: SmartSelectOption[] = [ + { code: "Y", label: "발송완료" }, + { code: "N", label: "미발송" }, + { code: "orderCancel", label: "발주취소" }, ]; -const STATUS_BADGE_CLASS: Record = { - "작성중": "bg-warning/10 text-warning border-warning/20", - "발주확정": "bg-primary/10 text-primary border-primary/20", - "입고완료": "bg-success/10 text-success border-success/20", - "취소": "bg-destructive/10 text-destructive border-destructive/20", +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, }; -const EXCEL_COLUMNS = [ - { key: "purchase_no", label: "발주번호" }, - { key: "order_date", label: "발주일" }, - { key: "supplier_name", label: "공급업체명" }, - { key: "item_code", label: "품번" }, - { key: "item_name", label: "품명" }, - { key: "order_qty", label: "발주수량" }, - { key: "unit_price", label: "단가" }, - { key: "amount", label: "금액" }, - { key: "due_date", label: "납기일" }, -]; +// wace 코드그룹 ID (PurchaseOrderController.matermgmtList 기준) +const CODE_GROUP = { + CATEGORY: "0000167", // 주문유형 + PRODUCT: "0000001", // 제품구분 + PURCHASE: "0001814", // 구매유형 +} as const; -const GRID_COLUMNS_CONFIG = [ - { key: "purchase_no", label: "발주번호" }, - { key: "order_date", label: "발주일" }, - { key: "supplier_name", label: "공급업체" }, - { key: "item_code", label: "품번" }, - { key: "item_name", label: "품명" }, - { key: "spec", label: "규격" }, - { key: "order_qty", label: "발주수량" }, - { key: "received_qty", label: "입고수량" }, - { key: "remain_qty", label: "잔량" }, - { key: "unit_price", label: "단가" }, - { key: "amount", label: "금액" }, - { key: "due_date", label: "납기일" }, - { key: "status", label: "상태" }, - { key: "memo", label: "메모" }, -]; - -const MODAL_DETAIL_COLUMNS = [ - { key: "item_code", label: "품번", width: "min-w-[120px]" }, - { key: "item_name", label: "품명", width: "min-w-[150px]" }, - { key: "supplier", label: "공급업체", width: "min-w-[150px]" }, - { key: "spec", label: "규격", width: "min-w-[80px]" }, - { key: "unit", label: "단위", width: "min-w-[90px]" }, - { key: "order_qty", label: "발주수량", width: "min-w-[90px]" }, - { key: "received_qty", label: "입고수량", width: "min-w-[90px]" }, - { key: "remain_qty", label: "잔량", width: "min-w-[80px]" }, - { key: "unit_price", label: "단가", width: "min-w-[100px]" }, - { key: "amount", label: "금액", width: "min-w-[100px]" }, - { key: "due_date", label: "납기일", width: "min-w-[160px]" }, - { key: "memo", label: "메모", width: "min-w-[120px]" }, -]; - -const MODAL_COL_ORDER_KEY = "purchase_order_modal_col_order_c16"; - -function SortableModalHead({ col }: { col: { key: string; label: string; width: string } }) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: col.key }); - const style: React.CSSProperties = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - }; - return ( - -
- - {col.label} -
-
- ); +async function loadCodes(groupId: string): Promise { + try { + const r = await apiClient.get(`/sales/codes/${groupId}`); + return (r.data?.data ?? []) as OptionItem[]; + } catch { + return []; + } } -export default function PurchaseOrderPage() { - const { user } = useAuth(); - const { confirm, ConfirmDialogComponent } = useConfirmDialog(); - const [orders, setOrders] = useState([]); +export default function PurchaseOrderWacePage() { + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); - const [totalCount, setTotalCount] = useState(0); - - // 검색 필터 (DynamicSearchFilter) - const [searchFilters, setSearchFilters] = useState([]); - - const [isModalOpen, setIsModalOpen] = useState(false); - const [isEditMode, setIsEditMode] = useState(false); - const [saving, setSaving] = useState(false); - const [masterForm, setMasterForm] = useState>({}); - const [detailRows, setDetailRows] = useState([]); - - // 품목 선택 모달 - const [itemSelectOpen, setItemSelectOpen] = useState(false); - const [itemSearchKeyword, setItemSearchKeyword] = useState(""); - const [itemSearchResults, setItemSearchResults] = useState([]); - const [itemSearchLoading, setItemSearchLoading] = useState(false); - const [itemSelectedMap, setItemSelectedMap] = useState>(new Map()); - const [itemSearchDivision, setItemSearchDivision] = useState("all"); - const [itemPage, setItemPage] = useState(1); - const [itemPageSize, setItemPageSize] = useState(20); - const [itemTotalPages, setItemTotalPages] = useState(0); - const [itemTotal, setItemTotal] = useState(0); - const [itemPageInput, setItemPageInput] = useState("1"); - - const [excelUploadOpen, setExcelUploadOpen] = useState(false); - const [categoryOptions, setCategoryOptions] = useState>({}); + const [filter, setFilter] = useState(EMPTY_FILTER); const [checkedIds, setCheckedIds] = useState([]); - // 테이블 설정 - const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG); + const [vendorOpts, setVendorOpts] = useState([]); + const [userOpts, setUserOpts] = useState([]); + const [categoryOpts, setCategoryOpts] = useState([]); + const [productOpts, setProductOpts] = useState([]); + const [purchaseOpts, setPurchaseOpts] = useState([]); - // 발주 목록 컬럼 정의 — 영업관리 4메뉴와 일관성 (DataGrid + logicstudio props) - // 날짜/숫자는 데이터 매핑 단계에서 pre-format. status 는 plain text (영업메뉴 동일). - const orderTableColumns = useMemo(() => { - const numCols = new Set(["order_qty", "received_qty", "remain_qty"]); - const moneyCols = new Set(["unit_price", "amount"]); - return ts.visibleColumns.map((col) => { - const base: DataGridColumn = { - key: col.key, - label: col.label, - align: numCols.has(col.key) || moneyCols.has(col.key) - ? "right" - : col.key === "status" ? "center" : undefined, - }; - if (numCols.has(col.key)) base.formatNumber = true; - if (moneyCols.has(col.key)) base.formatMoney = true; - return base; - }); - }, [ts.visibleColumns]); + const yearOpts = useMemo(() => getYearOptions(), []); - // 모달 품목 테이블 컬럼 순서 (드래그 재정렬) - const [modalColumns, setModalColumns] = useState(MODAL_DETAIL_COLUMNS); - const modalSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } })); - - useEffect(() => { - const saved = localStorage.getItem(MODAL_COL_ORDER_KEY); - if (saved) { - try { - const order = JSON.parse(saved) as string[]; - const reordered = order.map((key) => MODAL_DETAIL_COLUMNS.find((c) => c.key === key)).filter(Boolean) as typeof MODAL_DETAIL_COLUMNS; - const remaining = MODAL_DETAIL_COLUMNS.filter((c) => !order.includes(c.key)); - setModalColumns([...reordered, ...remaining]); - } catch { /* skip */ } - } - }, []); - - const handleModalDragEnd = (event: DragEndEvent) => { - const { active, over } = event; - if (!over || active.id === over.id) return; - setModalColumns((prev) => { - const oldIndex = prev.findIndex((c) => c.key === active.id); - const newIndex = prev.findIndex((c) => c.key === over.id); - const next = arrayMove(prev, oldIndex, newIndex); - localStorage.setItem(MODAL_COL_ORDER_KEY, JSON.stringify(next.map((c) => c.key))); - return next; - }); - }; - - const isReadOnly = masterForm.status === "입고완료" || masterForm.status === "취소"; - - const visibleModalColumns = useMemo(() => { - return modalColumns.filter((col) => { - if (col.key === "supplier" && masterForm.input_mode !== "itemFirst") return false; - return true; - }); - }, [modalColumns, masterForm.input_mode]); - - // 카테고리 로드 - useEffect(() => { - const loadCategories = async () => { - const catColumns = ["input_mode", "price_mode"]; - const optMap: Record = {}; - const flatten = (vals: any[]): { code: string; label: string }[] => { - const result: { code: string; label: string }[] = []; - for (const v of vals) { - result.push({ code: v.valueCode, label: v.valueLabel }); - if (v.children?.length) result.push(...flatten(v.children)); - } - return result; - }; - const dedup = (items: { code: string; label: string }[]) => { - const seen = new Set(); - return items.filter((item) => { - const key = item.label.replace(/\s/g, ""); - if (seen.has(key)) return false; - seen.add(key); - return true; - }); - }; - await Promise.all( - catColumns.map(async (col) => { - try { - const res = await apiClient.get(`/table-categories/${MASTER_TABLE}/${col}/values`); - if (res.data?.success && res.data.data?.length > 0) { - optMap[col] = dedup(flatten(res.data.data)); - } - } catch { /* skip */ } - }) - ); - try { - const suppRes = await apiClient.post(`/table-management/tables/supplier_mng/data`, { - page: 1, size: 0, autoFilter: true, - }); - const supps = suppRes.data?.data?.data || suppRes.data?.data?.rows || []; - optMap["supplier_code"] = supps.map((s: any) => ({ - code: s.supplier_code, - label: `${s.supplier_name} (${s.supplier_code})`, - })); - } catch { /* skip */ } - try { - const userRes = await apiClient.post(`/table-management/tables/user_info/data`, { - page: 1, size: 0, autoFilter: true, - }); - const users = userRes.data?.data?.data || userRes.data?.data?.rows || []; - optMap["manager"] = users.map((u: any) => ({ - code: u.user_id || u.id, - label: `${u.user_name || u.name || u.user_id}${u.position_name ? ` (${u.position_name})` : ""}`, - })); - } catch { /* skip */ } - for (const col of ["inventory_unit", "material", "division"]) { - try { - const res = await apiClient.get(`/table-categories/item_info/${col}/values`); - if (res.data?.success && res.data.data?.length > 0) { - optMap[`item_${col}`] = flatten(res.data.data); - } - } catch { /* skip */ } - } - setCategoryOptions(optMap); - const divs = optMap["item_division"] || []; - const purchaseDiv = divs.find((o) => o.label === "구매관리") - || divs.find((o) => o.label === "원자재") - || divs.find((o) => o.label === "부자재"); - if (purchaseDiv) setItemSearchDivision(purchaseDiv.code); - }; - loadCategories(); - }, []); - - // 마스터 테이블 컬럼 (supplier_name, order_date 등) - const MASTER_COLUMNS = new Set(["supplier_name", "supplier_code", "order_date", "status"]); - - // 데이터 조회 - const fetchOrders = useCallback(async () => { + const fetchList = useCallback(async (override?: Partial) => { setLoading(true); try { - // searchFilters를 detail / master로 분리 - const detailFilters: any[] = []; - const masterExtraFilters: any[] = []; - for (const f of searchFilters) { - const filter = { columnName: f.columnName, operator: f.operator, value: f.value }; - if (MASTER_COLUMNS.has(f.columnName)) { - masterExtraFilters.push(filter); - } else { - detailFilters.push(filter); - } - } - - const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, { - page: 1, size: 0, - dataFilter: detailFilters.length > 0 ? { enabled: true, filters: detailFilters } : undefined, - autoFilter: true, - sort: { columnName: "purchase_no", order: "desc" }, - }); - const rows = res.data?.data?.data || res.data?.data?.rows || []; - - const purchaseNos = [...new Set(rows.map((r: any) => r.purchase_no).filter(Boolean))]; - let masterMap: Record = {}; - if (purchaseNos.length > 0) { - try { - const masterFilters: any[] = [{ columnName: "purchase_no", operator: "in", value: purchaseNos }, ...masterExtraFilters]; - const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, { - page: 1, size: purchaseNos.length + 10, - dataFilter: { enabled: true, filters: masterFilters }, - autoFilter: true, - }); - const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || []; - for (const m of masters) masterMap[m.purchase_no] = m; - } catch { /* skip */ } - } - - const itemCodes = [...new Set(rows.map((r: any) => r.item_code).filter(Boolean))]; - let itemMap: Record = {}; - if (itemCodes.length > 0) { - try { - const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, { - page: 1, size: itemCodes.length + 10, - dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] }, - autoFilter: true, - }); - const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || []; - for (const item of items) itemMap[item.item_number] = item; - } catch { /* skip */ } - } - - const resolveLabel = (key: string, code: string) => { - if (!code) return ""; - const opts = categoryOptions[key]; - if (!opts) return code; - return opts.find((o) => o.code === code)?.label || code; - }; - - const hasMasterFilters = masterExtraFilters.length > 0; - const data = rows - .filter((row: any) => { - const master = masterMap[row.purchase_no]; - if (hasMasterFilters && !master) return false; - return true; - }) - .map((row: any) => { - const item = itemMap[row.item_code]; - const master = masterMap[row.purchase_no]; - const rawUnit = row.unit || item?.inventory_unit || ""; - const fmtDate = (v: any) => v ? new Date(v).toLocaleDateString("ko-KR") : ""; - const od = master?.order_date || ""; - const dd = row.due_date || ""; - return { - ...row, - item_name: row.item_name || item?.item_name || "", - spec: row.spec || item?.size || "", - unit: resolveLabel("item_inventory_unit", rawUnit) || rawUnit, - status: master?.status || "", - supplier_name: master?.supplier_name || "", - order_date: od ? fmtDate(od) : "", - due_date: dd ? fmtDate(dd) : "", - memo: row.memo || master?.memo || "", - }; - }); - - setOrders(data); - setTotalCount(data.length); - } catch { - toast.error("발주 목록을 불러오는데 실패했어요."); + const f = { ...filter, ...override }; + const res = await purchaseApi.listOrder(f); + setRows(res.rows ?? []); + setTotal(res.totalCount ?? 0); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); } finally { setLoading(false); } - }, [searchFilters, categoryOptions]); + }, [filter]); - useEffect(() => { fetchOrders(); }, [fetchOrders]); - - // purchase_no 기준 그룹핑 - - const getCategoryLabel = (col: string, code: string) => { - if (!code) return ""; - const found = categoryOptions[col]?.find((o) => o.code === code); - return found?.label || code; - }; - - // 등록 모달 열기 - const openRegisterModal = () => { - const defaultInputMode = categoryOptions["input_mode"]?.[0]?.code || ""; - const defaultPriceMode = categoryOptions["price_mode"]?.[0]?.code || ""; - setMasterForm({ - input_mode: defaultInputMode, - price_mode: defaultPriceMode, - manager: user?.userId || "", - order_date: new Date().toISOString().split("T")[0], - status: "작성중", - }); - setDetailRows([]); - setIsEditMode(false); - setIsModalOpen(true); - }; - - // 수정 모달 열기 - const openEditModal = async (purchaseNo: string) => { - try { - const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, { - page: 1, size: 1, - dataFilter: { enabled: true, filters: [{ columnName: "purchase_no", operator: "equals", value: purchaseNo }] }, - autoFilter: true, - }); - const masterData = (masterRes.data?.data?.data || masterRes.data?.data?.rows || [])[0]; - - const detailRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, { - page: 1, size: 100, - dataFilter: { enabled: true, filters: [{ columnName: "purchase_no", operator: "equals", value: purchaseNo }] }, - autoFilter: true, - }); - const detailData = detailRes.data?.data?.data || detailRes.data?.data?.rows || []; - - setMasterForm(masterData || {}); - setDetailRows(detailData.map((d: any, i: number) => ({ ...d, _id: d.id || `row_${i}` }))); - setIsEditMode(true); - setIsModalOpen(true); - } catch { - toast.error("발주 정보를 불러오는데 실패했어요."); - } - }; - - // 삭제 (다중 선택) - const handleDelete = async () => { - if (checkedIds.length === 0) { toast.error("삭제할 발주를 선택해주세요."); return; } - const selectedItems = orders.filter((o) => checkedIds.includes(o.id)); - const purchaseNos = [...new Set(selectedItems.map((o) => o.purchase_no))]; - const ok = await confirm(`${checkedIds.length}건의 발주 데이터를 삭제하시겠어요?`, { - description: "삭제된 데이터는 복구할 수 없어요.", - variant: "destructive", - confirmText: "삭제", - }); - if (!ok) return; - try { - await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, { - data: checkedIds.map((id) => ({ id })), - }); - for (const purchaseNo of purchaseNos) { - const remaining = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, { - page: 1, size: 1, - dataFilter: { enabled: true, filters: [{ columnName: "purchase_no", operator: "equals", value: purchaseNo }] }, - autoFilter: true, - }); - const remainRows = remaining.data?.data?.data || remaining.data?.data?.rows || []; - if (remainRows.length === 0) { - const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, { - page: 1, size: 1, - dataFilter: { enabled: true, filters: [{ columnName: "purchase_no", operator: "equals", value: purchaseNo }] }, - autoFilter: true, - }); - const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || []; - if (masters.length > 0) { - await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, { - data: masters.map((m: any) => ({ id: m.id })), - }); - } - } - } - toast.success("삭제되었어요."); - setCheckedIds([]); - fetchOrders(); - } catch { - toast.error("삭제에 실패했어요."); - } - }; - - // 저장 (마스터 + 디테일) - const handleSave = async () => { - if (detailRows.length === 0) { - toast.error("품목을 1개 이상 추가해주세요."); - return; - } - setSaving(true); - try { - const { id, created_date, updated_date, writer, company_code, created_by, updated_by, ...masterFields } = masterForm; - - if (isEditMode && id) { - await apiClient.put(`/table-management/tables/${MASTER_TABLE}/edit`, { - originalData: { id }, - updatedData: masterFields, - }); - const existingDetails = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, { - page: 1, size: 100, - dataFilter: { enabled: true, filters: [{ columnName: "purchase_no", operator: "equals", value: masterForm.purchase_no }] }, - autoFilter: true, - }); - const existings = existingDetails.data?.data?.data || existingDetails.data?.data?.rows || []; - if (existings.length > 0) { - await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, { - data: existings.map((d: any) => ({ id: d.id })), - }); - } - for (const [idx, row] of detailRows.entries()) { - const { _id, id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, ...detailFields } = row; - await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/add`, { - id: crypto.randomUUID(), - ...detailFields, - purchase_no: masterForm.purchase_no, - seq_no: idx + 1, - }); - } - } else { - const { purchase_no: _pn, ...fieldsWithoutPurchaseNo } = masterFields; - const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/add`, { id: crypto.randomUUID(), ...fieldsWithoutPurchaseNo }); - const createdData = masterRes.data?.data; - let purchaseNo = createdData?.purchase_no; - if (!purchaseNo) { - const queryRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, { - page: 1, size: 1, - sort: { columnName: "created_date", order: "desc" }, - autoFilter: true, - }); - const records = queryRes.data?.data?.data || queryRes.data?.data?.rows || []; - purchaseNo = records[0]?.purchase_no; - } - if (!purchaseNo) { - toast.error("발주번호를 가져올 수 없어요. 다시 시도해주세요."); - return; - } - for (const [idx, row] of detailRows.entries()) { - const { _id, id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, ...detailFields } = row; - await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/add`, { - id: crypto.randomUUID(), - ...detailFields, - purchase_no: purchaseNo, - seq_no: idx + 1, - }); - } - } - - toast.success(isEditMode ? "수정되었어요." : "등록되었어요."); - setIsModalOpen(false); - fetchOrders(); - } catch (err: any) { - toast.error(err.response?.data?.message || "저장에 실패했어요."); - } finally { - setSaving(false); - } - }; - - // 품목 검색 (수주관리와 동일한 서버 페이징 방식) - const searchItems = async (page?: number, size?: number) => { - const p = page ?? itemPage; - const s = size ?? itemPageSize; - setItemSearchLoading(true); - try { - const filters: any[] = []; - if (itemSearchKeyword) { - filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword }); - } - // 관리품목 필터를 서버 쿼리에 포함 (코드 + 라벨 양쪽 대응) - if (itemSearchDivision !== "all") { - const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || ""; - const divValues = [itemSearchDivision]; - if (divLabel) divValues.push(divLabel); - filters.push({ columnName: "division", operator: "in", value: divValues }); - } - - // 공급업체 선택 시 supplier_item_mapping으로 매핑 id 정규화 → 서버 필터 적용 - const supplierCode = masterForm.supplier_code; - if (supplierCode) { - try { - const mappingRes = await apiClient.post(`/table-management/tables/supplier_item_mapping/data`, { - page: 1, size: 0, - dataFilter: { enabled: true, filters: [{ columnName: "supplier_id", operator: "equals", value: supplierCode }] }, - autoFilter: true, - }); - const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || []; - const rawIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))] as string[]; - if (rawIds.length === 0) { - setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1); - setItemSearchLoading(false); - return; - } - // UUID와 문자열(item_number) 분리 - const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - const uuidIds = rawIds.filter((v) => uuidRegex.test(v)); - const codeIds = rawIds.filter((v) => !uuidRegex.test(v)); - - // 문자열(item_number)을 item_info에서 id로 변환 - let convertedIds: string[] = []; - if (codeIds.length > 0) { - const convRes = await apiClient.post(`/table-management/tables/item_info/data`, { - page: 1, size: codeIds.length + 10, - dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: codeIds }] }, - autoFilter: true, - }); - const convRows = convRes.data?.data?.data || convRes.data?.data?.rows || []; - convertedIds = convRows.map((r: any) => r.id).filter(Boolean); - } - - const finalIds = [...new Set([...uuidIds, ...convertedIds])]; - if (finalIds.length === 0) { - setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1); - setItemSearchLoading(false); - return; - } - filters.push({ columnName: "id", operator: "in", value: finalIds }); - } catch { /* skip */ } - } - - const res = await apiClient.post(`/table-management/tables/item_info/data`, { - page: p, size: s, - dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, - autoFilter: true, - }); - const resData = res.data?.data; - const rows = resData?.data || resData?.rows || []; - const serverTotal = resData?.total || resData?.totalCount || rows.length; - setItemSearchResults(rows); - setItemTotal(serverTotal); - setItemTotalPages(Math.max(1, Math.ceil(serverTotal / s))); - } catch { /* skip */ } finally { - setItemSearchLoading(false); - } - }; - - const handleItemPageChange = (newPage: number) => { - if (newPage < 1 || newPage > itemTotalPages) return; - setItemPage(newPage); - setItemPageInput(String(newPage)); - searchItems(newPage); - }; - - const commitItemPageInput = () => { - const parsed = parseInt(itemPageInput, 10); - if (isNaN(parsed) || itemPageInput.trim() === "") { - setItemPageInput(String(itemPage)); - return; - } - const clamped = Math.max(1, Math.min(parsed, itemTotalPages || 1)); - if (clamped !== itemPage) handleItemPageChange(clamped); - setItemPageInput(String(clamped)); - }; - - const triggerNewSearch = () => { - setItemPage(1); - setItemPageInput("1"); - searchItems(1); - }; - - const addSelectedItemsToDetail = async () => { - const selected = Array.from(itemSelectedMap.values()); - if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; } - - const supplierCode = masterForm.supplier_code; - const isStandard = masterForm.price_mode === "standard"; - const isSupplier = masterForm.price_mode === "supplier"; - let supplierPriceMap: Record = {}; - if (isSupplier && supplierCode) { + useEffect(() => { + let dead = false; + (async () => { try { - const itemIds = selected.map((item) => item.item_number || item.id); - const res = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, { - page: 1, size: 0, - dataFilter: { - enabled: true, - filters: [ - { columnName: "supplier_id", operator: "equals", value: supplierCode }, - { columnName: "item_id", operator: "in", value: itemIds }, - ], - }, - autoFilter: true, - }); - const prices = res.data?.data?.data || res.data?.data?.rows || []; - const today = new Date().toISOString().slice(0, 10); - for (const p of prices) { - if (p.start_date && p.start_date > today) continue; - if (p.end_date && p.end_date < today) continue; - const price = p.calculated_price || p.base_price || p.unit_price || ""; - if (price && Number(price) > 0) supplierPriceMap[p.item_id] = String(price); - } + 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 newRows = selected.map((item) => { - const itemCode = item.item_number || item.id; - let unitPrice = ""; - if (isStandard) { - unitPrice = item.purchase_price || item.standard_price || ""; - } else if (isSupplier && supplierCode) { - unitPrice = supplierPriceMap[itemCode] || ""; - } - return { - _id: `new_${Date.now()}_${Math.random()}`, - item_code: itemCode, - item_name: item.item_name, - spec: item.size || "", - material: getCategoryLabel("item_material", item.material) || item.material || "", - unit: getCategoryLabel("item_inventory_unit", item.inventory_unit) || item.inventory_unit || "", - order_qty: "", - received_qty: "0", - remain_qty: "0", - unit_price: unitPrice, - amount: "", - due_date: "", - memo: "", - }; - }); + const gridRows = useMemo( + () => rows.map((r, i) => ({ ...r, id: r.objid ?? `r_${i}` })), + [rows], + ); - setDetailRows((prev) => [...prev, ...newRows]); - toast.success(`${selected.length}개 품목이 추가되었어요.`); - setItemSelectedMap(new Map()); - setItemSelectOpen(false); - }; + 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 recalcPrices = useCallback(async (priceMode: string, supplierCode: string) => { - if (detailRows.length === 0) return; - const isStandard = priceMode === "standard"; - const isSupplier = priceMode === "supplier"; - - if (isStandard) { - const itemCodes = detailRows.map((r) => r.item_code).filter(Boolean); - if (itemCodes.length === 0) return; - try { - const res = await apiClient.post(`/table-management/tables/item_info/data`, { - page: 1, size: 0, - dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] }, - autoFilter: true, - }); - const items = res.data?.data?.data || res.data?.data?.rows || []; - const priceMap: Record = {}; - for (const item of items) { - const price = item.purchase_price || item.standard_price || ""; - if (price) priceMap[item.item_number] = String(price); - } - setDetailRows((prev) => prev.map((row) => { - const up = priceMap[row.item_code] || ""; - const qty = parseFloat(row.order_qty) || 0; - const price = parseFloat(up) || 0; - return { ...row, unit_price: up, amount: (qty * price).toString() }; - })); - } catch { /* skip */ } - } else if (isSupplier && supplierCode) { - const itemCodes = detailRows.map((r) => r.item_code).filter(Boolean); - if (itemCodes.length === 0) return; - try { - const res = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, { - page: 1, size: 0, - dataFilter: { enabled: true, filters: [ - { columnName: "supplier_id", operator: "equals", value: supplierCode }, - { columnName: "item_id", operator: "in", value: itemCodes }, - ]}, - autoFilter: true, - }); - const prices = res.data?.data?.data || res.data?.data?.rows || []; - const today = new Date().toISOString().slice(0, 10); - const priceMap: Record = {}; - for (const p of prices) { - if (p.start_date && p.start_date > today) continue; - if (p.end_date && p.end_date < today) continue; - const price = p.calculated_price || p.base_price || p.unit_price || ""; - if (price && Number(price) > 0) priceMap[p.item_id] = String(price); - } - setDetailRows((prev) => prev.map((row) => { - const up = priceMap[row.item_code] || ""; - const qty = parseFloat(row.order_qty) || 0; - const price = parseFloat(up) || 0; - return { ...row, unit_price: up, amount: (qty * price).toString() }; - })); - } catch { /* skip */ } - } - }, [detailRows]); - - const updateDetailRow = (idx: number, field: string, value: string) => { - setDetailRows((prev) => { - const next = [...prev]; - next[idx] = { ...next[idx], [field]: value }; - if (field === "order_qty" || field === "unit_price") { - const qty = parseFloat(field === "order_qty" ? value : next[idx].order_qty) || 0; - const price = parseFloat(field === "unit_price" ? value : next[idx].unit_price) || 0; - next[idx].amount = (qty * price).toString(); - const received = parseFloat(next[idx].received_qty) || 0; - next[idx].remain_qty = (qty - received).toString(); - } - return next; - }); - }; - - const removeDetailRow = (idx: number) => { - setDetailRows((prev) => prev.filter((_, i) => i !== idx)); - }; - - const handleExcelDownload = async () => { - if (orders.length === 0) { toast.error("다운로드할 데이터가 없어요."); return; } - const data = orders.map((o) => { - const row: Record = {}; - for (const col of EXCEL_COLUMNS) row[col.label] = o[col.key] || ""; - return row; - }); - await exportToExcel(data, "발주관리.xlsx", "발주목록"); - toast.success("다운로드 완료"); - }; + 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 ( -
- {/* 검색 필터 바 */} - +
+ - {/* 액션 바 */} -
-
-

발주 목록

- - {totalCount}건 - -
-
- - - -
- - -
- -
-
+ 총 {total.toLocaleString()}건}> + + setFilter({ ...filter, year: v })} /> + + + setFilter({ ...filter, customer_cd: v })} /> + + + setFilter({ ...filter, project_no: e.target.value })} /> + + + setFilter({ ...filter, purchase_order_no: e.target.value })} /> + + + setFilter({ ...filter, partner_objid: v })} /> + + + setFilter({ ...filter, part_no: e.target.value })} /> + + + setFilter({ ...filter, part_name: e.target.value })} /> + + + setFilter({ ...filter, delivery_start_date: v })} + to={filter.delivery_end_date ?? ""} setTo={(v) => setFilter({ ...filter, delivery_end_date: v })} + /> + + + setFilter({ ...filter, reg_start_date: v })} + to={filter.reg_end_date ?? ""} setTo={(v) => setFilter({ ...filter, reg_end_date: v })} + /> + + + setFilter({ ...filter, category_cd: v })} /> + + + setFilter({ ...filter, product_cd: v })} /> + + + setFilter({ ...filter, purchase_type: v })} /> + + + setFilter({ ...filter, writer: v })} /> + + + setFilter({ ...filter, mail_send_yn: v })} /> + + - {/* 데이터 테이블 — 영업관리 4개 메뉴와 동일한 DataGrid + logicstudio props */} openEditModal(row.purchase_no)} emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"} - showColumnSettings - paginationStyle="range" + gridId="purchase-order" pageSizeOptions={[10, 15, 20, 50, 100]} - summaryStats={[ - { label: "건수", value: totalCount.toLocaleString(), suffix: "건" }, - { label: "선택", value: checkedIds.length.toLocaleString(), suffix: "건" }, - { - label: "금액 합계", - value: orders.reduce((acc, o: any) => acc + Number(o.amount || 0), 0).toLocaleString(), - }, - ]} - onRefresh={fetchOrders} - onDownload={handleExcelDownload} + 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 = {}; + GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; }); + return out; + }); + exportToExcel(exportRows, "발주서관리.xlsx", "발주서"); + }} showChart /> - - - {/* 발주 등록/수정 모달 */} - - - - {isEditMode ? (isReadOnly ? "발주 상세" : "발주 수정") : "발주 등록"} - - {isEditMode ? (isReadOnly ? "발주 상세 정보를 확인해요." : "발주 정보를 수정해요.") : "새로운 발주를 등록해요."} - - - -
-
- {/* 기본 정보 */} -
-
- 기본 정보 -
-
-
-
- - -
-
- - setMasterForm((p) => ({ ...p, order_date: e.target.value }))} - className="h-9" - disabled={isReadOnly} - /> -
-
- - {isReadOnly ? ( -
- - {masterForm.status} - -
- ) : ( - - )} -
-
- - -
-
- - -
-
- - -
-
-
- - {/* 공급업체 / 담당자 — 입력방식이 '공급업체 우선'일 때만 표시 */} - {masterForm.input_mode === "supplierFirst" && ( -
-
- 공급업체 정보 -
-
-
- - { - const supp = categoryOptions["supplier_code"]?.find((o) => o.code === v); - const name = supp?.label.replace(` (${v})`, "") || ""; - setMasterForm((p) => ({ ...p, supplier_code: v, supplier_name: name })); - recalcPrices(masterForm.price_mode || "", v); - }} - placeholder="공급업체 선택" - disabled={isReadOnly} - /> -
-
-
- )} - - {/* 품목 내역 */} -
-
-
- 품목 내역 - - {detailRows.length} - -
- {!isReadOnly && ( - - )} -
- {detailRows.length === 0 ? ( -
- - 아직 추가된 품목이 없어요. 위 버튼으로 품목을 추가해주세요. -
- ) : ( -
- - - - c.key)} strategy={horizontalListSortingStrategy}> - - {!isReadOnly && } - {visibleModalColumns.map((col) => ( - - ))} - - - - - {detailRows.map((row, idx) => ( - - {!isReadOnly && ( - - - - )} - {visibleModalColumns.map((col) => { - switch (col.key) { - case "item_code": - return {row.item_code}; - case "item_name": - return {row.item_name}; - case "supplier": - return ( - - {isReadOnly ? ( - {(categoryOptions["supplier_code"] || []).find(o => o.code === row.supplier_code)?.label || row.supplier_code || "-"} - ) : ( - - )} - - ); - case "spec": - return {row.spec}; - case "unit": - return {row.unit}; - case "order_qty": - return ( - - {isReadOnly ? ( - {row.order_qty ? Number(row.order_qty).toLocaleString() : ""} - ) : ( - updateDetailRow(idx, "order_qty", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono w-full" /> - )} - - ); - case "received_qty": - return {row.received_qty ? Number(row.received_qty).toLocaleString() : "0"}; - case "remain_qty": - return {row.remain_qty ? Number(row.remain_qty).toLocaleString() : "0"}; - case "unit_price": - return ( - - {isReadOnly ? ( - {row.unit_price ? Number(row.unit_price).toLocaleString() : ""} - ) : ( - updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} className="h-8 text-xs text-right font-mono w-full" /> - )} - - ); - case "amount": - return {row.amount ? Number(row.amount).toLocaleString() : ""}; - case "due_date": - return ( - - {isReadOnly ? ( - {row.due_date} - ) : ( - updateDetailRow(idx, "due_date", e.target.value)} className="h-8 text-xs w-full" /> - )} - - ); - case "memo": - return ( - - {isReadOnly ? ( - {row.memo} - ) : ( - updateDetailRow(idx, "memo", e.target.value)} className="h-8 text-xs w-full" /> - )} - - ); - default: - return ; - } - })} - - ))} - -
-
-
- )} -
- - {/* 비고 */} -
-
- 비고 -
-
- setMasterForm((p) => ({ ...p, memo: e.target.value }))} - placeholder="특이사항이나 메모를 입력해주세요" - className="h-9" - disabled={isReadOnly} - /> -
-
-
- - - {isReadOnly ? ( - - ) : ( - <> - - - - )} - - - {/* 품목 선택 모달 (중첩) */} - - e.preventDefault()}> - - 품목 선택 - 발주에 추가할 품목을 선택 후 하단 버튼을 눌러주세요. - -
- setItemSearchKeyword(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && triggerNewSearch()} - className="h-9 flex-1" - /> - - -
-
- - - - - 0 && itemSearchResults.every((i) => itemSelectedMap.has(i.id))} - onChange={(e) => { - setItemSelectedMap((prev) => { - const next = new Map(prev); - if (e.target.checked) itemSearchResults.forEach((i) => next.set(i.id, i)); - else itemSearchResults.forEach((i) => next.delete(i.id)); - return next; - }); - }} /> - - 품목코드 - 품명 - 규격 - 재질 - 단위 - - - - {itemSearchResults.length === 0 ? ( - 검색 결과가 없어요 - ) : itemSearchResults.map((item) => ( - setItemSelectedMap((prev) => { - const next = new Map(prev); - if (next.has(item.id)) next.delete(item.id); else next.set(item.id, item); - return next; - })}> - - - - {item.item_number} - {item.item_name} - {item.size} - {categoryOptions["item_material"]?.find((o) => o.code === item.material)?.label || item.material} - {categoryOptions["item_inventory_unit"]?.find((o) => o.code === item.inventory_unit)?.label || item.inventory_unit} - - ))} - -
-
-
-
- 표시: - { - const v = Math.min(200, Math.max(1, Number(e.target.value) || 20)); - setItemPageSize(v); - setItemPage(1); - setItemPageInput("1"); - searchItems(1, v); - }} - className="h-7 w-14 rounded-md border px-1 text-center text-xs" /> -
-
- - - setItemPageInput(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter") { commitItemPageInput(); (e.target as HTMLInputElement).blur(); } }} - onBlur={commitItemPageInput} - onFocus={(e) => e.target.select()} - className="h-7 w-10 rounded-md border px-1 text-center text-xs" /> - / {itemTotalPages || 1} - - -
- 총 {itemTotal}건 -
- -
- {itemSelectedMap.size}개 선택됨 -
- - -
-
-
-
-
- -
- - {/* 엑셀 업로드 */} - fetchOrders()} - /> - - {/* 테이블 설정 모달 */} - - - {ConfirmDialogComponent}
); } diff --git a/frontend/components/common/PageHeader.tsx b/frontend/components/common/PageHeader.tsx index dea24e55..b7cf0654 100644 --- a/frontend/components/common/PageHeader.tsx +++ b/frontend/components/common/PageHeader.tsx @@ -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뎁스)이면 자식만 단독 표기. * * 명시 지정: - * + * * - * 원칙: - * - 모든 page.tsx 의 최상위 자식으로 를 배치한다. - * - 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 ( -
+
{resolvedTitle && ( -

{resolvedTitle}

- )} - {resolvedDesc && ( -

{resolvedDesc}

+

{resolvedTitle}

)}
{(actions || hasSearchButtons) && ( diff --git a/frontend/lib/api/purchase.ts b/frontend/lib/api/purchase.ts index 12b7af33..f25d0239 100644 --- a/frontend/lib/api/purchase.ts +++ b/frontend/lib/api/purchase.ts @@ -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 {