Files
wace_rps/frontend/components/material/IssueDispatchDialog.tsx
T
hjjeong aacbb62ad8 자재관리 2메뉴 풀-CRUD + 액션 (자재리스트 + 불출의뢰서)
- 신규 테이블 5종 (운영 11133 → RPS 11134 DDL 1:1):
  inventory_mgmt / inventory_mgmt_in / inventory_mgmt_out /
  inventory_mgmt_out_master / inventory_mgmt_history
- 백엔드 /api/inventory-mng — 리스트·재고등록·자재이동·삭제·이력 +
  불출의뢰 생성·접수·자재불출(재고 차감)·삭제. 채번 Rfw-YYYY-seq.
- 프론트 /COMPANY_16/material/{list, issue-request} +
  StockRegister / MaterialMove / IssueRequestCreate /
  InventoryHistory / IssueDispatch 다이얼로그 5종.
- AdminPageRenderer 등록 + /material/ prefix.
2026-05-19 11:25:15 +09:00

229 lines
9.0 KiB
TypeScript

"use client";
// 자재관리 > 불출의뢰서 — 자재불출 처리 다이얼로그
// wace 1:1: materialRequestDetailPopUp.jsp / acceptInventoryRequestInfo.do
// 라인별 OUT_QTY / OUT_DATE / ACQ_USER 입력 후 OUTSTATUS='complete' 처리.
// 읽기전용(조회) 모드도 지원.
import React, { useEffect, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Save, X } from "lucide-react";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import { NumberInput } from "@/components/common/NumberInput";
import { DateInput } from "@/components/common/DateInput";
import { SmartSelect } from "@/components/common/SmartSelect";
import { inventoryMngApi, OptionItem } from "@/lib/api/inventoryMng";
interface LineRow {
objid: string;
part_no: string;
part_name: string;
material: string;
spec: string;
location: string;
sub_location: string;
request_qty: string;
out_qty: number | "";
out_date: string;
acq_user: string;
writer_name: string;
}
interface Props {
open: boolean;
onClose: () => void;
onSaved: () => void;
masterObjid: string;
readOnly?: boolean; // 미접수/완료 상태에서 조회만
}
export function IssueDispatchDialog({ open, onClose, onSaved, masterObjid, readOnly }: Props) {
const [master, setMaster] = useState<any | null>(null);
const [rows, setRows] = useState<LineRow[]>([]);
const [userOpts, setUserOpts] = useState<OptionItem[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [bulkUser, setBulkUser] = useState("");
const [bulkDate, setBulkDate] = useState(new Date().toISOString().slice(0, 10));
useEffect(() => {
if (!open || !masterObjid) return;
setMaster(null);
setRows([]);
setLoading(true);
(async () => {
try {
const [detail, users] = await Promise.all([
inventoryMngApi.getIssue(masterObjid),
inventoryMngApi.users(),
]);
setMaster(detail.master);
setRows((detail.lines ?? []).map((l: any) => ({
objid: l.objid,
part_no: l.part_no ?? "",
part_name: l.part_name ?? "",
material: l.material ?? "",
spec: l.spec ?? "",
location: l.location ?? "",
sub_location: l.sub_location ?? "",
request_qty: l.request_qty ?? "0",
out_qty: l.out_qty ? Number(l.out_qty) : "",
out_date: l.out_date ?? "",
acq_user: l.acq_user ?? "",
writer_name: l.writer_name ?? "",
})));
setUserOpts(users);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
} finally {
setLoading(false);
}
})();
}, [open, masterObjid]);
const handleRow = (idx: number, patch: Partial<LineRow>) =>
setRows(rs => rs.map((r, i) => i === idx ? { ...r, ...patch } : r));
const handleApplyBulk = () => {
setRows(rs => rs.map(r => ({
...r,
acq_user: bulkUser || r.acq_user,
out_date: bulkDate || r.out_date,
})));
};
const handleSave = async () => {
const lines = rows.filter(r => r.out_qty !== "" && Number(r.out_qty) > 0);
if (!lines.length) return toast.info("불출수량을 입력해주세요.");
for (const r of lines) {
if (Number(r.out_qty) > Number(r.request_qty)) {
return toast.warning(`${r.part_no}: 의뢰수량(${r.request_qty}) 초과는 불가합니다.`);
}
if (!r.out_date) return toast.info(`${r.part_no}: 인계일을 입력해주세요.`);
if (!r.acq_user) return toast.info(`${r.part_no}: 인수자를 선택해주세요.`);
}
setSaving(true);
try {
await inventoryMngApi.dispatchIssue({
master_objid: masterObjid,
lines: lines.map(r => ({
objid: r.objid,
out_qty: Number(r.out_qty),
out_date: r.out_date,
acq_user: r.acq_user,
})),
});
toast.success("자재 불출 처리가 완료되었습니다.");
onSaved();
onClose();
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "불출 실패");
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-6xl">
<DialogHeader>
<DialogTitle>
{readOnly ? "불출의뢰 상세" : "자재 불출"}
{master?.inventory_out_no ? `${master.inventory_out_no}` : ""}
</DialogTitle>
<DialogDescription>
{readOnly
? "불출의뢰 라인을 조회합니다."
: "라인별 불출수량 / 인계일 / 인수자를 입력하고 저장하세요."}
</DialogDescription>
</DialogHeader>
{master && (
<div className="grid grid-cols-3 gap-2 text-xs text-muted-foreground">
<div>: <b>{master.request_date ?? "-"}</b></div>
<div>: <b>{master.request_user_name ?? master.request_id ?? "-"}</b></div>
<div>: <b>{master.reception_status === "reception" ? "접수" : "미접수"}</b>
{master.reception_date ? ` / ${master.reception_date}` : ""}</div>
<div className="col-span-3">: {master.remark || "-"}</div>
</div>
)}
{!readOnly && (
<div className="flex items-center gap-2 text-xs">
<span>:</span>
<span></span>
<div className="w-[140px]"><DateInput value={bulkDate} onChange={setBulkDate} /></div>
<span></span>
<div className="w-[180px]">
<SmartSelect options={userOpts} value={bulkUser} onValueChange={setBulkUser} />
</div>
<Button size="sm" variant="outline" onClick={handleApplyBulk}></Button>
</div>
)}
<div className="overflow-x-auto text-xs">
<table className="w-full border">
<thead className="bg-muted/50">
<tr>
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1">Location</th>
<th className="border px-2 py-1 text-right"></th>
<th className="border px-2 py-1 text-right"></th>
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1"></th>
</tr>
</thead>
<tbody>
{loading && <tr><td colSpan={9} className="border px-2 py-3 text-center"> ...</td></tr>}
{!loading && rows.length === 0 && (
<tr><td colSpan={9} className="border px-2 py-3 text-center"> .</td></tr>
)}
{rows.map((r, i) => (
<tr key={i}>
<td className="border px-2 py-1">{r.part_no}</td>
<td className="border px-2 py-1">{r.part_name}</td>
<td className="border px-2 py-1">{r.spec}</td>
<td className="border px-2 py-1">{r.material}</td>
<td className="border px-2 py-1">{r.location}{r.sub_location ? ` / ${r.sub_location}` : ""}</td>
<td className="border px-2 py-1 text-right">{Number(r.request_qty ?? 0).toLocaleString()}</td>
<td className="border px-1 py-1 w-[120px]">
{readOnly
? <span>{r.out_qty === "" ? "" : Number(r.out_qty).toLocaleString()}</span>
: <NumberInput value={r.out_qty}
onChange={(v) => handleRow(i, { out_qty: v })} />}
</td>
<td className="border px-1 py-1 w-[140px]">
{readOnly ? <span>{r.out_date}</span> :
<DateInput value={r.out_date} onChange={(v) => handleRow(i, { out_date: v })} />}
</td>
<td className="border px-1 py-1 w-[160px]">
{readOnly ? <span>{r.acq_user}</span> :
<SmartSelect options={userOpts} value={r.acq_user}
onValueChange={(v) => handleRow(i, { acq_user: v })} />}
</td>
</tr>
))}
</tbody>
</table>
</div>
<DialogFooter>
<Button size="sm" variant="ghost" onClick={onClose} disabled={saving}>
<X className="h-3.5 w-3.5 mr-1" />
</Button>
{!readOnly && (
<Button size="sm" onClick={handleSave} disabled={saving || loading}>
<Save className="h-3.5 w-3.5 mr-1" /> {saving ? "처리중..." : "자재불출"}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}