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
statsendpoint) beat a status dropdown: the user sees the workload. Default tab = the role’s to-do (pendingfor approvers). - Every column decides its search role:
search: falsefor computed/long columns;hideInTablefor search-only fields (date ranges). - Use
valueEnum/valueTypeinstead ofrenderwhenever possible — you get the Badge, the search widget and ProDescriptions reuse for free. fixed: "right"on the option column needsscroll={{ 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:
EditableProTablewitheditable={{ type: "multiple", editableKeys, onChange, onSave }}andrecordCreatorProps={{ 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