Files
star-erp/resources/js/Pages/Procurement/Analysis/Index.tsx
sky121113 58bd995cd8
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m13s
ERP-Deploy-Production / deploy-production (push) Successful in 1m13s
feat: 新增採購統計分析功能並優化 API 文件顯示樣式
- 在 RoleController 中新增 procurement_analysis 權限群組名稱
- 在 Procurement 模組中新增採購統計分析路由
- 在 PermissionSeeder 中新增 procurement_analysis.view 權限並分配給角色
- 在側邊欄「報表與分析」分組中新增「採購統計分析」項目
- 優化 API 文件視圖中的表格外觀樣式
2026-03-03 11:38:04 +08:00

849 lines
42 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, useMemo } from "react";
import { Head, router } 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 { SearchableSelect } from "@/Components/ui/searchable-select";
import {
BarChart3,
TrendingUp,
DollarSign,
Truck,
Clock,
CheckCircle,
Package,
Filter,
RotateCcw,
ShoppingCart,
Info,
} from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/Components/ui/tooltip";
import {
BarChart,
Bar,
LineChart,
Line,
PieChart,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip as RechartsTooltip,
ResponsiveContainer,
Legend,
AreaChart,
Area,
} from "recharts";
// ===== 型別定義 =====
interface KPIs {
total_amount: number;
total_orders: number;
total_receipts: number;
avg_delivery_days: number;
on_time_rate: number;
}
interface VendorDelivery {
vendor_id: number;
vendor_name: string;
total_count: number;
avg_days: number;
min_days: number;
max_days: number;
on_time_count: number;
on_time_rate: number;
}
interface DelayDistribution {
category: string;
count: number;
}
interface MonthlyTrend {
month: string;
total_quantity: number;
total_amount: number;
receipt_count: number;
}
interface VendorShare {
vendor_id: number;
vendor_name: string;
total_amount: number;
total_quantity: number;
}
interface ProductRanking {
product_id: number;
product_name: string;
product_code: string;
total_quantity: number;
total_amount: number;
}
interface PriceTrendItem {
product_id: number;
product_name: string;
month: string;
avg_price: number;
min_price: number;
max_price: number;
}
interface VendorComparison {
product_id: number;
product_name: string;
vendor_id: number;
vendor_name: string;
avg_price: number;
min_price: number;
max_price: number;
purchase_count: number;
}
interface FilterOption {
id: number;
name: string;
code?: string;
}
interface Props {
kpis: KPIs;
deliveryAnalysis: {
vendor_delivery: VendorDelivery[];
delay_distribution: DelayDistribution[];
};
quantityAnalysis: {
monthly_trend: MonthlyTrend[];
vendor_share: VendorShare[];
product_ranking: ProductRanking[];
};
priceTrendAnalysis: {
price_trend: PriceTrendItem[];
vendor_comparison: VendorComparison[];
};
vendors: FilterOption[];
warehouses: FilterOption[];
filters: {
date_from?: string;
date_to?: string;
vendor_id?: string;
warehouse_id?: string;
};
}
// 圓餅圖色彩
const PIE_COLORS = [
"#0ea5e9", "#8b5cf6", "#10b981", "#f59e0b", "#ef4444",
"#ec4899", "#06b6d4", "#84cc16", "#f97316", "#6366f1",
];
// Tab 定義
const TABS = [
{ key: "delivery", label: "供貨時效", icon: <Truck className="h-4 w-4" /> },
{ key: "quantity", label: "數量分析", icon: <Package className="h-4 w-4" /> },
{ key: "price", label: "價格趨勢", icon: <TrendingUp className="h-4 w-4" /> },
] as const;
type TabKey = (typeof TABS)[number]["key"];
export default function ProcurementAnalysisIndex({
kpis,
deliveryAnalysis,
quantityAnalysis,
priceTrendAnalysis,
vendors,
warehouses,
filters,
}: Props) {
const [activeTab, setActiveTab] = useState<TabKey>("delivery");
const [localFilters, setLocalFilters] = useState({
date_from: filters.date_from || "",
date_to: filters.date_to || "",
vendor_id: filters.vendor_id || "",
warehouse_id: filters.warehouse_id || "",
});
const handleApplyFilters = () => {
const params: Record<string, string> = {};
Object.entries(localFilters).forEach(([key, value]) => {
if (value) params[key] = value;
});
router.get(route("procurement.analysis.index"), params, {
preserveState: true,
preserveScroll: true,
});
};
const handleClearFilters = () => {
setLocalFilters({ date_from: "", date_to: "", vendor_id: "", warehouse_id: "" });
router.get(route("procurement.analysis.index"), {}, {
preserveState: true,
preserveScroll: true,
});
};
// ===== KPI 卡片資料 =====
const kpiCards = [
{
label: "採購總金額",
tooltip: "期間內所有非草稿、非取消的採購單總金額",
value: `NT$ ${Math.round(kpis.total_amount).toLocaleString()}`,
description: `${kpis.total_orders} 筆採購單`,
icon: <DollarSign className="h-5 w-5" />,
color: "text-blue-600",
bgColor: "bg-blue-50",
borderColor: "border-blue-100",
},
{
label: "進貨筆數",
tooltip: "期間內狀態為「已完成」的進貨單數量",
value: kpis.total_receipts,
description: "已完成進貨單",
icon: <Package className="h-5 w-5" />,
color: "text-emerald-600",
bgColor: "bg-emerald-50",
borderColor: "border-emerald-100",
},
{
label: "平均交期",
tooltip: "真實進貨日期減去採購單日期的平均天數",
value: `${kpis.avg_delivery_days}`,
description: "下單到到貨平均天數",
icon: <Clock className="h-5 w-5" />,
color: "text-amber-600",
bgColor: "bg-amber-50",
borderColor: "border-amber-100",
},
{
label: "準時到貨率",
tooltip: "進貨單真實進貨日期早於或等於採購單預計到貨日的比例",
value: `${kpis.on_time_rate}%`,
description: "於預計交期內到貨",
icon: <CheckCircle className="h-5 w-5" />,
color: kpis.on_time_rate >= 80 ? "text-emerald-600" : "text-red-600",
bgColor: kpis.on_time_rate >= 80 ? "bg-emerald-50" : "bg-red-50",
borderColor: kpis.on_time_rate >= 80 ? "border-emerald-100" : "border-red-100",
},
];
return (
<AuthenticatedLayout
breadcrumbs={[
{ label: "報表與分析", href: "#" },
{ label: "採購統計分析", href: route("procurement.analysis.index"), isPage: true },
]}
>
<Head title="採購統計分析" />
<div className="container mx-auto p-6 max-w-7xl space-y-6">
{/* 頁面標題 */}
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<BarChart3 className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">
</p>
</div>
{/* 篩選列 */}
<div className="bg-white rounded-xl shadow-sm border border-grey-4 p-5">
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 items-end">
<div className="md:col-span-2 space-y-1">
<Label className="text-xs text-grey-2 font-medium"></Label>
<Input
type="date"
value={localFilters.date_from}
onChange={(e) => setLocalFilters((f) => ({ ...f, date_from: e.target.value }))}
className="h-9 bg-white"
/>
</div>
<div className="md:col-span-2 space-y-1">
<Label className="text-xs text-grey-2 font-medium"></Label>
<Input
type="date"
value={localFilters.date_to}
onChange={(e) => setLocalFilters((f) => ({ ...f, date_to: e.target.value }))}
className="h-9 bg-white"
/>
</div>
<div className="md:col-span-3 space-y-1">
<Label className="text-xs text-grey-2 font-medium"></Label>
<SearchableSelect
value={localFilters.vendor_id || "all"}
onValueChange={(v) => setLocalFilters((f) => ({ ...f, vendor_id: v === "all" ? "" : v }))}
options={[{ label: "全部廠商", value: "all" }, ...vendors.map((v) => ({ label: v.name, value: v.id.toString() }))]}
className="w-full h-9"
placeholder="選擇廠商..."
/>
</div>
<div className="md:col-span-2 space-y-1">
<Label className="text-xs text-grey-2 font-medium"></Label>
<SearchableSelect
value={localFilters.warehouse_id || "all"}
onValueChange={(v) => setLocalFilters((f) => ({ ...f, warehouse_id: v === "all" ? "" : v }))}
options={[{ label: "全部倉庫", value: "all" }, ...warehouses.map((w) => ({ label: w.name, value: w.id.toString() }))]}
className="w-full h-9"
placeholder="選擇倉庫..."
/>
</div>
<div className="md:col-span-3 flex items-center gap-2">
<Button
variant="outline"
onClick={handleClearFilters}
className="flex-1 items-center gap-2 button-outlined-primary h-9"
>
<RotateCcw className="h-4 w-4" />
</Button>
<Button
onClick={handleApplyFilters}
className="flex-1 button-filled-primary h-9 gap-2"
>
<Filter className="h-4 w-4" />
</Button>
</div>
</div>
</div>
{/* KPI 卡片列 */}
<TooltipProvider>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{kpiCards.map((card) => (
<div key={card.label} className={`rounded-xl border ${card.borderColor} bg-white p-5 shadow-sm`}>
<div className="flex items-center justify-between mb-3">
<div className={`p-2 rounded-lg ${card.bgColor} ${card.color}`}>
{card.icon}
</div>
</div>
<div className="text-sm font-medium text-gray-500 mb-1 flex items-center gap-1">
{card.label}
<Tooltip>
<TooltipTrigger>
<Info className="h-3 w-3 text-gray-400 cursor-help" />
</TooltipTrigger>
<TooltipContent className="max-w-[200px] text-xs">
<p>{card.tooltip}</p>
</TooltipContent>
</Tooltip>
</div>
<div className="text-2xl font-bold text-gray-900 mb-1">{card.value}</div>
<div className="text-xs text-gray-400">{card.description}</div>
</div>
))}
</div>
</TooltipProvider>
{/* Tab 切換 */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<div className="border-b border-gray-200">
<div className="flex">
{TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex items-center gap-2 px-6 py-3 text-sm font-medium border-b-2 transition-colors ${activeTab === tab.key
? "border-primary-main text-primary-main bg-primary-lightest"
: "border-transparent text-gray-500 hover:text-gray-700 hover:bg-gray-50"
}`}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
</div>
<div className="p-6">
{activeTab === "delivery" && (
<DeliveryTab
vendorDelivery={deliveryAnalysis.vendor_delivery}
delayDistribution={deliveryAnalysis.delay_distribution}
/>
)}
{activeTab === "quantity" && (
<QuantityTab
monthlyTrend={quantityAnalysis.monthly_trend}
vendorShare={quantityAnalysis.vendor_share}
productRanking={quantityAnalysis.product_ranking}
/>
)}
{activeTab === "price" && (
<PriceTab
priceTrend={priceTrendAnalysis.price_trend}
vendorComparison={priceTrendAnalysis.vendor_comparison}
/>
)}
</div>
</div>
</div>
</AuthenticatedLayout>
);
}
// ===== Tab 1: 供貨時效 =====
function DeliveryTab({
vendorDelivery,
delayDistribution,
}: {
vendorDelivery: VendorDelivery[];
delayDistribution: DelayDistribution[];
}) {
if (vendorDelivery.length === 0) {
return <EmptyState message="尚無供貨時效資料,請確認篩選條件或是否有已完成的進貨單。" />;
}
const parsedDelayDistribution = useMemo(() => {
return delayDistribution.map(d => ({ ...d, count: Number(d.count) }));
}, [delayDistribution]);
return (
<div className="space-y-6">
<TooltipProvider>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 廠商平均交期長條圖 */}
<div className="lg:col-span-2">
<h3 className="text-sm font-semibold text-gray-700 mb-4 flex items-center gap-2">
<Truck className="h-4 w-4 text-blue-500" />
<Tooltip>
<TooltipTrigger>
<Info className="h-3.5 w-3.5 text-gray-400 cursor-help" />
</TooltipTrigger>
<TooltipContent className="max-w-[250px] text-xs">
<p> - </p>
</TooltipContent>
</Tooltip>
</h3>
<div className="h-[350px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={vendorDelivery} layout="vertical" margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
<XAxis type="number" unit=" 天" />
<YAxis dataKey="vendor_name" type="category" width={120} tick={{ fontSize: 12 }} />
<RechartsTooltip
formatter={(value: number, name: string) => {
const labels: Record<string, string> = {
avg_days: "平均交期",
min_days: "最短",
max_days: "最長",
};
return [`${value}`, labels[name] || name];
}}
/>
<Legend formatter={(v) => ({ avg_days: "平均", min_days: "最短", max_days: "最長" }[v] || v)} />
<Bar dataKey="avg_days" fill="#3b82f6" radius={[0, 4, 4, 0]} />
<Bar dataKey="min_days" fill="#10b981" radius={[0, 4, 4, 0]} />
<Bar dataKey="max_days" fill="#f59e0b" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* 交期分佈圓餅圖 */}
<div>
<h3 className="text-sm font-semibold text-gray-700 mb-4 flex items-center gap-2">
<Clock className="h-4 w-4 text-amber-500" />
</h3>
<div className="h-[350px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={parsedDelayDistribution}
dataKey="count"
nameKey="category"
cx="50%"
cy="50%"
outerRadius={100}
label={false}
labelLine={false}
>
{parsedDelayDistribution.map((_, index) => (
<Cell key={`cell-${index}`} fill={PIE_COLORS[index % PIE_COLORS.length]} />
))}
</Pie>
<RechartsTooltip
formatter={(value: number) => {
const total = parsedDelayDistribution.reduce((sum, item) => sum + item.count, 0);
const percent = total > 0 ? ((value / total) * 100).toFixed(0) : 0;
return [`${value} 筆 (${percent}%)`, "數量"];
}}
/>
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* 廠商準時率排行表格 */}
<div>
<h3 className="text-sm font-semibold text-gray-700 mb-4 flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-emerald-500" />
<Tooltip>
<TooltipTrigger>
<Info className="h-3.5 w-3.5 text-gray-400 cursor-help" />
</TooltipTrigger>
<TooltipContent className="max-w-[250px] text-xs">
<p> = ( / ) * 100%</p>
</TooltipContent>
</Tooltip>
</h3>
<div className="border border-gray-200 rounded-lg overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{vendorDelivery.map((v, idx) => (
<tr key={v.vendor_id} className="hover:bg-gray-50">
<td className="px-4 py-3 text-sm font-medium text-gray-900">{idx + 1}</td>
<td className="px-4 py-3 text-sm text-gray-900">{v.vendor_name}</td>
<td className="px-4 py-3 text-sm text-center text-gray-600">{v.total_count}</td>
<td className="px-4 py-3 text-sm text-center font-medium text-blue-600">{v.avg_days} </td>
<td className="px-4 py-3 text-sm text-center text-emerald-600">{v.min_days} </td>
<td className="px-4 py-3 text-sm text-center text-amber-600">{v.max_days} </td>
<td className="px-4 py-3 text-center">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${v.on_time_rate >= 80 ? "bg-emerald-100 text-emerald-800" :
v.on_time_rate >= 60 ? "bg-amber-100 text-amber-800" :
"bg-red-100 text-red-800"
}`}>
{v.on_time_rate}%
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</TooltipProvider>
</div>
);
}
// ===== Tab 2: 數量分析 =====
function QuantityTab({
monthlyTrend,
vendorShare,
productRanking,
}: {
monthlyTrend: MonthlyTrend[];
vendorShare: VendorShare[];
productRanking: ProductRanking[];
}) {
if (monthlyTrend.length === 0 && vendorShare.length === 0) {
return <EmptyState message="尚無進貨數量資料,請確認篩選條件。" />;
}
const parsedVendorShare = useMemo(() => {
return vendorShare.map(d => ({ ...d, total_amount: Number(d.total_amount) }));
}, [vendorShare]);
return (
<div className="space-y-6">
<TooltipProvider>
{/* 月度趨勢 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<h3 className="text-sm font-semibold text-gray-700 mb-4 flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-blue-500" />
<Tooltip>
<TooltipTrigger>
<Info className="h-3.5 w-3.5 text-gray-400 cursor-help" />
</TooltipTrigger>
<TooltipContent className="max-w-[250px] text-xs">
<p></p>
</TooltipContent>
</Tooltip>
</h3>
<div className="h-[350px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={monthlyTrend} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="colorAmount" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.8} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis dataKey="month" tick={{ fontSize: 12 }} />
<YAxis tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} tick={{ fontSize: 12 }} />
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<RechartsTooltip
formatter={(value: number, name: string) => {
if (name === "total_amount") return [`NT$ ${value.toLocaleString()}`, "進貨金額"];
if (name === "receipt_count") return [`${value}`, "進貨筆數"];
return [value, name];
}}
/>
<Area type="monotone" dataKey="total_amount" stroke="#3b82f6" fillOpacity={1} fill="url(#colorAmount)" />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
{/* 廠商佔比 */}
<div>
<h3 className="text-sm font-semibold text-gray-700 mb-4 flex items-center gap-2">
<ShoppingCart className="h-4 w-4 text-purple-500" />
</h3>
<div className="h-[350px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={parsedVendorShare}
dataKey="total_amount"
nameKey="vendor_name"
cx="50%"
cy="50%"
outerRadius={100}
label={false}
labelLine={false}
>
{parsedVendorShare.map((_, index) => (
<Cell key={`cell-${index}`} fill={PIE_COLORS[index % PIE_COLORS.length]} />
))}
</Pie>
<RechartsTooltip
formatter={(value: number) => {
const total = parsedVendorShare.reduce((sum, item) => sum + item.total_amount, 0);
const percent = total > 0 ? ((value / total) * 100).toFixed(0) : 0;
return [`NT$ ${value.toLocaleString()} (${percent}%)`, "金額"];
}}
/>
<Legend formatter={(value) => value.length > 6 ? value.slice(0, 6) + '…' : value} />
</PieChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* 商品進貨排行 */}
<div>
<h3 className="text-sm font-semibold text-gray-700 mb-4 flex items-center gap-2">
<Package className="h-4 w-4 text-emerald-500" />
Top 10
<Tooltip>
<TooltipTrigger>
<Info className="h-3.5 w-3.5 text-gray-400 cursor-help" />
</TooltipTrigger>
<TooltipContent className="max-w-[250px] text-xs">
<p> 10 </p>
</TooltipContent>
</Tooltip>
</h3>
<div className="border border-gray-200 rounded-lg overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{productRanking.map((p, idx) => (
<tr key={p.product_id} className="hover:bg-gray-50">
<td className="px-4 py-3 text-sm font-medium text-gray-900">{idx + 1}</td>
<td className="px-4 py-3 text-sm text-gray-900">{p.product_name}</td>
<td className="px-4 py-3 text-sm text-gray-500">{p.product_code}</td>
<td className="px-4 py-3 text-sm text-right text-gray-700">{Number(p.total_quantity).toLocaleString()}</td>
<td className="px-4 py-3 text-sm text-right font-medium text-blue-600">NT$ {Number(p.total_amount).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
{productRanking.length === 0 && (
<div className="p-8 text-center text-gray-400 text-sm"></div>
)}
</div>
</div>
</TooltipProvider>
</div>
);
}
// ===== Tab 3: 價格趨勢 =====
function PriceTab({
priceTrend,
vendorComparison,
}: {
priceTrend: PriceTrendItem[];
vendorComparison: VendorComparison[];
}) {
if (priceTrend.length === 0 && vendorComparison.length === 0) {
return <EmptyState message="尚無單價趨勢資料,請確認篩選條件。" />;
}
// 將 priceTrend 轉為折線圖資料(每個月為 X 軸,每個商品為一條線)
const productNames = [...new Set(priceTrend.map((p) => p.product_name))];
const months = [...new Set(priceTrend.map((p) => p.month))].sort();
const chartData = months.map((month) => {
const row: Record<string, string | number> = { month };
productNames.forEach((name) => {
const item = priceTrend.find((p) => p.month === month && p.product_name === name);
row[name] = item ? item.avg_price : 0;
});
return row;
});
// 將 vendorComparison 按商品分組
const comparisonGrouped = vendorComparison.reduce<Record<string, VendorComparison[]>>((acc, item) => {
if (!acc[item.product_name]) acc[item.product_name] = [];
acc[item.product_name].push(item);
return acc;
}, {});
return (
<div className="space-y-6">
<TooltipProvider>
{/* 商品單價趨勢折線圖 */}
{chartData.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-gray-700 mb-4 flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-blue-500" />
Top 10
<Tooltip>
<TooltipTrigger>
<Info className="h-3.5 w-3.5 text-gray-400 cursor-help" />
</TooltipTrigger>
<TooltipContent className="max-w-[250px] text-xs">
<p> 10 </p>
</TooltipContent>
</Tooltip>
</h3>
<div className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="month" tick={{ fontSize: 12 }} />
<YAxis tickFormatter={(v) => `$${v}`} tick={{ fontSize: 12 }} />
<RechartsTooltip
formatter={(value: number, name: string) => [
`NT$ ${value.toLocaleString()}`, name
]}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
{productNames.slice(0, 8).map((name, idx) => (
<Line
key={name}
type="monotone"
dataKey={name}
stroke={PIE_COLORS[idx % PIE_COLORS.length]}
strokeWidth={2}
dot={{ r: 3 }}
activeDot={{ r: 5 }}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
</div>
)}
{/* 跨廠商比價表格 */}
{Object.keys(comparisonGrouped).length > 0 && (
<div>
<h3 className="text-sm font-semibold text-gray-700 mb-4 flex items-center gap-2">
<DollarSign className="h-4 w-4 text-amber-500" />
<Tooltip>
<TooltipTrigger>
<Info className="h-3.5 w-3.5 text-gray-400 cursor-help" />
</TooltipTrigger>
<TooltipContent className="max-w-[250px] text-xs">
<p> 10</p>
</TooltipContent>
</Tooltip>
</h3>
<div className="space-y-4">
{Object.entries(comparisonGrouped).map(([productName, items]) => (
<div key={productName} className="border border-gray-200 rounded-lg overflow-hidden">
<div className="px-4 py-2 bg-gray-50 border-b border-gray-200">
<span className="text-sm font-semibold text-gray-700">{productName}</span>
</div>
<table className="w-full">
<thead>
<tr className="bg-gray-50/50">
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500"></th>
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500"></th>
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500"></th>
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500"></th>
<th className="px-4 py-2 text-center text-xs font-medium text-gray-500"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{items.map((item, idx) => {
const isLowest = item.avg_price === Math.min(...items.map((i) => i.avg_price));
return (
<tr key={item.vendor_id} className={`hover:bg-gray-50 ${isLowest ? "bg-emerald-50/50" : ""}`}>
<td className="px-4 py-2 text-sm text-gray-900">
{item.vendor_name}
{isLowest && (
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-emerald-100 text-emerald-700">
</span>
)}
</td>
<td className={`px-4 py-2 text-sm text-right font-medium ${isLowest ? "text-emerald-600" : "text-gray-700"}`}>
NT$ {item.avg_price.toLocaleString()}
</td>
<td className="px-4 py-2 text-sm text-right text-gray-600">
NT$ {item.min_price.toLocaleString()}
</td>
<td className="px-4 py-2 text-sm text-right text-gray-600">
NT$ {item.max_price.toLocaleString()}
</td>
<td className="px-4 py-2 text-sm text-center text-gray-600">
{item.purchase_count}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
))}
</div>
</div>
)}
</TooltipProvider>
</div>
);
}
// ===== 空狀態 =====
function EmptyState({ message }: { message: string }) {
return (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<BarChart3 className="h-12 w-12 mb-4 text-gray-300" />
<p className="text-sm">{message}</p>
</div>
);
}