Files
star-erp/resources/js/Pages/Warehouse/Index.tsx
sky121113 ba3c10ac13
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 48s
feat(warehouse): 庫存統計卡片加入總金額顯示 (可用/帳面)
2026-02-05 13:18:22 +08:00

348 lines
15 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 { SearchableSelect } from "@/Components/ui/searchable-select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/Components/ui/dialog";
import { Label } from "@/Components/ui/label";
import { Loader2, Plus, Warehouse as WarehouseIcon } from 'lucide-react';
import { Card, CardContent } from "@/Components/ui/card";
import { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, router } from "@inertiajs/react";
import WarehouseDialog from "@/Components/Warehouse/WarehouseDialog";
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";
import { getBreadcrumbs } from "@/utils/breadcrumb";
import { Can } from "@/Components/Permission/Can";
interface PageProps {
warehouses: {
data: Warehouse[];
links: any[];
current_page: number;
last_page: number;
total: number;
};
totals: {
available_stock: number;
available_amount: number;
book_stock: number;
book_amount: number;
};
filters: {
search?: string;
per_page?: string;
};
}
export default function WarehouseIndex({ warehouses, totals, filters }: PageProps) {
// 篩選狀態
const [searchTerm, setSearchTerm] = useState(filters.search || "");
const [perPage, setPerPage] = useState(filters.per_page || '10');
// 對話框狀態
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingWarehouse, setEditingWarehouse] = useState<Warehouse | null>(null);
// 調撥單單建立狀態
const [isTransferCreateOpen, setIsTransferCreateOpen] = useState(false);
const [sourceWarehouseId, setSourceWarehouseId] = useState("");
const [targetWarehouseId, setTargetWarehouseId] = useState("");
const [creating, setCreating] = useState(false);
// 搜尋處理
const handleSearch = (term: string) => {
setSearchTerm(term);
router.get(route('warehouses.index'), { search: term, per_page: perPage }, {
preserveState: true,
preserveScroll: true,
replace: true,
});
};
const handlePerPageChange = (value: string) => {
setPerPage(value);
router.get(route('warehouses.index'),
{ ...filters, per_page: value, page: 1 },
{ preserveState: false, replace: true, preserveScroll: true }
);
};
// 導航處理
const handleViewInventory = (warehouseId: string | number) => {
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 | number) => {
router.delete(route('warehouses.destroy', id), {
onSuccess: () => {
toast.success('倉庫已刪除');
setEditingWarehouse(null);
},
onError: (errors: any) => {
console.error(errors);
}
});
};
const handleAddTransferOrder = () => {
setIsTransferCreateOpen(true);
};
const handleCreateTransferOrder = () => {
if (!sourceWarehouseId) {
toast.error("請選擇來源倉庫");
return;
}
if (!targetWarehouseId) {
toast.error("請選擇目的倉庫");
return;
}
if (sourceWarehouseId === targetWarehouseId) {
toast.error("來源與目的倉庫不能相同");
return;
}
setCreating(true);
router.post(route('inventory.transfer.store'), {
from_warehouse_id: sourceWarehouseId,
to_warehouse_id: targetWarehouseId
}, {
onFinish: () => setCreating(false),
onSuccess: () => {
setIsTransferCreateOpen(false);
setSourceWarehouseId("");
setTargetWarehouseId("");
toast.success('調撥單已建立');
}
});
};
return (
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("warehouses")}>
<Head title="倉庫管理" />
<div className="container mx-auto p-6 max-w-7xl">
{/* 頁面標題 */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<WarehouseIcon className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">
</p>
</div>
{/* 統計區塊 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<Card className="shadow-sm">
<CardContent className="p-6">
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-500 mb-1"></span>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold text-primary-main">
{totals.available_stock.toLocaleString()}
</span>
<Can permission="inventory.view_cost">
<span className="text-lg font-medium text-gray-400">
( ${totals.available_amount?.toLocaleString()} )
</span>
</Can>
</div>
</div>
</CardContent>
</Card>
<Card className="shadow-sm">
<CardContent className="p-6">
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-500 mb-1"></span>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold text-gray-700">
{totals.book_stock.toLocaleString()}
</span>
<Can permission="inventory.view_cost">
<span className="text-lg font-medium text-gray-400">
( ${totals.book_amount?.toLocaleString()} )
</span>
</Can>
</div>
</div>
</CardContent>
</Card>
</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">
<Can permission="inventory_count.create">
<Button
onClick={handleAddTransferOrder}
className="flex-1 md:flex-initial button-outlined-primary"
>
<Plus className="mr-2 h-4 w-4" />
調
</Button>
</Can>
<Can permission="warehouses.create">
<Button
onClick={handleAddWarehouse}
className="flex-1 md:flex-initial button-filled-primary"
>
<Plus className="mr-2 h-4 w-4" />
</Button>
</Can>
</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.book_stock || 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 flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-sm text-gray-500">
<span></span>
<SearchableSelect
value={perPage}
onValueChange={handlePerPageChange}
options={[
{ label: "10", value: "10" },
{ label: "20", value: "20" },
{ label: "50", value: "50" },
{ label: "100", value: "100" }
]}
className="w-[90px] h-8"
showSearch={false}
/>
<span></span>
</div>
<span className="text-sm text-gray-500"> {warehouses.total} </span>
</div>
<Pagination links={warehouses.links} />
</div>
{/* 倉庫對話框 */}
<WarehouseDialog
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
warehouse={editingWarehouse}
onSave={handleSaveWarehouse}
onDelete={handleDeleteWarehouse}
/>
{/* 調撥單建立對話框 */}
<Dialog open={isTransferCreateOpen} onOpenChange={setIsTransferCreateOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>調</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="space-y-2">
<Label></Label>
<SearchableSelect
value={sourceWarehouseId}
onValueChange={setSourceWarehouseId}
options={warehouses.data.map((w: any) => ({ label: w.name, value: w.id.toString() }))}
placeholder="請選擇來源倉庫"
className="h-9"
/>
</div>
<div className="space-y-2">
<Label></Label>
<SearchableSelect
value={targetWarehouseId}
onValueChange={setTargetWarehouseId}
options={warehouses.data
.filter((w: any) => w.id.toString() !== sourceWarehouseId)
.map((w: any) => ({ label: w.name, value: w.id.toString() }))
}
placeholder="請選擇目的倉庫"
className="h-9"
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" className="button-outlined-primary" onClick={() => setIsTransferCreateOpen(false)}>
</Button>
<Button onClick={handleCreateTransferOrder} className="button-filled-primary" disabled={creating || !sourceWarehouseId || !targetWarehouseId}>
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</AuthenticatedLayout>
);
}