first commit
This commit is contained in:
422
resources/js/Pages/Warehouse/AddInventory.tsx
Normal file
422
resources/js/Pages/Warehouse/AddInventory.tsx
Normal file
@@ -0,0 +1,422 @@
|
||||
/**
|
||||
* 新增庫存頁面(手動入庫)
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Plus, Trash2, Calendar, ArrowLeft, Save } from "lucide-react";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Textarea } from "@/Components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, Link, router } from "@inertiajs/react";
|
||||
import { Warehouse, InboundItem, InboundReason } from "@/types/warehouse";
|
||||
import { getCurrentDateTime } from "@/utils/format";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
warehouse: Warehouse;
|
||||
products: Product[];
|
||||
}
|
||||
|
||||
const INBOUND_REASONS: InboundReason[] = [
|
||||
"期初建檔",
|
||||
"盤點調整",
|
||||
"實際入庫未走採購流程",
|
||||
"生產加工成品入庫",
|
||||
"其他",
|
||||
];
|
||||
|
||||
export default function AddInventoryPage({ warehouse, products }: Props) {
|
||||
const [inboundDate, setInboundDate] = useState(getCurrentDateTime());
|
||||
const [reason, setReason] = useState<InboundReason>("期初建檔");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [items, setItems] = useState<InboundItem[]>([]);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// 新增明細行
|
||||
const handleAddItem = () => {
|
||||
const defaultProduct = products.length > 0 ? products[0] : { id: "", name: "", unit: "kg" };
|
||||
const newItem: InboundItem = {
|
||||
tempId: `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
productId: defaultProduct.id,
|
||||
productName: defaultProduct.name,
|
||||
quantity: 0,
|
||||
unit: defaultProduct.unit,
|
||||
};
|
||||
setItems([...items, newItem]);
|
||||
};
|
||||
|
||||
// 刪除明細行
|
||||
const handleRemoveItem = (tempId: string) => {
|
||||
setItems(items.filter((item) => item.tempId !== tempId));
|
||||
};
|
||||
|
||||
// 更新明細行
|
||||
const handleUpdateItem = (tempId: string, updates: Partial<InboundItem>) => {
|
||||
setItems(
|
||||
items.map((item) =>
|
||||
item.tempId === tempId ? { ...item, ...updates } : item
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// 處理商品變更
|
||||
const handleProductChange = (tempId: string, productId: string) => {
|
||||
const product = products.find((p) => p.id === productId);
|
||||
if (product) {
|
||||
handleUpdateItem(tempId, {
|
||||
productId,
|
||||
productName: product.name,
|
||||
unit: product.unit,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 驗證表單
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!reason) {
|
||||
newErrors.reason = "請選擇入庫原因";
|
||||
}
|
||||
|
||||
if (reason === "其他" && !notes.trim()) {
|
||||
newErrors.notes = "原因為「其他」時,備註為必填";
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
newErrors.items = "請至少新增一筆庫存明細";
|
||||
}
|
||||
|
||||
items.forEach((item, index) => {
|
||||
if (!item.productId) {
|
||||
newErrors[`item-${index}-product`] = "請選擇商品";
|
||||
}
|
||||
if (item.quantity <= 0) {
|
||||
newErrors[`item-${index}-quantity`] = "數量必須大於 0";
|
||||
}
|
||||
});
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// 處理儲存
|
||||
const handleSave = () => {
|
||||
if (!validateForm()) {
|
||||
toast.error("請檢查表單內容");
|
||||
return;
|
||||
}
|
||||
|
||||
router.post(`/warehouses/${warehouse.id}/inventory`, {
|
||||
inboundDate,
|
||||
reason,
|
||||
notes,
|
||||
items: items.map(item => ({
|
||||
productId: item.productId,
|
||||
quantity: item.quantity
|
||||
}))
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success("庫存記錄已儲存");
|
||||
router.get(`/warehouses/${warehouse.id}/inventory`);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error("儲存失敗,請檢查輸入內容");
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout>
|
||||
<Head title={`新增庫存 - ${warehouse.name}`} />
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
{/* 頁面標題與導航 - 已於先前任務優化 */}
|
||||
<div className="mb-6">
|
||||
<div className="mb-6">
|
||||
<Link href={`/warehouses/${warehouse.id}/inventory`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 button-outlined-primary"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回庫存管理
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="mb-2">新增庫存(手動入庫)</h1>
|
||||
<p className="text-gray-600 font-medium">
|
||||
為 <span className="font-semibold text-gray-900">{warehouse.name}</span> 新增庫存記錄
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
className="button-filled-primary"
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
儲存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 表單內容 */}
|
||||
<div className="space-y-6">
|
||||
{/* 基本資訊區塊 */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6 space-y-4">
|
||||
<h3 className="font-semibold text-lg border-b pb-2">基本資訊</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 倉庫 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-700">倉庫</Label>
|
||||
<Input
|
||||
value={warehouse.name}
|
||||
disabled
|
||||
className="bg-gray-50 border-gray-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 入庫日期 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="inbound-date" className="text-gray-700">
|
||||
入庫日期 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="inbound-date"
|
||||
type="datetime-local"
|
||||
value={inboundDate}
|
||||
onChange={(e) => setInboundDate(e.target.value)}
|
||||
className="border-gray-300 pr-10"
|
||||
/>
|
||||
<Calendar className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 入庫原因 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reason" className="text-gray-700">
|
||||
入庫原因 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={reason} onValueChange={(value) => setReason(value as InboundReason)}>
|
||||
<SelectTrigger id="reason" className="border-gray-300">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{INBOUND_REASONS.map((r) => (
|
||||
<SelectItem key={r} value={r}>
|
||||
{r}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.reason && (
|
||||
<p className="text-sm text-red-500">{errors.reason}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 備註 */}
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor="notes" className="text-gray-700">
|
||||
備註 {reason === "其他" && <span className="text-red-500">*</span>}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="請輸入備註說明..."
|
||||
className="border-gray-300 resize-none min-h-[100px]"
|
||||
/>
|
||||
{errors.notes && (
|
||||
<p className="text-sm text-red-500">{errors.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 庫存明細區塊 */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">庫存明細</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
請新增要入庫的商品明細
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAddItem}
|
||||
variant="outline"
|
||||
className="button-outlined-primary"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新增明細
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{errors.items && (
|
||||
<p className="text-sm text-red-500">{errors.items}</p>
|
||||
)}
|
||||
|
||||
{items.length > 0 ? (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50/50">
|
||||
<TableHead className="w-[280px]">
|
||||
商品 <span className="text-red-500">*</span>
|
||||
</TableHead>
|
||||
<TableHead className="w-[120px]">
|
||||
數量 <span className="text-red-500">*</span>
|
||||
</TableHead>
|
||||
<TableHead className="w-[100px]">單位</TableHead>
|
||||
{/* <TableHead className="w-[180px]">效期</TableHead>
|
||||
<TableHead className="w-[220px]">進貨編號</TableHead> */}
|
||||
<TableHead className="w-[60px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item, index) => (
|
||||
<TableRow key={item.tempId}>
|
||||
{/* 商品 */}
|
||||
<TableCell>
|
||||
<Select
|
||||
value={item.productId}
|
||||
onValueChange={(value) =>
|
||||
handleProductChange(item.tempId, value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="border-gray-300">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{products.map((product) => (
|
||||
<SelectItem key={product.id} value={product.id}>
|
||||
{product.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors[`item-${index}-product`] && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors[`item-${index}-product`]}
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* 數量 */}
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={item.quantity || ""}
|
||||
onChange={(e) =>
|
||||
handleUpdateItem(item.tempId, {
|
||||
quantity: parseInt(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
className="border-gray-300"
|
||||
/>
|
||||
{errors[`item-${index}-quantity`] && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors[`item-${index}-quantity`]}
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* 單位 */}
|
||||
<TableCell>
|
||||
<Input
|
||||
value={item.unit}
|
||||
disabled
|
||||
className="bg-gray-50 border-gray-200"
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
{/* 效期 */}
|
||||
{/* <TableCell>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="date"
|
||||
value={item.expiryDate}
|
||||
onChange={(e) =>
|
||||
handleUpdateItem(item.tempId, {
|
||||
expiryDate: e.target.value,
|
||||
})
|
||||
}
|
||||
className="border-gray-300"
|
||||
/>
|
||||
</div>
|
||||
</TableCell> */}
|
||||
|
||||
{/* 批號 */}
|
||||
{/* <TableCell>
|
||||
<Input
|
||||
value={item.batchNumber}
|
||||
onChange={(e) =>
|
||||
handleBatchNumberChange(item.tempId, e.target.value)
|
||||
}
|
||||
className="border-gray-300"
|
||||
placeholder="系統自動生成"
|
||||
/>
|
||||
{errors[`item-${index}-batch`] && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors[`item-${index}-batch`]}
|
||||
</p>
|
||||
)}
|
||||
</TableCell> */}
|
||||
|
||||
{/* 刪除按鈕 */}
|
||||
<TableCell>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveItem(item.tempId)}
|
||||
className="hover:bg-red-50 hover:text-red-600 h-8 w-8"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-dashed rounded-lg p-12 text-center text-gray-500 bg-gray-50/30">
|
||||
<p className="text-base font-medium">尚無明細</p>
|
||||
<p className="text-sm mt-1">請點擊右上方「新增明細」按鈕加入商品</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
269
resources/js/Pages/Warehouse/EditInventory.tsx
Normal file
269
resources/js/Pages/Warehouse/EditInventory.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Head, Link, useForm } from "@inertiajs/react";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { ArrowLeft, Save, Trash2 } from "lucide-react";
|
||||
import { Warehouse, WarehouseInventory } from "@/types/warehouse";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/Components/ui/alert-dialog";
|
||||
import TransactionTable, { Transaction } from "@/Components/Warehouse/Inventory/TransactionTable";
|
||||
|
||||
|
||||
interface Props {
|
||||
warehouse: Warehouse;
|
||||
inventory: WarehouseInventory;
|
||||
transactions: Transaction[];
|
||||
}
|
||||
|
||||
export default function EditInventory({ warehouse, inventory, transactions = [] }: Props) {
|
||||
const { data, setData, put, delete: destroy, processing, errors } = useForm({
|
||||
quantity: inventory.quantity,
|
||||
batchNumber: inventory.batchNumber || "",
|
||||
expiryDate: inventory.expiryDate || "",
|
||||
lastInboundDate: inventory.lastInboundDate || "",
|
||||
lastOutboundDate: inventory.lastOutboundDate || "",
|
||||
// 為了記錄異動原因,還是需要傳這兩個欄位,雖然 UI 上原本的 EditPage 沒有原因輸入框
|
||||
// 但為了符合我們後端的交易紀錄邏輯,我們可能需要預設一個,或者偷加一個欄位?
|
||||
// 原 source code 沒有原因欄位。
|
||||
// 我們可以預設 reason 為 "手動編輯更新"
|
||||
reason: "編輯頁面手動更新",
|
||||
});
|
||||
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
const handleSave = () => {
|
||||
if (data.quantity < 0) {
|
||||
toast.error("庫存數量不可為負數");
|
||||
return;
|
||||
}
|
||||
|
||||
put(route("warehouses.inventory.update", { warehouse: warehouse.id, inventory: inventory.id }), {
|
||||
onSuccess: () => {
|
||||
toast.success("庫存資料已更新");
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("更新失敗,請檢查欄位");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
destroy(route("warehouses.inventory.destroy", { warehouse: warehouse.id, inventory: inventory.id }), {
|
||||
onSuccess: () => {
|
||||
toast.success("庫存品項已刪除");
|
||||
setShowDeleteDialog(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("刪除失敗");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout>
|
||||
<Head title={`編輯庫存 - ${inventory.productName} `} />
|
||||
<div className="container mx-auto p-6 max-w-4xl">
|
||||
{/* 頁面標題與麵包屑 */}
|
||||
<div className="mb-6">
|
||||
<Link href={`/warehouses/${warehouse.id}/inventory`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 button-outlined-primary mb-6"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回庫存管理
|
||||
</Button>
|
||||
</Link >
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
|
||||
<span>商品與庫存管理</span>
|
||||
<span>/</span>
|
||||
<span>倉庫管理</span>
|
||||
<span>/</span>
|
||||
<span>庫存管理</span>
|
||||
<span>/</span>
|
||||
<span className="text-gray-900">編輯庫存品項</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="mb-2">編輯庫存品項</h1>
|
||||
<p className="text-gray-600">
|
||||
倉庫:<span className="font-medium text-gray-900">{warehouse.name}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
variant="outline"
|
||||
className="group border-red-200 text-red-600 hover:bg-red-50 hover:text-red-700 hover:border-red-300"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
刪除品項
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="button-filled-primary" disabled={processing}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
儲存變更
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
|
||||
{/* 表單內容 */}
|
||||
< div className="bg-white rounded-lg shadow-sm border p-6 mb-6" >
|
||||
<div className="space-y-6">
|
||||
{/* 商品基本資訊 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium border-b pb-2 text-lg">商品基本資訊</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="productName">
|
||||
商品名稱 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="productName"
|
||||
value={inventory.productName}
|
||||
disabled
|
||||
className="bg-gray-100"
|
||||
/>
|
||||
<p className="text-sm text-gray-500">
|
||||
商品名稱無法修改
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="batchNumber">批號</Label>
|
||||
<Input
|
||||
id="batchNumber"
|
||||
type="text"
|
||||
value={data.batchNumber}
|
||||
onChange={(e) => setData("batchNumber", e.target.value)}
|
||||
placeholder="例:FL20251101"
|
||||
className="button-outlined-primary"
|
||||
// 目前後端可能尚未支援儲存,但依需求顯示
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 庫存數量 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium border-b pb-2 text-lg">庫存數量</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quantity">
|
||||
庫存數量 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="quantity"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={data.quantity}
|
||||
onChange={(e) =>
|
||||
setData("quantity", parseFloat(e.target.value) || 0)
|
||||
}
|
||||
placeholder="0"
|
||||
className={`button-outlined-primary ${errors.quantity ? "border-red-500" : ""}`}
|
||||
/>
|
||||
{errors.quantity && <p className="text-xs text-red-500">{errors.quantity}</p>}
|
||||
<p className="text-sm text-gray-500">
|
||||
批號層級的庫存數量,安全庫存請至「安全庫存設定」頁面進行商品層級設定
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 日期資訊 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium border-b pb-2 text-lg">日期資訊</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="expiryDate">保存期限</Label>
|
||||
<Input
|
||||
id="expiryDate"
|
||||
type="date"
|
||||
value={data.expiryDate}
|
||||
onChange={(e) => setData("expiryDate", e.target.value)}
|
||||
className="button-outlined-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lastInboundDate">最新入庫時間</Label>
|
||||
<Input
|
||||
id="lastInboundDate"
|
||||
type="date"
|
||||
value={data.lastInboundDate}
|
||||
onChange={(e) =>
|
||||
setData("lastInboundDate", e.target.value)
|
||||
}
|
||||
className="button-outlined-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lastOutboundDate">最新出庫時間</Label>
|
||||
<Input
|
||||
id="lastOutboundDate"
|
||||
type="date"
|
||||
value={data.lastOutboundDate}
|
||||
onChange={(e) =>
|
||||
setData("lastOutboundDate", e.target.value)
|
||||
}
|
||||
className="button-outlined-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
|
||||
{/* 庫存異動紀錄 */}
|
||||
< div className="bg-white rounded-lg shadow-sm border p-6" >
|
||||
<h3 className="font-medium text-lg border-b pb-4 mb-4">庫存異動紀錄</h3>
|
||||
|
||||
<TransactionTable transactions={transactions} />
|
||||
</div >
|
||||
|
||||
{/* 刪除確認對話框 */}
|
||||
< AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog} >
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>確認刪除庫存品項</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
您確定要刪除「{inventory.productName}」的此筆庫存嗎?此操作無法復原。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="button-outlined-primary">
|
||||
取消
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-red-600 text-white hover:bg-red-700"
|
||||
>
|
||||
確認刪除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog >
|
||||
</div >
|
||||
</AuthenticatedLayout >
|
||||
);
|
||||
}
|
||||
192
resources/js/Pages/Warehouse/Index.tsx
Normal file
192
resources/js/Pages/Warehouse/Index.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, router } from "@inertiajs/react";
|
||||
import WarehouseDialog from "@/Components/Warehouse/WarehouseDialog";
|
||||
import TransferOrderDialog from "@/Components/Warehouse/TransferOrderDialog";
|
||||
import SearchToolbar from "@/Components/shared/SearchToolbar";
|
||||
import WarehouseCard from "@/Components/Warehouse/WarehouseCard";
|
||||
import WarehouseEmptyState from "@/Components/Warehouse/WarehouseEmptyState";
|
||||
import { Warehouse } from "@/types/warehouse";
|
||||
import Pagination from "@/Components/shared/Pagination";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface PageProps {
|
||||
warehouses: {
|
||||
data: Warehouse[];
|
||||
links: any[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
total: number;
|
||||
};
|
||||
filters: {
|
||||
search?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function WarehouseIndex({ warehouses, filters }: PageProps) {
|
||||
// 篩選狀態
|
||||
const [searchTerm, setSearchTerm] = useState(filters.search || "");
|
||||
|
||||
// 對話框狀態
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [editingWarehouse, setEditingWarehouse] = useState<Warehouse | null>(null);
|
||||
const [transferOrderDialogOpen, setTransferOrderDialogOpen] = useState(false);
|
||||
|
||||
// 暫時的 Mock Inventories,直到後端 API 實作
|
||||
|
||||
|
||||
// 搜尋處理
|
||||
const handleSearch = (term: string) => {
|
||||
setSearchTerm(term);
|
||||
router.get(route('warehouses.index'), { search: term }, {
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
replace: true,
|
||||
});
|
||||
};
|
||||
|
||||
// 導航處理
|
||||
const handleViewInventory = (warehouseId: string) => {
|
||||
router.get(`/warehouses/${warehouseId}/inventory`);
|
||||
};
|
||||
|
||||
// 倉庫操作處理函式
|
||||
const handleAddWarehouse = () => {
|
||||
setEditingWarehouse(null);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEditWarehouse = (warehouse: Warehouse) => {
|
||||
setEditingWarehouse(warehouse);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
// 接收 Dialog 回傳的資料並呼叫後端
|
||||
const handleSaveWarehouse = (data: Partial<Warehouse>) => {
|
||||
if (editingWarehouse) {
|
||||
router.put(route('warehouses.update', editingWarehouse.id), data, {
|
||||
onSuccess: () => setIsDialogOpen(false),
|
||||
});
|
||||
} else {
|
||||
router.post(route('warehouses.store'), data, {
|
||||
onSuccess: () => setIsDialogOpen(false),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteWarehouse = (id: string) => {
|
||||
if (confirm("確定要停用此倉庫嗎?\n注意:刪除倉庫將連帶刪除所有庫存與紀錄!")) {
|
||||
router.delete(route('warehouses.destroy', id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTransferOrder = () => {
|
||||
setTransferOrderDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveTransferOrder = (data: any) => {
|
||||
router.post(route('transfer-orders.store'), data, {
|
||||
onSuccess: () => {
|
||||
toast.success('撥補單已建立且庫存已轉移');
|
||||
setTransferOrderDialogOpen(false);
|
||||
},
|
||||
onError: (errors) => {
|
||||
toast.error('建立撥補單失敗');
|
||||
console.error(errors);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout>
|
||||
<Head title="倉庫管理" />
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
{/* 頁面標題 */}
|
||||
<div className="mb-6">
|
||||
<h1 className="mb-2">倉庫管理</h1>
|
||||
<p className="text-gray-600 font-medium mb-4">管理倉庫資訊與庫存配置</p>
|
||||
</div>
|
||||
|
||||
{/* 工具列 */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
|
||||
<div className="flex flex-col md:flex-row gap-4 items-start md:items-center justify-between">
|
||||
<div className="flex flex-col sm:flex-row gap-4 flex-1 w-full">
|
||||
{/* 搜尋框 */}
|
||||
<SearchToolbar
|
||||
value={searchTerm}
|
||||
onChange={handleSearch}
|
||||
placeholder="搜尋倉庫名稱..."
|
||||
className="flex-1 w-full md:max-w-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 操作按鈕 */}
|
||||
<div className="flex gap-2 w-full md:w-auto">
|
||||
<Button
|
||||
onClick={handleAddTransferOrder}
|
||||
className="flex-1 md:flex-initial button-outlined-primary"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新增撥補單
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddWarehouse}
|
||||
className="flex-1 md:flex-initial button-filled-primary"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新增倉庫
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 倉庫卡片列表 */}
|
||||
{warehouses.data.length === 0 ? (
|
||||
<WarehouseEmptyState />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{warehouses.data.map((warehouse) => (
|
||||
<WarehouseCard
|
||||
key={warehouse.id}
|
||||
warehouse={warehouse}
|
||||
stats={{
|
||||
totalQuantity: warehouse.total_quantity || 0,
|
||||
lowStockCount: warehouse.low_stock_count || 0,
|
||||
replenishmentNeeded: warehouse.low_stock_count || 0
|
||||
}}
|
||||
hasWarning={(warehouse.low_stock_count || 0) > 0}
|
||||
onViewInventory={() => handleViewInventory(warehouse.id)}
|
||||
onEdit={handleEditWarehouse}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分頁 */}
|
||||
<div className="mt-6">
|
||||
<Pagination links={warehouses.links} />
|
||||
</div>
|
||||
|
||||
{/* 倉庫對話框 */}
|
||||
<WarehouseDialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
warehouse={editingWarehouse}
|
||||
onSave={handleSaveWarehouse}
|
||||
onDelete={handleDeleteWarehouse}
|
||||
/>
|
||||
|
||||
{/* 撥補單對話框 */}
|
||||
<TransferOrderDialog
|
||||
open={transferOrderDialogOpen}
|
||||
onOpenChange={setTransferOrderDialogOpen}
|
||||
order={null}
|
||||
onSave={handleSaveTransferOrder}
|
||||
warehouses={warehouses.data}
|
||||
/>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
187
resources/js/Pages/Warehouse/Inventory.tsx
Normal file
187
resources/js/Pages/Warehouse/Inventory.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { ArrowLeft, PackagePlus, AlertTriangle, Shield } from "lucide-react";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, Link, router } from "@inertiajs/react";
|
||||
import { Warehouse, WarehouseInventory, SafetyStockSetting, Product } from "@/types/warehouse";
|
||||
import InventoryToolbar from "@/Components/Warehouse/Inventory/InventoryToolbar";
|
||||
import InventoryTable from "@/Components/Warehouse/Inventory/InventoryTable";
|
||||
import { calculateLowStockCount } from "@/utils/inventory";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/Components/ui/alert-dialog";
|
||||
|
||||
// 庫存頁面 Props
|
||||
interface Props {
|
||||
warehouse: Warehouse;
|
||||
inventories: WarehouseInventory[];
|
||||
safetyStockSettings: SafetyStockSetting[];
|
||||
availableProducts: Product[];
|
||||
}
|
||||
|
||||
export default function WarehouseInventoryPage({
|
||||
warehouse,
|
||||
inventories,
|
||||
safetyStockSettings,
|
||||
availableProducts,
|
||||
}: Props) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
|
||||
// 篩選庫存列表
|
||||
const filteredInventories = useMemo(() => {
|
||||
return inventories.filter((item) => {
|
||||
// 搜尋條件:匹配商品名稱、編號或批號
|
||||
const matchesSearch = !searchTerm ||
|
||||
item.productName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(item.productCode && item.productCode.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||
item.batchNumber.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
// 類型篩選 (需要比對 availableProducts 找到類型)
|
||||
let matchesType = true;
|
||||
if (typeFilter !== "all") {
|
||||
const product = availableProducts.find((p) => p.id === item.productId);
|
||||
matchesType = product?.type === typeFilter;
|
||||
}
|
||||
|
||||
return matchesSearch && matchesType;
|
||||
});
|
||||
}, [inventories, searchTerm, typeFilter, availableProducts]);
|
||||
|
||||
// 計算統計資訊
|
||||
const lowStockItems = calculateLowStockCount(inventories, warehouse.id, safetyStockSettings);
|
||||
|
||||
// 導航至流動紀錄頁
|
||||
const handleView = (inventoryId: string) => {
|
||||
router.visit(route('warehouses.inventory.history', { warehouse: warehouse.id, inventory: inventoryId }));
|
||||
};
|
||||
|
||||
|
||||
const confirmDelete = (inventoryId: string) => {
|
||||
setDeleteId(inventoryId);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!deleteId) return;
|
||||
|
||||
router.delete(route("warehouses.inventory.destroy", { warehouse: warehouse.id, inventory: deleteId }), {
|
||||
onSuccess: () => {
|
||||
toast.success("庫存記錄已刪除");
|
||||
setDeleteId(null);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("刪除失敗");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout>
|
||||
<Head title={`庫存管理 - ${warehouse.name}`} />
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
{/* 頁面標題與導航 */}
|
||||
<div className="mb-6">
|
||||
<Link href="/warehouses">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 button-outlined-primary mb-6"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回倉庫管理
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="mb-2">庫存管理 - {warehouse.name}</h1>
|
||||
<p className="text-gray-600 font-medium">查看並管理此倉庫內的商品庫存數量與批號資訊</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按鈕 (位於標題下方) */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
{/* 安全庫存設定按鈕 */}
|
||||
<Link href={`/warehouses/${warehouse.id}/safety-stock-settings`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="button-outlined-primary"
|
||||
>
|
||||
<Shield className="mr-2 h-4 w-4" />
|
||||
安全庫存設定
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{/* 庫存警告顯示 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`button-outlined-primary cursor-default hover:bg-transparent ${lowStockItems > 0
|
||||
? "border-orange-500 text-orange-600"
|
||||
: "border-green-500 text-green-600"
|
||||
}`}
|
||||
>
|
||||
<AlertTriangle className="mr-2 h-4 w-4" />
|
||||
庫存警告:{lowStockItems} 項
|
||||
</Button>
|
||||
|
||||
{/* 新增庫存按鈕 */}
|
||||
<Link href={`/warehouses/${warehouse.id}/add-inventory`}>
|
||||
<Button
|
||||
className="button-filled-primary"
|
||||
>
|
||||
<PackagePlus className="mr-2 h-4 w-4" />
|
||||
新增庫存
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 篩選工具列 */}
|
||||
<div className="mb-6 bg-white rounded-lg shadow-sm border p-4">
|
||||
<InventoryToolbar
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
typeFilter={typeFilter}
|
||||
onTypeFilterChange={setTypeFilter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 庫存表格 */}
|
||||
<div className="bg-white rounded-lg shadow-sm border overflow-hidden">
|
||||
<InventoryTable
|
||||
inventories={filteredInventories}
|
||||
onView={handleView}
|
||||
onDelete={confirmDelete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
{/* 刪除確認對話框 */}
|
||||
<AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>確認刪除庫存項目</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
您確定要刪除此筆庫存項目嗎?此操作將會清空該項目的數量並保留刪除紀錄。此動作無法復原。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="button-outlined-primary">取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-red-600 hover:bg-red-700 text-white">
|
||||
確認刪除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
69
resources/js/Pages/Warehouse/InventoryHistory.tsx
Normal file
69
resources/js/Pages/Warehouse/InventoryHistory.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Head, Link } from "@inertiajs/react";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Warehouse } from "@/types/warehouse";
|
||||
import TransactionTable, { Transaction } from "@/Components/Warehouse/Inventory/TransactionTable";
|
||||
|
||||
interface Props {
|
||||
warehouse: Warehouse;
|
||||
inventory: {
|
||||
id: string;
|
||||
productName: string;
|
||||
productCode: string;
|
||||
quantity: number;
|
||||
};
|
||||
transactions: Transaction[];
|
||||
}
|
||||
|
||||
export default function InventoryHistory({ warehouse, inventory, transactions }: Props) {
|
||||
return (
|
||||
<AuthenticatedLayout>
|
||||
<Head title={`庫存異動紀錄 - ${inventory.productName}`} />
|
||||
<div className="container mx-auto p-6 max-w-4xl">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Link href={`/warehouses/${warehouse.id}/inventory`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 button-outlined-primary mb-6"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回庫存管理
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
|
||||
<span>倉庫管理</span>
|
||||
<span>/</span>
|
||||
<span>庫存管理</span>
|
||||
<span>/</span>
|
||||
<span className="text-gray-900">庫存異動紀錄</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="mb-2">庫存異動紀錄</h1>
|
||||
<p className="text-gray-600">
|
||||
商品:<span className="font-medium text-gray-900">{inventory.productName}</span>
|
||||
{inventory.productCode && <span className="text-gray-500 ml-2">({inventory.productCode})</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div className="flex justify-between items-center mb-4 border-b pb-4">
|
||||
<h3 className="font-medium text-lg">異動流水帳</h3>
|
||||
<div className="text-sm text-gray-500">
|
||||
目前庫存:<span className="font-medium text-gray-900">{inventory.quantity}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TransactionTable transactions={transactions} />
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
148
resources/js/Pages/Warehouse/SafetyStockSettings.tsx
Normal file
148
resources/js/Pages/Warehouse/SafetyStockSettings.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 安全庫存設定頁面
|
||||
* Last Updated: 2025-12-29
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { ArrowLeft, Plus } from "lucide-react";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, Link, router } from "@inertiajs/react";
|
||||
import { SafetyStockSetting, WarehouseInventory, Warehouse, Product } from "@/types/warehouse";
|
||||
import SafetyStockList from "@/Components/Warehouse/SafetyStock/SafetyStockList";
|
||||
import AddSafetyStockDialog from "@/Components/Warehouse/SafetyStock/AddSafetyStockDialog";
|
||||
import EditSafetyStockDialog from "@/Components/Warehouse/SafetyStock/EditSafetyStockDialog";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
warehouse: Warehouse;
|
||||
safetyStockSettings: SafetyStockSetting[];
|
||||
inventories: WarehouseInventory[];
|
||||
availableProducts: Product[];
|
||||
}
|
||||
|
||||
export default function SafetyStockPage({
|
||||
warehouse,
|
||||
safetyStockSettings: initialSettings = [],
|
||||
inventories = [],
|
||||
availableProducts = [],
|
||||
}: Props) {
|
||||
const [settings, setSettings] = useState<SafetyStockSetting[]>(initialSettings);
|
||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||
const [editingSetting, setEditingSetting] = useState<SafetyStockSetting | null>(null);
|
||||
|
||||
|
||||
// 當 Props 更新時同步本地 State
|
||||
useEffect(() => {
|
||||
setSettings(initialSettings);
|
||||
}, [initialSettings]);
|
||||
|
||||
const handleAdd = (newSettings: SafetyStockSetting[]) => {
|
||||
router.post(route('warehouses.safety-stock.store', warehouse.id), {
|
||||
settings: newSettings.map(s => ({
|
||||
productId: s.productId,
|
||||
quantity: s.safetyStock
|
||||
})),
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setShowAddDialog(false);
|
||||
toast.success(`成功新增 ${newSettings.length} 項安全庫存設定`);
|
||||
},
|
||||
onError: (errors) => {
|
||||
const firstError = Object.values(errors)[0];
|
||||
toast.error(typeof firstError === 'string' ? firstError : "新增失敗");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = (updatedSetting: SafetyStockSetting) => {
|
||||
router.put(route('warehouses.safety-stock.update', [warehouse.id, updatedSetting.id]), {
|
||||
safetyStock: updatedSetting.safetyStock,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setEditingSetting(null);
|
||||
toast.success(`成功更新 ${updatedSetting.productName} 的安全庫存`);
|
||||
},
|
||||
onError: (errors) => {
|
||||
const firstError = Object.values(errors)[0];
|
||||
toast.error(typeof firstError === 'string' ? firstError : "更新失敗");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
router.delete(route('warehouses.safety-stock.destroy', [warehouse.id, id]), {
|
||||
onSuccess: () => {
|
||||
toast.success("已刪除安全庫存設定");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (!warehouse) {
|
||||
return <div className="p-8 text-center text-muted-foreground">正在載入倉庫資料...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout>
|
||||
<Head title={`安全庫存設定 - ${warehouse.name}`} />
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
{/* 頁面標題與導航 */}
|
||||
<div className="mb-6">
|
||||
<Link href={route('warehouses.inventory.index', warehouse.id)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 button-outlined-primary mb-6"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回庫存管理
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="mb-2">安全庫存設定 - {warehouse.name}</h1>
|
||||
<p className="text-gray-600 font-medium">
|
||||
設定商品的安全庫存量,當庫存低於安全值時將發出警告
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setShowAddDialog(true)}
|
||||
className="button-filled-primary"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新增安全庫存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 安全庫存列表 */}
|
||||
<SafetyStockList
|
||||
settings={settings}
|
||||
inventories={inventories}
|
||||
onEdit={setEditingSetting}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
{/* 新增對話框 */}
|
||||
<AddSafetyStockDialog
|
||||
open={showAddDialog}
|
||||
onOpenChange={setShowAddDialog}
|
||||
warehouseId={warehouse.id}
|
||||
existingSettings={settings}
|
||||
availableProducts={availableProducts}
|
||||
onAdd={handleAdd}
|
||||
/>
|
||||
|
||||
{/* 編輯對話框 */}
|
||||
{editingSetting && (
|
||||
<EditSafetyStockDialog
|
||||
open={!!editingSetting}
|
||||
onOpenChange={(open) => !open && setEditingSetting(null)}
|
||||
setting={editingSetting}
|
||||
onSave={handleEdit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user