diff --git a/app/Modules/Core/Controllers/DashboardController.php b/app/Modules/Core/Controllers/DashboardController.php index 24a5629..185e277 100644 --- a/app/Modules/Core/Controllers/DashboardController.php +++ b/app/Modules/Core/Controllers/DashboardController.php @@ -32,6 +32,102 @@ class DashboardController extends Controller } $invStats = $this->inventoryService->getDashboardStats(); + $procStats = $this->procurementService->getDashboardStats(); + + // 銷售統計 (本月營收) + $thisMonthRevenue = \App\Modules\Sales\Models\SalesImportItem::whereMonth('transaction_at', now()->month) + ->whereYear('transaction_at', now()->year) + ->sum('amount'); + + // 生產統計 (待核准工單) + $pendingProductionCount = \App\Modules\Production\Models\ProductionOrder::where('status', 'pending')->count(); + + // 生產狀態分佈 + // 近30日銷售趨勢 (Area Chart) + $startDate = now()->subDays(29)->startOfDay(); + $salesData = \App\Modules\Sales\Models\SalesImportItem::where('transaction_at', '>=', $startDate) + ->selectRaw('DATE(transaction_at) as date, SUM(amount) as total') + ->groupBy('date') + ->orderBy('date') + ->get() + ->mapWithKeys(function ($item) { + return [$item->date => (int)$item->total]; + }); + + $salesTrend = []; + for ($i = 0; $i < 30; $i++) { + $date = $startDate->copy()->addDays($i)->format('Y-m-d'); + $salesTrend[] = [ + 'date' => $startDate->copy()->addDays($i)->format('m/d'), + 'amount' => $salesData[$date] ?? 0, + ]; + } + + // 本月熱銷商品 Top 5 (Bar Chart) + $topSellingProducts = \App\Modules\Sales\Models\SalesImportItem::with('product') + ->whereMonth('transaction_at', now()->month) + ->whereYear('transaction_at', now()->year) + ->select('product_code', 'product_id', \Illuminate\Support\Facades\DB::raw('SUM(amount) as total_amount')) + ->groupBy('product_code', 'product_id') + ->orderByDesc('total_amount') + ->limit(5) + ->get() + ->map(function ($item) { + return [ + 'name' => $item->product ? $item->product->name : $item->product_code, + 'amount' => (int)$item->total_amount, + ]; + }); + + // 庫存積壓排行 (Top Inventory Value) + $topInventoryValue = \App\Modules\Inventory\Models\Inventory::with('product') + ->select('product_id', \Illuminate\Support\Facades\DB::raw('SUM(quantity * unit_cost) as total_value')) + ->where('quantity', '>', 0) + ->groupBy('product_id') + ->orderByDesc('total_value') + ->limit(5) + ->get() + ->map(function ($item) { + return [ + 'name' => $item->product ? $item->product->name : 'Unknown Product', + 'code' => $item->product ? $item->product->code : '', + 'value' => (int)$item->total_value, + ]; + }); + + // 熱銷數量排行 (Top Selling by Quantity) + $topSellingByQuantity = \App\Modules\Sales\Models\SalesImportItem::with('product') + ->whereMonth('transaction_at', now()->month) + ->whereYear('transaction_at', now()->year) + ->select('product_code', 'product_id', \Illuminate\Support\Facades\DB::raw('SUM(quantity) as total_quantity')) + ->groupBy('product_code', 'product_id') + ->orderByDesc('total_quantity') + ->limit(5) + ->get() + ->map(function ($item) { + return [ + 'name' => $item->product ? $item->product->name : $item->product_code, + 'code' => $item->product_code, + 'value' => (int)$item->total_quantity, + ]; + }); + + // 即將過期商品 (Expiring Soon) + $expiringSoon = \App\Modules\Inventory\Models\Inventory::with('product') + ->where('quantity', '>', 0) + ->whereNotNull('expiry_date') + ->where('expiry_date', '>=', now()) // 只顯示未過期但即將過期的 + ->orderBy('expiry_date', 'asc') + ->limit(5) + ->get() + ->map(function ($item) { + return [ + 'name' => $item->product ? $item->product->name : 'Unknown Product', + 'batch_number' => $item->batch_number, + 'expiry_date' => $item->expiry_date->format('Y-m-d'), + 'quantity' => (int)$item->quantity, + ]; + }); return Inertia::render('Dashboard', [ 'stats' => [ @@ -39,8 +135,18 @@ class DashboardController extends Controller 'lowStockCount' => $invStats['lowStockCount'], 'negativeCount' => $invStats['negativeCount'] ?? 0, 'expiringCount' => $invStats['expiringCount'] ?? 0, + 'totalInventoryValue' => $invStats['totalInventoryValue'] ?? 0, + 'thisMonthRevenue' => $thisMonthRevenue, + 'pendingOrdersCount' => $procStats['pendingOrdersCount'] ?? 0, + 'pendingTransferCount' => $invStats['pendingTransferCount'] ?? 0, + 'pendingProductionCount' => $pendingProductionCount, + 'todoCount' => ($procStats['pendingOrdersCount'] ?? 0) + ($invStats['pendingTransferCount'] ?? 0) + $pendingProductionCount, + 'salesTrend' => $salesTrend, + 'topSellingProducts' => $topSellingProducts, + 'topInventoryValue' => $topInventoryValue, + 'topSellingByQuantity' => $topSellingByQuantity, + 'expiringSoon' => $expiringSoon, ], - 'abnormalItems' => $invStats['abnormalItems'] ?? [], ]); } } diff --git a/resources/js/Pages/Dashboard.tsx b/resources/js/Pages/Dashboard.tsx index 8c5f37d..f7943f4 100644 --- a/resources/js/Pages/Dashboard.tsx +++ b/resources/js/Pages/Dashboard.tsx @@ -1,34 +1,30 @@ import { Head, Link } from "@inertiajs/react"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; import { - Package, AlertTriangle, MinusCircle, Clock, - ArrowRight, LayoutDashboard, + TrendingUp, + DollarSign, + ClipboardCheck, + Trophy, + Package, } from "lucide-react"; import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/Components/ui/table"; -import { StatusBadge, StatusVariant } from "@/Components/shared/StatusBadge"; -import { Button } from "@/Components/ui/button"; - -interface AbnormalItem { - id: number; - product_code: string; - product_name: string; - warehouse_name: string; - quantity: number; - safety_stock: number | null; - expiry_date: string | null; - statuses: string[]; -} + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip as RechartsTooltip, + ResponsiveContainer, +} from "recharts"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/Components/ui/tooltip"; interface Props { stats: { @@ -36,45 +32,71 @@ interface Props { lowStockCount: number; negativeCount: number; expiringCount: number; + totalInventoryValue: number; + thisMonthRevenue: number; + pendingOrdersCount: number; + pendingTransferCount: number; + pendingProductionCount: number; + todoCount: number; + salesTrend: { date: string; amount: number }[]; + topSellingProducts: { name: string; amount: number }[]; + topInventoryValue: { name: string; code: string; value: number }[]; + topSellingByQuantity: { name: string; code: string; value: number }[]; + expiringSoon: { name: string; batch_number: string; expiry_date: string; quantity: number }[]; }; - abnormalItems: AbnormalItem[]; } -// 狀態 Badge 映射 -const statusConfig: Record = { - negative: { - label: "負庫存", - className: "bg-red-100 text-red-800 border-red-200", - }, - low_stock: { - label: "低庫存", - className: "bg-amber-100 text-amber-800 border-amber-200", - }, - expiring: { - label: "即將過期", - className: "bg-yellow-100 text-yellow-800 border-yellow-200", - }, - expired: { - label: "已過期", - className: "bg-red-100 text-red-800 border-red-200", - }, -}; - -export default function Dashboard({ stats, abnormalItems }: Props) { - const cards = [ +export default function Dashboard({ stats }: Props) { + const mainCards = [ { - label: "庫存明細數", - value: stats.totalItems, - icon: , - color: "text-primary-main", - bgColor: "bg-primary-lightest", - borderColor: "border-primary-light", - href: "/inventory/stock-query", + label: "庫存總值", + value: `NT$ ${Math.round(stats.totalInventoryValue).toLocaleString()}`, + description: `品項總數: ${stats.totalItems}`, + icon: , + color: "text-blue-600", + bgColor: "bg-blue-50", + borderColor: "border-blue-100", }, + { + label: "本月銷售營收", + value: `NT$ ${Math.round(stats.thisMonthRevenue).toLocaleString()}`, + description: "基於銷售導入數據", + icon: , + color: "text-emerald-600", + bgColor: "bg-emerald-50", + borderColor: "border-emerald-100", + }, + { + label: "待辦任務", + value: stats.todoCount, + description: ( +
+ + 採購: {stats.pendingOrdersCount} + + | + + 生產: {stats.pendingProductionCount} + + | + + 調撥: {stats.pendingTransferCount} + +
+ ), + icon: , + color: "text-purple-600", + bgColor: "bg-purple-50", + borderColor: "border-purple-100", + alert: stats.todoCount > 0, + }, + ]; + + const alertCards = [ { label: "低庫存", value: stats.lowStockCount, - icon: , + icon: , color: "text-amber-600", bgColor: "bg-amber-50", borderColor: "border-amber-200", @@ -84,7 +106,7 @@ export default function Dashboard({ stats, abnormalItems }: Props) { { label: "負庫存", value: stats.negativeCount, - icon: , + icon: , color: "text-red-600", bgColor: "bg-red-50", borderColor: "border-red-200", @@ -94,7 +116,7 @@ export default function Dashboard({ stats, abnormalItems }: Props) { { label: "即將過期", value: stats.expiringCount, - icon: , + icon: , color: "text-yellow-600", bgColor: "bg-yellow-50", borderColor: "border-yellow-200", @@ -103,161 +125,216 @@ export default function Dashboard({ stats, abnormalItems }: Props) { }, ]; - const getStatusVariant = (status: string): StatusVariant => { - switch (status) { - case 'negative': return 'destructive'; - case 'low_stock': return 'warning'; - case 'expiring': return 'warning'; - case 'expired': return 'destructive'; - default: return 'neutral'; - } - }; - - const getStatusLabel = (status: string): string => { - const config = statusConfig[status]; - return config ? config.label : status; - }; - return ( -
- {/* 頁面標題 */} -
-

- - 庫存總覽 -

-

- 即時掌握庫存狀態,異常情況一目了然 -

+
+
+
+

+ + 系統概況 +

+

即時分析營運數據與庫存警示

+
+
+ {alertCards.map((card) => ( + +
+
{card.icon}
+ {card.label} + {card.value} +
+ + ))} +
- {/* 統計卡片 */} -
- {cards.map((card) => ( - -
+
+ {mainCards.map((card) => ( +
+
+
+ {card.icon} +
{card.alert && ( - + )} -
-
- {card.icon} -
- - {card.label} - -
-
- {card.value.toLocaleString()} -
- +
{card.label}
+
{card.value}
+
{card.description}
+
))}
- {/* 異常庫存清單 */} -
-
-

- - 異常庫存清單 -

- - - + {/* 銷售趨勢 & 熱銷排行 */} +
+ {/* 銷售趨勢 - Area Chart */} +
+
+ +

近 30 日銷售趨勢

+
+
+ + + + + + + + + + `$${value / 1000}k`} /> + + `NT$ ${Number(value).toLocaleString()}`} /> + + + +
- - - - - # - - 商品代碼 - 商品名稱 - 倉庫 - - 數量 - - - 狀態 - - - - - {abnormalItems.length === 0 ? ( - - - 🎉 目前沒有異常庫存,一切正常! - - - ) : ( - abnormalItems.map((item, index) => ( - - - {index + 1} - - - {item.product_code} - - - {item.product_name} - - - {item.warehouse_name} - - - {item.quantity} - - -
- {item.statuses.map( - (status) => ( - - {getStatusLabel(status)} - - ) - )} + {/* 熱銷商品排行 (金額) - Bar Chart */} +
+
+ +

熱銷金額 Top 5

+
+
+ {stats.topSellingProducts.length > 0 ? ( + (() => { + const maxAmount = Math.max(...stats.topSellingProducts.map(p => p.amount)); + return stats.topSellingProducts.map((product, index) => ( +
+
+
+ + + + {product.name} + + + +

{product.name}

+
+
+
+ + NT$ {product.amount.toLocaleString()} +
- - - )) +
+
+
+
+ )); + })() + ) : ( +
暫無銷售數據
)} - -
+
+
+
+ + {/* 其他排行資訊 */} +
+ {/* 庫存積壓排行 */} +
+
+ +

庫存積壓 Top 5

+
+
+ {stats.topInventoryValue.length > 0 ? stats.topInventoryValue.map((item, idx) => ( +
+
+ + +
{item.name}
+
+ +

{item.name}

+
+
+
{item.code}
+
+
+
NT$ {item.value.toLocaleString()}
+
+
+ )) : ( +
無庫存資料
+ )} +
+
+ + {/* 熱銷數量排行 */} +
+
+ +

熱銷數量 Top 5

+
+
+ {stats.topSellingByQuantity.length > 0 ? stats.topSellingByQuantity.map((item, idx) => ( +
+
+ + +
{item.name}
+
+ +

{item.name}

+
+
+
{item.code}
+
+
+
{item.value.toLocaleString()}
+
+
+ )) : ( +
無銷售資料
+ )} +
+
+ + {/* 即將過期商品 */} +
+
+ +

即將過期 Top 5

+
+
+ {stats.expiringSoon.length > 0 ? stats.expiringSoon.map((item, idx) => ( +
+
+ + +
{item.name}
+
+ +

{item.name}

+
+
+
批號: {item.batch_number}
+
+
+
{item.expiry_date}
+
庫存: {item.quantity}
+
+
+ )) : ( +
目前無即將過期商品
+ )} +
+