feat: 新增採購統計分析功能並優化 API 文件顯示樣式
- 在 RoleController 中新增 procurement_analysis 權限群組名稱 - 在 Procurement 模組中新增採購統計分析路由 - 在 PermissionSeeder 中新增 procurement_analysis.view 權限並分配給角色 - 在側邊欄「報表與分析」分組中新增「採購統計分析」項目 - 優化 API 文件視圖中的表格外觀樣式
This commit is contained in:
@@ -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: "批號溯源",
|
||||
|
||||
848
resources/js/Pages/Procurement/Analysis/Index.tsx
Normal file
848
resources/js/Pages/Procurement/Analysis/Index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user