구매관리 입고관리 입고등록 + 입고일별 마감정보입력 + 매입마감
- 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 svc from "../services/purchaseService";
|
||||||
import * as formSvc from "../services/purchaseOrderFormService";
|
import * as formSvc from "../services/purchaseOrderFormService";
|
||||||
import * as mailSvc from "../services/purchaseOrderMailService";
|
import * as mailSvc from "../services/purchaseOrderMailService";
|
||||||
|
import * as inboundSvc from "../services/purchaseInboundService";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
function parseFilter(q: Record<string, any>): svc.PurchaseListFilter {
|
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) {
|
export async function getSuppliers(_req: AuthenticatedRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const data = await svc.listSupplierOptions();
|
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.get ("/order-form/:objid", ctrl.getPurchaseOrderForm); // 수정/조회
|
||||||
router.delete("/order-form/:objid", ctrl.deletePurchaseOrderForm); // 삭제 cascade
|
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/suppliers", ctrl.getSuppliers);
|
||||||
router.get("/options/vendors", ctrl.getVendors); // wace client_mng 기반
|
router.get("/options/vendors", ctrl.getVendors); // wace client_mng 기반
|
||||||
router.get("/options/users", ctrl.getUsers);
|
router.get("/options/users", ctrl.getUsers);
|
||||||
router.get("/options/projects", ctrl.getProjects);
|
router.get("/options/projects", ctrl.getProjects);
|
||||||
router.get("/options/partner-managers/:partnerObjid", ctrl.getPartnerManagers); // 발주서 메일 담당자
|
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;
|
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 { PageHeader } from "@/components/common/PageHeader";
|
||||||
import { purchaseApi, PurchaseListFilter, OptionItem, getYearOptions } from "@/lib/api/purchase";
|
import { purchaseApi, PurchaseListFilter, OptionItem, getYearOptions } from "@/lib/api/purchase";
|
||||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
import { DeadlineInfoDialog, DeadlinePrefill } from "@/components/purchase/DeadlineInfoDialog";
|
||||||
|
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||||
|
|
||||||
const CLOSE_OPTS: SmartSelectOption[] = [
|
const CLOSE_OPTS: SmartSelectOption[] = [
|
||||||
{ code: "N", label: "미마감" },
|
{ code: "N", label: "미마감" },
|
||||||
@@ -45,6 +47,12 @@ export default function InboundByDatePage() {
|
|||||||
|
|
||||||
const [supplierOpts, setSupplierOpts] = useState<OptionItem[]>([]);
|
const [supplierOpts, setSupplierOpts] = useState<OptionItem[]>([]);
|
||||||
const [userOpts, setUserOpts] = 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 yearOpts = useMemo(() => getYearOptions(), []);
|
||||||
|
|
||||||
const fetchList = useCallback(async (override?: Partial<PurchaseListFilter>) => {
|
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: "project_no", label: "프로젝트번호", width: "w-[135px]", align: "center" },
|
||||||
{ key: "component_part_no", label: "부품품번", width: "w-[135px]" },
|
{ key: "component_part_no", label: "부품품번", width: "w-[135px]" },
|
||||||
{ key: "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: "partner_name", label: "공급업체", minWidth: "min-w-[150px]" },
|
||||||
{ key: "currency_name", label: "환종", width: "w-[80px]", align: "center" },
|
{ key: "currency_name", label: "환종", width: "w-[80px]", align: "center" },
|
||||||
{ key: "receipt_date", label: "입고일", width: "w-[110px]", align: "center" },
|
{ key: "receipt_date", label: "입고일", width: "w-[110px]", align: "center" },
|
||||||
@@ -121,12 +129,55 @@ export default function InboundByDatePage() {
|
|||||||
actions={<>
|
actions={<>
|
||||||
<Button size="sm" variant="outline" className="h-8 gap-1 px-2 text-xs"
|
<Button size="sm" variant="outline" className="h-8 gap-1 px-2 text-xs"
|
||||||
disabled={checkedIds.length === 0}
|
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" /> 마감정보입력
|
<FileEdit className="h-3.5 w-3.5" /> 마감정보입력
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="default" className="h-8 gap-1 px-2 text-xs"
|
<Button size="sm" variant="default" className="h-8 gap-1 px-2 text-xs"
|
||||||
disabled={checkedIds.length === 0}
|
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" /> 매입마감
|
<Lock className="h-3.5 w-3.5" /> 매입마감
|
||||||
</Button>
|
</Button>
|
||||||
</>}
|
</>}
|
||||||
@@ -212,6 +263,15 @@ export default function InboundByDatePage() {
|
|||||||
}}
|
}}
|
||||||
showChart
|
showChart
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DeadlineInfoDialog
|
||||||
|
open={deadlineOpen}
|
||||||
|
objIds={deadlineObjIds}
|
||||||
|
prefill={deadlinePrefill}
|
||||||
|
onClose={() => setDeadlineOpen(false)}
|
||||||
|
onSaved={() => { setDeadlineOpen(false); fetchList(); }}
|
||||||
|
/>
|
||||||
|
{ConfirmDialogComponent}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export default function InboundByItemPage() {
|
|||||||
{ key: "project_no", label: "프로젝트번호", width: "w-[135px]", align: "center" },
|
{ key: "project_no", label: "프로젝트번호", width: "w-[135px]", align: "center" },
|
||||||
{ key: "component_part_no", label: "부품품번", width: "w-[140px]" },
|
{ key: "component_part_no", label: "부품품번", width: "w-[140px]" },
|
||||||
{ key: "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: "partner_name", label: "공급업체", minWidth: "min-w-[150px]" },
|
||||||
{ key: "currency_name", label: "환종", width: "w-[80px]", align: "center" },
|
{ key: "currency_name", label: "환종", width: "w-[80px]", align: "center" },
|
||||||
{ key: "delivery_request_date", label: "입고요청일", width: "w-[115px]", 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 { PageHeader } from "@/components/common/PageHeader";
|
||||||
import { purchaseApi, PurchaseListFilter, OptionItem, getYearOptions } from "@/lib/api/purchase";
|
import { purchaseApi, PurchaseListFilter, OptionItem, getYearOptions } from "@/lib/api/purchase";
|
||||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
import { InboundFormDialog } from "@/components/purchase/InboundFormDialog";
|
||||||
|
|
||||||
const DELIVERY_STATUS_OPTS: SmartSelectOption[] = [
|
const DELIVERY_STATUS_OPTS: SmartSelectOption[] = [
|
||||||
{ code: "입고중", label: "입고중" },
|
{ code: "입고중", label: "입고중" },
|
||||||
@@ -47,6 +48,10 @@ export default function InboundPage() {
|
|||||||
const [supplierOpts, setSupplierOpts] = useState<OptionItem[]>([]);
|
const [supplierOpts, setSupplierOpts] = useState<OptionItem[]>([]);
|
||||||
const [userOpts, setUserOpts] = useState<OptionItem[]>([]);
|
const [userOpts, setUserOpts] = useState<OptionItem[]>([]);
|
||||||
|
|
||||||
|
// 입고등록 다이얼로그
|
||||||
|
const [inboundOpen, setInboundOpen] = useState(false);
|
||||||
|
const [inboundPomObjid, setInboundPomObjid] = useState("");
|
||||||
|
|
||||||
const yearOpts = useMemo(() => getYearOptions(), []);
|
const yearOpts = useMemo(() => getYearOptions(), []);
|
||||||
|
|
||||||
const fetchList = useCallback(async (override?: Partial<PurchaseListFilter>) => {
|
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: "purchase_order_no", label: "발주서 No", width: "w-[125px]", align: "center" },
|
||||||
{ key: "project_no", label: "프로젝트번호", width: "w-[135px]", align: "center" },
|
{ key: "project_no", label: "프로젝트번호", width: "w-[135px]", align: "center" },
|
||||||
{ key: "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: "partner_name", label: "공급업체", minWidth: "min-w-[150px]" },
|
||||||
{ key: "currency_name", label: "환종", width: "w-[80px]", align: "center" },
|
{ key: "currency_name", label: "환종", width: "w-[80px]", align: "center" },
|
||||||
{ key: "writer_name", label: "구매담당자", width: "w-[110px]", align: "center" },
|
{ key: "writer_name", label: "구매담당자", width: "w-[110px]", align: "center" },
|
||||||
@@ -119,7 +124,13 @@ export default function InboundPage() {
|
|||||||
actions={
|
actions={
|
||||||
<Button size="sm" variant="default" className="h-8 gap-1 px-2 text-xs"
|
<Button size="sm" variant="default" className="h-8 gap-1 px-2 text-xs"
|
||||||
disabled={checkedIds.length !== 1}
|
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" /> 입고등록
|
<PackagePlus className="h-3.5 w-3.5" /> 입고등록
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
@@ -211,6 +222,13 @@ export default function InboundPage() {
|
|||||||
}}
|
}}
|
||||||
showChart
|
showChart
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<InboundFormDialog
|
||||||
|
open={inboundOpen}
|
||||||
|
pomObjid={inboundPomObjid}
|
||||||
|
onClose={() => setInboundOpen(false)}
|
||||||
|
onSaved={() => { setInboundOpen(false); fetchList(); }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ function SortableHeaderCell({
|
|||||||
col, sortKey, sortDir, onSort,
|
col, sortKey, sortDir, onSort,
|
||||||
headerFilterValues, uniqueValues, onToggleFilter, onClearFilter,
|
headerFilterValues, uniqueValues, onToggleFilter, onClearFilter,
|
||||||
frozenLeftClass = "left-0",
|
frozenLeftClass = "left-0",
|
||||||
|
frozenLeftPx,
|
||||||
widthPx, onResizeStart,
|
widthPx, onResizeStart,
|
||||||
}: {
|
}: {
|
||||||
col: DataGridColumn;
|
col: DataGridColumn;
|
||||||
@@ -140,6 +141,8 @@ function SortableHeaderCell({
|
|||||||
onToggleFilter: (colKey: string, value: string) => void;
|
onToggleFilter: (colKey: string, value: string) => void;
|
||||||
onClearFilter: (colKey: string) => void;
|
onClearFilter: (colKey: string) => void;
|
||||||
frozenLeftClass?: string;
|
frozenLeftClass?: string;
|
||||||
|
/** 다중 frozen 누적 left 픽셀 (지정 시 frozenLeftClass 무시) */
|
||||||
|
frozenLeftPx?: number;
|
||||||
/** 사용자 리사이즈로 결정된 현재 너비(px). 없으면 col.width Tailwind 클래스 사용 */
|
/** 사용자 리사이즈로 결정된 현재 너비(px). 없으면 col.width Tailwind 클래스 사용 */
|
||||||
widthPx?: number;
|
widthPx?: number;
|
||||||
/** 리사이즈 핸들 mousedown 핸들러 */
|
/** 리사이즈 핸들 mousedown 핸들러 */
|
||||||
@@ -148,7 +151,11 @@ function SortableHeaderCell({
|
|||||||
const [filterSearch, setFilterSearch] = useState("");
|
const [filterSearch, setFilterSearch] = useState("");
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: col.key });
|
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),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
opacity: isDragging ? 0.5 : 1,
|
opacity: isDragging ? 0.5 : 1,
|
||||||
@@ -159,6 +166,9 @@ function SortableHeaderCell({
|
|||||||
style.minWidth = widthPx;
|
style.minWidth = widthPx;
|
||||||
style.maxWidth = widthPx;
|
style.maxWidth = widthPx;
|
||||||
}
|
}
|
||||||
|
if (col.frozen && frozenLeftPx != null) {
|
||||||
|
style.left = frozenLeftPx;
|
||||||
|
}
|
||||||
|
|
||||||
const isSorted = sortKey === col.key;
|
const isSorted = sortKey === col.key;
|
||||||
const hasFilter = headerFilterValues.size > 0;
|
const hasFilter = headerFilterValues.size > 0;
|
||||||
@@ -171,7 +181,7 @@ function SortableHeaderCell({
|
|||||||
className={cn(
|
className={cn(
|
||||||
widthPx == null && col.width, widthPx == null && col.minWidth,
|
widthPx == null && col.width, widthPx == null && col.minWidth,
|
||||||
"select-none relative group/th !px-1.5",
|
"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">
|
<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 stickyFirstColBodyClass = "sticky left-0 z-[6]";
|
||||||
const frozenLeftClass = hasFirstCol ? "left-10" : "left-0";
|
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로 지정.
|
// 컬럼 settings dropdown — 데이터/시스템 그룹 분리. 시스템 키는 systemColumnKeys로 지정.
|
||||||
const systemKeySet = useMemo(() => new Set(systemColumnKeys ?? []), [systemColumnKeys]);
|
const systemKeySet = useMemo(() => new Set(systemColumnKeys ?? []), [systemColumnKeys]);
|
||||||
const dataCols = useMemo(() => columns.filter((c) => !systemKeySet.has(c.key)), [columns, systemKeySet]);
|
const dataCols = useMemo(() => columns.filter((c) => !systemKeySet.has(c.key)), [columns, systemKeySet]);
|
||||||
@@ -908,6 +931,7 @@ export function DataGrid({
|
|||||||
onToggleFilter={toggleHeaderFilter}
|
onToggleFilter={toggleHeaderFilter}
|
||||||
onClearFilter={clearHeaderFilter}
|
onClearFilter={clearHeaderFilter}
|
||||||
frozenLeftClass={frozenLeftClass}
|
frozenLeftClass={frozenLeftClass}
|
||||||
|
frozenLeftPx={frozenLeftPxMap[col.key]}
|
||||||
widthPx={columnWidths[col.key]}
|
widthPx={columnWidths[col.key]}
|
||||||
onResizeStart={startResize}
|
onResizeStart={startResize}
|
||||||
/>
|
/>
|
||||||
@@ -987,19 +1011,22 @@ export function DataGrid({
|
|||||||
)}
|
)}
|
||||||
{visibleColumns.map((col) => {
|
{visibleColumns.map((col) => {
|
||||||
const w = columnWidths[col.key];
|
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 외)
|
// 일반 텍스트 셀에 컬럼 단위 onClick 지원 (clip/folder 외)
|
||||||
const cellClickable = !!col.onClick && !col.editable;
|
const cellClickable = !!col.onClick && !col.editable;
|
||||||
return (
|
return (
|
||||||
<TableCell
|
<TableCell
|
||||||
key={col.key}
|
key={col.key}
|
||||||
style={inlineStyle}
|
style={Object.keys(inlineStyle).length > 0 ? inlineStyle : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
w == null && col.width, w == null && col.minWidth, "py-1",
|
w == null && col.width, w == null && col.minWidth, "py-1",
|
||||||
col.editable && "cursor-text",
|
col.editable && "cursor-text",
|
||||||
cellClickable && "cursor-pointer hover:underline text-primary",
|
cellClickable && "cursor-pointer hover:underline text-primary",
|
||||||
isSelected && "bg-accent",
|
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}
|
onClick={cellClickable ? (e) => { e.stopPropagation(); col.onClick!(row); } : undefined}
|
||||||
onDoubleClick={(e) => {
|
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;
|
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 = {
|
export const purchaseApi = {
|
||||||
// 그리드 7종
|
// 그리드 7종
|
||||||
listPurchaseRequest: (f: PurchaseListFilter = {}) => getList("purchase-request", f),
|
listPurchaseRequest: (f: PurchaseListFilter = {}) => getList("purchase-request", f),
|
||||||
@@ -154,6 +218,32 @@ export const purchaseApi = {
|
|||||||
return r.data as { success: boolean; message: string; objid?: string };
|
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[]> {
|
async listSuppliers(): Promise<OptionItem[]> {
|
||||||
const r = await apiClient.get("/purchase/options/suppliers");
|
const r = await apiClient.get("/purchase/options/suppliers");
|
||||||
|
|||||||
Reference in New Issue
Block a user