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

610 lines
29 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 { useState } 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;
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 [showApproveDialog, setShowApproveDialog] = useState(false);
const [approvedItems, setApprovedItems] = useState<{ id: number; approved_qty: string }[]>(
requisition.items.map((item) => ({
id: item.id,
approved_qty: item.requested_qty.toString(),
}))
);
// 駁回狀態
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 qty = parseFloat(item.approved_qty);
if (isNaN(qty) || qty < 0) {
toast.error("核准數量不能為負數");
return;
}
}
setApproving(true);
router.post(
route("store-requisitions.approve", [requisition.id]),
{
items: approvedItems.map((item) => ({
id: item.id,
approved_qty: parseFloat(item.approved_qty),
})),
},
{
onFinish: () => {
setApproving(false);
setShowApproveDialog(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 updateApprovedQty = (itemId: number, qty: string) => {
setApprovedItems(
approvedItems.map((item) => (item.id === itemId ? { ...item, approved_qty: qty } : 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={() => setShowApproveDialog(true)}
>
<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>
<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>
)}
<TableHead className="font-medium text-gray-600"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{requisition.items.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-center text-gray-500 font-medium">
{index + 1}
</TableCell>
<TableCell className="font-mono text-sm text-gray-600">
{item.product_code}
</TableCell>
<TableCell className="font-medium text-gray-800">
{item.product_name}
</TableCell>
<TableCell className="text-right text-gray-600">
{Number(item.current_stock).toLocaleString()}
</TableCell>
<TableCell className="text-right font-medium text-gray-800">
{Number(item.requested_qty).toLocaleString()}
</TableCell>
<TableCell className="text-gray-500">{item.unit_name}</TableCell>
{["approved", "completed"].includes(requisition.status) && (
<TableCell className="text-right font-medium text-green-600">
{item.approved_qty !== null
? Number(item.approved_qty).toLocaleString()
: "-"}
</TableCell>
)}
<TableCell className="text-gray-500 text-sm">
{item.remark || "-"}
</TableCell>
</TableRow>
))}
</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={showApproveDialog} onOpenChange={setShowApproveDialog}>
<DialogContent className="sm:max-w-[700px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="flex items-center justify-between p-3 bg-blue-50 border border-blue-100 rounded-lg">
<div className="flex items-center gap-2">
<span className="text-blue-700 font-medium"></span>
<span className="text-blue-900 font-bold">{requisition.supply_warehouse_name || "尚未選擇"}</span>
</div>
{!requisition.supply_warehouse_id && (
<span className="text-xs text-red-500 font-medium">* </span>
)}
</div>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="text-right font-medium text-gray-600 w-[120px]">
</TableHead>
<TableHead className="font-medium text-gray-600 w-[80px]"></TableHead>
<TableHead className="font-medium text-gray-600 w-[150px]">
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{requisition.items.map((item) => (
<TableRow key={item.id}>
<TableCell>
<span className="font-mono text-xs text-gray-500">
{item.product_code}
</span>
<span className="ml-2 text-gray-800">{item.product_name}</span>
</TableCell>
<TableCell className="text-right text-gray-700">
{Number(item.requested_qty).toLocaleString()}
</TableCell>
<TableCell className="text-gray-500 text-sm">
{item.unit_name}
</TableCell>
<TableCell>
<Input
type="number"
step="1"
min="0"
value={
approvedItems.find((ai) => ai.id === item.id)
?.approved_qty || ""
}
onChange={(e) =>
updateApprovedQty(item.id, e.target.value)
}
className="h-8 text-right"
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
className="button-outlined-primary"
onClick={() => setShowApproveDialog(false)}
>
</Button>
<Button
className="bg-green-600 hover:bg-green-700 text-white"
onClick={handleApprove}
disabled={approving || !requisition.supply_warehouse_id}
>
{approving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 駁回對話框 */}
<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>
);
}