diff --git a/.agent/skills/ui-consistency/SKILL.md b/.agent/skills/ui-consistency/SKILL.md
index 0f6069b..3ddf1e2 100644
--- a/.agent/skills/ui-consistency/SKILL.md
+++ b/.agent/skills/ui-consistency/SKILL.md
@@ -569,6 +569,7 @@ const handlePerPageChange = (value: string) => {
---
+
## 7. Badge 與狀態顯示
### 7.1 基本 Badge
@@ -614,6 +615,48 @@ import { Badge } from "@/Components/ui/badge";
```
+### 7.3 統一狀態標籤 (StatusBadge)
+
+系統提供統一的 `StatusBadge` 元件來顯示各種業務狀態,確保顏色與樣式的一致性。
+
+**引入方式**:
+
+```tsx
+import { StatusBadge, StatusVariant } from "@/Components/shared/StatusBadge";
+```
+
+**支援的變體 (Variant)**:
+
+| Variant | 顏色 | 適用情境 |
+|---|---|---|
+| `neutral` | 灰色 | 草稿、取消、關閉、缺貨 |
+| `info` | 藍色 | 處理中、啟用中 |
+| `warning` | 黃色 | 待審核、庫存預警、週轉慢 |
+| `success` | 綠色 | 已完成、已核准、正常 |
+| `destructive` | 紅色 | 作廢、駁回、滯銷、異常 |
+
+**實作模式**:
+
+建議定義一個 `getStatusVariant` 函式將業務狀態對應到 UI 變體,保持程式碼整潔。
+
+```tsx
+// 1. 定義狀態映射函式
+const getStatusVariant = (status: string): StatusVariant => {
+ switch (status) {
+ case 'normal': return 'success'; // 正常 -> 綠色
+ case 'slow': return 'warning'; // 週轉慢 -> 黃色
+ case 'dead': return 'destructive'; // 滯銷 -> 紅色
+ case 'out_of_stock': return 'neutral';// 缺貨 -> 灰色
+ default: return 'neutral';
+ }
+};
+
+// 2. 在表格中使用
+
+ {item.status_label}
+
+```
+
---
## 8. 頁面佈局規範
diff --git a/app/Modules/Inventory/Controllers/InventoryAnalysisController.php b/app/Modules/Inventory/Controllers/InventoryAnalysisController.php
new file mode 100644
index 0000000..9ce0ed4
--- /dev/null
+++ b/app/Modules/Inventory/Controllers/InventoryAnalysisController.php
@@ -0,0 +1,38 @@
+turnoverService = $turnoverService;
+ }
+
+ public function index(Request $request)
+ {
+ $filters = $request->only([
+ 'warehouse_id', 'category_id', 'search', 'per_page', 'sort_by', 'sort_order', 'status'
+ ]);
+
+ $analysisData = $this->turnoverService->getAnalysisData($filters, $request->input('per_page', 10));
+ $kpis = $this->turnoverService->getKPIs($filters);
+
+ return Inertia::render('Inventory/Analysis/Index', [
+ 'analysisData' => $analysisData,
+ 'kpis' => $kpis,
+ 'warehouses' => Warehouse::select('id', 'name')->get(),
+ 'categories' => Category::select('id', 'name')->get(),
+ 'filters' => $filters,
+ ]);
+ }
+}
diff --git a/app/Modules/Inventory/Routes/web.php b/app/Modules/Inventory/Routes/web.php
index 2c2674a..33652b7 100644
--- a/app/Modules/Inventory/Routes/web.php
+++ b/app/Modules/Inventory/Routes/web.php
@@ -14,6 +14,7 @@ use App\Modules\Inventory\Controllers\AdjustDocController;
use App\Modules\Inventory\Controllers\InventoryReportController;
use App\Modules\Inventory\Controllers\StockQueryController;
+use App\Modules\Inventory\Controllers\InventoryAnalysisController;
Route::middleware('auth')->group(function () {
@@ -32,6 +33,11 @@ Route::middleware('auth')->group(function () {
Route::get('/inventory/report/{product}', [InventoryReportController::class, 'show'])->name('inventory.report.show');
});
+ // 庫存分析 (Inventory Analysis)
+ Route::middleware('permission:inventory_report.view')->group(function () {
+ Route::get('/inventory/analysis', [InventoryAnalysisController::class, 'index'])->name('inventory.analysis.index');
+ });
+
// 類別管理 (用於商品對話框) - 需要商品權限
Route::middleware('permission:products.view')->group(function () {
Route::get('/categories', [CategoryController::class, 'index'])->name('categories.index');
diff --git a/app/Modules/Inventory/Services/TurnoverService.php b/app/Modules/Inventory/Services/TurnoverService.php
new file mode 100644
index 0000000..f74916c
--- /dev/null
+++ b/app/Modules/Inventory/Services/TurnoverService.php
@@ -0,0 +1,247 @@
+select([
+ 'products.id',
+ 'products.code',
+ 'products.name',
+ 'categories.name as category_name',
+ 'products.cost_price', // Assuming cost_price exists for value calculation
+ ])
+ ->leftJoin('categories', 'products.category_id', '=', 'categories.id')
+ ->leftJoin('inventories', 'products.id', '=', 'inventories.product_id')
+ ->groupBy(['products.id', 'products.code', 'products.name', 'categories.name', 'products.cost_price']);
+
+ // Filter by Warehouse (Current Inventory)
+ if ($warehouseId) {
+ $query->where('inventories.warehouse_id', $warehouseId);
+ }
+
+ // Filter by Category
+ if ($categoryId) {
+ $query->where('products.category_id', $categoryId);
+ }
+
+ // Filter by Search
+ if ($search) {
+ $query->where(function($q) use ($search) {
+ $q->where('products.name', 'like', "%{$search}%")
+ ->orWhere('products.code', 'like', "%{$search}%");
+ });
+ }
+
+ // Add Aggregated Columns
+
+ // 1. Current Inventory Quantity
+ $query->addSelect(DB::raw('COALESCE(SUM(inventories.quantity), 0) as current_stock'));
+
+ // 2. Sales in last 30 days (Outbound)
+ // We need a subquery or join for this to be efficient, or we use a separate query and map.
+ // Given potentially large data, subquery per row might be slow, but for pagination it's okay-ish.
+ // Better approach: Join with a subquery of aggregated transactions.
+
+ $thirtyDaysAgo = Carbon::now()->subDays(30);
+
+ // Subquery for 30-day sales
+ $salesSubquery = InventoryTransaction::query()
+ ->select('inventories.product_id', DB::raw('ABS(SUM(inventory_transactions.quantity)) as sales_qty_30d'))
+ ->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
+ ->where('inventory_transactions.type', '出庫') // Adjust type as needed based on actual data
+ ->where('inventory_transactions.actual_time', '>=', $thirtyDaysAgo)
+ ->groupBy('inventories.product_id');
+
+ if ($warehouseId) {
+ $salesSubquery->where('inventories.warehouse_id', $warehouseId);
+ }
+
+ $query->leftJoinSub($salesSubquery, 'sales_30d', function ($join) {
+ $join->on('products.id', '=', 'sales_30d.product_id');
+ });
+ $query->addSelect(DB::raw('COALESCE(sales_30d.sales_qty_30d, 0) as sales_30d'));
+
+ // 3. Last Sale Date
+ // Use max actual_time from outbound transactions
+ $lastSaleSubquery = InventoryTransaction::query()
+ ->select('inventories.product_id', DB::raw('MAX(actual_time) as last_sale_date'))
+ ->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
+ ->where('inventory_transactions.type', '出庫')
+ ->groupBy('inventories.product_id');
+
+ if ($warehouseId) {
+ $lastSaleSubquery->where('inventories.warehouse_id', $warehouseId);
+ }
+
+ $query->leftJoinSub($lastSaleSubquery, 'last_sales', function ($join) {
+ $join->on('products.id', '=', 'last_sales.product_id');
+ });
+ $query->addSelect('last_sales.last_sale_date');
+
+ // Apply Status Filter (Dead Stock etc) requires having clauses or wrapper query.
+ // Dead Stock: stock > 0 AND (last_sale_date < 90 days ago OR last_sale_date IS NULL)
+ // Slow Moving: turnover days > X?
+
+ // Let's modify query to handle ordering and filtering on calculated fields if possible.
+ // For simplicity in Laravel, we might fetch and transform, but pagination breaks.
+ // We'll use HAVING for status filtering if needed.
+
+ // Order by
+ $sortBy = $filters['sort_by'] ?? 'turnover_days'; // Default sort
+ $sortOrder = $filters['sort_order'] ?? 'desc';
+
+ // Turnover Days Calculation in SQL: (stock / (sales_30d / 30)) => (stock * 30) / sales_30d
+ // Handle division by zero: if sales_30d is 0, turnover is 'Inf' (or very high number like 9999)
+ $turnoverDaysSql = "CASE WHEN COALESCE(sales_30d.sales_qty_30d, 0) > 0
+ THEN (COALESCE(SUM(inventories.quantity), 0) * 30) / sales_30d.sales_qty_30d
+ ELSE 9999 END";
+
+ $query->addSelect(DB::raw("$turnoverDaysSql as turnover_days"));
+
+ // Only show items with stock > 0 ? User might want to see out of stock items too?
+ // Usually analysis focuses on what IS in stock. But Dead Stock needs items with stock.
+ // Stock-out analysis needs items with 0 stock.
+ // Let's filter stock > 0 by default for "Turnover Analysis".
+ // $query->havingRaw('current_stock > 0');
+ // Wait, better to let user filter?
+ // For dead stock, definitive IS stock > 0.
+
+ if ($statusFilter === 'dead') {
+ $ninetyDaysAgo = Carbon::now()->subDays(90);
+ $query->havingRaw("current_stock > 0 AND (last_sale_date < ? OR last_sale_date IS NULL)", [$ninetyDaysAgo]);
+ }
+
+ // Apply Sorting
+ if ($sortBy === 'turnover_days') {
+ $query->orderByRaw("$turnoverDaysSql $sortOrder");
+ } else if (in_array($sortBy, ['current_stock', 'sales_30d', 'last_sale_date'])) {
+ $query->orderBy($sortBy, $sortOrder);
+ } else {
+ $query->orderBy('products.code', 'asc');
+ }
+
+ return $query->paginate($perPage)->withQueryString()->through(function($item) {
+ // Post-processing for display
+ $item->turnover_days_display = $item->turnover_days >= 9999 ? '∞' : number_format($item->turnover_days, 1);
+
+ // Determine Status Label
+ $lastSale = $item->last_sale_date ? Carbon::parse($item->last_sale_date) : null;
+ $daysSinceSale = $lastSale ? $lastSale->diffInDays(Carbon::now()) : 9999;
+
+ if ($item->current_stock > 0 && $daysSinceSale > 90) {
+ $item->status = 'dead'; // 滯銷
+ $item->status_label = '滯銷';
+ } elseif ($item->current_stock > 0 && $item->turnover_days > 60) {
+ $item->status = 'slow'; // 週轉慢
+ $item->status_label = '週轉慢';
+ } elseif ($item->current_stock == 0) {
+ $item->status = 'out_of_stock';
+ $item->status_label = '缺貨';
+ } else {
+ $item->status = 'normal';
+ $item->status_label = '正常';
+ }
+
+ return $item;
+ });
+ }
+
+ public function getKPIs(array $filters)
+ {
+ // Calculates aggregate KPIs
+ $warehouseId = $filters['warehouse_id'] ?? null;
+ $categoryId = $filters['category_id'] ?? null;
+
+ // Helper to build base inv query
+ $buildInvQuery = function() use ($warehouseId, $categoryId) {
+ $q = DB::table('inventories')
+ ->join('products', 'inventories.product_id', '=', 'products.id')
+ ->where('inventories.quantity', '>', 0);
+ if ($warehouseId) $q->where('inventories.warehouse_id', $warehouseId);
+ if ($categoryId) $q->where('products.category_id', $categoryId);
+ return $q;
+ };
+
+ // 1. Total Inventory Value (Cost)
+ $totalValue = (clone $buildInvQuery())
+ ->sum(DB::raw('inventories.quantity * COALESCE(products.cost_price, 0)'));
+
+ // 2. Dead Stock Value (No sale in 90 days)
+ // Need last sale date for each product-location or just product?
+ // Assuming dead stock is product-level logic for simplicity.
+
+ $ninetyDaysAgo = Carbon::now()->subDays(90);
+
+ // Get IDs of products sold in last 90 days
+ $soldProductIds = InventoryTransaction::query()
+ ->where('type', '出庫')
+ ->where('actual_time', '>=', $ninetyDaysAgo)
+ ->distinct()
+ ->pluck('inventory_id') // Wait, transaction links to inventory, inventory links to product.
+ // We need product_id.
+ ->map(function($id) {
+ return DB::table('inventories')->where('id', $id)->value('product_id');
+ })
+ ->filter()
+ ->unique()
+ ->toArray();
+ // Optimization: Use join in subquery
+
+ $soldProductIdsQuery = DB::table('inventory_transactions')
+ ->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
+ ->where('inventory_transactions.type', '出庫')
+ ->where('inventory_transactions.actual_time', '>=', $ninetyDaysAgo)
+ ->select('inventories.product_id')
+ ->distinct();
+
+ $deadStockQuery = (clone $buildInvQuery())
+ ->whereNotIn('products.id', $soldProductIdsQuery);
+
+ $deadStockValue = $deadStockQuery->sum(DB::raw('inventories.quantity * COALESCE(products.cost_price, 0)'));
+ $deadStockCount = $deadStockQuery->count('products.id'); // Count of inventory records (batches) or products?
+ // Let's count distinct products
+ $deadStockProductCount = $deadStockQuery->distinct('products.id')->count('products.id');
+
+ // 3. Average Turnover Days (Company wide)
+ // Formula: (Avg Inventory / COGS) * 365 ?
+ // Simplified: (Total Stock / Total Sales 30d) * 30
+
+ $totalStock = (clone $buildInvQuery())->sum('inventories.quantity');
+
+ $totalSales30d = DB::table('inventory_transactions')
+ ->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
+ ->join('products', 'inventories.product_id', '=', 'products.id')
+ ->where('inventory_transactions.type', '出庫')
+ ->where('inventory_transactions.actual_time', '>=', Carbon::now()->subDays(30))
+ ->when($warehouseId, fn($q) => $q->where('inventories.warehouse_id', $warehouseId))
+ ->when($categoryId, fn($q) => $q->where('products.category_id', $categoryId))
+ ->sum(DB::raw('ABS(inventory_transactions.quantity)'));
+
+ $avgTurnoverDays = $totalSales30d > 0 ? ($totalStock * 30) / $totalSales30d : 0;
+
+ return [
+ 'total_stock_value' => $totalValue,
+ 'dead_stock_value' => $deadStockValue,
+ 'dead_stock_count' => $deadStockProductCount,
+ 'avg_turnover_days' => round($avgTurnoverDays, 1),
+ ];
+ }
+}
diff --git a/resources/js/Layouts/AuthenticatedLayout.tsx b/resources/js/Layouts/AuthenticatedLayout.tsx
index d85dc61..148bd61 100644
--- a/resources/js/Layouts/AuthenticatedLayout.tsx
+++ b/resources/js/Layouts/AuthenticatedLayout.tsx
@@ -249,6 +249,13 @@ export default function AuthenticatedLayout({
route: "/inventory/report",
permission: "inventory_report.view",
},
+ {
+ id: "inventory-analysis",
+ label: "庫存分析",
+ icon: ,
+ route: "/inventory/analysis",
+ permission: "inventory_report.view",
+ },
],
},
{
diff --git a/resources/js/Pages/Inventory/Analysis/Index.tsx b/resources/js/Pages/Inventory/Analysis/Index.tsx
new file mode 100644
index 0000000..11ae8e6
--- /dev/null
+++ b/resources/js/Pages/Inventory/Analysis/Index.tsx
@@ -0,0 +1,442 @@
+import { useState, useCallback } from "react";
+import { Button } from "@/Components/ui/button";
+import { Input } from "@/Components/ui/input";
+import { Label } from "@/Components/ui/label";
+import {
+ Filter,
+ Package,
+ RotateCcw,
+ BarChart3,
+ AlertTriangle,
+ CheckCircle2,
+ Clock,
+ ArrowUpDown,
+ ArrowUp,
+ ArrowDown,
+ XCircle
+} from 'lucide-react';
+import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
+import { Head, router } from "@inertiajs/react";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/Components/ui/table";
+import Pagination from "@/Components/shared/Pagination";
+import { SearchableSelect } from "@/Components/ui/searchable-select";
+import { PageProps } from "@/types/global";
+
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/Components/ui/tooltip";
+import { StatusBadge, StatusVariant } from "@/Components/shared/StatusBadge";
+
+interface AnalysisItem {
+ id: number;
+ code: string;
+ name: string;
+ category_name: string;
+ current_stock: string; // decimal string from DB
+ sales_30d: string;
+ last_sale_date: string | null;
+ turnover_days: number;
+ turnover_days_display: string;
+ status: 'dead' | 'slow' | 'normal' | 'out_of_stock';
+ status_label: string;
+}
+
+interface KPIProps {
+ total_stock_value: number;
+ dead_stock_value: number;
+ dead_stock_count: number;
+ avg_turnover_days: number;
+}
+
+interface PagePropsWithData extends PageProps {
+ analysisData: {
+ data: AnalysisItem[];
+ links: any[];
+ total: number;
+ from: number;
+ to: number;
+ current_page: number;
+ };
+ kpis: KPIProps;
+ warehouses: { id: number; name: string }[];
+ categories: { id: number; name: string }[];
+ filters: {
+ warehouse_id?: string;
+ category_id?: string;
+ search?: string;
+ per_page?: string;
+ sort_by?: string;
+ sort_order?: 'asc' | 'desc';
+ status?: string;
+ };
+}
+
+// Define status mapping
+const getStatusVariant = (status: string): StatusVariant => {
+ switch (status) {
+ case 'dead': return 'destructive';
+ case 'slow': return 'warning';
+ case 'normal': return 'success';
+ case 'out_of_stock': return 'neutral';
+ default: return 'neutral';
+ }
+};
+
+const getStatusLabel = (status: string): string => {
+ switch (status) {
+ case 'dead': return '滯銷';
+ case 'slow': return '週轉慢';
+ case 'normal': return '正常';
+ case 'out_of_stock': return '缺貨';
+ default: return status;
+ }
+};
+
+const statusOptions = [
+ { label: "全部狀態", value: "all" },
+ { label: "滯銷 (>90天)", value: "dead" },
+ { label: "週轉慢 (>60天)", value: "slow" },
+ { label: "正常", value: "normal" }
+];
+
+export default function InventoryAnalysisIndex({ analysisData, kpis, warehouses, categories, filters }: PagePropsWithData) {
+ const [warehouseId, setWarehouseId] = useState(filters.warehouse_id || "all");
+ const [categoryId, setCategoryId] = useState(filters.category_id || "all");
+ const [search, setSearch] = useState(filters.search || "");
+ const [status, setStatus] = useState(filters.status || "all");
+ const [perPage, setPerPage] = useState(filters.per_page?.toString() || "10");
+
+ const handleFilter = useCallback(() => {
+ router.get(
+ route("inventory.analysis.index"),
+ {
+ warehouse_id: warehouseId === "all" ? "" : warehouseId,
+ category_id: categoryId === "all" ? "" : categoryId,
+ status: status === "all" ? "" : status,
+ search: search,
+ per_page: perPage,
+ sort_by: filters.sort_by,
+ sort_order: filters.sort_order,
+ },
+ { preserveState: true, preserveScroll: true }
+ );
+ }, [warehouseId, categoryId, status, search, perPage, filters.sort_by, filters.sort_order]);
+
+ const handleClearFilters = () => {
+ setWarehouseId("all");
+ setCategoryId("all");
+ setStatus("all");
+ setSearch("");
+ setPerPage("10");
+ router.get(route("inventory.analysis.index"));
+ };
+
+ const handleSort = (field: string) => {
+ let newSortBy: string | undefined = field;
+ let newSortOrder: 'asc' | 'desc' | undefined = 'asc';
+
+ if (filters.sort_by === field) {
+ if (filters.sort_order === 'asc') {
+ newSortOrder = 'desc';
+ } else {
+ newSortBy = undefined;
+ newSortOrder = undefined;
+ }
+ } else {
+ // Default sort order for numeric fields might be desc
+ if (['turnover_days', 'current_stock', 'sales_30d'].includes(field)) {
+ newSortOrder = 'desc';
+ }
+ }
+
+ router.get(
+ route("inventory.analysis.index"),
+ {
+ warehouse_id: warehouseId === "all" ? "" : warehouseId,
+ category_id: categoryId === "all" ? "" : categoryId,
+ status: status === "all" ? "" : status,
+ search: search,
+ per_page: perPage,
+ sort_by: newSortBy,
+ sort_order: newSortOrder,
+ },
+ { preserveState: true, preserveScroll: true }
+ );
+ };
+
+ const handlePerPageChange = (value: string) => {
+ setPerPage(value);
+ // Trigger filter immediately
+ router.get(
+ route("inventory.analysis.index"),
+ {
+ warehouse_id: warehouseId === "all" ? "" : warehouseId,
+ category_id: categoryId === "all" ? "" : categoryId,
+ status: status === "all" ? "" : status,
+ search: search,
+ per_page: value,
+ sort_by: filters.sort_by,
+ sort_order: filters.sort_order,
+ },
+ { preserveState: true, preserveScroll: true }
+ );
+ };
+
+ const SortIcon = ({ field }: { field: string }) => {
+ if (filters.sort_by !== field) {
+ return ;
+ }
+ if (filters.sort_order === "asc") {
+ return ;
+ }
+ return ;
+ };
+
+ return (
+
+
+
+
+ {/* Header */}
+
+
+
+
+ 庫存分析
+
+
+ 分析商品庫存週轉率、滯銷品項與庫存健康度
+
+
+
+
+ {/* KPI Cards */}
+
+
+
+
+
+
+
平均週轉天數
+
{kpis.avg_turnover_days} 天
+
+
+
+
+
+
+
滯銷品項數
+
{kpis.dead_stock_count} 項
+
+
+
+
+
+
+
+
+
滯銷庫存成本
+
+
+
+ ${Number(kpis.dead_stock_value).toLocaleString()}
+
+
+ 在此定義為庫存大於 0 且超過 90 天未銷售的商品成本總和
+
+
+
+
+
+
+
+
+
+
+
+
庫存總成本
+
${Number(kpis.total_stock_value).toLocaleString()}
+
+
+
+
+ {/* Filters */}
+
+
+ {/* Search */}
+
+
+ setSearch(e.target.value)}
+ className="h-9 bg-white"
+ onKeyDown={(e) => e.key === 'Enter' && handleFilter()}
+ />
+
+
+ {/* Warehouse & Category */}
+
+
+ ({ label: w.name, value: w.id.toString() }))]}
+ className="w-full h-9"
+ placeholder="選擇倉庫..."
+ />
+
+
+
+ ({ label: c.name, value: c.id.toString() }))]}
+ className="w-full h-9"
+ placeholder="選擇分類..."
+ />
+
+
+
+
+
+
+
+
+
+ {/* Action Buttons Integrated */}
+
+
+
+
+
+
+
+ {/* Results Table */}
+
+
+
+
+ handleSort('products.code')}>
+ 商品代碼
+
+ handleSort('products.name')}>
+ 商品名稱
+
+ 分類
+ handleSort('current_stock')}>
+ 現有庫存
+
+ handleSort('sales_30d')}>
+ 30天銷量
+
+ handleSort('turnover_days')}>
+ 週轉天數
+
+ handleSort('last_sale_date')}>
+ 最後銷售
+
+ 狀態
+
+
+
+ {analysisData.data.length === 0 ? (
+
+
+
+
+
+ ) : (
+ analysisData.data.map((row) => (
+
+
+ {row.code}
+
+
+ {row.name}
+
+ {row.category_name || '-'}
+
+ {Number(row.current_stock).toLocaleString()}
+
+
+ {Number(row.sales_30d).toLocaleString()}
+
+
+ {row.turnover_days_display}
+
+
+ {row.last_sale_date ? row.last_sale_date.split(' ')[0] : '從未銷售'}
+
+
+
+ {getStatusLabel(row.status)}
+
+
+
+ ))
+ )}
+
+
+
+
+ {/* Pagination Footer */}
+
+
+
+ );
+}