Day 26:報表模組從零開始,從需求到 Resource 骨架 結尾留下一句話,報表模組加上圖表與篩選條件,是接下來要處理的內容。今天要處理的正是這件事。
先具體想像管理者昨天打開報表頁面會看到什麼。一張表格,商品名稱一列一列排下去,旁邊跟著累計銷售數量,資料是對的,欄位也依 Day 26 定案的 Schema 機制排得整整齊齊,但要回答「這個月銷售是往上還是往下」這種問題,管理者得自己把好幾列數字在腦中兜起來比較,看完還不一定確定自己有沒有看漏哪一列。這種體驗,跟 Day 19:第一個 Widget,把訂單數字擺上首頁 之前的後台首頁其實是同一種問題,當時想知道經營現況,得逐一點開商品、訂單、客戶三個 Resource 自己拼湊,直到 Widget 出現才把這幾個數字收斂成一眼看懂的畫面。報表頁面現在正卡在 Widget 出現之前的那個階段。
想確認某段期間表現如何,或是只想單獨看某個商品分類的數字,目前的骨架也給不出答案,因為表格只會老實顯示 SalesReport 彙整出來的全部結果,沒有任何開關可以縮小範圍。管理者能做的事情,只有把整份列表捲到底,用眼睛核對想看的那幾筆。
今天要做的事情,是延續 Day 26 的骨架,加上圖表視覺化與依時間區間、商品分類篩選的介面,同時持續驗證一件事,Day 20:圖表 Widget,讓庫存與訂單趨勢一眼看懂 建立的圖表機制、Day 07:讓大量資料可用,篩選、排序與搜尋 建立的篩選機制,搬到報表模組這個全新場景上是否真的還管用,還是得另外重新設計一套。
先處理銷售趨勢圖表,回答「這段時間以來,訂單量是怎麼變化的」。這裡直接沿用 Day 20 建立 OrderTrendChart 時走過的整套做法,ChartWidget 這個基底類別、getData() 裡把資料整理成 labels 對應 datasets 的結構、getType() 回傳 line 呈現折線圖,全部照搬過來,差別只在資料查詢改用 Day 26 已經寫好的彙整邏輯:
<?php
namespace App\Filament\Widgets;
use App\Models\Order;
use Filament\Widgets\ChartWidget;
class SalesReportTrendChart extends ChartWidget
{
protected static ?string $heading = '銷售趨勢';
protected function getData(): array
{
$days = collect(range(29, 0))->map(
fn (int $offset) => now()->subDays($offset)->startOfDay()
);
$amounts = $days->map(function ($day) {
return Order::query()
->whereBetween('order_date', [$day, $day->copy()->endOfDay()])
->sum('total_amount');
});
return [
'datasets' => [
[
'label' => '訂單金額',
'data' => $amounts->toArray(),
],
],
'labels' => $days->map(fn ($day) => $day->format('n/j'))->toArray(),
];
}
protected function getType(): string
{
return 'line';
}
}
getData() 裡依日期迴圈統計金額的寫法,跟 Day 20 統計近七天訂單筆數的做法幾乎一模一樣,只是把區間從七天拉長到三十天,把統計對象從筆數換成金額,這兩處都是配合報表場景做的參數調整,底層的整理邏輯沒有變動。
第二個圖表換一個問題面向,回答「同一個時間點下,哪個商品分類賣得比較好」,這正是 Day 20 建立 InventoryByCategoryChart 時處理庫存分佈用的分組對比手法,這裡把分組對象從庫存換成銷售數量:
<?php
namespace App\Filament\Widgets;
use App\Models\OrderItem;
use Filament\Widgets\ChartWidget;
class SalesReportByCategoryChart extends ChartWidget
{
protected static ?string $heading = '商品分類銷售量';
protected function getData(): array
{
$sales = OrderItem::query()
->join('products', 'products.id', '=', 'order_items.product_id')
->selectRaw('products.category, sum(order_items.quantity) as total_quantity')
->groupBy('products.category')
->pluck('total_quantity', 'category');
return [
'datasets' => [
[
'label' => '銷售數量',
'data' => $sales->values()->toArray(),
],
],
'labels' => $sales->keys()->toArray(),
];
}
protected function getType(): string
{
return 'bar';
}
}
兩個 Widget 寫好之後,放進 app/Filament/Widgets 資料夾,接著在報表 Resource 的列表頁掛載:
protected function getHeaderWidgets(): array
{
return [
SalesReportTrendChart::class,
SalesReportByCategoryChart::class,
];
}
這裡冒出一個值得停下來確認的問題。Day 19、Day 20 建立的 Widget,掛載的地方是 Panel 首頁,透過 AdminPanelProvider.php 裡的 discoverWidgets 自動掃描;今天這兩個圖表掛載的地方換成報表 Resource 自己的列表頁,用的是頁面類別裡的 getHeaderWidgets() 方法。掛載位置確實不同,但 ChartWidget 這個類別本身、getData() 該回傳什麼格式、getType() 該回傳哪種圖表類型,這些定義圖表內容的核心寫法完全沒變。當初設計這套機制的時候,圖表怎麼畫是一回事,圖表擺在哪裡是另一回事,兩件事本來就沒有被綁死在一起,這正是這套機制今天能直接搬過來用的原因。同一套模具,換個位置照樣脫得出一樣的形狀,機制本身的通用性在這裡得到了驗證,不是只服務 Panel 首頁那一次性的設計。

圖表解決了「看不看得懂」的問題,接下來要解決「看不看得到自己想看的範圍」。管理者想檢視本月、本季,或是自己指定一段區間的報表數據,這個需求對應到 Day 07 建立商品價格區間篩選時走過的做法,用自訂的 Filter 搭配 schema 定義畫面上的輸入欄位,再用 query() 接住使用者輸入的資料:
use Filament\Tables\Filters\Filter;
use Filament\Forms\Components\DatePicker;
use Illuminate\Database\Eloquent\Builder;
Filter::make('date_range')
->label('時間區間')
->schema([
DatePicker::make('date_from')
->label('起始日期'),
DatePicker::make('date_to')
->label('結束日期'),
])
->query(function (Builder $query, array $data): Builder {
return $query
->when(
$data['date_from'],
fn (Builder $query, $date): Builder => $query->where('order_date', '>=', $date),
)
->when(
$data['date_to'],
fn (Builder $query, $date): Builder => $query->where('order_date', '<=', $date),
);
}),
這段程式碼跟 Day 07 的價格區間篩選幾乎是同一個模子刻出來的,schema 換成 DatePicker 取代原本的 TextInput,query() 裡 when() 的用法完全沒變。真正需要留意的差異在於這個篩選條件實際生效的方式,商品列表的價格區間篩選只是從既有的商品資料裡排除掉不符合條件的列,資料本身早就存在,篩選只是決定顯示哪幾筆;報表這裡的時間區間篩選,直接改變了 SalesReport 背後彙整查詢該涵蓋哪一段訂單資料,篩選條件一變,連銷售總額、排行榜這些統計結果都會跟著重新計算一次,不只是畫面顯示的列數變多變少而已。
商品分類篩選則單純得多,直接沿用 Day 07 用 SelectFilter 處理商品分類篩選的做法,選項也直接對應 Day 04 已經定案的三個分類:
use Filament\Tables\Filters\SelectFilter;
SelectFilter::make('category')
->label('商品分類')
->options([
'food' => '寵物飼料',
'toy' => '寵物玩具',
'cleaning' => '清潔用品',
]),
管理者選擇寵物玩具,報表就只彙整這個分類底下的訂單明細,銷售數量圖表跟表格會同步只顯示這個分類的數字。這裡同樣是把篩選結果從「排除幾筆既有資料」換成「限縮彙整查詢的資料範圍」,寫法上跟 Day 07 一模一樣,唯一不同的是這個篩選條件實際發生作用的位置,從資料顯示層退後到彙整運算層。
兩段示範走完,可以把驗證的結論說得更明確一點。篩選機制在商品、訂單這類具體業務實體上處理的問題是「從一批已經存在的資料裡,挑出符合條件的幾筆顯示出來」;搬到報表模組上,處理的問題變成「決定彙整查詢一開始就該涵蓋哪個範圍的資料」。問題發生的層次不一樣,但背後篩選元件的定義方式、query() 接住條件再組出查詢的操作邏輯,完全相通,這正是流程可複製性具體展現的地方,不是巧合,也不需要另外發明一套報表專用的篩選語法。

回顧今天做完的事,在 Day 26 建立的骨架上,加上了銷售趨勢與商品分類兩種圖表,也加上了時間區間與商品分類兩種篩選條件。管理者現在打開報表頁面,能一眼看出銷售金額是往上還是往下,能單獨檢視某個分類、某段期間的表現,這些機制全部直接沿用系列已經建立的圖表與篩選技巧,沒有發明任何一個系列前面沒出現過的新東西。報表現在看起來,已經像是一份真正能回答業務問題的報表了。
但這裡要老實點出一個新的落差。這份報表目前不管誰打開,看到的都是全公司、全部經銷據點的完整數字。倉管、業務、財務理應關心的報表面向不盡相同,倉管在意庫存偏低的警訊,業務在意銷售排行,財務在意訂單金額的走勢,今天的圖表跟篩選完全沒有替這幾個角色做出區隔。不同經銷據點的人打開報表,看到的也還是同一份全公司彙總數字,沒有人只看得到自己據點的版本。
回頭看看第五階段建立的 Day 16:設計一份貼近真實團隊的角色權限矩陣 與 Day 17:多租戶隔離,讓每個經銷據點只看到自己的資料,這兩層機制當時已經讓 Resource 的操作與資料查詢依角色和據點區分開來,Day 20:圖表 Widget,讓庫存與訂單趨勢一眼看懂 結尾也曾經誠實承認過,Widget 圖表當時還沒接上這兩層機制,直到 Day 21:誰登入看到誰的儀表板,Widget 排版與權限呼應 才把這個落差補上。報表模組今天正好停在 Day 21 之前的那個位置,圖表跟篩選都做好了,權限跟租戶隔離卻還沒有延伸過來。
報表模組接上權限與租戶隔離,完工,是接下來要處理的內容。