一句話:
/admin儀表板依登入者角色切換指標範圍與版面;純讀取、零 schema 改動,業績歸屬以訂單派發為準,業務排行與熱銷產品只給 admin。
/admin 儀表板根據登入者角色顯示不同的指標範圍。
非目標:本 spec 不重設權限模型、不改 schema、不引入新的 stat 計算邏輯(只是 filter 範圍變動)。
與旅行社經理對焦時,經理對「每個業務登入後都看到全公司營業額」這件事很在意,原話:
「業務看自己的數字,看自己這個月做多少。看全公司的事情他們就不專心了, 還會比較 — Sandy 怎麼比我多五十萬,那種比較性的東西在業務間是很傷士氣的。 老闆看老闆的、業務看業務的,本來就應該分開。」
歸納兩個 retention / 心理面的需求:
現行系統設計 §6.5 Resource-level 規則 已經規範「業務只能看自己經手的訂單」在 service 層 filter;本 spec 把同一規則延伸到 dashboard 的 aggregate 查詢。
| 角色 | 看到的內容 | 不看到的 |
|---|---|---|
| sales | 我的本月新單數、我的本月業績、我的待付款、我的同意書待簽(4 個 stat cards)+ 我的最近 5 筆訂單 | 全公司營業額、其他業務的數據、業務排行榜、熱銷產品 |
| op | 全公司待出團梯次、待安排梯次(缺資料)、本月新單數、全公司未付款訂單 | 業績金額欄、業務排行、熱銷產品 |
| accountant | 全公司本月已收款、待對帳筆數、待付尾款、本月退款 | 業務個人指標、待出團、熱銷產品 |
| admin | 全公司 4 個 KPI + 最新待付款 / 最新已付款 + 業務排行 mini-widget(Top 3) + Top-5 熱銷產品 | 無限制 |
說明:
op 與 accountant 看的「全公司」屬性與業務不衝突 — 他們本來就需要看跨業務的營運狀態。無 schema 改動。
依賴既有欄位:
orders.created_by_user_id(已存在,已 populate)— 訂單建單者orders.assigned_to_user_id / assignment_status — sales 視角歸屬orders.created_at — 時間範圍判定用orders.booking_state / payment_state / refund_state — 顯示與篩選快取order_charge_lines / payment_transactions — 應收與實收 KPI業績歸屬規則:
orders.assigned_to_user_id = current_user_id 且 assignment_status IN ('assigned','reassigned')(DashboardScope 的 ownedBy)。歸屬已對齊 訂單派發 與 leaderboard —— sales 看得到「派給我」的單,不只自己 K 的(packages/core/src/orders/dashboard-stats.ts)。assigned_to_user_id 為 NULL,不會出現在任何 sales 視角(本來就還沒「屬於」誰)。packages/core/src/orders/dashboard-stats.ts 的 getDashboardStats 簽名(實際 code):
export type DashboardScope =
| { kind: 'all' } // admin / op / accountant
| { kind: 'ownedBy'; userId: string }; // sales:filter assigned_to_user_id
export async function getDashboardStats(
db: ScopedDb,
scope: DashboardScope = { kind: 'all' }, // 第二參數就是 scope 本身(非 { scope })
): Promise<DashboardStats>;
對齊 code:函式在
packages/core/src/orders/dashboard-stats.ts(非admin-repo.ts);第一參數db: ScopedDb;第二參數直接是scope(不是{ scope });scope 的 discriminant 是ownedBy(小駝峰,不是'owned-by')。
DashboardStats 結構由角色決定哪些欄位實際用得到,但型別保持單一,repo 一律回傳全欄位,view 層挑哪些渲染。Repo 內 SQL 根據 scope 決定是否加 AND assigned_to_user_id = $1 且 assignment 已成立(NULL 語意自然排除未派發訂單)。
admin 視角需要 top-3 sales by 本月業績。repo function(實際 code 在 dashboard-stats.ts):
export async function getTopSalesThisMonth(
db: ScopedDb,
limit = 3,
): Promise<Array<{ userId: string; name: string; orderCount: number; revenueTwd: number }>>;
只在 admin 視角呼叫。SQL 用 sales 歸屬欄位 + LEFT JOIN "user"(同庫租戶 user 表)、限本月 created_at、revenueTwd 以已成立訂單的 order_charge_lines 聚合計算(整數 TWD,非 cents)。
src/app/(admin)/admin/page.tsx:
const session = await requireSession();
const role = await getActiveMemberRole(); // 既有 helper(user.role → Role)
const userId = session.user.id;
const db = await getCurrentTenantDb();
const scope: DashboardScope =
role === 'sales' ? { kind: 'ownedBy', userId } : { kind: 'all' };
const stats = await getDashboardStats(db, scope); // scope 直接當第二參數
const topSales = role === 'admin' // 無 owner role
? await getTopSalesThisMonth(db, 3)
: null;
return <DashboardView role={role} stats={stats} topSales={topSales} />;
View 元件依 role 切分 4 個小 view 元件(SalesDashboard、OpDashboard、AccountantDashboard、AdminDashboard)— 各自挑用 stats 的子集,UI 各自宣告,不再用一堆 {role === 'sales' && ...} 條件式塞在一個元件裡(後續改動才會痛)。
/admin 本來就需要登入;以 role 決定視角而非 permission(permission 用於「能不能呼叫某 service」這種 binary 判定)。畫面以「視角預覽切換」(業務 vs 主管)呈現 sales / admin 兩種 layout(見線稿)。op 與 accountant 視角 layout 同 admin 但 KPI 不同,實作時複用元件。
把 wildtw-erp 與舊系統比對後,旅行社經理指出舊系統有一個目前沒有的主管功能:主管儀表板上的「熱銷產品排行」——讓老闆 / 主管一眼看出「最近哪幾條行程賣最好」,並能切時間範圍(今天賣什麼、這週、這個月)。這是純讀取的營運洞察,不影響任何寫入路徑。
對齊整體設計哲學(§3):
orders → departures → trips 關聯。此區塊只擴 repo + admin 視角 view,不動已定義的 KPI cards、不動其他角色視角。
「熱銷」可以有三種衡量基準,三者都能從現有資料算出:
| 基準 | 算法 | 取捨 |
|---|---|---|
| 訂單數(order count) | COUNT(DISTINCT order) per trip | 反映「成交筆數」,但一筆 10 人團與一筆 1 人團權重相同,會低估大團行程 |
| 營收(revenue, TWD) | tour 訂單的 charge line 應收聚合;若報表語意是 cash,改讀 payment_transactions | 反映「成交金額」或「實收」,需在 UI label 明確 |
| 出團人數(pax / party size) | SUM(tour_booking_lines.party_size) per trip | 反映「載客量 / 滿團程度」,OP 排團視角較有用 |
以「營收」為主排序,同時回傳另兩個指標供顯示:
revenueTwd 相同 → 再比 orderCount desc → 再比 paxCount desc。對齊 getTopSalesThisMonth 的 ORDER BY revenue_twd DESC, order_count DESC 慣例。orderCount 與 paxCount:repo 一次算齊三個指標,view 主顯示營收、副標顯示「N 筆訂單 · M 人」。未來若 user 想改成「依人數排」只需換前端排序鍵或加一個 sortBy 參數,不必改 SQL 結構。「營收」的收款狀態口徑(與 getDashboardStats / getTopSalesThisMonth 完全一致,避免兩個排行對不上):
booking_state 未取消,且退款顯示語意需排除或另列;實收型報表改用 cash ledger。orders.created_at 判定(不是 paid_at)。理由:經理問的是「這段期間賣出了什麼」=下單時間區間內的成交;用 created_at 與「當日 / 當週 / 當月新單」語意一致。營收的 paid 狀態是「品質過濾」(排除未成立),時間窗則一律看 created_at。主管可切四種時間範圍。所有邊界一律以 Asia/Taipei 為準(對齊 §11 與既有 payment-requests/repo.ts 的 NOW() AT TIME ZONE 'Asia/Taipei' 慣例)。
| 範圍 | label | 起點(含) | 終點(不含) |
|---|---|---|---|
today | 當日 | date_trunc('day', NOW() AT TIME ZONE 'Asia/Taipei') | 起點 + 1 day |
week | 當週 | date_trunc('week', NOW() AT TIME ZONE 'Asia/Taipei')(Postgres week = 週一起算,ISO-8601) | 起點 + 7 day |
month | 當月 | date_trunc('month', NOW() AT TIME ZONE 'Asia/Taipei') | 下月 1 號 |
custom | 自訂 | caller 傳入 from(YYYY-MM-DD,含) | caller 傳入 to(YYYY-MM-DD,含當日,SQL 上轉成 to + 1 day exclusive) |
重點:時區換算。orders.created_at 是 timestamptz(UTC 儲存)。時間窗邊界用 Asia/Taipei 算出 naive 牆鐘邊界後,需轉回比較。採既有 repo 證實可行的寫法:把 created_at 轉到台北牆鐘再和台北邊界比,或把台北邊界轉回 timestamptz 再和 created_at 比。本 spec 採後者(與 payment-requests/repo.ts 一致):
-- 當週起點(台北牆鐘的本週一 00:00,轉回 timestamptz 與 created_at 比較)
o.created_at >= (date_trunc('week', NOW() AT TIME ZONE 'Asia/Taipei')
AT TIME ZONE 'Asia/Taipei')
說明:
NOW() AT TIME ZONE 'Asia/Taipei'得到台北牆鐘的timestamp(無時區),date_trunc取週/月/日起點,再AT TIME ZONE 'Asia/Taipei'把該牆鐘解讀為台北當地時間轉回timestamptz,即可直接和 UTC 儲存的o.created_at比較。today/month同理。
{ kind: 'custom', from: 'YYYY-MM-DD', to: 'YYYY-MM-DD' }。from 解讀為台北該日 00:00(含);to 解讀為台北該日 23:59:59(含)→ SQL 上用 < (to::date + INTERVAL '1 day'),與既有 listAdminOrders 的 dateTo 處理(exclusive next-day)完全一致。from <= to;跨度上限建議 366 天(防呆,避免一次掃整庫;超過則 server action 回錯誤、UI 提示)。預設值(UI 開啟自訂時)= 最近 30 天。既有 stat card 有「vs 上月」delta。Top-5 區塊現階段不做環比 delta(每條產品都要再算上一期會讓 query 變兩倍、UI 也雜)。若想要,於 future 補。現階段只顯示當期絕對值。
於 packages/core/src/orders/dashboard-stats.ts 新增一個函式(與既有 getTopSalesThisMonth 同檔),沿用相同 raw-SQL + db.execute + ScopedDb first-arg 慣例。
// ----- Dashboard: Top products (熱銷產品) ----------------------------------
/** 主管儀表板 Top-N 熱銷產品的時間範圍。邊界一律 Asia/Taipei。 */
export type TopProductsRange =
| { kind: 'today' }
| { kind: 'week' } // ISO 週一起算
| { kind: 'month' }
| { kind: 'custom'; from: string; to: string }; // YYYY-MM-DD, 皆含當日
export interface TopProductRow {
tripId: string;
tripTitle: string;
tripSlug: string;
/** 主排序鍵:成立訂單的應收或實收合計,TWD 整數;口徑由 repo label 明示。 */
revenueTwd: number;
/** 非取消訂單筆數(含 pending_payment)。 */
orderCount: number;
/** 非取消 tour 訂單的 party size 合計(出團人數)。 */
paxCount: number;
}
/**
* Top-N 熱銷產品(trip 層彙總),給 admin 儀表板。
*
* 排序:revenueTwd DESC, orderCount DESC, paxCount DESC(對齊 getTopSalesThisMonth)。
* 時間窗以 orders.created_at 判定,邊界 Asia/Taipei(見 §7.2)。
* revenueTwd 以 charge lines 或 cash ledger 聚合;orderCount / paxCount 排除 cancelled。
*
* 純讀取,無 schema 改動。Join: orders → order_lines → tour_booking_lines → departures → trips。
*/
export async function getTopProducts(
db: ScopedDb,
opts: { range: TopProductsRange; limit?: number }, // limit 預設 5
): Promise<TopProductRow[]>;
SELECT
t.id AS trip_id,
t.title AS trip_title,
t.slug AS trip_slug,
COALESCE(SUM(ocl.amount_twd) FILTER (
WHERE o.booking_state <> 'cancelled'
), 0)::int AS revenue_twd,
COUNT(DISTINCT o.id) FILTER (WHERE o.booking_state <> 'cancelled')::int AS order_count,
COALESCE(SUM(tbl.party_size) FILTER (WHERE o.booking_state <> 'cancelled'), 0)::int AS pax_count
FROM orders o
JOIN order_lines ol ON ol.order_id = o.id AND ol.line_kind = 'tour'
JOIN tour_booking_lines tbl ON tbl.order_line_id = ol.id
JOIN departures d ON d.id = tbl.departure_id
JOIN trips t ON t.id = d.trip_id
LEFT JOIN order_charge_lines ocl ON ocl.order_id = o.id
WHERE o.created_at >= <range_start> -- 由 range 組出(§7.2),參數化
AND o.created_at < <range_end>
GROUP BY t.id, t.title, t.slug
HAVING COUNT(DISTINCT o.id) FILTER (WHERE o.booking_state <> 'cancelled') > 0
ORDER BY revenue_twd DESC, order_count DESC, pax_count DESC
LIMIT <limit>;
sql fragment 依 range.kind 組裝(參考 §7.2 與既有 listAdminOrders 的 conds 模式),caller 值一律參數化、不字串插值。HAVING ... > 0:該時間窗內完全沒有非取消訂單的 trip 不出現(否則會塞一堆 0 列)。角色 scope 注意:
getTopProducts不吃DashboardScope——它本質是「全公司熱銷」,只給主管看(§7.4),所以一律 scope=all,沒有 sales owner filter。這與getDashboardStats的ownedByscope 是兩回事,不要混。
延伸 §3 的角色與視角對照:
| 角色 | 是否看到 Top-5 熱銷產品 | 理由 |
|---|---|---|
| sales | 否 | 與「業務心理隔離」一致——業務看自己的數字,全公司產品熱度是干擾資訊 |
| op | 否(現階段) | OP 在乎「待出團 / 待安排」;熱銷排行是商業面非排團面。未來 若 OP 想看「哪條團最需要備位」可另議 |
| accountant | 否 | 會計在乎收款 / 對帳,非銷售熱度 |
| admin | 是 | 全局營運視角;與既有「業務排行 Top-3」並列 |
page.tsx:只有 role === 'admin' 才呼叫 getTopProducts(無 owner role),否則 null,view 不渲染此區塊。放在 admin 視角,與既有「本月業務排行 Top 3」mini-widget 並列(同一列雙欄,或業務排行下方一個 card,視寬度)。
?topRange=week)讓 server component 重渲染,或 client fetch server action;spec 不強制,建議 query string(與既有 admin 篩選器一致、可分享 URL)。排行列樣式對齊既有「業務排行」mini-widget,不引入新設計語彙。
tests/dashboard-role.test.ts:
getDashboardStats(db, { kind: 'ownedBy', userId: A.id }) 只回 A 的訂單彙總。getDashboardStats(db, { kind: 'all' }) 回兩筆訂單合計。assigned_to_user_id = NULL 的訂單在 sales 視角下不被計入(SQL NULL 語意下不會 match — 預期行為)。getTopSalesThisMonth limit=3 時最多回 3 筆且依業績 desc。packages/core/src/orders/dashboard-stats.test.ts(Top-5 熱銷產品補測):
getTopProducts(db, { range: { kind: 'month' } }) → A 在前(revenue 60000)、B 次(revenue 40000,pending 不計營收)。orderCount=2(paid + pending)、paxCount=6(5+1);A 的 orderCount=2(cancelled 不計)、paxCount=4。確認營收嚴格、筆數/人數寬鬆。limit: 1 只回 A。created_at 設成「台北日界線附近」(例 UTC 16:01 = 台北隔日 00:01),用 custom from=隔日 驗證它被算進隔日、不算前一日。驗證 Asia/Taipei 邊界正確。today / week range 至少能跑、回傳型別正確(邊界值靠 Case 4 已驗時區邏輯)。tests/e2e/dashboard-roles.spec.ts:
/admin。text=全站本月業績 或「業務排行榜」block。assigned_to_user_id = self。text=熱銷產品(與「看不到業務排行」斷言並列)。| 路徑 | 改動類型 |
|---|---|
src/app/(admin)/admin/page.tsx | 改寫:根據 role 渲染不同子元件;admin 視角多取 topProducts(依 ?topRange) |
src/app/(admin)/admin/_views/sales-dashboard.tsx | 新增(從 page.tsx 拆出) |
src/app/(admin)/admin/_views/admin-dashboard.tsx | 新增 + Top-5 區塊 + 時間範圍切換 UI |
src/app/(admin)/admin/_views/op-dashboard.tsx | 新增 |
src/app/(admin)/admin/_views/accountant-dashboard.tsx | 新增 |
packages/core/src/orders/dashboard-stats.ts | 改 getDashboardStats signature、加 getTopSalesThisMonth;加 getTopProducts + TopProductsRange / TopProductRow 型別 |
packages/core/src/orders/dashboard-stats.test.ts | Top-5 補測 |
tests/dashboard-role.test.ts | 角色化單元測試 |
tests/e2e/dashboard-roles.spec.ts | E2E(新增 / 補斷言) |
不動的部分:
packages/core/src/permissions.ts);本區塊用 role gate(admin)而非新 permission。assigned_to_user_id 與已成立 assignment 為準;未派發訂單不屬於任何 sales 個人視角。/admin/sales-rank),本 spec 範圍外。Asia/Taipei 為準。Repo 內計算 thisMonthStart() 時要明確指定 TZ,不要用 UTC midnight。舊系統有「網站流量 / 行程頁瀏覽量」可視化,旅行社經理列為低優先。ERP 現階段不做,原因與接點:
POST /api/analytics/pageview,寫獨立 page_views 表)——但這是另一條工作流、有 schema 與隱私(PDPA)考量,超出本範圍。getTopViewedTrips(db, { range }),資料源為外部分析或自建 page_views,signature 比照 getTopProducts(同一組 TopProductsRange 時間範圍型別可重用)。0617 會議:後台需要一個一目了然的總覽,把最重要的訂單與狀態往前擺,避免人員上下捲動、到處找資訊。本節是既有角色化 dashboard(§3–§7)的版面強化,不是第二個 dashboard。
已落地於 src/app/(admin)/admin/_views/overview-todos.tsx(OverviewTodos),並織入 sales / op / accountant / admin 四個角色視角(各 *-dashboard.tsx 皆引用)。待辦數量沿用 DashboardStats 既有 count(不另寫口徑),每項連到已預過濾清單(帶 owner=me / status= query),空狀態顯示「目前沒有待處理事項」。
角色化 dashboard 回答「我的數字如何」;Overview 回答「現在有什麼非我處理不可」。把跨頁面散落的待辦聚合到 /admin 頂部一個「待辦區」,每項是可點擊的入口(連到對應清單已預過濾)。純讀取、零 schema 改動,沿用各既有 repo 的計數查詢。
| 角色 | 待辦項(每項 = 數量 + 連到已過濾清單) |
|---|---|
sales | 我的待付款、我的同意書待簽、我的未中籤待處理(抽籤) |
op | 7 天內待出團、待分房梯次、待安排(缺資料)、入山名冊未備(departure-prep) |
accountant | 待對帳、待付尾款、待核請款(control-finance)、待開立 / 缺件代收轉付收據(travelinvoice) |
admin | 上述全公司彙總 + 異常(overpaid / refund pending / schedule invariant 失敗) |
assigned_to_user_id),與 §3 一致。/admin/orders?payment_state=partially_paid&owner=me),不在 Overview 內重做清單。/admin/reports)角色化 dashboard 回答「我的數字如何」、Overview 回答「現在有什麼待處理」;月報表頁回答「某一整月的營運匯總」。掛在 dashboard 家族下(非獨立子系統),純讀取、零 schema 改動,資料來自 getMonthlyReport(db, year, month)(packages/core/src/reports/admin-repo.ts)。權限閘 order.read。實作在 src/app/(admin)/admin/reports/page.tsx。
?year=&month=,<form method="GET"> + 兩個 <Select>(年為當年 ±2、月 1–12)+「套用」。收款依 paid_at 統計、訂單依 created_at 統計。本月逐日實收現金長條圖(MonthlyReport.dailyRevenue):Asia/Taipei 日界 bucket、整月零填滿、tour-scoped 與總營收 KPI 同口徑(Σ 逐日 = 總營收,可對帳)。渲染走 @toko-cloud/ui/chart(shadcn base 變體 chart wrapper on Recharts,色彩吃 admin-tokens 的 --chart-* palette,深淺色自動跟隨)+ domain 元件 DailyTrendChart(與 §6 admin 儀表板「新單趨勢」卡共用;儀表板變體是最近 30 天每日新單 area 圖,資料來自 getDailyNewOrdersSeries,口徑對齊 todayNewOrders)。
匯出 PDF(print 頁)/ 匯出 CSV 皆為真功能(/admin/reports/print、/admin/reports/export)。
| 版本 | 日期 | 變更 | Commit |
|---|---|---|---|
| 1 | 2026-05-28 | 初版 | 5d9a95a |
| 2 | 2026-05-30 | 整理:對齊現行 code + 路徑搬移 | — |
| 3 | 2026-06-20 | N8 Overview 總覽面板(§13):跨頁面待辦聚合至 /admin 頂部、依角色隔離、純讀取零 schema、可點即達。 | — |
| 4 | 2026-06-20 | 修正 §3 Changelog 章節參照:Overview 總覽面板實際為 §13(原誤植 §14)。 | — |
| 5 | 2026-07-01 | Code-Is-Truth 對齊:§13 Overview 待辦聚合去除 stale「未實作」、補記已落地(overview-todos.tsx 織入四視角);新增 §14 月報表頁(/admin/reports)薄描述,明標趨勢圖與 PDF/CSV 匯出為 Plan 9 未完成 stub。 | — |
| 6 | 2026-07-06 | §14.2 改寫:每日營收趨勢圖落地(dailyRevenue 逐日實收 + @toko-cloud/ui/chart shadcn/Recharts wrapper);儀表板「新單趨勢」卡假 polyline 改真資料(getDailyNewOrdersSeries);移除 stale「匯出 disabled」敘述(匯出早已接真)與懸空的 Plan 9 指向。 | — |