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