Files
wace_rps/frontend/components/purchase/InboundFormDialog.tsx
T
hjjeong e51f5f7b69 구매관리 입고관리 입고등록 + 입고일별 마감정보입력 + 매입마감
- 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 은 별도 작업
2026-05-20 10:04:39 +09:00

392 lines
17 KiB
TypeScript

"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>
);
}