Skip to Content

Lists

Lists — ProTable

const columns: ProColumns<Req>[] = [ { title: "Tiêu đề", dataIndex: "title", ellipsis: true, fieldProps: { placeholder: "Tìm theo tiêu đề" }, render: (_, r) => <Space orientation="vertical" size={0}><Typography.Link strong onClick={() => nav(`/requests/${r.id}`)}>{r.title}</Typography.Link> <Typography.Text type="secondary" style={{ fontSize: 12 }}>#{r.id} · {vnDate(r.createdAt)}</Typography.Text></Space> }, { title: "Số tiền", dataIndex: "amount", align: "right", search: false, render: (_, r) => <b>{vnd(r.amount)}</b> }, { title: "Trạng thái", dataIndex: "status", valueEnum: STATUS_ENUM, search: false }, // Badge { title: "Hạng mục", dataIndex: "category", valueType: "select", valueEnum: CATEGORY_ENUM }, // also a search field { title: "Tạo lúc", dataIndex: "createdAt", valueType: "dateTime", search: false }, { title: "Tạo trong", dataIndex: "createdAt", valueType: "dateRange", hideInTable: true, search: { transform: (v) => ({ from: v[0], to: v[1] }) } }, // search-only { title: "Thao tác", valueType: "option", width: 120, render: (_, r) => [<a key="v" onClick={() => open(r)}>Xem</a>] }, ]; <ProTable<Req> rowKey="id" actionRef={ref} columns={columns} cardBordered search={{ labelWidth: "auto", searchText: "Tìm", resetText: "Xoá lọc" }} params={{ tab }} // changing params refetches request={async (p) => { const r = await api.requests.list({ page: p.current, pageSize: p.pageSize, status: tab === "all" ? undefined : tab, category: p.category, search: p.title }); return { data: r.data, total: r.total, success: true }; }} pagination={{ pageSize: 20, showSizeChanger: false, showTotal: (t) => `${t} đề xuất` }} dateFormatter="string" toolbar={{ menu: { type: "tab", activeKey: tab, onChange: (k) => setTab(k as Tab), items: TABS.map((t) => ({ key: t.key, label: <span>{t.label}<Badge count={counts?.[t.key]} style={{ marginInlineStart: 6 }} /></span> })) } }} rowSelection={canBulk ? { getCheckboxProps: (r) => ({ disabled: r.status !== "pending" }) } : undefined} tableAlertRender={({ selectedRows }) => <span>Đã chọn <b>{selectedRows.length}</b> · tổng <b>{vnd(sum(selectedRows))}</b></span>} tableAlertOptionRender={({ selectedRows, onCleanSelected }) => <Space><a onClick={onCleanSelected}>Bỏ chọn</a><BulkApprove rows={selectedRows} /></Space>} toolBarRender={() => [<CreateDrawer key="new" />]} locale={{ emptyText: "Chưa có đề xuất nào." }} />
  • Status tabs with counts (from a stats endpoint) beat a status dropdown: the user sees the workload. Default tab = the role’s to-do (pending for approvers).
  • Every column decides its search role: search: false for computed/long columns; hideInTable for search-only fields (date ranges).
  • Use valueEnum/valueType instead of render whenever possible — you get the Badge, the search widget and ProDescriptions reuse for free.
  • fixed: "right" on the option column needs scroll={{ x: 1100 }}.
  • Master–detail: <ProCard split="vertical"><ProCard colSpan="384px" ghost><ProTable search={false} options={false} … onRow={(r) => ({ onClick: () => setId(r.id) })} /></ProCard><ProCard title={…}>…</ProCard></ProCard>.
  • Editable grids: EditableProTable with editable={{ type: "multiple", editableKeys, onChange, onSave }} and recordCreatorProps={{ position: "bottom", record: () => ({ id: Date.now() }) }}.
  • Live lists: polling={5000} (min 2000).

Danh sách ProTable — Danh sách: ProTable với form tìm kiếm, tab trạng thái có số đếm, chọn nhiều để duyệt, valueEnum dạng Badge. Mở trong Playground →

Xem code (102 dòng)
// Danh sách: ProTable với form tìm kiếm, tab trạng thái có số đếm, chọn nhiều để duyệt, valueEnum dạng Badge.
// Trong app thật: request gọi api.<resource>.list(...) và trả { data, total, success: true }.
import { useMemo, useRef, useState } from "react";
import { PageContainer, ProTable, type ActionType, type ProColumns } from "@ant-design/pro-components";
import { App, Badge, Button, Popconfirm, Space, Typography } from "antd";
import { CheckOutlined, PlusOutlined } from "@ant-design/icons";

type Status = "pending" | "approved" | "rejected";
type Row = { id: number; title: string; amount: number; owner: string; dept: string; status: Status; category: string; createdAt: string };

const STATUS = {
  pending: { text: "Chờ duyệt", status: "Processing" },
  approved: { text: "Đã duyệt", status: "Success" },
  rejected: { text: "Từ chối", status: "Error" },
} as const;
const CATEGORY = { office: { text: "Văn phòng phẩm" }, equipment: { text: "Thiết bị" }, software: { text: "Phần mềm" } };
const vnd = (n: number) => `${n.toLocaleString("vi-VN")} ₫`;

const SEED: Row[] = [
  { id: 9, title: "Gia hạn Microsoft 365 (12 tháng, 10 user)", amount: 18600000, owner: "Trần Thị Lan", dept: "Hành chính", status: "pending", category: "software", createdAt: "2026-11-10" },
  { id: 8, title: "2 màn hình Dell P2423 cho team dev", amount: 7800000, owner: "Lê Văn Minh", dept: "Kỹ thuật", status: "pending", category: "equipment", createdAt: "2026-11-09" },
  { id: 7, title: "Giấy in A4 (20 ram)", amount: 2400000, owner: "Trần Thị Lan", dept: "Hành chính", status: "approved", category: "office", createdAt: "2026-11-05" },
  { id: 6, title: "Bàn phím cơ cho dev", amount: 1890000, owner: "Lê Văn Minh", dept: "Kỹ thuật", status: "rejected", category: "equipment", createdAt: "2026-11-02" },
];

export default function Page() {
  const { message } = App.useApp();
  const ref = useRef<ActionType>(undefined);
  const [rows, setRows] = useState(SEED);
  const [tab, setTab] = useState<"all" | Status>("pending");
  const counts = useMemo(() => ({
    all: rows.length,
    pending: rows.filter((r) => r.status === "pending").length,
    approved: rows.filter((r) => r.status === "approved").length,
    rejected: rows.filter((r) => r.status === "rejected").length,
  }), [rows]);

  const columns: ProColumns<Row>[] = [
    { title: "Tiêu đề", dataIndex: "title", ellipsis: true, fieldProps: { placeholder: "Tìm theo tiêu đề" },
      render: (_, r) => (
        <Space orientation="vertical" size={0}>
          <Typography.Link strong>{r.title}</Typography.Link>
          <Typography.Text type="secondary" style={{ fontSize: 12 }}>#{r.id} · {r.createdAt}</Typography.Text>
        </Space>
      ) },
    { title: "Số tiền", dataIndex: "amount", align: "right", search: false, render: (_, r) => <b>{vnd(r.amount)}</b> },
    { title: "Người tạo", dataIndex: "owner", search: false,
      render: (_, r) => <Space orientation="vertical" size={0}><span>{r.owner}</span><Typography.Text type="secondary" style={{ fontSize: 12 }}>{r.dept}</Typography.Text></Space> },
    { title: "Trạng thái", dataIndex: "status", valueEnum: STATUS, search: false },
    { title: "Hạng mục", dataIndex: "category", valueType: "select", valueEnum: CATEGORY },
    { title: "Thao tác", valueType: "option", render: () => [<a key="v">Xem</a>] },
  ];

  return (
    <PageContainer title="Tất cả đề xuất" subTitle="Duyệt hoặc từ chối đề xuất của nhân viên">
      <ProTable<Row>
        actionRef={ref}
        rowKey="id"
        cardBordered
        columns={columns}
        params={{ tab, rows }}
        search={{ labelWidth: "auto", searchText: "Tìm", resetText: "Xoá lọc" }}
        pagination={{ pageSize: 10, showTotal: (t) => `${t} đề xuất` }}
        request={async (p: Record<string, unknown>) => {
          const data = rows
            .filter((r) => tab === "all" || r.status === tab)
            .filter((r) => !p.title || r.title.toLowerCase().includes(String(p.title).toLowerCase()))
            .filter((r) => !p.category || r.category === p.category);
          return { data, total: data.length, success: true };
        }}
        toolbar={{
          menu: {
            type: "tab",
            activeKey: tab,
            onChange: (k) => setTab(k as typeof tab),
            items: (["all", "pending", "approved", "rejected"] as const).map((k) => ({
              key: k,
              label: <span>{k === "all" ? "Tất cả" : STATUS[k].text}<Badge count={counts[k]} color={k === "pending" ? "orange" : "#bfbfbf"} style={{ marginInlineStart: 6 }} /></span>,
            })),
          },
        }}
        rowSelection={tab === "pending" ? {} : undefined}
        tableAlertRender={({ selectedRows }) => <span>Đã chọn <b>{selectedRows.length}</b> · tổng <b>{vnd(selectedRows.reduce((a, r) => a + r.amount, 0))}</b></span>}
        tableAlertOptionRender={({ selectedRows, onCleanSelected }) => (
          <Space>
            <a onClick={onCleanSelected}>Bỏ chọn</a>
            <Popconfirm title={`Duyệt ${selectedRows.length} đề xuất?`} onConfirm={() => {
              const ids = new Set(selectedRows.map((r) => r.id));
              setRows((all) => all.map((r) => (ids.has(r.id) ? { ...r, status: "approved" } : r)));
              message.success(`Đã duyệt ${ids.size} đề xuất`);
              onCleanSelected();
            }}>
              <Button type="primary" size="small" icon={<CheckOutlined />}>Duyệt đã chọn</Button>
            </Popconfirm>
          </Space>
        )}
        toolBarRender={() => [<Button key="n" type="primary" icon={<PlusOutlined />}>Tạo yêu cầu</Button>]}
      />
    </PageContainer>
  );
}

Danh sách dạng thẻ — Danh sách dạng thẻ: ProList grid + metas (title, subTitle tag, avatar, description, content progress, actions khi hover). Mở trong Playground →

Xem code (38 dòng)
// Danh sách dạng thẻ: ProList grid + metas (title, subTitle tag, avatar, description, content progress, actions khi hover).
import { PageContainer, ProList } from "@ant-design/pro-components";
import { Avatar, Progress, Segmented, Tag } from "antd";
import { useState } from "react";

const projects = [
  { id: 1, name: "Triển khai CRM", owner: "Lê Văn Minh", tag: "Đang chạy", pct: 72, desc: "Chuyển khách hàng từ Excel sang CRM, đào tạo 12 sales." },
  { id: 2, name: "Kiểm kê kho Q4", owner: "Trần Thị Lan", tag: "Sắp hạn", pct: 40, desc: "Đối soát tồn kho 3 kho trước 30/11." },
  { id: 3, name: "Website tuyển dụng", owner: "Phạm Quốc Bảo", tag: "Hoàn tất", pct: 100, desc: "Trang tuyển dụng mới + form ứng tuyển." },
  { id: 4, name: "Số hoá hợp đồng", owner: "Nguyễn Thu Hà", tag: "Đang chạy", pct: 18, desc: "Scan và gắn nhãn 1.200 hợp đồng cũ." },
];
const TAG_COLOR: Record<string, string> = { "Đang chạy": "processing", "Sắp hạn": "warning", "Hoàn tất": "success" };

export default function Page() {
  const [filter, setFilter] = useState("Tất cả");
  const data = projects.filter((p) => filter === "Tất cả" || p.tag === filter);
  return (
    <PageContainer title="Dự án" extra={<Segmented options={["Tất cả", "Đang chạy", "Sắp hạn", "Hoàn tất"]} value={filter} onChange={(v) => setFilter(String(v))} />}>
      <ProList
        rowKey="id"
        dataSource={data}
        grid={{ gutter: 16, xs: 1, sm: 2, lg: 3 }}
        showActions="hover"
        itemCardProps={{ ghost: false }}
        onItem={() => ({ style: { cursor: "pointer" } })}
        metas={{
          title: { dataIndex: "name" },
          subTitle: { render: (_, r) => <Tag color={TAG_COLOR[r.tag]}>{r.tag}</Tag> },
          avatar: { render: (_, r) => <Avatar>{r.owner.split(" ").pop()?.[0]}</Avatar> },
          description: { dataIndex: "desc" },
          content: { render: (_, r) => <Progress percent={r.pct} size="small" status={r.pct === 100 ? "success" : "active"} /> },
          actions: { render: () => [<a key="o">Mở</a>, <a key="e">Sửa</a>] },
        }}
      />
    </PageContainer>
  );
}
Last updated on