Skip to content

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

VersionDateAuthorThay đổi
1.023/03/2026Dev TeamInitial dev-spec — FE refactor, no BE changes
1.107/04/2026Dev TeamLoạ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ụcLayerEffort
1Tách OrderCommissions.tsx (656 LOC) thành 3 components mớiFEM
2Tạo CommissionSummaryBar — 4 chỉ số tổng quan đơn hàngFES
3Tạo CommissionSummaryCards — cards per NV với expand chi tiết DVFEM
4Refactor OrderCommissionTable thành CommissionTransactionList — flat list, adaptive columnsFEM
5Đổi thuật ngữ i18n + hardcode: "Hoa hồng" -> "Tư vấn"FES
6Client-side filter: card click filter + search + dropdownFES
7Empty state + Loading stateFES

C1.2 Out-of-scope

#Hạng mụcLý do
1DB schema changesDEC-004: chỉ đổi UI, không đổi DB
2Backend logic changesKhông đổi commission calculation
3New GraphQL queries/mutationsReuse ServiceOrderDetail + TransactionsOfOrder
4Notification template migrationDEC-007: tách task riêng
5Customer commission (affiliate)Backlog Phase 2
6Commission settings module renameChỉ đổi report label (i18n)

C2) Impact Summary

C2.1 Impact Map

AreaComponent / FileActionImpact
FE PageOrderCommissions.tsx (656 LOC)REFACTOR — tách thành orchestrator + 3 child componentsHigh
FE ComponentOrderCommissionTable.tsxREPLACE — refactor thành CommissionTransactionList.tsxHigh
FE ComponentOrderCommissionItem.tsxREMOVE — logic nhập vào CommissionTransactionList (không còn nested expand)High
FE i18necommerce/i18n/vi.ts:770EDITcommissions: "Hoa hồng" -> "Tư vấn"Low
FE i18nreport/i18n/vi.ts:99EDIT"Báo cáo hoa hồng" -> "Báo cáo tư vấn"Low
FE i18nreport/i18n/vi.ts:182EDITcommission_report_group -> "Báo cáo tư vấn"Low
FE PageOrderDetail.tsx:103EDIT — hardcode "Hoa hồng" -> i18n keyLow
FE PageProductOrderDetail.tsx:78EDIT — hardcode "Hoa hồng" -> i18n keyLow
FE PageOrderCommissions.tsx:566EDIT"THÔNG TIN HOA HỒNG" -> "PHÂN BỔ TƯ VẤN"Low
FE PageOrderCommissions.tsx:109EDIT"Thêm người nhận HH" -> "Cấu hình tư vấn"Low
FE PageOrderCommissions.tsx:516EDIT"người nhận hoa hồng" -> "người tư vấn"Low
BENONE — không đổi logic, không đổi APINone
DBNONE — không đổi schemaNone

C2.2 Risk Assessment

RiskSeverityMitigation
Regression: mất tính năng cũ sau refactorHigh100% feature parity check (QA test cases), reuse existing data logic
Commission form dialogs bị ảnh hưởngMediumKhông đổi dialog components (ServiceOrderCommissionCreate, CosmeticOrderCommissionCreate, PrepaidOrderCommissionCreate)
GraphQL query response thiếu fieldLowĐã confirm: ServiceOrderDetailTransactionsOfOrder đủ 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[].amount WHERE user.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[].amount WHERE user.id = {userId} AND invoice_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.amount WHERE behavior_id = 'refund_commission'
  • DB View reference: wallet.order_commission_refund — aggregates transaction_commission vs refund_commission per 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

ColumnTypeDescription
order_iduuidFK -> order.id
user_iduuidFK -> account.id (NV nhận commission)
order_item_iduuidFK -> order_item.id
amountintegerGiá trị cấu hình (VND hoặc %)
unittextcommission_vnd / commission_percent
created_byuuidFK -> 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

ColumnTypeDescription
iduuidPK
user_iduuidFK -> account.id
order_iduuidFK -> order.id
invoice_iduuidFK -> invoice.id
order_item_iduuidFK -> order_item.id
amountbigintSố tiền commission đã tính (luôn VND)
unittextLuôn = vnd (đã convert từ %)
invoice_statustextCopy 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──> product

C5) 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.tsx

C6.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 sets activeUserId
  • 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 gian

Client-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 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

LineBeforeAfter
770commissions: "Hoa hồng"commissions: "Tư vấn"
1076order_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

LineBeforeAfter
99[ROUTE_COMMISSION_REPORT_GROUP]: "Báo cáo hoa hồng"[ROUTE_COMMISSION_REPORT_GROUP]: "Báo cáo tư vấn"
182commission_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)

FileLineBeforeAfter
pages/OrderDetail.tsx103label: "Hoa hồng"label: i18n.t('ecommerce.label.commissions')
pages/ProductOrderDetail.tsx78label: "Hoa hồng"label: i18n.t('ecommerce.label.commissions')
pages/OrderCommissions.tsx566"THÔNG TIN HOA HỒNG""PHÂN BỔ TƯ VẤN"
pages/OrderCommissions.tsx109"Thêm người nhận HH" / "Cấu hình hoa hồng""Cấu hình tư vấn"
pages/OrderCommissions.tsx516"người nhận hoa hồng""người tư vấn"

NOT changed (out-of-scope)

FileContentLý do
settings/i18n/vi.ts"hoa hồng" in commission settings contextSettings 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 messagesDEC-007: tách task riêng

C8) Security

KHÔNG THAY ĐỔI. Permission logic giữ nguyên 100%.

C8.1 Existing Permission Matrix (reference)

RoleScopeCodeCan Edit Commission
IT Leader / IT Staff / HR CheckUnlimitedglobalStore.hasPermissions([UserRole.ITLeader, ITStaff, HRCheck])Yes (no time limit)
POS (any role)Time-limitedisPlatformPos() && daysDiff <= expiredDaysYes (within expired_days_update_commission setting)
CRM (Telesales/CS roles)Role + TimeisPlatformCrm() && hasPermissions([TelesalesLeader, ...]) && isWithinTimeLimitYes (within time + has CRM role)
OtherNoneDefault falseNo

Implementation: canEditCommission computed property in OrderCommissions.tsxKEEP 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)

#RequirementTargetHow to verify
NFR-001Tab load time (Summary Bar + Cards)< 500ms (data from parent query, no extra fetch)Chrome DevTools Performance
NFR-002Transaction list load time< 1s (existing TransactionsOfOrder query)Network tab
NFR-003Client-side filter response< 100ms (card click, search, dropdown)Manual test (perception)
NFR-004Max NV cards renderedSupport 20+ NV cards without layout breakSeed data test
NFR-005Max transactions renderedSupport 200+ transactions without scroll lagSeed data test
NFR-006Bundle size delta< +5KB gzipped (refactor, no new dependencies)npm run build comparison
NFR-007Mobile responsiveCards stack vertically on viewport < 768pxQuasar responsive test
NFR-008Backward compatibilityRoute URLs unchanged, no breaking changesURL regression test

C10) Observability

Optional cho M-size. Ghi nhận các điểm cần monitor.

#WhatHowPriority
OBS-001Tab usage count ("Tư vấn" tab viewed)Existing analytics tracking (nếu có)Nice-to-have
OBS-002Card click filter usageConsole log / analytics eventNice-to-have
OBS-003Error boundary: TransactionsOfOrder query failureExisting URQL error handling + error toastMust (existing)

C11) Tasks (Implementation Plan)

C11.1 Task Breakdown

TaskDescriptionDepends onEstimateOwner
T-001Setup: tạo 3 file component mới (empty shells)0.5hFE Dev
T-002CommissionSummaryBar.tsx: implement 4 metrics + tooltip + context line + skeleton loadingT-0013hFE Dev
T-003CommissionSummaryCards.tsx: implement cards + expand DV + sort + card click emitT-0014hFE Dev
T-004CommissionTransactionList.tsx: implement flat list + group headers + adaptive columns + sub-sectionsT-0015hFE Dev
T-005Client-side filters: card filter + search (FR-005) + dropdown (FR-006)T-003, T-0042hFE Dev
T-006Refactor OrderCommissions.tsx: orchestrator, wire 3 components, remove old importsT-002, T-003, T-0042hFE Dev
T-007i18n changes: vi.ts + report/vi.ts1hFE Dev
T-008Hardcode fixes: OrderDetail.tsx, ProductOrderDetail.tsx, OrderCommissions.tsxT-0061hFE Dev
T-009Empty state (FR-009) + Loading state (FR-010)T-002, T-003, T-0041hFE Dev
T-010Delete old files: OrderCommissionTable.tsx, OrderCommissionItem.tsxT-0060.5hFE Dev
T-011Feature parity test: verify all 3 order types (service/cosmetic/prepaid)T-0062hFE Dev
T-012Code review + QA handoffT-0111hFE 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

DayTasksDeliverable
Day 1T-001, T-002, T-003Summary Bar + Cards dựng độc
Day 2T-004, T-005, T-007Transaction List + Filters + i18n
Day 3T-006, T-008, T-009, T-010, T-011, T-012Refactor + Integration + QA handoff

C12) Traceability Matrix

C12.1 FR -> FE Component -> BE Artifact -> Test Case

FRFE ComponentGraphQL QueryBE LogicTC ref
FR-001CommissionSummaryBar.tsxServiceOrderDetail (order_commissions + invoice_commissions)N/A (FE computed)TC-001-01..TC-001-07
FR-002CommissionSummaryCards.tsxServiceOrderDetail (order_commissions + invoice_commissions)N/A (FE computed)TC-002-01..TC-002-08
FR-003CommissionSummaryCards.tsx + CommissionTransactionList.tsxN/A (client-side filter)N/ATC-003-01..TC-003-05
FR-004aCommissionTransactionList.tsxTransactionsOfOrderN/A (FE render)TC-004a-01..TC-004a-xx
FR-004bCommissionTransactionList.tsxTransactionsOfOrderN/A (FE render)TC-004b-01..TC-004b-xx
FR-004cCommissionTransactionList.tsxTransactionsOfOrderN/A (FE render) [PLANNED]TC-004c-01..TC-004c-xx
FR-005CommissionTransactionList.tsxN/A (client-side)N/ATC-005-01..TC-005-05
FR-006CommissionTransactionList.tsxN/A (client-side)N/ATC-006-01..TC-006-03
FR-007ecommerce/i18n/vi.ts, report/i18n/vi.ts, OrderDetail.tsx, ProductOrderDetail.tsx, OrderCommissions.tsxN/AN/ATC-007-01..TC-007-03
FR-008OrderCommissions.tsxN/AN/A (permission logic unchanged)TC-008-01..TC-008-03
FR-009CommissionSummaryBar.tsx, CommissionSummaryCards.tsx, CommissionTransactionList.tsxBoth queriesN/ATC-009-01..TC-009-06
FR-010All 3 new componentsBoth queriesN/ATC-010-01..TC-010-03
FR-011CommissionSummaryCards.tsx, CommissionTransactionList.tsxN/A (FE sort)N/ATC-011-01..TC-011-03
FR-012All 3 new componentsN/AN/A (FE format)TC-012-01..TC-012-05
FR-013CommissionSummaryBar.tsx, CommissionSummaryCards.tsxN/AN/ATC-013-01..TC-013-03

C12.2 DEC -> Implementation

DECImplementation
DEC-001Layout: CommissionSummaryBar + CommissionSummaryCards + CommissionTransactionList (3 sections)
DEC-002Card chỉ hiện 2 số: allocation + received + 1 dòng status
DEC-003Group header luôn hiện (showGroupHeaders = true). Adaptive chỉ còn showSubSections (thu hồi sub-sections)
DEC-004i18n changes only (C7.2), DB/API untouched
DEC-005Empty state logic in each component (FR-009)
DEC-006[PLANNED] tag in FORMULA-003, revoke data = 0 until BE ready
DEC-007Notification out-of-scope, not in this dev-spec
DEC-008report/i18n/vi.ts changes (C7.2)

C12.3 Formula -> Source

FormulaPRD A10 RefFE ComponentData SourceDB Table
FORMULA-001Phân bổCommissionSummaryBar, CommissionSummaryCardsorder.order_commissions[].amountecommerce.order_commission
FORMULA-002Đã chiCommissionSummaryBar, CommissionSummaryCardsorder.invoice_commissions[].amount (completed)ecommerce.invoice_commission
FORMULA-003Thu hồi [PLANNED]CommissionSummaryBar, CommissionSummaryCardstransaction_requests[].amount (refund_commission)wallet.transaction_request
FORMULA-004Đã nhậnCommissionSummaryBar, CommissionSummaryCardsComputed: FORMULA-002 - FORMULA-003
FORMULA-005Tỷ lệ thu hồiCommissionTransactionListPer-group computed