Appearance
Dev Spec: Cải thiện UX cài đặt Mã Công Việc / Tiền Tour
Feature slug: settings-ux-subtask-tagVersion: 1.0 Ngày: 2026-03-26
C1) Scope
| Loại | Nội dung |
|---|---|
| In scope | Trang TourFeeConfig (gộp), TaskTagSelect enriched, SubTaskTable cải thiện, blast radius display, rename, route redirects |
| Out of scope | Inline-create tag, bulk operations, backend changes, DB changes, Hasura metadata changes |
C2) Impact
| Layer | File | Thay đổi |
|---|---|---|
| FE (new) | settings/pages/TourFeeConfig.tsx | Trang mới — accordion layout |
| FE (new) | settings/components/tour-fee/TourFeeGroupAccordion.tsx | Component nhóm collapsible |
| FE (new) | settings/components/tour-fee/TourFeeTagTable.tsx | Bảng tags + range + chi tiết |
| FE (new) | settings/components/tour-fee/TourFeeUsageExpand.tsx | "Đang dùng bởi" expandable |
| FE (sửa) | settings/components/task-tag/TaskTagSelect.tsx | Enriched chip + dropdown + tooltip |
| FE (sửa) | settings/components/task-tag/TaskTagDisplay.tsx | Display format: name → code (range) |
| FE (sửa) | settings/components/subtask/SubTaskForm.tsx | Rename label |
| FE (sửa) | settings/components/subtask/SubTaskTable.tsx | Cột tag + filter + fix label swap |
| FE (sửa) | settings/pages/SubTask.tsx | Rename title |
| FE (sửa) | settings/module.ts | Route mới + redirects + sidebar rename |
| FE (sửa) | settings/types.ts | Route constants |
| FE (blast) | ecommerce/components/order/OrderForm/OrderTaskLimitForm.tsx | Tag display format |
| FE (blast) | ecommerce/components/service/ServiceForm/ServiceSubtaskItem.tsx | Tag display format |
| GraphQL | settings/graphql/task-tag.graphql | Fragment thêm tag_tour_moneys |
| GraphQL | settings/graphql/subtask.graphql | Fragment thêm tag.code + tag_tour_moneys |
C3) Rules & Formulas
FORMULA-001: Tour Fee Range
- Mô tả: Tính range tiền tour từ bảng tour_moneys của 1 tag.
- Công thức:
min = MIN(tag_tour_moneys.tour_money)max = MAX(tag_tour_moneys.tour_money)- Nếu
min === max→ flat-rate:"${formatCurrency(min)}đ" - Nếu
min !== max→ multi-rate:"${formatShort(min)}~${formatShort(max)}" - Nếu
tag_tour_moneys.length === 0→"—"
- Biến số:
tour_money: số tiền tour — nguồn:tag_tour_moneys.tour_money
- Đơn vị: VNĐ
- Ví dụ:
- T26: [100k, 100k, 100k, 100k, 100k] → flat → "100,000đ"
- T15: [50k, 60k, 80k, 100k, 120k] → multi → "50k~120k"
- T99: [] → empty → "—"
- Edge cases:
- Tất cả = 0 → flat → "0đ"
- Chỉ 1 cấp bậc → flat → "{amount}đ"
- NULL tour_money → exclude khỏi min/max
FORMULA-002: Tour Fee Detail Summary
- Mô tả: Tóm tắt chi tiết tiền tour theo cấp bậc.
- Công thức:
- Flat-rate:
"CĐ1-{n}: đều {amount}" - Multi-rate:
"CĐ1:{amount1} CĐ2:{amount2} ... CĐn:{amountn}" - Empty:
"Chưa có tiền tour"
- Flat-rate:
- Ví dụ:
- T26: "CĐ1-5: đều 100k"
- T15: "CĐ1:50k CĐ2:60k CĐ3:80k CĐ4:100k CĐ5:120k"
C4) Data Model
ZERO database changes. Tất cả tables đã tồn tại:
| Table | DB | Vai trò trong feature |
|---|---|---|
project_task_group_tag | project | Nhóm tiền tour (accordion header) |
project_task_tag | project | Tag tiền tour (accordion content) |
tag_tour_money | project | Tiền tour theo cấp bậc (range + chi tiết) |
subtask | ecommerce | Mã công việc (SubTaskTable) |
subtask_tag | ecommerce | Junction subtask ↔ tag (cột tag + "đang dùng bởi") |
C5) API & GraphQL
Fragment mở rộng — task-tag.graphql
Thêm vào fragment TaskTag:
graphql
fragment TaskTag on project_task_tag {
code
group_tag_id
project_task_group_tag {
name
}
tag_tour_moneys { # MỚI
seniority
tour_money
level_info { # MỚI — cho tooltip
name
}
}
id
name
disabled
}Fragment mở rộng — subtask.graphql
Thêm vào fragment Subtask:
graphql
fragment Subtask on subtask {
subtask_tags {
tag_id
tag {
name
code # MỚI
tag_tour_moneys { # MỚI
tour_money
}
}
}
code
default_subtask
description
disabled
id
name
type
}Query: Trang TourFeeConfig
Reuse GetTaskTagGroup — mở rộng nested data:
graphql
query GetTaskTagGroupWithTags($where: project_task_group_tag_bool_exp, $order_by: [project_task_group_tag_order_by!]) {
project_task_group_tag(where: $where, order_by: $order_by) {
id
code
name
disabled
project_task_tags(order_by: { code: asc }) {
id
code
name
disabled
tag_tour_moneys {
seniority
tour_money
level_info {
name
}
}
}
project_task_tags_aggregate {
aggregate {
count
}
}
}
}Query: "Đang dùng bởi" (lazy load)
graphql
query GetSubtasksByTagIds($tagIds: [uuid!]!) {
subtask(
where: { subtask_tags: { tag_id: { _in: $tagIds } } }
order_by: { code: desc }
limit: 50
) {
id
code
name
subtask_tags {
tag_id
tag {
code
name
}
}
}
}Error Contract
| Scenario | Error | FE handling |
|---|---|---|
| GetTaskTagGroupWithTags fail | GraphQL error | Toast "Không thể tải dữ liệu tiền tour" |
| GetSubtasksByTagIds fail | GraphQL error | Toast "Không thể tải danh sách mã công việc" |
| Xóa nhóm có tags | Validation block (FE side) | Toast "Nhóm đang có {N} tag. Xóa hoặc chuyển tag trước." |
C6) Scheduler
Không có scheduler.
C7) Migration
ZERO migration. Không thay đổi database.
C8) Security
| Concern | Mitigation |
|---|---|
| Unauthorized access TourFeeConfig | Giữ nguyên route guard: UserRole.ITLeader, UserRole.ITStaff |
| Route redirect bypass | Redirect routes cũng require same permissions |
| Cross-module data exposure | Tag data đã public cho role user (Hasura permission đã có) |
C9) NFR
| ID | Requirement | Metric | Target |
|---|---|---|---|
| NFR-001 | TourFeeConfig page load | First meaningful paint | < 1s (lazy load accordion) |
| NFR-002 | Accordion expand | Tags render time | < 300ms per group |
| NFR-003 | SubTaskTable with tag column | Load 275+ rows | < 500ms |
| NFR-004 | TaskTagSelect dropdown | Options load | < 300ms (existing limit 10) |
| NFR-005 | "Đang dùng bởi" expand | Lazy query time | < 500ms |
C10) Observability
| Metric | Cách đo | Alert threshold |
|---|---|---|
| TourFeeConfig page load time | Performance API | p99 > 3s → investigate |
| GetTaskTagGroupWithTags query time | Hasura request duration | p99 > 1s → alert |
| GetSubtasksByTagIds query time | Hasura request duration | p99 > 1s → alert |
| Route redirect miss (404) | Client error log | Any 404 on old routes → alert |
C11) Tasks (Dev breakdown)
| # | Task | Estimate | Dependency |
|---|---|---|---|
| T1 | Mở rộng GraphQL fragments (task-tag + subtask) + pnpm codegen | 0.5d | — |
| T2 | Utility function computeTourFeeRange() + computeTourFeeDetail() | 0.25d | T1 |
| T3 | TaskTagSelect enriched: chip format + dropdown option format | 1d | T1, T2 |
| T4 | TaskTagSelect tooltip: hover chip → bảng tiền tour + link | 0.5d | T3 |
| T5 | TourFeeConfig page + route + sidebar | 0.5d | T1 |
| T6 | TourFeeGroupAccordion (expand/collapse, search, add group) | 1.5d | T5 |
| T7 | TourFeeTagTable (bảng tags + range + chi tiết + status + actions) | 1d | T2, T6 |
| T8 | TourFeeUsageExpand (lazy query + expand/collapse) | 0.5d | T7 |
| T9 | Reuse TaskTagForm trong TourFeeConfig (side dialog) | 0.5d | T6 |
| T10 | SubTaskTable: thêm cột "Tag tiền tour" | 0.5d | T1, T2 |
| T11 | SubTaskTable: thêm filter nhóm/tag + fix label swap | 0.5d | T10 |
| T12 | SubTaskForm: rename labels | 0.1d | — |
| T13 | OrderTaskLimitForm: sửa tag display format | 0.25d | T2 |
| T14 | ServiceSubtaskItem: sửa tag display format | 0.25d | T2 |
| T15 | TaskTagDisplay: enriched format | 0.25d | T2 |
| T16 | Route redirects + route name mapping | 0.25d | T5 |
| T17 | Sidebar rename (module.ts) | 0.1d | T5 |
| T18 | Testing + fix + responsive | 1.5d | All |
| Tổng | ~10.85d |
C12) Traceability
| FR/NFR | FE Artifact | TC-ID | Status |
|---|---|---|---|
| FR-001 Trang TourFeeConfig | SCR-01: TourFeeConfig + Accordion + TagTable | TC-FR-001 | Planned |
| FR-002 Range + Chi tiết | SCR-01: TourFeeTagTable columns | TC-FR-002 | Planned |
| FR-003 "Đang dùng bởi" | SCR-01: TourFeeUsageExpand | TC-FR-003 | Planned |
| FR-004 Tag chip enriched | SCR-03,04,05,06: TaskTagSelect + Display | TC-FR-004 | Planned |
| FR-005 Tooltip tiền tour | SCR-03: TaskTagSelect tooltip | TC-FR-005 | Planned |
| FR-006 Dropdown enriched | SCR-03,04: TaskTagSelect dropdown | TC-FR-006 | Planned |
| FR-007 SubTaskTable cải thiện | SCR-02: SubTaskTable | TC-FR-007 | Planned |
| FR-008 Rename + redirects | Sidebar + routes | TC-FR-008 | Planned |
| NFR-001 Page load | Performance test | TC-NFR-001 | Planned |
| NFR-003 SubTaskTable load | Performance test | TC-NFR-002 | Planned |