영업관리 구매요청 2메뉴 액션 완성 + SmartSelect 키보드 네비

- sales_request_part DDL 추출(운영 11133)→RPS(11134) 마이그레이션
- 백엔드 6 엔드포인트: 프로젝트 자동채움/M-BOM 품목/저장/품의서생성/SSO
  · 품의서 결재상신 Amaranth SSO (target_type=PROPOSAL, formId=1163)
- 프론트 다이얼로그 2개 (구매요청서작성 / 품의서생성 확인)
  · 프로젝트 선택→주문유형·제품구분·국내외·고객사·유무상 자동 채움
  · 행추가 시 M-BOM 품번 셀렉트→품명/공급업체/단가 자동 셋팅
- 공용 SmartSelect: ↑↓·Enter·Esc·Home·End·PageUp·Down 키보드 네비
- 그리드 delivery_request_date . → - 형식 정규화

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-15 14:01:26 +09:00
parent 3db55d9fd9
commit 75f4ca8127
11 changed files with 1494 additions and 29 deletions
@@ -1,8 +1,14 @@
// ============================================================
// 영업관리 > 구매요청서관리 / 품의서관리 라우트
// 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' 그리드
// GET /api/sales/purchase-request — 구매요청서 그리드
// GET /api/sales/purchase-request/mbom-parts — 프로젝트별 M-BOM 품목 목록 (다이얼로그용)
// GET /api/sales/purchase-request/:objid — 단건 + 라인
// GET /api/sales/purchase-request/:objid/proposal-targets — 품의서 대상 품목
// POST /api/sales/purchase-request — 저장(UPSERT master + 라인 재생성)
// POST /api/sales/purchase-request/:objid/proposal — 품의서 생성
// GET /api/sales/purchase-proposal — 영업>품의서 그리드
// POST /api/sales/purchase-proposal/:objid/approval — Amaranth SSO 결재상신
// ============================================================
import { Router, Response } from "express";
@@ -10,6 +16,7 @@ import { authenticateToken } from "../middleware/authMiddleware";
import { AuthenticatedRequest } from "../types/auth";
import * as svc from "../services/salesPurchaseRequestService";
import { logger } from "../utils/logger";
import { AppError } from "../middleware/errorHandler";
const router = Router();
router.use(authenticateToken);
@@ -36,7 +43,99 @@ async function run(
}
}
function handleError(res: Response, e: any, label: string) {
if (e instanceof AppError) {
return res.status(e.statusCode).json({ success: false, message: e.message });
}
logger.error(`${label} 실패`, { error: e?.message });
return res.status(500).json({ success: false, message: e?.message ?? `${label} 실패` });
}
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, "영업>품의서관리"));
// 프로젝트 자동채움 정보 (주문유형/제품구분/국내외/고객사/유무상 + mbom_header_objid)
router.get("/purchase-request/project-info/:projectObjid", async (req, res) => {
try {
const info = await svc.getProjectAutoFillInfo(req.params.projectObjid);
return res.json({ success: true, data: info });
} catch (e: any) {
return handleError(res, e, "프로젝트 자동채움 정보");
}
});
// 프로젝트별 M-BOM 품목 (행추가 시 품번 셀렉트 옵션)
router.get("/purchase-request/mbom-parts", async (req, res) => {
try {
const projectObjid = String(req.query.project_objid ?? "");
if (!projectObjid) return res.json({ success: true, data: [] });
const rows = await svc.listMbomPartsForProject(projectObjid);
return res.json({ success: true, data: rows });
} catch (e: any) {
return handleError(res, e, "M-BOM 품목 조회");
}
});
// 단건 + 라인
router.get("/purchase-request/:objid", async (req, res) => {
try {
const detail = await svc.getPurchaseRequestDetail(req.params.objid);
return res.json({ success: true, data: detail });
} catch (e: any) {
return handleError(res, e, "구매요청서 상세");
}
});
// 품의서 대상 품목
router.get("/purchase-request/:objid/proposal-targets", async (req, res) => {
try {
const data = await svc.getProposalTargetParts(req.params.objid);
return res.json({ success: true, data });
} catch (e: any) {
return handleError(res, e, "품의서 대상 품목");
}
});
// 저장 (신규/수정 UPSERT)
router.post("/purchase-request", async (req, res) => {
const ar = req as AuthenticatedRequest;
try {
const userId = ar.user?.userId;
if (!userId) return res.status(401).json({ success: false, message: "인증 필요" });
const out = await svc.savePurchaseRequest(userId, req.body);
return res.json({ success: true, data: out });
} catch (e: any) {
return handleError(res, e, "구매요청서 저장");
}
});
// 품의서 생성
router.post("/purchase-request/:objid/proposal", async (req, res) => {
const ar = req as AuthenticatedRequest;
try {
const userId = ar.user?.userId;
if (!userId) return res.status(401).json({ success: false, message: "인증 필요" });
const out = await svc.createProposalFromPurchaseReg(userId, req.params.objid);
return res.json({ success: true, data: out });
} catch (e: any) {
return handleError(res, e, "품의서 생성");
}
});
// 결재상신 (Amaranth SSO)
router.post("/purchase-proposal/:objid/approval", async (req, res) => {
const ar = req as AuthenticatedRequest;
try {
const userId = ar.user?.userId;
if (!userId) return res.status(401).json({ success: false, message: "인증 필요" });
const out = await svc.startProposalApproval(userId, req.params.objid, {
approvalTitle: req.body?.approvalTitle,
subjectStr: req.body?.subjectStr,
});
return res.json({ success: true, data: out });
} catch (e: any) {
return handleError(res, e, "품의서 결재상신");
}
});
export default router;
@@ -17,6 +17,9 @@
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
import { createObjId } from "../utils/objidUtil";
import { AppError } from "../middleware/errorHandler";
import * as amaranth from "./amaranthApprovalClient";
export interface SalesPurchaseRequestFilter {
project_no?: string;
@@ -151,7 +154,8 @@ export async function listPurchaseRequestReg(
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,
REPLACE(COALESCE(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
@@ -294,3 +298,537 @@ export async function listPurchaseRegProposal(
return { rows: [], totalCount: 0, page, pageSize };
}
}
// ─── 3) 구매요청서 단건 + 라인 조회 ─────────────────────────────
// wace: getSalesRequestMasterInfo + getSalesRequestTargetList
export async function getPurchaseRequestDetail(srmObjid: string) {
const pool = getPool();
const headRes = await pool.query(
`SELECT
SRM.OBJID, SRM.REQUEST_MNG_NO, SRM.PROJECT_NO, SRM.MBOM_HEADER_OBJID,
SRM.PURCHASE_TYPE, SRM.ORDER_TYPE, SRM.PRODUCT_NAME, SRM.AREA_CD,
SRM.CUSTOMER_OBJID, SRM.PAID_TYPE, SRM.DELIVERY_REQUEST_DATE,
SRM.REQUEST_USER_ID, SRM.WRITER, SRM.STATUS, SRM.DOC_TYPE,
PM.PROJECT_NO AS PROJECT_NUMBER, PM.PROJECT_NAME,
PM.CATEGORY_CD, PM.CONTRACT_OBJID
FROM SALES_REQUEST_MASTER SRM
LEFT JOIN PROJECT_MGMT PM ON PM.OBJID::VARCHAR = SRM.PROJECT_NO
WHERE SRM.OBJID = $1`,
[srmObjid],
);
if (headRes.rowCount === 0) throw new AppError("구매요청서를 찾을 수 없습니다.", 404);
const partRes = await pool.query(
`SELECT SRP.OBJID, SRP.PART_OBJID,
COALESCE(PM.PART_NO, '') AS PART_NO,
COALESCE(PM.PART_NAME, '') AS PART_NAME,
COALESCE(SRP.QTY, '0') AS QTY,
SRP.ORG_QTY, SRP.PARTNER_OBJID,
COALESCE(SRP.PARTNER_PRICE, '') AS PARTNER_PRICE,
COALESCE(SRP.UNIT_PRICE, 0) AS UNIT_PRICE,
COALESCE(SRP.VENDOR_PM, '') AS VENDOR_PM,
SRP.DELIVERY_REQUEST_DATE, SRP.STATUS, SRP.PROPOSAL_DATE
FROM SALES_REQUEST_PART SRP
LEFT JOIN PART_MNG PM ON PM.OBJID::VARCHAR = SRP.PART_OBJID::VARCHAR
WHERE SRP.SALES_REQUEST_MASTER_OBJID = $1
ORDER BY SRP.REGDATE`,
[srmObjid],
);
return { header: headRes.rows[0], parts: partRes.rows };
}
// ─── 3-1) 프로젝트 자동채움 정보 (wace purchaseOrderAdminSupplyInfo 1:1) ─
// 프로젝트 선택 시 주문유형(CATEGORY_CD) · 제품구분(PRODUCT) · 국내/해외(AREA_CD) ·
// 고객사(CUSTOMER_OBJID) · 유/무상(PAID_TYPE) 자동 채움 + M-BOM 헤더.
export async function getProjectAutoFillInfo(projectObjid: string) {
const pool = getPool();
const sql = `
SELECT
PM.OBJID,
PM.PROJECT_NO,
PM.PROJECT_NAME,
PM.CATEGORY_CD,
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = PM.CATEGORY_CD LIMIT 1) AS CATEGORY_NAME,
PM.CUSTOMER_OBJID,
CASE
WHEN PM.CUSTOMER_OBJID LIKE 'C_%'
THEN (SELECT CLIENT_NM FROM CLIENT_MNG WHERE 'C_' || OBJID::VARCHAR = PM.CUSTOMER_OBJID LIMIT 1)
ELSE (SELECT SUPPLY_NAME FROM SUPPLY_MNG WHERE OBJID::VARCHAR = PM.CUSTOMER_OBJID::VARCHAR LIMIT 1)
END AS CUSTOMER_NAME,
PM.PRODUCT,
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = PM.PRODUCT LIMIT 1) AS PRODUCT_NAME,
PM.AREA_CD,
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = PM.AREA_CD LIMIT 1) AS AREA_NAME,
CM.PAID_TYPE,
CM.OBJID AS CONTRACT_OBJID,
(SELECT MH.OBJID FROM MBOM_HEADER MH
WHERE MH.PROJECT_OBJID::VARCHAR = PM.OBJID::VARCHAR
ORDER BY MH.REGDATE DESC LIMIT 1) AS MBOM_HEADER_OBJID
FROM PROJECT_MGMT PM
LEFT JOIN CONTRACT_MGMT CM ON PM.CONTRACT_OBJID = CM.OBJID::VARCHAR
WHERE PM.OBJID::VARCHAR = $1
LIMIT 1
`;
try {
const r = await pool.query(sql, [projectObjid]);
return r.rows[0] || null;
} catch (e: any) {
logger.error("getProjectAutoFillInfo 실패", { error: e.message, projectObjid });
return null;
}
}
// ─── 4) 프로젝트별 M-BOM 품목 (구매요청서 신규 작성용) ─────────
// wace: salesMng.SalesBomPartListByProjectUnit (mbom_detail → part_mng)
export async function listMbomPartsForProject(projectObjid: string) {
const pool = getPool();
const sql = `
SELECT
MD.OBJID AS mbom_detail_objid,
MD.PART_OBJID AS part_objid,
MH.OBJID AS mbom_header_objid,
COALESCE(PM.PART_NO, '') AS part_no,
COALESCE(PM.PART_NAME, '') AS part_name,
COALESCE(MD.UNIT, '') AS unit,
COALESCE(MD.PRODUCTION_QTY, MD.PO_QTY, 0) AS qty,
COALESCE(MD.UNIT_PRICE, 0) AS unit_price,
COALESCE(MD.VENDOR, '') AS vendor_objid,
CASE
WHEN MD.VENDOR IS NULL OR MD.VENDOR = '' THEN ''
WHEN MD.VENDOR LIKE 'C_%'
THEN (SELECT CLIENT_NM FROM CLIENT_MNG WHERE 'C_' || OBJID::VARCHAR = MD.VENDOR LIMIT 1)
ELSE (SELECT CLIENT_NM FROM CLIENT_MNG WHERE OBJID::VARCHAR = MD.VENDOR LIMIT 1)
END AS vendor_name
FROM MBOM_HEADER MH
JOIN MBOM_DETAIL MD ON MD.MBOM_HEADER_OBJID = MH.OBJID
LEFT JOIN PART_MNG PM ON PM.OBJID::VARCHAR = MD.PART_OBJID::VARCHAR
WHERE MH.PROJECT_OBJID::VARCHAR = $1
AND COALESCE(MD.USE_YN, 'Y') = 'Y'
ORDER BY MD.REGDATE
`;
try {
const r = await pool.query(sql, [projectObjid]);
return r.rows;
} catch (e: any) {
logger.error("listMbomPartsForProject 실패", { error: e.message, projectObjid });
return [];
}
}
// ─── 5) 구매요청서 저장 (UPSERT master + 라인 재생성) ────────────
// wace: saveSalesRequestInfo → mergeSalesRequestMasterInfo + initSalesRequestPart + mergeSalesRequestPartInfo
// doc_type='PURCHASE_REG' 명시 (구매요청서관리 신규 메뉴 흐름).
// request_mng_no 채번: R + YYYYMMDD + - + 3자리 (wace mergeSalesRequestMasterInfo 1:1)
export interface SavePurchaseRequestPayload {
objid?: string;
project_no?: string;
mbom_header_objid?: string;
purchase_type?: string;
order_type?: string;
product_name?: string;
area_cd?: string;
customer_objid?: string;
paid_type?: string;
delivery_request_date?: string;
parts: Array<{
objid?: string;
part_objid: string;
part_name?: string;
qty?: string | number;
org_qty?: string | number;
partner_objid?: string;
partner_price?: string | number;
delivery_request_date?: string;
status?: string;
}>;
}
export async function savePurchaseRequest(userId: string, payload: SavePurchaseRequestPayload) {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
let srmObjid = payload.objid;
const isNew = !srmObjid;
if (isNew) srmObjid = createObjId();
// 채번 — wace mergeSalesRequestMasterInfo 의 SELECT 절 1:1
const nextNoSql = `
SELECT 'R'||TO_CHAR(NOW(),'YYYYMMDD')||'-'||
LPAD((COALESCE(MAX(SUBSTR(REQUEST_MNG_NO,11,13)),'0')::INTEGER+1)::TEXT,3,'0') AS no
FROM SALES_REQUEST_MASTER
WHERE DOC_TYPE IN ('PURCHASE_REQUEST','PURCHASE_REG') OR DOC_TYPE IS NULL
`;
let requestMngNo: string | null = null;
if (isNew) {
const noRes = await client.query(nextNoSql);
requestMngNo = noRes.rows[0]?.no ?? null;
}
if (isNew) {
await client.query(
`INSERT INTO SALES_REQUEST_MASTER
(OBJID, REQUEST_MNG_NO, PROJECT_NO, MBOM_HEADER_OBJID,
PURCHASE_TYPE, ORDER_TYPE, PRODUCT_NAME, AREA_CD,
CUSTOMER_OBJID, PAID_TYPE, DELIVERY_REQUEST_DATE,
REQUEST_USER_ID, STATUS, WRITER, REGDATE, DOC_TYPE)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'create',$12,NOW(),'PURCHASE_REG')`,
[
srmObjid, requestMngNo, payload.project_no, payload.mbom_header_objid,
payload.purchase_type, payload.order_type, payload.product_name, payload.area_cd,
payload.customer_objid, payload.paid_type, payload.delivery_request_date || null,
userId,
],
);
} else {
await client.query(
`UPDATE SALES_REQUEST_MASTER
SET PROJECT_NO=$2, MBOM_HEADER_OBJID=$3,
PURCHASE_TYPE=$4, ORDER_TYPE=$5, PRODUCT_NAME=$6, AREA_CD=$7,
CUSTOMER_OBJID=$8, PAID_TYPE=$9, DELIVERY_REQUEST_DATE=$10
WHERE OBJID=$1`,
[
srmObjid, payload.project_no, payload.mbom_header_objid,
payload.purchase_type, payload.order_type, payload.product_name, payload.area_cd,
payload.customer_objid, payload.paid_type, payload.delivery_request_date || null,
],
);
}
// 라인 재생성 (wace initSalesRequestPart + mergeSalesRequestPartInfo)
await client.query(`DELETE FROM SALES_REQUEST_PART WHERE SALES_REQUEST_MASTER_OBJID=$1`, [srmObjid]);
for (const p of payload.parts || []) {
const partObjid = p.objid || createObjId();
const qtyVal = p.qty == null ? "0" : String(p.qty);
const orgQtyVal = p.org_qty == null ? "0" : String(p.org_qty);
await client.query(
`INSERT INTO SALES_REQUEST_PART
(OBJID, SALES_REQUEST_MASTER_OBJID, PART_OBJID, PART_NAME,
QTY, ORG_QTY, PARTNER_OBJID, PARTNER_PRICE,
DELIVERY_REQUEST_DATE, WRITER, REGDATE, STATUS)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW(),$11)`,
[
partObjid, srmObjid, p.part_objid, p.part_name || null,
qtyVal, orgQtyVal, p.partner_objid || null,
p.partner_price == null ? null : String(p.partner_price),
p.delivery_request_date || null,
userId, p.status || "create",
],
);
}
await client.query("COMMIT");
return { objid: srmObjid, request_mng_no: requestMngNo, isNew };
} catch (e: any) {
await client.query("ROLLBACK");
logger.error("savePurchaseRequest 실패", { error: e.message });
throw e;
} finally {
client.release();
}
}
// ─── 6) 품의서생성 대상 품목 조회 ──────────────────────────────
// wace: getProposalTargetPartsFromPurchaseReg
// 조건: 단가(UNIT_PRICE 또는 PARTNER_PRICE) > 0 AND 공급업체(VENDOR_PM 또는 PARTNER_OBJID) 입력 AND PROPOSAL_DATE 미입력
export async function getProposalTargetParts(srmObjid: string) {
const pool = getPool();
const targetSql = `
SELECT
SRP.OBJID, SRP.PART_OBJID,
COALESCE(PM.PART_NO, '') AS PART_NO,
COALESCE(PM.PART_NAME, '') AS PART_NAME,
COALESCE(SRP.QTY, '0') AS QTY,
COALESCE(SRP.UNIT_PRICE, NULLIF(SRP.PARTNER_PRICE,'')::NUMERIC, 0) AS UNIT_PRICE,
SRP.TOTAL_PRICE,
COALESCE(SRP.VENDOR_PM, SRP.PARTNER_OBJID, '') AS VENDOR_PM,
CASE
WHEN COALESCE(SRP.VENDOR_PM, SRP.PARTNER_OBJID) IS NULL THEN ''
WHEN COALESCE(SRP.VENDOR_PM, SRP.PARTNER_OBJID) LIKE 'C_%'
THEN (SELECT CLIENT_NM FROM CLIENT_MNG WHERE 'C_' || OBJID::VARCHAR = COALESCE(SRP.VENDOR_PM, SRP.PARTNER_OBJID) LIMIT 1)
ELSE (SELECT CLIENT_NM FROM CLIENT_MNG WHERE OBJID::VARCHAR = COALESCE(SRP.VENDOR_PM, SRP.PARTNER_OBJID) LIMIT 1)
END AS VENDOR_NAME
FROM SALES_REQUEST_PART SRP
LEFT JOIN PART_MNG PM ON SRP.PART_OBJID::VARCHAR = PM.OBJID::VARCHAR
WHERE SRP.SALES_REQUEST_MASTER_OBJID = $1
AND (
(SRP.UNIT_PRICE IS NOT NULL AND SRP.UNIT_PRICE > 0)
OR (SRP.PARTNER_PRICE IS NOT NULL AND SRP.PARTNER_PRICE <> '' AND SRP.PARTNER_PRICE::NUMERIC > 0)
)
AND (
(SRP.VENDOR_PM IS NOT NULL AND SRP.VENDOR_PM <> '')
OR (SRP.PARTNER_OBJID IS NOT NULL AND SRP.PARTNER_OBJID <> '')
)
AND SRP.PROPOSAL_DATE IS NULL
ORDER BY SRP.REGDATE
`;
const excludedSql = `
SELECT
SRP.OBJID, SRP.PART_OBJID,
COALESCE(PM.PART_NO,'') AS PART_NO,
COALESCE(PM.PART_NAME,'') AS PART_NAME,
COALESCE(SRP.QTY,'0') AS QTY
FROM SALES_REQUEST_PART SRP
LEFT JOIN PART_MNG PM ON SRP.PART_OBJID::VARCHAR = PM.OBJID::VARCHAR
WHERE SRP.SALES_REQUEST_MASTER_OBJID = $1
AND (
(SRP.UNIT_PRICE IS NOT NULL AND SRP.UNIT_PRICE > 0)
OR (SRP.PARTNER_PRICE IS NOT NULL AND SRP.PARTNER_PRICE <> '' AND SRP.PARTNER_PRICE::NUMERIC > 0)
)
AND (SRP.VENDOR_PM IS NULL OR SRP.VENDOR_PM = '')
AND (SRP.PARTNER_OBJID IS NULL OR SRP.PARTNER_OBJID = '')
AND SRP.PROPOSAL_DATE IS NULL
ORDER BY SRP.REGDATE
`;
const [t, x] = await Promise.all([pool.query(targetSql, [srmObjid]), pool.query(excludedSql, [srmObjid])]);
return { targets: t.rows, excluded: x.rows };
}
// ─── 7) 품의서 생성 (PURCHASE_REG → PURCHASE_REG_PROPOSAL) ─────
// wace: createProposalFromPurchaseReg
// 1) request_mng_no 채번 (P + YYYYMMDD + - + 3자리)
// 2) sales_request_master INSERT (DOC_TYPE='PURCHASE_REG_PROPOSAL', PROJECT_NO=원본 OBJID, STATUS='create')
// 3) 선택된 SRP 행을 새 master 로 복사 (UNIT_PRICE/VENDOR_PM 보정, PROPOSAL_DATE=NOW)
// 4) 원본 SRP.PROPOSAL_DATE = NOW (재생성 방지)
export async function createProposalFromPurchaseReg(userId: string, srmObjid: string) {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
// 1) 원본 master 정보 조회
const masterRes = await client.query(
`SELECT OBJID, PROJECT_NO, PURCHASE_TYPE, ORDER_TYPE, PRODUCT_NAME, AREA_CD,
CUSTOMER_OBJID, PAID_TYPE, DELIVERY_REQUEST_DATE, MBOM_HEADER_OBJID, DOC_TYPE
FROM SALES_REQUEST_MASTER WHERE OBJID=$1`,
[srmObjid],
);
if (masterRes.rowCount === 0) throw new AppError("구매요청서를 찾을 수 없습니다.", 404);
const m = masterRes.rows[0];
if (m.doc_type && m.doc_type !== "PURCHASE_REG" && m.doc_type !== "PURCHASE_REQUEST") {
throw new AppError("구매요청서만 품의서를 생성할 수 있습니다.", 400);
}
// 2) 대상 품목 조회 (위 getProposalTargetParts 와 동일 SQL — 트랜잭션 내 client 재사용)
const partsRes = await client.query(
`SELECT SRP.OBJID, SRP.PART_OBJID, SRP.QTY, SRP.UNIT_PRICE, SRP.TOTAL_PRICE,
SRP.PARTNER_PRICE, SRP.VENDOR_PM, SRP.PARTNER_OBJID, SRP.NET_QTY, SRP.USE_YN
FROM SALES_REQUEST_PART SRP
WHERE SRP.SALES_REQUEST_MASTER_OBJID = $1
AND (
(SRP.UNIT_PRICE IS NOT NULL AND SRP.UNIT_PRICE > 0)
OR (SRP.PARTNER_PRICE IS NOT NULL AND SRP.PARTNER_PRICE <> '' AND SRP.PARTNER_PRICE::NUMERIC > 0)
)
AND (
(SRP.VENDOR_PM IS NOT NULL AND SRP.VENDOR_PM <> '')
OR (SRP.PARTNER_OBJID IS NOT NULL AND SRP.PARTNER_OBJID <> '')
)
AND SRP.PROPOSAL_DATE IS NULL
ORDER BY SRP.REGDATE`,
[srmObjid],
);
if (partsRes.rowCount === 0) {
throw new AppError("품의서 생성 대상 품목이 없습니다. (단가+공급업체 입력 + 품의서 미생성 품목만 대상)", 400);
}
// 3) 품의서 채번
const proposalNoRes = await client.query(
`SELECT 'P'||TO_CHAR(NOW(),'YYYYMMDD')||'-'||
LPAD((COALESCE(MAX(SUBSTR(REQUEST_MNG_NO,11,3))::INTEGER, 0)+1)::TEXT,3,'0') AS no
FROM SALES_REQUEST_MASTER
WHERE DOC_TYPE IN ('PROPOSAL','PURCHASE_REG_PROPOSAL')
AND REQUEST_MNG_NO LIKE 'P'||TO_CHAR(NOW(),'YYYYMMDD')||'%'`,
);
const proposalNo = proposalNoRes.rows[0]?.no;
// 4) 품의서 master INSERT (PROJECT_NO=원본 OBJID — 자식 관계 추적)
const proposalObjid = createObjId();
await client.query(
`INSERT INTO SALES_REQUEST_MASTER
(OBJID, REQUEST_MNG_NO, PROJECT_NO, PURCHASE_TYPE, ORDER_TYPE, PRODUCT_NAME,
AREA_CD, CUSTOMER_OBJID, PAID_TYPE, REQUEST_USER_ID, DELIVERY_REQUEST_DATE,
STATUS, WRITER, REGDATE, DOC_TYPE)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'create',$10,NOW(),'PURCHASE_REG_PROPOSAL')`,
[
proposalObjid, proposalNo, m.objid,
m.purchase_type, m.order_type, m.product_name,
m.area_cd, m.customer_objid, m.paid_type, userId,
m.delivery_request_date,
],
);
// 5) 품의서 part INSERT (원본 SRP 복사, PROPOSAL_DATE=NOW)
const sourceObjids: string[] = [];
for (const p of partsRes.rows) {
sourceObjids.push(p.objid);
const newPartObjid = createObjId();
await client.query(
`INSERT INTO SALES_REQUEST_PART
(OBJID, SALES_REQUEST_MASTER_OBJID, PART_OBJID, QTY, UNIT_PRICE, TOTAL_PRICE,
VENDOR_PM, NET_QTY, PO_QTY, USE_YN, PROPOSAL_DATE, WRITER, REGDATE)
VALUES ($1, $2, $3, $4,
CASE
WHEN $5::NUMERIC IS NOT NULL AND $5::NUMERIC > 0 THEN $5::NUMERIC
WHEN $6 IS NOT NULL AND $6 <> '' THEN $6::NUMERIC
ELSE 0
END,
CASE
WHEN $7::NUMERIC IS NOT NULL AND $7::NUMERIC > 0 THEN $7::NUMERIC
ELSE COALESCE(NULLIF($4,'')::NUMERIC,0) *
CASE
WHEN $5::NUMERIC IS NOT NULL AND $5::NUMERIC > 0 THEN $5::NUMERIC
WHEN $6 IS NOT NULL AND $6 <> '' THEN $6::NUMERIC
ELSE 0
END
END,
COALESCE(NULLIF($8,''), $9),
$10,
COALESCE(NULLIF($4,'')::NUMERIC, 0),
$11,
NOW(), $12, NOW())`,
[
newPartObjid, proposalObjid, p.part_objid, p.qty,
p.unit_price, p.partner_price, p.total_price,
p.vendor_pm, p.partner_objid, p.net_qty,
p.use_yn || "Y", userId,
],
);
}
// 6) 원본 SRP.PROPOSAL_DATE 업데이트 (재생성 방지)
await client.query(
`UPDATE SALES_REQUEST_PART SET PROPOSAL_DATE = NOW() WHERE OBJID = ANY($1::varchar[])`,
[sourceObjids],
);
await client.query("COMMIT");
logger.info("품의서 생성", { srmObjid, proposalObjid, proposalNo, partCount: sourceObjids.length });
return { proposal_objid: proposalObjid, proposal_no: proposalNo, part_count: sourceObjids.length };
} catch (e: any) {
await client.query("ROLLBACK");
if (e instanceof AppError) throw e;
logger.error("createProposalFromPurchaseReg 실패", { error: e.message });
throw new AppError(`품의서 생성 실패: ${e.message}`, 500);
} finally {
client.release();
}
}
// ─── 8) 품의서 결재상신 (Amaranth SSO) ──────────────────────────
// wace: purchaseRegProposalMngList.jsp:75~99 fn_openAmaranthApproval + ApprovalService.getAmaranthSsoUrl
// target_type='PROPOSAL', formId='1163', compSeq='1000'
// 재상신/재사용 로직은 G11(견적) 동일 패턴.
export async function startProposalApproval(
userId: string,
proposalSrmObjid: string,
opts: { approvalTitle?: string; subjectStr?: string } = {},
): Promise<{ fullUrl: string; approKey: string; status: string; proposalObjid: string }> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const userRes = await client.query(
`SELECT user_id, user_name, emp_seq FROM user_info WHERE user_id=$1 LIMIT 1`,
[userId],
);
const u = userRes.rows[0];
if (!u) throw new AppError("사용자 정보를 찾을 수 없습니다.", 401);
const empSeq = String(u.emp_seq ?? "").trim();
if (!empSeq) throw new AppError(`empSeq 정보가 없습니다. (userId: ${userId}) 관리자에게 문의하세요.`, 400);
const headRes = await client.query(
`SELECT OBJID, REQUEST_MNG_NO, DOC_TYPE, STATUS
FROM SALES_REQUEST_MASTER WHERE OBJID=$1`,
[proposalSrmObjid],
);
if (headRes.rowCount === 0) throw new AppError("품의서를 찾을 수 없습니다.", 404);
const head = headRes.rows[0];
if (head.doc_type !== "PURCHASE_REG_PROPOSAL" && head.doc_type !== "PROPOSAL") {
throw new AppError("품의서만 결재상신할 수 있습니다.", 400);
}
const targetType = "PROPOSAL";
const targetObjid = String(proposalSrmObjid);
const approvalTitle = opts.approvalTitle || `품의서 결재${head.request_mng_no ? " - " + head.request_mng_no : ""}`;
const outProcessCode =
process.env.AMARANTH_OUT_PROCESS_CODE_PROPOSAL ||
process.env.AMARANTH_OUT_PROCESS_CODE || "";
const formId = process.env.AMARANTH_FORM_ID_PROPOSAL || "1163";
const compSeq = process.env.AMARANTH_COMP_SEQ || "1000";
const existRes = await client.query(
`SELECT objid, appro_key, status FROM amaranth_approval
WHERE target_type=$1 AND target_objid=$2
ORDER BY regdate DESC LIMIT 1`,
[targetType, targetObjid],
);
let approKey: string;
let mode: "insert" | "update_resubmit" | "update_reuse";
let existingObjid: number | null = null;
if (existRes.rowCount === 0) {
approKey = "UB_" + Date.now().toString(36).toUpperCase();
mode = "insert";
} else {
existingObjid = existRes.rows[0].objid;
const existingStatus = String(existRes.rows[0].status || "");
if (["reject", "delete", "create"].includes(existingStatus)) {
approKey = "UB_" + Date.now().toString(36).toUpperCase();
mode = "update_resubmit";
} else {
approKey = String(existRes.rows[0].appro_key || "");
mode = "update_reuse";
}
}
const ssoRes = await amaranth.getSsoUrl({
empSeq,
outProcessCode: outProcessCode || undefined,
formId,
approKey,
subjectStr: opts.subjectStr || approvalTitle,
mod: "W",
compSeq,
deptSeq: "",
loginId: u.user_id,
});
const fullUrl: string = ssoRes?.resultData?.fullUrl || ssoRes?.fullUrl || "";
const resultCode = String(ssoRes?.resultCode ?? ssoRes?.resultData?.resultCode ?? "");
if (!fullUrl || (resultCode !== "0" && resultCode !== "")) {
const msg = ssoRes?.resultMsg || ssoRes?.resultData?.resultMsg || "SSO URL 생성 실패";
throw new AppError(`결재 연동 오류: ${msg}`, 502);
}
if (mode === "insert") {
const objid = Date.now();
await client.query(
`INSERT INTO amaranth_approval
(objid, target_objid, target_type, appro_key, out_process_code, form_id,
status, emp_seq, comp_seq, dept_seq, writer, sso_url, regdate)
VALUES ($1, $2, $3, $4, $5, $6, 'create', $7, $8, '', $9, $10, NOW())`,
[objid, targetObjid, targetType, approKey, outProcessCode || null, formId, empSeq, compSeq, userId, fullUrl],
);
} else {
const resetStatus = mode === "update_resubmit" ? "create" : null;
await client.query(
`UPDATE amaranth_approval
SET appro_key=$2, sso_url=$3, writer=$4,
status=COALESCE($5, status),
editdate=NOW()
WHERE objid=$1`,
[existingObjid, approKey, fullUrl, userId, resetStatus],
);
}
await client.query("COMMIT");
logger.info("품의서 결재상신", { proposalSrmObjid, approKey, mode });
return { fullUrl, approKey, status: "create", proposalObjid: targetObjid };
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
+7 -11
View File
@@ -10,8 +10,8 @@
| 상태 | 메뉴 수 | 의미 |
|---|---|---|
| ✅ 완료 | 17 | wace 1:1 검증 PASS 또는 마이너 차이만 (기능/SQL 일치) |
| 🟡 베이스 | 7 | 그리드/검색 완료, 액션 모달 또는 detail SQL 일부 미진 |
| ✅ 완료 | 19 | wace 1:1 검증 PASS 또는 마이너 차이만 (기능/SQL 일치) |
| 🟡 베이스 | 5 | 그리드/검색 완료, 액션 모달 또는 detail SQL 일부 미진 |
| 🟠 빈 그리드 | 3 | 화면은 있으나 데이터 SQL 미연결 (의존 테이블 DDL 추출 선행 필요) |
| 🔴 미진 | 0 | — |
@@ -23,8 +23,8 @@
| 2 | 영업관리 | 주문서관리 | `sales/order` | ✅ | — |
| 3 | 영업관리 | 판매관리 | `sales/sale` | ✅ | — |
| 4 | 영업관리 | 매출관리 | `sales/revenue` | ✅ | — |
| 5 | 영업/구매요청 | 구매요청서관리 | `purchase-request/request` (이동 중) | 🟡 | 구매요청서작성 다이얼로그 · 품의서생성 액션 |
| 6 | 영업/구매요청 | 품의서관리(영업) | `purchase-request/proposal` (이동 중) | 🟡 | Amaranth 결재상신 (`target_type='PROPOSAL'`, `formId='1163'`) |
| 5 | 영업/구매요청 | 구매요청서관리 | `purchase-request/request` | | |
| 6 | 영업/구매요청 | 품의서관리(영업) | `purchase-request/proposal` | | |
| 7 | 프로젝트관리 | 진행관리 | `project/progress` | ✅ | — |
| 8 | 프로젝트관리 | 제품구분_WBS관리 | `project/wbs-template` | ✅ | — |
| 9 | 개발관리 | PART 등록 | `development/part-regist` | ✅ | — |
@@ -52,7 +52,7 @@
| 도메인 | 메뉴 | 마감도 | 대표 커밋 | 상세 문서 |
|---|---|---|---|---|
| **영업관리** | 4 | 100% (G6 메일 발송까지) | (다수) | [sales/README.md](./sales/README.md) |
| **구매요청** (영업↔구매 교차) | 2 | 베이스 (액션 모달 미진) | `7e7c6a0a` | [sales/09-purchase-request.md](./sales/09-purchase-request.md) |
| **구매요청** (영업↔구매 교차) | 2 | 100% (작성·품의서생성·결재상신 SSO 완료) | `7e7c6a0a` + 본 작업 | [sales/09-purchase-request.md](./sales/09-purchase-request.md) |
| **프로젝트관리** | 2 | 100% | `a1ace226` / `332688a4` / `7c4817b0` / `50669a66` | [project/00-gap.md](./project/00-gap.md) |
| **개발관리** | 5 | 100% + Import + 도면 다중 업로드 (16 커밋) | (PR-A/B/C 다수) | [development/00-gap.md](./development/00-gap.md) |
| **구매관리** | 9 | 1차 스캐폴드 (마스터 3 데이터 노출 / detail 4 빈 그리드 / 발주·M-BOM 완료) | `b38f5957` | (메모리만) |
@@ -109,12 +109,8 @@
## 6. 다음 작업 우선순위 (제안)
1. **구매요청 2메뉴 액션 완성** — 영업↔구매 교차 핵심
- 구매요청서작성 다이얼로그
- 품의서생성 액션
- 결재상신 (`target_type='PROPOSAL'`, `formId='1163'`)
- 선행: `sales_request_part` 운영DB DDL 추출
2. **구매관리 빈 그리드 4개 보강**`sales_request_part` 추출 후 quote-request / inbound 3종 detail SQL
1. ~~**구매요청 2메뉴 액션 완성**~~ — ✅ 2026-05-15 완료 ([sales/09-purchase-request.md §6](./sales/09-purchase-request.md))
2. **구매관리 빈 그리드 4개 보강**`sales_request_part` 추출 완료, quote-request / inbound 3종 detail SQL 연결만 남음
3. **plan-result 액션 모달**`prodPlanFormPopup.jsp` / `prodResultFormPopup.jsp` 1:1
4. **공통 PartSelect 컴포넌트** — wace `Select2-part`(AJAX 자동완성), 영업/생산/구매 다수 메뉴 공통
5. **품질관리 후속** (chpark 베이스 4메뉴 상세화) / **자재관리 신규 도메인 진입**
+43 -7
View File
@@ -113,14 +113,50 @@ wace 매퍼 `salesMng.xml:4805~4812` 1:1.
### 5.3 sales_request_part 누락 처리
wace 원본은 `SALES_REQUEST_PART` (구매요청 라인) 테이블을 사용해 품번/품명 집계. RPS 에는 미존재 → **MBOM_DETAIL → PART_MNG fallback** (구매관리 패턴 동일). 운영DB DDL 추출 후 전환 예정. 메모리 [feedback_missing_tables_workflow](../../../../.claude/projects/-Users-jhj-vexplor-rps/memory/feedback_missing_tables_workflow.md).
## 6. 백로그 (placeholder 상태)
## 6. 액션 구현 (2026-05-15 완료)
| 우선 | 항목 | 원본 위치 | 권장 작업 |
|---|---|---|---|
| 🟠 | **구매요청서작성 다이얼로그** | wace `salesRequestFormPopUp.jsp` + `purchaseListFormPopUp.jsp` | M-BOM 선택 → sales_request_master INSERT (DOC_TYPE='PURCHASE_REG') + 라인 입력 (sales_request_part 신설 후) |
| 🟠 | **품의서생성** | wace `salesMng/createProposalFromPurchaseReg.do` + `getProposalTargetPartsFromPurchaseReg.do` | 선택된 구매요청서 → 단가+공급업체 입력된 품목만 필터 → 새 sales_request_master INSERT (DOC_TYPE='PURCHASE_REG_PROPOSAL', PROJECT_NO=원본 OBJID, STATUS='create') |
| 🟠 | **결재상신 (Amaranth SSO)** | wace `purchaseRegProposalMngList.jsp:75~99` + `getAmaranthSsoUrl` | target_type=`PROPOSAL`, formId=`1163`, compSeq=`1000`. 기존 견적/수주 결재상신 패턴(G11/G11E) 재사용. `AMARANTH_OUT_PROCESS_CODE_PROPOSAL` 환경변수 신설 |
| 🟡 | sales_request_part 신설 | 운영DB 211.115.91.141:11133 | DDL 추출 → `db/migrations/NNN_create_sales_request_part.sql` |
| 항목 | 상태 | 구현 위치 |
|---|---|---|
| sales_request_part 신설 | ✅ | `docs/migration/sales/ddl-extracted/106_create_sales_request_part.sql` (운영 DDL 1:1, RPS 적용 완료) |
| 구매요청서작성 다이얼로그 | ✅ | `components/sales/PurchaseRequestFormDialog.tsx` + 백엔드 `POST /api/sales/purchase-request` (savePurchaseRequest) |
| 품의서생성 액션 | ✅ | `components/sales/ProposalCreateDialog.tsx` + 백엔드 `POST /api/sales/purchase-request/:objid/proposal` (createProposalFromPurchaseReg) |
| 결재상신 (Amaranth SSO) | ✅ | 백엔드 `POST /api/sales/purchase-proposal/:objid/approval` (startProposalApproval) — target_type='PROPOSAL', formId=`AMARANTH_FORM_ID_PROPOSAL` 기본 `'1163'` |
### 6.1 신규 백엔드 엔드포인트
| Method | Path | 용도 |
|---|---|---|
| GET | `/api/sales/purchase-request/mbom-parts?project_objid=…` | 프로젝트별 M-BOM 품목 (다이얼로그 자동 채움) |
| GET | `/api/sales/purchase-request/:objid` | 헤더 + 라인 단건 |
| GET | `/api/sales/purchase-request/:objid/proposal-targets` | 품의서 대상 품목 + 제외 품목 |
| POST | `/api/sales/purchase-request` | 신규/수정 UPSERT (라인 재생성) |
| POST | `/api/sales/purchase-request/:objid/proposal` | 품의서 생성 (PURCHASE_REG → PURCHASE_REG_PROPOSAL) |
| POST | `/api/sales/purchase-proposal/:objid/approval` | Amaranth SSO 결재상신 (TARGET_TYPE='PROPOSAL') |
### 6.2 환경변수 (신규)
| 키 | 기본값 | 비고 |
|---|---|---|
| `AMARANTH_FORM_ID_PROPOSAL` | `1163` | 품의서 결재 폼 ID |
| `AMARANTH_OUT_PROCESS_CODE_PROPOSAL` | — | 미설정 시 공통 `AMARANTH_OUT_PROCESS_CODE` fallback |
| `AMARANTH_COMP_SEQ` | `1000` | 영업 메뉴 전체 공통 |
### 6.3 데이터 흐름 (확정)
```
[구매요청서관리] [영업>품의서관리] [구매>품의서관리]
DOC_TYPE='PURCHASE_REG' DOC_TYPE='PURCHASE_REG_PROPOSAL' 결재완료 시 자동 노출
SRM + SRP (라인) SRM(PROJECT_NO=원본 OBJID) + SRP(PROPOSAL_DATE=NOW)
+
[품의서생성] 액션 [발주서생성]
(단가+공급업체 입력 라인만) (기존 구현)
[결재상신] Amaranth SSO
TARGET_TYPE='PROPOSAL', formId=1163
```
부모-자식 연결: 품의서 SRM 의 `PROJECT_NO` 컬럼이 원본 구매요청서 SRM 의 `OBJID`(varchar)를 담음.
원본 SRP 행의 `PROPOSAL_DATE` 가 채워지면 재생성 대상에서 제외.
## 7. 1차 스캐폴드 커밋
@@ -0,0 +1,45 @@
-- ====================================================================
-- sales_request_part — 구매요청서/품의서 라인
-- ====================================================================
-- 출처: wace_plm 운영 DB 211.115.91.141:11133/waceplm (PG 16.8)
-- 추출일: 2026-05-15
-- 부모: sales_request_master (doc_type 으로 갈래 분기)
-- PURCHASE_REG → 구매요청서 라인 (단가/공급업체 입력 후 품의서 생성 대상)
-- PURCHASE_REG_PROPOSAL → 품의서 라인 (PROPOSAL_DATE 가 채워진 사본)
-- ====================================================================
CREATE TABLE IF NOT EXISTS sales_request_part (
objid VARCHAR NOT NULL,
sales_bom_qty_objid VARCHAR,
part_objid VARCHAR,
sales_request_master_objid VARCHAR,
qty VARCHAR,
partner_objid VARCHAR,
partner_price VARCHAR,
delivery_request_date VARCHAR,
writer VARCHAR,
regdate TIMESTAMP,
status VARCHAR,
remark VARCHAR,
order_qty VARCHAR,
org_qty VARCHAR,
spec VARCHAR,
part_name VARCHAR,
use_yn VARCHAR(1) DEFAULT 'Y',
net_qty NUMERIC DEFAULT 0,
po_qty NUMERIC DEFAULT 0,
unit_price NUMERIC DEFAULT 0,
total_price NUMERIC DEFAULT 0,
proposal_date DATE,
vendor_pm VARCHAR(50),
unit VARCHAR(50),
processing_vendor VARCHAR(50),
processing_proposal_date DATE,
production_qty NUMERIC(15,4),
material_yn VARCHAR(1) DEFAULT 'N',
currency VARCHAR(50),
CONSTRAINT sales_request_part_pkey PRIMARY KEY (objid)
);
CREATE INDEX IF NOT EXISTS idx_srp_master ON sales_request_part (sales_request_master_objid);
CREATE INDEX IF NOT EXISTS idx_srp_part ON sales_request_part (part_objid);
@@ -123,7 +123,9 @@ export default function PurchaseRegProposalPage() {
const handleSearch = () => { setFilter(f => ({ ...f, page: 1 })); fetchList({ page: 1 }); };
const handleReset = () => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); };
const onApproval = () => {
const [approvalLoading, setApprovalLoading] = useState(false);
const onApproval = async () => {
if (checkedIds.length !== 1) {
toast.info("결재상신할 1건을 선택해주세요.");
return;
@@ -132,7 +134,26 @@ export default function PurchaseRegProposalPage() {
if (!sel) return;
if (sel.status === "inProcess") return toast.info("결재 진행중인 건은 상신할 수 없습니다.");
if (sel.status === "approvalComplete") return toast.info("결재 완료된 건은 상신할 수 없습니다.");
toast.info("결재상신 기능은 준비 중입니다.");
setApprovalLoading(true);
try {
const res = await salesPurchaseRequestApi.startApproval(sel.objid, {
approvalTitle: `품의서 결재 - ${sel.proposal_no}`,
subjectStr: `품의서 결재 - ${sel.proposal_no}`,
});
if (!res?.fullUrl) {
toast.error("결재 SSO URL을 받지 못했습니다.");
return;
}
window.open(res.fullUrl, "approvalPopup", "width=900,height=900");
toast.success("결재 화면을 새 창으로 열었습니다.");
// 사용자가 결재상신 완료 후 새로고침해야 status 반영
setTimeout(() => fetchList(), 500);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "결재상신 실패");
} finally {
setApprovalLoading(false);
}
};
return (
@@ -142,9 +163,9 @@ export default function PurchaseRegProposalPage() {
loading={loading} onSearch={handleSearch} onReset={handleReset}
actions={<>
<Button size="sm" variant="default" className="h-8 gap-1 px-2 text-xs bg-cyan-600 hover:bg-cyan-700"
disabled={checkedIds.length !== 1}
disabled={checkedIds.length !== 1 || approvalLoading}
onClick={onApproval}>
<Send className="h-3.5 w-3.5" />
<Send className="h-3.5 w-3.5" /> {approvalLoading ? "처리 중..." : "결재상신"}
</Button>
</>}
/>
@@ -21,6 +21,8 @@ import {
SalesPurchaseRequestFilter,
} from "@/lib/api/salesPurchaseRequest";
import { exportToExcel } from "@/lib/utils/excelExport";
import { PurchaseRequestFormDialog } from "@/components/sales/PurchaseRequestFormDialog";
import { ProposalCreateDialog } from "@/components/sales/ProposalCreateDialog";
const PARENT_PURCHASE_TYPE = "0001814"; // 구매유형
const PARENT_PART_TYPE = "0000001"; // 제품구분
@@ -38,6 +40,8 @@ export default function PurchaseRequestRegPage() {
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<SalesPurchaseRequestFilter>(EMPTY_FILTER);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [formOpen, setFormOpen] = useState(false);
const [proposalOpen, setProposalOpen] = useState(false);
const [purchaseTypeOpts, setPurchaseTypeOpts] = useState<SmartSelectOption[]>([]);
const [partTypeOpts, setPartTypeOpts] = useState<SmartSelectOption[]>([]);
@@ -116,18 +120,29 @@ export default function PurchaseRequestRegPage() {
const handleSearch = () => { setFilter(f => ({ ...f, page: 1 })); fetchList({ page: 1 }); };
const handleReset = () => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); };
const selectedSrm = useMemo(() => {
if (checkedIds.length !== 1) return null;
return gridRows.find((r: any) => r.id === checkedIds[0]) ?? null;
}, [checkedIds, gridRows]);
const handleProposal = () => {
if (!selectedSrm) return toast.info("품의서를 생성할 1건을 선택해주세요.");
if (selectedSrm.status_title === "품의서생성") return toast.info("이미 품의서가 생성된 항목입니다.");
setProposalOpen(true);
};
return (
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
<PageHeader
loading={loading} onSearch={handleSearch} onReset={handleReset}
actions={<>
<Button size="sm" variant="outline" className="h-8 gap-1 px-2 text-xs"
onClick={() => toast.info("구매요청서작성 기능은 준비 중입니다.")}>
onClick={() => setFormOpen(true)}>
<FilePlus className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="default" className="h-8 gap-1 px-2 text-xs"
disabled={checkedIds.length !== 1}
onClick={() => toast.info("품의서생성 기능은 준비 중입니다.")}>
onClick={handleProposal}>
<ClipboardCheck className="h-3.5 w-3.5" />
</Button>
</>}
@@ -197,6 +212,22 @@ export default function PurchaseRequestRegPage() {
}}
showChart
/>
<PurchaseRequestFormDialog
open={formOpen}
onClose={() => setFormOpen(false)}
onSaved={() => { fetchList(); setCheckedIds([]); }}
/>
{selectedSrm && (
<ProposalCreateDialog
open={proposalOpen}
onClose={() => setProposalOpen(false)}
srmObjid={selectedSrm.objid}
requestMngNo={selectedSrm.request_mng_no}
onCreated={() => { fetchList(); setCheckedIds([]); }}
/>
)}
</div>
);
}
+76 -2
View File
@@ -48,6 +48,7 @@ export function SmartSelect({
}: SmartSelectProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const scrollRef = useRef<HTMLDivElement | null>(null);
// code가 비어있는 옵션은 자동 제외 (Radix Select value 제약 + key 중복 방지)
@@ -87,6 +88,73 @@ export function SmartSelect({
return () => cancelAnimationFrame(id);
}, [open, virtualizer, filtered.length]);
// 팝오버 열릴 때 현재 선택값 위치로 활성 인덱스 초기화 (없으면 0)
useEffect(() => {
if (!open) return;
const idx = filtered.findIndex((o) => o.code === value);
setActiveIndex(idx >= 0 ? idx : 0);
// 의도적으로 filtered.length 변화 시에도 재계산 안 함 (검색 입력 중 0번 유지)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
// 검색어가 바뀌면 첫 항목으로 리셋
useEffect(() => {
if (open) setActiveIndex(0);
}, [search, open]);
// 활성 인덱스가 바뀌면 가시 영역으로 스크롤
useEffect(() => {
if (!open) return;
if (activeIndex < 0 || activeIndex >= filtered.length) return;
virtualizer.scrollToIndex(activeIndex, { align: "auto" });
}, [activeIndex, open, virtualizer, filtered.length]);
const onSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (filtered.length === 0) {
if (e.key === "Escape") { e.preventDefault(); setOpen(false); }
return;
}
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setActiveIndex((i) => Math.min(filtered.length - 1, (i < 0 ? -1 : i) + 1));
break;
case "ArrowUp":
e.preventDefault();
setActiveIndex((i) => Math.max(0, (i < 0 ? 0 : i) - 1));
break;
case "Home":
e.preventDefault();
setActiveIndex(0);
break;
case "End":
e.preventDefault();
setActiveIndex(filtered.length - 1);
break;
case "PageDown":
e.preventDefault();
setActiveIndex((i) => Math.min(filtered.length - 1, (i < 0 ? 0 : i) + 8));
break;
case "PageUp":
e.preventDefault();
setActiveIndex((i) => Math.max(0, (i < 0 ? 0 : i) - 8));
break;
case "Enter": {
e.preventDefault();
const hit = filtered[activeIndex];
if (hit) {
onValueChange(hit.code);
setOpen(false);
}
break;
}
case "Escape":
e.preventDefault();
setOpen(false);
break;
}
};
const showClear = clearable && !disabled && !!value;
// Radix Select/Popover trigger는 onPointerDown으로 열린다 → 같은 단계에서 차단해야 X 클릭이 trigger를 안 깨움
const stopAndClear = (e: React.PointerEvent | React.MouseEvent) => {
@@ -162,6 +230,7 @@ export function SmartSelect({
placeholder="검색..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={onSearchKeyDown}
className="h-9 border-0 px-1 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
/>
</div>
@@ -183,17 +252,22 @@ export function SmartSelect({
{virtualizer.getVirtualItems().map((vItem) => {
const o = filtered[vItem.index];
const isSelected = value === o.code;
const isActive = activeIndex === vItem.index;
return (
<button
key={`${o.code}-${vItem.index}`}
type="button"
role="option"
aria-selected={isSelected}
onMouseEnter={() => setActiveIndex(vItem.index)}
onClick={() => {
onValueChange(o.code);
setOpen(false);
}}
className={cn(
"absolute left-0 top-0 w-full flex items-center px-2 text-sm text-left hover:bg-accent",
isSelected && "bg-accent/60",
"absolute left-0 top-0 w-full flex items-center px-2 text-sm text-left",
isActive ? "bg-accent" : "hover:bg-accent/40",
isSelected && !isActive && "bg-accent/60",
)}
style={{
height: `${vItem.size}px`,
@@ -0,0 +1,183 @@
"use client";
// 영업관리 > 구매요청서관리 — 품의서생성 확인 다이얼로그
// wace 1:1: createProposalFromPurchaseReg.do — 선택된 PURCHASE_REG 의 단가+공급업체 입력 품목만 필터해
// PURCHASE_REG_PROPOSAL row 신규 생성. 단가 또는 공급업체가 없는 품목은 제외 목록으로 표시.
import React, { useEffect, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { ClipboardCheck, X, AlertTriangle } from "lucide-react";
import { toast } from "sonner";
import { salesPurchaseRequestApi, ProposalTargetPart } from "@/lib/api/salesPurchaseRequest";
interface Props {
open: boolean;
onClose: () => void;
srmObjid: string; // 원본 구매요청서 OBJID
requestMngNo?: string;
onCreated: (proposalNo: string) => void;
}
export function ProposalCreateDialog({ open, onClose, srmObjid, requestMngNo, onCreated }: Props) {
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [targets, setTargets] = useState<ProposalTargetPart[]>([]);
const [excluded, setExcluded] = useState<ProposalTargetPart[]>([]);
useEffect(() => {
if (!open || !srmObjid) return;
setTargets([]); setExcluded([]);
setLoading(true);
(async () => {
try {
const data = await salesPurchaseRequestApi.getProposalTargets(srmObjid);
setTargets(data.targets ?? []);
setExcluded(data.excluded ?? []);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
} finally {
setLoading(false);
}
})();
}, [open, srmObjid]);
const handleCreate = async () => {
setSubmitting(true);
try {
const res = await salesPurchaseRequestApi.createProposal(srmObjid);
toast.success(`품의서가 생성되었습니다. (${res.proposal_no})`);
onCreated(res.proposal_no);
onClose();
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "생성 실패");
} finally {
setSubmitting(false);
}
};
const sumTotal = targets.reduce((s, r) => s + (Number(r.total_price ?? 0) || Number(r.unit_price ?? 0) * Number(r.qty ?? 0)), 0);
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
{requestMngNo ? `${requestMngNo}` : ""}
.
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="py-10 text-center text-sm text-muted-foreground"> ...</div>
) : (
<>
<Section title={`품의서 생성 대상 (${targets.length}건)`} emptyMsg="대상 품목이 없습니다. 단가와 공급업체가 모두 입력되어야 합니다.">
{targets.length > 0 && (
<table className="w-full text-xs">
<thead className="bg-muted/30">
<tr>
<th className="px-2 py-1 text-left w-[140px]"></th>
<th className="px-2 py-1 text-left"></th>
<th className="px-2 py-1 text-right w-[90px]"></th>
<th className="px-2 py-1 text-right w-[110px]"></th>
<th className="px-2 py-1 text-right w-[120px]"></th>
<th className="px-2 py-1 text-left w-[160px]"></th>
</tr>
</thead>
<tbody>
{targets.map((r) => (
<tr key={r.objid} className="border-t">
<td className="px-2 py-1">{r.part_no}</td>
<td className="px-2 py-1">{r.part_name}</td>
<td className="px-2 py-1 text-right">{fmt(r.qty)}</td>
<td className="px-2 py-1 text-right">{fmtMoney(r.unit_price)}</td>
<td className="px-2 py-1 text-right">
{fmtMoney(Number(r.total_price ?? 0) || Number(r.unit_price ?? 0) * Number(r.qty ?? 0))}
</td>
<td className="px-2 py-1">{r.vendor_name || r.vendor_pm}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="bg-muted/20 font-medium">
<td className="px-2 py-1" colSpan={4}></td>
<td className="px-2 py-1 text-right">{fmtMoney(sumTotal)}</td>
<td></td>
</tr>
</tfoot>
</table>
)}
</Section>
{excluded.length > 0 && (
<Section
title={
<span className="inline-flex items-center gap-1 text-amber-700">
<AlertTriangle className="h-3.5 w-3.5" /> ({excluded.length})
</span>
}
emptyMsg=""
>
<table className="w-full text-xs">
<thead className="bg-muted/30">
<tr>
<th className="px-2 py-1 text-left w-[140px]"></th>
<th className="px-2 py-1 text-left"></th>
<th className="px-2 py-1 text-right w-[90px]"></th>
<th className="px-2 py-1 text-right w-[110px]"></th>
</tr>
</thead>
<tbody>
{excluded.map((r) => (
<tr key={r.objid} className="border-t">
<td className="px-2 py-1">{r.part_no}</td>
<td className="px-2 py-1">{r.part_name}</td>
<td className="px-2 py-1 text-right">{fmt(r.qty)}</td>
<td className="px-2 py-1 text-right">{fmtMoney(r.unit_price)}</td>
</tr>
))}
</tbody>
</table>
</Section>
)}
</>
)}
<DialogFooter className="mt-2">
<Button variant="outline" onClick={onClose} disabled={submitting}>
<X className="h-3.5 w-3.5 mr-1" />
</Button>
<Button onClick={handleCreate} disabled={submitting || loading || targets.length === 0}>
<ClipboardCheck className="h-3.5 w-3.5 mr-1" />
{submitting ? "생성 중..." : `품의서 생성 (${targets.length}건)`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function Section({ title, emptyMsg, children }: { title: React.ReactNode; emptyMsg: string; children: React.ReactNode }) {
const hasChildren = React.Children.count(children) > 0;
return (
<div className="mt-3 border rounded">
<div className="border-b px-2 py-1 bg-muted/40 text-xs font-medium">{title}</div>
<div className="max-h-[280px] overflow-auto">
{hasChildren ? children : (
<div className="py-6 text-center text-xs text-muted-foreground">{emptyMsg}</div>
)}
</div>
</div>
);
}
function fmt(n: any) {
const v = Number(n ?? 0);
return v.toLocaleString();
}
function fmtMoney(n: any) {
const v = Number(n ?? 0);
return v.toLocaleString("ko-KR", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
@@ -0,0 +1,335 @@
"use client";
// 영업관리 > 구매요청서관리 — 구매요청서작성 다이얼로그
// wace 1:1: salesRequestFormPopUp.jsp
// - 프로젝트 선택 → purchaseOrderAdminSupplyInfo: 주문유형/제품구분/국내외/고객사/유무상 자동 채움
// - 행추가: 품번 SmartSelect (해당 프로젝트 M-BOM 품목) → 선택 시 품명/공급업체/단가 자동 셋팅
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Plus, Trash2, Save, X } from "lucide-react";
import { toast } from "sonner";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
import { CustomerSelect } from "@/components/common/CustomerSelect";
import { purchaseApi, OptionItem } from "@/lib/api/purchase";
import {
salesPurchaseRequestApi,
MbomPartItem,
PurchaseRequestPartInput,
} from "@/lib/api/salesPurchaseRequest";
interface Props {
open: boolean;
onClose: () => void;
onSaved: () => void;
}
interface FormState {
project_no: string; // PROJECT_MGMT.OBJID
mbom_header_objid: string;
purchase_type: string;
order_type: string;
product_name: string;
area_cd: string;
customer_objid: string;
paid_type: string;
delivery_request_date: string;
}
interface PartRow extends PurchaseRequestPartInput {
rowKey: string;
part_no?: string;
}
const EMPTY_FORM: FormState = {
project_no: "", mbom_header_objid: "",
purchase_type: "", order_type: "", product_name: "",
area_cd: "", customer_objid: "", paid_type: "",
delivery_request_date: "",
};
let _rk = 0;
const nextKey = () => `r${++_rk}_${Date.now()}`;
export function PurchaseRequestFormDialog({ open, onClose, onSaved }: Props) {
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [parts, setParts] = useState<PartRow[]>([]);
const [saving, setSaving] = useState(false);
const [loadingProject, setLoadingProject] = useState(false);
const [projectOpts, setProjectOpts] = useState<SmartSelectOption[]>([]);
const [supplierOpts, setSupplierOpts] = useState<SmartSelectOption[]>([]);
// 선택된 프로젝트의 M-BOM 품목 풀 (행추가 시 품번 셀렉트 옵션)
const [mbomItems, setMbomItems] = useState<MbomPartItem[]>([]);
// 모달 열릴 때 옵션 1회 로드 + 폼 초기화
useEffect(() => {
if (!open) return;
setForm(EMPTY_FORM);
setParts([]);
setMbomItems([]);
(async () => {
try {
const [proj, suppliers] = await Promise.all([
purchaseApi.listProjects(),
purchaseApi.listSuppliers(),
]);
setProjectOpts(proj.map(toSmart));
setSupplierOpts(suppliers.map(toSmart));
} catch (e: any) {
toast.error(`옵션 로드 실패: ${e?.message ?? ""}`);
}
})();
}, [open]);
// 프로젝트 선택 → 자동채움 (주문유형/제품구분/국내외/고객사/유무상) + M-BOM 품번 풀 갱신
const onProjectChange = useCallback(async (newProjectObjid: string) => {
setForm((f) => ({ ...f, project_no: newProjectObjid }));
setParts([]);
setMbomItems([]);
if (!newProjectObjid) {
setForm((f) => ({
...f, project_no: "",
mbom_header_objid: "", order_type: "", product_name: "",
area_cd: "", customer_objid: "", paid_type: "",
}));
return;
}
setLoadingProject(true);
try {
const [info, items] = await Promise.all([
salesPurchaseRequestApi.getProjectAutoFill(newProjectObjid),
salesPurchaseRequestApi.listMbomParts(newProjectObjid),
]);
setMbomItems(items ?? []);
setForm((f) => ({
...f,
project_no: newProjectObjid,
mbom_header_objid: info?.mbom_header_objid ?? (items?.[0]?.mbom_header_objid ?? ""),
order_type: info?.category_cd ?? "",
product_name: info?.product ?? "",
area_cd: info?.area_cd ?? "",
customer_objid: info?.customer_objid ?? "",
paid_type: info?.paid_type ?? "",
}));
if (!items || items.length === 0) {
toast.info("선택한 프로젝트에 M-BOM 품목이 없습니다. 품번 선택지가 비어 있습니다.");
}
} catch (e: any) {
toast.error(`프로젝트 정보 조회 실패: ${e?.message ?? ""}`);
} finally {
setLoadingProject(false);
}
}, []);
// M-BOM 품목 → 품번 셀렉트 옵션
const partOpts: SmartSelectOption[] = useMemo(
() => mbomItems.map((it) => ({ code: it.part_objid, label: it.part_no || it.part_objid })),
[mbomItems],
);
const addRow = () => {
setParts((p) => [
...p,
{ rowKey: nextKey(), part_objid: "", part_no: "", part_name: "", qty: "1", partner_objid: "", partner_price: "" },
]);
};
const deleteRow = (rowKey: string) => setParts((p) => p.filter((r) => r.rowKey !== rowKey));
const updateRow = (rowKey: string, patch: Partial<PartRow>) =>
setParts((p) => p.map((r) => (r.rowKey === rowKey ? { ...r, ...patch } : r)));
// 품번 선택 → M-BOM 메타데이터로 품명/공급업체/단가/수량 자동 셋팅
const onPartSelect = (rowKey: string, partObjid: string) => {
const hit = mbomItems.find((it) => it.part_objid === partObjid);
if (!hit) {
updateRow(rowKey, { part_objid: partObjid, part_no: "", part_name: "" });
return;
}
updateRow(rowKey, {
part_objid: hit.part_objid,
part_no: hit.part_no,
part_name: hit.part_name,
qty: hit.qty > 0 ? String(hit.qty) : "1",
partner_objid: hit.vendor_objid || "",
partner_price: hit.unit_price > 0 ? String(hit.unit_price) : "",
});
};
const canSave = useMemo(() => {
if (!form.project_no) return false;
if (!form.purchase_type) return false;
if (parts.length === 0) return false;
return parts.every((r) => r.part_objid && Number(r.qty || 0) > 0);
}, [form, parts]);
const handleSave = async () => {
if (!canSave) {
if (!form.project_no) return toast.error("프로젝트번호를 선택해주세요.");
if (!form.purchase_type) return toast.error("구매유형을 선택해주세요.");
if (parts.length === 0) return toast.error("품목이 1건 이상 필요합니다.");
return toast.error("품번/수량(0 초과)을 모두 입력해주세요.");
}
setSaving(true);
try {
const payload = {
...form,
parts: parts.map(({ rowKey, part_no, ...rest }) => rest), // eslint-disable-line @typescript-eslint/no-unused-vars
};
const res = await salesPurchaseRequestApi.save(payload);
toast.success(`저장되었습니다. (${res.request_mng_no ?? res.objid})`);
onSaved();
onClose();
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패");
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-5xl">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> //// . .</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-4 gap-3 text-sm">
<Field label="구매유형 *">
<CommCodeSelect groupId="0001814" value={form.purchase_type}
onValueChange={(v) => setForm({ ...form, purchase_type: v })} withAll={false} />
</Field>
<Field label="프로젝트번호 *">
<SmartSelect options={projectOpts} value={form.project_no}
onValueChange={onProjectChange}
placeholder={loadingProject ? "프로젝트 정보 조회 중..." : "선택"} />
</Field>
<Field label="주문유형">
<CommCodeSelect groupId="0000167" value={form.order_type}
onValueChange={(v) => setForm({ ...form, order_type: v })} withAll={false} />
</Field>
<Field label="제품구분">
<CommCodeSelect groupId="0000001" value={form.product_name}
onValueChange={(v) => setForm({ ...form, product_name: v })} withAll={false} />
</Field>
<Field label="국내/해외">
<CommCodeSelect groupId="0001219" value={form.area_cd}
onValueChange={(v) => setForm({ ...form, area_cd: v })} withAll={false} />
</Field>
<Field label="고객사">
<CustomerSelect value={form.customer_objid}
onValueChange={(v) => setForm({ ...form, customer_objid: v })} />
</Field>
<Field label="유/무상">
<SmartSelect
options={[{ code: "paid", label: "유상" }, { code: "free", label: "무상" }]}
value={form.paid_type}
onValueChange={(v) => setForm({ ...form, paid_type: v })} />
</Field>
<Field label="입고요청일">
<Input type="date" value={form.delivery_request_date}
onChange={(e) => setForm({ ...form, delivery_request_date: e.target.value })} />
</Field>
</div>
<div className="mt-3 border rounded">
<div className="flex items-center justify-between border-b px-2 py-1 bg-muted/40 text-xs">
<div className="font-medium">
({parts.length})
{form.project_no ? (
<span className="ml-2 text-muted-foreground"> {partOpts.length}</span>
) : null}
</div>
<div className="flex gap-1">
<Button size="sm" variant="outline" className="h-7 gap-1 px-2 text-xs"
onClick={addRow}
disabled={!form.project_no}>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="max-h-[360px] overflow-auto">
<table className="w-full text-xs">
<thead className="bg-muted/30 sticky top-0">
<tr>
<th className="px-2 py-1 text-left w-[200px]"></th>
<th className="px-2 py-1 text-left"></th>
<th className="px-2 py-1 text-right w-[90px]"></th>
<th className="px-2 py-1 text-left w-[180px]"></th>
<th className="px-2 py-1 text-right w-[110px]"></th>
<th className="px-2 py-1 w-[36px]"></th>
</tr>
</thead>
<tbody>
{parts.length === 0 ? (
<tr>
<td colSpan={6} className="px-2 py-6 text-center text-muted-foreground">
{form.project_no
? "[행추가] 버튼을 눌러 품번을 선택해주세요."
: "먼저 프로젝트번호를 선택해주세요."}
</td>
</tr>
) : parts.map((r) => (
<tr key={r.rowKey} className="border-t">
<td className="px-2 py-1">
<SmartSelect options={partOpts} value={r.part_objid}
onValueChange={(v) => onPartSelect(r.rowKey, v)}
placeholder={partOpts.length === 0 ? "M-BOM 품목 없음" : "선택"} />
</td>
<td className="px-2 py-1">{r.part_name || ""}</td>
<td className="px-2 py-1">
<Input type="number" min={0} value={r.qty ?? ""} className="h-7 text-right"
onChange={(e) => updateRow(r.rowKey, { qty: e.target.value })} />
</td>
<td className="px-2 py-1">
<SmartSelect options={supplierOpts} value={r.partner_objid ?? ""}
onValueChange={(v) => updateRow(r.rowKey, { partner_objid: v })} />
</td>
<td className="px-2 py-1">
<Input type="number" min={0} step="0.01" value={r.partner_price ?? ""} className="h-7 text-right"
onChange={(e) => updateRow(r.rowKey, { partner_price: e.target.value })} />
</td>
<td className="px-2 py-1 text-center">
<Button size="icon" variant="ghost" className="h-6 w-6"
onClick={() => deleteRow(r.rowKey)}>
<Trash2 className="h-3.5 w-3.5 text-red-600" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<DialogFooter className="mt-2">
<Button variant="outline" onClick={onClose} disabled={saving}>
<X className="h-3.5 w-3.5 mr-1" />
</Button>
<Button onClick={handleSave} disabled={saving || !canSave}>
<Save className="h-3.5 w-3.5 mr-1" /> {saving ? "저장 중..." : "저장"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1">
<Label className="text-xs">{label}</Label>
{children}
</div>
);
}
function toSmart(o: OptionItem): SmartSelectOption {
return { code: o.code, label: o.label };
}
+107
View File
@@ -35,7 +35,114 @@ async function getList<T = any>(
return res.data?.data as SalesPurchaseRequestListResponse<T>;
}
export interface PurchaseRequestPartInput {
objid?: string;
part_objid: string;
part_name?: string;
qty?: string | number;
org_qty?: string | number;
partner_objid?: string;
partner_price?: string | number;
delivery_request_date?: string;
status?: string;
}
export interface SavePurchaseRequestPayload {
objid?: string;
project_no?: string;
mbom_header_objid?: string;
purchase_type?: string;
order_type?: string;
product_name?: string;
area_cd?: string;
customer_objid?: string;
paid_type?: string;
delivery_request_date?: string;
parts: PurchaseRequestPartInput[];
}
export interface MbomPartItem {
mbom_detail_objid: string;
part_objid: string;
mbom_header_objid: string;
part_no: string;
part_name: string;
unit: string;
qty: number;
unit_price: number;
vendor_objid: string;
vendor_name: string;
}
export interface ProjectAutoFillInfo {
objid: string;
project_no: string;
project_name: string;
category_cd: string | null;
category_name: string | null;
customer_objid: string | null;
customer_name: string | null;
product: string | null;
product_name: string | null;
area_cd: string | null;
area_name: string | null;
paid_type: string | null;
contract_objid: string | null;
mbom_header_objid: string | null;
}
export interface ProposalTargetPart {
objid: string;
part_objid: string;
part_no: string;
part_name: string;
qty: string;
unit_price: number;
total_price: number | null;
vendor_pm: string;
vendor_name: string;
}
export const salesPurchaseRequestApi = {
listPurchaseRequestReg: (f: SalesPurchaseRequestFilter = {}) => getList("purchase-request", f),
listPurchaseRegProposal: (f: SalesPurchaseRequestFilter = {}) => getList("purchase-proposal", f),
async getProjectAutoFill(projectObjid: string): Promise<ProjectAutoFillInfo | null> {
const res = await apiClient.get(`/sales/purchase-request/project-info/${projectObjid}`);
return (res.data?.data ?? null) as ProjectAutoFillInfo | null;
},
async listMbomParts(projectObjid: string): Promise<MbomPartItem[]> {
const res = await apiClient.get("/sales/purchase-request/mbom-parts", {
params: { project_objid: projectObjid },
});
return (res.data?.data ?? []) as MbomPartItem[];
},
async getDetail(objid: string): Promise<{ header: any; parts: any[] }> {
const res = await apiClient.get(`/sales/purchase-request/${objid}`);
return res.data?.data as { header: any; parts: any[] };
},
async getProposalTargets(objid: string): Promise<{ targets: ProposalTargetPart[]; excluded: ProposalTargetPart[] }> {
const res = await apiClient.get(`/sales/purchase-request/${objid}/proposal-targets`);
return res.data?.data as { targets: ProposalTargetPart[]; excluded: ProposalTargetPart[] };
},
async save(payload: SavePurchaseRequestPayload): Promise<{ objid: string; request_mng_no: string | null; isNew: boolean }> {
const res = await apiClient.post("/sales/purchase-request", payload);
return res.data?.data;
},
async createProposal(srmObjid: string): Promise<{ proposal_objid: string; proposal_no: string; part_count: number }> {
const res = await apiClient.post(`/sales/purchase-request/${srmObjid}/proposal`, {});
return res.data?.data;
},
async startApproval(proposalObjid: string, opts: { approvalTitle?: string; subjectStr?: string } = {}): Promise<{
fullUrl: string; approKey: string; status: string; proposalObjid: string;
}> {
const res = await apiClient.post(`/sales/purchase-proposal/${proposalObjid}/approval`, opts);
return res.data?.data;
},
};