Skip to content

Prepaid Card Analytics Tab — Implementation Plan

For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (- [ ]) syntax for tracking.

⚠️ Plan đã được update theo prd.md DEC-B05, DEC-U04..U07 (2026-05-04). Đọc kỹ banner Phase split dưới đây trước khi bắt đầu.

v3.15 — 14/05/2026

Thay đổiSectionẢnh hưởng
CustomerTable.tsx: QTable keyset pagination đổi 12 → 13 cột; thêm Dư ví DIVA + Dư ví KM, risk highlight theo tổng DIVA + KM > 5trComponent tree · Task 15 Step 3FE
Wallet balance aggregate query thêm field split wallet_balance_diva + wallet_balance_km; exact field name chờ BE V5 confirmGraphQL query examplesBE, FE

v3.14 — 12/05/2026

Thay đổiSectionẢnh hưởng
TransactionExpandedRow.tsx: 3 tabs → 2 tabs (bỏ "Lịch sử dùng") — ref DEC-T07Task 12 Step 4FE
Task 4 Step 4 comment: "3 tabs inside expanded row" → "2 tabs"Task 4FE, QA
⭐ DEC-T08: Tab Hoa hồng — source invoice_commission, bỏ status S/P/C. Refund row riêng amount âmTask 12 Step 4FE

🚦 Phase split (cập nhật 2026-05-04)

Plan này gốc viết cho 6 sub-tab + Compare Mode. Sau update scope, một số task/step bị DEPRECATED hoặc DEFER:

✅ Phase 1 — MVP (build trong sprint hiện tại)

ChunkTasksSub-tab
Chunk 1: FoundationTask 1–7Backend + scaffold
Chunk 2: Frontend P1Task 8 (sửa: bỏ CompareModeToggle) · Task 9 (sửa: bỏ compare queries) · Task 10–13Tổng quan · Giao dịch · Tài chính
Chunk 2 (mở rộng)Task 15 (kéo từ Chunk 3 sang)Khách hàng

Task 8 sửa lại: Tạo BranchSelector + DateRangePicker (dropdown gộp presets) + Search input. KHÔNG tạo CompareModeToggleusePrepaidCompareMode.

Task 13 sửa lại: Sub-tab "Tài chính" (rename từ "Doanh thu & Công nợ"). Migration + i18n key đã đổi sang report.prepaid_analytics.finance = "Tài chính".

⏳ Phase 2 — Sau MVP (1-2 sprint)

TasksNội dung
Task 14 (subset) + Task 18Global Search (pg_trgm, search_prepaid_global)

🚫 Phase 3+ — Defer indefinitely (TBD)

TasksLý do defer
Task 16 — Marketing sub-tabKhách hàng (P1) đã đủ cho Marketing tự phục vụ. Marketing chuyên sâu chưa khẩn cấp
Task 17 — Staff sub-tabHoa hồng NV đã có ở Tài chính tab con 3 (P1) cho Kế toán. Ranking/coaching chưa có nhu cầu
Task 19 — Compare mode integrationDELETE — không build (DEC-U04 bỏ Compare Mode toggle)

Migration list rút gọn (Phase 1)

Chỉ chạy 7 migrations P1 (xem dev-spec.md Section 11.1):

  • create_region_branch (đã có sẵn — KHÔNG tạo) · create_mv_prepaid_order_daily ⭐ · create_mv_prepaid_card_daily ⭐ · create_mv_prepaid_customer_stats · create_mv_prepaid_finance_daily · create_indexes_prepaid_report · create_compute_prepaid_alerts · create_export_job_table

KHÔNG chạy: create_mv_prepaid_staff_stats, create_mv_prepaid_campaign_stats, create_search_prepaid_global (Phase 2/3+).


Goal: Build a new analytics tab in the prepaid card report module. Phase 1 = 4 sub-tab (Tổng quan · Giao dịch · Khách hàng · Tài chính) serving Accounting + Marketing (KH self-serve) + Management. 2 sub-tab Marketing + Nhân viên defer Phase 3+ TBD.

Architecture: Vue 3 + Quasar QTabs + QTabPanels (DEC-T06 — KHÔNG dùng router-view child components, để giữ state khi switch tab + đơn giản state machine). Backend Phase 1 dùng 4 PostgreSQL materialized views (CONCURRENT refresh): mv_prepaid_order_daily · mv_prepaid_card_daily · mv_prepaid_customer_stats · mv_prepaid_finance_daily (đã tách order/card-level để fix double count) + 1 SQL function compute_prepaid_alerts. Defer Phase 2/3+: mv_prepaid_staff_stats, mv_prepaid_campaign_stats, search_prepaid_global. Keyset pagination + Redis caching cho wallet balance. Feature flag FEATURE_PREPAID_ANALYTICS_V2 toggles old/new tab.

Tech Stack: Vue 3 / Quasar 2.7.5 / TypeScript / URQL / Pinia / Chart.js 3 / vue-chart-3 / Go 1.22 / Hasura / PostgreSQL 14 / Redis

Spec References:

Codebase Paths (relative to inner diva-group/diva-group/ repo root):

  • Frontend root: diva-admin/src/modules/report/
  • Backend migrations: diva-backend/services/controller/migrations/ecommerce/
  • Hasura metadata: diva-backend/services/controller/metadata/
  • Export API: diva-backend/services/export-api/
  • On-disk: C:\Users\sonth\Desktop\DIVA\diva-group\diva-group\ is the actual repo root

Architectural decision — QTabPanels vs router-view: This plan uses QTabPanels with direct component rendering (matching ServiceReport.tsx pattern) rather than child routes. This keeps consistency with existing report pages. Sub-tab components are imported and rendered inside QTabPanel, lazy-loaded via dynamic imports.


File Structure

Backend — New Files

migrations/ecommerce/
├── # KHÔNG cần tạo migration region_branch — đã có sẵn (region_branch + branch.region_id)
│   └── down.sql
├── YYYYMMDDHHMMSS_create_mv_prepaid_order_daily/
│   ├── up.sql          — MV + unique index
│   └── down.sql
├── YYYYMMDDHHMMSS_create_mv_prepaid_customer_stats/
│   ├── up.sql          — MV + unique index
│   └── down.sql
├── YYYYMMDDHHMMSS_create_mv_prepaid_finance_daily/
│   ├── up.sql          — MV + unique index (P1)
│   └── down.sql
│   # Phase 3+ (TBD — KHÔNG tạo trong P1):
│   # YYYYMMDDHHMMSS_create_mv_prepaid_staff_stats/
│   # YYYYMMDDHHMMSS_create_mv_prepaid_campaign_stats/
│   # branch_flow MV đã loại bỏ (DEC-U04)
├── YYYYMMDDHHMMSS_create_indexes_prepaid_report/
│   ├── up.sql          — composite/partial indexes (P1, KHÔNG có pg_trgm GIN — defer P2)
│   └── down.sql
│   # Phase 2 (TBD):
│   # YYYYMMDDHHMMSS_create_fn_search_prepaid_global/  — pg_trgm search function
├── YYYYMMDDHHMMSS_create_fn_compute_prepaid_alerts/
│   ├── up.sql          — Alert computation function (P1)
│   └── down.sql
└── YYYYMMDDHHMMSS_create_export_job_table/
    ├── up.sql          — Export job tracking table (P1)
    └── down.sql

Frontend — New Files

src/modules/report/
├── pages/
│   └── prepaid-analytics/
│       ├── PrepaidCardAnalytics.tsx              — Parent page (QTabs + QTabPanels) (P1)
│       ├── PrepaidAnalyticsOverview.tsx           — Sub-tab 1: Overview (P1)
│       ├── PrepaidAnalyticsTransactions.tsx       — Sub-tab 2: Transactions (P1)
│       ├── PrepaidAnalyticsCustomers.tsx          — Sub-tab 3: Customers (P1)
│       └── PrepaidAnalyticsFinance.tsx            — Sub-tab 4: Tài chính (P1)
│       # P3+ TBD (KHÔNG tạo P1):
│       # PrepaidAnalyticsMarketing.tsx           — Sub-tab 5
│       # PrepaidAnalyticsStaff.tsx               — Sub-tab 6
├── components/
│   └── prepaid-analytics/
│       ├── shared/
│       │   ├── PrepaidAnalyticsFilter.tsx         — Shared filter bar sticky, 3 element (P1)
│       │   ├── BranchSelector.tsx                 — QSelect grouped multi-select 70 CN (P1)
│       │   ├── DateRangePicker.tsx                — Dropdown gộp presets + custom range (P1)
│       │   ├── KpiCard.tsx                        — Reusable KPI card with tooltip (P1)
│       │   ├── KpiCardGrid.tsx                    — Grid layout for KPI cards (P1)
│       │   └── ExportProgressDialog.tsx           — Export progress bar dialog (P1)
│       │   # P2 (defer): PrepaidAnalyticsGlobalSearch.tsx
│       │   # 🚫 SKIP (DEC-U04): CompareModeToggle.tsx
│       ├── overview/
│       │   ├── OverviewKpiCards.tsx               — 8 KPI cards (P1)
│       │   ├── OverviewCharts.tsx                 — 4 charts grid (P1)
│       │   ├── OverviewAlerts.tsx                 — Alert box 3 levels (P1)
│       │   └── OverviewRankings.tsx               — 3 mini tables (P1)
│       │   # 🚫 OverviewCustomerFlow.tsx — đã xóa (DEC-U04)
│       ├── transactions/
│       │   ├── TransactionLocalFilter.tsx         — Local filter bar 2 element (DEC-U12 — bỏ local search) (P1)
│       │   ├── TransactionSumCards.tsx            — 7 sum cards grid (P1, đổi từ SummaryBar)
│       │   ├── TransactionTable.tsx               — QTable + keyset pagination, 10 cột (P1)
│       │   └── TransactionExpandedRow.tsx         — 2 tabs: Chi tiết TT, Hoa hồng (DEC-T07 — bỏ "Lịch sử dùng") (P1)
│       ├── finance/
│       │   ├── FinanceKpiCards.tsx                — 8 finance KPI cards (P1)
│       │   ├── FinanceRevenue.tsx                 — Tab Tổng hợp DT (P1)
│       │   ├── FinanceDebt.tsx                    — Tab Công nợ (P1)
│       │   ├── FinanceCommission.tsx              — Tab Hoa hồng (P1)
│       │   ├── FinancePaymentMethods.tsx          — Tab Phương thức thanh toán donut + table (P1)
│       │   └── FinanceExportBar.tsx               — 5 export buttons (P1)
│       └── customers/
│           ├── CustomerSegmentCards.tsx           — 4 segment cards (P1)
│           ├── CustomerBehaviorBar.tsx            — Chỉ số hành vi KH bar 3 metrics (P1)
│           ├── CustomerTable.tsx                  — QTable + keyset pagination 13 cột (P1)
│           ├── CustomerExpandedRow.tsx            — 3 tabs: Cards, Behavior, Actions (P1)
│           └── CustomerTableHeader.tsx            — Title + Export button top-right (P1) — đổi từ CustomerBulkActions
│       # P3+ TBD (KHÔNG tạo P1):
│       # marketing/* — 6 components
│       # staff/* — 4 components
├── graphql/
│   └── prepaid_analytics.graphql                 — GraphQL queries P1 (KHÔNG include staff/campaign)
├── composables/
│   ├── usePrepaidAnalyticsFilter.ts              — Filter state route + Pinia (P1)
│   └── usePrepaidExport.ts                       — Export job + progress (P1)
│   # 🚫 SKIP (DEC-U04): usePrepaidCompareMode.ts
├── stores/
│   └── usePrepaidAnalyticsStore.ts               — Pinia store (P1)
└── types/
    └── prepaid-analytics.types.ts                — TypeScript types (P1)

Frontend — Modified Files

src/modules/report/
├── module.ts           — Add new routes + feature flag
├── types.ts            — Add route constants + permission tree
└── i18n/vi.ts          — Add Vietnamese translations

Chunk 1: Foundation — Backend + Frontend Scaffold

Task 1: Verify existing region_branch schema (KHÔNG tạo migration mới)

🚫 ĐÃ ĐỔI SCOPE. Schema region_branch + branch.region_id đã có sẵn trong codebase (migration 1678865967129_region_branch/up.sql). KHÔNG tạo migration mới — chỉ verify dữ liệu.

Files: không tạo migration mới.

  • [ ] Step 1: Verify schema existing
sql
-- Connect ecommerce DB và chạy:
SELECT COUNT(*) FROM region_branch;
-- Expected: ≥ 4 rows (HCM, HN, ĐN, CT, ...)

SELECT COUNT(*) FROM branch WHERE region_id IS NULL AND deleted_at IS NULL;
-- Expected: 0 (tất cả 70 CN đang active đều phải có region)
  • [ ] Step 2: Nếu có CN chưa gán region → seed thủ công
sql
-- Chỉ chạy nếu Step 1 trả COUNT > 0
-- Ví dụ: gán region cho các CN missing
UPDATE branch SET region_id = (SELECT id FROM region_branch WHERE code = 'HCM')
WHERE region_id IS NULL AND name LIKE '%Q.%';
-- Sau đó re-verify Step 1 trả 0
  • [ ] Step 3: Document trong runbook

Ghi vào go-live-checklist.md Pre-deploy: "Verify region_branch data + tất cả CN có region_id".

Migration timestamp convention (cho các migration P1 còn lại): Use incrementing values to avoid collision. All migrations in this plan use 171030000N000 where N = 2-7 (Phase 1):

  • 2: mv_summary, 3: mv_customer, 4: mv_finance, 5: indexes, 6: fn_alerts, 7: export_job
  • 8 (P2): fn_search_global · 9-10 (P3+): mv_staff, mv_campaign

Task 2: Create 3 Materialized Views (Phase 1)

🚦 Phase 1 chỉ tạo 3 MV. 2 MVs staff_statscampaign_stats defer Phase 3+ — KHÔNG tạo migration trong Phase 1. MV branch_flow đã loại bỏ hoàn toàn (DEC-U04).

Files:

  • Create: 4 migration directories under migrations/ecommerce/ (one per MV) — Phase 1 (review L4 update: tách order/card MV để fix double count).

Reference: Design spec Section 9.2 có complete SQL cho từng MV. Copy verbatim. Schema fields đã được update theo Section 0 Schema Mapping (prepaid_value_into_wallet KHÔNG × quantity, product_id JOIN prepaid_card_view, order.total).

  • [ ] Step 1: Create mv_prepaid_order_daily migration (P1) ⭐

File: migrations/ecommerce/171030002000_create_mv_prepaid_order_daily/up.sql

Copy SQL from design spec Section 9.2 (mv_prepaid_order_daily). Key points:

  • GROUP BY: report_date, branch_id, region_id — KHÔNG có product_id (fix double count)

  • CTE per_order dedupe invoice + item aggregate trước khi roll up

  • prepaid_value_into_wallet KHÔNG × quantity (Section 0.2 Rule 1)

  • Unique index on (report_date, branch_id)

  • down.sql: DROP MATERIALIZED VIEW IF EXISTS mv_prepaid_order_daily CASCADE;

  • [ ] Step 2: Create mv_prepaid_card_daily migration (P1) ⭐

File: migrations/ecommerce/171030003000_create_mv_prepaid_card_daily/up.sql

Copy SQL from design spec Section 9.2 (mv_prepaid_card_daily). Key points:

  • GROUP BY: report_date, branch_id, product_id — card-level only

  • CHỈ có sold_quantity, total_wallet_topup, total_card_value — KHÔNG total_collected (order-level)

  • prepaid_value_into_wallet KHÔNG × quantity

  • JOIN prepaid_card_view

  • Unique index on (report_date, branch_id, product_id)

  • [ ] Step 3: Create mv_prepaid_customer_stats migration (P1)

File: migrations/ecommerce/171030004000_create_mv_prepaid_customer_stats/up.sql

Copy SQL from design spec Section 9.2. Key points:

  • CTE per_order dedupe invoice + item trước khi customer-level roll up (fix double count)

  • prepaid_value_into_wallet KHÔNG × quantity

  • Segment logic: 'new' check ĐẦU TIÊN trong CASE

  • 🔍 Verify table: customer hay ecommerce_user/default account — BE confirm trước khi chạy

  • Unique index on (customer_id)

  • [ ] Step 4: Create mv_prepaid_finance_daily migration (P1)

File: migrations/ecommerce/171030005000_create_mv_prepaid_finance_daily/up.sql

Copy SQL from design spec Section 9.2. Key points:

  • GROUP BY: report_date, branch_id, region_id, payment_method_id

  • KHÔNG include total_wallet_topup ở MV này (Finance MV bỏ field này — fix double count). Lấy total_wallet_topup từ mv_prepaid_order_daily

  • Unique index on (report_date, branch_id, payment_method_id)

  • [ ] Step 5-7: staff_stats / campaign_stats / branch_flow — 🚫 SKIP

P3+ defer (staff_stats, campaign_stats) hoặc deleted (branch_flow — DEC-U04). KHÔNG tạo trong Phase 1.

  • [ ] Step 8: Apply 4 P1 MVs and verify
hasura migrate apply --database-name ecommerce
psql -c "SELECT matviewname FROM pg_matviews WHERE matviewname LIKE 'mv_prepaid_%';"

Expected: 4 rows returned: mv_prepaid_order_daily, mv_prepaid_card_daily, mv_prepaid_customer_stats, mv_prepaid_finance_daily.

  • [ ] Step 9: Test CONCURRENT refresh — 4 MVs P1
psql -c "REFRESH MATERIALIZED VIEW CONCURRENTLY mv_prepaid_order_daily;"
psql -c "REFRESH MATERIALIZED VIEW CONCURRENTLY mv_prepaid_card_daily;"
psql -c "REFRESH MATERIALIZED VIEW CONCURRENTLY mv_prepaid_customer_stats;"
psql -c "REFRESH MATERIALIZED VIEW CONCURRENTLY mv_prepaid_finance_daily;"

Expected: All 4 refresh không lỗi.

  • [ ] Step 9: Commit
git add migrations/ecommerce/*_create_mv_prepaid_*/
git commit -m "feat(db): add 3 materialized views for prepaid card analytics Phase 1"

Task 3: Create composite indexes

Files:

  • Create: migrations/ecommerce/YYYYMMDDHHMMSS_create_indexes_prepaid_report/up.sql
  • Create: migrations/ecommerce/YYYYMMDDHHMMSS_create_indexes_prepaid_report/down.sql
  • [ ] Step 1: Write up.sql with all 6 indexes

Copy from design spec Section 9.3:

  1. idx_order_prepaid_report — main report query (ecommerce)
  2. idx_order_prepaid_debt — debt lookup (ecommerce)
  3. idx_invoice_order_paid — invoice aggregation (ecommerce)
  4. idx_invoice_wallet_usage — wallet usage filter (ecommerce)
  5. idx_txreq_commission_order — commission lookup (wallet schema)
  6. idx_order_prepaid_search — pg_trgm GIN for global search (ecommerce)

All use CREATE INDEX IF NOT EXISTS for idempotency.

  • [ ] Step 2: Write down.sql
DROP INDEX IF EXISTS idx_order_prepaid_report;
DROP INDEX IF EXISTS idx_order_prepaid_debt;
DROP INDEX IF EXISTS idx_invoice_order_paid;
DROP INDEX IF EXISTS idx_invoice_wallet_usage;
-- wallet schema index
DROP INDEX IF EXISTS wallet_schema.idx_txreq_commission_order;
DROP INDEX IF EXISTS idx_order_prepaid_search;
  • [ ] Step 3: Apply and verify
hasura migrate apply --database-name ecommerce
psql -c "SELECT indexname FROM pg_indexes WHERE indexname LIKE 'idx_%prepaid%' OR indexname LIKE 'idx_invoice_%' OR indexname LIKE 'idx_txreq_%';"
  • [ ] Step 4: Commit
git add migrations/ecommerce/*_create_indexes_prepaid_report/
git commit -m "feat(db): add composite indexes for prepaid report queries"

Task 4: Create SQL function compute_prepaid_alerts (P1 only)

🚦 Phase 1 chỉ tạo compute_prepaid_alerts. search_prepaid_global defer Phase 2 — KHÔNG migrate trong P1 (DEC defer Global Search). pg_trgm extension cũng defer P2.

Files:

  • Create: migrations/ecommerce/171030006000_create_fn_compute_prepaid_alerts/up.sql

  • Create: down.sql file

  • [ ] Step 1: Write compute_prepaid_alerts function (P1)

Copy from design spec Section 9.9. Critical details:

  • 4 alert types: overdue_payment, inactive_customer (filter balance > 0 — review L4 fix), revenue_drop, slow_card
  • Dual revenue thresholds: _revenue_drop_warning = 0.15, _revenue_drop_critical = 0.30
  • CTE for current 30d vs previous 30d comparison
  • Query mv_prepaid_order_daily (KPIs) và mv_prepaid_card_daily (slow card alert)
  • LANGUAGE plpgsql STABLE

⚠️ Alert payload schema (ref ui-spec Section 10.1.3): Function PHẢI return payload JSONB cho mỗi alert với 5 alert type. Riêng alert "Thẻ giảm bán > 30%" payload PHẢI return prepaid_card.code (string ILIKE-friendly) — KHÔNG return card_id (UUID). Lý do: FE click alert → set shared q={card.code} (DEC-U12 — single search source, scope Sub-tab Giao dịch include prepaid_card.code). Alert "Đơn quá hạn" payload return branch_id, "KH không dùng" return branch_id, "DT giảm" return branch_id, "KH VIP không hoạt động" return branch_id (filter override range = last_365d cho 4 alert lũy kế; "DT giảm" + "Thẻ giảm bán" giữ shared range).

  • [ ] Step 2: Write search_prepaid_global function — 🚫 SKIP (Phase 2)

KHÔNG TẠO trong Phase 1. pg_trgm extension + GIN index + function này defer Phase 2 sau MVP stable. SQL giữ trong dev-spec Section 10.2 làm reference.

  • [ ] Step 3: Write down.sql
sql
DROP FUNCTION IF EXISTS compute_prepaid_alerts(UUID[], INT, INT, INT, INT, NUMERIC, NUMERIC);
  • [ ] Step 4: Apply and test
hasura migrate apply --database-name ecommerce

# Test alerts function
psql -c "SELECT * FROM compute_prepaid_alerts(NULL);"

Expected: Function returns (possibly empty) result set without error.

  • [ ] Step 5: Commit
git add migrations/ecommerce/*_create_fn_compute_prepaid_alerts/
git commit -m "feat(db): add compute_prepaid_alerts function for prepaid analytics P1"

Task 5: Create export_job table

Files:

  • Create: migrations/ecommerce/YYYYMMDDHHMMSS_create_export_job_table/up.sql
  • Create: migrations/ecommerce/YYYYMMDDHHMMSS_create_export_job_table/down.sql
  • [ ] Step 1: Write up.sql
CREATE TABLE IF NOT EXISTS export_job (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id TEXT NOT NULL,
  branch_id UUID,
  report_type TEXT NOT NULL,        -- 'prepaid_revenue', 'prepaid_debt', etc.
  status TEXT NOT NULL DEFAULT 'pending',  -- pending, processing, completed, failed
  progress INT DEFAULT 0,           -- 0-100
  total_rows INT DEFAULT 0,
  processed_rows INT DEFAULT 0,
  file_url TEXT,                     -- MinIO/GCS URL when done
  filter_params JSONB,              -- Serialized filter state
  error_message TEXT,
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now(),
  completed_at TIMESTAMPTZ
);

CREATE INDEX idx_export_job_user ON export_job(user_id, created_at DESC);
CREATE INDEX idx_export_job_status ON export_job(status) WHERE status IN ('pending', 'processing');

CREATE TRIGGER set_export_job_updated_at
  BEFORE UPDATE ON export_job
  FOR EACH ROW EXECUTE FUNCTION set_current_timestamp_updated_at();

-- MV refresh tracking (for stale data badge — UI spec Section 9.4)
CREATE TABLE IF NOT EXISTS mv_refresh_log (
  mv_name TEXT PRIMARY KEY,
  last_refreshed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  refresh_duration_ms INT,
  status TEXT DEFAULT 'success'  -- success / failed
);

-- Seed entries for each MV (Phase 1 — 4 MVs only)
INSERT INTO mv_refresh_log (mv_name) VALUES
  ('mv_prepaid_order_daily'),
  ('mv_prepaid_card_daily'),
  ('mv_prepaid_finance_daily'),
  ('mv_prepaid_customer_stats')
  -- Phase 3+ (KHÔNG seed Phase 1):
  -- ('mv_prepaid_staff_stats'),
  -- ('mv_prepaid_campaign_stats'),
  -- branch_flow đã loại bỏ (DEC-U04)
ON CONFLICT (mv_name) DO NOTHING;
  • [ ] Step 2: Apply and commit
hasura migrate apply --database-name ecommerce
git add migrations/ecommerce/*_create_export_job_table/
git commit -m "feat(db): add export_job table for async report export tracking"

Task 6: Track in Hasura metadata

Files:

  • Modify: Hasura metadata tables.yaml / functions.yaml (exact path depends on Hasura CLI version)

Note: This task depends on your Hasura metadata structure. Use hasura metadata export to see current format, then add tracking for the new objects.

  • [ ] Step 1: Track 4 MVs P1 as tables in Hasura
# Track 4 MVs P1 (Hasura treats MVs as tables)
hasura metadata apply  # or use console to track tables

4 MVs P1:

  • mv_prepaid_order_daily (order-level KPIs)
  • mv_prepaid_card_daily (card-level chart Phân bố mệnh giá)
  • mv_prepaid_customer_stats (KH segment + behavior)
  • mv_prepaid_finance_daily (PTTT split — KHÔNG có total_wallet_topup)

For each MV, configure (per Section 2.5 RBAC v2 fine-grained):

  • Select permission cho role có report.prepaid_analytics.view action (mọi role mặc định ngoại trừ staff)
  • Branch scope: filter branch_id = X-Hasura-Branch-Id cho non-admin (nếu có cột branch_id)
  • SDT trong mv_prepaid_customer_stats: dùng computed column phone_number_masked cho role không có view_full_phone
  • No insert/update/delete (read-only MVs)

🚫 KHÔNG track P3+ MVs trong P1: mv_prepaid_staff_stats, mv_prepaid_campaign_stats. Migration cũng chưa chạy.

  • [ ] Step 2: Track 1 function P1

Track compute_prepaid_alerts as custom function:

  • Expose as query (not mutation)
  • Permission: role có report.prepaid_analytics.view

🚫 KHÔNG track search_prepaid_global — function defer Phase 2 (DEC + Task 4 plan đã skip migration). Track function này khi Phase 2 ramp up.

  • [ ] Step 3: Track export_job table

  • export_job: select cho user xem export của mình (user_id = X-Hasura-User-Id); insert cho role có report.prepaid_analytics.export; update cho admin (cập nhật trạng thái job)

region_branch đã track sẵn — không cần thêm tracking mới (đã có từ migration 1678865967129).

  • [ ] Step 4: Add relationships

  • branch.regionregion_branch (đã có sẵn, verify)

  • region_branch.branchesbranch[] (đã có sẵn, verify)

  • export_job.userecommerce_user (object relationship)

  • mv_prepaid_card_daily.productprepaid_card_view (object relationship qua product_id)

  • mv_prepaid_order_daily.branchbranch (object relationship qua branch_id)

  • mv_prepaid_customer_stats.customerecommerce_user (object relationship qua customer_id)

  • [ ] Step 5: Verify via Hasura console

Open Hasura console → Data → verify 4 MVs P1 + 1 function P1 + export_job đều xuất hiện. Marketing/Staff/CompareMode metadata KHÔNG có.

  • [ ] Step 6: Commit metadata
hasura metadata export
git add services/controller/metadata/
git commit -m "feat(hasura): track prepaid analytics 4 P1 MVs + compute_prepaid_alerts function + export_job"

Task 7: Frontend foundation — Types, routes, store, feature flag

Files:

  • Modify: diva-admin/src/modules/report/types.ts
  • Modify: diva-admin/src/modules/report/module.ts
  • Modify: diva-admin/src/modules/report/i18n/vi.ts
  • Create: diva-admin/src/modules/report/types/prepaid-analytics.types.ts
  • Create: diva-admin/src/modules/report/stores/usePrepaidAnalyticsStore.ts
  • Create: diva-admin/src/modules/report/pages/prepaid-analytics/PrepaidCardAnalytics.tsx
  • [ ] Step 1: Add route constants to types.ts

Add to diva-admin/src/modules/report/types.ts:

// Prepaid Card Analytics V2 — follow codebase convention: underscores in URL slugs
export const ROUTE_PREPAID_CARD_ANALYTICS = `${ROUTE_REPORT_LIST}/prepaidCard_analytics_group`;
export const ROUTE_PREPAID_ANALYTICS_OVERVIEW = `${ROUTE_PREPAID_CARD_ANALYTICS}/overview`;
export const ROUTE_PREPAID_ANALYTICS_TRANSACTIONS = `${ROUTE_PREPAID_CARD_ANALYTICS}/transactions`;
export const ROUTE_PREPAID_ANALYTICS_CUSTOMERS = `${ROUTE_PREPAID_CARD_ANALYTICS}/customers`;
export const ROUTE_PREPAID_ANALYTICS_FINANCE = `${ROUTE_PREPAID_CARD_ANALYTICS}/finance`;
// ⚠️ Phase 3+ defer (KHÔNG khai báo trong P1):
// export const ROUTE_PREPAID_ANALYTICS_MARKETING = `${ROUTE_PREPAID_CARD_ANALYTICS}/marketing`;
// export const ROUTE_PREPAID_ANALYTICS_STAFF = `${ROUTE_PREPAID_CARD_ANALYTICS}/staff`;

// Permission key
export const PREPAID_CARD_ANALYTICS = "prepaidCard_analytics";

// Add to REPORT_TREE — coexists with existing PREPAID_CARD_REPORT_GROUP for feature flag toggle.
// Phase 1 chỉ register 4 routes P1:
REPORT_TREE[PREPAID_CARD_ANALYTICS] = [
  ROUTE_PREPAID_ANALYTICS_OVERVIEW,
  ROUTE_PREPAID_ANALYTICS_TRANSACTIONS,
  ROUTE_PREPAID_ANALYTICS_CUSTOMERS,
  ROUTE_PREPAID_ANALYTICS_FINANCE,
  // Phase 3+: thêm ROUTE_..._MARKETING và ROUTE_..._STAFF khi build
];
  • [ ] Step 2: Create TypeScript types

File: diva-admin/src/modules/report/types/prepaid-analytics.types.ts

typescript
// ⚠️ KHÔNG khai báo CompareMode (DEC-U04 đã bỏ Compare Mode toggle)

// Filter state — chỉ 3 element (DEC-U04 + DEC-U05 + DEC-U12)
export interface PrepaidAnalyticsFilter {
  branchIds: string[];
  from: string;        // ISO date
  to: string;          // ISO date
  q?: string;          // Single search source (DEC-U12) — apply theo context sub-tab active.
                       // Sub-tab Giao dịch:   _or trên order_code, customer.display_name, customer.phone_search, prepaid_card.code
                       // Sub-tab Khách hàng:  _or trên customer.display_name, customer.phone_search
                       // Sub-tab Tài chính:   _or trên order_code, customer.display_name, customer.phone_search
                       // Sub-tab Tổng quan:   không apply (highlight metric — defer Phase 3+)
                       // KHÔNG còn local search ở sub-tab — bỏ field `search` cũ.
}

// ⚠️ DEC-U12: KHÔNG được tạo store/composable local searchKeyword riêng cho từng sub-tab.
// `q` ở `PrepaidAnalyticsFilter` (shared store) là single source. Mọi GraphQL query của sub-tab
// có table phải nhận `q` từ shared composable `usePrepaidAnalyticsFilter().filter.q`.

// KPI card data
export interface KpiCardData {
  label: string;
  value: number;
  previousValue?: number;
  changePercent?: number;
  tooltip: string;
  navigateTo?: string;
  severity?: "normal" | "warning" | "critical";
}

// Alert item
export interface PrepaidAlert {
  severity: "critical" | "warning" | "info";
  alertType: string;
  branchId?: string;
  branchName?: string;
  regionId?: string;
  title: string;
  detailCount: number;
  detailAmount: number;
}

// Customer segment
export type CustomerSegment = "active" | "dormant" | "at_risk" | "new" | "inactive";

// Global search result
export interface SearchResult {
  resultType: "customer" | "order" | "staff";
  id: string;
  displayText: string;
  subtitle: string;
  branchName: string;
}

// Keyset cursor for pagination
export interface KeysetCursor {
  paidAt: string;
  id: string;
}

// Export job
export interface ExportJob {
  id: string;
  reportType: string;
  status: "pending" | "processing" | "completed" | "failed";
  progress: number;
  totalRows: number;
  processedRows: number;
  fileUrl?: string;
}

// Sub-tab keys — Phase 1 chỉ 4 tabs (Marketing/Staff defer P3+ không khai báo)
export type PrepaidAnalyticsTab =
  | "overview"
  | "transactions"
  | "customers"
  | "finance";
  • [ ] Step 3: Create Pinia store

File: diva-admin/src/modules/report/stores/usePrepaidAnalyticsStore.ts

typescript
import { defineStore } from "pinia";
import type {
  PrepaidAnalyticsFilter,
  PrepaidAnalyticsTab,
} from "../types/prepaid-analytics.types";

// ⚠️ KHÔNG import CompareMode (DEC-U04 — đã bỏ)

interface PrepaidAnalyticsState {
  filter: PrepaidAnalyticsFilter;
  activeTab: PrepaidAnalyticsTab;
  lastRefreshTime: string | null;
}

const getDefaultFilter = (): PrepaidAnalyticsFilter => {
  const now = new Date();
  const from = new Date(now.getFullYear(), now.getMonth(), 1);
  return {
    branchIds: [],
    from: from.toISOString().slice(0, 10),
    to: now.toISOString().slice(0, 10),
    // KHÔNG có `mode` field (DEC-U04 bỏ Compare Mode)
  };
};

export const usePrepaidAnalyticsStore = defineStore("prepaidAnalytics", {
  state: (): PrepaidAnalyticsState => ({
    filter: getDefaultFilter(),
    activeTab: "overview",
    lastRefreshTime: null,
  }),
  getters: {
    branchIdsArray: (state) =>
      state.filter.branchIds.length > 0 ? state.filter.branchIds : null,
    // ⚠️ KHÔNG có isCompareMode / isRegionCompare / isBranchCompare (DEC-U04)
  },
  actions: {
    setFilter(filter: Partial<PrepaidAnalyticsFilter>) {
      this.filter = { ...this.filter, ...filter };
    },
    setActiveTab(tab: PrepaidAnalyticsTab) {
      this.activeTab = tab;
    },
    setLastRefreshTime(time: string) {
      this.lastRefreshTime = time;
    },
    resetFilter() {
      this.filter = getDefaultFilter();
    },
  },
});
  • [ ] Step 4: Add routes to module.ts

Find diva-admin/src/modules/report/module.ts. Add inside the route children array:

// Feature flag — toggle between old tab and new analytics tab
const FEATURE_PREPAID_ANALYTICS_V2 = true;

// In route config children:
// NOTE: extractSubRoute requires 2 args: (routeName, prefix)
// Following exact pattern from existing routes in module.ts
...(FEATURE_PREPAID_ANALYTICS_V2
  ? [
      {
        // extractSubRoute(ROUTE_PREPAID_CARD_ANALYTICS, ROUTE_REPORT_LIST)
        // removes the "/r/reports/" prefix, leaving "prepaidCard_analytics_group"
        path: extractSubRoute(ROUTE_PREPAID_CARD_ANALYTICS, ROUTE_REPORT_LIST),
        component: () =>
          import("./pages/prepaid-analytics/PrepaidCardAnalytics"),
        meta: { moduleId: "report_management", permissions: [] },
      },
    ]
  : []),

No child routes needed. Following ServiceReport.tsx pattern, sub-tabs are rendered via QTabPanels inside the parent component — NOT via Vue Router children. This avoids the need for nested route definitions and matches the codebase convention.

When FEATURE_PREPAID_ANALYTICS_V2 = false, the old route ROUTE_PREPAID_CARD_REPORT_GROUP remains active. When true, add the new route and optionally hide the old one from the sidebar.

  • [ ] Step 5: Add i18n translations

Add to diva-admin/src/modules/report/i18n/vi.ts:

// Prepaid Analytics V2
[ROUTE_PREPAID_CARD_ANALYTICS]: "Phân tích thẻ trả trước",
"prepaid_analytics.overview": "Tổng quan",
"prepaid_analytics.transactions": "Giao dịch",
"prepaid_analytics.customers": "Khách hàng",
"prepaid_analytics.finance": "Tài chính",
"prepaid_analytics.marketing": "Marketing",
"prepaid_analytics.staff": "Nhân viên",
// ... (full list in design spec Section 12.4)
  • [ ] Step 6: Create parent page component

File: diva-admin/src/modules/report/pages/prepaid-analytics/PrepaidCardAnalytics.tsx

Pattern: Follow ServiceReport.tsx exactly — XTabs + QTabPanels with direct component rendering (NOT router-view). Sub-tab components dynamically imported inside QTabPanel.

import { defineComponent, computed, ref, defineAsyncComponent } from "vue";
import { useI18n } from "vue-i18n";
import { XTabs } from "@/components/core";
import useGlobalStore from "@/stores/useGlobalStore";
import PrepaidAnalyticsFilter from "../../components/prepaid-analytics/shared/PrepaidAnalyticsFilter";
import { PREPAID_CARD_ANALYTICS } from "../../types";
import { usePrepaidAnalyticsStore } from "../../stores/usePrepaidAnalyticsStore";

// Lazy-loaded sub-tab components (only loads when tab is selected)
const PrepaidAnalyticsOverview = defineAsyncComponent(() => import("./PrepaidAnalyticsOverview"));
const PrepaidAnalyticsTransactions = defineAsyncComponent(() => import("./PrepaidAnalyticsTransactions"));
const PrepaidAnalyticsCustomers = defineAsyncComponent(() => import("./PrepaidAnalyticsCustomers"));
const PrepaidAnalyticsFinance = defineAsyncComponent(() => import("./PrepaidAnalyticsFinance"));
const PrepaidAnalyticsMarketing = defineAsyncComponent(() => import("./PrepaidAnalyticsMarketing"));
const PrepaidAnalyticsStaff = defineAsyncComponent(() => import("./PrepaidAnalyticsStaff"));

export default defineComponent({
  name: "PrepaidCardAnalytics",
  setup() {
    const i18n = useI18n();
    const globalStore = useGlobalStore();
    const store = usePrepaidAnalyticsStore();
    const activeTab = ref("overview");

    // Tab config — follows ServiceReport.tsx pattern
    const tabs = computed(() => ({
      overview: i18n.t("prepaid_analytics.overview"),
      transactions: i18n.t("prepaid_analytics.transactions"),
      customers: i18n.t("prepaid_analytics.customers"),
      finance: i18n.t("prepaid_analytics.finance"),
      marketing: i18n.t("prepaid_analytics.marketing"),
      staff: i18n.t("prepaid_analytics.staff"),
    }));

    // Filter by reportRoles — exact pattern from ServiceReport.tsx lines 64-73
    const allowedTabs = computed(() => {
      const roles = globalStore?.reportRoles?.map((e) => e.report_id) || [];
      return Object.keys(tabs.value).filter(
        (tab) => roles.includes(PREPAID_CARD_ANALYTICS)
      );
    });

    return () => (
      <div class="prepaid-card-analytics">
        {/* Shared filter bar — sticky */}
        <PrepaidAnalyticsFilter />

        {/* Sub-tab bar — XTabs matching ServiceReport pattern */}
        <XTabs v-model={activeTab.value} class="q-mt-sm" dense activeBgColor="primary">
          {allowedTabs.value.map((key) => (
            <q-tab key={key} name={key} label={tabs.value[key as keyof typeof tabs.value]} />
          ))}
        </XTabs>

        {/* Tab content — QTabPanels matching ServiceReport pattern */}
        <q-tab-panels v-model={activeTab.value} animated keep-alive>
          <q-tab-panel name="overview"><PrepaidAnalyticsOverview /></q-tab-panel>
          <q-tab-panel name="transactions"><PrepaidAnalyticsTransactions /></q-tab-panel>
          <q-tab-panel name="customers"><PrepaidAnalyticsCustomers /></q-tab-panel>
          <q-tab-panel name="finance"><PrepaidAnalyticsFinance /></q-tab-panel>
          <q-tab-panel name="marketing"><PrepaidAnalyticsMarketing /></q-tab-panel>
          <q-tab-panel name="staff"><PrepaidAnalyticsStaff /></q-tab-panel>
        </q-tab-panels>
      </div>
    );
  },
});
  • [ ] Step 7: Create placeholder sub-tab pages

Create 6 placeholder files in pages/prepaid-analytics/:

// PrepaidAnalyticsOverview.tsx (and similarly for each sub-tab)
import { defineComponent } from "vue";

export default defineComponent({
  name: "PrepaidAnalyticsOverview",
  setup() {
    return () => (
      <div class="q-pa-md">
        <div class="text-h6">Tổng quan</div>
        <div class="text-grey">Coming soon...</div>
      </div>
    );
  },
});

Repeat for Phase 1 sub-tabs only: PrepaidAnalyticsTransactions.tsx, PrepaidAnalyticsCustomers.tsx, PrepaidAnalyticsFinance.tsx. KHÔNG tạo PrepaidAnalyticsMarketing.tsxPrepaidAnalyticsStaff.tsx (P3+ defer).

  • [ ] Step 8: Verify route loads in browser
cd diva-group/diva-admin
npm run dev
# Navigate to http://localhost:9000/r/reports/prepaid-card-analytics
# Should see: filter bar 3 element + 4 tabs (Tổng quan / Giao dịch / Khách hàng / Tài chính) + "Tổng quan" content
# Click each tab — should lazy-load và show placeholder
# Marketing và Nhân viên KHÔNG có trong tab bar (P3+ defer)
  • [ ] Step 9: Commit
git add src/modules/report/types.ts src/modules/report/module.ts src/modules/report/i18n/vi.ts
git add src/modules/report/types/prepaid-analytics.types.ts
git add src/modules/report/stores/usePrepaidAnalyticsStore.ts
git add src/modules/report/pages/prepaid-analytics/
git commit -m "feat(prepaid-analytics): add route structure, types, store, and parent page with 4 P1 sub-tabs"

Chunk 2: Phase 1 Frontend — Shared Filter + Overview + Transactions + Customers + Finance + Export

Task 8: Shared filter components

Files (Phase 1 — chỉ 4 files):

  • Create: components/prepaid-analytics/shared/PrepaidAnalyticsFilter.tsx
  • Create: components/prepaid-analytics/shared/BranchSelector.tsx
  • Create: components/prepaid-analytics/shared/DateRangePicker.tsx (gộp presets + custom range)
  • Create: composables/usePrepaidAnalyticsFilter.ts

🚫 KHÔNG tạo (DEC-U04): CompareModeToggle.tsx, usePrepaidCompareMode.ts — Compare Mode đã bỏ.

Reference: UI spec Section 2 for exact layout and behavior.

  • [ ] Step 1: Create usePrepaidAnalyticsFilter composable

File: diva-admin/src/modules/report/composables/usePrepaidAnalyticsFilter.ts

Key responsibilities:

  • Sync filter state between Pinia store and URL query params (?branch=uuid1,uuid2&from=2026-03-01&to=2026-03-13&preset=this_month)
  • KHÔNG có mode= param (DEC-U04 bỏ Compare Mode)
  • Use useRoute() and useRouter() for URL sync
  • Provide watchEffect that updates store when URL changes and vice versa
  • Expose: filter (reactive), setFilter(), resetFilter(), branchIdsArray (computed for GraphQL)
  • previousPeriod computed — implement PRD A10 FORMULA-019 Period Comparison rule:
typescript
import { addDays, daysBetween, startOfPreviousMonth, addMonths, startOfMonth, endOfMonth } from '@/utils/date'

const previousPeriod = computed(() => {
  const { from, to, presetKey } = filter.value
  const fromDate = new Date(from)
  const toDate = new Date(to)
  const N = daysBetween(fromDate, toDate) + 1  // inclusive

  // Special: this_month → cùng độ dài, tháng trước
  if (presetKey === 'this_month') {
    const prevStart = startOfPreviousMonth(fromDate)
    return { from: formatDate(prevStart), to: formatDate(addDays(prevStart, N - 1)) }
  }
  // Special: last_month → full tháng trước nữa
  if (presetKey === 'last_month') {
    const prevPrev = addMonths(fromDate, -1)
    return { from: formatDate(startOfMonth(prevPrev)), to: formatDate(endOfMonth(prevPrev)) }
  }
  // Special: this_quarter / last_quarter — same pattern
  if (presetKey === 'this_quarter') { /* sliding from prev quarter start */ }
  if (presetKey === 'last_quarter') { /* full prev-prev quarter */ }

  // Default: sliding N ngày liền trước (today/yesterday/last_7d/last_30d/custom)
  return {
    from: formatDate(addDays(fromDate, -N)),
    to: formatDate(addDays(fromDate, -1))
  }
})
  • trendLabel computed — dynamic theo presetKey (DEC-U08 + FORMULA-019):
    • today"so với hôm qua"
    • last_7d"so với 7 ngày trước"
    • this_month"so với tháng trước"
    • last_month"so với tháng trước nữa"
    • this_quarter"so với quý trước"
    • custom"so với kỳ trước (${N} ngày)"
  • [ ] Step 2: Create BranchSelector component

File: components/prepaid-analytics/shared/BranchSelector.tsx

Key details:

  • QSelect with multiple, use-chips, option-group (via slots)
  • Query region_branchbranches[] to build grouped options
  • Client-side search filter (70 items — no need for server search)
  • Click region header → select/deselect all branches in region
  • Badge: “3 CN” or “HCM (25)” or “Tất cả”
  • Max 10 branches when compare mode = “branch” → disable extras + QTooltip warning
  • Emit update:modelValue with string[] of branch UUIDs
  • [ ] Step 3: Create DateRangePicker component (UPDATED — gộp presets vào dropdown, ref: DEC-U05)

File: components/prepaid-analytics/shared/DateRangePicker.tsx

Key details:

  • QSelect dropdown chứa CẢ presets + custom range (KHÔNG tách button rời)

  • Presets: Hôm nay · Hôm qua · 7 ngày qua · 30 ngày qua · Tháng này · Tháng trước · Quý này · "Tùy chọn..."

  • "Tùy chọn..." → mở QDate range calendar

  • Max range: 365 days

  • Default: "Tháng này"

  • Emit update:modelValue with { from: string, to: string, presetKey?: string }

  • [ ] Step 4: Create CompareModeToggle component — 🚫 SKIPPED (DEC-U04 bỏ Compare Mode)

Bỏ qua step này. Filter bar chỉ còn 3 element (Branch · Date · Search). Đi thẳng sang Step 5.

  • [ ] Step 5: Create PrepaidAnalyticsFilter parent (UPDATED — 3 element)

File: components/prepaid-analytics/shared/PrepaidAnalyticsFilter.tsx

Compose: BranchSelector + DateRangePicker + search input (P1) / PrepaidAnalyticsGlobalSearch placeholder (P2).

Layout per UI spec Section 2.1:

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

Sticky behavior: position: sticky, top calculated below app header, z-index: 10.

  • [ ] Step 6: Create usePrepaidCompareMode composable — 🚫 SKIPPED (DEC-U04)

Bỏ qua step này. Không có compare mode → không cần composable này. Bỏ luôn import từ tất cả sub-tab components.

  • [ ] Step 7: Verify filter in browser

Navigate to /r/reports/prepaid-card-analytics. Filter bar should:

  • Show branch selector with grouped regions

  • Show date range dropdown gộp presets + custom range

  • Show search input

  • URL update when filters change (?branch=&from=&to=)

  • [ ] Step 8: Commit

git add src/modules/report/composables/usePrepaidAnalyticsFilter.ts
git add src/modules/report/components/prepaid-analytics/shared/PrepaidAnalyticsFilter.tsx
git add src/modules/report/components/prepaid-analytics/shared/BranchSelector.tsx
git add src/modules/report/components/prepaid-analytics/shared/DateRangePicker.tsx
git commit -m "feat(prepaid-analytics): add shared filter bar 3-element (branch + date range + search)"

Task 9: GraphQL queries (Phase 1)

Files:

  • Create: diva-admin/src/modules/report/graphql/prepaid_analytics.graphql

Reference: Design spec Section 11.3 for full query list.

🔒 PROVISIONAL — Wallet split aliases (V9-V12 BLOCKER, Review L8)

Theo PRD A10.0 Canonical Wallet Table, sum cards "Ví Diva" / "Ví KM" cần 4 alias riêng trên MV — KHÔNG được derive bằng phép trừ ở FE:

AliasSource MV column (chờ V9-V12)FORMULA
total_vi_diva_nappedmv_prepaid_order_daily.total_vi_diva_napped (CHƯA có — cần thêm sau V9)FORMULA-003
total_vi_km_nappedmv_prepaid_order_daily.total_vi_km_napped (CHƯA có — cần thêm sau V10)FORMULA-004
total_vi_diva_usedmv_prepaid_finance_daily filter payment_method_id='wallet' (chờ V11)FORMULA-005
total_vi_km_usedmv_prepaid_finance_daily filter payment_method_id='wallet_promotion' (chờ V12)FORMULA-005b

Trước khi V9-V12 confirmed: GraphQL queries dưới đây CHỈ dùng total_collected + total_wallet_topup (đã khóa). FE hiển thị Ví Diva / Ví KM = "—" (chờ schema). KHÔNG được derive vi_diva = total_collected hoặc vi_km = total_wallet_topup − total_collected ở client (Review L8 chứng minh SAI).

Sau khi V9-V12 confirmed: Add 4 alias trên vào MV migration → re-track Hasura → update queries dưới đây để select 4 alias.

  • [ ] Step 1: Write GraphQL queries for Overview sub-tab
# KPI overview — aggregate from mv_prepaid_order_daily
query PrepaidAnalyticsOverview(
  $where: mv_prepaid_order_daily_bool_exp!
  $wherePrev: mv_prepaid_order_daily_bool_exp!
) {
  current: mv_prepaid_order_daily_aggregate(where: $where) {
    aggregate {
      sum {
        total_collected
        total_wallet_topup
        sold_quantity
        unique_customers
        new_customers
        order_count
      }
    }
  }
  previous: mv_prepaid_order_daily_aggregate(where: $wherePrev) {
    aggregate {
      sum {
        total_collected
        total_wallet_topup
        sold_quantity
        unique_customers
        new_customers
        order_count
      }
    }
  }
}

# Alerts
query PrepaidAnalyticsAlerts($args: compute_prepaid_alerts_args!) {
  compute_prepaid_alerts(args: $args) {
    severity
    alert_type
    branch_id
    branch_name
    region_id
    title
    detail_count
    detail_amount
  }
}

# Wallet balance aggregate — from wallet DB, Redis cached (design spec Section 9.1; exact split fields chờ V5)
query GetWalletBalanceAggregate($customerIds: [String!]) {
  wallet_balance_aggregate(where: { user_id: { _in: $customerIds } }) {
    aggregate {
      sum { wallet_balance_diva wallet_balance_km balance }
    }
  }
}

# Overview charts — time-series data (daily granularity)
query PrepaidAnalyticsOverviewTimeSeries(
  $where: mv_prepaid_order_daily_bool_exp!
  $order_by: [mv_prepaid_order_daily_order_by!]
) {
  mv_prepaid_order_daily(where: $where, order_by: $order_by) {
    report_date
    total_collected
    total_wallet_topup
    sold_quantity
    unique_customers
    region_id
    branch_id
  }
}

# Rankings (top 5 cards, staff, customers) — Phase 1
# 🚦 IMPORTANT: top_staff KHÔNG dùng mv_prepaid_staff_stats (P3+ defer).
# Compute trực tiếp từ order_commission + invoice — query nhẹ vì chỉ trả 5 dòng.
query PrepaidAnalyticsRankings(
  $cardWhere: mv_prepaid_card_daily_bool_exp!
  $orderWhere: order_bool_exp!
  $customerWhere: mv_prepaid_customer_stats_bool_exp!
) {
  # Top 5 thẻ bán chạy — query từ mv_prepaid_card_daily (group by product_id)
  # KHÔNG query từ order_daily vì MV này không có card_name/product_id
  top_cards: mv_prepaid_card_daily(
    where: $cardWhere
    order_by: { sold_quantity: desc }
    limit: 5
  ) {
    card_name
    product_id
    sold_quantity
    total_wallet_topup
  }
  # top_staff — compute trực tiếp từ order + order_commission (KHÔNG dùng MV staff_stats P3+)
  # Chỉ trả top 5, query nhẹ với index trên order(branch_id, paid_at)
  top_staff: order_commission(
    where: { order: $orderWhere }
    order_by: { order_aggregate: { sum: { invoice_amount: desc } } }
    limit: 5
    distinct_on: [user_id]
  ) {
    user { id, display_name }
    order { branch { name } }
    # Aggregate từ child invoices ở FE side hoặc dùng action handler tổng hợp
  }
  top_customers: mv_prepaid_customer_stats(
    where: $customerWhere
    order_by: { total_paid: desc }
    limit: 5
  ) {
    display_name
    total_paid
    order_count
  }
}

Nếu Hasura aggregate qua nested object không support được, tạo Hasura action handler prepaid_top_staff_p1 trả top 5 staff (REST endpoint trong service restful-api hoặc ecommerce-api). Query path: aggregate order_commission JOIN invoice filter prepaid + branch + date range, group by user_id, ORDER BY SUM(amount) DESC LIMIT 5. Cost rất rẻ — query trên 1-2 tháng prepaid orders + index (branch_id, paid_at).

  • [ ] Step 2: Write GraphQL queries for Transactions sub-tab
# Transaction list — keyset pagination
query PrepaidAnalyticsTransactions(
  $where: order_bool_exp!
  $limit: Int!
  $cursor: order_bool_exp
  $order_by: [order_order_by!]
) {
  order(
    where: { _and: [$where, $cursor] }
    limit: $limit
    order_by: $order_by
  ) {
    id
    code
    paid_at
    customer { display_name, phone_number }
    order_items(where: { product_id: { _is_null: false } }) {
      prepaid_card { name, flexible }
      quantity
      price
      prepaid_value_into_wallet
    }
    invoices(where: { canceled_at: { _is_null: true } }) {
      amount
      payment_method_id
    }
    total
    paid_amount
    branch { name }
    order_commissions { user { display_name } }
  }
}

# Transaction detail (expand row — lazy loaded)
query PrepaidAnalyticsTransactionDetail($orderId: uuid!) {
  invoices: invoice(where: { order_id: { _eq: $orderId }, canceled_at: { _is_null: true } }) {
    payment_method_id
    amount
    paid_at
    canceled_at
  }
  # Wallet usage history for this customer's order
  # Commission details
  order_commissions: order_commission(where: { order_id: { _eq: $orderId } }) {
    user { display_name }
    # Join to transaction_request for commission amount
  }
}
  • [ ] Step 3: Write GraphQL queries for Finance sub-tab
# Finance KPIs
# ⚠️ Update review L4: total_wallet_topup KHÔNG có trong mv_prepaid_finance_daily nữa
# (đã bỏ vì gây double count). Lấy total_wallet_topup từ mv_prepaid_order_daily riêng.
query PrepaidAnalyticsFinanceSummary(
  $where: mv_prepaid_finance_daily_bool_exp!
  $wherePrev: mv_prepaid_finance_daily_bool_exp!
  $orderWhere: mv_prepaid_order_daily_bool_exp!
  $orderWherePrev: mv_prepaid_order_daily_bool_exp!
) {
  # Phương thức thanh toán split — từ finance MV
  current_pm: mv_prepaid_finance_daily_aggregate(where: $where) {
    aggregate {
      sum {
        paid_amount
        wallet_amount
        promo_amount
        order_count
      }
    }
  }
  previous_pm: mv_prepaid_finance_daily_aggregate(where: $wherePrev) {
    aggregate {
      sum {
        paid_amount
        wallet_amount
        promo_amount
        order_count
      }
    }
  }
  # Tổng thu / Nạp ví — từ order MV (đúng aggregation cấp đơn)
  current_order: mv_prepaid_order_daily_aggregate(where: $orderWhere) {
    aggregate {
      sum {
        total_collected
        total_wallet_topup
      }
    }
  }
  previous_order: mv_prepaid_order_daily_aggregate(where: $orderWherePrev) {
    aggregate {
      sum {
        total_collected
        total_wallet_topup
      }
    }
  }
}

# Revenue by date
query PrepaidAnalyticsRevenueByDate(
  $where: mv_prepaid_order_daily_bool_exp!
  $order_by: [mv_prepaid_order_daily_order_by!]
) {
  mv_prepaid_order_daily(where: $where, order_by: $order_by) {
    report_date
    total_collected
    total_wallet_topup
    order_count
  }
}

# Debt list — keyset pagination (consistent with design spec Section 9.4)
query PrepaidAnalyticsDebtList(
  $where: order_bool_exp!
  $limit: Int!
  $cursor: order_bool_exp
) {
  order(
    where: { _and: [$where, $cursor] }
    limit: $limit
    order_by: [{ created_at: desc }, { id: desc }]
  ) {
    id
    code
    created_at
    customer { display_name, phone_number }
    total
    paid_amount
    branch { name }
    order_commissions { user { display_name } }
  }
  order_aggregate(where: $where) {
    aggregate {
      count
      sum { total, paid_amount }
    }
  }
}

# Payment methods
query PrepaidAnalyticsPaymentMethods($where: mv_prepaid_finance_daily_bool_exp!) {
  mv_prepaid_finance_daily(where: $where) {
    payment_method_id
    paid_amount
    order_count
  }
}

# Commission summary by staff (Tab Tài chính > Hoa hồng)
# 🚦 KHÔNG dùng mv_prepaid_staff_stats (P3+ defer). Compute trực tiếp từ order_commission + transaction_request.
query PrepaidAnalyticsCommissions(
  $orderWhere: order_bool_exp!
  $limit: Int!
  $offset: Int!
) {
  # Aggregate order_commission filter prepaid + branch + date range
  # Group by user_id, ORDER BY SUM(commission) DESC
  order_commission(
    where: { order: $orderWhere }
    distinct_on: [user_id]
    limit: $limit
    offset: $offset
  ) {
    user { id, display_name }
    order { branch { name } }
    # Aggregate ở FE side hoặc dùng action handler `prepaid_commission_summary` (REST endpoint)
  }
}

# Khuyến nghị: tạo Hasura action handler `prepaid_commission_summary` trả structure đầy đủ
# (staff_name, branch_name, order_count, revenue, commission) tránh aggregate phức tạp ở FE.
# Cost rất rẻ — query trên 1-2 tháng prepaid orders + index (branch_id, paid_at).
  • [ ] Step 4: Run GraphQL codegen
cd diva-group/diva-admin
npm run codegen  # or the project's specific codegen command

Expected: New query hooks generated in src/api/graphql/generated/controller-types.ts.

  • [ ] Step 5: Commit
git add src/modules/report/graphql/prepaid_analytics.graphql
git add src/api/graphql/generated/
git commit -m "feat(prepaid-analytics): add GraphQL queries for Phase 1 (overview, transactions, finance)"

Task 10: Shared KPI Card component

Files:

  • Create: components/prepaid-analytics/shared/KpiCard.tsx
  • Create: components/prepaid-analytics/shared/KpiCardGrid.tsx
  • [ ] Step 1: Create reusable KpiCard component

Per UI spec Section 3.1. Props:

  • label: string, value: number, previousValue?: number, tooltip: string
  • severity?: 'normal' | 'warning' | 'critical'
  • navigateTo?: string (route for click action)
  • subtitle?: string (e.g., “(Nợ phải trả)” for Tổng dư ví)

Features:

  • Format VND with formatCurrency() (existing util)
  • Calculate changePercent from current vs previous
  • Arrow icon: green up / red down
  • Border-left color by group
  • Tooltip on ℹ️ icon (QTooltip, max-width 320px, content from Section 16.2-16.5)
  • Click → router.push(navigateTo)
  • Loading state: QSkeleton rect
  • [ ] Step 2: Create KpiCardGrid layout

Renders N KpiCard components in responsive grid:

  • Desktop: 4 per row
  • Tablet: 2 per row
  • Mobile: 1 per row

Uses Quasar row/col classes.

  • [ ] Step 3: Commit
git add src/modules/report/components/prepaid-analytics/shared/KpiCard.tsx
git add src/modules/report/components/prepaid-analytics/shared/KpiCardGrid.tsx
git commit -m "feat(prepaid-analytics): add reusable KpiCard and KpiCardGrid components"

Task 11: Overview sub-tab implementation

Files:

  • Modify: pages/prepaid-analytics/PrepaidAnalyticsOverview.tsx
  • Create: components/prepaid-analytics/overview/OverviewKpiCards.tsx
  • Create: components/prepaid-analytics/overview/OverviewCharts.tsx
  • Create: components/prepaid-analytics/overview/OverviewAlerts.tsx
  • Create: components/prepaid-analytics/overview/OverviewRankings.tsx
  • Create: components/prepaid-analytics/overview/OverviewCustomerFlow.tsx

Reference: UI spec Section 3 for layout. Design spec Section 3 for data formulas. Design spec Section 16.2 for tooltip text.

  • [ ] Step 1: Implement OverviewKpiCards

8 KPI cards in 2 rows of 4. Use KpiCardGrid + KpiCard.

  • Row 1 (Finance): Tiền thu vào, DT ghi nhận, Tổng dư ví, Công nợ chưa thu
  • Row 2 (Operations): Thẻ đã bán (kèm sub-breakdown “X Cố định + Y Linh hoạt” font 12px gray-6, tách theo prepaid_card.flexible), KH đã dùng ví, Tỷ lệ tái nạp, KH mới
  • Data from usePrepaidAnalyticsOverviewQuery (current + previous period)
  • Wallet balance from separate query (Redis-cached)
  • Tooltips: verbatim from design spec Section 16.2
  • Compare mode: Tổng hợp (1 set), So sánh KV (mini-table per card), So sánh CN (sparkline)
  • [ ] Step 2: Implement OverviewCharts

4 charts in 2×2 grid. Use vue-chart-3 components.

  • Chart 1: Line chart — revenue over time + dashed previous period
  • Chart 2: Grouped bar — revenue by region
  • Chart 3: Donut — card denomination distribution. Segments = mệnh giá 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 tooltip: “Nạp linh hoạt: {count} thẻ, TB {avg}đ, khoảng {min}–{max}đ”
  • Chart 4: Area — KH đã dùng ví / % Đã xài rate trend
  • Time-series charts luôn group theo ngày — KHÔNG implement toggle Ngày/Tuần/Tháng. Range follow shared filter
  • Chart.js config: Vietnamese locale, VND format, Quasar color palette
  • Loading: QSkeleton rect 300×200px
  • [ ] Step 3: Implement OverviewAlerts

Alert box with 3 severity levels. Uses compute_prepaid_alerts function.

  • QExpansionItem or custom collapsible
  • Auto-expand if critical alerts present
  • [Xem] button → navigate to relevant sub-tab with filter
  • Empty state: “Không có cảnh báo — mọi thứ đang tốt!”
  • Colors: $negative, $warning, $info
  • [ ] Step 4: Implement OverviewRankings

3 mini-tables side by side. Each QCard with compact QTable (5 rows).

  • Top 5 cards sold, Top 5 staff, Top 5 VIP customers
  • [Xem thêm →] links to respective sub-tabs
  • Click name → navigate to detail
  • [ ] Step 5: Implement OverviewCustomerFlow — 🚫 SKIPPED (DEC-U04 bỏ Compare Mode + branch_flow MV)

KHÔNG implement. Section "Dòng chảy khách hàng" đã xóa khỏi spec. MV mv_prepaid_branch_flow cũng đã loại bỏ.

  • [ ] Step 6: Wire up PrepaidAnalyticsOverview

Replace placeholder. Compose 4 components vertically (Customer Flow đã xóa — DEC-U04):

KPI Cards → Charts → Alerts → Rankings
  • [ ] Step 7: Verify in browser

Navigate to overview tab. Should see:

  • 8 KPI cards with real/mock data
  • KPI “Thẻ đã bán”: verify sub-breakdown “X Cố định + Y Linh hoạt” hiển thị dưới giá trị chính (TC-FLEX-08)
  • 4 charts rendering
  • Donut chart “Phân bố mệnh giá”: verify có segment “Nạp linh hoạt” riêng (màu tím), hover hiện tooltip count + avg + range (TC-FLEX-09)
  • Alert box (possibly empty)
  • 3 mini ranking tables
  • Filter changes → all components re-render
  • [ ] Step 8: Commit
git add src/modules/report/components/prepaid-analytics/overview/
git add src/modules/report/pages/prepaid-analytics/PrepaidAnalyticsOverview.tsx
git commit -m "feat(prepaid-analytics): implement Overview sub-tab with KPIs, charts, alerts, rankings"

Task 12: Transactions sub-tab implementation

Files:

  • Modify: pages/prepaid-analytics/PrepaidAnalyticsTransactions.tsx
  • Create: components/prepaid-analytics/transactions/TransactionLocalFilter.tsx
  • Create: components/prepaid-analytics/transactions/TransactionSummaryBar.tsx
  • Create: components/prepaid-analytics/transactions/TransactionTable.tsx
  • Create: components/prepaid-analytics/transactions/TransactionExpandedRow.tsx

Reference: UI spec Section 4. Design spec Section 4 + 16.6.

  • [ ] Step 1: Implement TransactionLocalFilter (DEC-U12 — 2 element only)

Local filter bar (not shared — specific to transactions sub-tab). DEC-U12: BỎ local search input — search dùng từ shared filter usePrepaidAnalyticsFilter().filter.q.

  • Loại thẻ: QSelect (Tất cả / Cố định / Linh hoạt)
  • Trạng thái TT: QSelect (Tất cả / Đã TT đủ / Còn nợ / Chưa TT)
  • NV bán: QSelect multi-select ❌ Removed (out of scope Phase 1 — cần thì sort cột bảng)
  • Mệnh giá: 2× QInput ❌ Removed (sort cột "Tiền thu" là đủ)
  • Local search: QInput ❌ Removed (DEC-U12 — dùng shared q)
  • Sync to URL: ?card_type=fixed&status=debt (q ở root URL: ?q=...)
  • GraphQL where clause include q từ shared filter:
    typescript
    const sharedFilter = usePrepaidAnalyticsFilter()
    const where = computed(() => {
      const w: any = { /* card_type, status, branch, date... */ }
      if (sharedFilter.filter.q && sharedFilter.filter.q.length >= 2) {
        w._or = [
          { code: { _ilike: `%${sharedFilter.filter.q}%` } },
          { customer: { display_name: { _ilike: `%${sharedFilter.filter.q}%` } } },
          { customer: { phone_search: { _ilike: `%${sharedFilter.filter.q}%` } } },
          { order_items: { prepaid_card: { code: { _ilike: `%${sharedFilter.filter.q}%` } } } },
        ]
      }
      return w
    })
  • [ ] Step 2: Implement TransactionSummaryBar

Summary bar reflecting current filters:

156 đơn | Thu: 850tr | Nạp ví: 920tr | Nợ: 45tr | HH: 38tr
  • Uses aggregate query with same filters
  • Compare mode: split by KV/CN
  • [ ] Step 3: Implement TransactionTable

XTable (project’s QTable wrapper) with 11 columns per design spec Section 16.6.

  • Columns: Mã đơn (link), Ngày TT (sortable), KH (link), Loại (QBadge: Cố định=$primary tint xanh, Linh hoạt=$purple-6 tím), Tên thẻ, SL, Tiền thu, Nạp ví, TT status (QBadge), NV bán (link), CN
  • Cột "Tên thẻ" khi flexible: flexible = true → hiển thị "Nạp linh hoạt {prepaid_value_into_wallet}đ" (KHÔNG × quantity — đã là line total). Nếu prepaid_card.name có giá trị → "{name} (linh hoạt {số tiền}đ)"
  • Keyset pagination: “Trang trước / Trang sau” (no total page count)
  • 20 rows per page (configurable 10/20/50)
  • Click row → expand (see next step)
  • Quick links: Mã đơn → /e/prepaid-order/:id, KH → CRM, NV → Staff tab
  • [ ] Step 4: Implement TransactionExpandedRow

2 tabs inside expanded row (QTabs within row) — DEC-T07:

  1. Chi tiết TT: invoice list (payment method, amount, date, status)
  2. Hoa hồng (DEC-T08): commission details (NV, amount, loại) — source invoice_commission only invoice_status='invoice_completed'. Refund row riêng từ transaction_request behavior_id='refund_commission' (amount âm). KHÔNG có cột "Trạng thái" (chỉ 1 status thực tế).

Both tabs lazy-loaded (query only when tab selected).

DEC-T07: Tab "Lịch sử sử dụng" đã bị xoá khỏi level đơn nạp. Wallet pool chung per-customer, transaction_request.order_id = đơn dịch vụ (không phải đơn thẻ nạp). Lịch sử biến động ví hiển thị ở Sub-tab Khách hàng.

  • [ ] Step 5: Wire up PrepaidAnalyticsTransactions

Compose: LocalFilter → SummaryBar → Table (with expanded rows).

  • [ ] Step 6: Verify in browser

Test: filter changes, table pagination, row expand, quick links.

  • Flexible card tests (xem design spec Section 17):

    • Filter “Loại thẻ” = “Linh hoạt” → verify summary bar chỉ tính flexible (TC-FLEX-01)
    • Verify tag màu: Cố định = xanh, Linh hoạt = tím (TC-FLEX-05)
    • Verify cột “Tên thẻ” hiển thị đúng “Nạp linh hoạt {số tiền}đ” cho flexible (TC-FLEX-06)
    • Export khi filter flexible → verify file Excel có cột “Loại” và format đúng (TC-FLEX-11, TC-FLEX-12)
  • [ ] Step 7: Commit

git add src/modules/report/components/prepaid-analytics/transactions/
git add src/modules/report/pages/prepaid-analytics/PrepaidAnalyticsTransactions.tsx
git commit -m "feat(prepaid-analytics): implement Transactions sub-tab with filters, table, expanded rows"

Task 13: Finance sub-tab implementation

Files:

  • Modify: pages/prepaid-analytics/PrepaidAnalyticsFinance.tsx
  • Create: components/prepaid-analytics/finance/FinanceKpiCards.tsx
  • Create: components/prepaid-analytics/finance/FinanceRevenue.tsx
  • Create: components/prepaid-analytics/finance/FinanceDebt.tsx
  • Create: components/prepaid-analytics/finance/FinanceCommission.tsx
  • Create: components/prepaid-analytics/finance/FinancePaymentMethods.tsx
  • Create: components/prepaid-analytics/finance/FinanceExportBar.tsx

Reference: UI spec Section 6. Design spec Section 6 + 16.3 + 16.8-16.11.

  • [ ] Step 1: Implement FinanceKpiCards

8 finance KPI cards (design spec Section 6.1). Use KpiCardGrid.

  • Row 1: Tiền thu vào, Nạp ví KH, Tổng dư ví ⏳ V5, Công nợ chưa thu
  • Row 2: DT ghi nhận ⏳ V11, Hoa hồng đã chi, KM đã nạp ⏳ V10, Lợi nhuận gộp ⏳ depends V10
  • 🔒 PROVISIONAL — DO NOT IMPLEMENT card "KM đã nạp" + "Lợi nhuận gộp" cho đến khi V10 unlock. FE scaffold render placeholder "—" cho 2 card này.
  • Critical formula (V10 canonical — chờ BE confirm trước khi enable):
    ✅ KM đã nạp = SUM(parent_invoice.wallet_promotion_amount) cho prepaid order
                  WHERE parent_id IS NULL
                    AND canceled_at IS NULL
                    AND (status='invoice_completed' OR status IS NULL)  -- Rule 0a
                    AND order.order_kind = 'prepaid'
                    AND order.paid_at IS NOT NULL
                    AND order.deleted_at IS NULL
       Source: mv_prepaid_order_daily.total_vi_km_napped (alias TBD post-V10 — xem Task 9)
  • ❌ FORBIDDEN (Review L8 chứng minh SAI khi discount/base≠sell — KHÔNG implement):
    ~~KM đã nạp = SUM(total_wallet_topup) − SUM(total_collected)~~
    Lý do: VD thẻ 10tr giá bán 8tr discount, KM 2tr → derive ra 4tr ❌ (canonical 2tr ✅).
  • Critical formula LN gộp (depends V10 — placeholder cho đến khi unlock):
    LN gộp = Tiền thu − HH − KM đã nạp (canonical)
    Ref PRD A10 FORMULA-010 + A10.0 hàng #3.
  • Tooltips: verbatim from Section 16.3 (đã update v3.9 — ref canonical, không còn forbidden derivation)
  • [ ] Step 2: Implement FinanceRevenue (Tab con 1: Tổng hợp doanh thu)
  • KHÔNG có toggle group-by. Bảng + chart luôn theo ngày, follow shared filter "Khoảng thời gian"
  • Table: Thời gian, Tiền thu vào, Nạp ví, KM đã nạp, Hoa hồng, Lợi nhuận gộp, Số đơn (Section 16.8)
  • Source: mv_prepaid_order_daily (KHÔNG mv_prepaid_finance_daily cho các metric order-level)
  • Footer row: Tổng cộng
  • Line chart: revenue + dashed previous period
  • [ ] Step 3: Implement FinanceDebt (Tab con 2: Công nợ)
  • Summary: Tổng nợ + breakdown by severity (<15d, 15-30d, >30d)
  • Table: columns from Section 16.9
  • Overdue calculated from created_at (NOT paid_at) — accounting standard
  • Color: green < 15d, yellow 15-30d, red > 30d
  • Action button: chỉ 1 nút [📞] icon-only (tel: link hoặc copy SDT). KHÔNG render [TT]/[Ghi chú] (write actions OUT OF SCOPE P1). Mã đơn click = navigate detail.
  • [ ] Step 4: Implement FinanceCommission (Tab con 3: Hoa hồng)
  • Table: NV, CN, Số đơn, DT, HH, %HH/DT (Section 16.10)
  • Filter: status='S', is_sender=false (only successful, receiver side)
  • Expand → detail per order
  • [ ] Step 5: Implement FinancePaymentMethods (Tab con 4: PTTT)
  • Donut chart (left) + table (right) on desktop, stacked on mobile
  • Table: PTTT, Số đơn, Số tiền, %, Trend (Section 16.11)
  • Data from mv_prepaid_finance_daily grouped by payment_method_id
  • [ ] Step 6: Implement FinanceExportBar

Sticky bottom bar with 5 export buttons:

  1. Sổ DT thẻ trả trước
  2. Công nợ
  3. Hoa hồng
  4. Đối soát CN (mỗi CN 1 sheet)
  5. Dư ví

Each button → create export_job → show progress dialog → download on complete.

  • [ ] Step 7: Create ExportProgressDialog shared component

File: components/prepaid-analytics/shared/ExportProgressDialog.tsx

  • QDialog with progress bar
  • Shows: “65% (32K/50K dòng)”
  • Poll export_job status every 2s
  • Complete → toast + download link
  • Cancel button
  • [ ] Step 8: Create usePrepaidExport composable

File: composables/usePrepaidExport.ts

  • startExport(reportType, filterParams) → insert export_job via mutation
  • pollProgress(jobId) → subscribe to export_job status changes
  • cancelExport(jobId) → update status to ‘cancelled’
  • Returns: { isExporting, progress, startExport, cancelExport }
  • [ ] Step 9: Wire up PrepaidAnalyticsFinance

Parent uses QTabs for 4 internal tabs: [Tổng hợp DT] [Công nợ] [Hoa hồng] [PTTT]. Export bar fixed at bottom.

  • [ ] Step 10: Verify in browser

Test all 4 tab cons, export flow, debt actions.

  • [ ] Step 11: Commit
git add src/modules/report/components/prepaid-analytics/finance/
git add src/modules/report/components/prepaid-analytics/shared/ExportProgressDialog.tsx
git add src/modules/report/composables/usePrepaidExport.ts
git add src/modules/report/pages/prepaid-analytics/PrepaidAnalyticsFinance.tsx
git commit -m "feat(prepaid-analytics): implement Finance sub-tab with 4 tabs, KPIs, export"

Chunk 3: Phase 2/3+ Frontend — Global Search (P2) + Marketing/Staff (P3+ defer)

🚦 Chunk 3 ĐƯỢC PHASE-TAG LẠI (review L7 fix): Customers đã được kéo về Chunk 2 = Phase 1 (theo prd.md DEC-B05 — 4 sub-tab P1 gồm Khách hàng). Chunk 3 còn lại là Phase 2 (Global Search) + Phase 3+ (Marketing/Staff defer indefinitely).

Task 14: GraphQL queries cho Customers (Phase 1) — đã move sang Chunk 2

⚠️ Customer queries thuộc Phase 1, KHÔNG phải Phase 2 (review L7 fix). Đặt vào commit cùng các queries P1 khác trong Chunk 2 (Task 9 hoặc Step 9.x). Section này giữ làm reference SQL — KHÔNG commit riêng vào Chunk 3.

Customer queries (P1 — commit cùng Chunk 2):

graphql
query PrepaidAnalyticsCustomers(
  $where: mv_prepaid_customer_stats_bool_exp!
  $limit: Int!
  $offset: Int
  $order_by: [mv_prepaid_customer_stats_order_by!]
) {
  mv_prepaid_customer_stats(where: $where, limit: $limit, offset: $offset, order_by: $order_by) {
    customer_id
    display_name
    phone_number
    order_count
    total_paid
    total_wallet_topup
    total_wallet_used
    consumption_rate
    first_order_at
    last_order_at
    last_used_at
    primary_buy_branch_id
    buy_branch_count
    segment
  }
  mv_prepaid_customer_stats_aggregate(where: $where) {
    aggregate { count }
  }
}

# Customer segment counts (4 segment cards)
query PrepaidAnalyticsSegments($where: mv_prepaid_customer_stats_bool_exp!) {
  active: mv_prepaid_customer_stats_aggregate(where: { _and: [$where, { segment: { _eq: "active" } }] }) {
    aggregate { count }
  }
  dormant: mv_prepaid_customer_stats_aggregate(where: { _and: [$where, { segment: { _eq: "dormant" } }] }) {
    aggregate { count }
  }
  at_risk: mv_prepaid_customer_stats_aggregate(where: { _and: [$where, { segment: { _eq: "at_risk" } }] }) {
    aggregate { count }
  }
  new: mv_prepaid_customer_stats_aggregate(where: { _and: [$where, { segment: { _eq: "new" } }] }) {
    aggregate { count }
  }
}
  • [ ] 🚫 Step 2: Marketing queries — SKIP (P3+ defer)
query PrepaidAnalyticsCampaigns($where: mv_prepaid_campaign_stats_bool_exp!) {
  mv_prepaid_campaign_stats(where: $where, order_by: { revenue: desc }) {
    campaign_id
    branch_id
    order_count
    revenue
    unique_customers
    new_customers
  }
}
  • [ ] Step 3: Add Staff queries
query PrepaidAnalyticsStaffRanking(
  $where: mv_prepaid_staff_stats_bool_exp!
  $order_by: [mv_prepaid_staff_stats_order_by!]
  $limit: Int
) {
  mv_prepaid_staff_stats(where: $where, order_by: $order_by, limit: $limit) {
    staff_id
    staff_name
    branch_id
    branch { name, region { name } }
    order_count
    revenue
    commission
    unique_customers
    new_customers
  }
}
  • [ ] Step 4: Add Global Search query
query SearchPrepaidGlobal($args: search_prepaid_global_args!) {
  search_prepaid_global(args: $args) {
    result_type
    id
    display_text
    subtitle
    branch_name
  }
}
  • [ ] Step 5: Run codegen and commit
npm run codegen
git add src/modules/report/graphql/prepaid_analytics.graphql src/api/graphql/generated/
git commit -m "feat(prepaid-analytics): add Phase 2 GraphQL queries (customers, marketing, staff, search)"

Task 15: Customers sub-tab implementation

Files:

  • Modify: pages/prepaid-analytics/PrepaidAnalyticsCustomers.tsx
  • Create: components/prepaid-analytics/customers/CustomerSegmentCards.tsx
  • Create: components/prepaid-analytics/customers/CustomerBehaviorBar.tsx
  • Create: components/prepaid-analytics/customers/CustomerTable.tsx
  • Create: components/prepaid-analytics/customers/CustomerExpandedRow.tsx
  • Create: components/prepaid-analytics/customers/CustomerBulkActions.tsx

Reference: UI spec Section 5. Design spec Section 5 + 16.7 + 16.19-16.20.

  • [ ] Step 1: Implement CustomerSegmentCards

4 segment cards (Section 5.1):

  • 🟢 Hoạt động, 🟡 Ngủ đông, 🔴 Rủi ro, 🔵 Mới
  • Each shows: count, % total, wallet balance sum (for dormant + at_risk)
  • Click card → filter table below by segment
  • Active card: bold border + tinted background
  • “inactive” segment (from MV SQL ELSE case): NOT shown as a card. These are customers who have no wallet usage AND are not new. They appear in the table when no segment filter is active, but are excluded from the 4 segment cards’ totals. The “%” calculation excludes inactive from the denominator.
  • [ ] Step 2: Implement CustomerBehaviorBar (đổi từ CustomerClvBar)

Đổi tên section từ "CLV Bar" → "Chỉ số hành vi khách hàng" (DEC 2026-05-04). 3 metrics thay vì 4.

Metrics bar 3 cards: Giá trị đơn TB (AOV) | Tỷ lệ tái nạp % | Chu kỳ trung bình (ngày)

  • AOV = SUM(invoice.amount) / COUNT(DISTINCT order.id) cho đơn prepaid trong kỳ + filter
  • Trend label dynamic theo filter: "so với tháng trước" / "so với 7 ngày trước" / etc.
  • File rename: CustomerClvBar.tsxCustomerBehaviorBar.tsx
  • [ ] Step 3: Implement CustomerTable

13 columns per Section 16.7 (Dư ví DIVA + Dư ví KM tách riêng).

  • % Đã xài: QLinearProgress (Green > 70%, Yellow 30-70%, Red < 30%)
  • “Lần cuối”: relative time, red highlight if > 60 days
  • Highlight đỏ nếu Dư ví DIVA + Dư ví KM > 5tr
  • SĐT: masked 0912***456
  • VIP badge per Section 16.19 (≥ 20tr OR ≥ 5 orders OR active + dư ≥ 5tr)
  • Click row → expand
  • [ ] Step 4: Implement CustomerExpandedRow

3 tabs (Section 5.3):

  1. Thẻ đã mua: list of cards purchased
  2. Hành vi sử dụng: frequency, preferred services, peak hours, preferred branch
  3. Gợi ý hành động: auto-suggestions per Section 16.7 (“Tần suất giảm…”, “Dư ví thấp…”, etc.)
  • [ ] Step 5: Implement CustomerTableHeader (đổi từ CustomerBulkActions)

Update 2026-05-04 (review L5 user simplification): Sub-tab Khách hàng = pure read-only. KHÔNG có checkbox multi-select, KHÔNG có bulk actions toolbar. Chỉ có header bảng với nút Export Excel ở góc trên bên phải.

Header component:

  • Layout: title trái (Dữ liệu khách hàng — Tổng cộng {N} KH) + button phải [📥 Tải Excel]

  • Button click: trigger server-side export TOÀN BỘ danh sách KH đang filter (không cần select)

  • Permission check: report.prepaid_analytics.export (FE hide button + BE enforce)

  • SDT mask theo view_full_phone permission (BE enforce trong export)

  • [ ] SMS/ZNS/Gán NV components — 🚫 BỎ HẲN (không build trong tab Analytics)

Bulk SMS/ZNS thuộc nghiệp vụ module Marketing/CRM riêng — KHÔNG nên duplicate vào tab Analytics. User flow: lọc KH ở tab này → click [📥 Tải Excel] để có danh sách → import vào module Marketing để gửi.

  • [ ] Step 6: Wire up PrepaidAnalyticsCustomers

Compose: SegmentCards → BehaviorBar → CustomerTableHeader (title + Export button) → Table (expandable rows). KHÔNG có sticky toolbar bulk actions.

  • [ ] Step 7: Verify and commit
git add src/modules/report/components/prepaid-analytics/customers/
git add src/modules/report/pages/prepaid-analytics/PrepaidAnalyticsCustomers.tsx
git commit -m "feat(prepaid-analytics): implement Customers sub-tab read-only with segments, behavior bar, table, export"

🚫 Task 16: Marketing sub-tab implementation — DEFER Phase 3+ (TBD)

🚫 KHÔNG build trong Phase 1. Chuyển defer indefinitely (ref: prd.md DEC-B05). Section này giữ trong plan làm reference cho khi Phase 3+ ramp up. Bỏ qua tất cả steps dưới đây.

Files (TBD — chỉ tạo khi Phase 3+ start):

  • Modify: pages/prepaid-analytics/PrepaidAnalyticsMarketing.tsx
  • Create: 6 component files in components/prepaid-analytics/marketing/

Reference: UI spec Section 7. Design spec Section 7 + 16.4 + 16.12 + 16.15-16.17.

  • [ ] Step 1: Implement MarketingKpiCards

8 cards (Section 7.1): Đơn từ CD, DT từ CD, KH mới, Tỷ lệ chuyển đổi, Đơn affiliate, DT affiliate, Chi phí MKT, ROI.

  • ROI card: green highlight > 200%, red < 100%
  • Tooltips from Section 16.4
  • [ ] Step 2: Implement MarketingCampaigns (Tab con 1)

Table: Chiến dịch, Thời gian, Đơn, DT, KH mới, Thẻ bán chạy, KV hiệu quả, Trạng thái (Section 16.12).

  • Warning: 1 order can belong to multiple campaigns (campaign_ids array)
  • Status: QBadge — Hiệu quả (green), TB (yellow), Kém (red)
  • Expand: by region, by date, by card type, customer list
  • [ ] Step 3: Implement MarketingAffiliate (Tab con 2)

Table: Nguồn, Loại, Đơn, DT, KH mới, HH đã chi (Section 16.15).

  • Filter: status='S', is_sender=false for commission
  • [ ] Step 4: Implement MarketingSources (Tab con 3)

Charts-only tab (Section 16.16):

  • Donut: Walk-in / Chiến dịch / Affiliate / Tái nạp
  • Stacked area: trend by month
  • Priority: CD > Affiliate > Tái nạp > Walk-in
  • [ ] Step 5: Implement MarketingCompare (Tab con 4)

Table: Kênh, Đơn, DT, Chi phí, ROI, Insight (Section 16.17).

  • Auto-insight: “ROI cao nhất” / “Cần cải thiện”
  • [ ] Step 6: Implement MarketingActions

Button group: [+ Tạo CD mới] [Nhân bản CD] [Xem KH từ CD] [Xuất báo cáo]

  • [ ] Step 7: Wire up, verify, commit
git add src/modules/report/components/prepaid-analytics/marketing/
git add src/modules/report/pages/prepaid-analytics/PrepaidAnalyticsMarketing.tsx
git commit -m "feat(prepaid-analytics): implement Marketing sub-tab with campaigns, affiliate, sources, compare"

🚫 Task 17: Staff sub-tab implementation — DEFER Phase 3+ (TBD)

🚫 KHÔNG build trong Phase 1. Chuyển defer indefinitely (ref: prd.md DEC-B05). Phase 1 đã có Hoa hồng theo NV ở Sub-tab Tài chính tab con 3 (Task 13) cho Kế toán đối soát lương — đủ nghiệp vụ. Section này (ranking, chi tiết, so sánh CN) ưu tiên lại khi Quản lý vùng có nhu cầu coaching. Bỏ qua tất cả steps dưới đây.

Files (TBD — chỉ tạo khi Phase 3+ start):

  • Modify: pages/prepaid-analytics/PrepaidAnalyticsStaff.tsx
  • Create: 4 component files in components/prepaid-analytics/staff/

Reference: UI spec Section 8. Design spec Section 8 + 16.5 + 16.13-16.14 + 16.18.

  • [ ] Step 1: Implement StaffKpiCards

4 cards (Section 8.1): NV có đơn (%), Đơn TB/NV, DT TB/NV, Tổng HH đã chi. Tooltips from Section 16.5.

  • [ ] Step 2: Implement StaffRanking (Tab con 1)

Leaderboard table (Section 16.13):

  • Filter: Xếp theo (DT/Đơn/etc.), Khu vực, Top (10/20/50)
  • Columns: #, NV, CN, KV, Đơn, DT, HH, DT TB/đơn, KH mới, Đánh giá
  • Top 3: medal emoji
  • Highlight: green > 150% avg, red < 50% avg
  • Insight: “77% NV chưa bán thẻ → cơ hội đào tạo”
  • [ ] Step 3: Implement StaffDetail (Tab con 2)

Per-staff profile (Section 16.18):

  • KPI cards: Đơn, DT, HH, KH mới
  • Line chart: DT 6 months vs system average
  • Analysis: preferred card, best selling hours, returning customers
  • Order list (paginated)
  • [ ] Step 4: Implement StaffBranchCompare (Tab con 3)

Grouped table (Section 16.14):

  • 2-level: Region (collapsed) → Branch (expand)
  • Columns: CN/KV, Tổng NV, NV có đơn, % Tham gia, Đơn TB/NV, DT TB/NV
  • Auto-insight: “CN mẫu” / “Cần đào tạo”
  • [ ] Step 5: Wire up, verify, commit
git add src/modules/report/components/prepaid-analytics/staff/
git add src/modules/report/pages/prepaid-analytics/PrepaidAnalyticsStaff.tsx
git commit -m "feat(prepaid-analytics): implement Staff sub-tab with ranking, detail, branch comparison"

⏳ Task 18: Global search implementation — DEFER Phase 2 (sau MVP)

⏳ KHÔNG build trong Phase 1 MVP. Phase 1 đã có search trong từng sub-tab (Giao dịch, Khách hàng) — đủ nghiệp vụ. Global search là tiện ích nâng cao, build trong Phase 2 (1-2 sprint sau khi MVP stable). Cần migration create_search_prepaid_global (dev-spec Section 11.1 #8) trước khi chạy task này.

Files:

  • Create: components/prepaid-analytics/shared/PrepaidAnalyticsGlobalSearch.tsx
  • Modify: components/prepaid-analytics/shared/PrepaidAnalyticsFilter.tsx (add search)

Reference: UI spec Section 2.4. Design spec Section 10.

  • [ ] Step 1: Implement PrepaidAnalyticsGlobalSearch

Custom search input + dropdown results component.

  • QInput with search icon

  • Debounce: 300ms, minimum 2 chars

  • Dropdown shows 3 sections: 👤 KH (max 5), 📋 Đơn (max 5), 👨‍💼 NV (max 5)

  • Data from search_prepaid_global function

  • Click result:

    • KH → navigate Customers sub-tab + filter customer
    • Đơn → navigate /e/prepaid-order/:id
    • NV → navigate Staff sub-tab + filter staff
  • ESC / click outside → close dropdown

  • Loading: QSpinner-dots in dropdown

  • [ ] Step 2: Integrate into PrepaidAnalyticsFilter

Replace search placeholder in PrepaidAnalyticsFilter.tsx with PrepaidAnalyticsGlobalSearch.

  • [ ] Step 3: Verify and commit
git add src/modules/report/components/prepaid-analytics/shared/PrepaidAnalyticsGlobalSearch.tsx
git add src/modules/report/components/prepaid-analytics/shared/PrepaidAnalyticsFilter.tsx
git commit -m "feat(prepaid-analytics): implement global search with pg_trgm fuzzy matching"

🚫 Task 19: Compare mode integration across sub-tabs — DELETED (DEC-U04)

🚫 TASK BỊ XÓA. DEC-U04 quyết định bỏ Compare Mode toggle khỏi filter bar — không có 3 chế độ Tổng hợp/So sánh KV/So sánh CN. Filter bar chỉ còn 3 element (Branch · Date · Search). Bỏ qua TOÀN BỘ task này — KHÔNG implement.

Việc cần làm thay vào đó: Đảm bảo trong Task 8 (Shared filter) đã KHÔNG tạo CompareModeToggleusePrepaidCompareMode (xem note ở đầu plan).

Reference (deprecated): Section này giữ lại làm context lịch sử, KHÔNG còn áp dụng.

  • [ ] Step 1: Update KPI cards for compare mode
  • Tổng hợp: 1 set of cards (default)
  • So sánh KV: each card shows mini-table (KV | Value | Trend)
  • So sánh CN: each card shows bar sparkline per branch
  • [ ] Step 2: Update summary bars for compare mode
  • Tổng hợp: single summary row
  • So sánh KV: summary grouped by region with subtotals
  • So sánh CN: summary per branch (max 10)
  • [ ] Step 3: Update charts for compare mode
  • Single series → multi-series with legend
  • Color per region/branch from predefined palette
  • [ ] Step 4: Update tables for compare mode
  • Tổng hợp: flat list
  • So sánh KV: grouped rows with region header (collapsible)
  • So sánh CN: grouped rows with branch header
  • [ ] Step 5: Verify all 3 modes across sub-tabs and commit
git add src/modules/report/composables/usePrepaidCompareMode.ts
git commit -m "feat(prepaid-analytics): implement 3 compare modes (aggregate, region, branch)"

Task 19b: Empty State v2 component (DEC-U12)

Files:

  • Create: components/prepaid-analytics/shared/EmptyState.tsx (hoặc reuse — xem Step 0)
  • Create: composables/useFilterChips.ts

Reference: UI spec Section 9.2 (Empty States v2 — DEC-U12).

  • [ ] Step 0: Pattern reuse check (BẮT BUỘC theo CLAUDE.md rule)

Trước khi tạo component mới, search codebase:

bash
rg "EmptyState|NoDataPlaceholder|empty-state" diva-admin/src/components/ --type vue --type ts
  • Reuse: nếu đã có <EmptyState> / <NoDataPlaceholder> component → extend bằng prop variant
  • 🔧 Extend: thêm prop variant: 'no-data' | 'filter-narrow' | 'error' + chips slot + primaryAction + secondaryAction
  • 🆕 Build mới: chỉ khi không có pattern tương đương — log lý do trong commit message
  • [ ] Step 1: Implement EmptyState component

API:

typescript
interface EmptyStateProps {
  variant: 'no-data' | 'filter-narrow' | 'error'
  // For 'no-data':
  fromDate?: string
  toDate?: string
  entityLabel?: string  // "giao dịch" / "khách hàng" / "mục PTTT"
  // For 'filter-narrow':
  countBase?: number
  chips?: FilterChip[]   // [{ label: 'Linh hoạt', onRemove: () => void }, ...]
  // Actions:
  onPrimaryAction?: () => void  // 'no-data' = expand range; 'filter-narrow' = reset all; 'error' = retry
  onSecondaryAction?: () => void
}

interface FilterChip {
  key: string
  label: string  // "Linh hoạt" or "Từ khóa: \"TT-99\""
  onRemove: () => void
}

Layout per UI spec 9.2.2/9.2.3:

  • variant='no-data': icon 📭 + title + desc dynamic + secondary link "Mở rộng → 90 ngày"
  • variant='filter-narrow': icon 🔍 + title + counter desc + chips list + primary [Đặt lại tất cả] + secondary link
  • variant='error': icon ⚠️ + title + primary [Thử lại]

Accessibility:

  • <div role="status" aria-live="polite"> wrapper
  • Chip remove aria-label="Bỏ filter {label}"
  • Touch target chip × ≥ 44×44px
  • Keyboard nav: Tab qua chips, Enter/Space to remove

Mobile responsive (< 480px):

  • Chips wrap or scroll-x
  • Buttons stack vertical full-width
  • [ ] Step 2: Implement useFilterChips composable
typescript
// useFilterChips.ts
import { computed } from 'vue'
import { usePrepaidAnalyticsFilter } from './usePrepaidAnalyticsFilter'

export function useFilterChips(localFilters: Ref<Record<string, any>>) {
  const sharedFilter = usePrepaidAnalyticsFilter()

  return computed<FilterChip[]>(() => {
    const chips: FilterChip[] = []

    // Shared search keyword
    if (sharedFilter.filter.q) {
      chips.push({
        key: 'q',
        label: `Từ khóa: "${sharedFilter.filter.q}"`,
        onRemove: () => sharedFilter.setFilter({ q: undefined }),
      })
    }

    // Local filters (per sub-tab — driven by `localFilters` prop)
    Object.entries(localFilters.value).forEach(([key, value]) => {
      if (value && value !== 'all') {
        chips.push({
          key,
          label: humanizeFilterLabel(key, value),
          onRemove: () => /* clear that local filter */,
        })
      }
    })

    return chips
  })
}
  • [ ] Step 3: Implement detect priority logic

Mỗi sub-tab có table cần 2 query:

typescript
// PrepaidAnalyticsTransactions.tsx
const { data: dataFiltered, fetching: loading } = useQuery({ /* full filter */ })
const { data: dataBase } = useQuery({ /* chỉ shared filter, KHÔNG có local + q */ })

const emptyVariant = computed<'loading' | 'error' | 'no-data' | 'filter-narrow' | null>(() => {
  if (loading.value) return 'loading'
  if (queryError.value) return 'error'
  if ((dataFiltered.value?.count ?? 0) > 0) return null  // Has data → render table
  if ((dataBase.value?.count ?? 0) === 0) return 'no-data'
  return 'filter-narrow'
})

Render:

tsx
<template v-if="emptyVariant === 'loading'">  <SkeletonTable /> </template>
<template v-else-if="emptyVariant === 'error'">  <EmptyState variant="error" :onPrimaryAction="refetch" /> </template>
<template v-else-if="emptyVariant === 'no-data'">
  <EmptyState variant="no-data" :fromDate :toDate :entityLabel="'giao dịch'" :onSecondaryAction="expandTo90Days" />
</template>
<template v-else-if="emptyVariant === 'filter-narrow'">
  <EmptyState variant="filter-narrow" :countBase="dataBase.count" :chips="filterChips"
              :onPrimaryAction="resetAll" :onSecondaryAction="expandTo90Days" />
</template>
<template v-else>  <TransactionTable :data="dataFiltered" /> </template>
  • [ ] Step 4: Wire vào tất cả sub-tab có table

Apply pattern này cho:

  • Sub-tab Giao dịch — PrepaidAnalyticsTransactions.tsx
  • Sub-tab Khách hàng — PrepaidAnalyticsCustomers.tsx
  • Sub-tab Tài chính (4 component-level tab) — Tổng hợp / Công nợ / Hoa hồng / PTTT

Sub-tab Tổng quan (KPI cards) → KHÔNG cần (luôn có data hoặc fallback "—" per metric).

  • [ ] Step 5: GraphQL count_base queries

Mỗi sub-tab có table thêm 1 query parallel:

graphql
query TransactionsBaseCount($branch: ..., $from: ..., $to: ...) {
  prepaid_orders_aggregate(where: { branch_id: { _in: $branch }, paid_at: { _gte: $from, _lte: $to } }) {
    aggregate { count }
  }
}

Query này CHỈ có shared filter (Chi nhánh + Khoảng TG), KHÔNG có local filter + q → dùng để diagnose case 3 vs 4.

  • [ ] Step 6: Test cases — TC-EMPTY-1..5

Coordinate với QA test plan (TC mới):

IDScenarioExpected variant
TC-EMPTY-1Loading stateSkeleton (no EmptyState rendered)
TC-EMPTY-2API errorvariant=error + "Thử lại"
TC-EMPTY-3CN trống thực sự (count_base=0)variant=no-data + 📭 + secondary expand link
TC-EMPTY-4Filter quá hẹp (count_base>0, count_filtered=0)variant=filter-narrow + 🔍 + counter + chips + 2 CTA
TC-EMPTY-5Click chip ×Chip fade out, table refresh, filter cleared
  • [ ] Step 7: Commit
git add src/modules/report/components/prepaid-analytics/shared/EmptyState.tsx
git add src/modules/report/composables/useFilterChips.ts
git add src/modules/report/pages/prepaid-analytics/PrepaidAnalytics{Transactions,Customers,Finance}.tsx
git commit -m "feat(prepaid-analytics): add EmptyState v2 with filter chips + counter (DEC-U12)"

Task 20: UI states, stale badge, accessibility

Files:

  • Modify: Multiple components

Reference: UI spec Sections 9-12.

  • [ ] Step 1: Add loading states to all components

Per UI spec Section 9.1:

  • KPI Cards: QSkeleton rect (pulse animation)
  • Charts: QSkeleton rect 300×200px
  • Tables: shimmer rows (5 fake rows)
  • Expanded rows: 3 skeleton rows
  • [ ] Step 2: Add empty states

Per UI spec Section 9.2:

📊 Không có dữ liệu trong khoảng thời gian này
Thử: Mở rộng thời gian / Chọn thêm CN / Bỏ bớt filter
  • [ ] Step 3: Add error states

Per UI spec Section 9.3:

  • Error component with retry button
  • QNotify toast on error (position bottom-right, timeout 5s)
  • [ ] Step 4: Add stale data badge

Per UI spec Section 9.4:

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

Warning badge when MV data > 30 minutes old.

  • [ ] Step 5: Add permission checks

Per UI spec Section 9.5:

  • Sub-tabs without permission → hidden entirely
  • Direct URL access to unauthorized tab → redirect to first allowed tab
  • [ ] Step 6: Add keyboard navigation and aria labels

Per UI spec Section 12:

  • Tab/Enter/Escape navigation
  • aria-label on KPI cards, charts
  • aria-live="polite" on alert badges
  • [ ] Step 7: Commit
git commit -am "feat(prepaid-analytics): add loading/empty/error states, stale badge, accessibility"

Task 21: MV refresh cron setup

Files:

  • Modify: Backend cron/scripts configuration

Reference: Design spec Section 11.5.

  • [ ] Step 1: Add MV refresh cron jobs

Add to the scripts service or cron configuration:

# Phase 1 cron — 4 MVs only (staff/campaign/branch_flow defer P3+)
# Each cron job: refresh MV + update mv_refresh_log timestamp

# 15-minute refresh (high-frequency MVs)
*/15 * * * * psql -c "REFRESH MATERIALIZED VIEW CONCURRENTLY mv_prepaid_order_daily; UPDATE mv_refresh_log SET last_refreshed_at = now() WHERE mv_name = 'mv_prepaid_order_daily';"
*/15 * * * * psql -c "REFRESH MATERIALIZED VIEW CONCURRENTLY mv_prepaid_card_daily; UPDATE mv_refresh_log SET last_refreshed_at = now() WHERE mv_name = 'mv_prepaid_card_daily';"
*/15 * * * * psql -c "REFRESH MATERIALIZED VIEW CONCURRENTLY mv_prepaid_finance_daily; UPDATE mv_refresh_log SET last_refreshed_at = now() WHERE mv_name = 'mv_prepaid_finance_daily';"

# 30-minute refresh
*/30 * * * * psql -c "REFRESH MATERIALIZED VIEW CONCURRENTLY mv_prepaid_customer_stats; UPDATE mv_refresh_log SET last_refreshed_at = now() WHERE mv_name = 'mv_prepaid_customer_stats';"

# Phase 3+ TBD (KHÔNG add Phase 1):
# */30 * * * * REFRESH mv_prepaid_staff_stats
# */30 * * * * REFRESH mv_prepaid_campaign_stats
# branch_flow đã loại bỏ (DEC-U04)

Frontend queries mv_refresh_log to show stale badge: SELECT MIN(last_refreshed_at) FROM mv_refresh_log.

  • [ ] Step 2: Verify cron runs correctly
# Manually trigger each and check timing
time psql -c "REFRESH MATERIALIZED VIEW CONCURRENTLY mv_prepaid_order_daily;"

Expected: Each refresh < 30 seconds.

  • [ ] Step 3: Commit
git commit -am "feat(prepaid-analytics): add MV refresh cron jobs (15min/30min/hourly)"

Task 22: Export-API endpoint for prepaid reports

Files:

  • Modify: diva-backend/services/export-api/ (add new handler)

Reference: Design spec Section 9.8 + 11.4.

  • [ ] Step 1: Add prepaid export action handler

In the export-api service, add a handler for prepaid report exports:

  • Accept: { reportType, filterParams } from Hasura action
  • Query data in chunks (1000 rows/batch) using keyset cursor
  • Stream to Excel file using excelize library (existing)
  • Upload to MinIO/GCS
  • Update export_job status + progress
  • Send notification via notification-api when complete

Supported report types:

  1. prepaid_revenue — Sổ doanh thu
  2. prepaid_debt — Công nợ
  3. prepaid_commission — Hoa hồng
  4. prepaid_branch_reconcile — Đối soát CN (1 sheet per branch)
  5. prepaid_wallet_balance — Dư ví
  • [ ] Step 2: Register Hasura action

Create action export_prepaid_report in Hasura with:

  • Input: { report_type: String!, filter_params: jsonb! }
  • Output: { job_id: uuid! }
  • Handler: export-api endpoint
  • [ ] Step 3: Test export flow
# Trigger export via GraphQL
mutation {
  export_prepaid_report(report_type: "prepaid_revenue", filter_params: { from: "2026-03-01", to: "2026-03-13" }) {
    job_id
  }
}
# Then check export_job status
  • [ ] Step 4: Commit
git add services/export-api/
git add services/controller/metadata/
git commit -m "feat(export-api): add prepaid report export handler with chunked streaming"

Task 23: Final integration and feature flag verification

  • [ ] Step 1: Test feature flag toggle

Set FEATURE_PREPAID_ANALYTICS_V2 = false → verify old tab visible, new tab hidden. Set FEATURE_PREPAID_ANALYTICS_V2 = true → verify new tab visible.

  • [ ] Step 2: End-to-end walkthrough — Phase 1 scope only (4 sub-tab P1)

Test each Phase 1 sub-tab (DEC-B05 — Marketing/NV defer P3+, Global Search defer P2):

  1. Tổng quan: KPI cards (8), charts time-series (Ngày only — DEC-U08), alerts list, Top 5 CN ranking, [🔄 Tải lại] (DEC-U11)
  2. Giao dịch: 2-element local filter (Loại thẻ + Trạng thái TT — DEC-U12), 7 sum cards, table 10 cột expandable, pagination, shared q apply scope
  3. Khách hàng: 4 segment cards (clickable filter + chip), behavior bar (3 metrics AOV-based), table read-only (DEC-U09 — KHÔNG bulk SMS/ZNS/Gán NV), [📥 Xuất Excel] ở header bảng
  4. Tài chính: 8 KPI + 4 component-level tabs (Tổng hợp DT · Công nợ · Hoa hồng · PTTT) + export bar 5 nút (DEC-U10 — 1 permission unified)

🚫 OUT OF SCOPE Phase 1 — KHÔNG TEST:

  • Marketing sub-tab (DEC-B05 defer P3+)
  • Nhân viên sub-tab (DEC-B05 defer P3+)
  • Bulk actions Khách hàng (SMS/ZNS/Gán NV/checkbox multi-select) (DEC-U09 — pure read-only)
  • Global Search dropdown suggestion (DEC-U12 — Phase 2 enhancement của shared search hiện tại)
  • Compare Mode toggle (Tổng hợp/So sánh KV/So sánh CN) (DEC-U04 — bỏ hoàn toàn)
  • Charts toggle Ngày/Tuần/Tháng (DEC-U08 — Phase 1 chỉ Ngày)
  • Write actions Công nợ ([Xác nhận TT]/[Ghi chú]) (Phase 1 chỉ [📞 Gọi])

Test Empty State v2 detect priority cho 3 sub-tab có table (Giao dịch + Khách hàng + Tài chính/Công nợ+Hoa hồng):

  • Loading skeleton
  • Error state + retry
  • No-data thực sự (📭 + secondary link)
  • Filter quá hẹp (🔍 + counter + active chips clickable + primary [Đặt lại tất cả])

Test trend label dynamic theo FORMULA-019 (sliding N ngày / cùng độ dài calendar / full calendar).

  • [ ] Step 3: Performance check
  • Tổng quan load < 500ms
  • Table pagination < 300ms
  • Expand row < 200ms
  • Shared search response < 300ms (debounce 300ms ở input, query trả < 300ms)
  • Empty state baseline count query < 200ms parallel với main query
  • [ ] Step 4: Cross-tab navigation check

Verify all links from UI spec Section 10.1:

  • KPI click → correct sub-tab
  • Alert [Xem] → correct sub-tab + filter
  • Search result click → correct destination
  • NV name click → Staff sub-tab
  • [ ] Step 5: Final commit
git commit -am "feat(prepaid-analytics): complete Phase 1+2 implementation of prepaid card analytics tab"