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 은 별도 작업
392 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|