Beyond tables — diagrams, kanban, AI chat
Use these libraries instead of hand-drawing. Each example below is a complete app/page.tsx you can copy (open it in the Playground to edit).
Edit a process — @xyflow/react
ReactFlow, useNodesState, useEdgesState, addEdge, custom nodes with Handle. Give the container an explicit height, style nodes with theme.useToken(), and never import its CSS — it is bundled (code.no-css-import). Save { nodes, edges } as JSON through the API.
Thiết kế quy trình (React Flow) — Thiết kế quy trình duyệt: @xyflow/react kéo thả node, nối cạnh, thêm bước, lưu JSON (CSS đã có trong vendor — không import .css). Mở trong Playground →
Xem code (89 dòng)
// Thiết kế quy trình duyệt: @xyflow/react kéo thả node, nối cạnh, thêm bước, lưu JSON (CSS đã có trong vendor — không import .css).
import { useCallback, useState } from "react";
import { PageContainer, ProCard } from "@ant-design/pro-components";
import { App, Button, Flex, Input, Select, Space, Tag, theme } from "antd";
import { PlusOutlined, SaveOutlined } from "@ant-design/icons";
import { ReactFlow, Background, Controls, MiniMap, addEdge, useNodesState, useEdgesState, Handle, Position, type Connection, type Node, type Edge } from "@xyflow/react";
type StepData = { label: string; role: string };
const ROLES = [
{ value: "staff", label: "Nhân viên" },
{ value: "manager", label: "Trưởng phòng" },
{ value: "accountant", label: "Kế toán" },
{ value: "director", label: "Giám đốc" },
];
function StepNode({ data }: { data: StepData }) {
const { token } = theme.useToken();
return (
<div style={{ padding: "8px 12px", minWidth: 160, borderRadius: token.borderRadiusLG, background: token.colorBgContainer, border: `1px solid ${token.colorBorder}`, boxShadow: token.boxShadowTertiary }}>
<Handle type="target" position={Position.Top} />
<div style={{ fontWeight: 600, color: token.colorText }}>{data.label}</div>
<Tag color="blue" style={{ marginTop: 4 }}>{ROLES.find((r) => r.value === data.role)?.label ?? data.role}</Tag>
<Handle type="source" position={Position.Bottom} />
</div>
);
}
const nodeTypes = { step: StepNode };
const initialNodes: Node<StepData>[] = [
{ id: "1", type: "step", position: { x: 120, y: 0 }, data: { label: "Gửi đề xuất", role: "staff" } },
{ id: "2", type: "step", position: { x: 120, y: 120 }, data: { label: "Trưởng phòng duyệt", role: "manager" } },
{ id: "3", type: "step", position: { x: 0, y: 240 }, data: { label: "Kế toán kiểm tra", role: "accountant" } },
{ id: "4", type: "step", position: { x: 260, y: 240 }, data: { label: "Giám đốc duyệt (> 50 triệu)", role: "director" } },
];
const initialEdges: Edge[] = [
{ id: "e1-2", source: "1", target: "2", animated: true },
{ id: "e2-3", source: "2", target: "3" },
{ id: "e2-4", source: "2", target: "4", label: "> 50 triệu" },
];
export default function Page() {
const { message } = App.useApp();
const [nodes, setNodes, onNodesChange] = useNodesState<Node<StepData>>(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>(initialEdges);
const [label, setLabel] = useState("");
const [role, setRole] = useState("manager");
const onConnect = useCallback((c: Connection) => setEdges((eds) => addEdge(c, eds)), [setEdges]);
const addStep = () => {
if (!label.trim()) return;
const id = String(Date.now());
setNodes((ns) => [...ns, { id, type: "step", position: { x: 120, y: 360 + ns.length * 10 }, data: { label: label.trim(), role } }]);
setLabel("");
};
return (
<PageContainer
title="Quy trình duyệt đề xuất chi"
extra={[
<Button key="save" type="primary" icon={<SaveOutlined />} onClick={() => message.success(`Đã lưu ${nodes.length} bước, ${edges.length} liên kết (gửi JSON lên API)`)}>
Lưu quy trình
</Button>,
]}
>
<ProCard
title="Sơ đồ"
extra={
<Space.Compact>
<Input placeholder="Tên bước" value={label} onChange={(e) => setLabel(e.target.value)} onPressEnter={addStep} style={{ width: 200 }} />
<Select value={role} onChange={setRole} options={ROLES} style={{ width: 150 }} />
<Button icon={<PlusOutlined />} onClick={addStep}>
Thêm bước
</Button>
</Space.Compact>
}
>
<Flex style={{ height: 520 }}>
<ReactFlow nodes={nodes} edges={edges} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onConnect={onConnect} nodeTypes={nodeTypes} fitView>
<Background />
<MiniMap pannable zoomable />
<Controls />
</ReactFlow>
</Flex>
</ProCard>
</PageContainer>
);
}
Show an org chart / flow — @ant-design/graphs
OrganizationChart, FlowGraph, MindMap, FlowDirectionGraph. Data is { nodes: [{ id, data }], edges: [{ source, target }] }; label with labelField={(d) => d.data.name}; fixed-height wrapper + autoFit="view".
Sơ đồ tổ chức (graphs) — Sơ đồ tổ chức chỉ-xem: @ant-design/graphs OrganizationChart từ dữ liệu phòng ban (G6: { nodes: [{ id, data }], edges }). Mở trong Playground →
Xem code (49 dòng)
// Sơ đồ tổ chức chỉ-xem: @ant-design/graphs OrganizationChart từ dữ liệu phòng ban (G6: { nodes: [{ id, data }], edges }).
import { useMemo, useState } from "react";
import { PageContainer, ProCard } from "@ant-design/pro-components";
import { Segmented } from "antd";
import { OrganizationChart } from "@ant-design/graphs";
const people = [
{ id: "ceo", name: "Nguyễn An · Giám đốc", manager: null },
{ id: "cfo", name: "Trần Bình · Kế toán trưởng", manager: "ceo" },
{ id: "coo", name: "Lê Chi · Vận hành", manager: "ceo" },
{ id: "sales", name: "Phạm Dũng · Kinh doanh", manager: "ceo" },
{ id: "acc1", name: "Hà · Kế toán", manager: "cfo" },
{ id: "wh", name: "Quang · Kho", manager: "coo" },
{ id: "ship", name: "Vy · Giao hàng", manager: "coo" },
{ id: "s1", name: "Minh · Sales", manager: "sales" },
{ id: "s2", name: "Lan · Sales", manager: "sales" },
];
export default function Page() {
const [direction, setDirection] = useState<"vertical" | "horizontal">("vertical");
const data = useMemo(
() => ({
nodes: people.map((p) => ({ id: p.id, data: { name: p.name } })),
edges: people.filter((p) => p.manager).map((p) => ({ source: p.manager as string, target: p.id })),
}),
[],
);
return (
<PageContainer title="Sơ đồ tổ chức">
<ProCard
extra={
<Segmented
value={direction}
onChange={(v) => setDirection(v as "vertical" | "horizontal")}
options={[
{ value: "vertical", label: "Dọc" },
{ value: "horizontal", label: "Ngang" },
]}
/>
}
>
<div style={{ height: 520 }}>
<OrganizationChart key={direction} data={data} direction={direction} labelField={(d: { data?: { name?: string } }) => d.data?.name ?? ""} autoFit="view" />
</div>
</ProCard>
</PageContainer>
);
}
Kanban — @dnd-kit/core
DndContext, useDraggable, useDroppable, DragOverlay; PointerSensor with activationConstraint: { distance: 5 } so clicks still work. Persist the new stage on onDragEnd. For tables use DragSortTable.
Kanban kéo thả (dnd-kit) — Kanban pipeline khách hàng: @dnd-kit kéo thẻ giữa các cột, đếm và tổng giá trị mỗi cột, thẻ ProCard theo token antd. Mở trong Playground →
Xem code (104 dòng)
// Kanban pipeline khách hàng: @dnd-kit kéo thẻ giữa các cột, đếm và tổng giá trị mỗi cột, thẻ ProCard theo token antd.
import { useMemo, useState } from "react";
import { PageContainer } from "@ant-design/pro-components";
import { Avatar, Badge, Card, Flex, Tag, Typography, theme } from "antd";
import { DndContext, DragOverlay, PointerSensor, useDroppable, useDraggable, useSensor, useSensors, type DragEndEvent, type DragStartEvent } from "@dnd-kit/core";
type Stage = "lead" | "contact" | "quote" | "won";
type Deal = { id: string; name: string; company: string; value: number; owner: string; stage: Stage };
const STAGES: Array<{ key: Stage; title: string; color: string }> = [
{ key: "lead", title: "Khách tiềm năng", color: "default" },
{ key: "contact", title: "Đã liên hệ", color: "processing" },
{ key: "quote", title: "Đã báo giá", color: "warning" },
{ key: "won", title: "Chốt đơn", color: "success" },
];
const DEALS: Deal[] = [
{ id: "d1", name: "Phần mềm kho", company: "Cty An Khang", value: 120_000_000, owner: "Minh", stage: "lead" },
{ id: "d2", name: "Gói CRM 20 user", company: "Đông Á Group", value: 85_000_000, owner: "Lan", stage: "contact" },
{ id: "d3", name: "Bảo trì năm 2027", company: "Thép Việt", value: 40_000_000, owner: "Bảo", stage: "quote" },
{ id: "d4", name: "Tích hợp hoá đơn", company: "Café Mộc", value: 25_000_000, owner: "Minh", stage: "won" },
{ id: "d5", name: "Dashboard bán hàng", company: "Siêu thị Xanh", value: 60_000_000, owner: "Hà", stage: "lead" },
];
const vnd = (n: number) => n.toLocaleString("vi-VN") + " ₫";
function DealCard({ deal, dragging }: { deal: Deal; dragging?: boolean }) {
return (
<Card size="small" style={{ cursor: "grab", opacity: dragging ? 0.4 : 1 }}>
<Typography.Text strong>{deal.name}</Typography.Text>
<div style={{ fontSize: 12 }}>
<Typography.Text type="secondary">{deal.company}</Typography.Text>
</div>
<Flex justify="space-between" align="center" style={{ marginTop: 8 }}>
<Typography.Text>{vnd(deal.value)}</Typography.Text>
<Avatar size="small">{deal.owner[0]}</Avatar>
</Flex>
</Card>
);
}
function Draggable({ deal }: { deal: Deal }) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id: deal.id });
return (
<div ref={setNodeRef} {...listeners} {...attributes}>
<DealCard deal={deal} dragging={isDragging} />
</div>
);
}
function Column({ stage, deals }: { stage: (typeof STAGES)[number]; deals: Deal[] }) {
const { token } = theme.useToken();
const { setNodeRef, isOver } = useDroppable({ id: stage.key });
const total = deals.reduce((a, d) => a + d.value, 0);
return (
<div
ref={setNodeRef}
style={{ flex: "1 1 240px", minWidth: 220, background: isOver ? token.colorPrimaryBg : token.colorFillQuaternary, borderRadius: token.borderRadiusLG, padding: token.paddingSM }}
>
<Flex justify="space-between" align="center" style={{ marginBottom: 8 }}>
<Flex gap={6} align="center">
<Tag color={stage.color}>{stage.title}</Tag>
<Badge count={deals.length} showZero color={token.colorTextTertiary} />
</Flex>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{vnd(total)}</Typography.Text>
</Flex>
<Flex vertical gap={8} style={{ minHeight: 120 }}>
{deals.map((d) => (
<Draggable key={d.id} deal={d} />
))}
</Flex>
</div>
);
}
export default function Page() {
const [deals, setDeals] = useState(DEALS);
const [active, setActive] = useState<string | null>(null);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
const byStage = useMemo(() => Object.fromEntries(STAGES.map((s) => [s.key, deals.filter((d) => d.stage === s.key)])), [deals]);
const onStart = (e: DragStartEvent) => setActive(String(e.active.id));
const onEnd = (e: DragEndEvent) => {
setActive(null);
const to = e.over?.id as Stage | undefined;
if (!to) return;
// Real app: await api.deals.update(id, { stage: to }) then refresh().
setDeals((ds) => ds.map((d) => (d.id === e.active.id ? { ...d, stage: to } : d)));
};
return (
<PageContainer title="Pipeline khách hàng" subTitle="Kéo thẻ sang cột khác để đổi giai đoạn">
<DndContext sensors={sensors} onDragStart={onStart} onDragEnd={onEnd}>
<Flex gap={12} wrap align="flex-start">
{STAGES.map((s) => (
<Column key={s.key} stage={s} deals={byStage[s.key]} />
))}
</Flex>
<DragOverlay>{active ? <DealCard deal={deals.find((d) => d.id === active)!} /> : null}</DragOverlay>
</DndContext>
</PageContainer>
);
}
AI chat — @ant-design/x + @ant-design/x-markdown
Welcome, Prompts, Bubble.List, Sender, answers rendered with <XMarkdown content>. For the backend (conversations, tools over your data, streaming) use the lego block: block_add("ai-assistant").
Trợ lý AI (antd X) — Trợ lý AI trong app: antd X (Welcome, Prompts, Bubble.List, Sender) + XMarkdown; câu trả lời giả lập stream (app thật: khối lego "ai-assistant"). Mở trong Playground →
Xem code (76 dòng)
// Trợ lý AI trong app: antd X (Welcome, Prompts, Bubble.List, Sender) + XMarkdown; câu trả lời giả lập stream (app thật: khối lego "ai-assistant").
import { useState } from "react";
import { PageContainer, ProCard } from "@ant-design/pro-components";
import { Flex } from "antd";
import { RobotOutlined, UserOutlined } from "@ant-design/icons";
import { Bubble, Prompts, Sender, Welcome } from "@ant-design/x";
import XMarkdown from "@ant-design/x-markdown";
type Msg = { key: string; role: "user" | "ai"; content: string; loading?: boolean };
const ANSWER = `Có **3 đơn quá hạn giao**:
| Mã đơn | Khách | Trễ |
|---|---|---|
| DH-2026-0141 | Cty An Khang | 2 ngày |
| DH-2026-0150 | Café Mộc | 1 ngày |
| DH-2026-0152 | Thép Việt | 1 ngày |
Gợi ý: gọi khách **An Khang** trước — đơn lớn nhất (120 triệu).`;
export default function Page() {
const [items, setItems] = useState<Msg[]>([]);
const [text, setText] = useState("");
const [busy, setBusy] = useState(false);
const send = (q: string) => {
if (!q.trim() || busy) return;
const key = "a" + Date.now();
setItems((p) => [...p, { key: "u" + Date.now(), role: "user", content: q }, { key, role: "ai", content: "", loading: true }]);
setText("");
setBusy(true);
// Simulated token stream (the real block reads SSE from POST /assistant/chat).
let i = 0;
const timer = setInterval(() => {
i += 6;
setItems((p) => p.map((m) => (m.key === key ? { ...m, loading: false, content: ANSWER.slice(0, i) } : m)));
if (i >= ANSWER.length) {
clearInterval(timer);
setBusy(false);
}
}, 30);
};
return (
<PageContainer title="Trợ lý">
<ProCard>
<Flex vertical gap={12} style={{ height: 520 }}>
<div style={{ flex: 1, minHeight: 0, overflow: "auto" }}>
{items.length === 0 ? (
<Flex vertical gap={16}>
<Welcome icon={<RobotOutlined style={{ fontSize: 32 }} />} title="Trợ lý bán hàng" description="Hỏi về đơn hàng, tồn kho, khách hàng — trợ lý tra dữ liệu thật bằng công cụ của app." />
<Prompts
items={["Đơn nào quá hạn giao?", "Sản phẩm nào sắp hết hàng?", "Doanh số tuần này?"].map((d, i) => ({ key: String(i), description: d }))}
onItemClick={(info) => send(String(info.data.description))}
/>
</Flex>
) : (
<Bubble.List
items={items.map((m) => ({
key: m.key,
role: m.role,
placement: m.role === "user" ? "end" : "start",
avatar: m.role === "user" ? <UserOutlined /> : <RobotOutlined />,
loading: m.loading,
content: m.role === "ai" ? <XMarkdown content={m.content} /> : m.content,
}))}
/>
)}
</div>
<Sender value={text} onChange={setText} loading={busy} onSubmit={send} placeholder="Hỏi trợ lý…" />
</Flex>
</ProCard>
</PageContainer>
);
}
Live updates
When another user changes data: useLive("orders", refresh) from app/hooks, and broadcast("orders", { id }) from app/std/realtime in the handler.