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

@@ -199,6 +199,7 @@ class RoleController extends Controller
'sales_imports' => '銷售單匯入管理',
'sales_orders' => '銷售訂單管理',
'store_requisitions' => '門市叫貨申請',
'procurement_analysis' => '採購統計分析',
'users' => '使用者管理',
'roles' => '角色與權限',
'system' => '系統管理',

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Modules\Procurement\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Procurement\Services\ProcurementAnalysisService;
use App\Modules\Procurement\Models\Vendor;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use Illuminate\Http\Request;
use Inertia\Inertia;
class ProcurementAnalysisController extends Controller
{
public function __construct(
protected ProcurementAnalysisService $analysisService,
protected InventoryServiceInterface $inventoryService,
) {}
public function index(Request $request)
{
$filters = $request->only([
'date_from', 'date_to', 'vendor_id', 'warehouse_id',
]);
// 取得各面向數據
$kpis = $this->analysisService->getKPIs($filters);
$deliveryAnalysis = $this->analysisService->getDeliveryAnalysis($filters);
$quantityAnalysis = $this->analysisService->getQuantityAnalysis($filters);
$priceTrendAnalysis = $this->analysisService->getPriceTrendAnalysis($filters);
// 取得篩選器選項(跨模組透過 Service 取得倉庫)
$warehouses = $this->inventoryService->getAllWarehouses();
$vendors = Vendor::select('id', 'name', 'code')->orderBy('name')->get();
return Inertia::render('Procurement/Analysis/Index', [
'kpis' => $kpis,
'deliveryAnalysis' => $deliveryAnalysis,
'quantityAnalysis' => $quantityAnalysis,
'priceTrendAnalysis' => $priceTrendAnalysis,
'vendors' => $vendors,
'warehouses' => $warehouses,
'filters' => $filters,
]);
}
}

View File

@@ -4,6 +4,7 @@ use Illuminate\Support\Facades\Route;
use App\Modules\Procurement\Controllers\VendorController;
use App\Modules\Procurement\Controllers\VendorProductController;
use App\Modules\Procurement\Controllers\PurchaseOrderController;
use App\Modules\Procurement\Controllers\ProcurementAnalysisController;
Route::middleware('auth')->group(function () {
// 廠商管理
@@ -77,4 +78,9 @@ Route::middleware('auth')->group(function () {
Route::delete('/delivery-notes/{id}', [\App\Modules\Procurement\Controllers\ShippingOrderController::class, 'destroy'])->middleware('permission:delivery_notes.delete')->name('delivery-notes.destroy');
});
// 採購統計分析
Route::get('/procurement/analysis', [ProcurementAnalysisController::class, 'index'])
->middleware('permission:procurement_analysis.view')
->name('procurement.analysis.index');
});

View File

@@ -0,0 +1,280 @@
<?php
namespace App\Modules\Procurement\Services;
use App\Modules\Procurement\Models\PurchaseOrder;
use App\Modules\Procurement\Models\PurchaseOrderItem;
use App\Modules\Procurement\Models\Vendor;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Carbon;
class ProcurementAnalysisService
{
public function __construct(
protected InventoryServiceInterface $inventoryService,
) {}
/**
* 取得 KPI 總覽數據
*/
public function getKPIs(array $filters): array
{
$dateFrom = $filters['date_from'] ?? Carbon::now()->subMonths(6)->format('Y-m-d');
$dateTo = $filters['date_to'] ?? Carbon::now()->format('Y-m-d');
$vendorId = $filters['vendor_id'] ?? null;
$warehouseId = $filters['warehouse_id'] ?? null;
// 採購單基礎查詢(排除草稿與已取消)
$poQuery = PurchaseOrder::whereNotIn('status', ['draft', 'cancelled'])
->whereBetween('order_date', [$dateFrom, $dateTo]);
if ($vendorId) $poQuery->where('vendor_id', $vendorId);
if ($warehouseId) $poQuery->where('warehouse_id', $warehouseId);
$totalAmount = (clone $poQuery)->sum('grand_total') ?: (clone $poQuery)->sum('total_amount');
$totalOrders = (clone $poQuery)->count();
// 進貨單查詢
$grQuery = DB::table('goods_receipts')
->where('status', 'completed')
->whereBetween('received_date', [$dateFrom, $dateTo])
->whereNull('deleted_at');
if ($vendorId) $grQuery->where('vendor_id', $vendorId);
if ($warehouseId) $grQuery->where('warehouse_id', $warehouseId);
$totalReceipts = (clone $grQuery)->count();
// 平均交期天數(採購單→進貨單)
$deliveryStats = DB::table('purchase_orders as po')
->join('goods_receipts as gr', 'gr.purchase_order_id', '=', 'po.id')
->whereNotIn('po.status', ['draft', 'cancelled'])
->where('gr.status', 'completed')
->whereNull('gr.deleted_at')
->whereBetween('po.order_date', [$dateFrom, $dateTo]);
if ($vendorId) $deliveryStats->where('po.vendor_id', $vendorId);
if ($warehouseId) $deliveryStats->where('po.warehouse_id', $warehouseId);
$deliveryStats = $deliveryStats->selectRaw('
AVG(DATEDIFF(gr.received_date, po.order_date)) as avg_days,
COUNT(*) as total_linked,
SUM(CASE WHEN gr.received_date <= po.expected_delivery_date THEN 1 ELSE 0 END) as on_time_count
')->first();
$avgDays = round($deliveryStats->avg_days ?? 0, 1);
$onTimeRate = $deliveryStats->total_linked > 0
? round(($deliveryStats->on_time_count / $deliveryStats->total_linked) * 100, 1)
: 0;
return [
'total_amount' => (float) $totalAmount,
'total_orders' => $totalOrders,
'total_receipts' => $totalReceipts,
'avg_delivery_days' => $avgDays,
'on_time_rate' => $onTimeRate,
];
}
/**
* 廠商供貨時效分析
*/
public function getDeliveryAnalysis(array $filters): array
{
$dateFrom = $filters['date_from'] ?? Carbon::now()->subMonths(6)->format('Y-m-d');
$dateTo = $filters['date_to'] ?? Carbon::now()->format('Y-m-d');
$vendorId = $filters['vendor_id'] ?? null;
$warehouseId = $filters['warehouse_id'] ?? null;
$query = DB::table('purchase_orders as po')
->join('goods_receipts as gr', 'gr.purchase_order_id', '=', 'po.id')
->whereNotIn('po.status', ['draft', 'cancelled'])
->where('gr.status', 'completed')
->whereNull('gr.deleted_at')
->whereBetween('po.order_date', [$dateFrom, $dateTo]);
if ($vendorId) $query->where('po.vendor_id', $vendorId);
if ($warehouseId) $query->where('po.warehouse_id', $warehouseId);
// 按廠商分組的交期統計
$vendorDelivery = (clone $query)
->join('vendors', 'vendors.id', '=', 'po.vendor_id')
->groupBy('po.vendor_id', 'vendors.name')
->selectRaw('
po.vendor_id,
vendors.name as vendor_name,
COUNT(*) as total_count,
ROUND(AVG(DATEDIFF(gr.received_date, po.order_date)), 1) as avg_days,
MIN(DATEDIFF(gr.received_date, po.order_date)) as min_days,
MAX(DATEDIFF(gr.received_date, po.order_date)) as max_days,
SUM(CASE WHEN gr.received_date <= po.expected_delivery_date THEN 1 ELSE 0 END) as on_time_count
')
->orderBy('avg_days', 'asc')
->get()
->map(function ($row) {
$row->on_time_rate = $row->total_count > 0
? round(($row->on_time_count / $row->total_count) * 100, 1) : 0;
return $row;
});
// 延遲分佈統計
$delayDistribution = (clone $query)
->selectRaw("
CASE
WHEN DATEDIFF(gr.received_date, po.order_date) <= 0 THEN '提前到貨'
WHEN DATEDIFF(gr.received_date, po.order_date) BETWEEN 1 AND 3 THEN '1-3天'
WHEN DATEDIFF(gr.received_date, po.order_date) BETWEEN 4 AND 7 THEN '4-7天'
WHEN DATEDIFF(gr.received_date, po.order_date) BETWEEN 8 AND 14 THEN '8-14天'
ELSE '超過14天'
END as category,
COUNT(*) as count
")
->groupByRaw("
CASE
WHEN DATEDIFF(gr.received_date, po.order_date) <= 0 THEN '提前到貨'
WHEN DATEDIFF(gr.received_date, po.order_date) BETWEEN 1 AND 3 THEN '1-3天'
WHEN DATEDIFF(gr.received_date, po.order_date) BETWEEN 4 AND 7 THEN '4-7天'
WHEN DATEDIFF(gr.received_date, po.order_date) BETWEEN 8 AND 14 THEN '8-14天'
ELSE '超過14天'
END
")
->get();
return [
'vendor_delivery' => $vendorDelivery,
'delay_distribution' => $delayDistribution,
];
}
/**
* 進貨數量分析
*/
public function getQuantityAnalysis(array $filters): array
{
$dateFrom = $filters['date_from'] ?? Carbon::now()->subMonths(6)->format('Y-m-d');
$dateTo = $filters['date_to'] ?? Carbon::now()->format('Y-m-d');
$vendorId = $filters['vendor_id'] ?? null;
$warehouseId = $filters['warehouse_id'] ?? null;
$baseQuery = DB::table('goods_receipts as gr')
->join('goods_receipt_items as gri', 'gri.goods_receipt_id', '=', 'gr.id')
->where('gr.status', 'completed')
->whereNull('gr.deleted_at')
->whereBetween('gr.received_date', [$dateFrom, $dateTo]);
if ($vendorId) $baseQuery->where('gr.vendor_id', $vendorId);
if ($warehouseId) $baseQuery->where('gr.warehouse_id', $warehouseId);
// 月度進貨量趨勢
$monthlyTrend = (clone $baseQuery)
->selectRaw("
DATE_FORMAT(gr.received_date, '%Y-%m') as month,
ROUND(SUM(gri.quantity_received), 2) as total_quantity,
ROUND(SUM(gri.total_amount), 2) as total_amount,
COUNT(DISTINCT gr.id) as receipt_count
")
->groupByRaw("DATE_FORMAT(gr.received_date, '%Y-%m')")
->orderBy('month', 'asc')
->get();
// 廠商佔比(按進貨金額)
$vendorShare = (clone $baseQuery)
->join('vendors', 'vendors.id', '=', 'gr.vendor_id')
->selectRaw('
gr.vendor_id,
vendors.name as vendor_name,
ROUND(SUM(gri.total_amount), 2) as total_amount,
ROUND(SUM(gri.quantity_received), 2) as total_quantity
')
->groupBy('gr.vendor_id', 'vendors.name')
->orderByDesc('total_amount')
->limit(10)
->get();
// 商品進貨排行 Top 10
$productRanking = (clone $baseQuery)
->join('products', 'products.id', '=', 'gri.product_id')
->selectRaw('
gri.product_id,
products.name as product_name,
products.code as product_code,
ROUND(SUM(gri.quantity_received), 2) as total_quantity,
ROUND(SUM(gri.total_amount), 2) as total_amount
')
->groupBy('gri.product_id', 'products.name', 'products.code')
->orderByDesc('total_amount')
->limit(10)
->get();
return [
'monthly_trend' => $monthlyTrend,
'vendor_share' => $vendorShare,
'product_ranking' => $productRanking,
];
}
/**
* 單價趨勢分析
*/
public function getPriceTrendAnalysis(array $filters): array
{
$dateFrom = $filters['date_from'] ?? Carbon::now()->subMonths(6)->format('Y-m-d');
$dateTo = $filters['date_to'] ?? Carbon::now()->format('Y-m-d');
$vendorId = $filters['vendor_id'] ?? null;
$warehouseId = $filters['warehouse_id'] ?? null;
$baseQuery = DB::table('goods_receipts as gr')
->join('goods_receipt_items as gri', 'gri.goods_receipt_id', '=', 'gr.id')
->join('products', 'products.id', '=', 'gri.product_id')
->where('gr.status', 'completed')
->whereNull('gr.deleted_at')
->whereBetween('gr.received_date', [$dateFrom, $dateTo]);
if ($vendorId) $baseQuery->where('gr.vendor_id', $vendorId);
if ($warehouseId) $baseQuery->where('gr.warehouse_id', $warehouseId);
// 商品月平均單價趨勢(取進貨金額 Top 10 的商品)
$topProductIds = (clone $baseQuery)
->selectRaw('gri.product_id, SUM(gri.total_amount) as total')
->groupBy('gri.product_id')
->orderByDesc('total')
->limit(10)
->pluck('product_id');
$priceTrend = [];
if ($topProductIds->isNotEmpty()) {
$priceTrend = (clone $baseQuery)
->whereIn('gri.product_id', $topProductIds->toArray())
->selectRaw("
gri.product_id,
products.name as product_name,
DATE_FORMAT(gr.received_date, '%Y-%m') as month,
ROUND(AVG(gri.unit_price), 2) as avg_price,
ROUND(MIN(gri.unit_price), 2) as min_price,
ROUND(MAX(gri.unit_price), 2) as max_price
")
->groupBy('gri.product_id', 'products.name', DB::raw("DATE_FORMAT(gr.received_date, '%Y-%m')"))
->orderBy('month', 'asc')
->get();
}
// 跨廠商比價(同一商品不同廠商的最近價格)
$vendorComparison = (clone $baseQuery)
->join('vendors', 'vendors.id', '=', 'gr.vendor_id')
->whereIn('gri.product_id', $topProductIds->toArray())
->selectRaw('
gri.product_id,
products.name as product_name,
gr.vendor_id,
vendors.name as vendor_name,
ROUND(AVG(gri.unit_price), 2) as avg_price,
ROUND(MIN(gri.unit_price), 2) as min_price,
ROUND(MAX(gri.unit_price), 2) as max_price,
COUNT(*) as purchase_count
')
->groupBy('gri.product_id', 'products.name', 'gr.vendor_id', 'vendors.name')
->orderBy('products.name')
->orderBy('avg_price')
->get();
return [
'price_trend' => $priceTrend,
'vendor_comparison' => $vendorComparison,
];
}
}

View File

@@ -162,6 +162,9 @@ class PermissionSeeder extends Seeder
// 銷售訂單管理 (API)
'sales_orders.view' => '檢視',
// 採購統計分析
'procurement_analysis.view' => '檢視',
];
foreach ($permissions as $name => $displayName) {
@@ -210,6 +213,7 @@ class PermissionSeeder extends Seeder
'sales_imports.view', 'sales_imports.create', 'sales_imports.confirm', 'sales_imports.delete',
'store_requisitions.view', 'store_requisitions.create', 'store_requisitions.edit',
'store_requisitions.delete', 'store_requisitions.approve', 'store_requisitions.cancel',
'procurement_analysis.view',
]);
// warehouse-manager 管理庫存與倉庫
@@ -236,6 +240,7 @@ class PermissionSeeder extends Seeder
'vendors.view', 'vendors.create', 'vendors.edit',
'inventory.view',
'goods_receipts.view', 'goods_receipts.create',
'procurement_analysis.view',
]);
// viewer 僅能查看

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>