구매관리 입고관리 입고등록 + 입고일별 마감정보입력 + 매입마감

- 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:
hjjeong
2026-05-20 10:04:39 +09:00
parent 17b08c7a09
commit e51f5f7b69
10 changed files with 1228 additions and 16 deletions
@@ -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>
);
}
+32 -5
View File
@@ -129,6 +129,7 @@ function SortableHeaderCell({
col, sortKey, sortDir, onSort,
headerFilterValues, uniqueValues, onToggleFilter, onClearFilter,
frozenLeftClass = "left-0",
frozenLeftPx,
widthPx, onResizeStart,
}: {
col: DataGridColumn;
@@ -140,6 +141,8 @@ function SortableHeaderCell({
onToggleFilter: (colKey: string, value: string) => void;
onClearFilter: (colKey: string) => void;
frozenLeftClass?: string;
/** 다중 frozen 누적 left 픽셀 (지정 시 frozenLeftClass 무시) */
frozenLeftPx?: number;
/** 사용자 리사이즈로 결정된 현재 너비(px). 없으면 col.width Tailwind 클래스 사용 */
widthPx?: number;
/** 리사이즈 핸들 mousedown 핸들러 */
@@ -148,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>
);
}
+90
View File
@@ -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");