Skip to content

Hướng dẫn từng bước: quản lý đơn hàng

Dựng một màn hình nghiệp vụ hoàn chỉnh — danh sách có lọc, trang chi tiết, form thêm mới, phân quyền — trên dự án vừa tạo bằng CLI. Khoảng 45 phút. Mỗi bước là một file hoàn chỉnh: chép vào dự án là chạy được.

Điều kiện

Đã làm xong Tạo app mới, pnpm dev chạy được và .envNUXT_AUTH_MOCK=true. Backend giả định phục vụ GET /orders, GET /orders/:id, POST /orders sau NUXT_API_PROXY_TARGET; chưa có backend thì xem bước 2b.

Bước 1 — Khai báo menu

Menu, tiêu đề và footer đều nằm trong app/app.config.ts. Layout, sidebar, header đã có sẵn từ layer.

ts
// app/app.config.ts
import { h } from 'vue'
import { IconHome, IconShoppingCart } from '@tabler/icons-vue'
import type { NavItem } from '@tasco/ui'

export default defineAppConfig({
  tasco: {
    appTitle: 'Quản lý đơn hàng',
    nav: [
      { label: 'Trang chủ', path: '/', icon: () => h(IconHome, { size: 18 }) },
      {
        label: 'Đơn hàng',
        path: '/orders',
        icon: () => h(IconShoppingCart, { size: 18 }),
        // Người không có quyền này sẽ không thấy mục menu.
        permission: '/orders/search',
      },
    ] satisfies NavItem[],
  },
})

Icon là render function nên chế độ thu gọn sidebar vẫn hiển thị đúng. Dùng @tabler/icons-vue — đã có sẵn trong dự án, không cài thêm bộ icon khác.

Bước 2 — Lớp gọi backend

Đặt mọi lời gọi HTTP của một nghiệp vụ vào một composable. Trang không tự gọi useApi, nhờ vậy đổi đường dẫn hay định dạng backend chỉ sửa một chỗ.

ts
// app/composables/useOrdersApi.ts
import type { Order, OrderInput, Paginated, TableQuery } from './orders.types'

export function useOrdersApi() {
  const api = useApi()

  /** Backend nhận `sort`/`order` và các bộ lọc ở cấp cao nhất — quy đổi tại đây. */
  function fetchOrders(query: TableQuery): Promise<Paginated<Order>> {
    return api<Paginated<Order>>('/orders', {
      query: {
        page: query.page,
        pageSize: query.pageSize,
        sort: query.sortField,
        order: query.sortOrder === 'descend' ? 'desc' : query.sortOrder ? 'asc' : undefined,
        ...query.filters,
      },
    })
  }

  const fetchOrder = (id: string) => api<Order>(`/orders/${id}`)

  const createOrder = (payload: OrderInput) =>
    api<Order>('/orders', { method: 'POST', body: payload })

  return { fetchOrders, fetchOrder, createOrder }
}
ts
// app/composables/orders.types.ts
export interface Order {
  id: number
  code: string
  customer: string
  amount: number
  status: 'new' | 'processing' | 'done' | 'cancelled'
  createdAt: string
}

export interface OrderInput {
  customer: string
  amount: number
  note?: string
}

PaginatedTableQuery là kiểu của framework:

ts
// app/composables/orders.types.ts (tiếp)
export type { Paginated } from '@tasco/utils'
export type { TableQuery } from '@tasco/composables'

Cài thêm package kiểu

@tasco/utils@tasco/composables là phụ thuộc gián tiếp qua layer. Muốn import type trực tiếp thì thêm chúng vào dependencies của dự án — cùng phiên bản với @tasco/nuxt-layer-base.

Đường dẫn /orders đi qua BFF: trình duyệt gọi /api/orders, Nitro gắn Bearer từ cookie phiên rồi chuyển tiếp sang NUXT_API_PROXY_TARGET. Token không bao giờ xuống client. Xem BFF: /api/** đi đâu.

Bước 2b — Chưa có backend

Trả dữ liệu giả ngay trong composable để làm giao diện trước; đổi sang useApi khi backend sẵn sàng.

ts
// app/composables/useOrdersApi.ts — bản tạm
const ALL: Order[] = Array.from({ length: 47 }, (_, i) => ({
  id: i + 1,
  code: `DH-${String(i + 1).padStart(4, '0')}`,
  customer: ['Công ty An Bình', 'Tập đoàn Bắc Sơn', 'Cửa hàng Cường Thịnh'][i % 3] ?? '',
  amount: (i + 1) * 1_250_000,
  status: (['new', 'processing', 'done', 'cancelled'] as const)[i % 4] ?? 'new',
  createdAt: '2026-09-01',
}))

async function fetchOrders(query: TableQuery): Promise<Paginated<Order>> {
  await new Promise((resolve) => setTimeout(resolve, 200))
  const start = (query.page - 1) * query.pageSize
  return { items: ALL.slice(start, start + query.pageSize), total: ALL.length }
}

Bước 3 — Trang danh sách

Trạng thái xuất hiện ở cả danh sách lẫn chi tiết, nên map màu và nhãn một chỗ. File trong app/utils/ được Nuxt auto-import, không cần khai báo lại ở từng trang.

ts
// app/utils/order-status.ts
import type { Order } from '~/composables/orders.types'

export const ORDER_STATUS: Record<
  Order['status'],
  { color: 'primary' | 'warning' | 'success' | 'error', label: string }
> = {
  new: { color: 'primary', label: 'Mới' },
  processing: { color: 'warning', label: 'Đang xử lý' },
  done: { color: 'success', label: 'Hoàn thành' },
  cancelled: { color: 'error', label: 'Đã huỷ' },
}
vue
<!-- app/pages/orders/index.vue -->
<script setup lang="ts">
import type { Order } from '~/composables/orders.types'

definePageMeta({ permissions: ['/orders/search'] })

const { fetchOrders } = useOrdersApi()
const { dataSource, loading, pagination, onChange, reload } = useTable<Order>(fetchOrders, {
  pageSize: 20,
})

const columns = [
  { title: 'Mã đơn', dataIndex: 'code', key: 'code', width: 140 },
  { title: 'Khách hàng', dataIndex: 'customer', key: 'customer' },
  { title: 'Giá trị', dataIndex: 'amount', key: 'amount', align: 'right', width: 160, sorter: true },
  { title: 'Trạng thái', dataIndex: 'status', key: 'status', width: 150 },
]

const currency = new Intl.NumberFormat('vi-VN')
</script>

<template>
  <CPageHeader title="Đơn hàng" sub-title="Danh sách đơn hàng toàn hệ thống" />

  <CTable
    row-key="id"
    :columns="columns"
    :data-source="dataSource"
    :loading="loading"
    :pagination="pagination"
    show-reload
    @change="onChange"
    @reload="reload"
  >
    <template #bodyCell="{ column, record }">
      <template v-if="column.key === 'amount'">
        {{ currency.format((record as Order).amount) }} ₫
      </template>
      <template v-else-if="column.key === 'status'">
        <CTag :color="ORDER_STATUS[(record as Order).status].color" dot>
          {{ ORDER_STATUS[(record as Order).status].label }}
        </CTag>
      </template>
    </template>
  </CTable>
</template>

Vào http://localhost:3000/orders. useTable tự gọi fetcher lần đầu, giữ trang/sắp xếp/bộ lọc và đưa pagination + onChange sẵn sàng để bind — không cần tự quản lý state phân trang.

Ép kiểu bản ghi trong #bodyCell bằng as Ordera-table khai recordunknown, và quy ước của framework cấm any.

Bước 4 — Tìm kiếm và bộ lọc

CTable có sẵn ô tìm kiếm và drawer lọc. Trang chỉ khai báo các trường lọc, useTable gộp chúng vào query.filters gửi cho fetcher.

vue
<script setup lang="ts">
import type { TableFilterField } from '@tasco/ui'

const { dataSource, loading, pagination, filterValues, onChange, onFilter, reload }
  = useTable<Order>(fetchOrders, { pageSize: 20 })

const search = ref('')

const filterFields: TableFilterField[] = [
  { key: 'customer', label: 'Khách hàng', type: 'input', placeholder: 'Tên khách hàng' },
  {
    key: 'status',
    label: 'Trạng thái',
    type: 'select',
    multiple: true,
    options: Object.entries(ORDER_STATUS).map(([value, { label }]) => ({ label, value })),
  },
  { key: 'createdAt', label: 'Ngày tạo', type: 'dateRange' },
]

function onSearch(keyword: string) {
  onFilter({ ...filterValues.value, keyword: keyword || undefined })
}
</script>

<template>
  <CTable
    v-model:search-value="search"
    row-key="id"
    :columns="columns"
    :data-source="dataSource"
    :loading="loading"
    :pagination="pagination"
    :filter-fields="filterFields"
    :filter-values="filterValues"
    show-search
    show-filter
    show-reload
    @change="onChange"
    @search="onSearch"
    @reload="reload"
    @update:filter-values="onFilter"
  />
</template>
  • Bấm Áp dụng trong drawer → @update:filter-valuesonFilter về trang 1 và tải lại.
  • Điều kiện đang áp dụng hiện thành thẻ ngay trên bảng; bỏ một thẻ cũng phát update:filterValues.
  • Đổi trang hay sắp xếp không làm mất bộ lọc — useTable giữ bộ lọc form tách khỏi filter cột.

Bước 5 — Trang chi tiết

vue
<!-- app/pages/orders/[id].vue -->
<script setup lang="ts">
import { CDescriptions, CDescriptionsItem } from '@tasco/ui'
import type { Order } from '~/composables/orders.types'

definePageMeta({ permissions: ['/orders/search'] })

const route = useRoute()
const { fetchOrder } = useOrdersApi()

const { data: order, status, error } = await useAsyncData<Order>(
  () => `order-${route.params.id}`,
  () => fetchOrder(String(route.params.id)),
)
</script>

<template>
  <CPageHeader
    :title="order?.code ?? 'Đơn hàng'"
    :breadcrumb="[{ title: 'Đơn hàng', to: '/orders' }, { title: order?.code ?? '' }]"
    @navigate="navigateTo"
  />

  <CCard v-if="status === 'pending'">
    Đang tải…
  </CCard>

  <CEmpty
    v-else-if="error || !order"
    description="Không tải được đơn hàng này."
    bordered
  />

  <CCard v-else>
    <CDescriptions :column="2" bordered>
      <CDescriptionsItem label="Mã đơn">{{ order.code }}</CDescriptionsItem>
      <CDescriptionsItem label="Khách hàng">{{ order.customer }}</CDescriptionsItem>
      <CDescriptionsItem label="Ngày tạo">{{ order.createdAt }}</CDescriptionsItem>
      <CDescriptionsItem label="Trạng thái">
        <CTag :color="ORDER_STATUS[order.status].color" dot>{{ ORDER_STATUS[order.status].label }}</CTag>
      </CDescriptionsItem>
    </CDescriptions>
  </CCard>
</template>

CDescriptions là primitive antdv re-export nên phải import từ @tasco/ui; chỉ 17 component thương hiệu (CTable, CTag, CCard…) mới đăng ký toàn cục. Chi tiết: Trang chi tiết.

Bước 6 — Form thêm mới

vue
<!-- app/pages/orders/new.vue -->
<script setup lang="ts">
import { CFormItem, CInput, CTextarea, message } from '@tasco/ui'
import { getFieldErrors, isValidation } from '@tasco/utils'
import type { OrderInput } from '~/composables/orders.types'

definePageMeta({ permissions: ['/orders/create'] })

const { createOrder } = useOrdersApi()
const { $tascoError } = useNuxtApp()

const model = reactive<OrderInput>({ customer: '', amount: 0, note: '' })
const submitting = ref(false)
// Lỗi 422 do backend trả về, theo từng field.
const serverErrors = ref<Record<string, string[]>>({})

const rules = {
  customer: [{ required: true, message: 'Nhập tên khách hàng' }],
  amount: [{ required: true, message: 'Nhập giá trị đơn hàng' }],
}

// CForm phát `finish` sau khi mọi rule phía client đã qua — đủ cho form một nút Lưu như đây.
// Form phải validate từng phần (wizard) thì gọi `formRef.value.validate([...])` qua ref, xem
// [Trang form](/guide/patterns/form).
async function onFinish() {
  submitting.value = true
  serverErrors.value = {}
  try {
    const order = await createOrder({ ...model })
    message.success('Đã tạo đơn hàng.')
    await navigateTo(`/orders/${order.id}`)
  } catch (caught) {
    // 422 → hiện lỗi ngay dưới ô nhập, không toast. Lỗi khác để handler chung xử lý.
    if (isValidation(caught)) serverErrors.value = getFieldErrors(caught)
    else $tascoError(caught)
  } finally {
    submitting.value = false
  }
}
</script>

<template>
  <CPageHeader
    title="Tạo đơn hàng"
    :breadcrumb="[{ title: 'Đơn hàng', to: '/orders' }, { title: 'Tạo mới' }]"
    @navigate="navigateTo"
  />

  <CCard>
    <CForm :model="model" :rules="rules" style="max-width: 520px" @finish="onFinish">
      <CFormItem
        label="Khách hàng"
        name="customer"
        :validate-status="serverErrors.customer ? 'error' : undefined"
        :help="serverErrors.customer?.[0]"
      >
        <CInput v-model:value="model.customer" placeholder="Tên khách hàng" />
      </CFormItem>

      <CFormItem
        label="Giá trị"
        name="amount"
        :validate-status="serverErrors.amount ? 'error' : undefined"
        :help="serverErrors.amount?.[0]"
      >
        <CInputCurrency v-model:value="model.amount" />
      </CFormItem>

      <CFormItem label="Ghi chú" name="note">
        <CTextarea v-model:value="model.note" :rows="3" />
      </CFormItem>

      <CFormItem>
        <CButton
          variant="primary"
          :loading="submitting"
          html-type="submit"
        >
          Lưu
        </CButton>
      </CFormItem>
    </CForm>
  </CCard>
</template>

Hai tầng kiểm tra dữ liệu, cả hai đều cần:

TầngAi kiểmHiển thị ở đâu
Ràng buộc nhập liệu (bắt buộc, độ dài, định dạng)rules của CForm ở clientNgay khi rời ô nhập
Nghiệp vụ (mã trùng, hạn mức, tồn kho)Backend, trả 422serverErrors gắn vào đúng field

Xem thêm Lỗi 422 và form.

Nối nút Thêm mới của bảng ở bước 4 vào trang này:

vue
<CTable show-create create-text="Tạo đơn" @create="navigateTo('/orders/new')" />

Bước 7 — Phân quyền

Quyền là URI, so khớp chính xác, lấy từ IAM khi đăng nhập. Ba điểm chặn, khai báo độc lập:

ts
// 1. Vào trang — thiếu quyền thì middleware ném 403
definePageMeta({ permissions: ['/orders/search'] })
ts
// 2. Mục menu — tự ẩn (app.config.ts, bước 1)
{ label: 'Đơn hàng', path: '/orders', permission: '/orders/search' }
vue
<!-- 3. Nút hành động -->
<CButton v-can="'/orders/create'" variant="primary" @click="navigateTo('/orders/new')">
  Tạo đơn
</CButton>

<CButton v-can:disable="'/orders/update'" @click="edit">Sửa</CButton>

Chế độ mock cấp sẵn /orders/search, /orders/create, /orders/update — thử đổi definePageMeta sang một URI không có trong danh sách để thấy trang 403.

WARNING

Ba lớp trên chỉ làm trải nghiệm gọn gàng. Backend phải tự kiểm tra quyền trên mọi request: người dùng vẫn gọi được API thẳng bằng công cụ khác. Xem Mô hình phân quyền.

Bước 8 — Kiểm tra trước khi mở MR

bash
pnpm check   # lint + typecheck
pnpm build   # bản production

Danh sách rà soát nhanh:

  • [ ] Không import trực tiếp ant-design-vue, không thẻ <a-*> trong template.
  • [ ] Không hardcode mã màu — chỉ var(--tasco-*).
  • [ ] Không any: ép kiểu record trong #bodyCell bằng as Order.
  • [ ] Mọi trang có definePageMeta khai quyền đúng, hoặc auth: false nếu công khai.
  • [ ] Lỗi đi qua $tascoError hoặc để handler chung bắt — không console.log còn sót.

Đi tiếp