feat: 新增採購統計分析功能並優化 API 文件顯示樣式
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m13s
ERP-Deploy-Production / deploy-production (push) Successful in 1m13s

- 在 RoleController 中新增 procurement_analysis 權限群組名稱
- 在 Procurement 模組中新增採購統計分析路由
- 在 PermissionSeeder 中新增 procurement_analysis.view 權限並分配給角色
- 在側邊欄「報表與分析」分組中新增「採購統計分析」項目
- 優化 API 文件視圖中的表格外觀樣式
This commit is contained in:
2026-03-03 11:38:04 +08:00
parent 036f4a4fb6
commit 58bd995cd8
8 changed files with 1196 additions and 4 deletions

View File

@@ -255,7 +255,7 @@ export default function AuthenticatedLayout({
id: "report-management",
label: "報表與分析",
icon: <BarChart3 className="h-5 w-5" />,
permission: ["accounting.view", "inventory_report.view"],
permission: ["accounting.view", "inventory_report.view", "procurement_analysis.view"],
children: [
{
id: "accounting-report",
@@ -278,6 +278,13 @@ export default function AuthenticatedLayout({
route: "/inventory/analysis",
permission: "inventory_report.view",
},
{
id: "procurement-analysis",
label: "採購統計分析",
icon: <ShoppingCart className="h-4 w-4" />,
route: "/procurement/analysis",
permission: "procurement_analysis.view",
},
{
id: "inventory-traceability",
label: "批號溯源",

View File

@@ -0,0 +1,848 @@
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>
);
}

View File

@@ -57,9 +57,9 @@
prose-headings:scroll-mt-20
prose-h1:text-4xl prose-h1:font-extrabold prose-h1:tracking-tight
prose-pre:p-6 prose-pre:text-sm
prose-table:border prose-table:rounded-xl prose-table:overflow-hidden
prose-th:bg-slate-100 prose-th:p-4
prose-td:p-4 prose-td:border-t prose-td:border-slate-100">
prose-table:border prose-table:border-slate-300 prose-table:rounded-xl prose-table:overflow-hidden
prose-th:bg-slate-100 prose-th:p-4 prose-th:border prose-th:border-slate-300 prose-th:text-left prose-th:font-semibold
prose-td:p-4 prose-td:border prose-td:border-slate-200">
{!! $content !!}
</article>