구매관리 입고관리 입고등록 + 입고일별 마감정보입력 + 매입마감
- backend purchaseInboundService 신설 — getInboundFormInit / saveInboundForm
(arrival_plan UPSERT 트랜잭션) / saveDeadlineInfo (8필드 일괄 UPDATE) /
closeArrival (이미 마감된 건 차단) + listWarehouseOptions / listAcctCodeOptions
- backend routes — GET /inbound-form/:pomObjid / POST /inbound-form/save /
POST /arrival/deadline / POST /arrival/close + 옵션 2개
- InboundFormDialog 신설 — wace deliveryAcceptanceFormPopUp_new.jsp 1:1
(좌 발주품목 read-only + 우 차수별 입고입력 + 미입고 일괄적용)
- DeadlineInfoDialog 신설 — wace swal 모달 1:1 (8필드 일괄, 단건 시 prefill)
- inbound 페이지 입고등록 / inbound-by-date 마감정보입력+매입마감 연결
- 입고등록 master SELECT 함정 수정 — RPS 에 POM.delivery_status 없어 reception_status fallback
- DataGrid 다중 frozen 누적 left 계산 인프라 추가 (frozenLeftPx props 보강)
— shadcn Table 기반이라 진짜 column pinning 불가 (자연 위치 도달 후 sticky),
입고 3페이지의 frozen 부여는 일단 제거. 진짜 pinning 은 별도 작업
This commit is contained in:
@@ -7,6 +7,7 @@ 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 {
|
||||
@@ -156,6 +157,86 @@ export async function sendPurchaseOrderMail(req: AuthenticatedRequest, res: Resp
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 입고관리 (입고등록 / 마감정보 / 매입마감) ─────────────────
|
||||
|
||||
/** 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();
|
||||
|
||||
@@ -28,11 +28,19 @@ 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,7 +151,11 @@ function SortableHeaderCell({
|
||||
const [filterSearch, setFilterSearch] = useState("");
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: col.key });
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
// 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,
|
||||
@@ -159,6 +166,9 @@ function SortableHeaderCell({
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -101,6 +101,70 @@ export interface SendOrderMailPayload {
|
||||
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),
|
||||
@@ -154,6 +218,32 @@ export const purchaseApi = {
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user