feat(dashboard): 新增庫存積壓、熱銷數量與即將過期排行,優化熱銷商品顯示與 Tooltip
This commit is contained in:
@@ -32,6 +32,102 @@ class DashboardController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$invStats = $this->inventoryService->getDashboardStats();
|
$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', [
|
return Inertia::render('Dashboard', [
|
||||||
'stats' => [
|
'stats' => [
|
||||||
@@ -39,8 +135,18 @@ class DashboardController extends Controller
|
|||||||
'lowStockCount' => $invStats['lowStockCount'],
|
'lowStockCount' => $invStats['lowStockCount'],
|
||||||
'negativeCount' => $invStats['negativeCount'] ?? 0,
|
'negativeCount' => $invStats['negativeCount'] ?? 0,
|
||||||
'expiringCount' => $invStats['expiringCount'] ?? 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'] ?? [],
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,30 @@
|
|||||||
import { Head, Link } from "@inertiajs/react";
|
import { Head, Link } from "@inertiajs/react";
|
||||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||||
import {
|
import {
|
||||||
Package,
|
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
MinusCircle,
|
MinusCircle,
|
||||||
Clock,
|
Clock,
|
||||||
ArrowRight,
|
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
TrendingUp,
|
||||||
|
DollarSign,
|
||||||
|
ClipboardCheck,
|
||||||
|
Trophy,
|
||||||
|
Package,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Table,
|
AreaChart,
|
||||||
TableBody,
|
Area,
|
||||||
TableCell,
|
XAxis,
|
||||||
TableHead,
|
YAxis,
|
||||||
TableHeader,
|
CartesianGrid,
|
||||||
TableRow,
|
Tooltip as RechartsTooltip,
|
||||||
} from "@/Components/ui/table";
|
ResponsiveContainer,
|
||||||
import { StatusBadge, StatusVariant } from "@/Components/shared/StatusBadge";
|
} from "recharts";
|
||||||
import { Button } from "@/Components/ui/button";
|
import {
|
||||||
|
Tooltip,
|
||||||
interface AbnormalItem {
|
TooltipContent,
|
||||||
id: number;
|
TooltipTrigger,
|
||||||
product_code: string;
|
} from "@/Components/ui/tooltip";
|
||||||
product_name: string;
|
|
||||||
warehouse_name: string;
|
|
||||||
quantity: number;
|
|
||||||
safety_stock: number | null;
|
|
||||||
expiry_date: string | null;
|
|
||||||
statuses: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
stats: {
|
stats: {
|
||||||
@@ -36,45 +32,71 @@ interface Props {
|
|||||||
lowStockCount: number;
|
lowStockCount: number;
|
||||||
negativeCount: number;
|
negativeCount: number;
|
||||||
expiringCount: 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 映射
|
export default function Dashboard({ stats }: Props) {
|
||||||
const statusConfig: Record<string, { label: string; className: string }> = {
|
const mainCards = [
|
||||||
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 = [
|
|
||||||
{
|
{
|
||||||
label: "庫存明細數",
|
label: "庫存總值",
|
||||||
value: stats.totalItems,
|
value: `NT$ ${Math.round(stats.totalInventoryValue).toLocaleString()}`,
|
||||||
icon: <Package className="h-6 w-6" />,
|
description: `品項總數: ${stats.totalItems}`,
|
||||||
color: "text-primary-main",
|
icon: <TrendingUp className="h-5 w-5" />,
|
||||||
bgColor: "bg-primary-lightest",
|
color: "text-blue-600",
|
||||||
borderColor: "border-primary-light",
|
bgColor: "bg-blue-50",
|
||||||
href: "/inventory/stock-query",
|
borderColor: "border-blue-100",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "本月銷售營收",
|
||||||
|
value: `NT$ ${Math.round(stats.thisMonthRevenue).toLocaleString()}`,
|
||||||
|
description: "基於銷售導入數據",
|
||||||
|
icon: <DollarSign className="h-5 w-5" />,
|
||||||
|
color: "text-emerald-600",
|
||||||
|
bgColor: "bg-emerald-50",
|
||||||
|
borderColor: "border-emerald-100",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "待辦任務",
|
||||||
|
value: stats.todoCount,
|
||||||
|
description: (
|
||||||
|
<div className="flex items-center gap-1 font-medium">
|
||||||
|
<Link href={route('purchase-orders.index')} className="text-purple-600 hover:text-purple-800 hover:underline transition-colors">
|
||||||
|
採購: {stats.pendingOrdersCount}
|
||||||
|
</Link>
|
||||||
|
<span className="mx-1 text-gray-400">|</span>
|
||||||
|
<Link href={route('production-orders.index')} className="text-purple-600 hover:text-purple-800 hover:underline transition-colors">
|
||||||
|
生產: {stats.pendingProductionCount}
|
||||||
|
</Link>
|
||||||
|
<span className="mx-1 text-gray-400">|</span>
|
||||||
|
<Link href={route('inventory.transfer.index')} className="text-purple-600 hover:text-purple-800 hover:underline transition-colors">
|
||||||
|
調撥: {stats.pendingTransferCount}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
icon: <ClipboardCheck className="h-5 w-5" />,
|
||||||
|
color: "text-purple-600",
|
||||||
|
bgColor: "bg-purple-50",
|
||||||
|
borderColor: "border-purple-100",
|
||||||
|
alert: stats.todoCount > 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const alertCards = [
|
||||||
{
|
{
|
||||||
label: "低庫存",
|
label: "低庫存",
|
||||||
value: stats.lowStockCount,
|
value: stats.lowStockCount,
|
||||||
icon: <AlertTriangle className="h-6 w-6" />,
|
icon: <AlertTriangle className="h-4 w-4" />,
|
||||||
color: "text-amber-600",
|
color: "text-amber-600",
|
||||||
bgColor: "bg-amber-50",
|
bgColor: "bg-amber-50",
|
||||||
borderColor: "border-amber-200",
|
borderColor: "border-amber-200",
|
||||||
@@ -84,7 +106,7 @@ export default function Dashboard({ stats, abnormalItems }: Props) {
|
|||||||
{
|
{
|
||||||
label: "負庫存",
|
label: "負庫存",
|
||||||
value: stats.negativeCount,
|
value: stats.negativeCount,
|
||||||
icon: <MinusCircle className="h-6 w-6" />,
|
icon: <MinusCircle className="h-4 w-4" />,
|
||||||
color: "text-red-600",
|
color: "text-red-600",
|
||||||
bgColor: "bg-red-50",
|
bgColor: "bg-red-50",
|
||||||
borderColor: "border-red-200",
|
borderColor: "border-red-200",
|
||||||
@@ -94,7 +116,7 @@ export default function Dashboard({ stats, abnormalItems }: Props) {
|
|||||||
{
|
{
|
||||||
label: "即將過期",
|
label: "即將過期",
|
||||||
value: stats.expiringCount,
|
value: stats.expiringCount,
|
||||||
icon: <Clock className="h-6 w-6" />,
|
icon: <Clock className="h-4 w-4" />,
|
||||||
color: "text-yellow-600",
|
color: "text-yellow-600",
|
||||||
bgColor: "bg-yellow-50",
|
bgColor: "bg-yellow-50",
|
||||||
borderColor: "border-yellow-200",
|
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 (
|
return (
|
||||||
<AuthenticatedLayout
|
<AuthenticatedLayout
|
||||||
breadcrumbs={[
|
breadcrumbs={[{ label: "儀表板", href: "/", isPage: true }]}
|
||||||
{
|
|
||||||
label: "儀表板",
|
|
||||||
href: "/",
|
|
||||||
isPage: true,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
<Head title="儀表板" />
|
<Head title="儀表板" />
|
||||||
|
|
||||||
<div className="container mx-auto p-6 max-w-7xl">
|
<div className="container mx-auto p-6 max-w-7xl space-y-8">
|
||||||
{/* 頁面標題 */}
|
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
||||||
<div className="mb-6">
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||||
<LayoutDashboard className="h-6 w-6 text-primary-main" />
|
<LayoutDashboard className="h-6 w-6 text-primary-main" />
|
||||||
庫存總覽
|
系統概況
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-500 mt-1">
|
<p className="text-gray-500 mt-1">即時分析營運數據與庫存警示</p>
|
||||||
即時掌握庫存狀態,異常情況一目了然
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 統計卡片 */}
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
|
||||||
{cards.map((card) => (
|
|
||||||
<Link key={card.label} href={card.href}>
|
|
||||||
<div
|
|
||||||
className={`relative rounded-xl border ${card.borderColor} ${card.bgColor} p-5 transition-all hover:shadow-md hover:-translate-y-0.5 cursor-pointer`}
|
|
||||||
>
|
|
||||||
{card.alert && (
|
|
||||||
<span className="absolute top-3 right-3 h-2.5 w-2.5 rounded-full bg-red-500 animate-pulse" />
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-3 mb-3">
|
|
||||||
<div className={card.color}>
|
|
||||||
{card.icon}
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-medium text-grey-1">
|
|
||||||
{card.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`text-3xl font-bold ${card.color}`}
|
|
||||||
>
|
|
||||||
{card.value.toLocaleString()}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{alertCards.map((card) => (
|
||||||
|
<Link key={card.label} href={card.href} className="flex-1 md:flex-none">
|
||||||
|
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border ${card.borderColor} ${card.bgColor} transition-colors hover:shadow-sm`}>
|
||||||
|
<div className={card.color}>{card.icon}</div>
|
||||||
|
<span className="text-xs font-medium text-gray-700">{card.label}</span>
|
||||||
|
<span className={`text-sm font-bold ${card.color}`}>{card.value}</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 異常庫存清單 */}
|
|
||||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
|
|
||||||
<h2 className="text-lg font-semibold text-grey-0 flex items-center gap-2">
|
|
||||||
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
|
||||||
異常庫存清單
|
|
||||||
</h2>
|
|
||||||
<Link href="/inventory/stock-query?status=abnormal">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="button-outlined-primary gap-1"
|
|
||||||
>
|
|
||||||
查看完整庫存
|
|
||||||
<ArrowRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Table>
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
<TableHeader className="bg-gray-50">
|
{mainCards.map((card) => (
|
||||||
<TableRow>
|
<div key={card.label} className={`relative rounded-xl border ${card.borderColor} bg-white p-6 shadow-sm`}>
|
||||||
<TableHead className="w-[50px] text-center">
|
<div className="flex items-center justify-between mb-4">
|
||||||
#
|
<div className={`p-2 rounded-lg ${card.bgColor} ${card.color}`}>
|
||||||
</TableHead>
|
{card.icon}
|
||||||
<TableHead>商品代碼</TableHead>
|
</div>
|
||||||
<TableHead>商品名稱</TableHead>
|
{card.alert && (
|
||||||
<TableHead>倉庫</TableHead>
|
<span className="flex h-2 w-2 rounded-full bg-red-500 animate-pulse" />
|
||||||
<TableHead className="text-right">
|
)}
|
||||||
數量
|
</div>
|
||||||
</TableHead>
|
<div className="text-sm font-medium text-gray-500 mb-1">{card.label}</div>
|
||||||
<TableHead className="text-center">
|
<div className="text-2xl font-bold text-gray-900 mb-1">{card.value}</div>
|
||||||
狀態
|
<div className="text-xs text-gray-400">{card.description}</div>
|
||||||
</TableHead>
|
</div>
|
||||||
</TableRow>
|
))}
|
||||||
</TableHeader>
|
</div>
|
||||||
<TableBody>
|
|
||||||
{abnormalItems.length === 0 ? (
|
{/* 銷售趨勢 & 熱銷排行 */}
|
||||||
<TableRow>
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<TableCell
|
{/* 銷售趨勢 - Area Chart */}
|
||||||
colSpan={6}
|
<div className="lg:col-span-2 bg-white rounded-xl border border-gray-200 shadow-sm p-6">
|
||||||
className="text-center py-8 text-gray-500"
|
<div className="flex items-center gap-2 mb-6">
|
||||||
>
|
<TrendingUp className="h-5 w-5 text-emerald-500" />
|
||||||
🎉 目前沒有異常庫存,一切正常!
|
<h2 className="text-lg font-semibold text-gray-800">近 30 日銷售趨勢</h2>
|
||||||
</TableCell>
|
</div>
|
||||||
</TableRow>
|
<div className="h-[300px] w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={stats.salesTrend} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorAmount" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#10b981" stopOpacity={0.8} />
|
||||||
|
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<XAxis dataKey="date" />
|
||||||
|
<YAxis tickFormatter={(value) => `$${value / 1000}k`} />
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||||
|
<RechartsTooltip formatter={(value) => `NT$ ${Number(value).toLocaleString()}`} />
|
||||||
|
<Area type="monotone" dataKey="amount" stroke="#10b981" fillOpacity={1} fill="url(#colorAmount)" />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 熱銷商品排行 (金額) - Bar Chart */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
|
||||||
|
<div className="flex items-center gap-2 mb-6">
|
||||||
|
<Trophy className="h-5 w-5 text-indigo-500" />
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800">熱銷金額 Top 5</h2>
|
||||||
|
</div>
|
||||||
|
<div className="h-[300px] w-full flex flex-col justify-center space-y-6">
|
||||||
|
{stats.topSellingProducts.length > 0 ? (
|
||||||
|
(() => {
|
||||||
|
const maxAmount = Math.max(...stats.topSellingProducts.map(p => p.amount));
|
||||||
|
return stats.topSellingProducts.map((product, index) => (
|
||||||
|
<div key={index} className="space-y-1">
|
||||||
|
<div className="flex justify-between items-end">
|
||||||
|
<div className="min-w-0 flex-1 pr-4">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="block text-sm font-medium text-gray-700 truncate cursor-help">
|
||||||
|
{product.name}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{product.name}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-bold text-indigo-600 shrink-0">
|
||||||
|
NT$ {product.amount.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-100 rounded-full h-2 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="bg-indigo-500 h-2 rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${(product.amount / maxAmount) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
})()
|
||||||
) : (
|
) : (
|
||||||
abnormalItems.map((item, index) => (
|
<div className="h-full flex items-center justify-center text-gray-400 text-sm">暫無銷售數據</div>
|
||||||
<TableRow key={item.id}>
|
|
||||||
<TableCell className="text-gray-500 font-medium text-center">
|
|
||||||
{index + 1}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono text-sm">
|
|
||||||
{item.product_code}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-medium">
|
|
||||||
{item.product_name}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{item.warehouse_name}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell
|
|
||||||
className={`text-right font-medium ${item.quantity < 0
|
|
||||||
? "text-red-600"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{item.quantity}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-center">
|
|
||||||
<div className="flex flex-wrap items-center justify-center gap-1">
|
|
||||||
{item.statuses.map(
|
|
||||||
(status) => (
|
|
||||||
<StatusBadge
|
|
||||||
key={status}
|
|
||||||
variant={getStatusVariant(status)}
|
|
||||||
>
|
|
||||||
{getStatusLabel(status)}
|
|
||||||
</StatusBadge>
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</div>
|
||||||
</TableRow>
|
</div>
|
||||||
))
|
|
||||||
|
{/* 其他排行資訊 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{/* 庫存積壓排行 */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-gray-100 bg-gray-50 flex items-center gap-2">
|
||||||
|
<DollarSign className="h-4 w-4 text-blue-500" />
|
||||||
|
<h3 className="font-semibold text-gray-700">庫存積壓 Top 5</h3>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{stats.topInventoryValue.length > 0 ? stats.topInventoryValue.map((item, idx) => (
|
||||||
|
<div key={idx} className="p-3 flex items-center justify-between hover:bg-gray-50 transition-colors">
|
||||||
|
<div className="min-w-0 flex-1 pr-2">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="text-sm font-medium text-gray-900 truncate cursor-help">{item.name}</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{item.name}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<div className="text-xs text-gray-500 truncate">{item.code}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-sm font-bold text-gray-700">NT$ {item.value.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)) : (
|
||||||
|
<div className="p-8 text-center text-gray-400 text-sm">無庫存資料</div>
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</div>
|
||||||
</Table>
|
</div>
|
||||||
|
|
||||||
|
{/* 熱銷數量排行 */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-gray-100 bg-gray-50 flex items-center gap-2">
|
||||||
|
<Package className="h-4 w-4 text-emerald-500" />
|
||||||
|
<h3 className="font-semibold text-gray-700">熱銷數量 Top 5</h3>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{stats.topSellingByQuantity.length > 0 ? stats.topSellingByQuantity.map((item, idx) => (
|
||||||
|
<div key={idx} className="p-3 flex items-center justify-between hover:bg-gray-50 transition-colors">
|
||||||
|
<div className="min-w-0 flex-1 pr-2">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="text-sm font-medium text-gray-900 truncate cursor-help">{item.name}</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{item.name}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<div className="text-xs text-gray-500 truncate">{item.code}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-sm font-bold text-gray-700">{item.value.toLocaleString()} <span className="text-xs font-normal text-gray-500">件</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)) : (
|
||||||
|
<div className="p-8 text-center text-gray-400 text-sm">無銷售資料</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 即將過期商品 */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-gray-100 bg-gray-50 flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4 text-red-500" />
|
||||||
|
<h3 className="font-semibold text-gray-700">即將過期 Top 5</h3>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{stats.expiringSoon.length > 0 ? stats.expiringSoon.map((item, idx) => (
|
||||||
|
<div key={idx} className="p-3 flex items-center justify-between hover:bg-gray-50 transition-colors">
|
||||||
|
<div className="min-w-0 flex-1 pr-2">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="text-sm font-medium text-gray-900 truncate cursor-help">{item.name}</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{item.name}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<div className="text-xs text-gray-500 truncate">批號: {item.batch_number}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-sm font-bold text-red-600">{item.expiry_date}</div>
|
||||||
|
<div className="text-xs text-gray-500">庫存: {item.quantity}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)) : (
|
||||||
|
<div className="p-8 text-center text-green-500 text-sm">目前無即將過期商品</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AuthenticatedLayout>
|
</AuthenticatedLayout>
|
||||||
|
|||||||
Reference in New Issue
Block a user