Files
star-erp/resources/js/Pages/StoreRequisition/Show.tsx

709 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect } from "react";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, Link, router } from "@inertiajs/react";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Textarea } from "@/Components/ui/textarea";
import { Label } from "@/Components/ui/label";
import { StatusBadge } from "@/Components/shared/StatusBadge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/Components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/Components/ui/alert-dialog";
import { toast } from "sonner";
import { Can } from "@/Components/Permission/Can";
import { usePermission } from "@/hooks/usePermission";
import {
Store,
SendHorizontal,
CheckCircle2,
XCircle,
Pencil,
Loader2,
ArrowLeft,
} from "lucide-react";
import { formatDate } from "@/lib/date";
function getStatusBadge(status: string) {
const statusMap: Record<string, { label: string; variant: "neutral" | "warning" | "success" | "destructive" | "info" }> = {
draft: { label: "草稿", variant: "neutral" },
pending: { label: "待審核", variant: "warning" },
approved: { label: "已核准", variant: "success" },
rejected: { label: "已駁回", variant: "destructive" },
completed: { label: "已完成", variant: "success" },
cancelled: { label: "已取消", variant: "neutral" },
};
const config = statusMap[status];
if (!config) return <StatusBadge variant="neutral">{status}</StatusBadge>;
return (
<StatusBadge variant={config.variant}>
{config.label}
</StatusBadge>
);
}
interface RequisitionItem {
id: number;
product_id: number;
product_name: string;
product_code: string;
unit_name: string;
requested_qty: number;
approved_qty: number | null;
current_stock: number;
supply_stock: number | null;
supply_batches?: { inventory_id: number; batch_number: string | null; position: string | null; available_qty: number; expiry_date: string | null }[];
approved_batches?: { batch_number: string | null; qty: number }[];
remark: string | null;
}
interface Requisition {
id: number;
doc_no: string;
status: string;
store_warehouse_id: number;
store_warehouse_name: string;
supply_warehouse_id: number | null;
supply_warehouse_name: string;
remark: string | null;
reject_reason: string | null;
creator_name: string;
approver_name: string;
submitted_at: string | null;
approved_at: string | null;
transfer_order_id: number | null;
created_at: string;
items: RequisitionItem[];
}
interface Props {
requisition: Requisition;
warehouses: { id: number; name: string }[];
activities: any[];
}
export default function Show({ requisition, warehouses }: Props) {
usePermission();
const [submitting, setSubmitting] = useState(false);
const [approving, setApproving] = useState(false);
const [rejecting, setRejecting] = useState(false);
// 核准狀態 (支援批號分配)
const [approvedItems, setApprovedItems] = useState<{
id: number;
batches: { inventory_id: number | null; batch_number: string | null; qty: string }[];
}[]>(
requisition.items.map((item) => {
// 如果有批號,預設給空的陣列讓使用者自己填寫
if (item.supply_batches && item.supply_batches.length > 0) {
return {
id: item.id,
batches: item.supply_batches.map(b => ({ inventory_id: b.inventory_id, batch_number: b.batch_number, qty: "" }))
};
}
// 無批號時,退回傳統單一輸入
return {
id: item.id,
batches: [{ inventory_id: null, batch_number: null, qty: Number(item.requested_qty).toString() }],
};
})
);
// 當 requisition.items 變動時 (例如更換供貨倉庫導致 supply_batches 更新),同步更新核准狀態
useEffect(() => {
setApprovedItems(
requisition.items.map((item) => {
if (item.supply_batches && item.supply_batches.length > 0) {
return {
id: item.id,
batches: item.supply_batches.map(b => ({
inventory_id: b.inventory_id,
batch_number: b.batch_number,
qty: ""
}))
};
}
return {
id: item.id,
batches: [{ inventory_id: null, batch_number: null, qty: Number(item.requested_qty).toString() }],
};
})
);
}, [requisition.items]);
// 駁回狀態
const [showRejectDialog, setShowRejectDialog] = useState(false);
const [rejectReason, setRejectReason] = useState("");
// 提交確認
const [showSubmitDialog, setShowSubmitDialog] = useState(false);
const handleSubmit = () => {
setSubmitting(true);
router.post(route("store-requisitions.submit", [requisition.id]), {}, {
onFinish: () => {
setSubmitting(false);
setShowSubmitDialog(false);
},
});
};
const handleApprove = () => {
// 確認每個核准數量與庫存上限
for (const item of approvedItems) {
const originalItem = requisition.items.find(i => i.id === item.id);
if (!originalItem) continue;
for (const batch of item.batches) {
if (batch.qty !== "") {
const qty = parseFloat(batch.qty);
if (isNaN(qty) || qty < 0) {
toast.error("核准數量不能為負數或無效數字");
return;
}
// 檢查是否超過批號最大可用庫存
if (batch.inventory_id && originalItem.supply_batches) {
const originalBatch = originalItem.supply_batches.find(b => b.inventory_id === batch.inventory_id);
if (originalBatch && qty > originalBatch.available_qty) {
toast.error(`${originalItem.product_name}」批號 ${originalBatch.batch_number || '無批號'} 數量不可大於庫存上限 (${originalBatch.available_qty})`);
return;
}
} else if (batch.inventory_id === null) {
// 無批號情境:檢查總可用庫存
if (originalItem.supply_stock !== null && qty > originalItem.supply_stock) {
toast.error(`${originalItem.product_name}」數量不可大於供貨倉庫存上限 (${originalItem.supply_stock})`);
return;
}
}
}
}
}
setApproving(true);
router.post(
route("store-requisitions.approve", [requisition.id]),
{
items: approvedItems.map((item) => {
// 計算總數,提供給沒有批號情境的相容性,或作為總數參考
const totalQty = item.batches.reduce((sum, b) => sum + (parseFloat(b.qty) || 0), 0);
return {
id: item.id,
approved_qty: totalQty,
batches: item.batches.filter(b => b.qty !== "" && parseFloat(b.qty) > 0).map(b => ({
inventory_id: b.inventory_id,
batch_number: b.batch_number,
qty: parseFloat(b.qty)
}))
};
}),
},
{
onFinish: () => {
setApproving(false);
},
}
);
};
const handleReject = () => {
if (!rejectReason.trim()) {
toast.error("請填寫駁回原因");
return;
}
setRejecting(true);
router.post(
route("store-requisitions.reject", [requisition.id]),
{ reject_reason: rejectReason },
{
onFinish: () => {
setRejecting(false);
setShowRejectDialog(false);
},
}
);
};
const updateApprovedBatchQty = (itemId: number, inventoryId: number | null, qty: string) => {
setApprovedItems(prev => prev.map(item => {
if (item.id === itemId) {
return {
...item,
batches: item.batches.map(b => b.inventory_id === inventoryId ? { ...b, qty } : b)
};
}
return item;
}));
};
const isEditable = ["draft", "rejected"].includes(requisition.status);
const isPending = requisition.status === "pending";
const canApprove = usePermission().can("store_requisitions.approve");
const handleUpdateSupplyWarehouse = (warehouseId: string) => {
setSubmitting(true);
router.patch(
route("store-requisitions.update-supply-warehouse", [requisition.id]),
{ supply_warehouse_id: warehouseId },
{
onFinish: () => setSubmitting(false),
onSuccess: () => toast.success("供貨倉庫已更新"),
preserveScroll: true,
}
);
};
return (
<AuthenticatedLayout
breadcrumbs={[
{ label: "商品與庫存管理", href: "#" },
{ label: "門市叫貨", href: route("store-requisitions.index") },
{ label: requisition.doc_no, href: "#", isPage: true },
]}
>
<Head title={`叫貨單 ${requisition.doc_no}`} />
<div className="container mx-auto p-6 max-w-7xl">
{/* 返回按鈕 */}
<div className="mb-6">
<Link href={route("store-requisitions.index")}>
<Button variant="outline" className="gap-2 button-outlined-primary">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
</div>
{/* 頁面標題與操作 */}
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4 mb-6">
<div className="space-y-2">
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Store className="h-6 w-6 text-primary-main" />
: {requisition.doc_no}
</h1>
{getStatusBadge(requisition.status)}
</div>
<p className="text-sm text-gray-500 mt-1 font-medium flex items-center gap-2">
: {requisition.store_warehouse_name} <span className="mx-1">|</span>
: {requisition.creator_name} <span className="mx-1">|</span>
: {formatDate(requisition.created_at)}
{requisition.transfer_order_id && (
<>
<span className="mx-1">|</span>
<Link
href={`${route("inventory.transfer.show", [requisition.transfer_order_id])}?from=requisition&from_id=${requisition.id}&from_doc=${encodeURIComponent(requisition.doc_no)}`}
className="text-primary-main hover:underline flex items-center gap-1"
>
調: {requisition.transfer_order_id}
</Link>
</>
)}
</p>
</div>
{/* 操作按鈕 */}
<div className="flex flex-wrap gap-2">
{isEditable && (
<>
<Can permission="store_requisitions.edit">
<Link href={route("store-requisitions.edit", [requisition.id])}>
<Button variant="outline" className="button-outlined-primary">
<Pencil className="w-4 h-4 mr-1" />
</Button>
</Link>
</Can>
{requisition.status === "draft" && (
<Can permission="store_requisitions.view">
<Button
className="button-filled-primary"
onClick={() => setShowSubmitDialog(true)}
>
<SendHorizontal className="w-4 h-4 mr-1" />
</Button>
</Can>
)}
</>
)}
{isPending && (
<>
<Can permission="store_requisitions.approve">
<Button
variant="outline"
className="button-outlined-error"
onClick={() => setShowRejectDialog(true)}
>
<XCircle className="w-4 h-4 mr-1" />
</Button>
<Button
className="button-filled-success"
onClick={handleApprove}
disabled={approving || !requisition.supply_warehouse_id}
>
{approving && <Loader2 className="w-4 h-4 mr-1 animate-spin" />}
<CheckCircle2 className="w-4 h-4 mr-1" />
</Button>
</Can>
</>
)}
</div>
</div>
{/* 基本資訊 */}
<div className="bg-white rounded-lg shadow-sm border p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4 border-b pb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-6">
<div>
<span className="text-sm text-gray-500 block mb-1"></span>
<div className="mt-1">
{isPending && canApprove ? (
<SearchableSelect
value={requisition.supply_warehouse_id?.toString() || ""}
onValueChange={handleUpdateSupplyWarehouse}
options={warehouses
.filter((w) => w.id !== requisition.store_warehouse_id)
.map((w) => ({
label: w.name,
value: w.id.toString(),
}))}
placeholder="選擇供貨倉庫"
className="h-9 w-full max-w-[200px]"
disabled={submitting}
/>
) : (
<p className="font-medium text-gray-800">
{requisition.supply_warehouse_name || "-"}
</p>
)}
</div>
</div>
{requisition.submitted_at && (
<div>
<span className="text-sm text-gray-500 block mb-1"></span>
<p className="font-medium text-gray-800">
{formatDate(requisition.submitted_at)}
</p>
</div>
)}
{requisition.approved_at && (
<>
<div>
<span className="text-sm text-gray-500 block mb-1"></span>
<p className="font-medium text-gray-800">
{requisition.approver_name}
</p>
</div>
<div>
<span className="text-sm text-gray-500 block mb-1"></span>
<p className="font-medium text-gray-800">
{formatDate(requisition.approved_at)}
</p>
</div>
</>
)}
{requisition.remark && (
<div className="md:col-span-3">
<span className="text-sm text-gray-500 block mb-1"></span>
<p className="font-medium text-gray-800">
{requisition.remark}
</p>
</div>
)}
{requisition.reject_reason && (
<div className="md:col-span-3">
<span className="text-sm text-red-500 font-medium"></span>
<p className="text-red-600 bg-red-50 rounded-md p-3 mt-1">
{requisition.reject_reason}
</p>
</div>
)}
{requisition.transfer_order_id && (
<div>
<span className="text-sm text-gray-500">調</span>
<p className="mt-1">
<Link
href={`${route("inventory.transfer.show", [requisition.transfer_order_id])}?from=requisition&from_id=${requisition.id}&from_doc=${encodeURIComponent(requisition.doc_no)}`}
className="text-primary-main hover:underline font-medium"
>
調
</Link>
</p>
</div>
)}
</div>
</div>
{/* 商品明細 */}
<div className="bg-white rounded-lg shadow-sm border p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4"></h2>
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[50px] text-center font-medium text-gray-600">
#
</TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="text-right font-medium text-gray-600">
</TableHead>
{requisition.supply_warehouse_id && (
<TableHead className="text-right font-medium text-gray-600">
</TableHead>
)}
<TableHead className="text-right font-medium text-gray-600">
</TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
{["approved", "completed"].includes(requisition.status) && (
<TableHead className="text-right font-medium text-gray-600">
</TableHead>
)}
{isPending && canApprove && (
<TableHead className="font-medium text-gray-600">
</TableHead>
)}
<TableHead className="font-medium text-gray-600"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{requisition.items.map((item, index) => {
const approvedItem = approvedItems.find((ai) => ai.id === item.id);
// 判斷是否為「多批號」情境 (審核時使用)
const hasBatches = item.supply_batches && item.supply_batches.length > 0;
// 判斷是否含有效已核准批號 (檢視時使用)
const hasApprovedBatches = item.approved_batches && item.approved_batches.some(b => b.batch_number !== null);
// 計算目前填寫的核准總量
const totalApprovedQty = approvedItem
? approvedItem.batches.reduce((sum, b) => sum + (parseFloat(b.qty) || 0), 0)
: 0;
const isOverTotalStock = item.supply_stock !== null && totalApprovedQty > item.supply_stock;
const rowClassName = (isPending && canApprove && isOverTotalStock) ? "bg-red-50/50" : "";
return (
<React.Fragment key={item.id}>
<TableRow className={rowClassName}>
<TableCell className="text-center text-gray-500 font-medium pb-2">
{index + 1}
</TableCell>
<TableCell className="font-mono text-sm text-gray-600 pb-2">
{item.product_code}
</TableCell>
<TableCell className="font-medium text-gray-800 pb-2">
{item.product_name}
{hasBatches && isPending && canApprove && (
<div className="text-xs text-blue-500 mt-1"> </div>
)}
</TableCell>
<TableCell className="text-right text-gray-600 pb-2">
{Number(item.current_stock).toLocaleString()}
</TableCell>
{requisition.supply_warehouse_id && (
<TableCell className="text-right text-gray-600 font-medium pb-2">
{item.supply_stock !== null ? Number(item.supply_stock).toLocaleString() : "-"}
</TableCell>
)}
<TableCell className="text-right font-medium text-gray-800 pb-2">
{Number(item.requested_qty).toLocaleString()}
</TableCell>
<TableCell className="text-gray-500 pb-2">{item.unit_name}</TableCell>
{["approved", "completed"].includes(requisition.status) && (
<TableCell className="text-right font-medium text-green-600 pb-2">
{item.approved_qty !== null
? Number(item.approved_qty).toLocaleString()
: "-"}
</TableCell>
)}
{isPending && canApprove && (
<TableCell className="pb-2">
{!hasBatches ? (
<Input
type="number"
step="1"
min="0"
value={approvedItem?.batches[0]?.qty || ""}
onChange={(e) =>
updateApprovedBatchQty(item.id, null, e.target.value)
}
className={`h-8 text-right w-[100px] ${isOverTotalStock ? "border-red-400 text-red-600 focus-visible:ring-red-400" : ""}`}
/>
) : (
<div className="text-sm font-medium text-gray-700 text-right pr-6">
: {totalApprovedQty > 0 ? totalApprovedQty : "-"}
</div>
)}
</TableCell>
)}
<TableCell className="text-gray-500 text-sm pb-2">
{item.remark || "-"}
</TableCell>
</TableRow>
{/* 展開批號/貨道輸入子行 */}
{hasBatches && isPending && canApprove && item.supply_batches!.map((batch) => {
const batchInput = approvedItem?.batches.find(b => b.inventory_id === batch.inventory_id);
const inputQty = parseFloat(batchInput?.qty || "0");
const isBatchOverStock = inputQty > batch.available_qty;
return (
<TableRow key={`${item.id}-inv-${batch.inventory_id}`} className="bg-gray-50/50 border-0">
<TableCell colSpan={4}></TableCell>
<TableCell className="text-right text-xs text-gray-500 py-1">
{batch.batch_number ? (
<>: <span className="font-mono text-gray-700">{batch.batch_number}</span><br /></>
) : (
<><br /></>
)}
{batch.position && (
<>: <span className="font-medium text-gray-700">{batch.position}</span><br /></>
)}
(: {Number(batch.available_qty)})<br />
{batch.expiry_date && <span className="text-gray-400">: {batch.expiry_date}</span>}
</TableCell>
<TableCell colSpan={2}></TableCell>
<TableCell className="py-1">
<Input
type="number"
step="1"
min="0"
value={batchInput?.qty || ""}
onChange={(e) => updateApprovedBatchQty(item.id, batch.inventory_id, e.target.value)}
placeholder="輸入數量"
className={`h-7 text-right w-[100px] text-xs ${isBatchOverStock ? "border-red-400 text-red-600 focus-visible:ring-red-400 bg-red-50" : ""}`}
/>
</TableCell>
<TableCell></TableCell>
</TableRow>
);
})}
{/* 展開已核准批號子行 (檢視用) */}
{["approved", "completed"].includes(requisition.status) && hasApprovedBatches && item.approved_batches!.filter(b => b.batch_number).map((batch, bIndex) => (
<TableRow key={`${item.id}-approved-batch-${batch.batch_number}-${bIndex}`} className="bg-green-50/40 border-0">
<TableCell colSpan={5}></TableCell>
<TableCell colSpan={2} className="text-right text-xs text-gray-500 py-1 pr-6">
: <span className="font-mono text-gray-700">{batch.batch_number}</span>
</TableCell>
<TableCell className="text-right text-xs font-medium py-1 text-green-700 pr-4">
{Number(batch.qty).toLocaleString()}
</TableCell>
<TableCell colSpan={2}></TableCell>
</TableRow>
))}
</React.Fragment>
);
})}
</TableBody>
</Table>
</div>
</div>
</div>
{/* 提交確認 */}
<AlertDialog open={showSubmitDialog} onOpenChange={setShowSubmitDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleSubmit}
className="button-filled-primary"
disabled={submitting}
>
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 駁回對話框 */}
<Dialog open={showRejectDialog} onOpenChange={setShowRejectDialog}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="py-2">
<Label>
<span className="text-red-500">*</span>
</Label>
<Textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder="請填寫駁回原因..."
rows={4}
className="mt-2"
/>
</div>
<DialogFooter>
<Button
variant="outline"
className="button-outlined-primary"
onClick={() => setShowRejectDialog(false)}
>
</Button>
<Button
variant="destructive"
onClick={handleReject}
disabled={rejecting || !rejectReason.trim()}
>
{rejecting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</AuthenticatedLayout>
);
}