Appearance
Dev Spec — Redesign Tab Tư vấn (Commission -> Advisory Fee)
Version: 1.0 Date: 23/03/2026 Author: Dev Team Complexity: M Module: ecommerce (FE-only refactor) Ref PRD: docs/features/redesign-tab-tu-van/prd.md
Changelog
| Version | Date | Author | Thay đổi |
|---|---|---|---|
| 1.0 | 23/03/2026 | Dev Team | Initial dev-spec — FE refactor, no BE changes |
| 1.1 | 07/04/2026 | Dev Team | Loại bỏ trạng thái "Chờ duyệt" — commission không có bước duyệt |
C1) Scope
C1.1 In-scope
| # | Hạng mục | Layer | Effort |
|---|---|---|---|
| 1 | Tách OrderCommissions.tsx (656 LOC) thành 3 components mới | FE | M |
| 2 | Tạo CommissionSummaryBar — 4 chỉ số tổng quan đơn hàng | FE | S |
| 3 | Tạo CommissionSummaryCards — cards per NV với expand chi tiết DV | FE | M |
| 4 | Refactor OrderCommissionTable thành CommissionTransactionList — flat list, adaptive columns | FE | M |
| 5 | Đổi thuật ngữ i18n + hardcode: "Hoa hồng" -> "Tư vấn" | FE | S |
| 6 | Client-side filter: card click filter + search + dropdown | FE | S |
| 7 | Empty state + Loading state | FE | S |
C1.2 Out-of-scope
| # | Hạng mục | Lý do |
|---|---|---|
| 1 | DB schema changes | DEC-004: chỉ đổi UI, không đổi DB |
| 2 | Backend logic changes | Không đổi commission calculation |
| 3 | New GraphQL queries/mutations | Reuse ServiceOrderDetail + TransactionsOfOrder |
| 4 | Notification template migration | DEC-007: tách task riêng |
| 5 | Customer commission (affiliate) | Backlog Phase 2 |
| 6 | Commission settings module rename | Chỉ đổi report label (i18n) |
C2) Impact Summary
C2.1 Impact Map
| Area | Component / File | Action | Impact |
|---|---|---|---|
| FE Page | OrderCommissions.tsx (656 LOC) | REFACTOR — tách thành orchestrator + 3 child components | High |
| FE Component | OrderCommissionTable.tsx | REPLACE — refactor thành CommissionTransactionList.tsx | High |
| FE Component | OrderCommissionItem.tsx | REMOVE — logic nhập vào CommissionTransactionList (không còn nested expand) | High |
| FE i18n | ecommerce/i18n/vi.ts:770 | EDIT — commissions: "Hoa hồng" -> "Tư vấn" | Low |
| FE i18n | report/i18n/vi.ts:99 | EDIT — "Báo cáo hoa hồng" -> "Báo cáo tư vấn" | Low |
| FE i18n | report/i18n/vi.ts:182 | EDIT — commission_report_group -> "Báo cáo tư vấn" | Low |
| FE Page | OrderDetail.tsx:103 | EDIT — hardcode "Hoa hồng" -> i18n key | Low |
| FE Page | ProductOrderDetail.tsx:78 | EDIT — hardcode "Hoa hồng" -> i18n key | Low |
| FE Page | OrderCommissions.tsx:566 | EDIT — "THÔNG TIN HOA HỒNG" -> "PHÂN BỔ TƯ VẤN" | Low |
| FE Page | OrderCommissions.tsx:109 | EDIT — "Thêm người nhận HH" -> "Cấu hình tư vấn" | Low |
| FE Page | OrderCommissions.tsx:516 | EDIT — "người nhận hoa hồng" -> "người tư vấn" | Low |
| BE | — | NONE — không đổi logic, không đổi API | None |
| DB | — | NONE — không đổi schema | None |
C2.2 Risk Assessment
| Risk | Severity | Mitigation |
|---|---|---|
| Regression: mất tính năng cũ sau refactor | High | 100% feature parity check (QA test cases), reuse existing data logic |
| Commission form dialogs bị ảnh hưởng | Medium | Không đổi dialog components (ServiceOrderCommissionCreate, CosmeticOrderCommissionCreate, PrepaidOrderCommissionCreate) |
| GraphQL query response thiếu field | Low | Đã confirm: ServiceOrderDetail và TransactionsOfOrder đủ data |
C3) Rules & Formulas (Implementation Delta)
Single-source: Tất cả business formulas defined in PRD A10. Section này CHỈ ghi implementation delta (SQL/computed), ref PRD A10.
FORMULA-001: Phân bổ (Allocation)
- Ref: PRD A10 FORMULA-001
- Computed (FE):
typescript
// Source: order.order_commissions (from ServiceOrderDetail query)
const allocation = (order.order_commissions ?? [])
.filter(oc => oc.user.id === userId)
.reduce((sum, oc) => sum + (oc.amount || 0), 0);
// Note: order_commission.amount stored as config value
// When unit = 'commission_percent', amount = percentage (e.g., 10 = 10%)
// When unit = 'commission_vnd', amount = VND absolute
// For Summary Bar total: SUM all users' VND equivalent- Source mapping:
allocation<-order.order_commissions[].amountWHEREuser.id = {userId} - Precision: Integer (VND), no decimal. Display with dot separator:
X.XXXđ
FORMULA-002: Đã chi (Disbursed)
- Ref: PRD A10 FORMULA-002
- Computed (FE):
typescript
// Source: order.invoice_commissions (from ServiceOrderDetail query)
const disbursed = (order.invoice_commissions ?? [])
.filter(ic => ic.user.id === userId && ic.invoice_status === 'invoice_completed')
.reduce((sum, ic) => sum + (ic.amount || 0), 0);
// Note: invoice_commission.amount always stored as VND (bigint)
// Backend calculates: subInvoice.ReferenceAmount * cmsAmountPercent (service/cosmetic)
// or: subInvoice.Amount * cmsAmountPercent (prepaid)- Source mapping:
disbursed<-order.invoice_commissions[].amountWHEREuser.id = {userId}ANDinvoice_status = 'invoice_completed' - Precision: Integer (VND bigint). Display:
X.XXXđ
FORMULA-003: Thu hồi (Revoked) [PLANNED]
- Ref: PRD A10 FORMULA-003
- Computed (FE):
typescript
// Source: order.invoices[].transaction_requests (from TransactionsOfOrder query)
// Filter: behavior_id = 'refund_commission' AND status = 'S'
const revoked = flatTransactions
.filter(t => t.behavior_id === 'refund_commission' && t.status === 'S')
.filter(t => t.transaction_request_users.some(u => u.user.id === userId))
.reduce((sum, t) => sum + (t.amount || 0), 0);
// [PLANNED] Backend refund_commission đang phát triển
// Hiện tại: revoked = 0 cho mọi trường hợp- Source mapping:
revoked<-transaction_request.amountWHEREbehavior_id = 'refund_commission' - DB View reference:
wallet.order_commission_refund— aggregatestransaction_commissionvsrefund_commissionper user per order
FORMULA-004: Đã nhận (Net Received)
- Ref: PRD A10 FORMULA-004
- Computed (FE):
typescript
const received = disbursed - revoked;
// Edge case: if received < 0 → display negative in red (abnormal case)- Precision: Integer (VND). Display:
X.XXXđ
FORMULA-005: Tỷ lệ thu hồi group (Group Revoke Ratio)
- Ref: PRD A10 FORMULA-005
- Computed (FE):
typescript
// Per invoice group (same invoice code)
const groupDisburse = groupTransactions
.filter(t => t.behavior_id === 'transaction_commission')
.reduce((sum, t) => sum + t.amount, 0);
const groupRevoke = groupTransactions
.filter(t => t.behavior_id === 'refund_commission')
.reduce((sum, t) => sum + t.amount, 0);
const ratio = groupDisburse > 0
? Math.round((groupRevoke / groupDisburse) * 100)
: null; // null → display "–"- Precision: Integer % (rounded). Display:
X%
C4) Data Model (READ ONLY)
KHÔNG CÓ DB MIGRATION. Section này document existing tables cho dev reference.
C4.1 Tables sử dụng
ecommerce.order_commission
| Column | Type | Description |
|---|---|---|
| order_id | uuid | FK -> order.id |
| user_id | uuid | FK -> account.id (NV nhận commission) |
| order_item_id | uuid | FK -> order_item.id |
| amount | integer | Giá trị cấu hình (VND hoặc %) |
| unit | text | commission_vnd / commission_percent |
| created_by | uuid | FK -> account.id (người tạo) |
PK: (order_id, user_id, order_item_id) — composite Used by: Summary Bar (FORMULA-001), Summary Cards (per NV allocation)
ecommerce.invoice_commission
| Column | Type | Description |
|---|---|---|
| id | uuid | PK |
| user_id | uuid | FK -> account.id |
| order_id | uuid | FK -> order.id |
| invoice_id | uuid | FK -> invoice.id |
| order_item_id | uuid | FK -> order_item.id |
| amount | bigint | Số tiền commission đã tính (luôn VND) |
| unit | text | Luôn = vnd (đã convert từ %) |
| invoice_status | text | Copy từ invoice.status tại thời điểm tính |
Used by: Summary Bar (FORMULA-002), Summary Cards (per NV disbursed)
wallet.order_commission_refund (VIEW)
sql
-- Existing VIEW (read-only reference)
CREATE OR REPLACE VIEW order_commission_refund AS
SELECT
tr.order_id,
t.user_id,
SUM(CASE WHEN tr.behavior_id = 'transaction_commission'
THEN t.amount END) AS transaction_commission,
SUM(CASE WHEN tr.behavior_id = 'refund_commission'
THEN t.amount END) AS refund_commission
FROM transaction t
JOIN transaction_request tr ON (tr.id = t.request_id)
WHERE tr.behavior_id IN ('transaction_commission', 'refund_commission')
AND tr.status = 'S'
GROUP BY t.user_id, tr.order_id;Used by: [PLANNED] Thu hồi aggregation. Hiện tại FE tính từ TransactionsOfOrder query.
C4.2 ER Diagram (Relevant subset)
order ──1:N──> order_commission ──N:1──> account (user)
│ │
│ └──N:1──> order_item
│
├──1:N──> invoice ──1:N──> invoice_commission ──N:1──> account (user)
│ │ │
│ │ └──N:1──> order_item
│ │
│ └──1:N──> transaction_request ──1:N──> transaction_request_users
│ │
│ └── behavior_id IN ('transaction_commission', 'refund_commission')
│
└──1:N──> order_item ──N:1──> productC5) API + Hasura (Existing Queries)
KHÔNG TẠO API MỚI. Reuse 2 existing GraphQL queries.
C5.1 Query: ServiceOrderDetail (order detail page)
Location: diva-admin/src/modules/ecommerce/graphql/order.graphqlUsed by: CommissionSummaryBar, CommissionSummaryCards, OrderCommissions (parent)
Relevant fields consumed:
graphql
# From Order fragment (existing)
order {
id
order_commissions {
user { id, display_name, avatar_url, roles { role { name } }, departments { department { name } } }
order_item { id, product_id, price, quantity, product { product_contents { name } } }
amount # -> FORMULA-001 (Phân bổ)
unit # commission_vnd | commission_percent
}
invoice_commissions {
user { id, display_name }
order_item { product_id }
amount # -> FORMULA-002 (Đã chi, luôn VND)
invoice_status # filter = 'invoice_completed'
unit
}
order_items { id, product_id, price, quantity, product_name, product { product_contents { name } } }
invoices { id, status_id }
}Data flow:
ServiceOrderDetail query
└─> OrderCommissions.tsx (parent orchestrator)
├─> CommissionSummaryBar (props: order_commissions, invoice_commissions)
├─> CommissionSummaryCards (props: order_commissions, invoice_commissions)
└─> CommissionTransactionList (separate query: TransactionsOfOrder)C5.2 Query: TransactionsOfOrder
Location: diva-admin/src/modules/ecommerce/graphql/order.graphql:1498Used by: CommissionTransactionList
graphql
query TransactionsOfOrder(
$where: order_bool_exp!
$invoiceWhere: invoice_bool_exp!
$transactionWhere: transaction_request_bool_exp!
$order_by: [transaction_request_order_by!]
) {
order(where: $where) {
id
invoices(where: $invoiceWhere) {
code # -> Group header: Mã thanh toán
transaction_requests(where: $transactionWhere, order_by: $order_by) {
code # -> Mã giao dịch
amount # -> Số tiền
created_at # -> Thời gian
behavior_id # -> 'transaction_commission' | 'refund_commission'
status # -> 'S' (success) — commission luôn là 'S' khi thanh toán
activity # -> Hành động
created_by # -> Người tạo (for revoke: "bởi: {Admin name}")
created_user {
id, display_name, avatar_url, role
profile { id }
}
transaction_request_users {
wallet_type_id # -> COMMISSION | VND_PROMOTION
is_sender
user {
id, display_name, avatar_url, role
profile { id }
}
}
}
}
}
}Variables (existing, no change):
typescript
{
where: { id: { _eq: orderId } },
invoiceWhere: {
// For product orders: parent_id: { _is_null: true }
// For service/prepaid: {}
},
transactionWhere: {
_or: [
{ behavior_id: { _eq: "transaction_commission" } },
{ behavior_id: { _eq: "refund_commission" } }
]
},
order_by: { created_at: "desc" }
}C6) Frontend Components
C6.1 Component Architecture (New)
OrderCommissions.tsx (REFACTORED — orchestrator, ~200 LOC)
├── CommissionSummaryBar.tsx (NEW — ~120 LOC)
│ Props: orderCommissions, invoiceCommissions, transactions
│ Display: Phân bổ | Đã chi | Thu hồi | Đã nhận + context line
│
├── CommissionSummaryCards.tsx (NEW — ~200 LOC)
│ Props: orderCommissions, invoiceCommissions, orderType, onCardClick
│ Display: per-NV cards, expand DV breakdown
│ Emits: cardClick(userId) for filtering
│
├── CommissionTransactionList.tsx (NEW — replaces OrderCommissionTable + OrderCommissionItem, ~300 LOC)
│ Props: orderId, orderType, activeUserId (filter from card click)
│ Own query: TransactionsOfOrder
│ Display: flat list, adaptive columns, group headers
│
├── [KEEP] CustomerReferralOrderCard (no change)
├── [KEEP] ServiceOrderCommissionCreate / CosmeticOrderCommissionCreate / PrepaidOrderCommissionCreate (no change)
├── [KEEP] *CommissionForm variants (no change)
└── [DELETE] OrderCommissionTable.tsx, OrderCommissionItem.tsxC6.2 File Structure
diva-admin/src/modules/ecommerce/
├── pages/
│ └── OrderCommissions.tsx # REFACTORED (orchestrator)
├── components/order/
│ ├── CommissionSummaryBar.tsx # NEW
│ ├── CommissionSummaryCards.tsx # NEW
│ ├── CommissionTransactionList.tsx # NEW (replaces OrderCommissionTable + OrderCommissionItem)
│ ├── OrderCommissionTable.tsx # DELETE (logic moved to CommissionTransactionList)
│ ├── OrderCommissionItem.tsx # DELETE (no more nested expand)
│ └── CustomerReferralOrderCard.tsx # KEEP (no change)C6.3 Component: CommissionSummaryBar
File: diva-admin/src/modules/ecommerce/components/order/CommissionSummaryBar.tsx
Props:
typescript
interface CommissionSummaryBarProps {
orderCommissions: OrderCommission[]; // from parent (ServiceOrderDetail)
invoiceCommissions: InvoiceCommission[]; // from parent
transactions: FlatTransaction[]; // from TransactionsOfOrder (for revoke calc)
loading: boolean;
}Computed values (pseudocode):
typescript
// FORMULA-001: Phân bổ = SUM order_commission.amount (all users, VND equivalent)
const totalAllocation = computed(() => {
// For percent-based: need to compute VND equivalent from invoice_commissions
// For VND-based: direct sum from order_commissions
// Simplified: SUM of invoice_commissions completed = theoretical total allocated
// OR: SUM order_commissions directly (config value)
return orderCommissions.reduce((sum, oc) => {
if (oc.unit === 'commission_vnd') return sum + oc.amount;
// For percent: sum the computed VND from invoice_commissions
const icForUser = invoiceCommissions.filter(ic =>
ic.user.id === oc.user.id && ic.order_item?.product_id === oc.order_item?.product_id
);
return sum + icForUser.reduce((s, ic) => s + ic.amount, 0);
}, 0);
});
// FORMULA-002: Đã chi
const totalDisbursed = computed(() =>
invoiceCommissions
.filter(ic => ic.invoice_status === 'invoice_completed')
.reduce((sum, ic) => sum + ic.amount, 0)
);
// FORMULA-003: Thu hồi [PLANNED]
const totalRevoked = computed(() =>
transactions
.filter(t => t.behavior_id === 'refund_commission' && t.status === 'S')
.reduce((sum, t) => sum + t.amount, 0)
);
// FORMULA-004: Đã nhận
const totalReceived = computed(() => totalDisbursed.value - totalRevoked.value);
// Context line logic
const contextLine = computed(() => {
if (totalAllocation.value > totalDisbursed.value)
return { type: 'remaining', amount: totalAllocation.value - totalDisbursed.value };
if (totalAllocation.value === totalReceived.value && totalRevoked.value === 0)
return { type: 'complete' };
return null;
});Render (ASCII wireframe):
+------------------------------------------------------------------------+
| Phân bổ (?) Đã chi (?) Thu hồi (?) Đã nhận (?) |
| 40.000đ 30.000đ 3.000đ 27.000đ |
| |
| (i) 10.000đ chờ thanh toán đợt tiếp theo |
+------------------------------------------------------------------------+Loading state: Skeleton placeholders (QSkeleton) for each metric box.
C6.4 Component: CommissionSummaryCards
File: diva-admin/src/modules/ecommerce/components/order/CommissionSummaryCards.tsx
Props:
typescript
interface CommissionSummaryCardsProps {
orderCommissions: OrderCommission[];
invoiceCommissions: InvoiceCommission[];
transactions: FlatTransaction[];
activeUserId: string | null; // highlight selected card
onCardClick: (userId: string | null) => void;
}Computed values (pseudocode):
typescript
interface EmployeeCard {
userId: string;
displayName: string;
role: string;
avatarUrl: string;
departments: string[];
allocation: number; // FORMULA-001 per user
received: number; // FORMULA-004 per user
status: CardStatus;
serviceBreakdown: ServiceBreakdownItem[];
}
type CardStatus =
| { type: 'complete' } // Đã nhận == Phân bổ
| { type: 'remaining'; amount: number } // Đã nhận < Phân bổ, no revoke
| { type: 'revoked'; amount: number } // Có thu hồi
| { type: 'fully_revoked' } // Đã nhận == 0 + có thu hồi
// Build cards from order_commissions grouped by user_id
const employeeCards = computed(() => {
const cardMap: Record<string, EmployeeCard> = {};
orderCommissions.forEach(oc => {
const uid = oc.user.id;
if (!cardMap[uid]) {
cardMap[uid] = {
userId: uid,
displayName: oc.user.display_name,
role: oc.user.roles?.[0]?.role?.name ?? '',
avatarUrl: oc.user.avatar_url ?? '',
departments: oc.user.departments?.map(d => d.department.name) ?? [],
allocation: 0,
received: 0,
status: { type: 'remaining', amount: 0 },
serviceBreakdown: [],
};
}
// Accumulate allocation per service
cardMap[uid].serviceBreakdown.push({
serviceName: oc.order_item?.product?.product_contents?.[0]?.name ?? '',
amount: oc.amount,
unit: oc.unit,
});
});
// Calculate totals + status per user
Object.values(cardMap).forEach(card => {
card.allocation = computeAllocationForUser(card.userId); // FORMULA-001
const disbursed = computeDisbursedForUser(card.userId); // FORMULA-002
const revoked = computeRevokedForUser(card.userId); // FORMULA-003
card.received = disbursed - revoked; // FORMULA-004
card.status = deriveCardStatus(card.allocation, card.received, revoked, card.userId);
});
// Sort: allocation DESC (FR-011)
return Object.values(cardMap).sort((a, b) => b.allocation - a.allocation);
});Render (ASCII wireframe):
+-------------------------------+ +-------------------------------+
| [Avatar] Nguyễn Văn A | | [Avatar] Trần Thị B |
| Kỹ thuật viên | | Tư vấn viên |
| | | |
| Phân bổ (?) Đã nhận (?) | | Phân bổ (?) Đã nhận (?) |
| 25.000đ 22.000đ | | 15.000đ 5.000đ |
| | | |
| (v) Đã chi đủ | | (!) Thu hồi 5.000đ |
| | | |
| > Chi tiết | | > Chi tiết |
+-------------------------------+ +-------------------------------+
[Expand "Chi tiết"]
+-------------------------------+
| DV | Phân bổ |
|------------------|------------|
| BTX thiết kế | 15.000đ |
| Filler | 10.000đ |
+-------------------------------+Card click (FR-003):
- Click card -> emit
onCardClick(userId)-> parent setsactiveUserId - Active card: highlighted border (class
commission-card--active) - Click active card again -> emit
onCardClick(null)-> clear filter (toggle) - Max 5 cards/row, flex-wrap
C6.5 Component: CommissionTransactionList
File: diva-admin/src/modules/ecommerce/components/order/CommissionTransactionList.tsx
Props:
typescript
interface CommissionTransactionListProps {
orderId: string;
orderType: 'service' | 'cosmetic' | 'prepaid';
activeUserId: string | null; // filter from card click (FR-003)
}Own query: TransactionsOfOrder (existing, fetched internally like current OrderCommissionTable)
Data transformation (pseudocode):
typescript
interface TransactionGroup {
invoiceCode: string;
invoiceDate: string;
transactions: FlatTransaction[];
// Adaptive fields
hasRevoke: boolean; // any behavior_id = 'refund_commission'
// Computed
totalDisburse: number; // SUM transaction_commission amounts
totalRevoke: number; // SUM refund_commission amounts
netAmount: number; // totalDisburse - totalRevoke
revokeRatio: number | null; // FORMULA-005
groupStatus: GroupStatus;
}
// Flatten: invoice.transaction_requests -> flat array per user
// Group by: invoiceCode (NOT invoiceCode+userId like current)
const buildTransactionGroups = (invoices): TransactionGroup[] => {
const groups: Record<string, TransactionGroup> = {};
invoices.forEach(invoice => {
const code = invoice.code;
if (!groups[code]) {
groups[code] = {
invoiceCode: code,
invoiceDate: '',
transactions: [],
hasRevoke: false,
totalDisburse: 0,
totalRevoke: 0,
netAmount: 0,
revokeRatio: null,
groupStatus: 'success',
};
}
invoice.transaction_requests.forEach(tr => {
tr.transaction_request_users
.filter(u => !u.is_sender)
.forEach(userObj => {
groups[code].transactions.push({
code: tr.code,
amount: tr.amount,
created_at: tr.created_at,
status: tr.status,
behavior_id: tr.behavior_id,
activity: tr.activity,
user: userObj.user,
created_by: tr.created_user,
});
if (tr.behavior_id === 'refund_commission') groups[code].hasRevoke = true;
});
});
});
// Compute aggregates per group
Object.values(groups).forEach(g => {
g.totalDisburse = g.transactions
.filter(t => t.behavior_id === 'transaction_commission')
.reduce((s, t) => s + t.amount, 0);
g.totalRevoke = g.transactions
.filter(t => t.behavior_id === 'refund_commission')
.reduce((s, t) => s + t.amount, 0);
g.netAmount = g.totalDisburse - g.totalRevoke;
g.revokeRatio = g.totalDisburse > 0
? Math.round((g.totalRevoke / g.totalDisburse) * 100) : null;
g.invoiceDate = g.transactions[0]?.created_at ?? '';
});
// Sort: time DESC (FR-011)
return Object.values(groups).sort((a, b) =>
new Date(b.invoiceDate).getTime() - new Date(a.invoiceDate).getTime()
);
};Adaptive column logic (DEC-003):
typescript
// Determine which columns to show
const showGroupHeaders = computed(() => true); // v1.1: luôn hiện — mỗi NV có mã GD riêng, cần group header hiện mã TT
const showSubSections = computed(() => // FR-004c
groups.value.some(g => g.hasRevoke)
);
// Base columns: # | Mã GD | Nhân viên | Số tiền | Thời gianClient-side filters (FR-003, FR-005, FR-006):
typescript
// Card click filter (FR-003)
const filteredGroups = computed(() => {
let result = groups.value;
// User filter from card click
if (activeUserId.value) {
result = result.map(g => ({
...g,
transactions: g.transactions.filter(t => t.user.id === activeUserId.value),
})).filter(g => g.transactions.length > 0);
}
// Search filter (FR-005)
if (searchKeyword.value) {
const kw = searchKeyword.value.toLowerCase();
result = result.map(g => ({
...g,
transactions: g.transactions.filter(t =>
g.invoiceCode.toLowerCase().includes(kw) ||
t.code?.toLowerCase().includes(kw) ||
t.user.display_name?.toLowerCase().includes(kw)
),
})).filter(g => g.transactions.length > 0);
}
// Dropdown filter (FR-006)
if (dropdownFilter.value !== 'all') {
result = result.map(g => ({
...g,
transactions: g.transactions.filter(t => {
switch (dropdownFilter.value) {
case 'disburse': return t.behavior_id === 'transaction_commission';
case 'revoke': return t.behavior_id === 'refund_commission';
default: return true;
}
}),
})).filter(g => g.transactions.length > 0);
}
return result;
});Render (ASCII wireframe):
FR-004a (1 lần TT, không thu hồi — luôn hiện group header):
---- #INV-001 . 23/03/2026 ---- (v) Đã chi . 15.000đ ----
+---+----------+--------------+-----------+------------------+
| # | Mã GD | Nhân viên | Số tiền | Thời gian |
+---+----------+--------------+-----------+------------------+
| 1 | GD-001 | Nguyễn Văn A | +10.000đ | 23/03/2026 14:30 |
| 2 | GD-002 | Trần Thị B | +5.000đ | 23/03/2026 14:30 |
+---+----------+--------------+-----------+------------------+FR-004b (nhiều lần TT — nhiều group headers):
---- #MTT-001 . 23/03/2026 ---- (v) Đã chi . 15.000đ ----
+---+----------+--------------+-----------+------------------+
| # | Mã GD | Nhân viên | Số tiền | Thời gian |
+---+----------+--------------+-----------+------------------+
| 1 | GD-001 | Nguyễn Văn A | +10.000đ | 23/03/2026 14:30 |
| 2 | GD-002 | Trần Thị B | +5.000đ | 23/03/2026 14:30 |
+---+----------+--------------+-----------+------------------+
---- #MTT-002 . 20/03/2026 ---- (v) Đã chi . 10.000đ ----
+---+----------+--------------+-----------+------------------+
| 1 | GD-003 | Nguyễn Văn A | +7.000đ | 20/03/2026 10:00 |
| 2 | GD-004 | Trần Thị B | +3.000đ | 20/03/2026 10:00 |
+---+----------+--------------+-----------+------------------+FR-004c (có thu hồi — WITH sub-sections):
---- #MTT-001 . 23/03/2026 ---- (!) Thu hồi 30% . 7.000đ ----
Chi tư vấn Subtotal: 10.000đ
+---+----------+--------------+-----------+------------------+
| 1 | GD-001 | Nguyễn Văn A | +10.000đ | 23/03/2026 14:30 |
+---+----------+--------------+-----------+------------------+
Thu hồi Subtotal: 3.000đ
+---+----------+--------------+-----------+------------------+
| 1 | GD-005 | Nguyễn Văn A | -3.000đ | 24/03/2026 09:00 |
| | | | bởi: Admin| |
+---+----------+--------------+-----------+------------------+C6.6 Refactored OrderCommissions.tsx (Orchestrator)
Changes:
typescript
// BEFORE: 656 LOC monolith
// AFTER: ~200 LOC orchestrator
export default defineComponent({
props: { modelValue: Object as PropType<Order> },
setup(props) {
// KEEP: useRefreshSettings(), permission logic, commission form dialogs
// KEEP: canEditCommission, formModelValue, initialCommissionData, handleCommissionSubmit
// KEEP: isPrepaidOrder, isCosmeticOrder, isServiceOrder
// NEW: activeUserId for card->transaction filter
const activeUserId = ref<string | null>(null);
// CHANGE: editBtnTitle text
const editBtnTitle = computed(() =>
!noInvoices.value ? "Cấu hình tư vấn" : "Cấu hình tư vấn"
);
// CHANGE: section title
// "THÔNG TIN HOA HỒNG" -> "PHÂN BỔ TƯ VẤN"
// CHANGE: success message
// "người nhận hoa hồng" -> "người tư vấn"
return () => (
<>
<QCol>
<CustomerReferralOrderCard ... /> {/* KEEP as-is */}
{/* NEW: Summary Bar */}
<CommissionSummaryBar
orderCommissions={props.modelValue?.order_commissions}
invoiceCommissions={props.modelValue?.invoice_commissions}
transactions={transactionData.value}
loading={commissionLoading.value}
/>
{/* NEW: Summary Cards */}
<CommissionSummaryCards
orderCommissions={props.modelValue?.order_commissions}
invoiceCommissions={props.modelValue?.invoice_commissions}
transactions={transactionData.value}
activeUserId={activeUserId.value}
onCardClick={(uid) => activeUserId.value = uid === activeUserId.value ? null : uid}
/>
<QSeparator ... />
{/* KEEP: Section title + Config button */}
<QRow>
<XInputLabel>"PHÂN BỔ TƯ VẤN"</XInputLabel>
{canEditCommission.value && <XBtn>Cấu hình tư vấn</XBtn>}
</QRow>
{/* KEEP: Commission forms (no change) */}
{hasCommissions.value && (
<>{/* PrepaidOrderCommissionForm / CosmeticOrderCommissionForm / ServiceOrderCommissionForm */}</>
)}
{/* NEW: Transaction List (replaces OrderCommissionTable) */}
<CommissionTransactionList
orderId={props.modelValue?.id}
orderType={orderType.value}
activeUserId={activeUserId.value}
/>
</QCol>
{/* KEEP: Commission create dialogs (no change) */}
</>
);
},
});C6.7 Tooltip Implementation (FR-013)
Pattern: Reuse existing Quasar QTooltip component.
typescript
// Tooltip dictionary (ref ui-spec B9)
// Summary Bar tooltips (canonical source: ui-spec B9)
const SUMMARY_BAR_TOOLTIPS = {
phan_bo: 'Tổng số tiền tư vấn được cấu hình cho các nhân viên trên đơn hàng này. Đây là số tiền dự kiến, chưa phải thực chi.',
da_chi: 'Tổng số tiền tư vấn đã được chuyển vào ví (wallet) của nhân viên thông qua các lần thanh toán đã hoàn tất.',
thu_hoi: 'Số tiền tư vấn bị rút lại do hoàn đơn hoặc điều chỉnh.',
da_nhan: 'Số tiền nhân viên thực tế giữ được = Đã chi - Thu hồi. Đây là con số cuối cùng.',
} as const;
// Card tooltips (shorter, per-NV context)
const CARD_TOOLTIPS = {
phan_bo: 'Số tiền tư vấn được gán cho nhân viên này từ đơn hàng.',
da_nhan: 'Số tiền nhân viên này thực tế giữ = Đã chi - Thu hồi.',
} as const;
// Transaction List tooltips
const TX_TOOLTIPS = {
chi_tu_van: 'Giao dịch chuyển tiền tư vấn vào ví nhân viên khi đơn hàng được thanh toán.',
thu_hoi: 'Giao dịch rút lại tiền tư vấn đã chi, thường do hoàn đơn hoặc sửa sai.',
} as const;
// Usage pattern
<span>Phân bổ <QIcon name="help_outline" size="14px">
<QTooltip>{TOOLTIPS.phan_bo}</QTooltip>
</QIcon></span>C7) Migration
C7.1 Database Migration
KHÔNG CÓ. No DB schema changes needed (DEC-004).
C7.2 i18n Changes
File: diva-admin/src/modules/ecommerce/i18n/vi.ts
| Line | Before | After |
|---|---|---|
| 770 | commissions: "Hoa hồng" | commissions: "Tư vấn" |
| 1076 | order_commission_detail: "Nhân viên nhận hoa hồng" | order_commission_detail: "Nhân viên tư vấn" |
File: diva-admin/src/modules/report/i18n/vi.ts
| Line | Before | After |
|---|---|---|
| 99 | [ROUTE_COMMISSION_REPORT_GROUP]: "Báo cáo hoa hồng" | [ROUTE_COMMISSION_REPORT_GROUP]: "Báo cáo tư vấn" |
| 182 | commission_report_group: "Báo cáo hoa hồng" | commission_report_group: "Báo cáo tư vấn" |
Hardcode fixes (convert to i18n key or direct string change)
| File | Line | Before | After |
|---|---|---|---|
pages/OrderDetail.tsx | 103 | label: "Hoa hồng" | label: i18n.t('ecommerce.label.commissions') |
pages/ProductOrderDetail.tsx | 78 | label: "Hoa hồng" | label: i18n.t('ecommerce.label.commissions') |
pages/OrderCommissions.tsx | 566 | "THÔNG TIN HOA HỒNG" | "PHÂN BỔ TƯ VẤN" |
pages/OrderCommissions.tsx | 109 | "Thêm người nhận HH" / "Cấu hình hoa hồng" | "Cấu hình tư vấn" |
pages/OrderCommissions.tsx | 516 | "người nhận hoa hồng" | "người tư vấn" |
NOT changed (out-of-scope)
| File | Content | Lý do |
|---|---|---|
settings/i18n/vi.ts | "hoa hồng" in commission settings context | Settings module out-of-scope (DEC-004 note) |
ecommerce/i18n/vi.ts:1008,1033,1036 | "hoa hồng CTV", "hoa hồng bác sĩ" | Affiliate/doctor commission — khác domain |
| Notification templates | "hoa hồng" in noti messages | DEC-007: tách task riêng |
C8) Security
KHÔNG THAY ĐỔI. Permission logic giữ nguyên 100%.
C8.1 Existing Permission Matrix (reference)
| Role | Scope | Code | Can Edit Commission |
|---|---|---|---|
| IT Leader / IT Staff / HR Check | Unlimited | globalStore.hasPermissions([UserRole.ITLeader, ITStaff, HRCheck]) | Yes (no time limit) |
| POS (any role) | Time-limited | isPlatformPos() && daysDiff <= expiredDays | Yes (within expired_days_update_commission setting) |
| CRM (Telesales/CS roles) | Role + Time | isPlatformCrm() && hasPermissions([TelesalesLeader, ...]) && isWithinTimeLimit | Yes (within time + has CRM role) |
| Other | None | Default false | No |
Implementation: canEditCommission computed property in OrderCommissions.tsx — KEEP AS-IS.
C8.2 Data Access
- GraphQL queries filtered by Hasura RBAC (
X-Hasura-User-Id,X-Hasura-Branch-Id) - No new queries = no new permission risks
- Tab visibility controlled by route + existing permission checks
C9) NFR (Non-Functional Requirements)
| # | Requirement | Target | How to verify |
|---|---|---|---|
| NFR-001 | Tab load time (Summary Bar + Cards) | < 500ms (data from parent query, no extra fetch) | Chrome DevTools Performance |
| NFR-002 | Transaction list load time | < 1s (existing TransactionsOfOrder query) | Network tab |
| NFR-003 | Client-side filter response | < 100ms (card click, search, dropdown) | Manual test (perception) |
| NFR-004 | Max NV cards rendered | Support 20+ NV cards without layout break | Seed data test |
| NFR-005 | Max transactions rendered | Support 200+ transactions without scroll lag | Seed data test |
| NFR-006 | Bundle size delta | < +5KB gzipped (refactor, no new dependencies) | npm run build comparison |
| NFR-007 | Mobile responsive | Cards stack vertically on viewport < 768px | Quasar responsive test |
| NFR-008 | Backward compatibility | Route URLs unchanged, no breaking changes | URL regression test |
C10) Observability
Optional cho M-size. Ghi nhận các điểm cần monitor.
| # | What | How | Priority |
|---|---|---|---|
| OBS-001 | Tab usage count ("Tư vấn" tab viewed) | Existing analytics tracking (nếu có) | Nice-to-have |
| OBS-002 | Card click filter usage | Console log / analytics event | Nice-to-have |
| OBS-003 | Error boundary: TransactionsOfOrder query failure | Existing URQL error handling + error toast | Must (existing) |
C11) Tasks (Implementation Plan)
C11.1 Task Breakdown
| Task | Description | Depends on | Estimate | Owner |
|---|---|---|---|---|
| T-001 | Setup: tạo 3 file component mới (empty shells) | — | 0.5h | FE Dev |
| T-002 | CommissionSummaryBar.tsx: implement 4 metrics + tooltip + context line + skeleton loading | T-001 | 3h | FE Dev |
| T-003 | CommissionSummaryCards.tsx: implement cards + expand DV + sort + card click emit | T-001 | 4h | FE Dev |
| T-004 | CommissionTransactionList.tsx: implement flat list + group headers + adaptive columns + sub-sections | T-001 | 5h | FE Dev |
| T-005 | Client-side filters: card filter + search (FR-005) + dropdown (FR-006) | T-003, T-004 | 2h | FE Dev |
| T-006 | Refactor OrderCommissions.tsx: orchestrator, wire 3 components, remove old imports | T-002, T-003, T-004 | 2h | FE Dev |
| T-007 | i18n changes: vi.ts + report/vi.ts | — | 1h | FE Dev |
| T-008 | Hardcode fixes: OrderDetail.tsx, ProductOrderDetail.tsx, OrderCommissions.tsx | T-006 | 1h | FE Dev |
| T-009 | Empty state (FR-009) + Loading state (FR-010) | T-002, T-003, T-004 | 1h | FE Dev |
| T-010 | Delete old files: OrderCommissionTable.tsx, OrderCommissionItem.tsx | T-006 | 0.5h | FE Dev |
| T-011 | Feature parity test: verify all 3 order types (service/cosmetic/prepaid) | T-006 | 2h | FE Dev |
| T-012 | Code review + QA handoff | T-011 | 1h | FE Dev + TL |
Total estimate: ~23h (approx 3 dev days)
C11.2 Dependency Graph
T-001 (Setup)
├──> T-002 (SummaryBar) ──┐
├──> T-003 (SummaryCards) ─┼──> T-005 (Filters)
├──> T-004 (TransactionList)┘ │
│ │
└──────────────────────────────────────┘
│
T-007 (i18n) ──────────┐ v
T-008 (Hardcode fixes) ─┼──> T-006 (Refactor orchestrator) ──> T-009 (States)
│ │
└────────────────────────────────────────┘
│
T-010 (Delete old) ─┘
│
T-011 (Parity test) ┘
│
T-012 (Review) ─────┘C11.3 Suggested Sprint Plan
| Day | Tasks | Deliverable |
|---|---|---|
| Day 1 | T-001, T-002, T-003 | Summary Bar + Cards dựng độc |
| Day 2 | T-004, T-005, T-007 | Transaction List + Filters + i18n |
| Day 3 | T-006, T-008, T-009, T-010, T-011, T-012 | Refactor + Integration + QA handoff |
C12) Traceability Matrix
C12.1 FR -> FE Component -> BE Artifact -> Test Case
| FR | FE Component | GraphQL Query | BE Logic | TC ref |
|---|---|---|---|---|
| FR-001 | CommissionSummaryBar.tsx | ServiceOrderDetail (order_commissions + invoice_commissions) | N/A (FE computed) | TC-001-01..TC-001-07 |
| FR-002 | CommissionSummaryCards.tsx | ServiceOrderDetail (order_commissions + invoice_commissions) | N/A (FE computed) | TC-002-01..TC-002-08 |
| FR-003 | CommissionSummaryCards.tsx + CommissionTransactionList.tsx | N/A (client-side filter) | N/A | TC-003-01..TC-003-05 |
| FR-004a | CommissionTransactionList.tsx | TransactionsOfOrder | N/A (FE render) | TC-004a-01..TC-004a-xx |
| FR-004b | CommissionTransactionList.tsx | TransactionsOfOrder | N/A (FE render) | TC-004b-01..TC-004b-xx |
| FR-004c | CommissionTransactionList.tsx | TransactionsOfOrder | N/A (FE render) [PLANNED] | TC-004c-01..TC-004c-xx |
| FR-005 | CommissionTransactionList.tsx | N/A (client-side) | N/A | TC-005-01..TC-005-05 |
| FR-006 | CommissionTransactionList.tsx | N/A (client-side) | N/A | TC-006-01..TC-006-03 |
| FR-007 | ecommerce/i18n/vi.ts, report/i18n/vi.ts, OrderDetail.tsx, ProductOrderDetail.tsx, OrderCommissions.tsx | N/A | N/A | TC-007-01..TC-007-03 |
| FR-008 | OrderCommissions.tsx | N/A | N/A (permission logic unchanged) | TC-008-01..TC-008-03 |
| FR-009 | CommissionSummaryBar.tsx, CommissionSummaryCards.tsx, CommissionTransactionList.tsx | Both queries | N/A | TC-009-01..TC-009-06 |
| FR-010 | All 3 new components | Both queries | N/A | TC-010-01..TC-010-03 |
| FR-011 | CommissionSummaryCards.tsx, CommissionTransactionList.tsx | N/A (FE sort) | N/A | TC-011-01..TC-011-03 |
| FR-012 | All 3 new components | N/A | N/A (FE format) | TC-012-01..TC-012-05 |
| FR-013 | CommissionSummaryBar.tsx, CommissionSummaryCards.tsx | N/A | N/A | TC-013-01..TC-013-03 |
C12.2 DEC -> Implementation
| DEC | Implementation |
|---|---|
| DEC-001 | Layout: CommissionSummaryBar + CommissionSummaryCards + CommissionTransactionList (3 sections) |
| DEC-002 | Card chỉ hiện 2 số: allocation + received + 1 dòng status |
| DEC-003 | Group header luôn hiện (showGroupHeaders = true). Adaptive chỉ còn showSubSections (thu hồi sub-sections) |
| DEC-004 | i18n changes only (C7.2), DB/API untouched |
| DEC-005 | Empty state logic in each component (FR-009) |
| DEC-006 | [PLANNED] tag in FORMULA-003, revoke data = 0 until BE ready |
| DEC-007 | Notification out-of-scope, not in this dev-spec |
| DEC-008 | report/i18n/vi.ts changes (C7.2) |
C12.3 Formula -> Source
| Formula | PRD A10 Ref | FE Component | Data Source | DB Table |
|---|---|---|---|---|
| FORMULA-001 | Phân bổ | CommissionSummaryBar, CommissionSummaryCards | order.order_commissions[].amount | ecommerce.order_commission |
| FORMULA-002 | Đã chi | CommissionSummaryBar, CommissionSummaryCards | order.invoice_commissions[].amount (completed) | ecommerce.invoice_commission |
| FORMULA-003 | Thu hồi [PLANNED] | CommissionSummaryBar, CommissionSummaryCards | transaction_requests[].amount (refund_commission) | wallet.transaction_request |
| FORMULA-004 | Đã nhận | CommissionSummaryBar, CommissionSummaryCards | Computed: FORMULA-002 - FORMULA-003 | — |
| FORMULA-005 | Tỷ lệ thu hồi | CommissionTransactionList | Per-group computed | — |