first commit

This commit is contained in:
2025-12-30 15:03:19 +08:00
commit c735c36009
902 changed files with 83591 additions and 0 deletions

View 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>
);
}

View 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 >
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}