Skip to Content
UI playbookSơ đồ, kanban, AI chat

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.

Last updated on