Skip to content

Prepaid Card Analytics Tab — UI Specification

Date: 2026-03-13 · Version: 3.6 Parent doc: dev-spec.mdFramework: Vue 3 + Quasar v2.7.5 + TypeScript Chart lib: Chart.js 3.9 + vue-chart-3 Scale: 70 chi nhánh, desktop-first (responsive support) Profile: Large (M/L compliance — bao gồm B-PRE / B0.4 / B0.5 / B-POST)

v3.16 — 16/05/2026

Thay đổiSectionẢnh hưởng
ROI card highlight rule (canonical PRD A10 FORMULA-020): 3 màu 🟢 xanh > 200% / 🟡 vàng 100%–200% / 🔴 đỏ < 100%; marketing_cost = 0 → "—" + tag "Chưa có budget" (KHÔNG tô màu)§7.1 Marketing KPI CardsFE, UI/UX

v3.15 — 14/05/2026

Thay đổiSectionẢnh hưởng
Field × Surface Matrix tách wallet_balance_total, wallet_balance_diva, wallet_balance_km để bảng KH hiển thị riêng Dư ví DIVA/KM nhưng KPI Tổng quan vẫn giữ 1 metric tổngB0.4 Field × Surface MatrixFE, BE
Column config Sub-tab Khách hàng: thay Dư ví bằng 2 cột Dư ví DIVA + Dư ví KM, mỗi cột 110px, format VND; risk highlight theo tổng dư ví§5.2 Customer TableFE, UI/UX, QA
Expanded row KH giữ số dư ở header cấp khách hàng, không đưa Dư ví vào Tab "Thẻ đã mua" vì ví là pool chung per-customer§5.3 Expanded RowFE, QA

v3.14 — 12/05/2026

Thay đổiSectionẢnh hưởng
Section 4.5 Expanded Row: wireframe từ 3 tab → 2 tab (bỏ "Lịch sử sử dụng") — ref DEC-T07§4.5 Expanded RowFE, UI/UX, QA
⭐ DEC-T08: Tab Hoa hồng (Sub-tab 4 expand) — bỏ cột "Trạng thái" (chỉ 1 status thực tế), refund hiển thị thành row riêng amount âm với badge ↩️ Hoàn HH. KPI "Tổng hoa hồng" đổi source transaction_request_userinvoice_commissionSub-tab 4 Tài chính (expand row HH), KPI Hoa hồngFE, UI/UX
⭐ DEC-U13: Alert Box redesign rich context — 4 lớp (SLA label / metric+trend / suggested action / primary verb-button). Bỏ secondary button. Hover tooltip preview mandatory§3.3 Alert BoxFE, UI/UX
⭐ DEC-U13: Click Action Matrix §10.1.3 — thêm 3 cột (Primary action label / Sort override / Banner top template). Banner top mandatory trên landing page§10.1.3FE, BE, QA
⭐ DEC-U13: Tab Công nợ — bổ sung wireframe landing-from-alert state (banner top, summary highlight, sort overdue DESC, filter chips)§6.4 Công nợFE, UI/UX, QA

B-PRE) Discovery & Pattern Reuse

Trước khi spec UI mới, đối chiếu codebase Diva theo CLAUDE.md "Codebase-First rule".

B-PRE.1 Reuse / Extend / Build matrix

SurfaceReuse / Extend / Build mớiPath / Lý do
Tab bar (parent QTabs)Reuse QTabs + QTabPanels (Quasar built-in, DEC-T06)Đã dùng đa module — KHÔNG router-view child
Filter bar (Chi nhánh + Khoảng TG + Tìm kiếm)🔧 Extend pattern shared filter có sẵnBranch selector + DateRangePicker đã có ở report module — extend thêm shared q (DEC-U12)
KPI Card🔧 Extend KpiCard component nếu cóVerify diva-admin/src/components/ — nếu có pattern thì extend prop severity, tooltip, onClick
Empty State🔧 Extend / Build mới EmptyState.vueSection 9.2 v2 — pattern reuse check ở plan.md Task 19b Step 0
Active filter chip🆕 Build mới useFilterChips composable + chip renderSpecific cho Empty State v2 — chưa có pattern existing
Search inputReuse QInput với debounce + min 2 charsPattern thông dụng
Export button🔧 Extend pattern export hiện cóPermission gate ở entry, chunked download dialog (Section 11.x)
QTable expandableReuse QTable slot body-expand + expanded-rows.syncQuasar built-in

B-PRE.2 Anti-pattern (đã reject)

Anti-patternReject reason
Router child components per sub-tabPhá state khi switch tab → DEC-T06 chọn QTabPanels
Local search input ở từng sub-tabDEC-U12 — drift state, duplicate UX
Compare Mode toggleDEC-U04 — phức tạp UX không đáng
Charts toggle Ngày/Tuần/ThángDEC-U08 — Phase 1 chỉ Ngày
Bulk actions (SMS/ZNS/Gán NV) ở sub-tab Khách hàngDEC-U09 — write actions thuộc Marketing/CRM module

B0.4) Field × Surface Matrix

Map mỗi data field từ source (MV / table column) → surface (sub-tab + section + UI element). Đảm bảo không có field nào "lạc" (không hiển thị) hoặc "duplicate" (hiển thị ở 2 nơi với label khác nhau).

Field sourceTổng quanGiao dịchKhách hàngTài chính
total_collectedKPI #1Sum card "Tổng thu" + cột "Tiền thu" bảngKPI #1 + Tab Tổng hợp DT
total_wallet_topupKPI #2 "Tổng nạp ví"Sum card "Tổng nạp ví" + cột "Nạp ví"Tab Tổng hợp DT
total_vi_diva_napped ⏳ V9(defer — Tổng quan giữ 1 metric)Sum card "Ví Diva ⭐"Defer cho đến V9 unlock
total_vi_km_napped ⏳ V10(defer)Sum card "Ví KM ⭐"Tab Tổng hợp DT cột "KM đã nạp"
total_vi_diva_used ⏳ V11KPI "DT ghi nhận"KPI segment KH "Đã dùng ví chính"Tab Tổng hợp DT cột "DT ghi nhận"
total_vi_km_used ⏳ V12(gộp trong DT)KPI segment KH (gộp)Tab Tổng hợp DT cột "KM đã sử dụng"
wallet_balance_total ⏳ V5KPI "Tổng dư ví"Header expand + risk logicTab Tổng hợp DT (báo cáo dư ví — defer P1.x)
wallet_balance_diva ⏳ V5aCột "Dư ví DIVA" bảng KHDùng để reconcile wallet_balance_total
wallet_balance_km ⏳ V5bCột "Dư ví KM" bảng KHDùng để reconcile wallet_balance_total
commissionKPI "Hoa hồng"Sum card "Tổng hoa hồng"Tab Hoa hồng
outstanding_debtKPI "Công nợ"Sum card "Tổng nợ"Tab Công nợ
customer.segment(gộp KPI "KH có thẻ")Segment cards (4 nhóm) + cột "Phân khúc"
customer.consumption_rateCột "% Đã dùng ví" + behavior bar
last_used_at(alert input)Cột "Lần dùng ví cuối"

Coverage check: mỗi field ≥ 1 surface, mỗi surface ≥ 1 field source. Field PROVISIONAL (V9-V12) đánh dấu rõ ⏳ trong cột — sau khi unlock thì cập nhật bảng này.


B0.5) State × Screen Matrix

Map state UI (loading / has-data / empty-no-data / empty-filter-narrow / error / no-permission / stale-data) × screen (sub-tab) → trạng thái render expected.

StateTổng quanGiao dịchKhách hàngTài chính
LoadingSkeleton 8 KPI cards + skeleton chartSkeleton 7 sum cards + skeleton table 5 rowSkeleton 4 segment cards + skeleton table 5 rowSkeleton 8 KPI + skeleton tab content
Has data8 KPI + chart + alert list + Top 5 CN7 sum cards + table + pagination4 segment + behavior bar + table8 KPI + 4 component-level tabs + export bar
Empty no-data (CN trống thực sự)KPI hiển thị "—" + chart empty illustration "Chưa có giao dịch"EmptyState v2 variant no-data 📭EmptyState v2 variant no-data 📭Tab nội dung trống — banner "CN này chưa có giao dịch nạp thẻ"
Empty filter-narrow (filter quá hẹp)N/A (KPI luôn render — không có filter narrow)EmptyState v2 variant filter-narrow 🔍 + chips + counterEmptyState v2 variant filter-narrow (chips: shared q + segment active)Tab Công nợ/Hoa hồng có table → variant filter-narrow
ErrorToast + EmptyState error ⚠️ + retryEmptyState error + retryEmptyState error + retryPer-tab EmptyState error
No permissionSub-tab không hiện trong tab bar (DEC-U03)SameSameSame
Stale data badgeBadge "Dữ liệu cập nhật lúc 14:30" + [🔄 Tải lại] (DEC-U11)Badge ở header bảngBadge ở header segment cardsBadge ở header KPI

Coverage check: mỗi state × mỗi screen có quy định rõ. Crossing-out cell N/A có lý do.


1. Page Layout

1.1 Cấu trúc tổng thể

┌──────────────────────────────────────────────────────────────────┐
│ SHARED FILTER BAR (sticky top, z-index: 10)                       │
│ [Chọn chi nhánh ▾] [Khoảng thời gian ▾]                          │
│ [🔍 Tìm KH, SDT, mã đơn, NV...]                                 │
├──────────────────────────────────────────────────────────────────┤
│ SUB-TAB BAR (QTabs/XTabs, sticky dưới filter)                     │
│                                                                   │
│ Phase 1 (4 tabs — render trong MVP):                              │
│ [Tổng quan] [Giao dịch] [Khách hàng] [Tài chính]                 │
│                                                                   │
│ Phase 3+ (TBD — KHÔNG render trong Phase 1):                      │
│ ... [Marketing] [Nhân viên]                                       │
├──────────────────────────────────────────────────────────────────┤
│                                                                   │
│                    TAB CONTENT AREA                               │
│              (QTabPanels + direct component, lazy import)         │
│                                                                   │
└──────────────────────────────────────────────────────────────────┘

Phase 1 sub-tab order chuẩn (ref: prd.md DEC-U07): Tổng quan → Giao dịch → Khách hàng → Tài chính

Khách hàng đứng TRƯỚC Tài chính — phù hợp luồng đọc của Quản lý: nhìn tổng → tra đơn → xem khách → mới đến số tiền.

1.2 Sticky Behavior

ElementStickyz-indexHeight ước tính
App header (có sẵn)sticky10048px
Shared filter barsticky, dưới app header1056px (collapsed) / 96px (expanded)
Sub-tab barsticky, dưới filter940px
Tab contentscroll tự doauto

Filter bar collapse: trên mobile hoặc khi scroll > 200px, filter thu gọn thành 1 dòng với nút “Mở rộng”.

1.3 Responsive Breakpoints

BreakpointLayoutGhi chú
≥ 1280px (desktop)Full layout, charts 2×2 gridMặc định
768–1279px (tablet)KPI cards 2 cột, charts stack dọcSub-tabs → horizontal scroll
< 768px (mobile)KPI 1 cột, charts full-width, tables horizontal scrollFilter collapsed mặc định

2. Shared Filter Bar

Shared filter bar chỉ có 3 element (ref: prd.md DEC-U04). Bỏ chế độ "So sánh KV / So sánh CN" — đơn giản hóa state, các chart/bảng có khu vực vẫn giữ nguyên (không phụ thuộc toggle).

2.1 Layout

Desktop:
┌────────────────────────────────────────────────────────────────────┐
│ [Chọn chi nhánh ▾]  [Khoảng thời gian ▾]                          │
│ [🔍 Tìm KH, SDT, mã đơn, NV...________________________]           │
└────────────────────────────────────────────────────────────────────┘

2.2 Branch Selector (Chọn chi nhánh)

Component: QSelect multi-select với search + grouped options.

┌─ Chọn chi nhánh ──────────────────────┐
│ 🔍 Tìm chi nhánh...                   │
│                                        │
│ ☑ Tất cả (70)                          │
│                                        │
│ ▼ HCM (25 CN)                          │
│   ☑ Q.1 Lê Lai                         │
│   ☑ Q.3 Nguyễn Đình Chiểu             │
│   ☐ Bình Thạnh                         │
│   ...                                  │
│                                        │
│ ▶ Hà Nội (15 CN)                       │
│ ▶ Đà Nẵng (8 CN)                       │
│ ▶ Cần Thơ (5 CN)                       │
│ ...                                    │
│                                        │
│ [Chọn tất cả KV] [Bỏ chọn tất cả]    │
└────────────────────────────────────────┘

Behavior:

  • Search: lọc realtime khi gõ (client-side, 70 items OK)
  • Click tên khu vực: chọn/bỏ tất cả CN trong khu vực đó
  • Badge hiển thị: "3 CN" hoặc "HCM (25)" hoặc "Tất cả"
  • Mặc định chọn các CN user có quyền (theo role)
  • Persisted: ?branch=uuid1,uuid2 trong URL query params
  • Tooltip ℹ️: "Chọn các CN bạn muốn xem báo cáo. Mặc định hiện CN bạn có quyền. Nhân viên chỉ xem CN đang làm; QL CN chỉ xem CN mình quản; QL vùng/Admin xem nhiều CN."

2.3 Date Range Picker (Khoảng thời gian)

Component: QSelect dropdown — gộp cả presets + custom range trong 1 control (ref: prd.md DEC-U05).

┌─ Khoảng thời gian ────────────────┐
│ ● Hôm nay                          │
│ ○ Hôm qua                          │
│ ○ 7 ngày qua                       │
│ ○ 30 ngày qua                      │
│ ● Tháng này (mặc định)             │
│ ○ Tháng trước                      │
│ ○ Quý này                          │
│ ─────────────────────────────────  │
│ ○ Tùy chọn... → mở calendar        │
│   [📅 01/03/2026 → 13/03/2026]    │
└────────────────────────────────────┘

Behavior:

  • Click preset → set range, đóng dropdown, badge hiển thị tên preset (VD: "Tháng này")
  • Click "Tùy chọn..." → mở QDate range calendar picker
  • Custom range chọn xong → badge hiển thị "01/03 → 13/03"
  • Mặc định: Tháng này
  • Max range: 1 năm (365 ngày) — vượt sẽ disable button "Áp dụng"
  • Persisted: ?from=2026-03-01&to=2026-03-13 trong URL
  • Dữ liệu so sánh kỳ trước được tính tự động (kỳ này N ngày → kỳ trước = N ngày liền kề trước đó)
  • Tooltip ℹ️: "Khoảng ngày để tính KPI. Mặc định 'Tháng này'. Tối đa 1 năm. So sánh kỳ trước tự động tính theo độ dài kỳ này."

2.4 Global Search (Tra cứu nhanh)

⏳ Phase 2 — Defer sau MVP. Phase 1 đã có search trong từng sub-tab (Giao dịch, Khách hàng) — đủ nghiệp vụ cấp thiết. Global search là tiện ích nâng cao, build sau khi MVP ổn định (ref: prd.md DEC-B05). Section này giữ trong spec để team có blueprint sẵn khi Phase 2 ramp up.

Component: Custom search input + dropdown results.

🔍 [Tìm KH, SDT, mã đơn, NV..._______________]

  ┌─ Kết quả ─────────────────────────────────┐
  │ 👤 Khách hàng                              │
  │   Nguyễn Văn A — 0901xxx — Q.1 — Dư: 7tr  │
  │   Trần Thị B — 0912xxx — Q.3 — Dư: 3tr    │
  │                                            │
  │ 📋 Đơn hàng                                │
  │   PO-0412 — 12/03 — VIP 10tr — Đã thanh toán │
  │   PO-0398 — 10/03 — Silver 5tr — Còn nợ    │
  │                                            │
  │ 👨‍💼 Nhân viên                               │
  │   Lan — Q.1 — 25 đơn — 180tr doanh thu     │
  └────────────────────────────────────────────┘

Behavior:

  • Debounce: 300ms sau khi ngừng gõ
  • Minimum: 2 ký tự
  • Max: 5 kết quả / loại (backend search_prepaid_global)
  • Click kết quả:
    • Khách hàng → navigate sang sub-tab Khách hàng, filter KH đó
    • Đơn → navigate /e/prepaid-order/:id
    • Nhân viên → navigate sang sub-tab Nhân viên, filter NV đó
  • ESC hoặc click ngoài → đóng dropdown
  • Loading state: spinner trong dropdown khi đang query
  • Tooltip ℹ️: "Tìm theo tên KH, SĐT, mã đơn (PO-xxxx), tên NV. Tối thiểu 2 ký tự. Click kết quả để chuyển sang sub-tab tương ứng."

3. Sub-tab: Tổng quan

3.1 KPI Cards

Layout: 2 hàng × 4 cards = 8 cards. Responsive: 2×4 → 2×2 (tablet) → 1×8 (mobile).

Header section (trên KPI grid):

┌────────────────────────────────────────────────────────────────┐
│ Tổng quan thẻ trả trước                       [🔄 Tải lại]    │  ← Title + nút manual refresh
└────────────────────────────────────────────────────────────────┘

[🔄 Tải lại] (P1): manual trigger refresh data — query lại MV + clear FE cache. Hữu ích khi user vừa tạo đơn mới và muốn xem ngay (không đợi cron 15 phút).

🚫 KHÔNG có "[Tải xuống]" tổng ở header Tổng quan — Export đã có ở Sub-tab Tài chính (5 nút) + Sub-tab Khách hàng (1 nút) cho dữ liệu chi tiết. Tổng quan = dashboard read-only.

🚫 KHÔNG có banner auto-insight ("Doanh thu giảm 18%, chủ yếu từ miền Tây") trong Phase 1 — feature này cần AI/heuristic logic, defer Phase 3+.

┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Tiền thu vàoℹ│ │ DT ghi nhận ℹ│ │ Tổng dư ví ℹ│ │ Công nợ     ℹ│
│ 💰 (xanh)    │ │ 📊 (tím)     │ │ 💼 (tím)     │ │ ⚠️ (đỏ/vàng) │
│ 850 tr       │ │ 620 tr       │ │ 2,1 tỷ       │ │ 45 tr        │
│ ↑12% so      │ │ ↑8% so       │ │ (Nợ phải trả)│ │ ↑5% so       │
│ tháng trước  │ │ tháng trước  │ │              │ │ tháng trước  │
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
┌──────────────┐ ┌──────────────────┐ ┌─────────────────┐ ┌──────────┐
│ Thẻ đã bán  ℹ│ │ Tỷ lệ KH đã    ℹ│ │ Tỷ lệ KH       ℹ│ │ KH mới  ℹ│
│ (tím)        │ │ dùng ví (tím)    │ │ tái nạp (tím)   │ │ (xanh)   │
│ 156 thẻ      │ │ 78%              │ │ 42%             │ │ 23 KH    │
│120 Cố định + │ │ ↑3% so           │ │ ↓2% so          │ │ ↑15% so  │
│ 36 Linh hoạt │ │ tháng trước      │ │ tháng trước     │ │ tháng tr.│
└──────────────┘ └──────────────────┘ └─────────────────┘ └──────────┘

Chi tiết mỗi card:

┌─────────────────────────────┐
│ [Icon] [Label]       [ℹ️]   │  ← Icon nhóm + Label + tooltip icon
│                             │
│ [Giá trị chính]             │  ← Font 24px bold; format VND rút gọn (xem rule dưới)
│                             │
│ [↑12% so với {kỳ trước}]   │  ← Trend dynamic theo filter (DEC-U08)
└─────────────────────────────┘

Format VND chuẩn (tất cả 8 cards):

Giá trịFormatVí dụ
< 1.000.000 đ{N} đ500.000 đ
≥ 1.000.000 đ{N} tr (rút gọn, 1 decimal)850 tr, 2,5 tr
≥ 1.000.000.000 đ{N} tỷ (rút gọn, 1 decimal)2,1 tỷ
Hover cardTooltip exact full format850.000.000 VND

Cấm hiển thị 35.000.000.000đ cho Thẻ đã bán / Tỷ lệ / KH — đây là wireframe lỗi đơn vị. Đơn vị phải đúng: thẻ → {N} thẻ, tỷ lệ → {N}%, KH → {N} KH.

Trend label dynamic theo filter Khoảng thời gian (đồng bộ DEC-U08 + ref PRD A10 FORMULA-019 cho cách tính kỳ trước):

Filter Khoảng thời gianTrend label
Hôm nay"so với hôm qua"
7 ngày qua"so với 7 ngày trước"
Tháng này (default)"so với tháng trước"
Quý này"so với quý trước"
Tùy chọn (N ngày)"so với kỳ trước (N ngày)"

Visual:

  • Border-left 4px solid theo nhóm:
    • 💰 Tài chính (Tiền thu, DT ghi nhận, Tổng dư ví): $primary (xanh)
    • ⚠️ Cảnh báo (Công nợ): $warning (vàng) → đổi $negative (đỏ) khi vượt ngưỡng
    • 📊 Vận hành (Thẻ đã bán, Tỷ lệ KH dùng/tái nạp, KH mới): $purple-6
  • Hover: shadow elevation tăng + cursor pointer
  • Click: navigate sang sub-tab tương ứng (xem Section 10.1)
  • ℹ️ icon: hover → QTooltip nội dung từ dev-spec Section 16.2
  • "Công nợ" card: border đỏ + pulse animation khi vượt ngưỡng cấu hình; bình thường vẫn hiển thị trend ↑↓%
  • "Tổng dư ví": giữ 1 metric tổng (= Ví Diva + Ví KM gộp) + dòng phụ "(Nợ phải trả)". KHÔNG tách sub-cards Diva/KM ở Tổng quan (chi tiết tách đã có ở Sub-tab Giao dịch Section 4.3).
  • "Thẻ đã bán": dòng phụ "X Cố định + Y Linh hoạt" (font 12px, color gray-6). Giá trị X/Y tách từ prepaid_card_view.flexible

3.2 Charts (2×2 Grid)

┌─────────────────────────┐ ┌─────────────────────────┐
│ Doanh thu theo thời gian│ │ So sánh khu vực          │
│ [Line chart]             │ │ [Grouped bar chart]      │
│                          │ │                          │
│ — Kỳ này (solid)        │ │ ▓ Doanh thu  ░ Thẻ bán  │
│ --- Kỳ trước (dashed)   │ │ HCM | HN | ĐN | CT      │
└──────────────────────────┘ └──────────────────────────┘
┌─────────────────────────┐ ┌─────────────────────────┐
│ Phân bố mệnh giá        │ │ Tỷ lệ sử dụng trend     │
│ [Donut chart]            │ │ [Area chart]             │
│                          │ │                          │
│ ● 1tr: 12%  ● 5tr: 28% │ │ ▓ KH đã dùng ví         │
│ ● 10tr: 25% ● 20tr: 15%│ │ ░ % Đã xài              │
│ ● Linh hoạt: 20% (tím) │ │                          │
└──────────────────────────┘ └──────────────────────────┘

Chart controls (mỗi chart):

  • Time-series charts luôn group theo ngày (granularity = ngày, không có toggle). Range được điều khiển bởi shared filter "Khoảng thời gian". Nếu range = 1 ngày → đổi sang single-value card thay vì line chart
  • Hover: tooltip với giá trị chính xác + format VND
  • Click data point: xem chi tiết (future enhancement)
  • Donut “Phân bố mệnh giá”: các segment là mệnh giá thẻ cố định (1tr, 5tr, 10tr, 20tr) + 1 segment riêng “Nạp linh hoạt” (màu $purple-6) gộp tất cả flexible cards. Hover “Nạp linh hoạt” → tooltip: “Nạp linh hoạt: {count} thẻ, TB {avg}đ, khoảng {min}–{max}đ”
  • Responsive: 2×2 → 1×4 stack (tablet/mobile)
  • Loading: skeleton placeholder 300×200px

Chart.js config chung:

  • Locale: vi-VN
  • Currency format: Intl.NumberFormat('vi-VN') → “850 tr”, “2,1 tỷ”
  • Color palette (Quasar): $primary, $secondary, $positive, $negative, $warning, $info
  • Font: inherit from Quasar theme
  • Animation: duration: 750, easing: 'easeOutQuart'

3.3 Alert Box — Rich Context v3.14 (DEC-U13)

Update v3.14 (DEC-U13): Wireframe cũ chỉ có nút [Xem] generic + 1 dòng metric → user không biết click sẽ navigate đâu, không biết urgency, không biết bước tiếp theo. Redesign rich context: SLA label theo severity + trend chip ↑↓ + suggested action + primary action button có số liệu rõ.

┌─ Cần chú ý (7) ─────────────────────────────────────────── [Thu gọn ▲] ┐
│                                                                          │
│  🔴 Khẩn cấp (2)  ⚠️ Cần xử lý trong 24h                                │
│  ┌──────────────────────────────────────────────────────────────────┐  │
│  │ 📍 CN Đà Lạt                                                      │  │
│  │ 120 đơn quá hạn > 30 ngày — Công nợ 45 tr   ↑15 (vs 7d qua)      │  │
│  │ 💡 Gợi ý: Gọi KH hoặc gửi SMS nhắc thanh toán                    │  │
│  │ [📋 Xem 120 đơn]                                                  │  │
│  └──────────────────────────────────────────────────────────────────┘  │
│  ┌──────────────────────────────────────────────────────────────────┐  │
│  │ 📍 CN Q.3 — Doanh thu giảm 35% so với tháng trước  ↓             │  │
│  │ 💡 Gợi ý: Phân tích lý do (nhân sự / cạnh tranh / mùa vụ)        │  │
│  │ [📊 Xem chart doanh thu]                                          │  │
│  └──────────────────────────────────────────────────────────────────┘  │
│                                                                          │
│  🟡 Cảnh báo (3)  📅 Cần xử lý trong 7 ngày                            │
│  ┌──────────────────────────────────────────────────────────────────┐  │
│  │ 📍 HCM — 23 KH > 60 ngày không dùng ví (dư > 1tr)                │  │
│  │ 💡 Gợi ý: Gửi SMS/ZNS kích hoạt lại                              │  │
│  │ [👥 Xem 23 KH]                                                    │  │
│  └──────────────────────────────────────────────────────────────────┘  │
│                                                                          │
│  🔵 Theo dõi (2)  👀 Chỉ thông tin — không cần hành động ngay          │
│  ┌──────────────────────────────────────────────────────────────────┐  │
│  │ 12 KH VIP > 90 ngày không hoạt động                              │  │
│  │ 💡 Gợi ý: Lên DS gọi điện hỏi thăm                               │  │
│  │ [⭐ Xem 12 KH VIP]                                                │  │
│  └──────────────────────────────────────────────────────────────────┘  │
│  ┌──────────────────────────────────────────────────────────────────┐  │
│  │ Thẻ Silver giảm 30% lượng bán so với kỳ trước  ↓                 │  │
│  │ 💡 Gợi ý: So sánh giá / promotion với kỳ trước                   │  │
│  │ [💳 Xem chi tiết thẻ Silver]                                     │  │
│  └──────────────────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────────────┘

Visual rules:

  • QExpansionItem hoặc custom collapsible
  • Mặc định: expanded nếu có 🔴 critical, collapsed nếu chỉ có 🔵 info
  • Badge count ở header: "Cần chú ý (7)" — tổng tất cả alert
  • Severity colors: $negative (đỏ), $warning (vàng), $info (xanh dương)
  • Empty state: "Không có cảnh báo — mọi thứ đang tốt! 👍" (ẩn cả box)

Alert card anatomy (4 layer):

#ElementRule
1SLA label (header group)⚠️ Cần xử lý trong 24h (🔴) / 📅 Cần xử lý trong 7 ngày (🟡) / 👀 Chỉ thông tin (🔵). Render bên cạnh count badge Khẩn cấp (N). Render từ payload.sla_label
2Metric + trend chipDòng 1 mô tả vấn đề. Trend chip cuối dòng: ↑15 (vs 7d qua) (negative trend = đỏ) hoặc (positive trend = xanh). Render từ payload.trend_value + payload.prior_count. Nếu BE không return trend → ẩn chip (KHÔNG render placeholder)
3Suggested action 💡Dòng 2 — hint hành động tiếp theo, 1 câu ngắn. Mapping từ payload.suggested_action_key → i18n key (VD: alert.suggest.call_overdue_debt)
4Primary action buttonVerb + số liệu rõ: [📋 Xem 120 đơn], [👥 Xem 23 KH], [📊 Xem chart doanh thu]. Render từ template alert.action_label.{alert_type} interpolate count. KHÔNG dùng generic [Xem]

Hover tooltip preview (mandatory — DEC-U13):

Trước khi click → hover vào primary button → tooltip preview navigation:

┌──────────────────────────────────────────────────────────┐
│ Sẽ navigate sang: Tài chính → Công nợ                    │
│ Filter: branch=Đà Lạt + overdue>30d + range=365d gần nhất│
└──────────────────────────────────────────────────────────┘
  • Quasar QTooltip max-width 380px, position top, delay 300ms
  • Content: "Sẽ navigate sang: {target_tab}{target_inner_tab}" + "Filter: {key1}={val1} + {key2}={val2}..."
  • Render từ Click Action Matrix Section 10.1.3 metadata

Cell rendering example:

  • SLA label: <QChip dense size="sm" color="negative-1" text-color="negative">⚠️ Cần xử lý trong 24h</QChip>
  • Trend chip negative: <QChip dense size="sm" color="negative-1" text-color="negative" icon="arrow_upward">↑15 (vs 7d qua)</QChip>
  • Suggested action: <div class="text-grey-7 q-mb-sm">💡 Gợi ý: {text}</div>
  • Primary button: <QBtn flat color="primary" :label="📋 Xem ${count} đơn" @click="navigate(alert.payload)" />

Bỏ secondary button (clarification v3.14):

Phase 1 chỉ giữ 1 primary action per alert. Secondary actions kiểu [📞 Gọi gấp danh sách] / [💬 Gửi SMS hàng loạt] cần Bulk Action API + RBAC + audit — defer Phase 1.x. Tránh tạo nút dead-end trong Phase 1.

3.4 Mini Rankings (3 bảng ngang)

┌─ Top 5 thẻ bán chạy ──┐ ┌─ Top 5 nhân viên giỏi ──┐ ┌─ Top 5 KH VIP ──────────┐
│ # Tên thẻ   Số  D.thu │ │ # NV    CN    Đơn  D.thu │ │ # KH      Nạp  Dư  Tần │
│ 1 VIP 20tr  45  900tr │ │ 1 Lan   Q.1   25   180tr │ │ 1 A.Minh  50tr 12tr 3/th│
│ 2 Gold 10tr 38  380tr │ │ 2 Hùng  Q.3   22   165tr │ │ 2 B.Thảo  45tr 8tr  2/th│
│ 3 Silver 5tr 32 160tr │ │ 3 Mai   B.Th. 18   120tr │ │ 3 ...                   │
│ 4 ...                 │ │ ...                       │ │ ...                     │
│ 5 ...                 │ │ ...                       │ │ ...                     │
│          [Xem thêm →] │ │             [Xem thêm →] │ │            [Xem thêm →] │
└───────────────────────┘ └───────────────────────────┘ └─────────────────────────┘

Header chuẩn:

  • Top thẻ bán chạy: Tên thẻ · Số đã bán · Doanh thu
  • Top nhân viên giỏi: Nhân viên · Chi nhánh · Số đơn · Doanh thu
  • Top KH VIP: Khách hàng · Tổng tiền đã nạp · Dư ví · Tần suất nạp / tháng

Layout: QCard × 3, flex row (desktop), stack (mobile)

[Xem thêm →] mỗi card — destination contract đầy đủ ở Section 10.1.2 Click Action Matrix. Tóm tắt:

  • Top thẻ bán chạy → ⚠️ Phase 1: DISABLED (chờ Phase 1.x bổ sung view "Báo cáo Top thẻ" dedicated). Lý do: Phase 1 không có surface card-level ranking thật.
  • Top NV giỏiTài chính → Hoa hồng (KHÔNG sub-tab Nhân viên defer P3+)
  • Top KH VIP → Khách hàng sub-tab (sort total_paid desc, KHÔNG segment filter)

4. Sub-tab: Giao dịch

Tiêu đề trang: "Chi tiết giao dịch nạp thẻ"

4.1 Layout

┌────────────────────────────────────────────────────────────────────────────┐
│ TITLE: "Chi tiết giao dịch nạp thẻ"                                         │
├────────────────────────────────────────────────────────────────────────────┤
│ SUM CARDS (grid 7 cards — auto-fit responsive)                              │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐                │
│ │ Tổng đơn   │ │ Tổng thu   │ │Tổng nạp ví │ │ Ví Diva  ⭐│ ...            │
│ │ 1.200      │ │ 390 tr     │ │ 250 tr     │ │ 200 tr     │                │
│ │ ↑12%       │ │ ↑12%       │ │ ↑12%       │ │ ↑10%       │                │
│ └────────────┘ └────────────┘ └────────────┘ └────────────┘                │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐                                │
│ │ Ví KM    ⭐│ │ Tổng nợ    │ │Tổng hoa hồng│                              │
│ │ 50 tr      │ │ 140 tr     │ │ 80 tr      │                                │
│ │ ↑20%       │ │ ↓5%        │ │ ↑8%        │                                │
│ └────────────┘ └────────────┘ └────────────┘                                │
├────────────────────────────────────────────────────────────────────────────┤
│ LOCAL FILTER (2 element — DEC-U12, search dùng shared filter top)           │
│ [Loại thẻ ▾]  [Trạng thái thanh toán ▾]                                    │
├────────────────────────────────────────────────────────────────────────────┤
│ TABLE (QTable, expandable rows)                                             │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ Mã đơn  NgàyTT  Khách hàng    Tên thẻ            SL  Tiền   Nạp ví   │ │
│ │ ─────── ─────── ────────────  ─────────────────  ─── ────── ──────   │ │
│ │▶TT-1232 10:00   Ng.T.T.Nguyên [Linh hoạt] 200k  10  222tr   222tr    │ │
│ │         01/01/25 0123456789                                          │ │
│ │▶TT-1233 10:00   ...           [Cố định] VIP 20tr 1   20tr    20tr    │ │
│ │ ...                                                                   │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ [◄ Trang trước] Trang 1 [Trang sau ►]  [📥 Xuất Excel]                     │
└────────────────────────────────────────────────────────────────────────────┘

Lưu ý wireframe: ASCII rút gọn. Tên cột đầy đủ + format multi-line ở Section 4.4.

4.2 Local Filter (2 element — DEC-U12)

Update 2026-05-04 (DEC-U12): BỎ local search input. Search keyword dùng từ shared filter top (single source). Local filter chỉ giữ 2 dropdown structured. Khi user gõ ở shared search → query Giao dịch tự động apply _or trên order_code · customer.display_name · customer.phone_search · prepaid_card.code.

FilterComponentBehavior
Loại thẻQSelectOptions: Tất cả · Cố định · Linh hoạt
Trạng thái thanh toánQSelectOptions: Tất cả · Đã thanh toán đủ · Còn nợ · Chưa thanh toán
Tìm kiếm❌ RemovedDùng shared filter top — placeholder dynamic "Tìm mã đơn / KH / SĐT / mã thẻ..." khi active sub-tab Giao dịch. Debounce 300ms ở shared input (không debounce thêm tầng local). Min 2 chars. Backend dùng ILIKE %term% qua _or 4 trường — KHÔNG cần pg_trgm GIN index Phase 1

Filter state sync với URL: ?card_type=fixed&status=debt (q ở root URL: ?q=Nguyen — persist cross-tab).

Đã bỏ (so với design cũ): Nhân viên bán filter, Mệnh giá range, Search input local (DEC-U12). Nếu cần lọc NV → sort/click cột "Nhân viên" trong bảng. Mệnh giá → user có thể sort cột "Tiền thu" hoặc "Nạp ví" tăng/giảm.

Layout filter row:

┌─────────────────────────────────────────────────┐
│ [Loại thẻ ▾]  [Trạng thái thanh toán ▾]         │
└─────────────────────────────────────────────────┘

(Search nằm ở shared filter top, KHÔNG lặp lại ở đây)

Empty state khi 0 row — xem Section 12 Empty State Spec.

4.3 Sum Cards (7 cards)

Component: KpiCard × 7 trong grid layout (giống Overview KPI cards Section 3.1).

Layout: Desktop ≥ 1280px → 7 cột (auto-fit, min 160px). Tablet 768–1279px → 4 + 3. Mobile < 768px → 1 cột.

#CardLabel hiển thịFormulaTooltip ℹ
1Tổng đơnTổng đơnCOUNT(DISTINCT order.id) WHERE filter đang chọn"Tổng số đơn mua thẻ trả trước trong kỳ + filter đang chọn."
2Tổng thuTổng thuSUM(invoice.amount) (canceled IS NULL)"Tiền KH thực trả khi mua thẻ. = Tiền thu vào."
3Tổng nạp víTổng nạp víSUM(prepaid_value_into_wallet) (KHÔNG × quantity — đã là line total, ref dev-spec Section 0.2)"Tổng giá trị thực nạp vào ví KH = Ví Diva + Ví KM."
4Ví DivaVí DivaPhần nạp vào ví chính (wallet) — KH dùng được, có thể rút theo policy"Phần ví chính — số tiền thực vào ví KH từ tiền KH trả. Đây là ví KH dùng để thanh toán dịch vụ."
5Ví KMVí KMPhần KM nạp vào ví khuyến mãi (wallet_promotion) — chỉ dùng được, không rút"Phần ví khuyến mãi = Tổng nạp ví − Ví Diva. Là phần spa bù thêm khi mua thẻ. Chỉ dùng để thanh toán, không rút được."
6Tổng nợTổng nợSUM(total_amount − paid_amount) WHERE chưa trả đủ"Tổng công nợ KH chưa trả đủ cho đơn mua thẻ."
7Tổng hoa hồng ⭐ DEC-T08Tổng hoa hồngSUM(invoice_commission.amount) WHERE invoice_status='invoice_completed'"Tổng hoa hồng đã chi cho NV bán thẻ. Chỉ 1 trạng thái 'Đã chi' — refund track riêng."

Mỗi card hiển thị:

┌──────────────────────┐
│ Tổng nạp ví       ℹ │  ← label + tooltip icon
│                      │
│ 250.000.000đ         │  ← value (font 24px bold)
│                      │
│ ↑ 12%                │  ← so sánh kỳ trước (xanh = tăng, đỏ = giảm)
└──────────────────────┘
  • Border-left: 4px solid theo nhóm (xanh = tài chính: 2,3,4,5; tím = vận hành: 1; đỏ = cảnh báo: 6; vàng = HH: 7)
  • Phản ánh đúng filter đang chọn (Loại thẻ + Trạng thái thanh toán)
  • Format VND rút gọn: "390 tr", "1,2 tỷ" — hover hiện exact value
  • So sánh kỳ trước: kỳ trước = N ngày liền kề trước kỳ hiện tại

4.4 Transaction Table

Component: QTable với custom row expand. 10 cột (gộp Loại thẻ + Tên thẻ thành 1 cột).

Column config:

#Column keyLabel headerWidthFormat
1codeMã đơn130pxLink, text-primary (VD: TT-1232MNGA)
2paid_atNgày TT110pxMulti-line: dòng 1 = giờ HH:mm, dòng 2 = ngày DD/MM/YYYY
3customerKhách hàng200pxMulti-line: dòng 1 = tên KH (link CRM), dòng 2 = SDT (text-secondary)
4cardTên thẻ220pxCombo: Badge [Cố định] (xanh) hoặc [Linh hoạt] (tím) inline + tên thẻ. VD: [Linh hoạt] Thẻ nạp linh hoạt 200k
5quantitySL60pxNumber, center align
6collectedTiền thu130pxVND right align, sortable
7wallet_topupNạp ví130pxVND right align, sortable
8statusTrạng thái130pxQBadge pill: 🟢 Đã thanh toán / 🟡 Còn nợ / 🔴 Chưa thanh toán
9staffNhân viên180pxMulti-line: dòng 1 = NV bán (avatar N tròn + tên — Phase 1: render text plain, KHÔNG link per Section 10.1.7 disabled rule, sub-tab Nhân viên defer P3+), dòng 2 = NV thu ngân ⏳ V3 (field source TBD — verify schema)
10branchChi nhánh130pxText (VD: "CN Thủ Dầu Một")

Thay đổi so với design cũ:

  • Gộp 2 cột Loại thẻ + Tên thẻ → 1 cột Tên thẻ với badge inline. Header chỉ "Tên thẻ".
  • 3 cột format multi-line: Ngày TT (giờ + ngày), Khách hàng (tên + SDT), Nhân viên (2 NV).
  • Bỏ cột code chuyển từ format PO-xxxxTT-xxxxxxxx (verify với codebase actual).

Cell rendering example:

  • Khách hàng: <div class="primary">Nguyễn Trần Thảo Nguyên</div><div class="secondary">0123456789</div>
  • Tên thẻ (Linh hoạt): <QBadge color="purple-6">Linh hoạt</QBadge> Thẻ nạp linh hoạt 200k
  • Tên thẻ (Cố định): <QBadge color="primary">Cố định</QBadge> VIP 20tr
  • Nhân viên: <QAvatar>N</QAvatar> <div><div>Nguyễn Trần Phúc</div><div class="secondary">Diệu Hạnh Nguyên</div></div>

4.5 Expanded Row

Click row → expand 2 sections (lazy load, QTabs bên trong).

Tab 1: Chi tiết thanh toán

▼ PO-0412 — Nguyễn Văn A — VIP 20tr
  ┌──────────────────────────────────────────────────┐
  │ [Chi tiết TT] [Hoa hồng]                         │
  ├──────────────────────────────────────────────────┤
  │ Tab: Chi tiết thanh toán                          │
  │ ┌──────────────────────────────────────────────┐ │
  │ │ Phương thức  Số tiền   Ngày TT   Trạng thái │ │
  │ │ Tiền mặt     7,000,000 12/03/26  Đã TT      │ │
  │ │ Chuyển khoản 3,000,000 12/03/26  Đã TT      │ │
  │ └──────────────────────────────────────────────┘ │
  └──────────────────────────────────────────────────┘

Tab 2: Hoa hồng ⭐ DEC-T08 (đã sửa — KHÔNG có cột "Trạng thái")

▼ PO-0412 — Nguyễn Văn A — VIP 20tr
  ┌──────────────────────────────────────────────────────────────┐
  │ [Chi tiết TT] [Hoa hồng]                                     │
  ├──────────────────────────────────────────────────────────────┤
  │ Tab: Hoa hồng                                                 │
  │ ┌──────────────────────────────────────────────────────────┐ │
  │ │ Nhân viên              Số tiền       Loại               │ │
  │ │ Vũ Hà Đàm Vĩnh Hưng   3,000,000 đ   Đã chi              │ │
  │ │ Vũ Hà Đàm Vĩnh Hưng   4,500,000 đ   Đã chi              │ │
  │ │ Vũ Hà Đàm Vĩnh Hưng  −4,500,000 đ   ↩️ Hoàn HH (đơn hủy)│ │
  │ └──────────────────────────────────────────────────────────┘ │
  │  Net commission: 3,000,000 đ                                  │
  └──────────────────────────────────────────────────────────────┘

⚠️ CHỐT theo DEC-T08: Mockup design cũ có 3 trạng thái (Đã chi / Đã hoàn tiền / Đang chờ) KHÔNG đúng codebase. Codebase Diva chỉ có 1 status thực tế (invoice_commission.invoice_status='invoice_completed'). Refund commission là bản ghi transaction_request MỚI với amount âm — hiển thị thành row riêng, KHÔNG phải đổi status row HH gốc.

Cell rendering:

  • Số tiền âm: text-negative (đỏ), prefix
  • Cột "Loại":
    • Đã chi<QChip color="positive" outline>Đã chi</QChip> (xanh)
    • ↩️ Hoàn HH (đơn hủy)<QChip color="warning" outline icon="undo">↩️ Hoàn HH</QChip> + tooltip "Đơn bị hủy ngày DD/MM/YYYY → tự động thu hồi hoa hồng"
  • Footer row "Net commission" = SUM(Số tiền) của tất cả rows (gross − refund) — bold, background nhẹ

Loading: Skeleton rows (3 rows) khi đang fetch data per tab.

⚠️ Tại sao chỉ 2 tab (DEC-T07 — ref PRD Z3): Tab "Lịch sử sử dụng" đã bị xoá khỏi level đơn nạp. Lý do: wallet là pool chung per-customer, không track per-card. Khi KH có nhiều thẻ (thẻ A 20tr + thẻ B 10tr), không thể attribute 5tr đã tiêu về thẻ A hay B. Lịch sử biến động ví hiển thị ở Sub-tab Khách hàng → expanded row KH (per customer — đúng level).

4.6 Pagination

  • Keyset-based: "Trang trước / Trang sau" (không hiện tổng trang)
  • 20 rows per page (configurable: 10/20/50)
  • Sort key: (order.paid_at DESC, order.id DESC)
ElementAction
Mã đơn (text-primary)router.push('/e/prepaid-order/' + id)
Tên Khách hàng (text-primary)Navigate CRM profile
Tên Nhân viên (Phase 1)Disabled cursor + tooltip "Sub-tab Nhân viên ra mắt Phase 3+" — KHÔNG navigate
Badge "Còn nợ" (vàng) / "Chưa thanh toán" (đỏ)Phase 1: navigate đến /e/prepaid-order/:id (xem chi tiết, không write action). P1.x: mở QDialog xác nhận thanh toán (cần API + audit)

Phase 1 navigation policy: Bất cứ link nào trỏ đến sub-tab defer P3+ (Marketing, Nhân viên) phải disable click + hiển thị tooltip "Sắp ra mắt Phase 3+". Tránh dead link gây UX confuse. Implementation: <a class="disabled-link" :title="'Sắp ra mắt Phase 3+'" @click.prevent>.


5. Sub-tab: Khách hàng

5.1 Segment Cards

┌─────────────────┐ ┌─────────────────┐ ┌──────────────────┐ ┌──────────────┐
│🟢 KH Hoạt động ℹ│ │🟡 KH Ngủ đông ℹ│ │🔴 KH Rủi ro mấtℹ│ │🔵 KH Mới   ℹ│
│                 │ │                 │ │                  │ │              │
│ 1,234 KH        │ │   456 KH        │ │   123 KH         │ │    23 KH     │
│ 78% tổng        │ │  29% tổng       │ │    8% tổng       │ │    1% tổng   │
│                 │ │ Dư ví: 2.1 tỷ ℹ│ │ Dư ví: 890tr  ℹ│ │              │
└─────────────────┘ └─────────────────┘ └──────────────────┘ └──────────────┘

Tooltip text (mỗi segment):

SegmentTooltip ℹ
🟢 KH Hoạt động"Khách có dùng ví trong 30 ngày gần nhất. Đây là nhóm tiếp tục mang doanh thu — duy trì chăm sóc, gợi ý dịch vụ mới."
🟡 KH Ngủ đông"Khách không dùng ví 30–60 ngày, vẫn còn dư ví. Cần nhắc nhở qua SMS/ZNS để kích hoạt lại trước khi rơi sang Rủi ro mất."
🔴 KH Rủi ro mất"Khách không dùng ví > 60 ngày, vẫn còn dư ví. Spa đang giữ tiền của KH (nợ phải trả) nhưng KH không quay lại — cần gọi điện trực tiếp."
🔵 KH Mới"Khách lần đầu mua thẻ trả trước trong khoảng thời gian đang xem. Cần chào đón, hướng dẫn sử dụng ví."
Dư ví (dòng phụ)"Tổng dư ví của nhóm = nợ phải trả của spa cho nhóm KH này. VD: 456 KH ngủ đông đang giữ tổng 2.1 tỷ trong ví — nếu họ không quay lại thì spa vẫn đang nợ họ số tiền này."
% tổng"Tỷ lệ trên tổng KH có thẻ trả trước trong kỳ xem = (Số KH nhóm này / Tổng KH có thẻ trả trước) × 100%."

Behavior:

  • Click segment card → filter bảng KH bên dưới theo segment (active state lưu vào local filter store: segment: 'active' | 'sleeping' | 'at_risk' | 'new' | null)
  • Click lại card đang active → bỏ filter (toggle behavior)
  • Active card: border bold + background tinted
  • "KH Ngủ đông" và "KH Rủi ro mất" hiển thị thêm dòng "Dư ví" (tổng nợ phải trả đang "ngủ")
  • Hover ℹ → QTooltip max-width 320px, position top, delay 200ms

Empty State v2 integration (DEC-U12):

Khi segment active + bảng có 0 row khớp:

  • Render <EmptyState variant="filter-narrow"> (Section 9.2.3)
  • Active chip xuất hiện: [Phân khúc: KH Ngủ đông ×] — click × → bỏ segment filter (set null), table refresh
  • [Đặt lại tất cả] → clear cả segment filter + shared q + bất kỳ local filter nào khác
  • useFilterChips composable nhận segment vào danh sách chip:
    typescript
    if (localFilter.segment) {
      chips.push({
        key: 'segment',
        label: `Phân khúc: ${segmentLabelMap[localFilter.segment]}`,
        onRemove: () => setLocalFilter({ segment: null }),
      })
    }

5.2 Customer Table

Component: QTable expandable. KHÔNG dùng selection prop (bỏ checkbox multi-select — read-only). Click row → expand chi tiết.

Header bảng:

┌─ Dữ liệu khách hàng ───────────────────────────────────────────────┐
│ Tổng cộng {N} khách hàng                       [📥 Xuất Excel]     │ ← title trái + export button phải
└────────────────────────────────────────────────────────────────────┘

Column config:

Column keyLabel (header)WidthFormatGhi chú
display_nameKhách hàng160pxLinkCRM profile
phone_numberSĐT110pxMasked0912***456
segmentPhân khúc110pxQBadge color4 colors (Hoạt động / Ngủ đông / Rủi ro mất / Mới)
total_paidTổng tiền đã nạp130pxVNDLũy kế tất cả lần nạp
wallet_balance_diva ⏳ V5aDư ví DIVA110pxVNDSố dư ví chính hiện tại; source field chờ BE confirm; highlight risk theo tổng dư ví
wallet_balance_km ⏳ V5bDư ví KM110pxVNDSố dư ví khuyến mãi hiện tại; không rút; source field chờ BE confirm; highlight risk theo tổng dư ví
total_usedĐã dùng ví110pxVNDLũy kế đã dùng từ ví
consumption_rate% Đã dùng ví110pxQLinearProgressXanh > 70% / Vàng 30–70% / Đỏ < 30%
order_countSố lần mua thẻ110pxNumberSố đơn prepaid lũy kế
last_used_atLần dùng ví cuối130pxRelative time"3 ngày trước", đỏ nếu > 60 ngày
buy_branchCN mua110pxTextCN nơi mua thẻ
use_branchCN sử dụng110pxTextCN nơi dùng ví trả dịch vụ
branch_countSố CN đã dùng100pxNumberBadge nếu > 1 (KH cross-branch)

5.3 Expanded Row — 3 Tabs

▼ Nguyễn Văn A — 🟢 KH Hoạt động — Dư ví: 7tr (DIVA 5tr · KM 2tr)
  [Thẻ đã mua] [Hành vi sử dụng] [Gợi ý hành động]

  Tab 1: Thẻ đã mua
  ┌────────────────────────────────────────────────┐
  │ Loại thẻ  Tên thẻ   CN mua  Ngày     Giá trị  │
  │ Cố định   VIP 20tr  Q.1     01/02    22tr     │
  │ Linh hoạt —         Q.3     15/01    5tr      │
  └────────────────────────────────────────────────┘

  Tab 2: Hành vi sử dụng
  ┌───────────────────────────────────────────────────────────────┐
  │ Tháng     Lần dùng  Tổng chi  CN sử dụng    Dịch vụ phổ biến│
  │ 03/2026   4         2.8tr     Q.1, Bình Thạnh  Massage (3x)  │
  │ 02/2026   3         1.9tr     Q.1             Facial (2x)     │
  │ 01/2026   5         3.5tr     Q.1, Q.3        Massage (3x)    │
  ├───────────────────────────────────────────────────────────────┤
  │ 📊 Tần suất: 4 lần/tháng (TB)  |  Xu hướng: ↑ tăng 15%      │
  │ 🕐 Giờ hay đến: 14:00–17:00    |  CN ưa thích: Q.1 (65%)    │
  └───────────────────────────────────────────────────────────────┘

  Tab 3: Gợi ý hành động
  ┌───────────────────────────────────────────────────┐
  │ 💡 Dư ví cao (7tr) — Gợi ý booking dịch vụ mới   │
  │ 📈 Tần suất ổn định (3 lần/tháng)                 │
  │ ⬆️ Upsell: Gói VIP 50tr (tiết kiệm 10%)          │
  └───────────────────────────────────────────────────┘

Tab 2 Behavior:

  • Bảng group by tháng, hiện lần dùng ví, tổng chi, CN sử dụng, dịch vụ phổ biến nhất
  • Footer bar: tần suất TB, xu hướng so kỳ trước, giờ hay đến, CN ưa thích
  • Data source: wallet.transaction_request JOIN ecommerce.order (lazy load khi click expand)
  • Load target: < 200ms

5.4 Export button (top-right table header)

Update 2026-05-04 (review L5 user simplification): BỎ HẲN checkbox multi-select + bulk SMS/ZNS/Gán NV (kể cả Phase 1.x). Nếu cần bulk notification → dùng module Marketing/CRM riêng. Sub-tab Khách hàng = pure read-only analytics.

Layout — nút Excel ở góc trên bên phải bảng:

┌─ Dữ liệu khách hàng ──────────────────────────────────────────────┐
│ Tổng cộng 1.000 khách hàng                       [📥 Xuất Excel]  │ ← header bảng + nút export
├───────────────────────────────────────────────────────────────────┤
│ TÊN     PHÂN KHÚC  TỔNG NẠP  DƯ VÍ      ĐÃ DÙNG  TỶ LỆ ...      │
│ ─────── ────────── ────────  ─────────  ───────  ────── ...      │
│ KH #1   Hoạt động  22,8tr    11,4tr     11,4tr   80%    ...      │
│ ...                                                               │
└───────────────────────────────────────────────────────────────────┘

Behavior:

  • [📥 Xuất Excel] button: trigger server-side export toàn bộ danh sách KH đang filter (không cần select rows). Permission: report.prepaid_analytics.export (hợp nhất — xem Section 12).
  • KHÔNG có checkbox column trong bảng — bỏ hoàn toàn selection prop của QTable
  • KHÔNG có sticky toolbar bulk actions
  • Click row vẫn expand chi tiết KH (3 tabs Section 5.3) — read interaction giữ nguyên
  • SDT mask theo permission view_full_phone (export cũng tuân theo rule này — BE enforce)

Lý do bỏ bulk actions:

  • Pure read-only analytics = an toàn deploy, không có write surface
  • Bulk SMS/ZNS có module riêng (Marketing/CRM) — KHÔNG nên duplicate trong Analytics
  • Gán NV chăm sóc thuộc nghiệp vụ CRM — KHÔNG phải báo cáo
  • Đơn giản UX cho Marketing dùng tab này: lọc → xem segment → cần gửi SMS thì sang module Marketing với danh sách đã lọc

Migration path (nếu Phase 3+ cần bulk action trong tab này): thêm checkbox column + sticky toolbar + 3 actions write (SMS, ZNS, Gán NV) — cần scope lại như feature mới (API + audit + permission).

5.5 Chỉ số hành vi khách hàng

Đổi tên section từ "CLV Bar / Giá trị vòng đời KH" → "Chỉ số hành vi khách hàng" (khớp với metric AOV-based, tránh hiểu nhầm CLV literal). 3 metrics behavior cover đủ 3 góc: độ lớn đơn · khả năng quay lại · tốc độ quay lại.

┌─ Chỉ số hành vi khách hàng ──────────────────────────────────────────────────┐
│                                                                              │
│ ┌──────────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐│
│ │ Giá trị đơn TB        ℹ │ │ Tỷ lệ tái nạp    ℹ │ │ Chu kỳ trung bình ℹ││
│ │                          │ │                     │ │                     ││
│ │ 2.400.000đ               │ │ 68%                 │ │ 24 ngày             ││
│ │                          │ │                     │ │                     ││
│ │ ↑12% so với tháng trước  │ │ ↑5% so tháng trước  │ │ ↓2 ngày so tháng tr.││
│ └──────────────────────────┘ └─────────────────────┘ └─────────────────────┘│
└──────────────────────────────────────────────────────────────────────────────┘

3 cards layout grid (desktop 3 cột, mobile stack), cố định ở trên bảng KH (dưới segment cards 5.1).

Tooltip text:

TrườngTooltip ℹ
Giá trị đơn hàng trung bình"Average Order Value (AOV) — Doanh thu trung bình mỗi đơn mua thẻ. Công thức: Tổng tiền thu vào / Số đơn. VD: 1.000 đơn × Tổng thu 2,4 tỷ → AOV = 2,4 tr/đơn. Tăng AOV = KH chi mạnh hơn, mua thẻ giá trị lớn (campaign upsell hiệu quả)."
Tỷ lệ tái nạp"Tỷ lệ KH mua thẻ trả trước ≥ 2 lần / Tổng KH có thẻ trong kỳ. VD: 1.000 KH có thẻ, 680 mua ≥ 2 lần → 68%. Cao = KH trung thành; thấp = cần campaign giữ chân."
Chu kỳ trung bình"Khoảng cách TB giữa 2 lần KH mua thẻ liên tiếp. Tính trung bình theo khách: mỗi KH có ≥2 lần nạp đóng góp 1 giá trị chu kỳ TB của riêng họ, hệ thống lấy AVG các giá trị đó (KHÔNG cộng dồn tất cả khoảng để tránh KH nạp nhiều lần đè kết quả). Loại các khoảng > 180 ngày (đó là KH quay lại sau ngủ đông, không phải cycle định kỳ). VD: KH mua lần 1 ngày 01/03, lần 2 ngày 25/03 → chu kỳ = 24 ngày. Dùng để chọn timing gửi nhắc nhở (gửi ưu đãi tái nạp trước khi hết chu kỳ ~5 ngày)."

Trend label dynamic theo filter "Khoảng thời gian" (ref PRD A10 FORMULA-019 cho cách tính kỳ trước):

FilterTrend label
Hôm nay / Hôm qua"so với hôm qua" / "so với 2 hôm trước"
7 ngày qua / 30 ngày qua"so với 7 ngày trước" / "so với 30 ngày trước"
Tháng này"so với tháng trước"
Tháng trước"so với tháng trước nữa"
Quý này"so với quý trước"
Tùy chọn (N ngày)"so với kỳ trước (N ngày)"

Ghi chú vận hành: Section này chỉ cover "tóm tắt behavior". Chi tiết per-customer ở Customer Table (Section 5.2). Trạng thái KH (Active/Dormant/At-risk/New) ở Segment Cards (Section 5.1). Churn signal proxy qua phân khúc "Rủi ro mất". KHÔNG hiển thị "Tỷ lệ rời bỏ" để tránh duplicate với segment.


6. Sub-tab: Tài chính

6.1 KPI Cards

Tương tự Section 3.1 nhưng 8 cards tài chính chuyên sâu (xem design spec Section 6.1).

6.2 4 Component-level Tabs

┌─────────────────────────────────────────────────────────────────────┐
│ [Tổng hợp doanh thu] [Công nợ] [Hoa hồng] [Phương thức thanh toán] │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│ (Nội dung tab con tương ứng)                                        │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

Component: QTabs bên trong sub-tab (nested tabs).

DEC-U12 conformance: Sub-tab Tài chính KHÔNG có local search input ở bất kỳ tab con nào. Search dùng từ shared filter top — scope q apply theo tab con đang active:

Tab conScope qEmpty State v2
Tổng hợp doanh thuKHÔNG apply (aggregation theo ngày — search keyword không có ý nghĩa). Khi q có giá trị → hiển thị banner info "Tab này không hỗ trợ tìm kiếm — keyword sẽ apply ở tab Công nợ và Hoa hồng"Section 9.2 (no-data / error only — không có filter-narrow)
Công nợ_or trên order.code, customer.display_name, customer.phone_searchSection 9.2.3 với chips
Hoa hồng_or trên staff.display_name, staff.code, order.code, customer.display_nameSection 9.2.3 với chips
PTTTKHÔNG apply (aggregation theo payment_method) — banner info giống Tổng hợp doanh thuSection 9.2 (no-data / error only)

Lý do scope khác nhau: 2 tab aggregation (Tổng hợp DT + PTTT) trả về số liệu summary theo dimension (date / payment method) — không có concept "filter từng row" nên q không apply. 2 tab transactional (Công nợ + Hoa hồng) trả về danh sách row → search có ý nghĩa.

6.3 Tab con: Tổng hợp Doanh thu

┌────────────────────────────────────────────────────────────────────────────┐
│ (Granularity = Ngày — follow shared filter "Khoảng thời gian", không có toggle)│
├────────────────────────────────────────────────────────────────────────────┤
│ [Line chart: Doanh thu + đường nét đứt kỳ trước]                           │
├────────────────────────────────────────────────────────────────────────────┤
│ Thời gian  Tiền thu vào  Nạp ví  KM đã nạp  Hoa hồng  Lợi nhuận gộp  Đơn │
│ 13/03      65tr           72tr    7tr        3tr       55tr            12 │
│ 12/03      58tr           64tr    6tr        2.5tr     49.5tr          10 │
│ ...                                                                        │
│                                                       TỔNG: 850tr          │
└────────────────────────────────────────────────────────────────────────────┘

6.4 Tab con: Công nợ

Default state:

┌──────────────────────────────────────────────────────────────────────────┐
│ SUMMARY: Tổng công nợ: 45tr | 🔴 > 30 ngày: 15tr | 🟡 15–30 ngày: 20tr  │
│        | ✅ < 15 ngày: 10tr                                               │
├──────────────────────────────────────────────────────────────────────────┤
│ Mã đơn  Khách hàng  Ngày   Tổng đơn  Đã trả  Còn nợ  Quá hạn  Hành động │
│ PO-380  Nguyễn A    01/03  10tr      7tr     3tr     12d ✅   [📞]      │
│ PO-355  Trần B      15/02  15tr      5tr     10tr    26d 🟡   [📞]      │
│ PO-310  Lê C        01/02  20tr      0       20tr    40d 🔴   [📞]      │
└──────────────────────────────────────────────────────────────────────────┘

Landing-from-alert state (v3.14 — DEC-U13):

Khi user click alert 🔴 Đơn quá hạn > 30 ngày ở Sub-tab Tổng quan → landing trên Tab Công nợ với state đặc biệt:

┌──────────────────────────────────────────────────────────────────────────┐
│ ⚠️ Đang xem từ alert: "CN Đà Lạt — 120 đơn quá hạn > 30 ngày"          │
│ Filter áp dụng: [📍 Đà Lạt ×]  [⏰ Quá hạn > 30d ×]  [📅 365d gần ×]    │
│ [Bỏ alert filter ×]    [Đặt lại tất cả ↻]                                │
├──────────────────────────────────────────────────────────────────────────┤
│ SUMMARY (filtered theo alert):                                            │
│   🔴 Quá hạn > 30 ngày: 45 tr (120 đơn)  ← highlight nhóm này            │
│   ~~🟡 15–30 ngày: hidden~~  ~~✅ < 15 ngày: hidden~~  (ẩn vì filter narrow)│
├──────────────────────────────────────────────────────────────────────────┤
│ Sắp xếp: Quá hạn cao nhất ▼ (từ alert)        Hiển thị: 20/trang        │
├──────────────────────────────────────────────────────────────────────────┤
│ Mã đơn  Khách hàng    SĐT          Còn nợ    Quá hạn    Hành động      │
│ PO-098  Lê Thị X      0912***456   500K      180d 🔴   [📞 Gọi]        │
│ PO-110  Trần Văn Y    0987***321   1.2tr     145d 🔴   [📞 Gọi]        │
│ PO-145  Phạm Thị Z    0901***777   350K      120d 🔴   [📞 Gọi]        │
│ ...                                                                      │
│ (117 đơn còn lại)                                                        │
└──────────────────────────────────────────────────────────────────────────┘
│ [◄ Trước] [1] [2] [3] ... [6] [Sau ►]                                   │
└──────────────────────────────────────────────────────────────────────────┘

Landing-from-alert behavior (DEC-U13):

ElementBehavior
Banner alert topHiển thị mandatory khi URL có from_alert=<alert_id>. Style: background $warning-1 (vàng nhạt), icon ⚠️, border-bottom $warning. Sticky ngay dưới shared filter bar
[Bỏ alert filter ×]Clear chỉ filter chips đến từ alert (overdue, vip, segment). GIỮ shared filter (q, branch, range) mà người dùng có thể đã set trước đó. Sau click → banner ẩn
[Đặt lại tất cả ↻]Reset full về shared filter mặc định + clear local filter + clear segment + remove all chips. Banner ẩn
Summary highlightKhi vào từ alert critical (quá hạn > 30d), nhóm tương ứng bold + background tinted. 2 nhóm còn lại (15-30d, <15d) ẩn hoặc grey out 40% opacity — focus user vào việc cần làm
Sort indicatorCột "Quá hạn" header: Quá hạn ▼ (DESC active). Tooltip header: "Sắp xếp tự động theo alert — quá hạn lâu nhất lên đầu"
PaginationDefault 20/trang theo Section 4.6. 120 đơn → 6 trang. Keyset pagination

Hành động buttons (Phase 1 — chỉ 1 nút):

  • [📞 Gọi] (icon-only): tel: link mở app gọi điện hoặc copy SĐT vào clipboard. Là action duy nhất trong row công nợ.
  • Để xem chi tiết đơn: click cột Mã đơn (PO-xxx) → navigate /e/prepaid-order/:id (đã có ở Section 4.7 Quick Links). KHÔNG cần nút [Xem] riêng — tránh duplicate action.
  • [✓ Xác nhận thanh toán]: 🚫 OUT OF SCOPE Phase 1 (write action — cần API + audit). Kế toán cần xác nhận TT → click Mã đơn sang trang chi tiết đơn thực hiện ở module Ecommerce.
  • [💬 Ghi chú công nợ]: 🚫 OUT OF SCOPE Phase 1 (write action — defer).

Empty State v2 (DEC-U12):

  • Tab Công nợ có table → áp dụng Section 9.2 detect priority (Loading → Error → No-data → Filter-narrow)
  • Active filter chips (nếu có): [Từ khóa: "PO-380" ×] (từ shared q) + future local filter nếu thêm (VD: [Quá hạn > 30d ×])
  • Primary CTA: [Đặt lại tất cả] clear shared q

6.5 Tab con: Hoa hồng

┌──────────────────────────────────────────────────────────────────────────────────┐
│ Nhân viên     Chi nhánh    Số đơn  Doanh thu    Hoa hồng    % HH/DT             │
├──────────────────────────────────────────────────────────────────────────────────┤
│ ▶ Trần Thị B   Q.1 Lê Lai   15      45,000,000   2,250,000   5.0%               │
│ ▶ Lê Văn C     Bình Thạnh   12      38,000,000   1,900,000   5.0%               │
│ ▶ Nguyễn D     Q.3           8      24,000,000   1,200,000   5.0%               │
├──────────────────────────────────────────────────────────────────────────────────┤
│ Tổng                        35      107,000,000  5,350,000   5.0%                │
└──────────────────────────────────────────────────────────────────────────────────┘

▼ Trần Thị B (expand) — DEC-T08: bỏ cột "Trạng thái" (1 status), refund row riêng amount âm
  ┌──────────────────────────────────────────────────────────────────────┐
  │ Mã đơn   Khách hàng    Ngày TT    Doanh thu   Hoa hồng   Loại       │
  │ PO-0412  Nguyễn Văn A  12/03/26   10,000,000     500K    Đã chi     │
  │ PO-0398  Trần Thị E    10/03/26   15,000,000     750K    Đã chi     │
  │ PO-0385  Lê Văn F      08/03/26   20,000,000    1,000K   Đã chi     │
  │ PO-0385  Lê Văn F      09/03/26   (hủy)         −1,000K  ↩️ Hoàn HH │
  └──────────────────────────────────────────────────────────────────────┘

Behavior:

  • Bảng tổng hợp Hoa hồng theo Nhân viên, expandable → chi tiết từng đơn
  • Chỉ tính Hoa hồng đã chi — DEC-T08 (invoice_commission.invoice_status = 'invoice_completed')
  • Dòng tổng cộng cố định ở cuối bảng
  • Expand row: lazy load danh sách đơn HH gốc (Đã chi ✅) + dòng riêng cho refund nếu có (↩️ Hoàn HH — amount âm). KHÔNG có "Chờ ⏳" vì codebase không có pending state

Empty State v2 (DEC-U12):

  • Bảng tổng hợp NV có table → Section 9.2 detect priority
  • Scope q: _or trên staff.display_name, staff.code, order.code, customer.display_name
  • Active chip nếu shared q set: [Từ khóa: "Trần Thị B" ×]
  • Primary CTA [Đặt lại tất cả] clear shared q

6.6 Tab con: Phương thức thanh toán

┌─────────────────────────────┐ ┌────────────────────────────────────────┐
│ [Donut chart]                │ │ Phương thức    Số đơn  Số tiền    %   │
│                              │ │ Tiền mặt       89      450tr      53% │
│   ● Tiền mặt: 53%           │ │ Chuyển khoản   45      280tr      33% │
│   ● Chuyển khoản: 33%       │ │ Quẹt thẻ       15       90tr      11% │
│   ● Quẹt thẻ: 11%           │ │ Ví             7        30tr       4% │
│   ● Ví: 4%                  │ │                                        │
└─────────────────────────────┘ └────────────────────────────────────────┘

Layout: chart (left) + table (right) trên desktop, stack trên mobile.

6.7 Export Bar (cố định bottom)

┌────────────────────────────────────────────────────────────────────────────┐
│ 📥 [Xuất Sổ doanh thu] [Xuất Công nợ] [Xuất Hoa hồng]                      │
│    [Xuất Đối soát chi nhánh] [Xuất Báo cáo dư ví]                          │
└────────────────────────────────────────────────────────────────────────────┘

Mỗi nút → trigger server-side export → progress dialog:

┌─ Đang xuất ──────────────────┐
│ Sổ doanh thu thẻ trả trước   │
│                               │
│ ████████████░░░ 65%           │
│ 32,500 / 50,000 dòng         │
│                               │
│ [Hủy]                        │
└───────────────────────────────┘

Hoàn thành → toast notification + download link.


7. Sub-tab: Marketing

🚫 Phase 3+ — Defer indefinitely (TBD). Section này KHÔNG triển khai trong Phase 1 (ref: prd.md DEC-B05). Giữ trong spec để team có context tổng thể; sẽ ưu tiên lại khi team Marketing yêu cầu báo cáo CD chuyên sâu vượt quá Sub-tab Khách hàng (Phase 1).

7.1 KPI Cards

8 cards (2 hàng): Đơn từ chiến dịch, Doanh thu từ chiến dịch, KH mới, Tỷ lệ chuyển đổi, Đơn affiliate, Doanh thu affiliate, Chi phí marketing, ROI.

ROI card highlight (canonical PRD A10 FORMULA-020): 🟢 xanh > 200% · 🟡 vàng 100%–200% · 🔴 đỏ < 100%. Nếu marketing_cost = 0 → hiển thị "—" + tag "Chưa có budget" (KHÔNG tô màu).

7.2 4 Component-level Tabs

[Chiến dịch] [Affiliate] [Nguồn khách hàng] [So sánh kênh]

7.3 Tab con: Chiến dịch

Bảng expandable. Status column: QBadge $positive (Hiệu quả) / $warning (Trung bình) / $negative (Kém).

Expand → 4 sections: hiệu quả theo Khu vực, Doanh thu theo ngày, Loại thẻ, Danh sách khách hàng.

7.4 Tab con: Nguồn khách hàng

┌──────────────────────────┐ ┌──────────────────────────┐
│ [Donut chart]             │ │ [Stacked area chart]      │
│                           │ │                           │
│ ● Walk-in: 40%           │ │ Trend theo tháng          │
│ ● Chiến dịch: 25%        │ │ ▓ Walk-in                 │
│ ● Tái nạp: 20%           │ │ ░ Chiến dịch              │
│ ● Affiliate: 15%         │ │ ▒ Tái nạp                 │
│                           │ │ ▓ Affiliate               │
└──────────────────────────┘ └──────────────────────────┘

7.5 Hành động Marketing

┌──────────────────────────────────────────────────────────────────────┐
│ [+ Tạo chiến dịch mới] [📋 Nhân bản chiến dịch thành công]           │
│ [👥 Xem KH từ chiến dịch] [📥 Xuất báo cáo chiến dịch]              │
└──────────────────────────────────────────────────────────────────────┘

QBtn group, positioned dưới KPI cards.


8. Sub-tab: Nhân viên

🚫 Phase 3+ — Defer indefinitely (TBD). Section này KHÔNG triển khai trong Phase 1 (ref: prd.md DEC-B05). Phase 1 đã có Hoa hồng theo NV ở Sub-tab Tài chính (Tab con 3) cho Kế toán đối soát lương — đủ nghiệp vụ cấp thiết. Section này (ranking, chi tiết NV, so sánh CN) ưu tiên lại khi Quản lý vùng có nhu cầu coaching/đào tạo data-driven.

8.1 KPI Cards

4 cards: Nhân viên có đơn (% tổng), Số đơn TB / NV, Doanh thu TB / NV, Tổng Hoa hồng đã chi.

8.2 3 Component-level Tabs

[Xếp hạng] [Chi tiết nhân viên] [So sánh chi nhánh]

8.3 Tab con: Xếp hạng

┌────────────────────────────────────────────────────────────────────────────┐
│ Filter: [Xếp theo ▾ Doanh thu] [Khu vực ▾] [Top ▾ 10]                      │
├────────────────────────────────────────────────────────────────────────────┤
│ #   Nhân viên  Chi nhánh  Khu vực  Số đơn  Doanh thu  Hoa hồng  DT/đơn KH mới│
│ 🥇 Lan         Q.1 LL     HCM      25      180tr      9tr       7.2tr  8 │
│ 🥈 Hùng        Q.3 NĐC    HCM      22      165tr      8tr       7.5tr  5 │
│ 🥉 Mai         Bình Thạnh HCM      18      120tr      6tr       6.7tr  7 │
│ 4  Tuấn        Cầu Giấy   HN       16      110tr      5.5tr     6.9tr  4 │
│ ...                                                                       │
│                                                                           │
│ ℹ️ 77% NV chưa bán thẻ trả trước → Cơ hội đào tạo                         │
└────────────────────────────────────────────────────────────────────────────┘

Row highlight:

  • 🟢 Xuất sắc (> 150% TB): background $positive tint
  • 🔴 Cần cải thiện (< 50% TB): background $negative tint
  • Top 3: medal emoji thay #

8.4 Tab con: Chi tiết nhân viên

Click NV từ Xếp hạng → chuyển sang tab Chi tiết.

┌──────────────────────────────────────────────────────────────┐
│ 👤 Lan — Q.1 Lê Lai — HCM                                    │
├──────────────────┬───────────────────────────────────────────┤
│ KPI cá nhân      │ [Line chart: Doanh thu 6 tháng]           │
│ Số đơn: 25      │ — NV (solid xanh)                          │
│ Doanh thu: 180tr│ --- TB hệ thống (dashed xám)              │
│ Hoa hồng: 9tr   │                                            │
│ KH mới: 8       │                                            │
├──────────────────┴───────────────────────────────────────────┤
│ Phân tích:                                                   │
│ • Thẻ hay bán: VIP 20tr (60%), Gold 10tr (30%)              │
│ • Giờ bán tốt: 10–12h sáng                                  │
│ • KH tái nạp: 8/25 (32%)                                    │
├──────────────────────────────────────────────────────────────┤
│ Đơn đã bán (paginated):                                      │
│ Mã đơn  Ngày   Khách hàng     Tên thẻ    Doanh thu  Hoa hồng│
│ PO-412  12/03  Nguyễn Văn A   VIP 20tr   10tr       500K    │
│ ...                                                          │
└──────────────────────────────────────────────────────────────┘

8.5 Tab con: So sánh chi nhánh

Grouped table: Khu vực (collapsed) → Chi nhánh (expand).

Cột: Chi nhánh / Khu vực · Tổng NV · NV có đơn · % Tham gia · Số đơn TB / NV · Doanh thu TB / NV · Đánh giá.

▶ HCM (25 CN)         Tổng NV: 120  Có đơn: 28  23%  2.3  6.4tr
  ▼ Q.1 Lê Lai        Tổng NV: 8    Có đơn: 5   63%  5.0  36tr   🟢 CN mẫu
    Q.3 NĐC           Tổng NV: 7    Có đơn: 4   57%  5.5  41tr
    Bình Thạnh        Tổng NV: 6    Có đơn: 1   17%  1.0  5tr    🔴 Cần đào tạo
▶ Hà Nội (15 CN)      Tổng NV: 75   Có đơn: 15  20%  1.8  5.2tr

9. UI States

9.1 Loading States

ElementLoading UI
KPI CardsQSkeleton rect (4 per row), pulse animation
ChartsQSkeleton rect 300×200px
TablesQTable shimmer rows (5 fake rows)
Expanded row3 skeleton rows bên trong
Global search dropdownQSpinner-dots
Export dialogQLinearProgress indeterminate → determinate khi có %

9.2 Empty States — v2 (DEC-U12)

Update 2026-05-04 (DEC-U12): Empty state v2 phân biệt 3 case theo priority để cung cấp diagnostic clarity. Áp dụng cho mọi table trong tab (Giao dịch, Khách hàng, Tài chính tab Tổng hợp/Công nợ/Hoa hồng/PTTT).

9.2.1 Detect priority (top → bottom, dừng ở match đầu tiên)

1. Loading            → QSkeleton (KHÔNG hiện empty state)
2. Error              → 9.3 Error State
3. CN trống thực sự   → 9.2.2 No Data
   (count_base = 0 với chỉ shared filter, KHÔNG có local + q)
4. Filter quá hẹp     → 9.2.3 Filter Narrow
   (count_base > 0 nhưng count_filtered = 0)
5. Default            → fallback 9.2.2 No Data

Implementation note: Mỗi sub-tab có table cần thực hiện 2 query song song:

  • count_filtered: count với full filter (shared + local + q) → dùng cho table data
  • count_base: count với chỉ shared filter (Chi nhánh + Khoảng TG) → dùng để diagnose case 3 vs 4

Nếu count_filtered = 0:

  • count_base = 0 → case 9.2.2 (No data thực sự)
  • count_base > 0 → case 9.2.3 (Filter quá hẹp, hiện counter)

9.2.2 No Data — CN trống thực sự

┌──────────────────────────────────────────────────────────┐
│           📭                                              │
│   Chi nhánh này chưa có giao dịch nạp thẻ                │
│                                                          │
│   Trong khoảng 01/05 – 04/05/2026.                       │
│   Thử mở rộng khoảng thời gian.                          │
│                                                          │
│   [Mở rộng → 90 ngày]   ← link, secondary                │
└──────────────────────────────────────────────────────────┘
  • Icon: 📭 (mailbox empty) — phân biệt rõ với case search
  • CTA secondary (link style, không filled button) — không tạo cảm giác urgent
  • Click [Mở rộng → 90 ngày] → set shared filter Khoảng thời gian = preset "90 ngày qua"
  • Headline + subtitle dynamic theo sub-tab (Khách hàng: "Chi nhánh này chưa có khách dùng thẻ trả trước...", v.v.)

9.2.3 Filter Narrow — Có data nhưng filter loại bỏ hết

┌──────────────────────────────────────────────────────────┐
│           🔍                                              │
│   Không có giao dịch nào khớp                            │
│                                                          │
│   Tổng 1.234 giao dịch trong khoảng — thử bỏ 1 filter.   │
│                                                          │
│   Đang lọc:                                              │
│   ┌─────────────────────┐ ┌─────────────────┐            │
│   │ Từ khóa: "TT-99" × │ │ Linh hoạt   ×   │            │
│   └─────────────────────┘ └─────────────────┘            │
│   ┌─────────────────────┐                                │
│   │ Đã thanh toán   ×  │                                 │
│   └─────────────────────┘                                │
│                                                          │
│   [Đặt lại tất cả]    Mở rộng khoảng thời gian          │
│   ↑ PRIMARY filled    ↑ SECONDARY link                   │
└──────────────────────────────────────────────────────────┘
ElementSpec
Icon🔍 (magnifying glass)
Headline"Không có giao dịch nào khớp" (đổi "giao dịch" theo entity sub-tab: khách hàng / mục PTTT...)
Counter sub-message"Tổng {count_base} giao dịch trong khoảng — thử bỏ 1 filter" — số count_base từ baseline query
Active filter chipsRender mỗi active filter (q, local dropdown values) thành 1 chip với × button. Mỗi chip clickable → remove filter đó, table refresh smooth
Chip structure[Label: value ×] ví dụ [Từ khóa: "TT-99" ×] hoặc [Linh hoạt ×] (nếu label đã rõ context, bỏ prefix)
Primary CTA[Đặt lại tất cả] filled button — clear cả q (shared) + tất cả local filter
Secondary CTAtext-link "Mở rộng khoảng thời gian" — set khoảng TG sang preset rộng hơn
Max chips visible5 chips hiển thị, còn lại "+ N filter khác" (collapsed expandable)
Mobile (< 480px)Stack: chips wrap 2 dòng (overflow-x scroll nếu > 5), button stack vertical full-width

Visual feedback sau action:

ActionFeedback
Click chip ×Chip animate fade-out 200ms, table refresh smooth
Click [Đặt lại tất cả]Toast 2s "Đã bỏ tất cả filter" + focus về shared search input
Click "Mở rộng khoảng thời gian"Date range badge highlight 1s khi value thay đổi

Accessibility (WCAG 2.1 AA):

  • Container: <div role="status" aria-live="polite"> để screen reader announce khi state thay đổi
  • Chip remove ×: aria-label="Bỏ filter {label}", touch target ≥ 44×44px
  • Tab navigation: Tab qua chips theo thứ tự, Enter/Space để remove
  • Color contrast icon + text ≥ 4.5:1
  • Focus management: sau click [Đặt lại tất cả] → focus về shared search input

Copy text canonical (lock):

KeyVI text
empty.no_data.title"Chi nhánh này chưa có giao dịch nạp thẻ" (đổi entity per sub-tab)
empty.no_data.desc"Trong khoảng {from} – {to}. Thử mở rộng khoảng thời gian."
empty.no_data.cta"Mở rộng → 90 ngày"
empty.filter_narrow.title"Không có giao dịch nào khớp" (đổi entity per sub-tab)
empty.filter_narrow.desc"Tổng {count_base} giao dịch trong khoảng — thử bỏ 1 filter."
empty.filter_narrow.primary"Đặt lại tất cả"
empty.filter_narrow.secondary"Mở rộng khoảng thời gian"
empty.error.title"Không tải được dữ liệu"
empty.error.cta"Thử lại"
empty.chip_remove_aria"Bỏ filter {label}"

Pattern reuse check (BẮT BUỘC trước implement — CLAUDE.md rule):

Trước khi tạo EmptyState.vue mới → kiểm tra diva-admin/src/components/:

  • ✅ Reuse: nếu có <EmptyState> / <NoDataPlaceholder> component
  • 🔧 Extend: thêm prop variant: 'no-data' | 'filter-narrow' | 'error' + chips slot
  • 🆕 Build mới: nếu hoàn toàn không có pattern tương đương

9.3 Error States

┌──────────────────────────────────────────────────────────┐
│           ⚠️                                              │
│   Không tải được dữ liệu                                 │
│                                                          │
│   [Thử lại]                                              │
└──────────────────────────────────────────────────────────┘

Toast notification kèm: QNotify position bottom-right, timeout 5s.

9.4 Stale Data Badge

ℹ️ Dữ liệu cập nhật lúc: 14:30  [🔄 Tải lại]

Hiển thị khi MV data > 15 phút. Warning badge nếu > 30 phút: “Dữ liệu có thể chưa cập nhật”.

9.5 No Permission

Sub-tab không có quyền → ẩn hoàn toàn (không hiện tab disabled). Nếu user navigate trực tiếp qua URL → redirect về sub-tab đầu tiên có quyền.


10. Interaction Patterns

10.1 Click Action Matrix (canonical — single source for FE/QA)

Mục đích: Lock từng click action với 5 cột đầy đủ: source control · target route/tab · filter params payload · disabled rule · empty/error state sau navigate. FE/QA bám bảng này, KHÔNG đoán. Mỗi row = 1 click target — nếu spec không có row tương ứng → row đó CHƯA được phép implement (block FE/QA).

10.1.1 Sub-tab Tổng quan — KPI Cards (8/8)

#SourceTarget routeTarget tabFilter params (payload)Date range behaviorDisabled ruleEmpty/error sau navigate
1KPI "Tiền thu vào"/r/reports/prepaid-card-analytics?tab=financeTài chính → tab con Tổng hợp doanh thuinherit shared branch[], from, to (không add filter mới)Giữ shared rangeKhông có quyền view tab Tài chính → cursor default, không clickEmpty State v2 (no-data / filter-narrow / error) per Section 9.2
2KPI "Thẻ đã bán"/r/reports/prepaid-card-analytics?tab=transactionsGiao dịchinherit shared filter; KHÔNG override local filter (Loại thẻ + Trạng thái TT)Giữ shared rangeSection 9.2
3KPI "DT ghi nhận" ⏳ V11/r/reports/prepaid-card-analytics?tab=financeTài chính → tab con Tổng hợp doanh thuinherit shared filterGiữ shared rangeV11 chưa unlock → KPI hiển thị "—" + cursor default + tooltip "Chờ V11 confirm"Section 9.2
4KPI "Tổng dư ví" ⏳ V5/r/reports/prepaid-card-analytics?tab=financeTài chính → tab con Tổng hợp doanh thuinherit shared filter; scroll-to section "Báo cáo dư ví" (anchor #report-wallet-balance)Giữ shared rangeV5 chưa unlock → "—" + cursor defaultSection 9.2
5KPI "Công nợ"/r/reports/prepaid-card-analytics?tab=finance&inner=debtTài chính → tab con Công nợinherit shared filterGiữ shared rangeSection 9.2 (no-data nếu CN không nợ)
6KPI "Tỷ lệ KH đã dùng ví"/r/reports/prepaid-card-analytics?tab=customers&segment=activeKhách hànginherit shared filter + segment=active (KH Hoạt động)Giữ shared rangeSection 9.2.3 (filter-narrow vì có active chip "Phân khúc: Hoạt động")
7KPI "Tỷ lệ KH tái nạp"/r/reports/prepaid-card-analytics?tab=customers&sort=order_count_descKhách hànginherit shared filter; sort cột "Số lần mua thẻ" desc (chỉ sort, KHÔNG add segment filter)Giữ shared rangeSection 9.2
8KPI "KH mới"/r/reports/prepaid-card-analytics?tab=customers&segment=newKhách hànginherit shared filter + segment=new (KH Mới)Giữ shared rangeSection 9.2.3

Quy tắc chung KPI click:

  • Cursor: pointer khi enabled, default khi disabled (chờ V-point)
  • Hover: card highlight border + transition 150ms
  • Keyboard: Tab focus + Enter/Space để navigate
  • Aria: role="link" + aria-label="Xem chi tiết {KPI name} trong sub-tab {target}"

10.1.2 Sub-tab Tổng quan — Mini Rankings [Xem thêm] (3/3)

SourceTargetFilter paramsDisabled ruleEmpty/error
[Xem thêm →] dưới "Top thẻ bán chạy" (3.4 Section 1)Phase 1: DISABLED (Phase 1.x: bổ sung surface "Báo cáo Top thẻ bán chạy" full ranking)Phase 1: nút render <button disabled> + cursor not-allowed + tooltip "Xem đầy đủ Top thẻ ra mắt Phase 1.x" (theo Section 10.1.7 unified disabled rule). Lý do: Phase 1 KHÔNG có surface card-level ranking thật — Sub-tab Giao dịch group theo đơn (không group theo card), Sub-tab Tài chính → Tổng hợp DT group theo ngày (không có cột Tên thẻ). Mini ranking 3 hàng trong Tổng quan đủ cho cấp tổng quan; "xem đầy đủ" cần view dedicated. Phase 1.x roadmap: thêm route /r/reports/prepaid-card-analytics?tab=transactions&view=top-cards (full table card-level: Tên thẻ · Loại · Số bán · DT · KM nạp · % so kỳ trước)N/A (disabled)
[Xem thêm →] dưới "Top nhân viên giỏi"/r/reports/prepaid-card-analytics?tab=finance&inner=commissioninherit shared filter; sort cột "Hoa hồng" descPhase 1: vẫn enabled — navigate sang Tài chính → Hoa hồng (không phải sub-tab Nhân viên defer P3+). Tooltip header "Top NV bán thẻ — chi tiết hoa hồng"Section 9.2
[Xem thêm →] dưới "Top KH VIP"/r/reports/prepaid-card-analytics?tab=customers&sort=total_paid_descinherit shared filter; sort cột "Tổng tiền đã nạp" desc; KHÔNG add segment filter (VIP không phải segment riêng — derive từ DEC-B02 threshold)Section 9.2

Lý do "Top NV" navigate Tài chính → Hoa hồng (không Nhân viên): Sub-tab Nhân viên defer P3+ (DEC-B05). Tab Hoa hồng đã chứa cùng dimension (NV × Số đơn × DT × HH) — đáp ứng intent "xem chi tiết NV bán thẻ" của user. Khi Phase 3+ launch sub-tab Nhân viên thì re-route.

10.1.3 Sub-tab Tổng quan — Alert List Primary Action (5/5) — UPDATED v3.14 DEC-U13

Update v3.14 (DEC-U13): Thêm 3 cột mới (Primary action label, Sort override, Banner top template) — phục vụ rich context UX (Section 3.3). Trước đây mỗi alert có nút [Xem] generic + landing page không có banner — fail context. Sau update: nút verb-specific + landing có banner explicit.

Alert typePrimary action labelTarget routeFilter params payloadSort overrideDate rangeBanner top templateEmpty/error
🔴 Đơn quá hạn > 30d[📋 Xem {count} đơn]/r/reports/...?tab=finance&inner=debt&overdue=gt30dinherit shared branch[]; overdue=gt30d chipoverdue DESC (đơn quá hạn lâu nhất lên đầu)Override: last_365d (alert lũy kế)⚠️ Đang xem từ alert: "{branch} — {count} đơn quá hạn > 30 ngày"Section 9.2
🟡 KH không dùng ví > 60d[👥 Xem {count} KH]?tab=customers&segment=at_riskinherit branch[]; segment=at_risk chiplast_wallet_usage_at ASC (KH lâu nhất chưa dùng lên đầu)Override: last_365d⚠️ Đang xem từ alert: "{count} KH > 60 ngày không dùng ví"Section 9.2.3
🟠 Doanh thu giảm > 20%[📊 Xem chart doanh thu]?tab=overviewinherit branch[] (1 CN bị giảm); scroll-to anchor #chart-revenue-trendN/A (chart, không phải table)Giữ shared (period comparison FORMULA-019)⚠️ Đang xem từ alert: "{branch} — Doanh thu giảm {pct}%"Section 9.2
🔴 KH VIP không hoạt động[⭐ Xem {count} KH VIP]?tab=customers&vip=true&segment=at_riskinherit branch[]; vip=true + segment=at_risktotal_paid DESC (VIP cao nhất lên đầu)Override: last_365d⚠️ Đang xem từ alert: "{count} KH VIP > 90 ngày không hoạt động"Section 9.2.3 (chips: VIP + Rủi ro mất)
🟡 Thẻ giảm bán > 30%[💳 Xem chi tiết thẻ {card_name}]?tab=transactions&q={prepaid_card.code}inherit branch[]; set shared q={prepaid_card.code} (DEC-U12)paid_at DESC (default Sub-tab Giao dịch)Giữ shared (alert đo trong kỳ)⚠️ Đang xem từ alert: "Thẻ {card_name} giảm {pct}% lượng bán"Section 9.2.3 (active chip: [Từ khóa: "{card.code}" ×])

Quy tắc chung Alert click — UPDATED v3.14 (DEC-U13):

  1. Payload format mở rộng: compute_prepaid_alerts trả về JSON với fields bắt buộc:

    json
    {
      "alert_id": "critical-overdue-1",
      "type": "overdue_gt30d",
      "severity": "critical",  // critical | warning | info
      "sla_label": "⚠️ Cần xử lý trong 24h",
      "title": "CN Đà Lạt — 120 đơn quá hạn > 30 ngày",
      "metric_text": "120 đơn quá hạn > 30 ngày — Công nợ 45 tr",
      "count": 120,
      "branch_name": "Đà Lạt",
      "trend_value": 15,         // số thay đổi vs prior period (null nếu BE chưa support)
      "prior_count": 105,        // count kỳ trước
      "trend_direction": "up",   // up | down | flat
      "trend_compare_label": "vs 7d qua",
      "suggested_action_key": "alert.suggest.call_overdue_debt",
      "navigate": {
        "target_tab": "finance",
        "target_inner_tab": "debt",
        "filter_chips": [
          {"key": "branch", "value": "DALAT", "label": "Đà Lạt"},
          {"key": "overdue", "value": "gt30d", "label": "Quá hạn > 30d"}
        ],
        "range_override": "last_365d",
        "sort_override": {"field": "overdue", "direction": "desc"},
        "scroll_to_anchor": null
      }
    }
  2. Hover trước clickQTooltip preview (max-width 380px, position top, delay 300ms):

    Sẽ navigate sang: {target_tab} → {target_inner_tab}
    Filter: {filter_chip_labels join " + "}
  3. Sau navigate → Banner top mandatory (render trong target page):

    ┌──────────────────────────────────────────────────────────────┐
    │ ⚠️ Đang xem từ alert: "{banner_top_text}"                    │
    │ Filter áp dụng: [chip1 ×] [chip2 ×] [chip3 ×]                │
    │ [Bỏ alert filter ×]    [Đặt lại tất cả ↻]                   │
    └──────────────────────────────────────────────────────────────┘
    • [Bỏ alert filter ×]: clear chỉ filter chips đến từ alert (giữ shared filter người dùng đã set trước đó như q)
    • [Đặt lại tất cả ↻]: reset toàn bộ về shared filter mặc định
  4. Sort override: Khi vào từ alert, target page TỰ ĐỘNG sort theo sort_override (VD: Công nợ sort overdue DESC). Render visual indicator: Sắp xếp: Quá hạn cao nhất ▼ (từ alert)

  5. Aria: Primary button role="button" + aria-label="Xem chi tiết alert {title}". Banner top role="status" + aria-live="polite".

10.1.4 Sub-tab Giao dịch — Cell click (3 cell có navigation)

Cell columnTargetFilter paramsDisabled rule (Phase 1)Empty/error
Cột "Mã đơn" (col 1)Navigate detail page /e/prepaid-order/:id (existing route, KHÔNG ở trong tab Analytics):id = order.idExisting detail page xử lý empty/error
Cột "Khách hàng" dòng 1 (tên KH)Navigate CRM profile /c/customer/:id (existing route):id = customer.idRBAC: thiếu permission crm.customer.view → cursor default + tooltip "Không có quyền xem CRM"Existing CRM xử lý
Cột "Nhân viên" dòng 1 (NV bán)Phase 1: DISABLEDCursor: not-allowed + tooltip "Sub-tab Nhân viên ra mắt Phase 3+". KHÔNG render link, render plain <span> text. Phase 3+: navigate /r/reports/prepaid-card-analytics?tab=staff&staff=<id>N/A

Note column config Section 4.4: Cột "Nhân viên" trong Phase 1 render text plain (KHÔNG link), thống nhất với rule disabled ở đây. Dev tránh render <a> tag từ column config.

10.1.5 Sub-tab Khách hàng — Cell click (2 cell)

Cell columnTargetFilter paramsDisabled ruleEmpty/error
Cột "Khách hàng" (tên)Navigate CRM profile /c/customer/:id:id = customer.idRBAC: thiếu crm.customer.view → disabledExisting CRM
Cột "SĐT"Click → copy clipboard + toast "Đã copy {phone}"RBAC: thiếu report.prepaid_analytics.view_full_phone → SĐT mask 0912***456, copy chỉ copy masked valueN/A

10.1.6 Sub-tab Tài chính — Cell click

CellTargetFilter paramsDisabled ruleEmpty/error
Tab Công nợ — cột "Mã đơn"/e/prepaid-order/:id:id = order.idExisting
Tab Công nợ — nút [📞 Gọi]tel:{phone} (mở app gọi) hoặc copy clipboard nếu desktopphone = customer.phone_number (full, KHÔNG mask vì đã có RBAC entry tab)N/A
Tab Hoa hồng — expand rowInline expand (KHÔNG navigate)Empty state inner: "NV chưa có chi tiết hoa hồng trong kỳ"

10.1.7 Disabled controls — Phase 1 unified rule

Mọi control deferred (Phase 2/3+) PHẢI áp dụng unified disabled pattern:

Visual:        cursor: not-allowed; opacity: 0.6; render plain <span> hoặc <button disabled>
Tooltip:       "Sắp ra mắt {Phase}" (Phase 2 / Phase 3+)
Aria:          aria-disabled="true" + aria-label gốc
KHÔNG:         render <a href> hoặc <router-link> ngay cả khi href=""
QA test:       click không trigger event, focus skip element (tabindex="-1")

Áp dụng cho:

  • Cột "Nhân viên" trong tất cả bảng (Giao dịch + Tài chính/Hoa hồng row chính)
  • KPI "DT ghi nhận" + "Tổng dư ví" trong Tổng quan khi V5/V11 chưa unlock
  • Sub-tab "Marketing" + "Nhân viên" trong tab bar (ẩn hoàn toàn — không hiện disabled tab)
  • Phase 2 Global Search dropdown enhancement (placeholder hiện input nhưng KHÔNG có dropdown suggestion)

10.2 VND Format Rules

Giá trịHiển thịDùng ở
< 1,000“500”Row data
1,000 – 999,999“500K”KPI cards, summary
1,000,000 – 999,999,999“1.5tr” / “850tr”KPI cards, summary, charts
≥ 1,000,000,000“2.1 tỷ”KPI cards, charts
Exact (table cells)“10,000,000” (full format)Table columns

Hover exact value trên rút gọn → QTooltip “850,000,000 VND”.

10.3 Date Format Rules

ContextFormatExample
Table columnsDD/MM/YYYY13/03/2026
Chart axisDD/MM (day), Tuần N (week), T03/2026 (month)
Relative time“3 ngày trước”, “2 tháng trước”Cột “Lần dùng cuối”
Stale badgeHH:mm14:30

10.4 Color System

SemanticQuasar varHex (reference)Dùng cho
Primary$primaryLinks, active tabs, primary actions
Positive$positiveTăng, Hoạt động, Xuất sắc, Đã TT
Negative$negativeGiảm, Rủi ro, Critical, Nợ quá hạn
Warning$warningCảnh báo, Ngủ đông, Nợ 15-30d
Info$infoTheo dõi, KH mới, Thông tin
Fixed card$primary tintTag “Cố định”
Flexible card$purple-6Tag “Linh hoạt”

10.5 Tooltip System

  • KPI Card ℹ️: QTooltip max-width 320px, position top, delay 200ms
  • Column header ℹ️ (nếu cần): QTooltip nhỏ hơn, max-width 240px
  • Thuật ngữ link: “Giải thích thuật ngữ” → QDialog hiển thị bảng 16.1 từ design spec
  • Nội dung tooltip: xem design spec Section 16.2–16.5

11. Component Tree

PrepaidCardAnalytics.tsx                   ← Parent, route: /r/reports/prepaid-card-analytics
├── PrepaidAnalyticsFilter.tsx             ← Shared filter sticky, 3 elements (P1)
│   ├── BranchSelector.tsx                 ← QSelect grouped multi-select (P1)
│   ├── DateRangePicker.tsx                ← QSelect dropdown gộp presets + custom range (P1)
│   └── PrepaidAnalyticsGlobalSearch.tsx   ← Search input + dropdown (⏳ P2)

├── [QTabPanels — direct component, KHÔNG router-view (DEC-T06)]
│   ├── PrepaidAnalyticsOverview.tsx        ← route: /overview (P1)
│   │   ├── OverviewKpiCards.tsx            ← 8 cards, 2 rows (P1)
│   │   ├── OverviewCharts.tsx             ← 4 charts, 2×2 grid (P1)
│   │   ├── OverviewAlerts.tsx             ← Alert box collapsible (P1)
│   │   └── OverviewRankings.tsx           ← 3 mini tables (P1)
│   │
│   ├── PrepaidAnalyticsTransactions.tsx    ← route: /transactions (P1)
│   │   ├── TransactionLocalFilter.tsx                                  (P1)
│   │   ├── TransactionSummaryBar.tsx                                   (P1)
│   │   ├── TransactionTable.tsx           ← QTable + keyset pagination (P1)
│   │   └── TransactionExpandedRow.tsx     ← 3 tabs (P1)
│   │
│   ├── PrepaidAnalyticsCustomers.tsx      ← route: /customers (P1)
│   │   ├── CustomerSegmentCards.tsx        ← 4 segment cards (P1)
│   │   ├── CustomerBehaviorBar.tsx        ← Chỉ số hành vi KH bar (3 metrics) (P1)
│   │   ├── CustomerTable.tsx              ← QTable + keyset (P1)
│   │   ├── CustomerExpandedRow.tsx        ← 3 tabs (P1)
│   │   └── CustomerTableHeader.tsx        ← Title + Export Excel button top-right (P1, read-only)
│   │
│   └── PrepaidAnalyticsFinance.tsx        ← route: /finance (P1)
│       ├── FinanceKpiCards.tsx            ← 8 finance cards (P1)
│       ├── FinanceRevenue.tsx             ← Tab con 1: Tổng hợp doanh thu (P1)
│       ├── FinanceDebt.tsx                ← Tab con 2: Công nợ (P1)
│       ├── FinanceCommission.tsx          ← Tab con 3: Hoa hồng (P1)
│       ├── FinancePaymentMethods.tsx      ← Tab con 4: Phương thức thanh toán (P1)
│       └── FinanceExportBar.tsx           ← Export buttons bar (P1)

⏳ Phase 3+ (TBD — KHÔNG build trong Phase 1, giữ trong spec làm reference):
│   ├── PrepaidAnalyticsMarketing.tsx      ← route: /marketing (⏳ P3+)
│   │   ├── MarketingKpiCards.tsx           ← 8 marketing cards
│   │   ├── MarketingCampaigns.tsx         ← Tab con 1: Chiến dịch
│   │   ├── MarketingAffiliate.tsx         ← Tab con 2: Affiliate
│   │   ├── MarketingSources.tsx           ← Tab con 3: Nguồn khách hàng
│   │   ├── MarketingCompare.tsx           ← Tab con 4: So sánh kênh
│   │   └── MarketingActions.tsx           ← Action buttons
│   │
│   └── PrepaidAnalyticsStaff.tsx          ← route: /staff (⏳ P3+)
│       ├── StaffKpiCards.tsx              ← 4 staff cards
│       ├── StaffRanking.tsx               ← Tab con 1: Xếp hạng
│       ├── StaffDetail.tsx                ← Tab con 2: Chi tiết nhân viên
│       └── StaffBranchCompare.tsx         ← Tab con 3: So sánh chi nhánh

Shared:
├── composables/
│   ├── usePrepaidAnalyticsFilter.ts       ← Filter state (route + Pinia)
│   └── usePrepaidExport.ts                ← Export job + progress
├── graphql/
│   └── prepaid_analytics.graphql          ← All queries
├── types/
│   └── prepaid-analytics.types.ts         ← TypeScript types
├── store/
│   └── prepaidAnalyticsStore.ts           ← Pinia store
└── i18n/
    └── prepaid-analytics.vi.ts            ← Vietnamese translations

11.1 Export — Quy tắc đặt label + permission (chuẩn hóa)

Update 2026-05-04 (review L5 user request): Đồng bộ label tiếng Việt + hợp nhất 1 permission cho mọi nút export trong tab Analytics.

Label pattern chuẩn

Trường hợpLabel chuẩnVị trí
Generic single button (1 loại export)[📥 Xuất Excel]Header bảng KH (Section 5.2), Bảng Giao dịch (Section 4)
Multiple specific buttons (nhiều loại báo cáo)[📥 Xuất {Tên báo cáo}]Section 6.7 Tài chính export bar (5 buttons)
Sub-tab Marketing (P3+)[📥 Xuất báo cáo chiến dịch]Khi build P3+

5 specific buttons trong Tài chính (Section 6.7):

  1. [📥 Xuất Sổ doanh thu]
  2. [📥 Xuất Công nợ]
  3. [📥 Xuất Hoa hồng]
  4. [📥 Xuất Đối soát chi nhánh] (mỗi CN 1 sheet)
  5. [📥 Xuất Báo cáo dư ví]

Cấm: "Tải Excel" / "Tải xuống" / "Download" / "Xuất file Excel" / "Export" — chỉ dùng "Xuất" + tên báo cáo (hoặc "Xuất Excel" cho generic).

Permission hợp nhất — 1 action duy nhất

Tất cả nút export trong tab Analytics dùng CÙNG 1 permission: report.prepaid_analytics.export. KHÔNG tách per-export-type (vd KHÔNG có export_revenue / export_debt / export_commission riêng).

Rule:

typescript
// FE — 1 computed cho tất cả export buttons trong module
const canExport = computed(() =>
  hasActionPermission('report.prepaid_analytics', 'export')
)

// Hide button khi không có permission:
<QBtn v-if="canExport" label="Xuất Sổ doanh thu" @click="exportRevenue" />
<QBtn v-if="canExport" label="Xuất Công nợ" @click="exportDebt" />
// ... tất cả các buttons khác cũng v-if="canExport"

// BE export-api endpoint:
// 1. Verify JWT có claim `report.prepaid_analytics.export` = true
// 2. Nếu không → return 403
// 3. Apply branch_mode + view_full_phone rule khi đọc data

Lý do hợp nhất 1 permission:

  • Đơn giản UX cho admin cấp quyền — bật/tắt 1 lần là cover hết
  • Compliance ý nghĩa: "user X có quyền xuất báo cáo prepaid analytics" — không cần chia nhỏ
  • Tránh quyền dạng tủ thuốc (5 sub-permissions) gây confusion

Audit log per-export: vẫn ghi chi tiết export_type trong export_job table (Section 9.x dev-spec) — biết user đã xuất loại báo cáo nào, lúc nào, với filter gì. Permission gate ở entry, audit chi tiết ở data.


12. Accessibility & UX

12.1 Keyboard Navigation

  • Tab: di chuyển giữa 3 filter elements (Chi nhánh → Khoảng thời gian → Tìm kiếm), sub-tabs, table rows
  • Enter: expand row, activate button
  • Escape: đóng dropdown, dialog, expanded row
  • Arrow keys: navigate trong dropdown results

12.2 Screen Reader

  • KPI cards: aria-label="Tiền thu vào: 850 triệu, tăng 12% so với kỳ trước"
  • Charts: aria-label mô tả trend chính
  • Alert badges: aria-live="polite" khi có alert mới

12.3 Performance UX

  • Skeleton loading cho mọi async content (không dùng spinner toàn trang)
  • Stale-while-revalidate: hiển thị data cũ ngay, replace khi có data mới (URQL cache-and-network)
  • Lazy load: sub-tab content chỉ load khi navigate vào
  • Virtual scroll: cho bảng > 100 rows (QTable virtual-scroll prop)
  • Chart animation: chỉ chạy lần đầu render, skip khi filter change

B-POST) Verification & Validation

Sau khi spec hoàn tất, verify cross-section consistency + completeness gate trước DELIVER.

B-POST.1 Consistency check (manual)

AspectCheckStatus
Wireframe ASCII vs Section detailMọi label/badge/button trong wireframe đều có spec chi tiết tương ứng✅ Pass (verified Section 4.1 ↔ 4.2/4.3/4.4)
Tooltip × FieldMọi metric có 3-part tooltip (definition / formula / example)⚠️ Partial — Section 16.x dev-spec chứa tooltip dictionary; ui-spec ref nhưng chưa inline tất cả
Empty State v2 detect priority3 case (no-data / filter-narrow / error) đều có spec render + CTA✅ Pass (Section 9.2.x)
Active filter chipMỗi local filter có chip representation + onRemove behavior✅ Pass (Section 5.1 segment + Section 9.2.3)
Permission scope (DEC-U10)1 permission report.prepaid_analytics.export cho TOÀN BỘ export✅ Pass (Section 11.1)
Single search source (DEC-U12)0 local search input ở mọi sub-tab✅ Pass (sweep verified Wave 14+15)
Trend label dynamic (FORMULA-019)Sub-tab Tổng quan + Giao dịch sum cards ref FORMULA-019✅ Pass (Section 3.1 + 4.3)
Wallet PROVISIONAL guardUI hiển thị "—" cho Ví Diva/Ví KM nạp khi V9-V10 chưa unlock✅ Pass (dev-spec Section 4.2 + plan note)

B-POST.2 Completeness gate

  • [x] B-PRE Discovery & Pattern Reuse
  • [x] B0.4 Field × Surface Matrix
  • [x] B0.5 State × Screen Matrix
  • [x] Section 1-12 (Layout, Sub-tab specs, States, Patterns, Component Tree, A11y)
  • [x] B-POST Verification
  • [x] _consistency-matrix.md (cross-doc index)
  • [ ] Pattern reuse verified với codebase (deferred to dev kickoff — plan.md Task 19b Step 0)

B-POST.3 Outstanding items (parked — không block DELIVER)

ItemReason parkedOwnerWhen
Inline 3-part tooltip cho mọi metric trong ui-spec (vs ref dev-spec 16.x)Avoid duplicate — tooltip canonical ở dev-specCó thể inline khi rebuild spec format
Pattern reuse SQL grep cho EmptyState/KpiCardCần codebase access — defer dev kickoffFE LeadTask 19b Step 0
WCAG audit thực tế (axe-core / Lighthouse)Cần build deploy stagingQA + FESau Phase 1 build done

B-QUALITY) Quality Bar Compliance

Đảm bảo UI spec đáp ứng quality bar 23-criteria của Mode B Large profile (po-ba-workflow). Mỗi criterion check + evidence link.

B-QUALITY.1 Specification Quality

#CriterionStatusEvidence
1Mỗi screen có wireframe ASCII với label tiếng Việt + dữ liệu mẫu ngành spaSection 1.x · 3.1 · 4.1 · 5.1 · 6.x
2Action buttons cụ thể (text + icon) — không generic "[Action]"[🔄 Tải lại] · [📥 Xuất Excel] · [📞 Gọi] · [Đặt lại tất cả]
3Badge text thực — không placeholder[Linh hoạt] · [Cố định] · 🟢 Hoạt động · 🔴 Rủi ro mất
4State matrix (loading/has-data/empty/error/no-permission/stale)B0.5 + Section 9.x
5Field × Surface coverage mọi field có ≥ 1 surface, mọi surface ≥ 1 fieldB0.4
6Permission matrix (3 RBAC actions per role)Section 12.1 + dev-spec §2.5
7Tooltip dictionary 3-part format (definition / formula / example)⚠️ Partialdev-spec §16.x canonical, ui-spec ref — chưa inline tất cả (parked B-POST.3)
8Copy text canonical lock cho mọi messageSection 9.2.3 (10 keys) + dev-spec §16.x
9Empty state v2 detect priority + diagnostic claritySection 9.2
10Active filter chips representationSection 5.1 (segment) + 9.2.3 (chips)
11Single source state (không drift)DEC-U12 + _consistency-matrix.md
12Pattern reuse check (CLAUDE.md Codebase-First)⚠️ PartialB-PRE matrix đã list — runtime grep parked đến dev kickoff
13Các anti-pattern bị cấm liệt kê + reject reasonB-PRE.2
14Accessibility WCAG 2.1 AASection 12.x + 9.2.3 detail
15Mobile responsive breakpoint < 480px / 768px / desktopSection 1.2 + 9.2.3 mobile note
16Cross-doc traceability (DEC × FORMULA × V × Surface)_consistency-matrix.md
17Decision Log ref (DEC-ID) trong mọi quyết định downstreamMọi section ref DEC-U01..U12, T01..T06, B01..B06
18PROVISIONAL guard cho V9-V13 (không hiển thị value derived sai)Section 4.3 + dev-spec PROVISIONAL blocks
19Trend label dynamic ref FORMULA-019Section 3.1 + 4.3
20Format VND nhất quán (tr/tỷ/full per surface)DEC-U11 (f) + Section 12.x
21Audit trail cho write actions✅ N/ADEC-U09 — Phase 1 read-only, KHÔNG có write actions
22Performance UX (skeleton + stale-while-revalidate + lazy load + virtual scroll)Section 12.3
23B-PRE / B0.4 / B0.5 / B-POST / B-QUALITY sections complianceSection này + 4 section trước

Score: 21/23 ✅ + 2 ⚠️ Partial (parked với reason) — đủ điều kiện pass DELIVER gate.

B-QUALITY.2 Outstanding Quality Items (parked)

ItemSeverityOwnerResolution
Tooltip inline trong ui-spec (vs ref dev-spec)LowAvoid duplicate — canonical ở dev-spec §16.x đủ
Pattern reuse runtime grep EmptyState/KpiCard codebaseMediumFE LeadPlan Task 19b Step 0 (dev kickoff)
WCAG audit axe-core / Lighthouse runtimeMediumQA + FESau Phase 1 build done (gate go-live E5)

B-QUALITY.3 Sign-off

ReviewerRoleSignNotes
___________________POApprove PRD A10.0 + DEC-U12
___________________UX/DesignerApprove Empty State v2 + B-PRE/B0.4/B0.5
___________________Tech LeadApprove B-PRE pattern reuse rule
___________________FE LeadApprove Section 11 component tree + Task 19b
___________________QA LeadApprove Empty State v2 test cases
___________________A11y Specialist⏳ N/ADefer audit runtime

Sign-off này là spec-level, KHÔNG phải go-live sign-off. Go-live sign-off ở go-live-checklist.md E5 sau khi V1-V13 unlocked + build complete.