線稿索引所有 spec最後更新 2026-07-06
specs/dashboard.md

角色化儀表板

一句話:/admin 儀表板依登入者角色切換指標範圍與版面;純讀取、零 schema 改動,業績歸屬以訂單派發為準,業務排行與熱銷產品只給 admin。

1. 目標

/admin 儀表板根據登入者角色顯示不同的指標範圍。

  • **業務(sales)**只看自己單月的訂單與業績。
  • OP / 會計看與自己工作相關的全公司面向。
  • admin(老闆 / 管理者;無獨立 owner role)看全公司一切,並加上業務排行 mini-widget 與 Top-5 熱銷產品。

非目標:本 spec 不重設權限模型、不改 schema、不引入新的 stat 計算邏輯(只是 filter 範圍變動)。

2. 問題與動機

與旅行社經理對焦時,經理對「每個業務登入後都看到全公司營業額」這件事很在意,原話:

「業務看自己的數字,看自己這個月做多少。看全公司的事情他們就不專心了, 還會比較 — Sandy 怎麼比我多五十萬,那種比較性的東西在業務間是很傷士氣的。 老闆看老闆的、業務看業務的,本來就應該分開。」

歸納兩個 retention / 心理面的需求:

  1. 業務心理隔離:業務間不應互相看到對方業績;當前 dashboard 顯示「本月營收 NT$ 1,284,600」對 sales 來說是干擾資訊(不是他的)也是士氣風險。
  2. 角色聚焦:OP 在乎「待出團、待安排」、會計在乎「付款狀況」,admin 在乎「全局 + 業務排行」。一個儀表板塞所有人的需求 → 沒人覺得這頁是為自己設計的。

現行系統設計 §6.5 Resource-level 規則 已經規範「業務只能看自己經手的訂單」在 service 層 filter;本 spec 把同一規則延伸到 dashboard 的 aggregate 查詢。

3. 角色與視角對照

角色看到的內容不看到的
sales我的本月新單數、我的本月業績、我的待付款、我的同意書待簽(4 個 stat cards)+ 我的最近 5 筆訂單全公司營業額、其他業務的數據、業務排行榜、熱銷產品
op全公司待出團梯次、待安排梯次(缺資料)、本月新單數、全公司未付款訂單業績金額欄、業務排行、熱銷產品
accountant全公司本月已收款、待對帳筆數、待付尾款、本月退款業務個人指標、待出團、熱銷產品
admin全公司 4 個 KPI + 最新待付款 / 最新已付款 + 業務排行 mini-widget(Top 3) + Top-5 熱銷產品無限制

說明:

  • 4 個 stat card 數量固定不變,內容隨角色換。不做「動態欄位個數」,UI 結構穩定降低 wireframe 與測試成本。
  • opaccountant 看的「全公司」屬性與業務不衝突 — 他們本來就需要看跨業務的營運狀態。
  • 業務排行與熱銷產品只在 admin 看得到。即使日後有「業務主管」這種介於兩者間的角色,也走 admin 視角(透過 hierarchy 機制,見 §11)。

4. 資料模型影響

無 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

業績歸屬規則

  • 對 sales 視角,filter orders.assigned_to_user_id = current_user_idassignment_status IN ('assigned','reassigned')DashboardScopeownedBy)。歸屬已對齊 訂單派發 與 leaderboard —— sales 看得到「派給我」的單,不只自己 K 的(packages/core/src/orders/dashboard-stats.ts)。
  • 未派發 / pending_review 的單 assigned_to_user_idNULL,不會出現在任何 sales 視角(本來就還沒「屬於」誰)。
  • admin 視角不 filter,看全部訂單(無 owner role)。

5. 實作面

5.1 Repo 層

packages/core/src/orders/dashboard-stats.tsgetDashboardStats 簽名(實際 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 語意自然排除未派發訂單)。

5.2 業務排行 sub-query

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_atrevenueTwd 以已成立訂單的 order_charge_lines 聚合計算(整數 TWD,非 cents)。

5.3 Page 層

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 元件(SalesDashboardOpDashboardAccountantDashboardAdminDashboard)— 各自挑用 stats 的子集,UI 各自宣告,不再用一堆 {role === 'sales' && ...} 條件式塞在一個元件裡(後續改動才會痛)。

5.4 權限檢查

  • /admin 本來就需要登入;以 role 決定視角而非 permission(permission 用於「能不能呼叫某 service」這種 binary 判定)。
  • 未知 / 未列出的 role(例如未來新加的)fallback 走「最受限視角」(sales 視角)。

6. UI 與排版

6.1 Sales 視角

  • 4 個 stat cards(橫排):
    1. 我的本月新單(數量 + vs 上月)
    2. 我的本月業績(金額)
    3. 我的待付款(筆數 + 金額)
    4. 我的同意書待簽(人數)
  • 「我的最近 5 筆訂單」表格(欄位同既有訂單表,但隱藏「業務」欄 — 反正都是自己)

6.2 Op 視角

  • 4 個 stat cards:本月新單(全站)、待出團 7 天內、待安排梯次、未付款訂單
  • 「最近待出團梯次」表格(取代訂單表)

6.3 Accountant 視角

  • 4 個 stat cards:本月已收款、待對帳、待付尾款、本月退款
  • 「待對帳清單」表格

6.4 Admin 視角

  • 4 個 stat cards:全站本月新單、全站本月業績、全站待付款、全站本月取消(保留既有 4 個 KPI 結構
  • 兩欄區塊:最新待付款 / 最新已付款(既有)
  • 業務排行榜 mini-widget(top 3 sales with horizontal bar,顯示業績金額)
  • Top-5 熱銷產品(見 §7)

6.5 視角預覽

畫面以「視角預覽切換」(業務 vs 主管)呈現 sales / admin 兩種 layout(見線稿)。op 與 accountant 視角 layout 同 admin 但 KPI 不同,實作時複用元件。

7. Top-5 熱銷產品(主管視角)

把 wildtw-erp 與舊系統比對後,旅行社經理指出舊系統有一個目前沒有的主管功能:主管儀表板上的「熱銷產品排行」——讓老闆 / 主管一眼看出「最近哪幾條行程賣最好」,並能切時間範圍(今天賣什麼、這週、這個月)。這是純讀取的營運洞察,不影響任何寫入路徑。

對齊整體設計哲學(§3):

  • 主管聚焦:「業務排行 Top-3」已設為 admin 專屬(業務本人永遠看不到,避免互比傷士氣)。Top-5 熱銷產品同屬「全局營運視角」,只在 admin 視角顯示,與業務排行並列。
  • 角色隔離:sales / op / accountant 視角不顯示此區塊(理由見 §7.4)。
  • 零 schema 改動:純 query,沿用既有 orders → departures → trips 關聯。

此區塊只擴 repo + admin 視角 view,不動已定義的 KPI cards、不動其他角色視角

7.1 「熱銷」定義與取捨

「熱銷」可以有三種衡量基準,三者都能從現有資料算出:

基準算法取捨
訂單數(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),desc。理由:經理原語意「哪條行程賣最好」最直接對應「進帳金額」;且與業務排行(也以 revenue 排)一致,主管在同一頁兩個排行用同一把尺,認知負擔低。
  • 平手時的 tie-breakerrevenueTwd 相同 → 再比 orderCount desc → 再比 paxCount desc。對齊 getTopSalesThisMonthORDER BY revenue_twd DESC, order_count DESC 慣例。
  • 同時回傳 orderCountpaxCount:repo 一次算齊三個指標,view 主顯示營收、副標顯示「N 筆訂單 · M 人」。未來若 user 想改成「依人數排」只需換前端排序鍵或加一個 sortBy 參數,不必改 SQL 結構。

「營收」的收款狀態口徑(與 getDashboardStats / getTopSalesThisMonth 完全一致,避免兩個排行對不上):

  • 營收只算已成立的金額booking_state 未取消,且退款顯示語意需排除或另列;實收型報表改用 cash ledger。
  • 訂單數 / 人數的計入口徑:計入「非取消」訂單,讓「熱銷」反映真實需求熱度,不會因為尾款未付就完全不算成交意願。
    • 取捨說明:營收用嚴格口徑(只算收到的錢)、筆數/人數用寬鬆口徑(含待付款、排除取消)。這是刻意的——金額要保守、熱度要完整。在此明示,避免誤以為三個指標口徑該一致。
  • 時間範圍以 orders.created_at 判定(不是 paid_at)。理由:經理問的是「這段期間賣出了什麼」=下單時間區間內的成交;用 created_at 與「當日 / 當週 / 當月新單」語意一致。營收的 paid 狀態是「品質過濾」(排除未成立),時間窗則一律看 created_at

7.2 時間範圍邏輯(日 / 週 / 月 / 自訂,含時區)

主管可切四種時間範圍。所有邊界一律以 Asia/Taipei 為準(對齊 §11 與既有 payment-requests/repo.tsNOW() 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_attimestamptz(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 同理。

自訂區間

  • caller 傳 { 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 天。

「上一期比較」(delta)— nice-to-have

既有 stat card 有「vs 上月」delta。Top-5 區塊現階段不做環比 delta(每條產品都要再算上一期會讓 query 變兩倍、UI 也雜)。若想要,於 future 補。現階段只顯示當期絕對值。

7.3 Repo 函式 signature(純新增,無 schema 改動)

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 與既有 listAdminOrdersconds 模式),caller 值一律參數化、不字串插值。
  • HAVING ... > 0:該時間窗內完全沒有非取消訂單的 trip 不出現(否則會塞一堆 0 列)。
  • 不分 departure:產品(熱銷)以 trip 為單位 彙總(多個梯次合計)。經理講的「產品」= 行程,不是單一梯次。

角色 scope 注意getTopProducts DashboardScope——它本質是「全公司熱銷」,只給主管看(§7.4),所以一律 scope=all,沒有 sales owner filter。這與 getDashboardStatsownedBy scope 是兩回事,不要混。

7.4 角色可見性(只主管)

延伸 §3 的角色與視角對照:

角色是否看到 Top-5 熱銷產品理由
sales與「業務心理隔離」一致——業務看自己的數字,全公司產品熱度是干擾資訊
op否(現階段)OP 在乎「待出團 / 待安排」;熱銷排行是商業面非排團面。未來 若 OP 想看「哪條團最需要備位」可另議
accountant會計在乎收款 / 對帳,非銷售熱度
admin全局營運視角;與既有「業務排行 Top-3」並列
  • 實作上比照 page.tsx:只有 role === 'admin' 才呼叫 getTopProducts無 owner role),否則 null,view 不渲染此區塊。
  • 未知 / 未列出 role → fallback 走最受限視角(sales),不顯示(對齊 §5.4)。

7.5 UI 與排版

放在 admin 視角,與既有「本月業務排行 Top 3」mini-widget 並列(同一列雙欄,或業務排行下方一個 card,視寬度)。

  • 卡片標題:「熱銷產品 Top 5」+ 時間範圍切換 segmented control(當日 / 當週 / 當月 / 自訂)。
  • 每列:名次徽章(1–5)+ 行程標題(連行程詳情)+ 副標「N 筆 · M 人」+ 右側營收金額(等寬數字)+ 比例橫條(該列營收 / 第一名營收)。
  • 自訂展開時出現兩個 date input(from / to)+「套用」。預設最近 30 天。
  • 空狀態:該時間窗無成交 → 「此區間尚無成交產品」。
  • 切時間範圍 = 重新查詢。實作可走 query string(?topRange=week)讓 server component 重渲染,或 client fetch server action;spec 不強制,建議 query string(與既有 admin 篩選器一致、可分享 URL)。

排行列樣式對齊既有「業務排行」mini-widget,不引入新設計語彙。

8. 測試與驗收

8.1 單元(vitest)

tests/dashboard-role.test.ts

  • Setup:同一個 tenant DB 灌兩筆 user(sales A、sales B),各建一筆已指派 order。
  • Case 1:getDashboardStats(db, { kind: 'ownedBy', userId: A.id }) 只回 A 的訂單彙總。
  • Case 2:getDashboardStats(db, { kind: 'all' }) 回兩筆訂單合計。
  • Case 3:assigned_to_user_id = NULL 的訂單在 sales 視角下不被計入(SQL NULL 語意下不會 match — 預期行為)。
  • Case 4:getTopSalesThisMonth limit=3 時最多回 3 筆且依業績 desc。

packages/core/src/orders/dashboard-stats.test.ts(Top-5 熱銷產品補測):

  • Setup:同一 tenant DB,灌 2 條 trip(各 1 梯次)、若干 order:
    • Trip A:2 筆已成立 tour 訂單(各 party size=2、應收各 30000)+ 1 筆 cancelled。
    • Trip B:1 筆已成立 tour 訂單(party size=5、應收 40000)+ 1 筆 pending payment(party size=1、應收 10000)。
  • Case 1(month 排序)getTopProducts(db, { range: { kind: 'month' } }) → A 在前(revenue 60000)、B 次(revenue 40000,pending 不計營收)。
  • Case 2(口徑):B 的 orderCount=2(paid + pending)、paxCount=6(5+1);A 的 orderCount=2(cancelled 不計)、paxCount=4。確認營收嚴格、筆數/人數寬鬆。
  • Case 3(limit)limit: 1 只回 A。
  • Case 4(custom 區間 + 時區):把某筆 order 的 created_at 設成「台北日界線附近」(例 UTC 16:01 = 台北隔日 00:01),用 custom from=隔日 驗證它被算進隔日、不算前一日。驗證 Asia/Taipei 邊界正確。
  • Case 5(HAVING):建一條全 cancelled 訂單的 trip → 不出現在結果。
  • Case 6(today / week 不爆):呼叫 today / week range 至少能跑、回傳型別正確(邊界值靠 Case 4 已驗時區邏輯)。

8.2 E2E(playwright)

tests/e2e/dashboard-roles.spec.ts

  • 建兩個 fixture user(role=sales、role=admin),各自登入 → 進 /admin
  • sales 看不到 text=全站本月業績 或「業務排行榜」block。
  • admin 看到上述兩者。
  • sales 看到的訂單表筆數 ≤ 5 且都是 assigned_to_user_id = self
  • admin 登入 → 看得到「熱銷產品 Top 5」區塊,且至少一列。
  • sales 登入 → 看不到 text=熱銷產品(與「看不到業務排行」斷言並列)。
  • 切時間範圍(點「當日」)→ 區塊重新渲染(筆數變動或空狀態出現)。

8.3 驗收條件

  • 旅行社經理用 sales 帳號登入後能說「對,這頁就是我想看的」。
  • admin 帳號保留原本 dashboard 體驗 + 業務排行 mini-widget。
  • 主管帳號登入後,能在儀表板看到「熱銷產品 Top 5」並切換日 / 週 / 月 / 自訂,數字與訂單列表交叉比對一致。
  • 業務帳號完全看不到熱銷產品區塊。
  • 時區:跨日界線下單的 order 歸屬到正確的台北日期。

9. 影響範圍

路徑改動類型
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.tsgetDashboardStats signature、加 getTopSalesThisMonth;加 getTopProducts + TopProductsRange / TopProductRow 型別
packages/core/src/orders/dashboard-stats.test.tsTop-5 補測
tests/dashboard-role.test.ts角色化單元測試
tests/e2e/dashboard-roles.spec.tsE2E(新增 / 補斷言)

不動的部分

  • 權限模型 (packages/core/src/permissions.ts);本區塊用 role gate(admin)而非新 permission。
  • Schema、migration(零 schema 改動)。
  • 訂單列表頁、訂單詳情頁。

10. 連動

  • order-money-model:sales 視角下「我的待付款」需使用現行 payment schedule / intent 狀態產生補款入口;本 spec 不另建催繳模型。
  • 團控分房:op 視角下「待安排梯次」KPI 在房間分配 UI 之前就可顯示(只看「梯次旅客 ≥ 2 人但未分房」這個條件),完成後點進去進到分配頁。
  • 護照 OCR:無直接連動。
  • 訂單派發強連動。sales 視角的 filter 以 assigned_to_user_id 與已成立 assignment 為準;未派發訂單不屬於任何 sales 個人視角。

11. 開放議題

  • 主管視角下要不要再切「我直屬下屬」vs「全公司」? 暫不做,等員工層級關係(staff hierarchy)有需求再說。目前 admin = 全公司。
  • 客戶自建單尚未派發時應該歸誰? 依賴訂單派發機制。期間這類訂單只在 admin / op 視角看到(NULL 不在 sales filter 範圍),驗收時要與經理確認可接受。
  • 業務排行榜要不要往下排到所有業務? 目前只 top 3。若公司業務 > 10 人可能會抱怨「我永遠看不到自己排第幾」 — 但這正是經理想要的(業務間不互比)。若 admin 想看完整排名,可走獨立頁面(/admin/sales-rank),本 spec 範圍外。
  • 時區:本月、本日的邊界以 Asia/Taipei 為準。Repo 內計算 thisMonthStart() 時要明確指定 TZ,不要用 UTC midnight。

12. Future — 網站瀏覽量可視化追蹤(不實作)

舊系統有「網站流量 / 行程頁瀏覽量」可視化,旅行社經理列為低優先。ERP 現階段不做,原因與接點:

  • 缺資料來源:目前後台只有「成交資料」(orders),沒有前台行為資料(page view / session / 行程頁停留)。瀏覽量需要外部分析來源——選項:
    1. 接 GA4 / Plausible / Umami 之類前端分析,後台讀其 API / 匯出。
    2. 自建輕量 event 收集(前台行程頁打一支 POST /api/analytics/pageview,寫獨立 page_views 表)——但這是另一條工作流、有 schema 與隱私(PDPA)考量,超出本範圍。
  • 未來接點(佔位,不實作)
    • 主管儀表板新增「行程頁瀏覽量 Top-N」widget,與「熱銷產品(成交)」並列——瀏覽 vs 成交的對照(高瀏覽低成交 = 頁面 / 定價問題的訊號)。
    • repo 佔位:未來 getTopViewedTrips(db, { range }),資料源為外部分析或自建 page_views,signature 比照 getTopProducts(同一組 TopProductsRange 時間範圍型別可重用)。
    • 需先決策資料來源(外接 vs 自建)+ PDPA 同意 / 匿名化策略,再另開 spec。

13. Overview 總覽面板(待辦聚合)

0617 會議:後台需要一個一目了然的總覽,把最重要的訂單與狀態往前擺,避免人員上下捲動、到處找資訊。本節是既有角色化 dashboard(§3–§7)的版面強化,不是第二個 dashboard。

已落地於 src/app/(admin)/admin/_views/overview-todos.tsxOverviewTodos),並織入 sales / op / accountant / admin 四個角色視角(各 *-dashboard.tsx 皆引用)。待辦數量沿用 DashboardStats 既有 count(不另寫口徑),每項連到已預過濾清單(帶 owner=me / status= query),空狀態顯示「目前沒有待處理事項」。

13.1 概念

角色化 dashboard 回答「我的數字如何」;Overview 回答「現在有什麼非我處理不可」。把跨頁面散落的待辦聚合到 /admin 頂部一個「待辦區」,每項是可點擊的入口(連到對應清單已預過濾)。純讀取、零 schema 改動,沿用各既有 repo 的計數查詢。

13.2 各角色的待辦聚合(依角色換內容,比照 §3 隔離原則)

角色待辦項(每項 = 數量 + 連到已過濾清單)
sales我的待付款、我的同意書待簽、我的未中籤待處理(抽籤
op7 天內待出團、待分房梯次、待安排(缺資料)、入山名冊未備(departure-prep
accountant待對帳、待付尾款、待核請款(control-finance)、待開立 / 缺件代收轉付收據(travelinvoice
admin上述全公司彙總 + 異常(overpaid / refund pending / schedule invariant 失敗)

13.3 設計約束

  • 不新增 stat 計算邏輯:待辦數量沿用既有 repo(dashboard-stats / control / payment-requests…)的 count 查詢聚合,不另寫口徑。
  • 角色隔離不破:sales 的待辦只含自己歸屬(assigned_to_user_id),與 §3 一致。
  • 可點即達:每項連到對應清單頁並帶 query 預過濾(如 /admin/orders?payment_state=partially_paid&owner=me),不在 Overview 內重做清單。
  • 空狀態:無待辦顯示「目前沒有待處理事項」,不擺空卡。

13.4 開放議題

  • Overview 待辦區是取代既有 4 個 KPI cards,還是疊在其上方(先疊在上方、KPI 保留)。
  • 待辦項的優先序 / 紅點門檻(如「逾期未付」才標紅)。

14. 月報表頁(/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

14.1 內容

  • 年 / 月切換?year=&month=<form method="GET"> + 兩個 <Select>(年為當年 ±2、月 1–12)+「套用」。收款依 paid_at 統計、訂單依 created_at 統計。
  • KPI 四卡:總營收(已付款收款加總)、新訂單、已付款訂單、取消數。
  • 付款方式佔比:依 provider 分組(ecpay / test / manual)的金額與百分比。
  • 行程銷售排行 Top 10:本月已付款收款依 trip 聚合(訂單數 + 營收 + 佔比橫條)。

14.2 每日營收趨勢圖

本月逐日實收現金長條圖(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)。

Changelog

版本日期變更Commit
12026-05-28初版5d9a95a
22026-05-30整理:對齊現行 code + 路徑搬移
32026-06-20N8 Overview 總覽面板(§13):跨頁面待辦聚合至 /admin 頂部、依角色隔離、純讀取零 schema、可點即達。
42026-06-20修正 §3 Changelog 章節參照:Overview 總覽面板實際為 §13(原誤植 §14)。
52026-07-01Code-Is-Truth 對齊:§13 Overview 待辦聚合去除 stale「未實作」、補記已落地(overview-todos.tsx 織入四視角);新增 §14 月報表頁(/admin/reports)薄描述,明標趨勢圖與 PDF/CSV 匯出為 Plan 9 未完成 stub。
62026-07-06§14.2 改寫:每日營收趨勢圖落地(dailyRevenue 逐日實收 + @toko-cloud/ui/chart shadcn/Recharts wrapper);儀表板「新單趨勢」卡假 polyline 改真資料(getDailyNewOrdersSeries);移除 stale「匯出 disabled」敘述(匯出早已接真)與懸空的 Plan 9 指向。