diff --git a/backend-node/src/controllers/purchaseController.ts b/backend-node/src/controllers/purchaseController.ts index 6ca10c11..f9974ab3 100644 --- a/backend-node/src/controllers/purchaseController.ts +++ b/backend-node/src/controllers/purchaseController.ts @@ -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): 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(); diff --git a/backend-node/src/routes/purchaseRoutes.ts b/backend-node/src/routes/purchaseRoutes.ts index a382029f..7a67908f 100644 --- a/backend-node/src/routes/purchaseRoutes.ts +++ b/backend-node/src/routes/purchaseRoutes.ts @@ -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; diff --git a/backend-node/src/services/purchaseInboundService.ts b/backend-node/src/services/purchaseInboundService.ts new file mode 100644 index 00000000..c0be7aac --- /dev/null +++ b/backend-node/src/services/purchaseInboundService.ts @@ -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 { + 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; + } +} diff --git a/backend-node/src/services/purchaseOrderFormService.ts b/backend-node/src/services/purchaseOrderFormService.ts index 444b146c..71933b2f 100644 --- a/backend-node/src/services/purchaseOrderFormService.ts +++ b/backend-node/src/services/purchaseOrderFormService.ts @@ -21,12 +21,24 @@ import { getPool } from "../database/db"; import { logger } from "../utils/logger"; +import { createObjId } from "../utils/objidUtil"; export interface OrderFormInitResult { master: Record; parts: Record[]; } +export interface SaveOrderFormPayload { + master: Record; + parts: Record[]; + 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 = { objid: "", // 신규 — 클라이언트가 채워 보내거나 save 시 채번 @@ -88,10 +119,12 @@ export async function getPurchaseOrderFormInit(proposalObjid: string): Promise[] = []; 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, ...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 { + 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 = 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 { + 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(); + } +} diff --git a/backend-node/src/services/purchaseOrderMailService.ts b/backend-node/src/services/purchaseOrderMailService.ts new file mode 100644 index 00000000..34359d1d --- /dev/null +++ b/backend-node/src/services/purchaseOrderMailService.ts @@ -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, "&") + .replace(//g, ">") + .replace(/\n/g, "
"); +} + +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 }; +} diff --git a/backend-node/src/services/purchaseService.ts b/backend-node/src/services/purchaseService.ts index 66b60cec..b0586792 100644 --- a/backend-node/src/services/purchaseService.ts +++ b/backend-node/src/services/purchaseService.ts @@ -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 []; } } diff --git a/frontend/app/(main)/COMPANY_16/purchase/inbound-by-date/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/inbound-by-date/page.tsx index 96edf1ee..b9d3e1bb 100644 --- a/frontend/app/(main)/COMPANY_16/purchase/inbound-by-date/page.tsx +++ b/frontend/app/(main)/COMPANY_16/purchase/inbound-by-date/page.tsx @@ -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([]); const [userOpts, setUserOpts] = useState([]); + + // 마감정보입력 / 매입마감 + const [deadlineOpen, setDeadlineOpen] = useState(false); + const [deadlineObjIds, setDeadlineObjIds] = useState([]); + const [deadlinePrefill, setDeadlinePrefill] = useState(undefined); + const { confirm, ConfirmDialogComponent } = useConfirmDialog(); const yearOpts = useMemo(() => getYearOptions(), []); const fetchList = useCallback(async (override?: Partial) => { @@ -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={<> } @@ -212,6 +263,15 @@ export default function InboundByDatePage() { }} showChart /> + + setDeadlineOpen(false)} + onSaved={() => { setDeadlineOpen(false); fetchList(); }} + /> + {ConfirmDialogComponent} ); } diff --git a/frontend/app/(main)/COMPANY_16/purchase/inbound-by-item/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/inbound-by-item/page.tsx index fdeb28c7..caa431f8 100644 --- a/frontend/app/(main)/COMPANY_16/purchase/inbound-by-item/page.tsx +++ b/frontend/app/(main)/COMPANY_16/purchase/inbound-by-item/page.tsx @@ -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" }, diff --git a/frontend/app/(main)/COMPANY_16/purchase/inbound/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/inbound/page.tsx index e828a19a..9f631b33 100644 --- a/frontend/app/(main)/COMPANY_16/purchase/inbound/page.tsx +++ b/frontend/app/(main)/COMPANY_16/purchase/inbound/page.tsx @@ -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([]); const [userOpts, setUserOpts] = useState([]); + // 입고등록 다이얼로그 + const [inboundOpen, setInboundOpen] = useState(false); + const [inboundPomObjid, setInboundPomObjid] = useState(""); + const yearOpts = useMemo(() => getYearOptions(), []); const fetchList = useCallback(async (override?: Partial) => { @@ -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={ } @@ -211,6 +222,13 @@ export default function InboundPage() { }} showChart /> + + setInboundOpen(false)} + onSaved={() => { setInboundOpen(false); fetchList(); }} + /> ); } diff --git a/frontend/app/(main)/COMPANY_16/purchase/order/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/order/page.tsx index e1f75167..04dd8d5b 100644 --- a/frontend/app/(main)/COMPANY_16/purchase/order/page.tsx +++ b/frontend/app/(main)/COMPANY_16/purchase/order/page.tsx @@ -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([]); const [purchaseOpts, setPurchaseOpts] = useState([]); + // 수정 다이얼로그 — form_type 따라 자동 분기 + const [editObjid, setEditObjid] = useState(""); + const [editFormType, setEditFormType] = useState(""); + const { confirm, ConfirmDialogComponent } = useConfirmDialog(); + const yearOpts = useMemo(() => getYearOptions(), []); const fetchList = useCallback(async (override?: Partial) => { @@ -138,7 +150,42 @@ export default function PurchaseOrderWacePage() { return (
- + + + + } /> 총 {total.toLocaleString()}건}> @@ -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); + }} /> + + setEditFormType("")} + onSaved={() => { setEditFormType(""); fetchList(); }} + /> + setEditFormType("")} + onSaved={() => { setEditFormType(""); fetchList(); }} + /> + setEditFormType("")} + onSaved={() => { setEditFormType(""); fetchList(); }} + /> + + {ConfirmDialogComponent}
); } diff --git a/frontend/app/(main)/COMPANY_16/purchase/proposal/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/proposal/page.tsx index 8e50b2f0..0790af9d 100644 --- a/frontend/app/(main)/COMPANY_16/purchase/proposal/page.tsx +++ b/frontend/app/(main)/COMPANY_16/purchase/proposal/page.tsx @@ -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([]); const [userOpts, setUserOpts] = useState([]); + // 발주서생성 — 양식 선택 모달 → 양식별 다이얼로그 + const [typeSelectOpen, setTypeSelectOpen] = useState(false); + const [orderFormType, setOrderFormType] = useState(""); + const [orderFormProposalId, setOrderFormProposalId] = useState(""); + const fetchList = useCallback(async (override?: Partial) => { setLoading(true); try { @@ -124,7 +136,12 @@ export default function ProposalPage() { } @@ -194,6 +211,31 @@ export default function ProposalPage() { }} showChart /> + + setTypeSelectOpen(false)} + onSelect={(t) => setOrderFormType(t)} + /> + + setOrderFormType("")} + onSaved={() => { setOrderFormType(""); fetchList(); }} + /> + setOrderFormType("")} + onSaved={() => { setOrderFormType(""); fetchList(); }} + /> + setOrderFormType("")} + onSaved={() => { setOrderFormType(""); fetchList(); }} + /> ); } diff --git a/frontend/components/common/DataGrid.tsx b/frontend/components/common/DataGrid.tsx index 13431e69..0f57df78 100644 --- a/frontend/components/common/DataGrid.tsx +++ b/frontend/components/common/DataGrid.tsx @@ -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), )} >
@@ -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 = {}; + 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 ( 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) => { diff --git a/frontend/components/purchase/DeadlineInfoDialog.tsx b/frontend/components/purchase/DeadlineInfoDialog.tsx new file mode 100644 index 00000000..f764fe0e --- /dev/null +++ b/frontend/components/purchase/DeadlineInfoDialog.tsx @@ -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({}); + + 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 ( + { if (!v && !saving) onClose(); }}> + + + 마감정보입력 + 선택된 {objIds.length}건의 입고건에 마감정보를 일괄 적용합니다 + + +
+ + setForm({ ...form, foreignType: v })} /> + + + setForm({ ...form, exchangeRate: String(v) })} + className="h-8 text-[12px]" /> + + + setForm({ ...form, taxType: v })} /> + + + setForm({ ...form, taxInvoiceDate: v })} /> + + + setForm({ ...form, exportDeclNo: e.target.value })} + className="h-8 text-[12px]" /> + + + setForm({ ...form, loadingDate: v })} /> + + + setForm({ ...form, duty: String(v) })} + className="h-8 text-[12px]" /> + + + setForm({ ...form, importVat: String(v) })} + className="h-8 text-[12px]" /> + +
+ + + + + +
+
+ ); +} + +function FieldRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ +
{children}
+
+ ); +} diff --git a/frontend/components/purchase/InboundFormDialog.tsx b/frontend/components/purchase/InboundFormDialog.tsx new file mode 100644 index 00000000..674a4a98 --- /dev/null +++ b/frontend/components/purchase/InboundFormDialog.tsx @@ -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(null); + const [parts, setParts] = useState([]); + const [arrivals, setArrivals] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + const [warehouseOpts, setWarehouseOpts] = useState([]); + const [acctOpts, setAcctOpts] = useState([]); + + // 일괄적용 (좌 미입고 영역) — 운영판 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 = {}; + 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) => { + 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 ( + { if (!v && !saving) onClose(); }}> + + 입고등록 + wace 운영판 입고등록 팝업 + +
+
입고등록
+
+ 발주번호 + {master?.purchase_order_no || "-"} + 프로젝트번호 + {master?.project_no || "-"} + 공급업체 + {master?.partner_name || "-"} +
+
+ +
+ {/* 일괄적용 영역 */} +
+ 미입고 +
+ +
+
+ +
+ +
+ + +
+ +
+ {/* 좌측: 발주 품목 */} +
+
+ + + + + + + + + + + + + + + + + {parts.length === 0 ? ( + + ) : parts.map((p, idx) => ( + + + + + + + + + + ))} + +
발주품목
품번품명규격단위수량미입고입고요청일
발주 품목이 없습니다
{p.part_no}{p.part_name}{p.spec}{p.unit_title}{p.order_qty.toLocaleString()}{p.non_arrival_qty.toLocaleString()}{p.delivery_request_date}
+
+
+ + {/* 우측: 차수별 입고 입력 */} +
+
+ + + + + + + + + + + + + + {parts.length === 0 ? ( + + ) : parts.map((p) => { + const groupRows = groupedByPart[p.order_part_objid] || []; + const rows: React.ReactElement[] = []; + groupRows.forEach((a, idx) => { + rows.push( + + {idx === 0 && ( + + )} + + + + + + + + ); + }); + // 차수 추가 행 + rows.push( + + + + ); + return rows; + }).flat()} + +
발주품목차수입고일입고창고계정과목입고수량
발주 품목이 없습니다
+
{p.part_no}
+
{p.part_name}
+
{a.group_seq}차 + updateArrival(a.rowKey, { receipt_date: v })} + size="sm" /> + + updateArrival(a.rowKey, { location: v })} + placeholder="선택" /> + + updateArrival(a.rowKey, { sub_location: v })} + placeholder="선택" /> + + updateArrival(a.rowKey, { receipt_qty: v })} + className="h-7 text-[11px]" /> + + +
+ +
+
+
+
+
+ +
+ ); +} diff --git a/frontend/components/purchase/PurchaseOrderEnglishFormDialog.tsx b/frontend/components/purchase/PurchaseOrderEnglishFormDialog.tsx new file mode 100644 index 00000000..7928f9f1 --- /dev/null +++ b/frontend/components/purchase/PurchaseOrderEnglishFormDialog.tsx @@ -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(EMPTY_MASTER); + const [parts, setParts] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + const [vendorOpts, setVendorOpts] = useState([]); + const [userOpts, setUserOpts] = useState([]); + + 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, ps: Record[]) => { + 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(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 => { + if (!pdfContainerRef.current) throw new Error("Purchase order container not found"); + return generatePurchaseOrderPdf(pdfContainerRef.current); + }; + + const updateRow = (rowKey: string, patch: Partial) => { + 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 ( + { if (!v) onClose(); }}> + + Purchase Order (English) + wace English Purchase Order PDF form +
+ {/* 헤더 */} +
+
+ RPS Logo +
+
+
R P S CO., LTD.
+
www.rps-korea.com
+
8, Gukjegwahak 10-ro, Yuseong-gu, Daejeon, Republic of Korea
+
Tel : +82-42-602-3300 / Fax : +82-42-672-3399 / E-mail : ady1225@rps-korea.com
+
Purchasing Team Manager, An-Dong-Yoon
+
+
+ +
+ Purchase Order +
+

+ We are pleased to issue Purchase Order with the terms and condition described as below. +

+ + {/* 좌+우 2열 5행 정보 테이블 */} + + + setMaster({ ...master, partner_objid: v })} + disabled={isReadOnly} /> + } + rightLabel="Shipment" + rightBg="#ebf1de" + rightCell={ + setMaster({ ...master, shipment: e.target.value })} + disabled={isReadOnly} /> + } + /> + setMaster({ ...master, attn_to: e.target.value })} + disabled={isReadOnly} /> + } + rightLabel="Payment" + rightCell={ + setMaster({ ...master, payment_terms: e.target.value })} + disabled={isReadOnly} /> + } + /> + setMaster({ ...master, purchase_date: v })} + disabled={isReadOnly} /> + } + rightLabel="Packing" + rightCell={ + setMaster({ ...master, packing: e.target.value })} + disabled={isReadOnly} /> + } + /> + {master.purchase_order_no || auto} + } + rightLabel="Validity" + rightCell={ + setMaster({ ...master, validity: e.target.value })} + disabled={isReadOnly} /> + } + /> +  } + rightLabel="Remarks" + rightCell={ + setMaster({ ...master, remark: e.target.value })} + disabled={isReadOnly} /> + } + /> + +
+ + {isReadOnly && ( +
+ This order is locked (approval in progress / completed or cancelled). + {master.appr_status ? ` (status: ${master.appr_status})` : ""} + {master.status === "cancel" ? " (cancelled)" : ""} +
+ )} + +
+ {!isReadOnly && ( + + )} + {isReadOnly && master.objid && ( + <> + + + + )} + +
+ + {/* 그리드 */} +
+ + + + + + + + + + + + + + + {parts.length === 0 ? ( + + + + ) : parts.map((row) => { + const desc = row.spec ? `${row.part_name}/${row.spec}` : row.part_name; + return ( + + + + + + + + + + + ); + })} + +
Item No.Commodity & DescriptionUnitQ'tyCurrencyUnit PriceAmountDelivery
+ Items are auto-filled from the proposal +
{row.part_no}{desc} + updateRow(row.rowKey, { unit: v })} + withAll={false} className="h-7" + disabled={isReadOnly} /> + + updateRow(row.rowKey, { order_qty: v })} + className="h-7 text-[11px]" + disabled={isReadOnly} /> + + updateRow(row.rowKey, { currency: v })} + withAll={false} className="h-7" + disabled={isReadOnly} /> + + updateRow(row.rowKey, { partner_price: v })} + className="h-7 text-[11px]" + disabled={isReadOnly} /> + + {fmt2(row.supply_unit_price)} + + updateRow(row.rowKey, { delivery_request_date: v })} + size="sm" + disabled={isReadOnly} /> +
+
+ + {/* TOTAL */} + + + + + + + +
+ TOTAL + + {fmt2(totalSupplyPrice)} +
+ +

+ Look forward to your soonest delivery with good condition. +

+ + {/* 서명 영역 */} +
+
+
Signed by Dong-Heon Lee / President
+
RPS CO.,LTD
+ Stamp { (e.currentTarget as HTMLImageElement).style.display = "none"; }} /> +
+
+
+ + setMailOpen(false)} + /> +
+ ); +} + +interface FieldRowProps { + leftLabel: string; + leftCell: React.ReactNode; + rightLabel: string; + rightCell: React.ReactNode; + rightBg?: string; +} +function FieldRow({ leftLabel, leftCell, rightLabel, rightCell, rightBg }: FieldRowProps) { + return ( + + {leftLabel || " "} + {leftCell} + + {rightLabel} + {rightCell} + + ); +} diff --git a/frontend/components/purchase/PurchaseOrderFormTypeSelectDialog.tsx b/frontend/components/purchase/PurchaseOrderFormTypeSelectDialog.tsx new file mode 100644 index 00000000..8aafcd46 --- /dev/null +++ b/frontend/components/purchase/PurchaseOrderFormTypeSelectDialog.tsx @@ -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 ( + { if (!v) onClose(); }}> + + 발주서 양식 선택 + 일반, 외주가공, 영문 중 하나를 선택하세요 +
+
발주서 양식을 선택하세요
+
+ + + +
+ +
+
+
+ ); +} diff --git a/frontend/components/purchase/PurchaseOrderGeneralFormDialog.tsx b/frontend/components/purchase/PurchaseOrderGeneralFormDialog.tsx new file mode 100644 index 00000000..b4b5e1c4 --- /dev/null +++ b/frontend/components/purchase/PurchaseOrderGeneralFormDialog.tsx @@ -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(EMPTY_MASTER); + const [parts, setParts] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + const [vendorOpts, setVendorOpts] = useState([]); + const [userOpts, setUserOpts] = useState([]); + 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, ps: Record[]) => { + 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(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 => { + 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) => { + 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 ( + { if (!v) onClose(); }}> + + {/* Radix UI 접근성 — 시각 노출 없이 타이틀/설명 제공 */} + 발주서 (일반) + wace 운영판 일반 발주서 PDF 양식 +
+ {/* ── 상단 메인 박스 (PDF 양식) ── */} +
+ {/* 1행: 로고 + 타이틀 */} +
+
+ {/* wace `<%=request.getContextPath()%>/images/logo.png` 1:1 */} + RPS Logo +
+
+ 발 주 서 +
+
+
+ + {/* 2행: 좌(기본정보) + 우(담당자) */} +
+ {/* 좌: 기본정보 5필드 */} +
+ + + + + + + + + + + + + + + + + + + + + + + +
1. 발 주 번 호 : + {master.purchase_order_no || 자동생성} +
2. 발 주 일 자 : +
+ setMaster({ ...master, purchase_date: v })} + disabled={isReadOnly} /> +
+
3. 수 신 업 체 : +
+ setMaster({ ...master, partner_objid: v })} + disabled={isReadOnly} /> +
+
4. 합 계 금 액(VAT별도) : + {fmt(totalSupplyPrice)} +
5. 결 제 방 식 : +
+ setMaster({ ...master, payment_terms: v })} + withAll={false} + disabled={isReadOnly} /> +
+
+
+ + {/* 우: 담당자 박스 + 회사정보 */} +
+ + + + + + + + + + + + + + + + + + + + + + +
+ 담

자 +
+
+ onManagerChange(1, v)} + disabled={isReadOnly} /> +
+
+ ({master.manager_phone || "-"} / {master.manager_email || "-"}) + {/* 직인 — wace `images/stamp_rps.png` (운영 파일은 stamp_seal.png) onerror hide */} + 직인 { (e.currentTarget as HTMLImageElement).style.display = "none"; }} /> +
+
+ onManagerChange(2, v)} + disabled={isReadOnly} /> +
+
+ ({master.manager_phone2 || "-"} / {master.manager_email2 || "-"}) +
+ ㈜알피에스 대표이사 이 동 헌 +
+ 대전광역시 유성구 국제과학10로8(둔곡동 402-4번지) +
+
+
+
+ + {/* 읽기전용 안내 — wace isModify=false 분기 */} + {isReadOnly && ( +
+ 결재 진행/완료 또는 취소 상태의 발주서는 수정할 수 없습니다. + {master.appr_status ? ` (결재상태: ${master.appr_status})` : ""} + {master.status === "cancel" ? " (취소됨)" : ""} +
+ )} + + {/* 버튼 영역 (그리드 위, 우측 정렬) — wace _general.jsp 941-948 1:1 */} +
+ {!isReadOnly && ( + + )} + {isReadOnly && master.objid && ( + <> + + + + )} + +
+ + {/* 그리드 */} +
+ + + + + + + + + + + + + + + + + {parts.length === 0 ? ( + + + + ) : parts.map((row, i) => ( + + + + + + + + + + + + + ))} + +
No품명규격수량단위배송지단가공급가액비고입고요청일
+ 품의서에서 진입하면 품목이 자동으로 채워집니다 +
{i + 1} + updateRow(row.rowKey, { part_name: e.target.value })} + disabled={isReadOnly} /> + + updateRow(row.rowKey, { spec: e.target.value })} + disabled={isReadOnly} /> + + updateRow(row.rowKey, { order_qty: v })} + className="h-7 text-[11px]" + disabled={isReadOnly} /> + + updateRow(row.rowKey, { unit: v })} + withAll={false} className="h-7" + disabled={isReadOnly} /> + + updateRow(row.rowKey, { part_delivery_place: v })} + withAll={false} className="h-7" + disabled={isReadOnly} /> + + updateRow(row.rowKey, { partner_price: v })} + className="h-7 text-[11px]" + disabled={isReadOnly} /> + + {fmt(row.supply_unit_price)} + + updateRow(row.rowKey, { remark: e.target.value })} + disabled={isReadOnly} /> + + updateRow(row.rowKey, { delivery_request_date: v })} + size="sm" + disabled={isReadOnly} /> +
+
+ + {/* 합계 영역 */} +
+ + + + + + + +
+ 총 공 급 가 액 (VAT별도) + + {fmt(totalSupplyPrice)} +
+
+ + {/* 보안 문구 */} +
+ ※ 보안문서(CONFIDENTIAL) : ㈜알피에스의 승인(APPROVAL) 없이 외부로 반출하거나 공유 할수 없습니다. +
+
+
+ + setMailOpen(false)} + /> +
+ ); +} diff --git a/frontend/components/purchase/PurchaseOrderMailDialog.tsx b/frontend/components/purchase/PurchaseOrderMailDialog.tsx new file mode 100644 index 00000000..c9358a8d --- /dev/null +++ b/frontend/components/purchase/PurchaseOrderMailDialog.tsx @@ -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; + 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([]); + const [checkedEmails, setCheckedEmails] = useState>({}); + 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 ( + { if (!sending) onOpenChange(o); }}> + + + {isEnglish ? "Send Purchase Order Email" : "발주서 메일 발송"} + + {isEnglish + ? "Attachment: Purchase Order PDF (1 page)" + : "PDF 첨부: 발주서 양식 1장"} + + + + {loading ? ( +
+ + {isEnglish ? "Loading..." : "정보를 불러오는 중..."} +
+ ) : ( +
+
+ +
+ {managers.length === 0 ? ( +
+ {isEnglish ? "No contacts. Enter recipient directly." : "등록된 담당자가 없습니다. 수신인을 직접 입력해주세요."} +
+ ) : ( + managers.map((m, i) => { + const email = m.email ?? ""; + const id = `pom_manager_${i}_${email}`; + return ( + + ); + }) + )} +
+
+ +
+ + setForm({ ...form, toEmails: e.target.value })} + placeholder="email1@example.com, email2@example.com" + /> +

+ {isEnglish ? "Multiple addresses separated by , or ;" : "여러 개는 쉼표(,) 또는 세미콜론(;)으로 구분"} +

+
+ +
+ + setForm({ ...form, ccEmails: e.target.value })} + placeholder={isEnglish ? "Optional" : "참조 이메일 주소 (선택사항)"} + /> +

+ {isEnglish ? "Writer email is auto-added as CC." : "작성자 이메일이 자동으로 참조에 추가됩니다."} +

+
+ +
+ + setForm({ ...form, subject: e.target.value })} + /> +
+ +
+ +