Appearance
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 đổi | Section | Ả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 > 5tr | Component tree · Task 15 Step 3 | FE |
Wallet balance aggregate query thêm field split wallet_balance_diva + wallet_balance_km; exact field name chờ BE V5 confirm | GraphQL query examples | BE, FE |
v3.14 — 12/05/2026
| Thay đổi | Section | Ảnh hưởng |
|---|---|---|
TransactionExpandedRow.tsx: 3 tabs → 2 tabs (bỏ "Lịch sử dùng") — ref DEC-T07 | Task 12 Step 4 | FE |
| Task 4 Step 4 comment: "3 tabs inside expanded row" → "2 tabs" | Task 4 | FE, QA |
⭐ DEC-T08: Tab Hoa hồng — source invoice_commission, bỏ status S/P/C. Refund row riêng amount âm | Task 12 Step 4 | FE |
🚦 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)
| Chunk | Tasks | Sub-tab |
|---|---|---|
| Chunk 1: Foundation | Task 1–7 | Backend + scaffold |
| Chunk 2: Frontend P1 | Task 8 (sửa: bỏ CompareModeToggle) · Task 9 (sửa: bỏ compare queries) · Task 10–13 | Tổ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 CompareModeToggle và usePrepaidCompareMode.
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)
| Tasks | Nội dung |
|---|---|
| Task 14 (subset) + Task 18 | Global Search (pg_trgm, search_prepaid_global) |
🚫 Phase 3+ — Defer indefinitely (TBD)
| Tasks | Lý do defer |
|---|---|
| Task 16 — Marketing sub-tab | Khá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-tab | Hoa 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 integration | DELETE — 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):
(đã có sẵn — KHÔNG tạo) ·create_region_branchcreate_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:
- Design: dev-spec.md
- UI: ui-spec.md
- PRD: prd.md
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.sqlFrontend — 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 translationsChunk 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 (migration1678865967129_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
171030000N000where 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_statsvàcampaign_statsdefer Phase 3+ — KHÔNG tạo migration trong Phase 1. MVbranch_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_walletKHÔNG × quantity,product_idJOINprepaid_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_orderdedupe invoice + item aggregate trước khi roll upprepaid_value_into_walletKHÔ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 onlyCHỈ có
sold_quantity,total_wallet_topup,total_card_value— KHÔNGtotal_collected(order-level)prepaid_value_into_walletKHÔNG × quantityJOIN
prepaid_card_viewUnique 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_orderdedupe invoice + item trước khi customer-level roll up (fix double count)prepaid_value_into_walletKHÔNG × quantitySegment logic: 'new' check ĐẦU TIÊN trong CASE
🔍 Verify table:
customerhayecommerce_user/default account — BE confirm trước khi chạyUnique 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_idKHÔNG include
total_wallet_topupở MV này (Finance MV bỏ field này — fix double count). Lấytotal_wallet_topuptừmv_prepaid_order_dailyUnique 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:
idx_order_prepaid_report— main report query (ecommerce)idx_order_prepaid_debt— debt lookup (ecommerce)idx_invoice_order_paid— invoice aggregation (ecommerce)idx_invoice_wallet_usage— wallet usage filter (ecommerce)idx_txreq_commission_order— commission lookup (wallet schema)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_globaldefer 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.sqlCreate:
down.sqlfile[ ] Step 1: Write compute_prepaid_alerts function (P1)
Copy from design spec Section 9.9. Critical details:
- 4 alert types:
overdue_payment,inactive_customer(filterbalance > 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 JSONBcho mỗi alert với 5 alert type. Riêng alert "Thẻ giảm bán > 30%" payload PHẢI returnprepaid_card.code(string ILIKE-friendly) — KHÔNG returncard_id(UUID). Lý do: FE click alert → set sharedq={card.code}(DEC-U12 — single search source, scope Sub-tab Giao dịch includeprepaid_card.code). Alert "Đơn quá hạn" payload returnbranch_id, "KH không dùng" returnbranch_id, "DT giảm" returnbranch_id, "KH VIP không hoạt động" returnbranch_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_trgmextension + 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 exportto 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 tables4 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.viewaction (mọi role mặc định ngoại trừstaff) - Branch scope: filter
branch_id = X-Hasura-Branch-Idcho non-admin (nếu có cột branch_id) - SDT trong
mv_prepaid_customer_stats: dùng computed columnphone_number_maskedcho 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_jobtableexport_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.region→region_branch(đã có sẵn, verify)region_branch.branches→branch[](đã có sẵn, verify)export_job.user→ecommerce_user(object relationship)mv_prepaid_card_daily.product→prepaid_card_view(object relationship quaproduct_id)mv_prepaid_order_daily.branch→branch(object relationship quabranch_id)mv_prepaid_customer_stats.customer→ecommerce_user(object relationship quacustomer_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.tsx và PrepaidAnalyticsStaff.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()anduseRouter()for URL sync - Provide
watchEffectthat updates store when URL changes and vice versa - Expose:
filter(reactive),setFilter(),resetFilter(),branchIdsArray(computed for GraphQL) previousPeriodcomputed — 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))
}
})trendLabelcomputed — dynamic theopresetKey(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:
QSelectwithmultiple,use-chips,option-group(via slots)- Query
region_branch→branches[]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” →
disableextras +QTooltipwarning - Emit
update:modelValuewithstring[]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:
QSelectdropdown 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ở
QDaterange calendarMax range: 365 days
Default: "Tháng này"
Emit
update:modelValuewith{ 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:
Alias Source 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_dailyfilterpayment_method_id='wallet'(chờ V11)FORMULA-005 total_vi_km_usedmv_prepaid_finance_dailyfilterpayment_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 derivevi_diva = total_collectedhoặcvi_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_p1trả top 5 staff (REST endpoint trong servicerestful-apihoặcecommerce-api). Query path: aggregateorder_commissionJOINinvoicefilter prepaid + branch + date range, group byuser_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 commandExpected: 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: stringseverity?: '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
changePercentfrom 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:
QSkeletonrect - [ ] 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:
QSkeletonrect 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_flowcũ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:❌ Removed (out of scope Phase 1 — cần thì sort cột bảng)QSelectmulti-selectMệnh giá: 2×❌ Removed (sort cột "Tiền thu" là đủ)QInputLocal search:❌ Removed (DEC-U12 — dùng sharedQInputq)- Sync to URL:
?card_type=fixed&status=debt(q ở root URL:?q=...) - GraphQL
whereclause includeqtừ shared filter:typescriptconst 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=
$primarytint xanh, Linh hoạt=$purple-6tí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ếuprepaid_card.namecó 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:
- Chi tiết TT: invoice list (payment method, amount, date, status)
- Hoa hồng (DEC-T08): commission details (NV, amount, loại) — source
invoice_commissiononlyinvoice_status='invoice_completed'. Refund row riêng từtransaction_requestbehavior_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):Lý do: VD thẻ 10tr giá bán 8tr discount, KM 2tr → derive ra 4tr ❌ (canonical 2tr ✅).
~~KM đã nạp = SUM(total_wallet_topup) − SUM(total_collected)~~ - Critical formula LN gộp (depends V10 — placeholder cho đến khi unlock):Ref PRD A10 FORMULA-010 + A10.0 hàng #3.
LN gộp = Tiền thu − HH − KM đã nạp (canonical) - 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ÔNGmv_prepaid_finance_dailycho 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(NOTpaid_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_dailygrouped bypayment_method_id - [ ] Step 6: Implement FinanceExportBar
Sticky bottom bar with 5 export buttons:
- Sổ DT thẻ trả trước
- Công nợ
- Hoa hồng
- Đối soát CN (mỗi CN 1 sheet)
- 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_jobstatus every 2s - Complete → toast + download link
- Cancel button
- [ ] Step 8: Create usePrepaidExport composable
File: composables/usePrepaidExport.ts
startExport(reportType, filterParams)→ insert export_job via mutationpollProgress(jobId)→ subscribe to export_job status changescancelExport(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
ELSEcase): 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.tsx→CustomerBehaviorBar.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):
- Thẻ đã mua: list of cards purchased
- Hành vi sử dụng: frequency, preferred services, peak hours, preferred branch
- 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_phonepermission (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=falsefor 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.
QInputwith search iconDebounce: 300ms, minimum 2 chars
Dropdown shows 3 sections: 👤 KH (max 5), 📋 Đơn (max 5), 👨💼 NV (max 5)
Data from
search_prepaid_globalfunctionClick 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-dotsin 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 CompareModeToggle và usePrepaidCompareMode (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 propvariant - 🔧 Extend: thêm prop
variant: 'no-data' | 'filter-narrow' | 'error'+chipsslot +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 linkvariant='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):
| ID | Scenario | Expected variant |
|---|---|---|
| TC-EMPTY-1 | Loading state | Skeleton (no EmptyState rendered) |
| TC-EMPTY-2 | API error | variant=error + "Thử lại" |
| TC-EMPTY-3 | CN trống thực sự (count_base=0) | variant=no-data + 📭 + secondary expand link |
| TC-EMPTY-4 | Filter quá hẹp (count_base>0, count_filtered=0) | variant=filter-narrow + 🔍 + counter + chips + 2 CTA |
| TC-EMPTY-5 | Click 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:
QSkeletonrect (pulse animation) - Charts:
QSkeletonrect 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
QNotifytoast 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-labelon KPI cards, chartsaria-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
excelizelibrary (existing) - Upload to MinIO/GCS
- Update
export_jobstatus + progress - Send notification via notification-api when complete
Supported report types:
prepaid_revenue— Sổ doanh thuprepaid_debt— Công nợprepaid_commission— Hoa hồngprepaid_branch_reconcile— Đối soát CN (1 sheet per branch)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):
- 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) - 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
qapply scope - 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 - 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"