Merge pull request 'hjjeong' (#17) from hjjeong into main

Reviewed-on: https://g.wace.me/chpark/vexplor_rps/pulls/17
This commit is contained in:
hjjeong
2026-05-20 01:04:58 +00:00
22 changed files with 4220 additions and 28 deletions
@@ -6,6 +6,8 @@ import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import * as svc from "../services/purchaseService";
import * as formSvc from "../services/purchaseOrderFormService";
import * as mailSvc from "../services/purchaseOrderMailService";
import * as inboundSvc from "../services/purchaseInboundService";
import { logger } from "../utils/logger";
function parseFilter(q: Record<string, any>): svc.PurchaseListFilter {
@@ -73,6 +75,168 @@ export async function getPurchaseOrderForm(req: AuthenticatedRequest, res: Respo
}
}
/**
* POST /api/purchase/order-form/save
* 마스터 + 파트 UPSERT + 삭제 처리. 트랜잭션.
*/
export async function savePurchaseOrderForm(req: AuthenticatedRequest, res: Response) {
try {
const payload = req.body as formSvc.SaveOrderFormPayload;
if (!payload || !payload.master) {
return res.status(400).json({ success: false, message: "master 가 필요해요" });
}
const writer = String(req.user?.userId ?? "");
const result = await formSvc.savePurchaseOrderForm(payload, writer);
return res.json({ success: true, data: result });
} catch (e: any) {
logger.error("발주서 폼 저장 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
/**
* DELETE /api/purchase/order-form/:objid
*/
export async function deletePurchaseOrderForm(req: AuthenticatedRequest, res: Response) {
try {
const objid = String(req.params.objid ?? "").trim();
if (!objid) return res.status(400).json({ success: false, message: "objid required" });
await formSvc.deletePurchaseOrderForm(objid);
return res.json({ success: true });
} catch (e: any) {
logger.error("발주서 폼 삭제 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
// ─── 발주서 메일 발송 ─────────────────────────────────────────
/** GET /api/purchase/order-form/mail-info/:objid */
export async function getPurchaseOrderMailInfo(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 mailSvc.getOrderMailInfo(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 });
}
}
/** GET /api/purchase/options/partner-managers/:partnerObjid */
export async function getPartnerManagers(req: AuthenticatedRequest, res: Response) {
try {
const partnerObjid = String(req.params.partnerObjid ?? "").trim();
if (!partnerObjid) return res.json({ success: true, data: [] });
const data = await mailSvc.getPartnerManagerList(partnerObjid);
return res.json({ success: true, data });
} catch (e: any) {
logger.error("공급업체 담당자 조회 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
/** POST /api/purchase/order-form/mail */
export async function sendPurchaseOrderMail(req: AuthenticatedRequest, res: Response) {
try {
const body = req.body as mailSvc.PurchaseOrderMailBody;
if (!body || !body.pomObjid) {
return res.status(400).json({ success: false, message: "pomObjid 가 필요해요" });
}
const userId = String(req.user?.userId ?? "");
const result = await mailSvc.sendOrderMail(userId, body);
if (!result.success) {
return res.status(400).json(result);
}
return res.json(result);
} catch (e: any) {
logger.error("발주서 메일 발송 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
// ─── 입고관리 (입고등록 / 마감정보 / 매입마감) ─────────────────
/** GET /api/purchase/inbound-form/:pomObjid */
export async function getInboundFormInit(req: AuthenticatedRequest, res: Response) {
try {
const objid = String(req.params.pomObjid ?? "").trim();
if (!objid) return res.status(400).json({ success: false, message: "pomObjid required" });
const data = await inboundSvc.getInboundFormInit(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 });
}
}
/** POST /api/purchase/inbound-form/save */
export async function saveInboundForm(req: AuthenticatedRequest, res: Response) {
try {
const { pomObjid, rows } = req.body as { pomObjid: string; rows: inboundSvc.InboundSaveRow[] };
if (!pomObjid) return res.status(400).json({ success: false, message: "pomObjid required" });
const writer = String(req.user?.userId ?? "");
const result = await inboundSvc.saveInboundForm(pomObjid, rows ?? [], writer);
return res.json({ success: true, data: result });
} catch (e: any) {
logger.error("입고등록 저장 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
/** POST /api/purchase/arrival/deadline */
export async function saveArrivalDeadline(req: AuthenticatedRequest, res: Response) {
try {
const body = req.body as inboundSvc.DeadlineInfoBody;
if (!body || !Array.isArray(body.objIds) || body.objIds.length === 0) {
return res.status(400).json({ success: false, message: "objIds required" });
}
const result = await inboundSvc.saveDeadlineInfo(body);
return res.json({ success: true, data: result });
} catch (e: any) {
logger.error("마감정보 저장 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
/** GET /api/purchase/options/warehouses */
export async function getWarehouses(_req: AuthenticatedRequest, res: Response) {
try {
const data = await inboundSvc.listWarehouseOptions();
return res.json({ success: true, data });
} catch (e: any) {
return res.status(500).json({ success: false, message: e.message });
}
}
/** GET /api/purchase/options/acct-codes */
export async function getAcctCodes(_req: AuthenticatedRequest, res: Response) {
try {
const data = await inboundSvc.listAcctCodeOptions();
return res.json({ success: true, data });
} catch (e: any) {
return res.status(500).json({ success: false, message: e.message });
}
}
/** POST /api/purchase/arrival/close */
export async function closeArrival(req: AuthenticatedRequest, res: Response) {
try {
const { objIds } = req.body as { objIds: string[] };
if (!Array.isArray(objIds) || objIds.length === 0) {
return res.status(400).json({ success: false, message: "objIds required" });
}
const result = await inboundSvc.closeArrival(objIds);
return res.json({ success: true, data: result });
} catch (e: any) {
logger.error("매입마감 처리 실패", { error: e.message });
return res.status(400).json({ success: false, message: e.message });
}
}
export async function getSuppliers(_req: AuthenticatedRequest, res: Response) {
try {
const data = await svc.listSupplierOptions();
+16 -3
View File
@@ -20,14 +20,27 @@ router.get("/inbound-by-date", ctrl.getInboundByDate); // 입고일별 입
router.get("/project-status", ctrl.getProjectStatus); // 프로젝트별 발주/입고 현황
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); // 수정/조회
// 발주서 폼 (general / outsourcing / english 양식, wace purchaseOrderFormPopup_*.do 1:1)
router.get ("/order-form/init", ctrl.getPurchaseOrderFormInit); // 품의서에서 자동 채움
router.post ("/order-form/save", ctrl.savePurchaseOrderForm); // 마스터+파트 UPSERT
router.post ("/order-form/mail", ctrl.sendPurchaseOrderMail); // 메일 발송 (PDF 첨부)
router.get ("/order-form/mail-info/:objid", ctrl.getPurchaseOrderMailInfo); // 메일 다이얼로그 자동 채움
router.get ("/order-form/:objid", ctrl.getPurchaseOrderForm); // 수정/조회
router.delete("/order-form/:objid", ctrl.deletePurchaseOrderForm); // 삭제 cascade
// 입고관리 (wace deliveryAcceptanceFormPopUp_new 1:1)
router.get ("/inbound-form/:pomObjid", ctrl.getInboundFormInit); // 입고등록 팝업 자동채움
router.post("/inbound-form/save", ctrl.saveInboundForm); // arrival_plan 다수 UPSERT
router.post("/arrival/deadline", ctrl.saveArrivalDeadline); // 마감정보 일괄 UPDATE
router.post("/arrival/close", ctrl.closeArrival); // 매입마감 일괄
// 공통 옵션
router.get("/options/suppliers", ctrl.getSuppliers);
router.get("/options/vendors", ctrl.getVendors); // wace client_mng 기반
router.get("/options/users", ctrl.getUsers);
router.get("/options/projects", ctrl.getProjects);
router.get("/options/partner-managers/:partnerObjid", ctrl.getPartnerManagers); // 발주서 메일 담당자
router.get("/options/warehouses", ctrl.getWarehouses); // 입고창고 (warehouse_info)
router.get("/options/acct-codes", ctrl.getAcctCodes); // 계정과목 (account_code_info)
export default router;
@@ -0,0 +1,372 @@
// ============================================================
// 구매관리 > 입고관리 — 입고등록 / 마감정보입력 / 매입마감
//
// wace_plm 1:1 이식:
// - 입고등록 팝업: purchaseOrder/deliveryAcceptanceFormPopUp_new.do
// - 입고 저장: purchaseOrder/saveDeliveryInfo.do
// → supplyChainMgmt.saveDeliveryInfo (ARRIVAL_PLAN UPSERT)
// - 마감정보: purchaseOrder/saveArrivalPlanDeadlineInfo.do
// → purchaseOrder.saveArrivalPlanDeadlineInfo (8필드 조건부 UPDATE)
// - 매입마감: purchaseOrder/purchaseCloseByArrival.do
// → purchaseOrder.updateArrivalPlanCloseDate (PURCHASE_CLOSE_DATE)
//
// RPS 단순화:
// - 동시발주(MULTI_YN), inventory_mgmt 동기, ERROR_QTY/ERROR_REASON 흐름은 추후
// - 입고등록은 ARRIVAL_PLAN UPSERT 만 처리 (자재 신규/입고 이력은 차후 도메인)
// ============================================================
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
import { createObjId } from "../utils/objidUtil";
export interface InboundFormInitResult {
master: {
pom_objid: string;
purchase_order_no: string;
project_no: string;
contract_mgmt_objid: string;
partner_objid: string;
partner_name: string;
delivery_status: string;
};
/** 발주 품목 — 입고 등록 팝업 좌측 그리드 */
parts: {
order_part_objid: string;
part_objid: string;
part_no: string;
part_name: string;
spec: string;
maker: string;
unit_title: string;
order_qty: number;
arrival_qty: number; // 이미 입고된 수량
non_arrival_qty: number; // 미입고 수량 (order_qty - arrival_qty)
delivery_request_date: string;
}[];
/** 기존 입고 차수 — 팝업 우측 그리드 */
arrivals: {
objid: string;
order_part_objid: string;
part_objid: string;
group_seq: string;
seq: string;
receipt_date: string;
location: string;
sub_location: string;
receipt_qty: number;
arrival_qty: number;
inventory_status: string;
}[];
}
export interface InboundSaveRow {
objid?: string;
parent_objid: string; // PURCHASE_ORDER_MASTER objid
order_part_objid: string;
part_objid: string; // bigint as string OK
group_seq: string;
seq: string;
receipt_date: string;
location: string;
sub_location: string;
receipt_qty: number;
arrival_qty: number;
arrival_plan_date?: string;
}
export interface DeadlineInfoBody {
objIds: string[];
taxType: string;
taxInvoiceDate?: string;
exportDeclNo?: string;
loadingDate?: string;
foreignType?: string;
duty?: string;
importVat?: string;
exchangeRate?: string;
}
/** GET /api/purchase/inbound-form/:pomObjid — 입고등록 팝업 자동채움 */
export async function getInboundFormInit(pomObjid: string): Promise<InboundFormInitResult | null> {
const pool = getPool();
try {
const m = await pool.query(
`SELECT POM.OBJID AS pom_objid,
POM.PURCHASE_ORDER_NO AS purchase_order_no,
POM.CONTRACT_MGMT_OBJID AS contract_mgmt_objid,
POM.PARTNER_OBJID AS partner_objid,
CM.PROJECT_NO AS project_no,
C.CLIENT_NM AS partner_name,
COALESCE(POM.RECEPTION_STATUS, '') AS delivery_status
FROM PURCHASE_ORDER_MASTER POM
LEFT JOIN PROJECT_MGMT CM ON POM.CONTRACT_MGMT_OBJID = CM.OBJID
LEFT JOIN CLIENT_MNG C ON C.OBJID = POM.PARTNER_OBJID
WHERE POM.OBJID = $1
LIMIT 1`,
[pomObjid],
);
if (m.rows.length === 0) return null;
const master = m.rows[0];
const p = await pool.query(
`SELECT POP.OBJID AS order_part_objid,
POP.PART_OBJID AS part_objid,
POP.PART_NO AS part_no,
POP.PART_NAME AS part_name,
POP.SPEC AS spec,
'' AS maker,
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = POP.UNIT) AS unit_title,
COALESCE(NULLIF(POP.ORDER_QTY,'')::numeric, 0) AS order_qty,
COALESCE(POP.DELIVERY_REQUEST_DATE, '') AS delivery_request_date,
COALESCE((SELECT SUM(COALESCE(NULLIF(AP.RECEIPT_QTY,'')::numeric, 0))
FROM ARRIVAL_PLAN AP
WHERE AP.ORDER_PART_OBJID = POP.OBJID), 0) AS arrival_qty
FROM PURCHASE_ORDER_PART POP
WHERE POP.PURCHASE_ORDER_MASTER_OBJID = $1
ORDER BY POP.REGDATE`,
[pomObjid],
);
const parts = p.rows.map((r) => {
const orderQty = Number(r.order_qty || 0);
const arrivalQty = Number(r.arrival_qty || 0);
return {
order_part_objid: String(r.order_part_objid ?? ""),
part_objid: String(r.part_objid ?? ""),
part_no: String(r.part_no ?? ""),
part_name: String(r.part_name ?? ""),
spec: String(r.spec ?? ""),
maker: String(r.maker ?? ""),
unit_title: String(r.unit_title ?? ""),
order_qty: orderQty,
arrival_qty: arrivalQty,
non_arrival_qty: Math.max(orderQty - arrivalQty, 0),
delivery_request_date: String(r.delivery_request_date ?? ""),
};
});
const a = await pool.query(
`SELECT OBJID, ORDER_PART_OBJID, PART_OBJID::VARCHAR, GROUP_SEQ, SEQ,
RECEIPT_DATE, LOCATION, SUB_LOCATION,
COALESCE(NULLIF(RECEIPT_QTY,'')::numeric, 0) AS receipt_qty,
COALESCE(NULLIF(ARRIVAL_QTY,'')::numeric, 0) AS arrival_qty,
COALESCE(INVENTORY_STATUS, '') AS inventory_status
FROM ARRIVAL_PLAN
WHERE PARENT_OBJID = $1
ORDER BY GROUP_SEQ, SEQ`,
[pomObjid],
);
const arrivals = a.rows.map((r) => ({
objid: String(r.objid ?? ""),
order_part_objid: String(r.order_part_objid ?? ""),
part_objid: String(r.part_objid ?? ""),
group_seq: String(r.group_seq ?? "1"),
seq: String(r.seq ?? ""),
receipt_date: String(r.receipt_date ?? ""),
location: String(r.location ?? ""),
sub_location: String(r.sub_location ?? ""),
receipt_qty: Number(r.receipt_qty || 0),
arrival_qty: Number(r.arrival_qty || 0),
inventory_status: String(r.inventory_status ?? ""),
}));
return {
master: {
pom_objid: String(master.pom_objid ?? ""),
purchase_order_no: String(master.purchase_order_no ?? ""),
project_no: String(master.project_no ?? ""),
contract_mgmt_objid: String(master.contract_mgmt_objid ?? ""),
partner_objid: String(master.partner_objid ?? ""),
partner_name: String(master.partner_name ?? ""),
delivery_status: String(master.delivery_status ?? ""),
},
parts,
arrivals,
};
} catch (e: any) {
logger.error("getInboundFormInit 실패", { error: e.message, pomObjid });
throw e;
}
}
/** POST /api/purchase/inbound-form/save — arrival_plan 다수 UPSERT 트랜잭션 */
export async function saveInboundForm(
pomObjid: string,
rows: InboundSaveRow[],
writer: string,
): Promise<{ saved: number }> {
if (!pomObjid) throw new Error("pomObjid is required");
if (!Array.isArray(rows)) throw new Error("rows must be array");
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
let saved = 0;
for (const row of rows) {
const qty = Number(row.receipt_qty || 0);
// 입고수량 0 인 행은 저장 skip
if (qty <= 0) continue;
const objid = row.objid || createObjId();
// ARRIVAL_PLAN UPSERT (wace supplyChainMgmt.saveDeliveryInfo 1:1)
await client.query(
`INSERT INTO ARRIVAL_PLAN
(OBJID, PARENT_OBJID, ORDER_PART_OBJID, PART_OBJID,
RECEIPT_QTY, RECEIPT_DATE, LOCATION, SUB_LOCATION,
WRITER, RECEIVER_ID, GROUP_SEQ, SEQ, ARRIVAL_QTY, ARRIVAL_PLAN_DATE)
VALUES ($1, $2, $3, $4::bigint,
$5, $6, $7, $8,
$9, $9, $10, $11, $12, $13)
ON CONFLICT (OBJID) DO UPDATE SET
RECEIPT_QTY = EXCLUDED.RECEIPT_QTY,
RECEIPT_DATE = EXCLUDED.RECEIPT_DATE,
LOCATION = EXCLUDED.LOCATION,
SUB_LOCATION = EXCLUDED.SUB_LOCATION,
ARRIVAL_QTY = EXCLUDED.ARRIVAL_QTY,
ARRIVAL_PLAN_DATE = EXCLUDED.ARRIVAL_PLAN_DATE,
RECEIVER_ID = EXCLUDED.RECEIVER_ID`,
[
objid,
pomObjid,
row.order_part_objid,
row.part_objid || null,
String(qty),
row.receipt_date || "",
row.location || "",
row.sub_location || "",
writer || "",
row.group_seq || "1",
row.seq || "",
String(row.arrival_qty || qty),
row.arrival_plan_date || "",
],
);
saved++;
}
await client.query("COMMIT");
return { saved };
} catch (e: any) {
await client.query("ROLLBACK");
logger.error("saveInboundForm 실패", { error: e.message, pomObjid });
throw e;
} finally {
client.release();
}
}
/** POST /api/purchase/arrival/deadline — 마감정보 일괄 UPDATE */
export async function saveDeadlineInfo(body: DeadlineInfoBody): Promise<{ updated: number }> {
if (!Array.isArray(body.objIds) || body.objIds.length === 0) {
throw new Error("objIds required");
}
const pool = getPool();
// wace 패턴: tax_type 은 항상 SET, 나머지는 비어있지 않을 때만
const sets: string[] = [`tax_type = $1`];
const vals: any[] = [body.taxType ?? ""];
let i = 2;
const addIf = (col: string, v?: string) => {
if (v != null && v !== "") {
sets.push(`${col} = $${i++}`);
vals.push(v);
}
};
const addNumIf = (col: string, v?: string) => {
if (v != null && v !== "") {
sets.push(`${col} = $${i++}::numeric`);
vals.push(v);
}
};
addIf("tax_invoice_date", body.taxInvoiceDate);
if (body.exportDeclNo != null) { sets.push(`export_decl_no = $${i++}`); vals.push(body.exportDeclNo); }
addIf("loading_date", body.loadingDate);
addIf("foreign_type", body.foreignType);
addNumIf("duty", body.duty);
addNumIf("exchange_rate", body.exchangeRate);
addNumIf("import_vat", body.importVat);
const idIdx = i;
vals.push(body.objIds);
const sql = `UPDATE arrival_plan
SET ${sets.join(", ")}
WHERE OBJID = ANY($${idIdx}::text[])`;
try {
const r = await pool.query(sql, vals);
return { updated: r.rowCount ?? 0 };
} catch (e: any) {
logger.error("saveDeadlineInfo 실패", { error: e.message });
throw e;
}
}
/** 입고창고 옵션 — RPS warehouse_info 기반 (wace 는 WAREHOUSE_LOCATION) */
export async function listWarehouseOptions(): Promise<{ code: string; label: string }[]> {
const pool = getPool();
try {
const r = await pool.query(
`SELECT WAREHOUSE_CODE AS code,
WAREHOUSE_CODE || ' ' || COALESCE(WAREHOUSE_NAME, '') AS label
FROM WAREHOUSE_INFO
WHERE COALESCE(STATUS, 'active') NOT IN ('inactive', 'delete', 'D', 'N')
AND WAREHOUSE_CODE IS NOT NULL AND WAREHOUSE_CODE <> ''
ORDER BY WAREHOUSE_CODE`,
);
return r.rows;
} catch (e: any) {
logger.error("listWarehouseOptions 실패", { error: e.message });
return [];
}
}
/** 계정과목 옵션 — RPS account_code_info 기반 (wace 는 ERP_ACCT_CODE) */
export async function listAcctCodeOptions(): Promise<{ code: string; label: string }[]> {
const pool = getPool();
try {
const r = await pool.query(
`SELECT ACCOUNT_CODE AS code,
ACCOUNT_CODE || ' ' || COALESCE(ACCOUNT_NAME, '') AS label
FROM ACCOUNT_CODE_INFO
WHERE COALESCE(USE_YN, 'Y') IN ('Y', 'y', '1')
AND ACCOUNT_CODE IS NOT NULL AND ACCOUNT_CODE <> ''
ORDER BY ACCOUNT_CODE`,
);
return r.rows;
} catch (e: any) {
logger.error("listAcctCodeOptions 실패", { error: e.message });
return [];
}
}
/** POST /api/purchase/arrival/close — 매입마감 일괄 (PURCHASE_CLOSE_DATE = 오늘) */
export async function closeArrival(objIds: string[]): Promise<{ updated: number }> {
if (!Array.isArray(objIds) || objIds.length === 0) {
throw new Error("objIds required");
}
const pool = getPool();
try {
// 이미 마감된 건 차단 (wace fn_purchaseClose 와 동일)
const check = await pool.query(
`SELECT OBJID FROM ARRIVAL_PLAN
WHERE OBJID = ANY($1::text[])
AND COALESCE(PURCHASE_CLOSE_DATE, '') <> ''`,
[objIds],
);
if (check.rows.length > 0) {
const dup = check.rows.map((r: any) => r.objid).join(", ");
throw new Error(`이미 매입마감된 건이 포함돼 있어요 (${dup})`);
}
const r = await pool.query(
`UPDATE ARRIVAL_PLAN
SET PURCHASE_CLOSE_DATE = TO_CHAR(NOW(), 'YYYY-MM-DD')
WHERE OBJID = ANY($1::text[])`,
[objIds],
);
return { updated: r.rowCount ?? 0 };
} catch (e: any) {
logger.error("closeArrival 실패", { error: e.message });
throw e;
}
}
@@ -21,12 +21,24 @@
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
import { createObjId } from "../utils/objidUtil";
export interface OrderFormInitResult {
master: Record<string, any>;
parts: Record<string, any>[];
}
export interface SaveOrderFormPayload {
master: Record<string, any>;
parts: Record<string, any>[];
deletedPartObjids?: string[];
}
export interface SaveOrderFormResult {
objid: string;
purchase_order_no: string;
}
/**
* GET /api/purchase/order-form/init?proposal_objid=...
*
@@ -75,6 +87,25 @@ export async function getPurchaseOrderFormInit(proposalObjid: string): Promise<O
logger.warn("발주번호 채번 실패", { error: e.message });
}
// USER_INFO 에서 기본 담당자 user_id lookup (select 자동 매칭용)
// 운영판 wace 는 매니저 select 옵션에 user_id 가 바인딩됨
let mgr1Id = "";
let mgr2Id = "";
try {
const r = await pool.query(
`SELECT USER_ID, USER_NAME
FROM USER_INFO
WHERE USER_NAME IN ('안동윤', '서동민')
OR EMAIL IN ('ady1225@rps-korea.com', 'sdm0927@rps-korea.com')`,
);
for (const u of r.rows) {
if (u.user_name === "안동윤" && !mgr1Id) mgr1Id = String(u.user_id);
if (u.user_name === "서동민" && !mgr2Id) mgr2Id = String(u.user_id);
}
} catch (e: any) {
logger.warn("기본 담당자 user_id lookup 실패", { error: e.message });
}
const todayIso = new Date().toISOString().slice(0, 10);
const master: Record<string, any> = {
objid: "", // 신규 — 클라이언트가 채워 보내거나 save 시 채번
@@ -88,10 +119,12 @@ export async function getPurchaseOrderFormInit(proposalObjid: string): Promise<O
contract_mgmt_objid: proposal?.project_no ?? "",
title: proposal?.title ?? "",
// wace controller _general 기본 담당자 (RPS 운영 고정값)
sales_mng_user_id: mgr1Id,
manager_name: "안동윤",
manager_position: "팀장",
manager_phone: "010-2313-2702",
manager_email: "ady1225@rps-korea.com",
sales_mng_user_id2: mgr2Id,
manager_name2: "서동민",
manager_position2: "주임",
manager_phone2: "010-9538-9513",
@@ -102,6 +135,7 @@ export async function getPurchaseOrderFormInit(proposalObjid: string): Promise<O
const parts: Record<string, any>[] = [];
if (proposalObjid) {
try {
logger.info("발주서 init parts 조회", { proposalObjid });
const r = await pool.query(
`SELECT
ROW_NUMBER() OVER(ORDER BY SRP.REGDATE) AS rnum,
@@ -143,6 +177,7 @@ export async function getPurchaseOrderFormInit(proposalObjid: string): Promise<O
ORDER BY SRP.REGDATE`,
[proposalObjid],
);
logger.info("발주서 init parts 결과", { proposalObjid, rowCount: r.rows.length });
for (const row of r.rows) {
const qty = toNum(row.qty);
@@ -238,3 +273,336 @@ function toNum(v: any): number {
function formatKorDate(d: Date): string {
return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}${String(d.getDate()).padStart(2, "0")}`;
}
/** "1,234,567.50" / 1234567.5 / null → '1234567.5' (numeric 컬럼은 모두 varchar 저장) */
function strNum(v: any): string {
if (v == null) return "";
const s = String(v).replace(/,/g, "").trim();
return s;
}
/** pick from various-cased keys (UPPER/lower/snake). 클라이언트가 어느 케이스로 보내든 수용. */
function pick(o: Record<string, any>, ...keys: string[]): any {
for (const k of keys) {
if (o == null) continue;
if (k in o && o[k] !== undefined && o[k] !== null) return o[k];
const lk = k.toLowerCase();
if (lk in o && o[lk] !== undefined && o[lk] !== null) return o[lk];
const uk = k.toUpperCase();
if (uk in o && o[uk] !== undefined && o[uk] !== null) return o[uk];
}
return "";
}
/**
* POST /api/purchase/order-form/save
*
* wace `PurchaseOrderService.savePurchaseOrder_new` (1472-1817) 의 단일-마스터 분기 1:1.
* 1) 마스터 UPSERT (mergePurchaseOrderMaster — INSERT ... ON CONFLICT(OBJID) DO UPDATE)
* 2) 파트 UPSERT (mergePurchaseOrderPartInfo — INSERT ... ON CONFLICT(OBJID) DO UPDATE)
* 3) deletedPartObjids 일괄 DELETE
* 트랜잭션. 동시발주(MULTI_*)는 RPS 미사용이라 제외.
*
* payload.master.objid 가 비어 있으면 신규 채번(createObjId).
* payload.parts[i].objid 가 비어 있으면 신규 채번.
*/
export async function savePurchaseOrderForm(
payload: SaveOrderFormPayload,
writerId: string,
): Promise<SaveOrderFormResult> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const m = payload.master ?? {};
let masterObjid = String(pick(m, "objid") || "").trim();
const isNew = !masterObjid;
if (isNew) masterObjid = createObjId();
// 마스터 UPSERT — wace mergePurchaseOrderMaster (purchaseOrder.xml 530-714) 1:1
// PURCHASE_ORDER_NO: 신규일 때만 채번 서브쿼리, 수정은 기존 값 유지.
const writer = writerId || String(pick(m, "writer") || "");
const params: any[] = [
/* 1 */ masterObjid,
/* 2 */ String(pick(m, "po_client_id") || ""),
/* 3 */ String(pick(m, "category_cd") || ""),
/* 4 */ String(pick(m, "product_group") || ""),
/* 5 */ String(pick(m, "product") || ""),
/* 6 */ String(pick(m, "product_code") || ""),
/* 7 */ String(pick(m, "my_company_objid") || ""),
/* 8 */ String(pick(m, "partner_objid") || ""),
/* 9 */ String(pick(m, "delivery_date") || ""),
/* 10 */ String(pick(m, "delivery_place") || ""),
/* 11 */ String(pick(m, "effective_date") || ""),
/* 12 */ String(pick(m, "payment_terms") || ""),
/* 13 */ String(pick(m, "remark") || ""),
/* 14 */ String(pick(m, "request_content") || ""),
/* 15 */ writer,
/* 16 */ String(pick(m, "status") || "create"),
/* 17 */ String(pick(m, "sales_request_objid") || ""),
/* 18 */ String(pick(m, "sales_mng_user_id") || ""),
/* 19 */ String(pick(m, "sales_mng_user_id2") || ""),
/* 20 */ String(pick(m, "form_type") || "general"),
/* 21 */ String(pick(m, "title") || ""),
/* 22 */ String(pick(m, "purchase_date") || ""),
/* 23 */ String(pick(m, "contract_mgmt_objid") || ""),
/* 24 */ String(pick(m, "type") || ""),
/* 25 */ String(pick(m, "inspect_method") || ""),
/* 26 */ String(pick(m, "total_price_txt") || ""),
/* 27 */ String(pick(m, "total_price_txt_all") || ""),
/* 28 */ String(pick(m, "vat_method") || ""),
/* 29 */ strNum(pick(m, "total_supply_unit_price")),
/* 30 */ strNum(pick(m, "total_supply_price")),
/* 31 */ strNum(pick(m, "total_real_supply_price")),
/* 32 */ strNum(pick(m, "discount_price")),
/* 33 */ strNum(pick(m, "total_price")),
/* 34 */ strNum(pick(m, "total_price_all")),
/* 35 */ strNum(pick(m, "nego_rate")),
/* 36 */ String(pick(m, "supply_bus_no") || ""),
/* 37 */ String(pick(m, "supply_user_name") || ""),
/* 38 */ String(pick(m, "supply_user_hp") || ""),
/* 39 */ String(pick(m, "supply_user_tel") || ""),
/* 40 */ String(pick(m, "supply_user_fax") || ""),
/* 41 */ String(pick(m, "supply_user_email") || ""),
/* 42 */ String(pick(m, "supply_addr") || ""),
/* 43 */ String(pick(m, "unit_code") || ""),
/* 44 */ String(pick(m, "bom_report_objid") || ""),
/* 45 */ String(pick(m, "order_type_cd") || ""),
/* 46 */ String(pick(m, "multi_yn") || "N"),
/* 47 */ String(pick(m, "multi_master_yn") || "N"),
/* 48 */ String(pick(m, "multi_master_objid") || ""),
/* 49 */ String(pick(m, "delivery_plan_date") || ""),
/* 50 */ String(pick(m, "delivery_plan_qty") || ""),
/* 51 */ String(pick(m, "purchase_order_no_org") || ""),
/* 52 */ String(pick(m, "shipment") || ""),
/* 53 */ String(pick(m, "packing") || ""),
/* 54 */ String(pick(m, "validity") || ""),
/* 55 */ String(pick(m, "attn_to") || ""),
];
const upsertMasterSql = `
INSERT INTO PURCHASE_ORDER_MASTER (
OBJID, PO_CLIENT_ID, PURCHASE_ORDER_NO, CATEGORY_CD, PRODUCT_GROUP, PRODUCT, PRODUCT_CODE,
MY_COMPANY_OBJID, PARTNER_OBJID, DELIVERY_DATE, DELIVERY_PLACE, EFFECTIVE_DATE, PAYMENT_TERMS,
REMARK, REQUEST_CONTENT, WRITER, REGDATE, STATUS, SALES_REQUEST_OBJID, SALES_MNG_USER_ID,
SALES_MNG_USER_ID2, FORM_TYPE, TITLE, PURCHASE_DATE, CONTRACT_MGMT_OBJID, TYPE, INSPECT_METHOD,
TOTAL_PRICE_TXT, TOTAL_PRICE_TXT_ALL, VAT_METHOD, TOTAL_SUPPLY_UNIT_PRICE, TOTAL_SUPPLY_PRICE,
TOTAL_REAL_SUPPLY_PRICE, DISCOUNT_PRICE, TOTAL_PRICE, TOTAL_PRICE_ALL, NEGO_RATE,
SUPPLY_BUS_NO, SUPPLY_USER_NAME, SUPPLY_USER_HP, SUPPLY_USER_TEL, SUPPLY_USER_FAX,
SUPPLY_USER_EMAIL, SUPPLY_ADDR, UNIT_CODE, BOM_REPORT_OBJID, ORDER_TYPE_CD, MULTI_YN,
MULTI_MASTER_YN, MULTI_MASTER_OBJID, DELIVERY_PLAN_DATE, DELIVERY_PLAN_QTY,
PURCHASE_ORDER_NO_ORG, SHIPMENT, PACKING, VALIDITY, ATTN_TO
) VALUES (
$1, $2,
(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')
FROM PURCHASE_ORDER_MASTER),
$3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
NOW(), $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28,
$29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45,
$46, $47, $48, $49, $50, $51, $52, $53, $54, $55
) ON CONFLICT (OBJID) DO UPDATE SET
PO_CLIENT_ID = EXCLUDED.PO_CLIENT_ID,
CATEGORY_CD = EXCLUDED.CATEGORY_CD,
PRODUCT_GROUP = EXCLUDED.PRODUCT_GROUP,
PRODUCT = EXCLUDED.PRODUCT,
PRODUCT_CODE = EXCLUDED.PRODUCT_CODE,
MY_COMPANY_OBJID = EXCLUDED.MY_COMPANY_OBJID,
PARTNER_OBJID = EXCLUDED.PARTNER_OBJID,
DELIVERY_DATE = EXCLUDED.DELIVERY_DATE,
DELIVERY_PLACE = EXCLUDED.DELIVERY_PLACE,
EFFECTIVE_DATE = EXCLUDED.EFFECTIVE_DATE,
PAYMENT_TERMS = EXCLUDED.PAYMENT_TERMS,
REMARK = EXCLUDED.REMARK,
REQUEST_CONTENT = EXCLUDED.REQUEST_CONTENT,
WRITER = EXCLUDED.WRITER,
STATUS = EXCLUDED.STATUS,
SALES_MNG_USER_ID = EXCLUDED.SALES_MNG_USER_ID,
SALES_MNG_USER_ID2 = EXCLUDED.SALES_MNG_USER_ID2,
FORM_TYPE = CASE WHEN EXCLUDED.FORM_TYPE = '' THEN PURCHASE_ORDER_MASTER.FORM_TYPE ELSE EXCLUDED.FORM_TYPE END,
TITLE = EXCLUDED.TITLE,
PURCHASE_DATE = EXCLUDED.PURCHASE_DATE,
CONTRACT_MGMT_OBJID = EXCLUDED.CONTRACT_MGMT_OBJID,
TYPE = EXCLUDED.TYPE,
INSPECT_METHOD = EXCLUDED.INSPECT_METHOD,
TOTAL_PRICE_TXT = EXCLUDED.TOTAL_PRICE_TXT,
TOTAL_PRICE_TXT_ALL = EXCLUDED.TOTAL_PRICE_TXT_ALL,
VAT_METHOD = EXCLUDED.VAT_METHOD,
TOTAL_SUPPLY_UNIT_PRICE = EXCLUDED.TOTAL_SUPPLY_UNIT_PRICE,
TOTAL_SUPPLY_PRICE = EXCLUDED.TOTAL_SUPPLY_PRICE,
TOTAL_REAL_SUPPLY_PRICE = EXCLUDED.TOTAL_REAL_SUPPLY_PRICE,
DISCOUNT_PRICE = EXCLUDED.DISCOUNT_PRICE,
TOTAL_PRICE = EXCLUDED.TOTAL_PRICE,
TOTAL_PRICE_ALL = EXCLUDED.TOTAL_PRICE_ALL,
NEGO_RATE = EXCLUDED.NEGO_RATE,
SUPPLY_BUS_NO = EXCLUDED.SUPPLY_BUS_NO,
SUPPLY_USER_NAME = EXCLUDED.SUPPLY_USER_NAME,
SUPPLY_USER_HP = EXCLUDED.SUPPLY_USER_HP,
SUPPLY_USER_TEL = EXCLUDED.SUPPLY_USER_TEL,
SUPPLY_USER_FAX = EXCLUDED.SUPPLY_USER_FAX,
SUPPLY_USER_EMAIL = EXCLUDED.SUPPLY_USER_EMAIL,
SUPPLY_ADDR = EXCLUDED.SUPPLY_ADDR,
UNIT_CODE = EXCLUDED.UNIT_CODE,
BOM_REPORT_OBJID = EXCLUDED.BOM_REPORT_OBJID,
ORDER_TYPE_CD = EXCLUDED.ORDER_TYPE_CD,
DELIVERY_PLAN_DATE = EXCLUDED.DELIVERY_PLAN_DATE,
DELIVERY_PLAN_QTY = EXCLUDED.DELIVERY_PLAN_QTY,
MULTI_YN = EXCLUDED.MULTI_YN,
MULTI_MASTER_YN = EXCLUDED.MULTI_MASTER_YN,
PURCHASE_ORDER_NO_ORG = EXCLUDED.PURCHASE_ORDER_NO_ORG,
SHIPMENT = EXCLUDED.SHIPMENT,
PACKING = EXCLUDED.PACKING,
VALIDITY = EXCLUDED.VALIDITY,
ATTN_TO = EXCLUDED.ATTN_TO
RETURNING OBJID, PURCHASE_ORDER_NO`;
const upRes = await client.query(upsertMasterSql, params);
const savedNo: string = upRes.rows[0]?.purchase_order_no ?? "";
// 파트 UPSERT — wace mergePurchaseOrderPartInfo (1205-1325) 1:1
const parts = payload.parts ?? [];
for (const raw of parts) {
const p: Record<string, any> = raw ?? {};
let popObjid = String(pick(p, "objid") || "").trim();
if (!popObjid) popObjid = createObjId();
const popParams: any[] = [
/* 1 */ popObjid,
/* 2 */ masterObjid,
/* 3 */ String(pick(p, "part_objid") || ""), // bigint 캐스트 in SQL
/* 4 */ strNum(pick(p, "bom_qty")),
/* 5 */ strNum(pick(p, "qty", "order_qty")),
/* 6 */ strNum(pick(p, "order_qty")),
/* 7 */ strNum(pick(p, "partner_price")),
/* 8 */ String(pick(p, "remark") || ""),
/* 9 */ writer,
/* 10 */ String(pick(p, "status") || "create"),
/* 11 */ String(pick(p, "part_name") || ""),
/* 12 */ String(pick(p, "part_no") || ""),
/* 13 */ String(pick(p, "do_no") || ""),
/* 14 */ String(pick(p, "thickness") || ""),
/* 15 */ String(pick(p, "width") || ""),
/* 16 */ String(pick(p, "height") || ""),
/* 17 */ String(pick(p, "out_diameter") || ""),
/* 18 */ String(pick(p, "length") || ""),
/* 19 */ String(pick(p, "in_diameter") || ""),
/* 20 */ String(pick(p, "inven_total_qty") || ""),
/* 21 */ String(pick(p, "ld_part_objid") || ""),
/* 22 */ String(pick(p, "spec") || ""),
/* 23 */ String(pick(p, "maker") || ""),
/* 24 */ String(pick(p, "unit") || ""),
/* 25 */ strNum(pick(p, "supply_unit_price")),
/* 26 */ strNum(pick(p, "price1")),
/* 27 */ strNum(pick(p, "price2")),
/* 28 */ strNum(pick(p, "price3")),
/* 29 */ strNum(pick(p, "price4")),
/* 30 */ strNum(pick(p, "supply_unit_vat_price")),
/* 31 */ strNum(pick(p, "supply_unit_vat_sum_price")),
/* 32 */ strNum(pick(p, "total_order_qty")),
/* 33 */ strNum(pick(p, "stock_qty")),
/* 34 */ strNum(pick(p, "real_order_qty")),
/* 35 */ strNum(pick(p, "real_supply_price")),
/* 36 */ String(pick(p, "part_delivery_place") || ""),
/* 37 */ String(pick(p, "product_name") || ""),
/* 38 */ String(pick(p, "work_order_no") || ""),
/* 39 */ String(pick(p, "delivery_request_date") || ""),
/* 40 */ String(pick(p, "currency") || ""),
];
// part_objid 가 빈 문자열이면 NULL 로 — bigint 컬럼은 빈 문자열 INSERT 실패.
const partObjidParam = popParams[2] === "" ? null : popParams[2];
popParams[2] = partObjidParam;
await client.query(
`INSERT INTO PURCHASE_ORDER_PART (
OBJID, PURCHASE_ORDER_MASTER_OBJID, PART_OBJID, BOM_QTY, QTY, ORDER_QTY,
PARTNER_PRICE, REMARK, WRITER, REGDATE, STATUS,
PART_NAME, PART_NO, DO_NO, THICKNESS, WIDTH, HEIGHT, OUT_DIAMETER, LENGTH,
IN_DIAMETER, INVEN_TOTAL_QTY, LD_PART_OBJID, SPEC, MAKER, UNIT,
SUPPLY_UNIT_PRICE, PRICE1, PRICE2, PRICE3, PRICE4,
SUPPLY_UNIT_VAT_PRICE, SUPPLY_UNIT_VAT_SUM_PRICE, TOTAL_ORDER_QTY,
STOCK_QTY, REAL_ORDER_QTY, REAL_SUPPLY_PRICE,
PART_DELIVERY_PLACE, PRODUCT_NAME, WORK_ORDER_NO, DELIVERY_REQUEST_DATE, CURRENCY
) VALUES (
$1, $2, NULLIF($3::text,'')::bigint, $4, $5,
COALESCE(NULLIF($6,'')::numeric::integer, 0),
$7, $8, $9, NOW(), $10,
$11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24,
$25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35,
$36, $37, $38, $39, $40
) ON CONFLICT (OBJID) DO UPDATE SET
ORDER_QTY = COALESCE(NULLIF($6,'')::numeric::integer, 0),
PARTNER_PRICE = EXCLUDED.PARTNER_PRICE,
REMARK = EXCLUDED.REMARK,
STATUS = EXCLUDED.STATUS,
MODIFIER = EXCLUDED.WRITER,
UPDATE_DATE = NOW(),
LD_PART_OBJID = EXCLUDED.LD_PART_OBJID,
SPEC = EXCLUDED.SPEC,
MAKER = EXCLUDED.MAKER,
UNIT = EXCLUDED.UNIT,
SUPPLY_UNIT_PRICE = EXCLUDED.SUPPLY_UNIT_PRICE,
PRICE1 = EXCLUDED.PRICE1,
PRICE2 = EXCLUDED.PRICE2,
PRICE3 = EXCLUDED.PRICE3,
PRICE4 = EXCLUDED.PRICE4,
SUPPLY_UNIT_VAT_PRICE = EXCLUDED.SUPPLY_UNIT_VAT_PRICE,
SUPPLY_UNIT_VAT_SUM_PRICE = EXCLUDED.SUPPLY_UNIT_VAT_SUM_PRICE,
TOTAL_ORDER_QTY = EXCLUDED.TOTAL_ORDER_QTY,
STOCK_QTY = EXCLUDED.STOCK_QTY,
REAL_ORDER_QTY = EXCLUDED.REAL_ORDER_QTY,
REAL_SUPPLY_PRICE = EXCLUDED.REAL_SUPPLY_PRICE,
PART_DELIVERY_PLACE = EXCLUDED.PART_DELIVERY_PLACE,
PRODUCT_NAME = EXCLUDED.PRODUCT_NAME,
WORK_ORDER_NO = EXCLUDED.WORK_ORDER_NO,
DELIVERY_REQUEST_DATE = EXCLUDED.DELIVERY_REQUEST_DATE,
CURRENCY = EXCLUDED.CURRENCY`,
popParams,
);
}
// 삭제된 파트 정리 — 클라이언트가 보낸 deletedPartObjids 일괄 DELETE
const deletedIds = (payload.deletedPartObjids ?? []).filter((s) => !!s);
if (deletedIds.length > 0) {
await client.query(
`DELETE FROM PURCHASE_ORDER_PART
WHERE PURCHASE_ORDER_MASTER_OBJID = $1
AND OBJID = ANY($2::text[])`,
[masterObjid, deletedIds],
);
}
await client.query("COMMIT");
return { objid: masterObjid, purchase_order_no: savedNo };
} catch (e: any) {
await client.query("ROLLBACK");
logger.error("savePurchaseOrderForm 실패", { error: e.message, stack: e.stack });
throw e;
} finally {
client.release();
}
}
/**
* DELETE /api/purchase/order-form/:objid
*
* 마스터 1건 + 파트 cascade 삭제. wace `deletePurchaseOrderMaster` 의 단일판.
*/
export async function deletePurchaseOrderForm(objid: string): Promise<void> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query(`DELETE FROM PURCHASE_ORDER_PART WHERE PURCHASE_ORDER_MASTER_OBJID = $1`, [objid]);
await client.query(`DELETE FROM PURCHASE_ORDER_MASTER WHERE OBJID = $1`, [objid]);
await client.query("COMMIT");
} catch (e: any) {
await client.query("ROLLBACK");
logger.error("deletePurchaseOrderForm 실패", { error: e.message });
throw e;
} finally {
client.release();
}
}
@@ -0,0 +1,158 @@
// ============================================================
// 구매관리 > 발주서관리 — 메일 발송 서비스
//
// wace ContractMgmtService.sendEstimateMailCustom 패턴(영업관리) 재사용:
// - 본문(contents): 다이얼로그 입력 그대로 (HTML 변환은 textToHtml)
// - 첨부: 프론트가 html2canvas + jsPDF 로 만든 base64 PDF 1장
// - SMTP 계정: PURCHASE (mailUtil SmtpAccountType)
// - mail_log title 에 [OBJID:nnn] 토큰 부착 — 그리드 LIKE 매칭 호환
// - 발송 성공 시 purchase_order_master.mail_send_yn='Y', mail_send_date=NOW()
// ============================================================
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
import { sendMailUTF8 } from "../utils/mailUtil";
export interface PurchaseOrderMailBody {
pomObjid: string;
toEmails: string; // ; 또는 , 로 구분
ccEmails?: string;
subject: string;
contents: string;
/** 프론트 html2canvas + jsPDF base64 (data URL 또는 raw base64) */
pdfBase64: string;
}
/** 메일 다이얼로그 자동채움 — 공급업체/작성자/발주번호 */
export async function getOrderMailInfo(pomObjid: string) {
const pool = getPool();
const sql = `
SELECT
POM.OBJID AS pom_objid,
POM.PURCHASE_ORDER_NO AS purchase_order_no,
POM.PARTNER_OBJID AS partner_objid,
POM.MANAGER_EMAIL AS writer_email,
POM.MANAGER_NAME AS writer_name,
POM.FORM_TYPE AS form_type,
CM.CLIENT_NM AS partner_name,
CM.EMAIL AS partner_email
FROM PURCHASE_ORDER_MASTER POM
LEFT JOIN CLIENT_MNG CM
ON CM.OBJID = POM.PARTNER_OBJID
WHERE POM.OBJID = $1
LIMIT 1`;
const r = await pool.query(sql, [pomObjid]);
return r.rows[0] ?? null;
}
/** 공급업체 담당자 리스트 — RPS client_mng 는 단일 email 만 보관 (별도 contact 테이블 없음) */
export async function getPartnerManagerList(partnerObjid: string) {
const pool = getPool();
const r = await pool.query(
`SELECT CLIENT_NM AS name, EMAIL AS email, TEL_NO AS phone, '' AS department, 'Y' AS is_main
FROM CLIENT_MNG
WHERE OBJID = $1
AND COALESCE(EMAIL, '') <> ''`,
[partnerObjid],
);
return r.rows;
}
function decodeBase64Pdf(input: string): Buffer {
const m = /^data:application\/pdf;base64,(.*)$/i.exec(input);
const b64 = m ? m[1] : input;
return Buffer.from(b64, "base64");
}
function textToHtml(text: string): string {
return (text || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\n/g, "<br>");
}
export async function sendOrderMail(userId: string, body: PurchaseOrderMailBody) {
const info = await getOrderMailInfo(body.pomObjid);
if (!info) {
return { success: false, message: "발주서 정보를 찾을 수 없습니다." };
}
const splitEmails = (s: string | undefined) => (s || "")
.split(/[;,]/)
.map((e) => e.trim())
.filter((e) => e !== "");
const toEmails = splitEmails(body.toEmails);
if (toEmails.length === 0) {
return { success: false, message: "수신인 이메일이 없습니다." };
}
const ccEmails = splitEmails(body.ccEmails);
// 작성자 이메일 자동 cc
if (info.writer_email && !ccEmails.includes(info.writer_email)) {
ccEmails.push(info.writer_email);
}
if (!body.pdfBase64) {
return { success: false, message: "발주서 PDF 가 전달되지 않았습니다." };
}
const pdfBuf = decodeBase64Pdf(body.pdfBase64);
const safeNo = (info.purchase_order_no || "발주서").toString().replace(/[^\w가-힣\-_.]/g, "_");
const ts = new Date().toISOString().replace(/[-:.TZ]/g, "").substring(0, 14);
const attachment = {
filename: `${safeNo}_${ts}.pdf`,
content: pdfBuf,
contentType: "application/pdf",
};
const subject = body.subject.trim();
const subjectForLog = subject.includes("[OBJID:")
? subject
: `${subject} [OBJID:${body.pomObjid}]`;
const html = textToHtml(body.contents);
const result = await sendMailUTF8({
accountType: "PURCHASE",
fromUserId: userId,
toEmails,
ccEmails: ccEmails.length > 0 ? ccEmails : undefined,
subject,
subjectForLog,
html,
attachments: [attachment],
mailType: "PURCHASE_ORDER",
});
logger.info("발주서 메일 발송 완료", {
pomObjid: body.pomObjid,
mailLogObjid: result.objid,
sent: result.sent,
to: toEmails,
cc: ccEmails,
});
if (!result.sent) {
return {
success: false,
message: result.error || "메일 발송에 실패했습니다.",
objid: result.objid,
};
}
// 발송 성공 시 mail_send_yn / mail_send_date 갱신
try {
await getPool().query(
`UPDATE PURCHASE_ORDER_MASTER
SET MAIL_SEND_YN = 'Y',
MAIL_SEND_DATE = NOW()
WHERE OBJID = $1`,
[body.pomObjid],
);
} catch (e: any) {
logger.warn("mail_send_yn 갱신 실패", { error: e.message, pomObjid: body.pomObjid });
}
return { success: true, message: "발주서가 성공적으로 발송되었습니다.", objid: result.objid };
}
+17 -7
View File
@@ -1062,19 +1062,21 @@ export async function listPurchaseOrderList(filter: PurchaseListFilter): Promise
// ─── 옵션 — 공급업체 / 작성자 (구매메뉴 공용) ──────────────────
// 견적요청서 / 발주서 vendor — wace 매퍼는 client_mng 와 매칭 (supply_mng 아님)
// RPS client_mng 는 status 컬럼 없음 → use_yn 사용 ('Y'/'N')
export async function listVendorOptions(): Promise<{ code: string; label: string }[]> {
const pool = getPool();
try {
const r = await pool.query(
`SELECT OBJID AS code, CLIENT_NM AS label
FROM CLIENT_MNG
WHERE COALESCE(STATUS, 'Y') IN ('Y', 'active', 'ACTIVE', '활성')
WHERE COALESCE(USE_YN, 'Y') IN ('Y', 'y', '1')
AND CLIENT_NM IS NOT NULL AND CLIENT_NM <> ''
ORDER BY CLIENT_NM
LIMIT 2000`,
);
return r.rows;
} catch {
} catch (e: any) {
logger.error("listVendorOptions 실패", { error: e.message });
return [];
}
}
@@ -1090,24 +1092,32 @@ export async function listSupplierOptions(): Promise<{ code: string; label: stri
ORDER BY SUPPLY_NAME`,
);
return r.rows;
} catch {
} catch (e: any) {
logger.error("listSupplierOptions 실패", { error: e.message });
return [];
}
}
export async function listUserOptions(): Promise<{ code: string; label: string }[]> {
export async function listUserOptions(): Promise<
{ code: string; label: string; name?: string; position?: string; phone?: string; email?: string }[]
> {
const pool = getPool();
try {
const r = await pool.query(
`SELECT USER_ID AS code,
USER_NAME || COALESCE(' (' || DEPT_NAME || ')', '') AS label
`SELECT USER_ID AS code,
USER_NAME || COALESCE(' (' || DEPT_NAME || ')', '') AS label,
USER_NAME AS name,
POSITION_NAME AS position,
CELL_PHONE AS phone,
EMAIL AS email
FROM USER_INFO
WHERE COALESCE(STATUS, 'active') IN ('active', '활성', 'ACTIVE')
AND USER_NAME IS NOT NULL AND USER_NAME <> ''
ORDER BY USER_NAME`,
);
return r.rows;
} catch {
} catch (e: any) {
logger.error("listUserOptions 실패", { error: e.message });
return [];
}
}
@@ -20,6 +20,8 @@ import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/compon
import { PageHeader } from "@/components/common/PageHeader";
import { purchaseApi, PurchaseListFilter, OptionItem, getYearOptions } from "@/lib/api/purchase";
import { exportToExcel } from "@/lib/utils/excelExport";
import { DeadlineInfoDialog, DeadlinePrefill } from "@/components/purchase/DeadlineInfoDialog";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
const CLOSE_OPTS: SmartSelectOption[] = [
{ code: "N", label: "미마감" },
@@ -45,6 +47,12 @@ export default function InboundByDatePage() {
const [supplierOpts, setSupplierOpts] = useState<OptionItem[]>([]);
const [userOpts, setUserOpts] = useState<OptionItem[]>([]);
// 마감정보입력 / 매입마감
const [deadlineOpen, setDeadlineOpen] = useState(false);
const [deadlineObjIds, setDeadlineObjIds] = useState<string[]>([]);
const [deadlinePrefill, setDeadlinePrefill] = useState<DeadlinePrefill | undefined>(undefined);
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
const yearOpts = useMemo(() => getYearOptions(), []);
const fetchList = useCallback(async (override?: Partial<PurchaseListFilter>) => {
@@ -83,7 +91,7 @@ export default function InboundByDatePage() {
{ key: "project_no", label: "프로젝트번호", width: "w-[135px]", align: "center" },
{ key: "component_part_no", label: "부품품번", width: "w-[135px]" },
{ key: "part_no", label: "품번", width: "w-[135px]" },
{ key: "part_name", label: "품명", minWidth: "min-w-[280px]" },
{ key: "part_name", label: "품명", width: "w-[280px]" },
{ key: "partner_name", label: "공급업체", minWidth: "min-w-[150px]" },
{ key: "currency_name", label: "환종", width: "w-[80px]", align: "center" },
{ key: "receipt_date", label: "입고일", width: "w-[110px]", align: "center" },
@@ -121,12 +129,55 @@ export default function InboundByDatePage() {
actions={<>
<Button size="sm" variant="outline" className="h-8 gap-1 px-2 text-xs"
disabled={checkedIds.length === 0}
onClick={() => toast.info("마감정보입력 — arrival_plan 신설 후 활성")}>
onClick={() => {
const objIds = rows
.filter((r: any) => checkedIds.includes(String(r.objid)))
.map((r: any) => String(r.arrival_plan_objid || r.objid));
if (objIds.length === 0) return;
setDeadlineObjIds(objIds);
// 단건 선택 시 기존 값 prefill
if (objIds.length === 1) {
const row = rows.find((r: any) => String(r.arrival_plan_objid || r.objid) === objIds[0]);
setDeadlinePrefill({
taxType: row?.tax_type,
taxInvoiceDate: row?.tax_invoice_date,
exportDeclNo: row?.export_decl_no,
loadingDate: row?.loading_date,
foreignType: row?.foreign_type,
duty: row?.duty ? String(row.duty) : "",
exchangeRate: row?.exchange_rate ? String(row.exchange_rate) : "",
importVat: row?.import_vat ? String(row.import_vat) : "",
});
} else {
setDeadlinePrefill(undefined);
}
setDeadlineOpen(true);
}}>
<FileEdit 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 === 0}
onClick={() => toast.info("매입마감 — arrival_plan 신설 후 활성")}>
onClick={async () => {
const selected = rows.filter((r: any) => checkedIds.includes(String(r.objid)));
const alreadyClosed = selected.filter((r: any) => (r.purchase_close_date ?? "") !== "");
if (alreadyClosed.length > 0) {
toast.error("이미 매입마감된 건이 포함돼 있습니다");
return;
}
const objIds = selected.map((r: any) => String(r.arrival_plan_objid || r.objid));
if (objIds.length === 0) return;
const ok = await confirm(`선택한 ${objIds.length}건을 매입마감 처리하시겠어요?`, {
confirmText: "매입마감",
});
if (!ok) return;
try {
const r = await purchaseApi.closeArrival(objIds);
toast.success(`매입마감 완료 (${r.updated}건)`);
fetchList();
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "매입마감 실패");
}
}}>
<Lock className="h-3.5 w-3.5" />
</Button>
</>}
@@ -212,6 +263,15 @@ export default function InboundByDatePage() {
}}
showChart
/>
<DeadlineInfoDialog
open={deadlineOpen}
objIds={deadlineObjIds}
prefill={deadlinePrefill}
onClose={() => setDeadlineOpen(false)}
onSaved={() => { setDeadlineOpen(false); fetchList(); }}
/>
{ConfirmDialogComponent}
</div>
);
}
@@ -81,7 +81,7 @@ export default function InboundByItemPage() {
{ key: "project_no", label: "프로젝트번호", width: "w-[135px]", align: "center" },
{ key: "component_part_no", label: "부품품번", width: "w-[140px]" },
{ key: "part_no", label: "품번", width: "w-[140px]" },
{ key: "part_name", label: "품명", minWidth: "min-w-[280px]" },
{ key: "part_name", label: "품명", width: "w-[280px]" },
{ key: "partner_name", label: "공급업체", minWidth: "min-w-[150px]" },
{ key: "currency_name", label: "환종", width: "w-[80px]", align: "center" },
{ key: "delivery_request_date", label: "입고요청일", width: "w-[115px]", align: "center" },
@@ -19,6 +19,7 @@ import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/compon
import { PageHeader } from "@/components/common/PageHeader";
import { purchaseApi, PurchaseListFilter, OptionItem, getYearOptions } from "@/lib/api/purchase";
import { exportToExcel } from "@/lib/utils/excelExport";
import { InboundFormDialog } from "@/components/purchase/InboundFormDialog";
const DELIVERY_STATUS_OPTS: SmartSelectOption[] = [
{ code: "입고중", label: "입고중" },
@@ -47,6 +48,10 @@ export default function InboundPage() {
const [supplierOpts, setSupplierOpts] = useState<OptionItem[]>([]);
const [userOpts, setUserOpts] = useState<OptionItem[]>([]);
// 입고등록 다이얼로그
const [inboundOpen, setInboundOpen] = useState(false);
const [inboundPomObjid, setInboundPomObjid] = useState("");
const yearOpts = useMemo(() => getYearOptions(), []);
const fetchList = useCallback(async (override?: Partial<PurchaseListFilter>) => {
@@ -88,7 +93,7 @@ export default function InboundPage() {
{ key: "purchase_order_no", label: "발주서 No", width: "w-[125px]", align: "center" },
{ key: "project_no", label: "프로젝트번호", width: "w-[135px]", align: "center" },
{ key: "part_no", label: "품번", width: "w-[140px]" },
{ key: "part_name", label: "품명", minWidth: "min-w-[280px]" },
{ key: "part_name", label: "품명", width: "w-[280px]" },
{ key: "partner_name", label: "공급업체", minWidth: "min-w-[150px]" },
{ key: "currency_name", label: "환종", width: "w-[80px]", align: "center" },
{ key: "writer_name", label: "구매담당자", width: "w-[110px]", align: "center" },
@@ -119,7 +124,13 @@ export default function InboundPage() {
actions={
<Button size="sm" variant="default" className="h-8 gap-1 px-2 text-xs"
disabled={checkedIds.length !== 1}
onClick={() => toast.info("입고등록 — purchase_order_part / arrival_plan 신설 후 활성")}>
onClick={() => {
const id = checkedIds[0]; if (!id) return;
const row = rows.find((r: any) => String(r.objid) === id);
const pomObjid = String(row?.objid ?? id);
setInboundPomObjid(pomObjid);
setInboundOpen(true);
}}>
<PackagePlus className="h-3.5 w-3.5" />
</Button>
}
@@ -211,6 +222,13 @@ export default function InboundPage() {
}}
showChart
/>
<InboundFormDialog
open={inboundOpen}
pomObjid={inboundPomObjid}
onClose={() => setInboundOpen(false)}
onSaved={() => { setInboundOpen(false); fetchList(); }}
/>
</div>
);
}
@@ -18,6 +18,13 @@ 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";
import { Button } from "@/components/ui/button";
import { Pencil, Trash2 } from "lucide-react";
import { PurchaseOrderGeneralFormDialog } from "@/components/purchase/PurchaseOrderGeneralFormDialog";
import { PurchaseOrderOutsourcingFormDialog } from "@/components/purchase/PurchaseOrderOutsourcingFormDialog";
import { PurchaseOrderEnglishFormDialog } from "@/components/purchase/PurchaseOrderEnglishFormDialog";
import { PurchaseOrderFormType } from "@/components/purchase/PurchaseOrderFormTypeSelectDialog";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
const MAIL_SEND_OPTS: SmartSelectOption[] = [
{ code: "Y", label: "발송완료" },
@@ -65,6 +72,11 @@ export default function PurchaseOrderWacePage() {
const [productOpts, setProductOpts] = useState<OptionItem[]>([]);
const [purchaseOpts, setPurchaseOpts] = useState<OptionItem[]>([]);
// 수정 다이얼로그 — form_type 따라 자동 분기
const [editObjid, setEditObjid] = useState<string>("");
const [editFormType, setEditFormType] = useState<PurchaseOrderFormType | "">("");
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
const yearOpts = useMemo(() => getYearOptions(), []);
const fetchList = useCallback(async (override?: Partial<PurchaseListFilter>) => {
@@ -138,7 +150,42 @@ export default function PurchaseOrderWacePage() {
return (
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
<PageHeader loading={loading} onSearch={handleSearch} onReset={handleReset} />
<PageHeader loading={loading} onSearch={handleSearch} onReset={handleReset}
actions={<>
<Button size="sm" variant="outline" className="h-8 gap-1 px-2 text-xs"
disabled={checkedIds.length !== 1}
onClick={() => {
const id = checkedIds[0]; if (!id) return;
const row = rows.find((r: any) => String(r.objid) === id);
const ft = (String(row?.form_type || "general") as PurchaseOrderFormType);
setEditObjid(id);
setEditFormType(ft);
}}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="destructive" className="h-8 gap-1 px-2 text-xs"
disabled={checkedIds.length === 0}
onClick={async () => {
const ok = await confirm(`선택한 ${checkedIds.length}건을 삭제하시겠어요?`, {
description: "발주서와 품목이 함께 삭제돼요.",
variant: "destructive",
confirmText: "삭제",
});
if (!ok) return;
try {
for (const id of checkedIds) {
await purchaseApi.deleteOrderForm(id);
}
toast.success("삭제 완료");
setCheckedIds([]);
fetchList();
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "삭제 실패");
}
}}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>} />
<CompactFilterBar totalText={<> {total.toLocaleString()}</>}>
<CompactFilterField label="년도" width={100}>
@@ -233,7 +280,33 @@ export default function PurchaseOrderWacePage() {
exportToExcel(exportRows, "발주서관리.xlsx", "발주서");
}}
showChart
onRowClick={(row: any) => {
if (!row?.objid) return;
setEditObjid(String(row.objid));
setEditFormType(String(row.form_type || "general") as PurchaseOrderFormType);
}}
/>
<PurchaseOrderGeneralFormDialog
open={editFormType === "general"}
pomObjid={editObjid}
onClose={() => setEditFormType("")}
onSaved={() => { setEditFormType(""); fetchList(); }}
/>
<PurchaseOrderOutsourcingFormDialog
open={editFormType === "outsourcing"}
pomObjid={editObjid}
onClose={() => setEditFormType("")}
onSaved={() => { setEditFormType(""); fetchList(); }}
/>
<PurchaseOrderEnglishFormDialog
open={editFormType === "english"}
pomObjid={editObjid}
onClose={() => setEditFormType("")}
onSaved={() => { setEditFormType(""); fetchList(); }}
/>
{ConfirmDialogComponent}
</div>
);
}
@@ -17,6 +17,13 @@ import { PageHeader } from "@/components/common/PageHeader";
import { apiClient } from "@/lib/api/client";
import { purchaseApi, PurchaseListFilter, OptionItem } from "@/lib/api/purchase";
import { exportToExcel } from "@/lib/utils/excelExport";
import { PurchaseOrderGeneralFormDialog } from "@/components/purchase/PurchaseOrderGeneralFormDialog";
import { PurchaseOrderOutsourcingFormDialog } from "@/components/purchase/PurchaseOrderOutsourcingFormDialog";
import { PurchaseOrderEnglishFormDialog } from "@/components/purchase/PurchaseOrderEnglishFormDialog";
import {
PurchaseOrderFormTypeSelectDialog,
PurchaseOrderFormType,
} from "@/components/purchase/PurchaseOrderFormTypeSelectDialog";
const PARENT_PURCHASE_TYPE = "0001814"; // 구매유형 comm_code
const PARENT_PART_TYPE = "0000001"; // 제품구분 comm_code
@@ -46,6 +53,11 @@ export default function ProposalPage() {
const [partTypeOpts, setPartTypeOpts] = useState<SmartSelectOption[]>([]);
const [userOpts, setUserOpts] = useState<OptionItem[]>([]);
// 발주서생성 — 양식 선택 모달 → 양식별 다이얼로그
const [typeSelectOpen, setTypeSelectOpen] = useState(false);
const [orderFormType, setOrderFormType] = useState<PurchaseOrderFormType | "">("");
const [orderFormProposalId, setOrderFormProposalId] = useState<string>("");
const fetchList = useCallback(async (override?: Partial<PurchaseListFilter>) => {
setLoading(true);
try {
@@ -124,7 +136,12 @@ export default function ProposalPage() {
</Button>
<Button size="sm" variant="default" className="h-8 gap-1 px-2 text-xs"
disabled={checkedIds.length !== 1}
onClick={() => toast.info("발주서생성 — purchase_order_part 신설 후 활성")}>
onClick={() => {
const proposalId = checkedIds[0];
if (!proposalId) return;
setOrderFormProposalId(proposalId);
setTypeSelectOpen(true);
}}>
<ClipboardCheck className="h-3.5 w-3.5" />
</Button>
</>}
@@ -194,6 +211,31 @@ export default function ProposalPage() {
}}
showChart
/>
<PurchaseOrderFormTypeSelectDialog
open={typeSelectOpen}
onClose={() => setTypeSelectOpen(false)}
onSelect={(t) => setOrderFormType(t)}
/>
<PurchaseOrderGeneralFormDialog
open={orderFormType === "general"}
proposalObjid={orderFormProposalId}
onClose={() => setOrderFormType("")}
onSaved={() => { setOrderFormType(""); fetchList(); }}
/>
<PurchaseOrderOutsourcingFormDialog
open={orderFormType === "outsourcing"}
proposalObjid={orderFormProposalId}
onClose={() => setOrderFormType("")}
onSaved={() => { setOrderFormType(""); fetchList(); }}
/>
<PurchaseOrderEnglishFormDialog
open={orderFormType === "english"}
proposalObjid={orderFormProposalId}
onClose={() => setOrderFormType("")}
onSaved={() => { setOrderFormType(""); fetchList(); }}
/>
</div>
);
}
+37 -10
View File
@@ -129,6 +129,7 @@ function SortableHeaderCell({
col, sortKey, sortDir, onSort,
headerFilterValues, uniqueValues, onToggleFilter, onClearFilter,
frozenLeftClass = "left-0",
frozenLeftPx,
widthPx, onResizeStart,
}: {
col: DataGridColumn;
@@ -140,6 +141,8 @@ function SortableHeaderCell({
onToggleFilter: (colKey: string, value: string) => void;
onClearFilter: (colKey: string) => void;
frozenLeftClass?: string;
/** 다중 frozen 누적 left 픽셀 (지정 시 frozenLeftClass 무시) */
frozenLeftPx?: number;
/** 사용자 리사이즈로 결정된 현재 너비(px). 없으면 col.width Tailwind 클래스 사용 */
widthPx?: number;
/** 리사이즈 핸들 mousedown 핸들러 */
@@ -148,17 +151,24 @@ function SortableHeaderCell({
const [filterSearch, setFilterSearch] = useState("");
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,
cursor: "grab",
};
// frozen 컬럼은 CSS sticky 가 작동하도록 dnd-kit transform/transition 모두 제외
// (transform 이 적용되면 sticky 의 containing block 이 변경돼 sticky 가 깨짐)
const style: React.CSSProperties = col.frozen
? {}
: {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
cursor: "grab",
};
if (widthPx != null) {
style.width = widthPx;
style.minWidth = widthPx;
style.maxWidth = widthPx;
}
if (col.frozen && frozenLeftPx != null) {
style.left = frozenLeftPx;
}
const isSorted = sortKey === col.key;
const hasFilter = headerFilterValues.size > 0;
@@ -171,7 +181,7 @@ function SortableHeaderCell({
className={cn(
widthPx == null && col.width, widthPx == null && col.minWidth,
"select-none relative group/th !px-1.5",
col.frozen && cn("sticky z-20 bg-background", frozenLeftClass),
col.frozen && cn("sticky z-20 bg-background", frozenLeftPx == null && frozenLeftClass),
)}
>
<div className="inline-flex items-center gap-0.5 w-full">
@@ -762,6 +772,19 @@ export function DataGrid({
const stickyFirstColBodyClass = "sticky left-0 z-[6]";
const frozenLeftClass = hasFirstCol ? "left-10" : "left-0";
// 다중 frozen 컬럼 누적 left 픽셀 (No/체크박스 다음 위치부터)
const frozenLeftPxMap = useMemo(() => {
const map: Record<string, number> = {};
let cursor = hasFirstCol ? 40 : 0;
for (const c of visibleColumns) {
if (!c.frozen) continue;
map[c.key] = cursor;
const px = columnWidths[c.key] ?? parseWidthClass(c.width) ?? parseWidthClass(c.minWidth) ?? 100;
cursor += px;
}
return map;
}, [visibleColumns, columnWidths, hasFirstCol]);
// 컬럼 settings dropdown — 데이터/시스템 그룹 분리. 시스템 키는 systemColumnKeys로 지정.
const systemKeySet = useMemo(() => new Set(systemColumnKeys ?? []), [systemColumnKeys]);
const dataCols = useMemo(() => columns.filter((c) => !systemKeySet.has(c.key)), [columns, systemKeySet]);
@@ -908,6 +931,7 @@ export function DataGrid({
onToggleFilter={toggleHeaderFilter}
onClearFilter={clearHeaderFilter}
frozenLeftClass={frozenLeftClass}
frozenLeftPx={frozenLeftPxMap[col.key]}
widthPx={columnWidths[col.key]}
onResizeStart={startResize}
/>
@@ -987,19 +1011,22 @@ export function DataGrid({
)}
{visibleColumns.map((col) => {
const w = columnWidths[col.key];
const inlineStyle = w != null ? { width: w, minWidth: w, maxWidth: w } : undefined;
const frozenLeft = col.frozen ? frozenLeftPxMap[col.key] : undefined;
const inlineStyle: React.CSSProperties = {};
if (w != null) { inlineStyle.width = w; inlineStyle.minWidth = w; inlineStyle.maxWidth = w; }
if (frozenLeft != null) { inlineStyle.left = frozenLeft; }
// 일반 텍스트 셀에 컬럼 단위 onClick 지원 (clip/folder 외)
const cellClickable = !!col.onClick && !col.editable;
return (
<TableCell
key={col.key}
style={inlineStyle}
style={Object.keys(inlineStyle).length > 0 ? inlineStyle : undefined}
className={cn(
w == null && col.width, w == null && col.minWidth, "py-1",
col.editable && "cursor-text",
cellClickable && "cursor-pointer hover:underline text-primary",
isSelected && "bg-accent",
col.frozen && cn("sticky z-[5]", frozenLeftClass, stickyBgClass),
col.frozen && cn("sticky z-[5]", frozenLeft == null && frozenLeftClass, stickyBgClass),
)}
onClick={cellClickable ? (e) => { e.stopPropagation(); col.onClick!(row); } : undefined}
onDoubleClick={(e) => {
@@ -0,0 +1,165 @@
"use client";
// 구매관리 > 입고일별 입고관리 > 마감정보입력 다이얼로그
// wace 원본: purchaseCloseList.jsp:75-246 swal 모달 1:1
// - 다중 행 선택 → 8필드 일괄 UPDATE
// - 단건 선택 시 그리드 행에서 기존 값 자동 채움 (호출자가 prefill 로 전달)
import React, { useEffect, useState } from "react";
import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogFooter, DialogHeader } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { DateInput } from "@/components/common/DateInput";
import { NumberInput } from "@/components/common/NumberInput";
import { purchaseApi, DeadlineInfoPayload } from "@/lib/api/purchase";
// wace purchaseCloseList.jsp:490-499 하드코딩 옵션
const FOREIGN_TYPE_OPTS: SmartSelectOption[] = [
{ code: "0001220", label: "국내" },
{ code: "0001221", label: "해외" },
];
const TAX_TYPE_OPTS: SmartSelectOption[] = [
{ code: "0900218", label: "과세매입" },
{ code: "0900219", label: "영세매입" },
{ code: "0900220", label: "수입" },
];
export interface DeadlinePrefill {
taxType?: string;
taxInvoiceDate?: string;
exportDeclNo?: string;
loadingDate?: string;
foreignType?: string;
duty?: string;
exchangeRate?: string;
importVat?: string;
}
interface Props {
open: boolean;
onClose: () => void;
onSaved?: () => void;
/** 선택된 arrival_plan.OBJID 목록 */
objIds: string[];
/** 단건 선택 시 기존 값 자동 채움 */
prefill?: DeadlinePrefill;
}
export function DeadlineInfoDialog({ open, onClose, onSaved, objIds, prefill }: Props) {
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<DeadlinePrefill>({});
useEffect(() => {
if (!open) return;
setForm({
taxType: prefill?.taxType ?? "",
taxInvoiceDate: prefill?.taxInvoiceDate ?? "",
exportDeclNo: prefill?.exportDeclNo ?? "",
loadingDate: prefill?.loadingDate ?? "",
foreignType: prefill?.foreignType ?? "",
duty: prefill?.duty ?? "",
exchangeRate: prefill?.exchangeRate ?? "",
importVat: prefill?.importVat ?? "",
});
}, [open, prefill]);
const handleSave = async () => {
if (objIds.length === 0) {
toast.warning("선택된 입고건이 없습니다");
return;
}
setSaving(true);
try {
const payload: DeadlineInfoPayload = {
objIds,
taxType: form.taxType ?? "",
taxInvoiceDate: form.taxInvoiceDate,
exportDeclNo: form.exportDeclNo,
loadingDate: form.loadingDate,
foreignType: form.foreignType,
duty: form.duty,
exchangeRate: form.exchangeRate,
importVat: form.importVat,
};
const r = await purchaseApi.saveArrivalDeadline(payload);
toast.success(`마감정보 저장 완료 (${r.updated}건)`);
onSaved?.();
onClose();
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패");
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={(v) => { if (!v && !saving) onClose(); }}>
<DialogContent className="max-w-[600px] bg-white">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription> {objIds.length} </DialogDescription>
</DialogHeader>
<div className="space-y-2">
<FieldRow label="국내/해외">
<SmartSelect options={FOREIGN_TYPE_OPTS} value={form.foreignType ?? ""}
onValueChange={(v) => setForm({ ...form, foreignType: v })} />
</FieldRow>
<FieldRow label="환율">
<NumberInput value={form.exchangeRate || ""} decimals={2}
onChange={(v) => setForm({ ...form, exchangeRate: String(v) })}
className="h-8 text-[12px]" />
</FieldRow>
<FieldRow label="과세구분">
<SmartSelect options={TAX_TYPE_OPTS} value={form.taxType ?? ""}
onValueChange={(v) => setForm({ ...form, taxType: v })} />
</FieldRow>
<FieldRow label="세금계산서발행일">
<DateInput value={form.taxInvoiceDate ?? ""}
onChange={(v) => setForm({ ...form, taxInvoiceDate: v })} />
</FieldRow>
<FieldRow label="수출신고필증신고번호">
<Input value={form.exportDeclNo ?? ""}
onChange={(e) => setForm({ ...form, exportDeclNo: e.target.value })}
className="h-8 text-[12px]" />
</FieldRow>
<FieldRow label="선적일자">
<DateInput value={form.loadingDate ?? ""}
onChange={(v) => setForm({ ...form, loadingDate: v })} />
</FieldRow>
<FieldRow label="관세">
<NumberInput value={form.duty || ""} decimals={0}
onChange={(v) => setForm({ ...form, duty: String(v) })}
className="h-8 text-[12px]" />
</FieldRow>
<FieldRow label="수입부가세">
<NumberInput value={form.importVat || ""} decimals={0}
onChange={(v) => setForm({ ...form, importVat: String(v) })}
className="h-8 text-[12px]" />
</FieldRow>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving}></Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function FieldRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid grid-cols-[180px_1fr] items-center gap-2 border-b border-gray-100 py-1.5">
<Label className="text-[12px] font-semibold text-right pr-3">{label}</Label>
<div>{children}</div>
</div>
);
}
@@ -0,0 +1,391 @@
"use client";
// 구매관리 > 입고관리 > 입고등록 다이얼로그
// wace 원본: purchaseOrder/deliveryAcceptanceFormPopUp_new.jsp 1:1
// - 헤더: 발주번호 / 프로젝트번호 (readonly)
// - 좌측: 발주 품목 read-only (품번/품명/규격/단위/수량/입고요청일)
// - 우측: 차수별 입고 입력 (입고일/입고창고/계정과목/입고수량)
// - 차수 추가: 같은 품목에 N개 차수 행 추가 가능 (group_seq)
// - 저장: arrival_plan 다수 UPSERT 트랜잭션 (receipt_qty=0 행은 skip)
import React, { useEffect, useMemo, useState } from "react";
import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { DateInput } from "@/components/common/DateInput";
import { NumberInput } from "@/components/common/NumberInput";
import { purchaseApi, InboundFormData, InboundSaveRow } from "@/lib/api/purchase";
interface Props {
open: boolean;
onClose: () => void;
onSaved?: () => void;
pomObjid: string;
}
interface PartRow {
order_part_objid: string;
part_objid: string;
part_no: string;
part_name: string;
spec: string;
maker: string;
unit_title: string;
order_qty: number;
arrival_qty: number;
non_arrival_qty: number;
delivery_request_date: string;
}
interface ArrivalRow {
rowKey: string;
objid: string;
order_part_objid: string;
part_objid: string;
group_seq: string;
seq: string;
receipt_date: string;
location: string;
sub_location: string;
receipt_qty: number | "";
/** 같은 발주품목의 다른 차수가 차지하는 총량 — 잔여수량 계산용 */
}
let _rk = 0;
const nextKey = () => `k${++_rk}_${Date.now()}`;
const todayIso = () => new Date().toISOString().slice(0, 10);
export function InboundFormDialog({ open, onClose, onSaved, pomObjid }: Props) {
const [master, setMaster] = useState<InboundFormData["master"] | null>(null);
const [parts, setParts] = useState<PartRow[]>([]);
const [arrivals, setArrivals] = useState<ArrivalRow[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [warehouseOpts, setWarehouseOpts] = useState<SmartSelectOption[]>([]);
const [acctOpts, setAcctOpts] = useState<SmartSelectOption[]>([]);
// 일괄적용 (좌 미입고 영역) — 운영판 LOCATION_CD/SUB_LOCATION_CD 일괄적용
const [bulkLocation, setBulkLocation] = useState("");
const [bulkSubLocation, setBulkSubLocation] = useState("");
useEffect(() => {
if (!open || !pomObjid) return;
setMaster(null);
setParts([]);
setArrivals([]);
(async () => {
try {
const [ws, ac] = await Promise.all([
purchaseApi.listWarehouses(),
purchaseApi.listAcctCodes(),
]);
setWarehouseOpts(ws.map((v) => ({ code: v.code, label: v.label })));
setAcctOpts(ac.map((v) => ({ code: v.code, label: v.label })));
} catch {/* skip */}
})();
setLoading(true);
(async () => {
try {
const r = await purchaseApi.getInboundForm(pomObjid);
setMaster(r.master);
setParts(r.parts);
const initRows: ArrivalRow[] = [];
// 기존 입고 차수가 있으면 그대로
for (const a of r.arrivals) {
initRows.push({
rowKey: nextKey(),
objid: a.objid,
order_part_objid: a.order_part_objid,
part_objid: a.part_objid,
group_seq: a.group_seq || "1",
seq: a.seq || "",
receipt_date: a.receipt_date || todayIso(),
location: a.location || "",
sub_location: a.sub_location || "",
receipt_qty: a.receipt_qty || "",
});
}
// 신규 — 각 발주품목마다 1차 행 (미입고수량 기본값)
if (initRows.length === 0) {
let seqCounter = 0;
for (const p of r.parts) {
seqCounter++;
initRows.push({
rowKey: nextKey(),
objid: "",
order_part_objid: p.order_part_objid,
part_objid: p.part_objid,
group_seq: "1",
seq: String(seqCounter),
receipt_date: todayIso(),
location: "",
sub_location: "",
receipt_qty: p.non_arrival_qty || "",
});
}
}
setArrivals(initRows);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "입고 정보 로드 실패");
} finally {
setLoading(false);
}
})();
}, [open, pomObjid]);
/** 차수별 그룹 매핑 — order_part_objid → [arrival rows] */
const groupedByPart = useMemo(() => {
const g: Record<string, ArrivalRow[]> = {};
for (const a of arrivals) {
const k = a.order_part_objid;
if (!g[k]) g[k] = [];
g[k].push(a);
}
return g;
}, [arrivals]);
const handleAddArrivalForPart = (orderPartObjid: string, partObjid: string) => {
const existing = arrivals.filter((a) => a.order_part_objid === orderPartObjid);
const nextSeq = existing.length + 1;
setArrivals((prev) => [
...prev,
{
rowKey: nextKey(),
objid: "",
order_part_objid: orderPartObjid,
part_objid: partObjid,
group_seq: String(nextSeq),
seq: String(nextSeq),
receipt_date: todayIso(),
location: "",
sub_location: "",
receipt_qty: "",
},
]);
};
const handleRemoveArrival = (rowKey: string) => {
setArrivals((prev) => prev.filter((a) => a.rowKey !== rowKey));
};
const updateArrival = (rowKey: string, patch: Partial<ArrivalRow>) => {
setArrivals((prev) => prev.map((a) => a.rowKey === rowKey ? { ...a, ...patch } : a));
};
const handleBulkApply = () => {
if (!bulkLocation && !bulkSubLocation) {
toast.info("일괄 적용할 입고창고 또는 계정과목을 선택하세요");
return;
}
setArrivals((prev) => prev.map((a) => ({
...a,
...(bulkLocation ? { location: bulkLocation } : {}),
...(bulkSubLocation ? { sub_location: bulkSubLocation } : {}),
})));
};
const handleSave = async () => {
if (!master) return;
const toSave = arrivals.filter((a) => Number(a.receipt_qty) > 0);
if (toSave.length === 0) {
toast.warning("입고 수량이 입력된 행이 없습니다");
return;
}
setSaving(true);
try {
const rows: InboundSaveRow[] = toSave.map((a) => ({
objid: a.objid || undefined,
parent_objid: master.pom_objid,
order_part_objid: a.order_part_objid,
part_objid: a.part_objid,
group_seq: a.group_seq || "1",
seq: a.seq || "",
receipt_date: a.receipt_date || "",
location: a.location || "",
sub_location: a.sub_location || "",
receipt_qty: Number(a.receipt_qty),
arrival_qty: Number(a.receipt_qty),
}));
const r = await purchaseApi.saveInboundForm(master.pom_objid, rows);
toast.success(`입고 등록 완료 (${r.saved}건)`);
onSaved?.();
onClose();
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패");
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={(v) => { if (!v && !saving) onClose(); }}>
<DialogContent className="max-w-[1500px] w-[97vw] max-h-[94vh] overflow-hidden flex flex-col p-0 gap-0 bg-white">
<DialogTitle className="sr-only"></DialogTitle>
<DialogDescription className="sr-only">wace </DialogDescription>
<div className="flex items-center justify-between border-b border-gray-300 px-4 py-3">
<div className="text-[22px] font-bold"></div>
<div className="flex items-center gap-3 text-[12px]">
<span className="text-gray-600"></span>
<span className="font-bold">{master?.purchase_order_no || "-"}</span>
<span className="text-gray-600 ml-3"></span>
<span className="font-bold">{master?.project_no || "-"}</span>
<span className="text-gray-600 ml-3"></span>
<span className="font-bold">{master?.partner_name || "-"}</span>
</div>
</div>
<div className="flex-1 overflow-y-auto p-3 text-[12px]">
{/* 일괄적용 영역 */}
<div className="flex items-center gap-2 mb-3 p-2 bg-gray-50 border border-gray-300 rounded">
<span className="text-red-600 font-semibold"></span>
<div className="w-[180px]">
<SmartSelect options={warehouseOpts} value={bulkLocation}
onValueChange={setBulkLocation}
placeholder="입고창고" />
</div>
<div className="w-[200px]">
<SmartSelect options={acctOpts} value={bulkSubLocation}
onValueChange={setBulkSubLocation}
placeholder="계정과목" />
</div>
<Button size="sm" variant="outline" className="h-8 px-3 text-xs"
onClick={handleBulkApply}></Button>
<div className="flex-1" />
<Button size="sm" className="h-8 px-5 text-[13px]"
style={{ background: "#dfeffc", color: "#000" }}
onClick={handleSave} disabled={saving || loading}>
{saving ? "저장 중..." : "저장"}
</Button>
<Button size="sm" variant="outline" className="h-8 px-5 text-[13px]"
onClick={onClose} disabled={saving}></Button>
</div>
<div className="flex gap-3">
{/* 좌측: 발주 품목 */}
<div className="w-[44%]">
<div className="border border-black overflow-x-auto">
<table className="w-full text-[11px] border-collapse">
<thead>
<tr className="bg-[#f0f4fa]">
<th colSpan={7} className="border border-black px-1 py-1.5 font-bold"></th>
</tr>
<tr className="bg-[#f0f4fa]">
<th className="border border-black px-1 py-1"></th>
<th className="border border-black px-1 py-1"></th>
<th className="border border-black px-1 py-1"></th>
<th className="border border-black px-1 py-1"></th>
<th className="border border-black px-1 py-1"></th>
<th className="border border-black px-1 py-1"></th>
<th className="border border-black px-1 py-1"></th>
</tr>
</thead>
<tbody>
{parts.length === 0 ? (
<tr><td colSpan={7} className="text-center py-6 border border-black text-gray-500"> </td></tr>
) : parts.map((p, idx) => (
<tr key={idx} className="hover:bg-blue-50/30">
<td className="border border-black px-1 py-0.5 text-left">{p.part_no}</td>
<td className="border border-black px-1 py-0.5 text-left">{p.part_name}</td>
<td className="border border-black px-1 py-0.5 text-left">{p.spec}</td>
<td className="border border-black px-1 py-0.5 text-center">{p.unit_title}</td>
<td className="border border-black px-1 py-0.5 text-right tabular-nums">{p.order_qty.toLocaleString()}</td>
<td className="border border-black px-1 py-0.5 text-right tabular-nums text-red-600">{p.non_arrival_qty.toLocaleString()}</td>
<td className="border border-black px-1 py-0.5 text-center">{p.delivery_request_date}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* 우측: 차수별 입고 입력 */}
<div className="flex-1">
<div className="border border-black overflow-x-auto">
<table className="w-full text-[11px] border-collapse">
<thead>
<tr className="bg-[#f0f4fa]">
<th className="border border-black px-1 py-1.5 w-[140px]"></th>
<th className="border border-black px-1 py-1.5 w-[60px]"></th>
<th className="border border-black px-1 py-1.5 w-[110px]"></th>
<th className="border border-black px-1 py-1.5"></th>
<th className="border border-black px-1 py-1.5"></th>
<th className="border border-black px-1 py-1.5 w-[90px]"></th>
<th className="border border-black px-1 py-1.5 w-[70px]"></th>
</tr>
</thead>
<tbody>
{parts.length === 0 ? (
<tr><td colSpan={7} className="text-center py-6 border border-black text-gray-500"> </td></tr>
) : parts.map((p) => {
const groupRows = groupedByPart[p.order_part_objid] || [];
const rows: React.ReactElement[] = [];
groupRows.forEach((a, idx) => {
rows.push(
<tr key={a.rowKey} className="hover:bg-blue-50/30">
{idx === 0 && (
<td rowSpan={groupRows.length} className="border border-black px-1 py-0.5 text-left align-middle">
<div className="text-[10px] text-gray-600">{p.part_no}</div>
<div>{p.part_name}</div>
</td>
)}
<td className="border border-black px-1 py-0.5 text-center">{a.group_seq}</td>
<td className="border border-black px-1 py-0.5">
<DateInput value={a.receipt_date}
onChange={(v) => updateArrival(a.rowKey, { receipt_date: v })}
size="sm" />
</td>
<td className="border border-black px-1 py-0.5">
<SmartSelect options={warehouseOpts} value={a.location}
onValueChange={(v) => updateArrival(a.rowKey, { location: v })}
placeholder="선택" />
</td>
<td className="border border-black px-1 py-0.5">
<SmartSelect options={acctOpts} value={a.sub_location}
onValueChange={(v) => updateArrival(a.rowKey, { sub_location: v })}
placeholder="선택" />
</td>
<td className="border border-black px-1 py-0.5">
<NumberInput value={a.receipt_qty} decimals={0}
onChange={(v) => updateArrival(a.rowKey, { receipt_qty: v })}
className="h-7 text-[11px]" />
</td>
<td className="border border-black px-1 py-0.5 text-center">
<Button size="sm" variant="ghost" className="h-7 w-7 p-0"
onClick={() => handleRemoveArrival(a.rowKey)}
disabled={groupRows.length <= 1}>
<Trash2 className="h-3.5 w-3.5 text-red-500" />
</Button>
</td>
</tr>
);
});
// 차수 추가 행
rows.push(
<tr key={`add_${p.order_part_objid}`}>
<td colSpan={7} className="border border-black px-1 py-0.5 text-center bg-gray-50">
<Button size="sm" variant="ghost" className="h-6 px-2 text-xs"
onClick={() => handleAddArrivalForPart(p.order_part_objid, p.part_objid)}>
<Plus className="h-3 w-3 mr-1" /> {p.part_name}
</Button>
</td>
</tr>
);
return rows;
}).flat()}
</tbody>
</table>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,553 @@
"use client";
// 구매관리 > 발주서관리 — english 양식 등록/수정 다이얼로그
// wace 원본: purchaseOrder/purchaseOrderFormPopup_english.jsp
// - 헤더: 로고 110px + "R P S CO., LTD." + 영문 회사정보 (주소/Tel/Fax/E-mail/Purchasing Team Manager)
// - 타이틀: "Purchase Order" (밑줄) + 부제 "We are pleased to issue Purchase Order ..."
// - 좌+우 2열 5행 필드: Messrs./Shipment, Attn.to/Payment, Date/Packing, Ref.NO/Validity, (빈)/Remarks
// - 그리드 8 visible: Item No. / Commodity & Description / Unit / Q'ty / Currency / Unit Price / Amount / Delivery
// - TOTAL 한 행 + 하단 "Look forward to your soonest delivery..." + 서명영역 (stamp_seal.png 65x65)
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Download, Mail } from "lucide-react";
import { toast } from "sonner";
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { DateInput } from "@/components/common/DateInput";
import { NumberInput } from "@/components/common/NumberInput";
import { purchaseApi, OptionItem } from "@/lib/api/purchase";
import { generatePurchaseOrderPdf } from "@/lib/utils/purchaseOrderPdf";
import { PurchaseOrderMailDialog } from "./PurchaseOrderMailDialog";
interface Props {
open: boolean;
onClose: () => void;
onSaved?: (result: { objid: string; purchase_order_no: string }) => void;
pomObjid?: string;
proposalObjid?: string;
}
interface PartRow {
rowKey: string;
objid: string;
part_objid: string;
part_no: string;
part_name: string;
spec: string;
order_qty: number | "";
qty: number | "";
unit: string;
currency: string;
partner_price: number | "";
supply_unit_price: number;
delivery_request_date: string;
_src?: string;
}
interface MasterState {
objid: string;
purchase_order_no: string;
purchase_date: string;
partner_objid: string;
payment_terms: string;
shipment: string;
attn_to: string;
packing: string;
validity: string;
remark: string;
sales_mng_user_id: string;
manager_name: string;
manager_position: string;
manager_phone: string;
manager_email: string;
sales_request_objid: string;
contract_mgmt_objid: string;
form_type: string;
status: string;
appr_status: string;
}
const EMPTY_MASTER: MasterState = {
objid: "", purchase_order_no: "", purchase_date: "",
partner_objid: "", payment_terms: "",
shipment: "", attn_to: "", packing: "", validity: "", remark: "",
sales_mng_user_id: "", manager_name: "", manager_position: "",
manager_phone: "", manager_email: "",
sales_request_objid: "", contract_mgmt_objid: "",
form_type: "english", status: "create",
appr_status: "",
};
const UNIT_GROUP_ID = "0001399";
const CURRENCY_GROUP_ID = "0001533"; // wace currency_cd (0001534=USD default)
const DEFAULT_CURRENCY = "0001534";
let _rk = 0;
const nextKey = () => `r${++_rk}_${Date.now()}`;
const toNum = (v: any): number => {
if (v == null || v === "") return 0;
const n = Number(String(v).replace(/,/g, ""));
return Number.isFinite(n) ? n : 0;
};
const fmt2 = (n: number) => n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
interface UserOptionExt extends OptionItem {
name?: string;
position?: string;
phone?: string;
email?: string;
}
export function PurchaseOrderEnglishFormDialog({
open, onClose, onSaved, pomObjid, proposalObjid,
}: Props) {
const isEdit = !!pomObjid;
const [master, setMaster] = useState<MasterState>(EMPTY_MASTER);
const [parts, setParts] = useState<PartRow[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [vendorOpts, setVendorOpts] = useState<SmartSelectOption[]>([]);
const [userOpts, setUserOpts] = useState<UserOptionExt[]>([]);
useEffect(() => {
if (!open) return;
setMaster({ ...EMPTY_MASTER });
setParts([]);
(async () => {
try {
const [vs, us] = await Promise.all([
purchaseApi.listVendors(),
purchaseApi.listUsers(),
]);
setVendorOpts(vs.map((v) => ({ code: v.code, label: v.label })));
setUserOpts(us as UserOptionExt[]);
} catch {/* skip */}
})();
setLoading(true);
(async () => {
try {
if (isEdit && pomObjid) {
const r = await purchaseApi.getOrderForm(pomObjid);
applyServerData(r.master ?? {}, r.parts ?? []);
} else if (proposalObjid) {
const r = await purchaseApi.initOrderForm(proposalObjid);
applyServerData(r.master ?? {}, r.parts ?? []);
}
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "초기 로드 실패");
} finally {
setLoading(false);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, isEdit, pomObjid, proposalObjid]);
const applyServerData = (m: Record<string, any>, ps: Record<string, any>[]) => {
setMaster({
objid: String(m.objid ?? ""),
purchase_order_no: String(m.purchase_order_no ?? ""),
purchase_date: String(m.purchase_date ?? ""),
partner_objid: String(m.partner_objid ?? ""),
payment_terms: String(m.payment_terms ?? ""),
shipment: String(m.shipment ?? ""),
attn_to: String(m.attn_to ?? ""),
packing: String(m.packing ?? ""),
validity: String(m.validity ?? ""),
remark: String(m.remark ?? ""),
sales_mng_user_id: String(m.sales_mng_user_id ?? ""),
manager_name: String(m.manager_name ?? ""),
manager_position: String(m.manager_position ?? ""),
manager_phone: String(m.manager_phone ?? ""),
manager_email: String(m.manager_email ?? ""),
sales_request_objid: String(m.sales_request_objid ?? m.proposal_objid ?? ""),
contract_mgmt_objid: String(m.contract_mgmt_objid ?? ""),
form_type: "english",
status: String(m.status ?? "create"),
appr_status: String(m.appr_status ?? ""),
});
setParts(ps.map((p) => ({
rowKey: nextKey(),
objid: String(p.objid ?? ""),
part_objid: String(p.part_objid ?? ""),
part_no: String(p.part_no ?? ""),
part_name: String(p.part_name ?? ""),
spec: String(p.spec ?? ""),
order_qty: p.order_qty === "" || p.order_qty == null ? "" : Number(p.order_qty),
qty: p.qty === "" || p.qty == null
? (p.order_qty === "" || p.order_qty == null ? "" : Number(p.order_qty))
: Number(p.qty),
unit: String(p.unit || "0001400"),
currency: String(p.currency || DEFAULT_CURRENCY),
partner_price: p.partner_price === "" || p.partner_price == null ? "" : Number(p.partner_price),
supply_unit_price: toNum(p.supply_unit_price ?? toNum(p.order_qty) * toNum(p.partner_price)),
delivery_request_date: String(p.delivery_request_date ?? ""),
_src: p._src,
})));
};
const totalSupplyPrice = useMemo(
() => parts.reduce((sum, p) => sum + toNum(p.supply_unit_price), 0),
[parts],
);
const isReadOnly = useMemo(() => {
const a = master.appr_status;
return a === "결재중" || a === "결재완료" || master.status === "cancel";
}, [master.appr_status, master.status]);
const pdfContainerRef = useRef<HTMLDivElement>(null);
const [mailOpen, setMailOpen] = useState(false);
const [generatingPdf, setGeneratingPdf] = useState(false);
const handleDownload = async () => {
if (!pdfContainerRef.current) return;
setGeneratingPdf(true);
try {
const filename = `${master.purchase_order_no || "purchase_order_english"}.pdf`;
await generatePurchaseOrderPdf(pdfContainerRef.current, { download: true, filename });
} catch (e: any) {
toast.error("PDF generation failed: " + (e?.message ?? ""));
} finally {
setGeneratingPdf(false);
}
};
const handleRequestPdf = async (): Promise<string> => {
if (!pdfContainerRef.current) throw new Error("Purchase order container not found");
return generatePurchaseOrderPdf(pdfContainerRef.current);
};
const updateRow = (rowKey: string, patch: Partial<PartRow>) => {
setParts((prev) => prev.map((r) => {
if (r.rowKey !== rowKey) return r;
const merged: PartRow = { ...r, ...patch };
const q = toNum(merged.order_qty);
const u = toNum(merged.partner_price);
merged.qty = merged.order_qty;
merged.supply_unit_price = q * u;
return merged;
}));
};
const handleSave = async () => {
if (!master.partner_objid) { toast.warning("Select supplier (Messrs.)"); return; }
if (parts.length === 0) { toast.warning("No items"); return; }
setSaving(true);
try {
const payload = {
master: {
...master,
form_type: "english",
total_supply_price: String(totalSupplyPrice),
total_supply_unit_price: String(totalSupplyPrice),
total_price: String(totalSupplyPrice),
},
parts: parts.map((p) => ({
objid: p.objid,
part_objid: p.part_objid,
part_no: p.part_no,
part_name: p.part_name,
spec: p.spec,
order_qty: toNum(p.order_qty),
qty: toNum(p.qty || p.order_qty),
unit: p.unit,
currency: p.currency,
partner_price: toNum(p.partner_price),
supply_unit_price: toNum(p.supply_unit_price),
delivery_request_date: p.delivery_request_date,
})),
deletedPartObjids: [],
};
const res = await purchaseApi.saveOrderForm(payload);
toast.success(`Saved (${res.purchase_order_no})`);
onSaved?.(res);
onClose();
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "Save failed");
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent className="max-w-[1280px] w-[96vw] max-h-[94vh] overflow-hidden flex flex-col p-0 gap-0 bg-white">
<DialogTitle className="sr-only">Purchase Order (English)</DialogTitle>
<DialogDescription className="sr-only">wace English Purchase Order PDF form</DialogDescription>
<div ref={pdfContainerRef} className="flex-1 overflow-y-auto p-6 text-[12px]" style={{ fontFamily: "Arial, 'Helvetica Neue', sans-serif" }}>
{/* 헤더 */}
<div className="flex items-start gap-4">
<div className="w-[120px]">
<img src="/images/rps-logo-on.png" alt="RPS Logo"
style={{ maxWidth: "110px", height: "auto" }} />
</div>
<div className="flex-1 text-[11px] leading-snug pt-1">
<div className="text-[14px] font-bold mb-1">R P S CO., LTD.</div>
<div>www.rps-korea.com</div>
<div>8, Gukjegwahak 10-ro, Yuseong-gu, Daejeon, Republic of Korea</div>
<div>Tel : +82-42-602-3300 / Fax : +82-42-672-3399 / E-mail : ady1225@rps-korea.com</div>
<div>Purchasing Team Manager, An-Dong-Yoon</div>
</div>
</div>
<div className="text-center text-[26px] font-bold mt-5 mb-2" style={{ textDecoration: "underline" }}>
Purchase Order
</div>
<p className="text-[11px] mb-2">
We are pleased to issue Purchase Order with the terms and condition described as below.
</p>
{/* 좌+우 2열 5행 정보 테이블 */}
<table className="w-full border-collapse text-[11px]">
<tbody>
<FieldRow
leftLabel="Messrs."
leftCell={
<SmartSelect options={vendorOpts}
value={master.partner_objid}
onValueChange={(v) => setMaster({ ...master, partner_objid: v })}
disabled={isReadOnly} />
}
rightLabel="Shipment"
rightBg="#ebf1de"
rightCell={
<Input className="h-7 text-[11px] px-1.5 border-0 bg-[#ebf1de] focus-visible:ring-1"
value={master.shipment}
onChange={(e) => setMaster({ ...master, shipment: e.target.value })}
disabled={isReadOnly} />
}
/>
<FieldRow
leftLabel="Attn. to"
leftCell={
<Input className="h-7 text-[11px] px-1.5 border-0 focus-visible:ring-1"
value={master.attn_to}
onChange={(e) => setMaster({ ...master, attn_to: e.target.value })}
disabled={isReadOnly} />
}
rightLabel="Payment"
rightCell={
<Input className="h-7 text-[11px] px-1.5 border-0 focus-visible:ring-1"
value={master.payment_terms}
onChange={(e) => setMaster({ ...master, payment_terms: e.target.value })}
disabled={isReadOnly} />
}
/>
<FieldRow
leftLabel="Date"
leftCell={
<DateInput value={master.purchase_date}
onChange={(v) => setMaster({ ...master, purchase_date: v })}
disabled={isReadOnly} />
}
rightLabel="Packing"
rightCell={
<Input className="h-7 text-[11px] px-1.5 border-0 focus-visible:ring-1"
placeholder="Export Standard"
value={master.packing}
onChange={(e) => setMaster({ ...master, packing: e.target.value })}
disabled={isReadOnly} />
}
/>
<FieldRow
leftLabel="Ref. NO"
leftCell={
<span className="font-bold">{master.purchase_order_no || <span className="text-gray-400 font-normal">auto</span>}</span>
}
rightLabel="Validity"
rightCell={
<Input className="h-7 text-[11px] px-1.5 border-0 focus-visible:ring-1"
value={master.validity}
onChange={(e) => setMaster({ ...master, validity: e.target.value })}
disabled={isReadOnly} />
}
/>
<FieldRow
leftLabel=""
leftCell={<span>&nbsp;</span>}
rightLabel="Remarks"
rightCell={
<Input className="h-7 text-[11px] px-1.5 border-0 focus-visible:ring-1"
value={master.remark}
onChange={(e) => setMaster({ ...master, remark: e.target.value })}
disabled={isReadOnly} />
}
/>
</tbody>
</table>
{isReadOnly && (
<div className="mt-3 text-[11px] text-amber-700 bg-amber-50 border border-amber-300 px-3 py-1.5 rounded">
This order is locked (approval in progress / completed or cancelled).
{master.appr_status ? ` (status: ${master.appr_status})` : ""}
{master.status === "cancel" ? " (cancelled)" : ""}
</div>
)}
<div className="flex justify-end gap-2 my-3">
{!isReadOnly && (
<Button size="sm" className="h-8 px-5 text-[13px]"
style={{ background: "#dfeffc", color: "#000" }}
onClick={handleSave} disabled={saving || loading}>
{saving ? "Saving..." : "Save"}
</Button>
)}
{isReadOnly && master.objid && (
<>
<Button size="sm" variant="outline" className="h-8 gap-1 px-3 text-xs"
onClick={() => setMailOpen(true)}>
<Mail className="h-3.5 w-3.5" /> Send Email
</Button>
<Button size="sm" className="h-8 gap-1 px-3 text-[13px]"
style={{ background: "#dfeffc", color: "#000" }}
onClick={handleDownload} disabled={generatingPdf}>
<Download className="h-3.5 w-3.5" />
{generatingPdf ? "Generating..." : "Download PDF"}
</Button>
</>
)}
<Button size="sm" variant="outline" className="h-8 px-5 text-[13px]"
style={{ background: "#dfeffc" }}
onClick={onClose} disabled={saving}>
Close
</Button>
</div>
{/* 그리드 */}
<div className="border border-black overflow-x-auto">
<table className="w-full text-[11px] border-collapse">
<thead>
<tr className="bg-[#f0f4fa]">
<th className="min-w-[120px] border border-black px-1 py-1.5">Item No.</th>
<th className="min-w-[260px] border border-black px-1 py-1.5">Commodity &amp; Description</th>
<th className="w-[60px] border border-black px-1 py-1.5">Unit</th>
<th className="w-[60px] border border-black px-1 py-1.5">Q&apos;ty</th>
<th className="w-[75px] border border-black px-1 py-1.5">Currency</th>
<th className="w-[100px] border border-black px-1 py-1.5">Unit Price</th>
<th className="w-[110px] border border-black px-1 py-1.5">Amount</th>
<th className="w-[110px] border border-black px-1 py-1.5">Delivery</th>
</tr>
</thead>
<tbody>
{parts.length === 0 ? (
<tr>
<td colSpan={8} className="text-center py-8 border border-black text-gray-500">
Items are auto-filled from the proposal
</td>
</tr>
) : parts.map((row) => {
const desc = row.spec ? `${row.part_name}/${row.spec}` : row.part_name;
return (
<tr key={row.rowKey} className="hover:bg-blue-50/30">
<td className="border border-black px-1 py-0.5 text-center text-gray-700">{row.part_no}</td>
<td className="border border-black px-1 py-0.5 text-left text-gray-700">{desc}</td>
<td className="border border-black px-1 py-0.5">
<CommCodeSelect groupId={UNIT_GROUP_ID}
value={row.unit}
onValueChange={(v) => updateRow(row.rowKey, { unit: v })}
withAll={false} className="h-7"
disabled={isReadOnly} />
</td>
<td className="border border-black px-1 py-0.5">
<NumberInput value={row.order_qty} decimals={0}
onChange={(v) => updateRow(row.rowKey, { order_qty: v })}
className="h-7 text-[11px]"
disabled={isReadOnly} />
</td>
<td className="border border-black px-1 py-0.5">
<CommCodeSelect groupId={CURRENCY_GROUP_ID}
value={row.currency}
onValueChange={(v) => updateRow(row.rowKey, { currency: v })}
withAll={false} className="h-7"
disabled={isReadOnly} />
</td>
<td className="border border-black px-1 py-0.5">
<NumberInput value={row.partner_price} decimals={2}
onChange={(v) => updateRow(row.rowKey, { partner_price: v })}
className="h-7 text-[11px]"
disabled={isReadOnly} />
</td>
<td className="border border-black px-1 py-0.5 text-right tabular-nums pr-2">
{fmt2(row.supply_unit_price)}
</td>
<td className="border border-black px-1 py-0.5">
<DateInput value={row.delivery_request_date}
onChange={(v) => updateRow(row.rowKey, { delivery_request_date: v })}
size="sm"
disabled={isReadOnly} />
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* TOTAL */}
<table className="w-full border-collapse mt-2">
<tbody>
<tr>
<td className="border border-black bg-[#d9e2f3] text-center text-[14px] font-bold py-1.5" style={{ width: "70%" }}>
TOTAL
</td>
<td className="border border-black text-right text-[14px] font-bold py-1.5 px-3 tabular-nums">
{fmt2(totalSupplyPrice)}
</td>
</tr>
</tbody>
</table>
<p className="text-[11px] italic mt-4">
Look forward to your soonest delivery with good condition.
</p>
{/* 서명 영역 */}
<div className="relative mt-8 min-h-[80px]">
<hr className="border-0 border-t border-black ml-auto" style={{ width: 250 }} />
<div className="text-right pr-[70px] text-[12px] mt-1">Signed by Dong-Heon Lee / President</div>
<div className="text-right pr-[70px] text-[12px] font-bold">RPS CO.,LTD</div>
<img src="/images/rps-stamp-seal.png" alt="Stamp"
className="absolute"
style={{ right: 0, bottom: 0, width: 65, height: 65, opacity: 0.85 }}
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }} />
</div>
</div>
</DialogContent>
<PurchaseOrderMailDialog
open={mailOpen}
onOpenChange={setMailOpen}
pomObjid={master.objid || null}
formType="english"
onRequestPdf={handleRequestPdf}
onSent={() => setMailOpen(false)}
/>
</Dialog>
);
}
interface FieldRowProps {
leftLabel: string;
leftCell: React.ReactNode;
rightLabel: string;
rightCell: React.ReactNode;
rightBg?: string;
}
function FieldRow({ leftLabel, leftCell, rightLabel, rightCell, rightBg }: FieldRowProps) {
return (
<tr>
<td className="border border-black bg-[#f6f6f6] font-semibold text-center w-[110px] px-2 py-1">{leftLabel || " "}</td>
<td className="border border-black px-2 py-1" style={{ width: "37%" }}>{leftCell}</td>
<td style={{ width: "1%", border: "none", padding: 0 }}></td>
<td className="border border-black bg-[#f6f6f6] font-semibold text-center w-[110px] px-2 py-1">{rightLabel}</td>
<td className="border border-black px-2 py-1" style={{ width: "37%", background: rightBg }}>{rightCell}</td>
</tr>
);
}
@@ -0,0 +1,57 @@
"use client";
// 구매관리 > 품의서관리 > 발주서생성 — 양식 선택 모달
// 운영판 wace 1:1 — 일반 / 외주가공 / 영문 / 취소
import React from "react";
import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
export type PurchaseOrderFormType = "general" | "outsourcing" | "english";
interface Props {
open: boolean;
onClose: () => void;
onSelect: (formType: PurchaseOrderFormType) => void;
}
export function PurchaseOrderFormTypeSelectDialog({ open, onClose, onSelect }: Props) {
const handlePick = (t: PurchaseOrderFormType) => {
onSelect(t);
onClose();
};
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent className="max-w-[520px] p-0 gap-0 bg-white">
<DialogTitle className="sr-only"> </DialogTitle>
<DialogDescription className="sr-only">, , </DialogDescription>
<div className="px-8 py-10 flex flex-col items-center gap-7">
<div className="text-[18px] font-semibold"> </div>
<div className="flex items-center gap-3">
<Button size="lg" className="h-11 px-7 text-[14px] font-semibold text-white"
style={{ background: "#1f7ad6" }}
onClick={() => handlePick("general")}>
</Button>
<Button size="lg" className="h-11 px-7 text-[14px] font-semibold text-white"
style={{ background: "#3aa455" }}
onClick={() => handlePick("outsourcing")}>
</Button>
<Button size="lg" className="h-11 px-7 text-[14px] font-semibold text-white"
style={{ background: "#7146b0" }}
onClick={() => handlePick("english")}>
</Button>
</div>
<Button size="lg" className="h-10 px-8 text-[14px] font-semibold text-white"
style={{ background: "#d64550" }}
onClick={onClose}>
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,615 @@
"use client";
// 구매관리 > 발주서관리 — general 양식 등록/수정 다이얼로그 (PDF 양식 1:1)
// wace 원본: purchaseOrder/purchaseOrderFormPopup_general.jsp (운영판 폼)
// - 좌 박스 5필드: 발주번호 / 발주일자 / 수신업체 / 합계금액(VAT별도) / 결제방식
// (납기일·납품장소는 운영판 주석 처리됨 — 그대로 미노출)
// - 우 박스: "담당자" 라벨 + 담당자1 + 담당자1 연락처/이메일 + 담당자2 + 담당자2 연락처/이메일
// + "㈜알피에스 대표이사 이 동 헌" + "대전광역시 유성구 국제과학10로8(둔곡동 402-4번지)"
// - 저장/닫기 버튼: 폼 박스 우측 상단
// - 그리드 컬럼: ☑ / No / 품명 / 규격 / 수량 / 단위 / 배송지 / 단가 / 공급가액 / 비고 / 입고요청일
// - 푸터: 총 공급 가액 (VAT별도) + 보안 문구 (빨간색)
//
// 저장: purchaseApi.saveOrderForm({ master, parts, deletedPartObjids })
// - SUPPLY_UNIT_PRICE = ORDER_QTY × PARTNER_PRICE (행 자동 계산)
// - TOTAL_SUPPLY_PRICE = Σ SUPPLY_UNIT_PRICE (마스터 자동 합산)
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Download, Mail } from "lucide-react";
import { toast } from "sonner";
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { DateInput } from "@/components/common/DateInput";
import { NumberInput } from "@/components/common/NumberInput";
import { purchaseApi, OptionItem } from "@/lib/api/purchase";
import { generatePurchaseOrderPdf } from "@/lib/utils/purchaseOrderPdf";
import { PurchaseOrderMailDialog } from "./PurchaseOrderMailDialog";
interface Props {
open: boolean;
onClose: () => void;
onSaved?: (result: { objid: string; purchase_order_no: string }) => void;
/** 수정 모드 시 POM OBJID — 미지정이고 proposalObjid 있으면 신규 (품의서 자동채움) */
pomObjid?: string;
/** 신규 등록 시 발주 원천 품의서 OBJID */
proposalObjid?: string;
}
interface PartRow {
rowKey: string;
objid: string;
part_objid: string;
part_no: string;
part_name: string;
spec: string;
order_qty: number | "";
qty: number | "";
unit: string;
part_delivery_place: string;
partner_price: number | "";
supply_unit_price: number;
remark: string;
delivery_request_date: string;
currency?: string;
_src?: string;
}
interface MasterState {
objid: string;
purchase_order_no: string;
purchase_date: string;
partner_objid: string;
payment_terms: string;
sales_mng_user_id: string;
manager_name: string;
manager_position: string;
manager_phone: string;
manager_email: string;
sales_mng_user_id2: string;
manager_name2: string;
manager_position2: string;
manager_phone2: string;
manager_email2: string;
title: string;
request_content: string;
sales_request_objid: string;
contract_mgmt_objid: string;
form_type: string;
status: string;
/** 결재 상태 — wace isModify 분기에 사용 ('결재중'/'결재완료' 시 읽기전용) */
appr_status: string;
}
const EMPTY_MASTER: MasterState = {
objid: "", purchase_order_no: "", purchase_date: "",
partner_objid: "", payment_terms: "",
sales_mng_user_id: "", manager_name: "", manager_position: "",
manager_phone: "", manager_email: "",
sales_mng_user_id2: "", manager_name2: "", manager_position2: "",
manager_phone2: "", manager_email2: "",
title: "", request_content: "",
sales_request_objid: "", contract_mgmt_objid: "",
form_type: "general", status: "create",
appr_status: "",
};
const UNIT_GROUP_ID = "0001399"; // wace unit_cd (0001400=EA)
const DELIVERY_PLACE_GROUP = "0001146"; // wace delivery_place_cd
const PAYMENT_TERMS_GROUP = "0001074"; // wace payment_terms_cd
let _rk = 0;
const nextKey = () => `r${++_rk}_${Date.now()}`;
const toNum = (v: any): number => {
if (v == null || v === "") return 0;
const n = Number(String(v).replace(/,/g, ""));
return Number.isFinite(n) ? n : 0;
};
const fmt = (n: number) => n.toLocaleString("ko-KR");
interface UserOptionExt extends OptionItem {
name?: string;
position?: string;
phone?: string;
email?: string;
}
export function PurchaseOrderGeneralFormDialog({
open, onClose, onSaved, pomObjid, proposalObjid,
}: Props) {
const isEdit = !!pomObjid;
const [master, setMaster] = useState<MasterState>(EMPTY_MASTER);
const [parts, setParts] = useState<PartRow[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [vendorOpts, setVendorOpts] = useState<SmartSelectOption[]>([]);
const [userOpts, setUserOpts] = useState<UserOptionExt[]>([]);
const userOptsForSelect: SmartSelectOption[] = useMemo(
() => userOpts.map((u) => ({ code: u.code, label: u.label })),
[userOpts],
);
useEffect(() => {
if (!open) return;
setMaster(EMPTY_MASTER);
setParts([]);
(async () => {
try {
const [vs, us] = await Promise.all([
purchaseApi.listVendors(),
purchaseApi.listUsers(),
]);
setVendorOpts(vs.map((v) => ({ code: v.code, label: v.label })));
setUserOpts(us as UserOptionExt[]);
} catch {/* skip */}
})();
setLoading(true);
(async () => {
try {
if (isEdit && pomObjid) {
const r = await purchaseApi.getOrderForm(pomObjid);
applyServerData(r.master ?? {}, r.parts ?? []);
} else if (proposalObjid) {
const r = await purchaseApi.initOrderForm(proposalObjid);
applyServerData(r.master ?? {}, r.parts ?? []);
}
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "초기 로드 실패");
} finally {
setLoading(false);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, isEdit, pomObjid, proposalObjid]);
const applyServerData = (m: Record<string, any>, ps: Record<string, any>[]) => {
setMaster({
objid: String(m.objid ?? ""),
purchase_order_no: String(m.purchase_order_no ?? ""),
purchase_date: String(m.purchase_date ?? ""),
partner_objid: String(m.partner_objid ?? ""),
payment_terms: String(m.payment_terms ?? ""),
sales_mng_user_id: String(m.sales_mng_user_id ?? ""),
manager_name: String(m.manager_name ?? ""),
manager_position: String(m.manager_position ?? ""),
manager_phone: String(m.manager_phone ?? ""),
manager_email: String(m.manager_email ?? ""),
sales_mng_user_id2: String(m.sales_mng_user_id2 ?? ""),
manager_name2: String(m.manager_name2 ?? ""),
manager_position2: String(m.manager_position2 ?? ""),
manager_phone2: String(m.manager_phone2 ?? ""),
manager_email2: String(m.manager_email2 ?? ""),
title: String(m.title ?? ""),
request_content: String(m.request_content ?? ""),
sales_request_objid: String(m.sales_request_objid ?? m.proposal_objid ?? ""),
contract_mgmt_objid: String(m.contract_mgmt_objid ?? ""),
form_type: String(m.form_type ?? "general"),
status: String(m.status ?? "create"),
appr_status: String(m.appr_status ?? ""),
});
setParts(ps.map((p) => ({
rowKey: nextKey(),
objid: String(p.objid ?? ""),
part_objid: String(p.part_objid ?? ""),
part_no: String(p.part_no ?? ""),
part_name: String(p.part_name ?? ""),
spec: String(p.spec ?? ""),
order_qty: p.order_qty === "" || p.order_qty == null ? "" : Number(p.order_qty),
qty: p.qty === "" || p.qty == null
? (p.order_qty === "" || p.order_qty == null ? "" : Number(p.order_qty))
: Number(p.qty),
unit: String(p.unit || "0001400"),
part_delivery_place: String(p.part_delivery_place || "RPS"),
partner_price: p.partner_price === "" || p.partner_price == null ? "" : Number(p.partner_price),
supply_unit_price: toNum(p.supply_unit_price ?? toNum(p.order_qty) * toNum(p.partner_price)),
remark: String(p.remark ?? ""),
delivery_request_date: String(p.delivery_request_date ?? ""),
currency: String(p.currency ?? ""),
_src: p._src,
})));
};
const totalSupplyPrice = useMemo(
() => parts.reduce((sum, p) => sum + toNum(p.supply_unit_price), 0),
[parts],
);
// 읽기전용 모드 — wace _general.jsp isModify 분기 1:1
// APPR_STATUS='결재중' / '결재완료' / STATUS='cancel' 일 때 입력 잠금
const isReadOnly = useMemo(() => {
const a = master.appr_status;
return a === "결재중" || a === "결재완료" || master.status === "cancel";
}, [master.appr_status, master.status]);
const pdfContainerRef = useRef<HTMLDivElement>(null);
const [mailOpen, setMailOpen] = useState(false);
const [generatingPdf, setGeneratingPdf] = useState(false);
/** PDF 다운로드 — html2canvas + jsPDF 로 발주서 양식 컨테이너 캡처 */
const handleDownload = async () => {
if (!pdfContainerRef.current) return;
setGeneratingPdf(true);
try {
const filename = `${master.purchase_order_no || "purchase_order"}.pdf`;
await generatePurchaseOrderPdf(pdfContainerRef.current, { download: true, filename });
} catch (e: any) {
toast.error("PDF 생성 실패: " + (e?.message ?? ""));
} finally {
setGeneratingPdf(false);
}
};
/** 메일 발송 — 다이얼로그가 onRequestPdf 콜백으로 PDF 생성 요청 */
const handleRequestPdf = async (): Promise<string> => {
if (!pdfContainerRef.current) throw new Error("발주서 컨테이너를 찾을 수 없습니다");
return generatePurchaseOrderPdf(pdfContainerRef.current);
};
// 담당자 select 변경 시 hidden 필드(name/position/phone/email) 자동 채움
const onManagerChange = (slot: 1 | 2, userId: string) => {
const u = userOpts.find((o) => o.code === userId);
setMaster((prev) => ({
...prev,
[`sales_mng_user_id${slot === 2 ? "2" : ""}`]: userId,
[`manager_name${slot === 2 ? "2" : ""}`]: u?.name ?? u?.label ?? "",
[`manager_position${slot === 2 ? "2" : ""}`]: u?.position ?? "",
[`manager_phone${slot === 2 ? "2" : ""}`]: u?.phone ?? "",
[`manager_email${slot === 2 ? "2" : ""}`]: u?.email ?? "",
}));
};
const updateRow = (rowKey: string, patch: Partial<PartRow>) => {
setParts((prev) => prev.map((r) => {
if (r.rowKey !== rowKey) return r;
const merged: PartRow = { ...r, ...patch };
const q = toNum(merged.order_qty);
const u = toNum(merged.partner_price);
merged.qty = merged.order_qty;
merged.supply_unit_price = q * u;
return merged;
}));
};
const handleSave = async () => {
if (!master.partner_objid) { toast.warning("수신업체를 선택하세요"); return; }
if (parts.length === 0) { toast.warning("발주 품목이 없습니다"); return; }
setSaving(true);
try {
const payload = {
master: {
...master,
total_supply_price: String(totalSupplyPrice),
total_supply_unit_price: String(totalSupplyPrice),
total_price: String(totalSupplyPrice),
},
parts: parts.map((p) => ({
objid: p.objid,
part_objid: p.part_objid,
part_no: p.part_no,
part_name: p.part_name,
spec: p.spec,
order_qty: toNum(p.order_qty),
qty: toNum(p.qty || p.order_qty),
unit: p.unit,
part_delivery_place: p.part_delivery_place,
partner_price: toNum(p.partner_price),
supply_unit_price: toNum(p.supply_unit_price),
remark: p.remark,
delivery_request_date: p.delivery_request_date,
currency: p.currency,
})),
deletedPartObjids: [],
};
const res = await purchaseApi.saveOrderForm(payload);
toast.success(`저장 완료 (${res.purchase_order_no})`);
onSaved?.(res);
onClose();
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패");
} finally {
setSaving(false);
}
};
// ───────── 운영판 PDF 양식 1:1 (테두리 박스 + 좌/우 분할) ─────────
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent className="max-w-[1280px] w-[96vw] max-h-[94vh] overflow-hidden flex flex-col p-0 gap-0 bg-white">
{/* Radix UI 접근성 — 시각 노출 없이 타이틀/설명 제공 */}
<DialogTitle className="sr-only"> ()</DialogTitle>
<DialogDescription className="sr-only">wace PDF </DialogDescription>
<div ref={pdfContainerRef} className="flex-1 overflow-y-auto p-4 text-[12px]" style={{ fontFamily: "'Malgun Gothic', '맑은 고딕', sans-serif" }}>
{/* ── 상단 메인 박스 (PDF 양식) ── */}
<div className="border-2 border-black">
{/* 1행: 로고 + 타이틀 */}
<div className="flex items-center border-b-2 border-black">
<div className="w-[140px] text-center py-2">
{/* wace `<%=request.getContextPath()%>/images/logo.png` 1:1 */}
<img src="/images/rps-logo-on.png" alt="RPS Logo"
style={{ maxWidth: "100px", height: "auto" }} />
</div>
<div className="flex-1 text-center py-3 text-[28px] font-bold" style={{ letterSpacing: "20px" }}>
</div>
<div className="w-[140px]"></div>
</div>
{/* 2행: 좌(기본정보) + 우(담당자) */}
<div className="flex">
{/* 좌: 기본정보 5필드 */}
<div className="w-[42%] p-3 border-r border-black">
<table className="w-full border-collapse">
<tbody>
<tr>
<td className="py-1.5 font-semibold whitespace-nowrap pr-3 w-[160px]">1. :</td>
<td className="py-1.5 font-bold">
<span>{master.purchase_order_no || <span className="text-gray-400"></span>}</span>
</td>
</tr>
<tr>
<td className="py-1.5 font-semibold whitespace-nowrap pr-3">2. :</td>
<td className="py-1.5">
<div className="w-[210px]">
<DateInput value={master.purchase_date}
onChange={(v) => setMaster({ ...master, purchase_date: v })}
disabled={isReadOnly} />
</div>
</td>
</tr>
<tr>
<td className="py-1.5 font-semibold whitespace-nowrap pr-3">3. :</td>
<td className="py-1.5">
<div className="w-[260px]">
<SmartSelect options={vendorOpts}
value={master.partner_objid}
onValueChange={(v) => setMaster({ ...master, partner_objid: v })}
disabled={isReadOnly} />
</div>
</td>
</tr>
<tr>
<td className="py-1.5 font-semibold whitespace-nowrap pr-3">4. (VAT별도) :</td>
<td className="py-1.5 font-bold">
{fmt(totalSupplyPrice)} <span className="font-normal"></span>
</td>
</tr>
<tr>
<td className="py-1.5 font-semibold whitespace-nowrap pr-3">5. :</td>
<td className="py-1.5">
<div className="w-[260px]">
<CommCodeSelect groupId={PAYMENT_TERMS_GROUP}
value={master.payment_terms}
onValueChange={(v) => setMaster({ ...master, payment_terms: v })}
withAll={false}
disabled={isReadOnly} />
</div>
</td>
</tr>
</tbody>
</table>
</div>
{/* 우: 담당자 박스 + 회사정보 */}
<div className="flex-1 p-3">
<table className="w-full border-collapse border border-black text-[11px]">
<tbody>
<tr>
<td rowSpan={4} className="w-[60px] text-center font-bold border border-black align-middle bg-gray-100"
style={{ letterSpacing: "3px" }}>
<br/><br/>
</td>
<td className="border border-black px-2 py-1 text-center">
<div className="w-full max-w-[220px] mx-auto">
<SmartSelect options={userOptsForSelect}
value={master.sales_mng_user_id}
onValueChange={(v) => onManagerChange(1, v)}
disabled={isReadOnly} />
</div>
</td>
</tr>
<tr>
<td className="border border-black px-2 py-1 text-center relative">
<span>(<span>{master.manager_phone || "-"}</span> / <span>{master.manager_email || "-"}</span>)</span>
{/* 직인 — wace `images/stamp_rps.png` (운영 파일은 stamp_seal.png) onerror hide */}
<img src="/images/rps-stamp-seal.png" alt="직인"
className="absolute"
style={{ right: 5, top: -10, width: 45, height: 45, opacity: 0.9 }}
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }} />
</td>
</tr>
<tr>
<td className="border border-black px-2 py-1 text-center">
<div className="w-full max-w-[220px] mx-auto">
<SmartSelect options={userOptsForSelect}
value={master.sales_mng_user_id2}
onValueChange={(v) => onManagerChange(2, v)}
disabled={isReadOnly} />
</div>
</td>
</tr>
<tr>
<td className="border border-black px-2 py-1 text-center">
<span>(<span>{master.manager_phone2 || "-"}</span> / <span>{master.manager_email2 || "-"}</span>)</span>
</td>
</tr>
<tr>
<td colSpan={2} className="border border-black border-b-0 px-2 py-1.5 text-center text-[14px] font-bold">
</td>
</tr>
<tr>
<td colSpan={2} className="border border-black border-t-0 px-2 py-1.5 text-center text-[11px] font-bold">
108( 402-4)
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
{/* 읽기전용 안내 — wace isModify=false 분기 */}
{isReadOnly && (
<div className="mt-2 text-[11px] text-amber-700 bg-amber-50 border border-amber-300 px-3 py-1.5 rounded">
/ .
{master.appr_status ? ` (결재상태: ${master.appr_status})` : ""}
{master.status === "cancel" ? " (취소됨)" : ""}
</div>
)}
{/* 버튼 영역 (그리드 위, 우측 정렬) — wace _general.jsp 941-948 1:1 */}
<div className="flex justify-end gap-2 my-3">
{!isReadOnly && (
<Button size="sm" className="h-8 px-5 text-[13px]"
style={{ background: "#dfeffc", color: "#000" }}
onClick={handleSave} disabled={saving || loading}>
{saving ? "저장 중..." : "저장"}
</Button>
)}
{isReadOnly && master.objid && (
<>
<Button size="sm" variant="outline" className="h-8 gap-1 px-3 text-xs"
onClick={() => setMailOpen(true)}>
<Mail className="h-3.5 w-3.5" />
</Button>
<Button size="sm" className="h-8 gap-1 px-3 text-[13px]"
style={{ background: "#dfeffc", color: "#000" }}
onClick={handleDownload} disabled={generatingPdf}>
<Download className="h-3.5 w-3.5" />
{generatingPdf ? "생성 중..." : "PDF 다운로드"}
</Button>
</>
)}
<Button size="sm" variant="outline" className="h-8 px-5 text-[13px]"
style={{ background: "#dfeffc" }}
onClick={onClose} disabled={saving}>
</Button>
</div>
{/* 그리드 */}
<div className="border border-black overflow-x-auto">
<table className="w-full text-[11px] border-collapse">
<thead>
<tr className="bg-[#f0f4fa]">
<th className="w-10 border border-black px-1 py-1.5">No</th>
<th className="min-w-[140px] border border-black px-1 py-1.5"></th>
<th className="min-w-[120px] border border-black px-1 py-1.5"></th>
<th className="w-[70px] border border-black px-1 py-1.5"></th>
<th className="w-[80px] border border-black px-1 py-1.5"></th>
<th className="w-[110px] border border-black px-1 py-1.5"></th>
<th className="w-[100px] border border-black px-1 py-1.5"></th>
<th className="w-[110px] border border-black px-1 py-1.5"></th>
<th className="min-w-[160px] border border-black px-1 py-1.5"></th>
<th className="w-[110px] border border-black px-1 py-1.5"></th>
</tr>
</thead>
<tbody>
{parts.length === 0 ? (
<tr>
<td colSpan={10} className="text-center py-8 border border-black text-gray-500">
</td>
</tr>
) : parts.map((row, i) => (
<tr key={row.rowKey} className="hover:bg-blue-50/30">
<td className="border border-black px-1 py-0.5 text-center text-gray-600">{i + 1}</td>
<td className="border border-black px-1 py-0.5">
<Input className="h-7 text-[11px] px-1.5 border-0 focus-visible:ring-1"
value={row.part_name}
onChange={(e) => updateRow(row.rowKey, { part_name: e.target.value })}
disabled={isReadOnly} />
</td>
<td className="border border-black px-1 py-0.5">
<Input className="h-7 text-[11px] px-1.5 border-0 focus-visible:ring-1"
value={row.spec}
onChange={(e) => updateRow(row.rowKey, { spec: e.target.value })}
disabled={isReadOnly} />
</td>
<td className="border border-black px-1 py-0.5">
<NumberInput value={row.order_qty} decimals={0}
onChange={(v) => updateRow(row.rowKey, { order_qty: v })}
className="h-7 text-[11px]"
disabled={isReadOnly} />
</td>
<td className="border border-black px-1 py-0.5">
<CommCodeSelect groupId={UNIT_GROUP_ID}
value={row.unit}
onValueChange={(v) => updateRow(row.rowKey, { unit: v })}
withAll={false} className="h-7"
disabled={isReadOnly} />
</td>
<td className="border border-black px-1 py-0.5">
<CommCodeSelect groupId={DELIVERY_PLACE_GROUP}
value={row.part_delivery_place}
onValueChange={(v) => updateRow(row.rowKey, { part_delivery_place: v })}
withAll={false} className="h-7"
disabled={isReadOnly} />
</td>
<td className="border border-black px-1 py-0.5">
<NumberInput value={row.partner_price} decimals={0}
onChange={(v) => updateRow(row.rowKey, { partner_price: v })}
className="h-7 text-[11px]"
disabled={isReadOnly} />
</td>
<td className="border border-black px-1 py-0.5 text-right tabular-nums pr-2">
{fmt(row.supply_unit_price)}
</td>
<td className="border border-black px-1 py-0.5">
<Input className="h-7 text-[11px] px-1.5 border-0 focus-visible:ring-1"
value={row.remark}
onChange={(e) => updateRow(row.rowKey, { remark: e.target.value })}
disabled={isReadOnly} />
</td>
<td className="border border-black px-1 py-0.5">
<DateInput value={row.delivery_request_date}
onChange={(v) => updateRow(row.rowKey, { delivery_request_date: v })}
size="sm"
disabled={isReadOnly} />
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 합계 영역 */}
<div className="flex justify-end mt-3">
<table className="border-collapse">
<tbody>
<tr>
<td className="bg-[#e8f0e1] border border-black px-6 py-2 text-[13px] font-bold whitespace-nowrap" style={{ letterSpacing: "2px" }}>
(VAT별도)
</td>
<td className="border border-black px-4 py-2 text-right font-bold text-[16px] min-w-[200px] tabular-nums">
{fmt(totalSupplyPrice)}
</td>
</tr>
</tbody>
</table>
</div>
{/* 보안 문구 */}
<div className="text-right text-[11px] text-red-600 mt-3">
(CONFIDENTIAL) : (APPROVAL) .
</div>
</div>
</DialogContent>
<PurchaseOrderMailDialog
open={mailOpen}
onOpenChange={setMailOpen}
pomObjid={master.objid || null}
formType="general"
onRequestPdf={handleRequestPdf}
onSent={() => setMailOpen(false)}
/>
</Dialog>
);
}
@@ -0,0 +1,305 @@
"use client";
// 구매관리 > 발주서관리 — 메일 발송 다이얼로그
// EstimateMailDialog 패턴 1:1 (영업관리 견적관리) 발주서용 단순화:
// - 공급업체(client_mng) 단일 email 자동 채움
// - 단일 PDF 첨부 (호출자가 base64 미리 생성해서 props 로 전달)
// - 발송 성공 시 purchase_order_master.mail_send_yn='Y'/mail_send_date=NOW()
import { useCallback, useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Loader2, Send } from "lucide-react";
import { toast } from "sonner";
import { purchaseApi, PartnerManager } from "@/lib/api/purchase";
export interface PurchaseOrderMailDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** purchase_order_master.objid */
pomObjid: string | null;
/** form_type ('general' | 'outsourcing' | 'english') — 본문 템플릿 분기 */
formType?: string;
/** 호출자가 미리 생성한 발주서 PDF base64 (data URL 또는 raw) — 빈값이면 발송 단계에서 onRequestPdf 콜백 사용 */
pdfBase64?: string;
/** pdfBase64 가 없을 때 다이얼로그가 호출하는 PDF 생성기 (다이얼로그 외부 컴포넌트에서 DOM 캡처) */
onRequestPdf?: () => Promise<string>;
onSent?: () => void;
}
export function PurchaseOrderMailDialog({
open,
onOpenChange,
pomObjid,
formType = "general",
pdfBase64,
onRequestPdf,
onSent,
}: PurchaseOrderMailDialogProps) {
const isEnglish = formType === "english";
const [loading, setLoading] = useState(false);
const [sending, setSending] = useState(false);
const [progress, setProgress] = useState("");
const [managers, setManagers] = useState<PartnerManager[]>([]);
const [checkedEmails, setCheckedEmails] = useState<Record<string, boolean>>({});
const [form, setForm] = useState({
toEmails: "",
ccEmails: "",
subject: "",
contents: "",
});
useEffect(() => {
if (!open || !pomObjid) return;
let cancelled = false;
(async () => {
setLoading(true);
setManagers([]);
setCheckedEmails({});
setForm({ toEmails: "", ccEmails: "", subject: "", contents: "" });
try {
const info = await purchaseApi.getOrderMailInfo(pomObjid);
if (cancelled || !info) return;
const partnerName = info.partner_name ?? "";
const orderNo = info.purchase_order_no ?? "";
const template = isEnglish
? `Dear ${partnerName},\n\n` +
`Please find attached our Purchase Order (Ref. No: ${orderNo}).\n\n` +
`Should you have any questions, please feel free to contact us.\n\n` +
`Best regards,\nRPS CO., LTD.\n`
: `안녕하세요.\n\n` +
`${partnerName} 귀하께 발주서를 송부드립니다.\n\n` +
`발주번호: ${orderNo}\n\n` +
`첨부된 발주서를 확인하신 후 문의사항이 있으시면 연락 주시기 바랍니다.\n\n` +
`감사합니다.\n`;
setForm({
toEmails: info.partner_email ?? "",
ccEmails: info.writer_email ?? "",
subject: isEnglish
? `[RPS] Purchase Order ${orderNo}`
: `[${partnerName}] ${orderNo} 발주서`,
contents: template,
});
if (info.partner_objid) {
try {
const mgrs = await purchaseApi.listPartnerManagers(info.partner_objid);
if (!cancelled) setManagers(mgrs);
} catch {/* skip */}
}
} catch (e: any) {
toast.error("발주서 정보를 불러올 수 없습니다: " + (e?.message ?? ""));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [open, pomObjid, isEnglish]);
function toggleManager(email: string, checked: boolean) {
setCheckedEmails((prev) => ({ ...prev, [email]: checked }));
setForm((prev) => {
const current = prev.toEmails.split(/[,;]/).map((e) => e.trim()).filter(Boolean);
if (checked && !current.includes(email)) {
return { ...prev, toEmails: current.concat(email).join(", ") };
}
if (!checked && current.includes(email)) {
return { ...prev, toEmails: current.filter((e) => e !== email).join(", ") };
}
return prev;
});
}
const handleSend = useCallback(async () => {
if (!pomObjid) return;
const toEmails = form.toEmails.trim();
const subject = form.subject.trim();
const contents = form.contents.trim();
if (toEmails === "") { toast.error(isEnglish ? "Recipient required" : "수신인을 입력해주세요"); return; }
if (subject === "") { toast.error(isEnglish ? "Subject required" : "제목을 입력해주세요"); return; }
if (contents === "") { toast.error(isEnglish ? "Body required" : "내용을 입력해주세요"); return; }
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const emails = toEmails.split(/[,;]/).map((e) => e.trim()).filter(Boolean);
for (const e of emails) {
if (!emailPattern.test(e)) {
toast.error(`올바른 이메일 형식이 아닙니다: ${e}`);
return;
}
}
if (!confirm(isEnglish ? "Send purchase order?" : "발주서를 발송하시겠습니까?")) return;
setSending(true);
setProgress(isEnglish ? "Preparing..." : "발송 준비 중...");
try {
let pdf = pdfBase64;
if (!pdf && onRequestPdf) {
setProgress(isEnglish ? "Generating PDF..." : "PDF 생성 중... (최대 30초)");
pdf = await onRequestPdf();
}
if (!pdf) {
toast.error(isEnglish ? "PDF not available" : "PDF 가 준비되지 않았습니다");
setSending(false);
setProgress("");
return;
}
setProgress(isEnglish ? "Sending mail..." : "메일 발송 중...");
const result = await purchaseApi.sendOrderMail({
pomObjid,
toEmails,
ccEmails: form.ccEmails.trim() || undefined,
subject,
contents,
pdfBase64: pdf,
});
if (!result.success) {
toast.error((isEnglish ? "Send failed: " : "발송 실패: ") + result.message);
} else {
toast.success(result.message || (isEnglish ? "Sent" : "발주서가 발송되었습니다"));
onOpenChange(false);
onSent?.();
}
} catch (e: any) {
toast.error((isEnglish ? "Send failed: " : "발송 실패: ") + (e?.response?.data?.message ?? e?.message ?? ""));
} finally {
setSending(false);
setProgress("");
}
}, [pomObjid, form, isEnglish, pdfBase64, onRequestPdf, onOpenChange, onSent]);
return (
<Dialog open={open} onOpenChange={(o) => { if (!sending) onOpenChange(o); }}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{isEnglish ? "Send Purchase Order Email" : "발주서 메일 발송"}</DialogTitle>
<DialogDescription>
{isEnglish
? "Attachment: Purchase Order PDF (1 page)"
: "PDF 첨부: 발주서 양식 1장"}
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 mr-2 animate-spin" />
{isEnglish ? "Loading..." : "정보를 불러오는 중..."}
</div>
) : (
<div className="space-y-3">
<div>
<Label className="text-xs">{isEnglish ? "Supplier contact" : "공급업체 담당자"}</Label>
<div className="border rounded-md p-2 bg-muted/30 max-h-[120px] overflow-y-auto text-sm">
{managers.length === 0 ? (
<div className="text-muted-foreground text-center py-2">
{isEnglish ? "No contacts. Enter recipient directly." : "등록된 담당자가 없습니다. 수신인을 직접 입력해주세요."}
</div>
) : (
managers.map((m, i) => {
const email = m.email ?? "";
const id = `pom_manager_${i}_${email}`;
return (
<label key={id} htmlFor={id} className="flex items-center gap-2 py-1 cursor-pointer">
<input
type="checkbox"
id={id}
checked={!!checkedEmails[email]}
disabled={email === ""}
onChange={(e) => toggleManager(email, e.target.checked)}
/>
<span>
{m.name}
{email && <span className="text-muted-foreground"> ({email})</span>}
</span>
</label>
);
})
)}
</div>
</div>
<div>
<Label className="text-xs">
{isEnglish ? "To" : "수신인"} <span className="text-red-500">*</span>
</Label>
<Input
value={form.toEmails}
onChange={(e) => setForm({ ...form, toEmails: e.target.value })}
placeholder="email1@example.com, email2@example.com"
/>
<p className="text-[11px] text-muted-foreground mt-1">
{isEnglish ? "Multiple addresses separated by , or ;" : "여러 개는 쉼표(,) 또는 세미콜론(;)으로 구분"}
</p>
</div>
<div>
<Label className="text-xs">{isEnglish ? "CC" : "참조"}</Label>
<Input
value={form.ccEmails}
onChange={(e) => setForm({ ...form, ccEmails: e.target.value })}
placeholder={isEnglish ? "Optional" : "참조 이메일 주소 (선택사항)"}
/>
<p className="text-[11px] text-muted-foreground mt-1">
{isEnglish ? "Writer email is auto-added as CC." : "작성자 이메일이 자동으로 참조에 추가됩니다."}
</p>
</div>
<div>
<Label className="text-xs">
{isEnglish ? "Subject" : "제목"} <span className="text-red-500">*</span>
</Label>
<Input
value={form.subject}
onChange={(e) => setForm({ ...form, subject: e.target.value })}
/>
</div>
<div>
<Label className="text-xs">
{isEnglish ? "Body" : "내용"} <span className="text-red-500">*</span>
</Label>
<Textarea
rows={8}
value={form.contents}
onChange={(e) => setForm({ ...form, contents: e.target.value })}
/>
</div>
{sending && progress && (
<div className="text-sm text-blue-600 dark:text-blue-400 flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
{progress}
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={sending}>
{isEnglish ? "Cancel" : "취소"}
</Button>
<Button onClick={handleSend} disabled={loading || sending}>
{sending ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : <Send className="w-4 h-4 mr-1" />}
{isEnglish ? "Send" : "발송"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,566 @@
"use client";
// 구매관리 > 발주서관리 — outsourcing 양식 등록/수정 다이얼로그
// wace 원본: purchaseOrder/purchaseOrderFormPopup_outsourcing.jsp
// - 타이틀: "외주가공 발주서"
// - 좌 박스 4필드: 발주번호 / 발주일자 / 수신업체 / 합계금액(VAT별도) — 결제방식 X
// - 우 박스: 일반발주서와 동일 (담당자1/2 + 회사정보 2줄 + 직인)
// - 그리드 컬럼: ☑ / No / 업체명 / 제품명 / 부품명 / 수량 / 단위 / 단가 / 합계 / 작업지시번호 / 부품품번 / 입고요청일
// - 푸터: 총공급가액(VAT별도) + 한글 보안문구
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Download, Mail } from "lucide-react";
import { toast } from "sonner";
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { DateInput } from "@/components/common/DateInput";
import { NumberInput } from "@/components/common/NumberInput";
import { purchaseApi, OptionItem } from "@/lib/api/purchase";
import { generatePurchaseOrderPdf } from "@/lib/utils/purchaseOrderPdf";
import { PurchaseOrderMailDialog } from "./PurchaseOrderMailDialog";
interface Props {
open: boolean;
onClose: () => void;
onSaved?: (result: { objid: string; purchase_order_no: string }) => void;
pomObjid?: string;
proposalObjid?: string;
}
interface PartRow {
rowKey: string;
objid: string;
part_objid: string;
part_no: string;
product_name: string;
part_name: string;
spec: string;
order_qty: number | "";
qty: number | "";
unit: string;
partner_price: number | "";
supply_unit_price: number;
work_order_no: string;
delivery_request_date: string;
_src?: string;
}
interface MasterState {
objid: string;
purchase_order_no: string;
purchase_date: string;
partner_objid: string;
sales_mng_user_id: string;
manager_name: string;
manager_position: string;
manager_phone: string;
manager_email: string;
sales_mng_user_id2: string;
manager_name2: string;
manager_position2: string;
manager_phone2: string;
manager_email2: string;
title: string;
request_content: string;
sales_request_objid: string;
contract_mgmt_objid: string;
form_type: string;
status: string;
appr_status: string;
}
const EMPTY_MASTER: MasterState = {
objid: "", purchase_order_no: "", purchase_date: "",
partner_objid: "",
sales_mng_user_id: "", manager_name: "", manager_position: "",
manager_phone: "", manager_email: "",
sales_mng_user_id2: "", manager_name2: "", manager_position2: "",
manager_phone2: "", manager_email2: "",
title: "", request_content: "",
sales_request_objid: "", contract_mgmt_objid: "",
form_type: "outsourcing", status: "create",
appr_status: "",
};
const UNIT_GROUP_ID = "0001399";
let _rk = 0;
const nextKey = () => `r${++_rk}_${Date.now()}`;
const toNum = (v: any): number => {
if (v == null || v === "") return 0;
const n = Number(String(v).replace(/,/g, ""));
return Number.isFinite(n) ? n : 0;
};
const fmt = (n: number) => n.toLocaleString("ko-KR");
interface UserOptionExt extends OptionItem {
name?: string;
position?: string;
phone?: string;
email?: string;
}
export function PurchaseOrderOutsourcingFormDialog({
open, onClose, onSaved, pomObjid, proposalObjid,
}: Props) {
const isEdit = !!pomObjid;
const [master, setMaster] = useState<MasterState>(EMPTY_MASTER);
const [parts, setParts] = useState<PartRow[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [vendorOpts, setVendorOpts] = useState<SmartSelectOption[]>([]);
const [userOpts, setUserOpts] = useState<UserOptionExt[]>([]);
const userOptsForSelect: SmartSelectOption[] = useMemo(
() => userOpts.map((u) => ({ code: u.code, label: u.label })),
[userOpts],
);
const partnerName = useMemo(() => {
const v = vendorOpts.find((o) => o.code === master.partner_objid);
return v?.label ?? "";
}, [vendorOpts, master.partner_objid]);
useEffect(() => {
if (!open) return;
setMaster({ ...EMPTY_MASTER });
setParts([]);
(async () => {
try {
const [vs, us] = await Promise.all([
purchaseApi.listVendors(),
purchaseApi.listUsers(),
]);
setVendorOpts(vs.map((v) => ({ code: v.code, label: v.label })));
setUserOpts(us as UserOptionExt[]);
} catch {/* skip */}
})();
setLoading(true);
(async () => {
try {
if (isEdit && pomObjid) {
const r = await purchaseApi.getOrderForm(pomObjid);
applyServerData(r.master ?? {}, r.parts ?? []);
} else if (proposalObjid) {
const r = await purchaseApi.initOrderForm(proposalObjid);
applyServerData(r.master ?? {}, r.parts ?? []);
}
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "초기 로드 실패");
} finally {
setLoading(false);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, isEdit, pomObjid, proposalObjid]);
const applyServerData = (m: Record<string, any>, ps: Record<string, any>[]) => {
setMaster({
objid: String(m.objid ?? ""),
purchase_order_no: String(m.purchase_order_no ?? ""),
purchase_date: String(m.purchase_date ?? ""),
partner_objid: String(m.partner_objid ?? ""),
sales_mng_user_id: String(m.sales_mng_user_id ?? ""),
manager_name: String(m.manager_name ?? ""),
manager_position: String(m.manager_position ?? ""),
manager_phone: String(m.manager_phone ?? ""),
manager_email: String(m.manager_email ?? ""),
sales_mng_user_id2: String(m.sales_mng_user_id2 ?? ""),
manager_name2: String(m.manager_name2 ?? ""),
manager_position2: String(m.manager_position2 ?? ""),
manager_phone2: String(m.manager_phone2 ?? ""),
manager_email2: String(m.manager_email2 ?? ""),
title: String(m.title ?? ""),
request_content: String(m.request_content ?? ""),
sales_request_objid: String(m.sales_request_objid ?? m.proposal_objid ?? ""),
contract_mgmt_objid: String(m.contract_mgmt_objid ?? ""),
form_type: "outsourcing",
status: String(m.status ?? "create"),
appr_status: String(m.appr_status ?? ""),
});
setParts(ps.map((p) => ({
rowKey: nextKey(),
objid: String(p.objid ?? ""),
part_objid: String(p.part_objid ?? ""),
part_no: String(p.part_no ?? ""),
product_name: String(p.product_name ?? p.part_name ?? ""),
part_name: String(p.part_name ?? ""),
spec: String(p.spec ?? ""),
order_qty: p.order_qty === "" || p.order_qty == null ? "" : Number(p.order_qty),
qty: p.qty === "" || p.qty == null
? (p.order_qty === "" || p.order_qty == null ? "" : Number(p.order_qty))
: Number(p.qty),
unit: String(p.unit || "0001400"),
partner_price: p.partner_price === "" || p.partner_price == null ? "" : Number(p.partner_price),
supply_unit_price: toNum(p.supply_unit_price ?? toNum(p.order_qty) * toNum(p.partner_price)),
work_order_no: String(p.work_order_no ?? ""),
delivery_request_date: String(p.delivery_request_date ?? ""),
_src: p._src,
})));
};
const totalSupplyPrice = useMemo(
() => parts.reduce((sum, p) => sum + toNum(p.supply_unit_price), 0),
[parts],
);
const isReadOnly = useMemo(() => {
const a = master.appr_status;
return a === "결재중" || a === "결재완료" || master.status === "cancel";
}, [master.appr_status, master.status]);
const pdfContainerRef = useRef<HTMLDivElement>(null);
const [mailOpen, setMailOpen] = useState(false);
const [generatingPdf, setGeneratingPdf] = useState(false);
const handleDownload = async () => {
if (!pdfContainerRef.current) return;
setGeneratingPdf(true);
try {
const filename = `${master.purchase_order_no || "purchase_order_outsourcing"}.pdf`;
await generatePurchaseOrderPdf(pdfContainerRef.current, { download: true, filename });
} catch (e: any) {
toast.error("PDF 생성 실패: " + (e?.message ?? ""));
} finally {
setGeneratingPdf(false);
}
};
const handleRequestPdf = async (): Promise<string> => {
if (!pdfContainerRef.current) throw new Error("발주서 컨테이너를 찾을 수 없습니다");
return generatePurchaseOrderPdf(pdfContainerRef.current);
};
const onManagerChange = (slot: 1 | 2, userId: string) => {
const u = userOpts.find((o) => o.code === userId);
setMaster((prev) => ({
...prev,
[`sales_mng_user_id${slot === 2 ? "2" : ""}`]: userId,
[`manager_name${slot === 2 ? "2" : ""}`]: u?.name ?? u?.label ?? "",
[`manager_position${slot === 2 ? "2" : ""}`]: u?.position ?? "",
[`manager_phone${slot === 2 ? "2" : ""}`]: u?.phone ?? "",
[`manager_email${slot === 2 ? "2" : ""}`]: u?.email ?? "",
}));
};
const updateRow = (rowKey: string, patch: Partial<PartRow>) => {
setParts((prev) => prev.map((r) => {
if (r.rowKey !== rowKey) return r;
const merged: PartRow = { ...r, ...patch };
const q = toNum(merged.order_qty);
const u = toNum(merged.partner_price);
merged.qty = merged.order_qty;
merged.supply_unit_price = q * u;
return merged;
}));
};
const handleSave = async () => {
if (!master.partner_objid) { toast.warning("수신업체를 선택하세요"); return; }
if (parts.length === 0) { toast.warning("발주 품목이 없습니다"); return; }
setSaving(true);
try {
const payload = {
master: {
...master,
form_type: "outsourcing",
total_supply_price: String(totalSupplyPrice),
total_supply_unit_price: String(totalSupplyPrice),
total_price: String(totalSupplyPrice),
},
parts: parts.map((p) => ({
objid: p.objid,
part_objid: p.part_objid,
part_no: p.part_no,
product_name: p.product_name,
part_name: p.part_name,
spec: p.spec,
order_qty: toNum(p.order_qty),
qty: toNum(p.qty || p.order_qty),
unit: p.unit,
partner_price: toNum(p.partner_price),
supply_unit_price: toNum(p.supply_unit_price),
work_order_no: p.work_order_no,
delivery_request_date: p.delivery_request_date,
})),
deletedPartObjids: [],
};
const res = await purchaseApi.saveOrderForm(payload);
toast.success(`저장 완료 (${res.purchase_order_no})`);
onSaved?.(res);
onClose();
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패");
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent className="max-w-[1280px] w-[96vw] max-h-[94vh] overflow-hidden flex flex-col p-0 gap-0 bg-white">
<DialogTitle className="sr-only"> </DialogTitle>
<DialogDescription className="sr-only">wace PDF </DialogDescription>
<div ref={pdfContainerRef} className="flex-1 overflow-y-auto p-4 text-[12px]" style={{ fontFamily: "'Malgun Gothic', '맑은 고딕', sans-serif" }}>
<div className="border-2 border-black">
<div className="flex items-center border-b-2 border-black">
<div className="w-[160px] text-center py-2">
<img src="/images/rps-logo-on.png" alt="RPS Logo"
style={{ maxWidth: "140px", height: "auto" }} />
</div>
<div className="flex-1 py-3 text-center">
<div className="text-[16px] font-bold mb-1"> </div>
<div className="text-[28px] font-bold"> </div>
</div>
<div className="w-[160px]"></div>
</div>
<div className="flex">
<div className="w-[45%] p-3 border-r border-black">
<table className="w-full border-collapse">
<tbody>
<tr>
<td className="py-1.5 font-semibold whitespace-nowrap pr-3 w-[160px]">1. :</td>
<td className="py-1.5 font-bold">
<span>{master.purchase_order_no || <span className="text-gray-400"></span>}</span>
</td>
</tr>
<tr>
<td className="py-1.5 font-semibold whitespace-nowrap pr-3">2. :</td>
<td className="py-1.5">
<div className="w-[210px]">
<DateInput value={master.purchase_date}
onChange={(v) => setMaster({ ...master, purchase_date: v })}
disabled={isReadOnly} />
</div>
</td>
</tr>
<tr>
<td className="py-1.5 font-semibold whitespace-nowrap pr-3">3. :</td>
<td className="py-1.5">
<div className="w-[260px]">
<SmartSelect options={vendorOpts}
value={master.partner_objid}
onValueChange={(v) => setMaster({ ...master, partner_objid: v })}
disabled={isReadOnly} />
</div>
</td>
</tr>
<tr>
<td className="py-1.5 font-semibold whitespace-nowrap pr-3">4. (VAT별도) :</td>
<td className="py-1.5 font-bold">
{fmt(totalSupplyPrice)} <span className="font-normal"></span>
</td>
</tr>
</tbody>
</table>
</div>
<div className="flex-1 p-3">
<table className="w-full border-collapse border border-black text-[11px]">
<tbody>
<tr>
<td rowSpan={4} className="w-[60px] text-center font-bold border border-black align-middle bg-gray-100"
style={{ letterSpacing: "3px" }}>
<br/><br/>
</td>
<td className="border border-black px-2 py-1 text-center">
<div className="w-full max-w-[220px] mx-auto">
<SmartSelect options={userOptsForSelect}
value={master.sales_mng_user_id}
onValueChange={(v) => onManagerChange(1, v)}
disabled={isReadOnly} />
</div>
</td>
</tr>
<tr>
<td className="border border-black px-2 py-1 text-center relative">
<span>(<span>{master.manager_phone || "-"}</span> / <span>{master.manager_email || "-"}</span>)</span>
<img src="/images/rps-stamp-seal.png" alt="직인"
className="absolute"
style={{ right: 5, top: -10, width: 45, height: 45, opacity: 0.9 }}
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }} />
</td>
</tr>
<tr>
<td className="border border-black px-2 py-1 text-center">
<div className="w-full max-w-[220px] mx-auto">
<SmartSelect options={userOptsForSelect}
value={master.sales_mng_user_id2}
onValueChange={(v) => onManagerChange(2, v)}
disabled={isReadOnly} />
</div>
</td>
</tr>
<tr>
<td className="border border-black px-2 py-1 text-center">
<span>(<span>{master.manager_phone2 || "-"}</span> / <span>{master.manager_email2 || "-"}</span>)</span>
</td>
</tr>
<tr>
<td colSpan={2} className="border border-black border-b-0 px-2 py-1.5 text-center text-[14px] font-bold">
</td>
</tr>
<tr>
<td colSpan={2} className="border border-black border-t-0 px-2 py-1.5 text-center text-[11px] font-bold">
108( 402-4)
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
{isReadOnly && (
<div className="mt-2 text-[11px] text-amber-700 bg-amber-50 border border-amber-300 px-3 py-1.5 rounded">
/ .
{master.appr_status ? ` (결재상태: ${master.appr_status})` : ""}
{master.status === "cancel" ? " (취소됨)" : ""}
</div>
)}
<div className="flex justify-end gap-2 my-3">
{!isReadOnly && (
<Button size="sm" className="h-8 px-5 text-[13px]"
style={{ background: "#dfeffc", color: "#000" }}
onClick={handleSave} disabled={saving || loading}>
{saving ? "저장 중..." : "저장"}
</Button>
)}
{isReadOnly && master.objid && (
<>
<Button size="sm" variant="outline" className="h-8 gap-1 px-3 text-xs"
onClick={() => setMailOpen(true)}>
<Mail className="h-3.5 w-3.5" />
</Button>
<Button size="sm" className="h-8 gap-1 px-3 text-[13px]"
style={{ background: "#dfeffc", color: "#000" }}
onClick={handleDownload} disabled={generatingPdf}>
<Download className="h-3.5 w-3.5" />
{generatingPdf ? "생성 중..." : "PDF 다운로드"}
</Button>
</>
)}
<Button size="sm" variant="outline" className="h-8 px-5 text-[13px]"
style={{ background: "#dfeffc" }}
onClick={onClose} disabled={saving}>
</Button>
</div>
<div className="border border-black overflow-x-auto">
<table className="w-full text-[11px] border-collapse">
<thead>
<tr className="bg-[#f0f4fa]">
<th className="w-10 border border-black px-1 py-1.5">No</th>
<th className="min-w-[130px] border border-black px-1 py-1.5"></th>
<th className="min-w-[160px] border border-black px-1 py-1.5"></th>
<th className="min-w-[140px] border border-black px-1 py-1.5"></th>
<th className="w-[60px] border border-black px-1 py-1.5"></th>
<th className="w-[70px] border border-black px-1 py-1.5"></th>
<th className="w-[90px] border border-black px-1 py-1.5"></th>
<th className="w-[100px] border border-black px-1 py-1.5"></th>
<th className="w-[110px] border border-black px-1 py-1.5"></th>
<th className="w-[100px] border border-black px-1 py-1.5"></th>
<th className="w-[110px] border border-black px-1 py-1.5"></th>
</tr>
</thead>
<tbody>
{parts.length === 0 ? (
<tr>
<td colSpan={11} className="text-center py-8 border border-black text-gray-500">
</td>
</tr>
) : parts.map((row, i) => (
<tr key={row.rowKey} className="hover:bg-blue-50/30">
<td className="border border-black px-1 py-0.5 text-center text-gray-600">{i + 1}</td>
<td className="border border-black px-1 py-0.5 text-left text-gray-700">{partnerName}</td>
<td className="border border-black px-1 py-0.5">
<Input className="h-7 text-[11px] px-1.5 border-0 focus-visible:ring-1"
value={row.product_name}
onChange={(e) => updateRow(row.rowKey, { product_name: e.target.value })}
disabled={isReadOnly} />
</td>
<td className="border border-black px-1 py-0.5 text-left text-gray-700">{row.part_name}</td>
<td className="border border-black px-1 py-0.5">
<NumberInput value={row.order_qty} decimals={0}
onChange={(v) => updateRow(row.rowKey, { order_qty: v })}
className="h-7 text-[11px]"
disabled={isReadOnly} />
</td>
<td className="border border-black px-1 py-0.5">
<CommCodeSelect groupId={UNIT_GROUP_ID}
value={row.unit}
onValueChange={(v) => updateRow(row.rowKey, { unit: v })}
withAll={false} className="h-7"
disabled={isReadOnly} />
</td>
<td className="border border-black px-1 py-0.5">
<NumberInput value={row.partner_price} decimals={0}
onChange={(v) => updateRow(row.rowKey, { partner_price: v })}
className="h-7 text-[11px]"
disabled={isReadOnly} />
</td>
<td className="border border-black px-1 py-0.5 text-right tabular-nums pr-2">
{fmt(row.supply_unit_price)}
</td>
<td className="border border-black px-1 py-0.5">
<Input className="h-7 text-[11px] px-1.5 border-0 focus-visible:ring-1"
value={row.work_order_no}
onChange={(e) => updateRow(row.rowKey, { work_order_no: e.target.value })}
disabled={isReadOnly} />
</td>
<td className="border border-black px-1 py-0.5 text-center text-gray-700">{row.part_no}</td>
<td className="border border-black px-1 py-0.5">
<DateInput value={row.delivery_request_date}
onChange={(v) => updateRow(row.rowKey, { delivery_request_date: v })}
size="sm"
disabled={isReadOnly} />
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex justify-end mt-3">
<table className="border-collapse">
<tbody>
<tr>
<td className="bg-[#e8f0e1] border border-black px-6 py-2 text-[13px] font-bold whitespace-nowrap" style={{ letterSpacing: "2px" }}>
(VAT별도)
</td>
<td className="border border-black px-4 py-2 text-right font-bold text-[16px] min-w-[200px] tabular-nums">
{fmt(totalSupplyPrice)}
</td>
</tr>
</tbody>
</table>
</div>
<div className="text-right text-[11px] text-red-600 mt-3">
(CONFIDENTIAL) : (APPROVAL) .
</div>
</div>
</DialogContent>
<PurchaseOrderMailDialog
open={mailOpen}
onOpenChange={setMailOpen}
pomObjid={master.objid || null}
formType="outsourcing"
onRequestPdf={handleRequestPdf}
onSent={() => setMailOpen(false)}
/>
</Dialog>
);
}
+176
View File
@@ -57,6 +57,114 @@ async function getList<T = any>(path: string, filter: PurchaseListFilter): Promi
return res.data?.data as PurchaseListResponse<T>;
}
export interface OrderFormData {
master: Record<string, any>;
parts: Record<string, any>[];
}
export interface SaveOrderPayload {
master: Record<string, any>;
parts: Record<string, any>[];
deletedPartObjids?: string[];
}
export interface SaveOrderResult {
objid: string;
purchase_order_no: string;
}
export interface OrderMailInfo {
pom_objid: string;
purchase_order_no: string;
partner_objid: string;
partner_name: string;
partner_email: string;
writer_email: string;
writer_name: string;
form_type: string;
}
export interface PartnerManager {
name: string;
email: string;
phone: string;
department: string;
is_main: string;
}
export interface SendOrderMailPayload {
pomObjid: string;
toEmails: string;
ccEmails?: string;
subject: string;
contents: string;
pdfBase64: string;
}
export interface InboundFormData {
master: {
pom_objid: string;
purchase_order_no: string;
project_no: string;
contract_mgmt_objid: string;
partner_objid: string;
partner_name: string;
delivery_status: string;
};
parts: {
order_part_objid: string;
part_objid: string;
part_no: string;
part_name: string;
spec: string;
maker: string;
unit_title: string;
order_qty: number;
arrival_qty: number;
non_arrival_qty: number;
delivery_request_date: string;
}[];
arrivals: {
objid: string;
order_part_objid: string;
part_objid: string;
group_seq: string;
seq: string;
receipt_date: string;
location: string;
sub_location: string;
receipt_qty: number;
arrival_qty: number;
inventory_status: string;
}[];
}
export interface InboundSaveRow {
objid?: string;
parent_objid: string;
order_part_objid: string;
part_objid: string;
group_seq: string;
seq: string;
receipt_date: string;
location: string;
sub_location: string;
receipt_qty: number;
arrival_qty: number;
}
export interface DeadlineInfoPayload {
objIds: string[];
taxType: string;
taxInvoiceDate?: string;
exportDeclNo?: string;
loadingDate?: string;
foreignType?: string;
duty?: string;
importVat?: string;
exchangeRate?: string;
}
export const purchaseApi = {
// 그리드 7종
listPurchaseRequest: (f: PurchaseListFilter = {}) => getList("purchase-request", f),
@@ -68,6 +176,74 @@ export const purchaseApi = {
listProjectStatus: (f: PurchaseListFilter = {}) => getList("project-status", f),
listOrder: (f: PurchaseListFilter = {}) => getList("order-list", f),
// ─── 발주서 폼 (general 양식) ──────────────────────────
/** 품의서 OBJID 로 발주서 폼 초기값 + 품목 자동채움. */
async initOrderForm(proposalObjid: string): Promise<OrderFormData> {
const r = await apiClient.get("/purchase/order-form/init", {
params: { proposal_objid: proposalObjid },
});
return r.data?.data as OrderFormData;
},
/** 기존 발주서 마스터+파트 조회 (수정 모드). */
async getOrderForm(objid: string): Promise<OrderFormData> {
const r = await apiClient.get(`/purchase/order-form/${encodeURIComponent(objid)}`);
return r.data?.data as OrderFormData;
},
/** 마스터+파트 UPSERT + 누락파트 삭제. */
async saveOrderForm(payload: SaveOrderPayload): Promise<SaveOrderResult> {
const r = await apiClient.post("/purchase/order-form/save", payload);
return r.data?.data as SaveOrderResult;
},
/** 발주서 삭제 (cascade). */
async deleteOrderForm(objid: string): Promise<void> {
await apiClient.delete(`/purchase/order-form/${encodeURIComponent(objid)}`);
},
// ─── 발주서 메일 발송 ─────────────────────────────────────
/** 메일 다이얼로그 자동채움 — 공급업체 이메일/이름 + 작성자 이메일 + 발주번호 */
async getOrderMailInfo(pomObjid: string): Promise<OrderMailInfo | null> {
const r = await apiClient.get(`/purchase/order-form/mail-info/${encodeURIComponent(pomObjid)}`);
return (r.data?.data ?? null) as OrderMailInfo | null;
},
/** 공급업체 담당자 리스트 — RPS client_mng 단일 email */
async listPartnerManagers(partnerObjid: string): Promise<PartnerManager[]> {
const r = await apiClient.get(
`/purchase/options/partner-managers/${encodeURIComponent(partnerObjid)}`,
);
return (r.data?.data ?? []) as PartnerManager[];
},
/** PDF 첨부 + SMTP 발송. 성공 시 mail_send_yn='Y'/mail_send_date=NOW() 갱신. */
async sendOrderMail(payload: SendOrderMailPayload): Promise<{ success: boolean; message: string; objid?: string }> {
const r = await apiClient.post("/purchase/order-form/mail", payload);
return r.data as { success: boolean; message: string; objid?: string };
},
// ─── 입고관리 (입고등록 / 마감정보 / 매입마감) ─────────────
async getInboundForm(pomObjid: string): Promise<InboundFormData> {
const r = await apiClient.get(`/purchase/inbound-form/${encodeURIComponent(pomObjid)}`);
return r.data?.data as InboundFormData;
},
async saveInboundForm(pomObjid: string, rows: InboundSaveRow[]): Promise<{ saved: number }> {
const r = await apiClient.post("/purchase/inbound-form/save", { pomObjid, rows });
return r.data?.data as { saved: number };
},
async saveArrivalDeadline(body: DeadlineInfoPayload): Promise<{ updated: number }> {
const r = await apiClient.post("/purchase/arrival/deadline", body);
return r.data?.data as { updated: number };
},
async closeArrival(objIds: string[]): Promise<{ updated: number }> {
const r = await apiClient.post("/purchase/arrival/close", { objIds });
return r.data?.data as { updated: number };
},
async listWarehouses(): Promise<OptionItem[]> {
const r = await apiClient.get("/purchase/options/warehouses");
return (r.data?.data ?? []) as OptionItem[];
},
async listAcctCodes(): Promise<OptionItem[]> {
const r = await apiClient.get("/purchase/options/acct-codes");
return (r.data?.data ?? []) as OptionItem[];
},
// 공통 옵션
async listSuppliers(): Promise<OptionItem[]> {
const r = await apiClient.get("/purchase/options/suppliers");
+59
View File
@@ -0,0 +1,59 @@
// 발주서 다이얼로그 컨테이너 DOM → PDF base64 변환 헬퍼.
// 영업관리 estimate template2 (lib + sales/estimate/template2/pop/page.tsx 372-438) 패턴 단순화 버전.
// - 다이얼로그 안의 양식 컨테이너를 html2canvas-pro 로 캡처 (scale=2, 흰배경, input/textarea → 텍스트)
// - A4 비율로 jsPDF 에 그려 datauristring 반환 (메일 첨부) 또는 save (파일 다운로드)
export async function generatePurchaseOrderPdf(
container: HTMLElement,
opts?: { filename?: string; download?: boolean },
): Promise<string> {
const html2canvas = (await import("html2canvas-pro")).default;
const jspdfModule: any = await import("jspdf");
const jsPDF = jspdfModule.jsPDF ?? jspdfModule.default;
const canvas = await html2canvas(container, {
scale: 2,
useCORS: true,
backgroundColor: "#ffffff",
onclone: (doc) => {
// input/textarea/select → 텍스트로 교체 (편집 윤곽 제거)
doc.querySelectorAll("input, textarea").forEach((el) => {
const v = (el as HTMLInputElement | HTMLTextAreaElement).value ?? "";
const span = doc.createElement("span");
span.textContent = v;
(span.style as any).whiteSpace = "pre-wrap";
el.parentNode?.replaceChild(span, el);
});
// ShadCN Select 트리거 안의 표시 텍스트만 추출 — 화살표/아이콘은 제거
doc.querySelectorAll('[role="combobox"]').forEach((el) => {
const text = el.textContent ?? "";
const span = doc.createElement("span");
span.textContent = text.trim();
el.parentNode?.replaceChild(span, el);
});
},
});
const pdf = new jsPDF("p", "mm", "a4");
const imgData = canvas.toDataURL("image/jpeg", 0.85);
const imgWidth = 210;
const pageHeight = 297;
const imgHeight = (canvas.height * imgWidth) / canvas.width;
let heightLeft = imgHeight;
let position = 0;
pdf.addImage(imgData, "JPEG", 0, position, imgWidth, imgHeight, undefined, "FAST");
heightLeft -= pageHeight;
while (heightLeft > 0) {
position = heightLeft - imgHeight;
pdf.addPage();
pdf.addImage(imgData, "JPEG", 0, position, imgWidth, imgHeight, undefined, "FAST");
heightLeft -= pageHeight;
}
if (opts?.download) {
pdf.save(opts.filename ?? "purchase_order.pdf");
}
return pdf.output("datauristring") as string;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB