Data Table

shadcn/ui の Table primitive + @tanstack/react-table。カイクラの会話履歴一覧画面のように、並び替え / ページングを要する一覧表示で利用する。

プレビュー(動作デモ)

会話履歴を題材にした実装例。ヘッダー列クリックで並び替え、5 件単位でページング。

2026-05-25 10:32山田 太郎通話佐藤 健5:12契約更新の問い合わせ。来週折返し希望。
2026-05-25 09:48鈴木 花子SMS高橋 美咲—予約確認のリマインダーを送信。
2026-05-24 18:11田中 翔太メール佐藤 健—請求書再発行の依頼。対応済み。
2026-05-24 15:22伊藤 次郎通話渡辺 翔太10:12解約相談。サービス継続を提案。
2026-05-24 11:05中村 美咲LINE高橋 美咲—LINE 経由でクーポンに関する質問。
2026-05-23 16:40小林 健通話佐藤 健2:25新規プラン申込。設定方法を案内。
2026-05-23 14:28加藤 太郎メール山本 花子—領収書 PDF を再送。
2026-05-23 10:12吉田 花子SMS渡辺 翔太—本人確認用ワンタイムコード送信。
2026-05-22 17:55山田 太郎通話高橋 美咲1:27支払方法変更について問合せ。
2026-05-22 13:20佐々木 翔太通話佐藤 健7:01クレーム対応。上長エスカレーション済み。

実装例

"use client";

import { useState } from "react";
import {
  type ColumnDef,
  type RowSelectionState,
  type SortingState,
  flexRender,
  getCoreRowModel,
  getPaginationRowModel,
  getSortedRowModel,
  useReactTable,
} from "@tanstack/react-table";
import {
  Table, TableBody, TableCell,
  TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/Button";
import { Checkbox } from "@/components/Checkbox";
import { Icon } from "@/components/Icon";

type InteractionType = "call" | "sms" | "mail" | "line";

type Interaction = {
  id: string;
  customer: string;
  type: InteractionType;
  occurredAt: string;
  assignee: string;
  durationSec: number;
  summary: string;
};

const TYPE_LABEL: Record<InteractionType, string> = {
  call: "通話", sms: "SMS", mail: "メール", line: "LINE",
};
const TYPE_ICON: Record<InteractionType, string> = {
  call: "phone", sms: "message", mail: "mail", line: "message",
};

function formatDuration(sec: number) {
  if (sec <= 0) return "—";
  return `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, "0")}`;
}

const DATA: Interaction[] = [
  { id: "1", customer: "山田 太郎", type: "call",
    occurredAt: "2026-05-25 10:32", assignee: "佐藤 健",
    durationSec: 312, summary: "契約更新の問い合わせ。来週折返し希望。" },
  { id: "2", customer: "鈴木 花子", type: "sms",
    occurredAt: "2026-05-25 09:48", assignee: "高橋 美咲",
    durationSec: 0, summary: "予約確認のリマインダーを送信。" },
  // ...
];

const columns: ColumnDef<Interaction>[] = [
  {
    id: "select",
    header: ({ table }) => (
      <Checkbox
        checked={table.getIsAllPageRowsSelected()}
        indeterminate={table.getIsSomePageRowsSelected()}
        onChange={(e) => table.toggleAllPageRowsSelected(e.target.checked)}
        aria-label="全行を選択"
      />
    ),
    cell: ({ row }) => (
      <Checkbox
        checked={row.getIsSelected()}
        onChange={(e) => row.toggleSelected(e.target.checked)}
        aria-label="行を選択"
      />
    ),
    enableSorting: false,
  },
  {
    accessorKey: "occurredAt",
    header: "発生日時",
    cell: ({ row }) => (
      <span className="whitespace-nowrap">{row.getValue<string>("occurredAt")}</span>
    ),
  },
  {
    accessorKey: "customer",
    header: "顧客",
    cell: ({ row }) => (
      <span className="font-medium">{row.getValue<string>("customer")}</span>
    ),
  },
  {
    accessorKey: "type",
    header: "種別",
    cell: ({ row }) => {
      const type = row.getValue<InteractionType>("type");
      return (
        <span className="inline-flex items-center gap-1">
          <Icon name={TYPE_ICON[type]} size={14} />
          {TYPE_LABEL[type]}
        </span>
      );
    },
  },
  { accessorKey: "assignee", header: "担当者" },
  {
    accessorKey: "durationSec",
    header: "通話時間",
    cell: ({ row }) => (
      <span className="tabular-nums">
        {formatDuration(row.getValue<number>("durationSec"))}
      </span>
    ),
  },
  {
    accessorKey: "summary",
    header: "要約",
  },
];

export function DataTableExample() {
  const [sorting, setSorting] = useState<SortingState>([
    { id: "occurredAt", desc: true },
  ]);
  const [rowSelection, setRowSelection] = useState<RowSelectionState>({});

  const table = useReactTable({
    data: DATA,
    columns,
    state: { sorting, rowSelection },
    onSortingChange: setSorting,
    onRowSelectionChange: setRowSelection,
    enableRowSelection: true,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    initialState: { pagination: { pageSize: 5 } },
  });

  return (
    <div className="flex flex-col gap-4">
      <Table>
        <TableHeader>
          {table.getHeaderGroups().map((hg) => (
            <TableRow key={hg.id}>
              {hg.headers.map((h) => {
                const canSort = h.column.getCanSort();
                const sorted = h.column.getIsSorted();
                return (
                  <TableHead key={h.id}>
                    {canSort ? (
                      <button
                        type="button"
                        onClick={h.column.getToggleSortingHandler()}
                        className="inline-flex items-center gap-1 cursor-pointer"
                      >
                        {flexRender(h.column.columnDef.header, h.getContext())}
                        {sorted === "asc" && <Icon name="caret-up" size={10} />}
                        {sorted === "desc" && <Icon name="caret-down" size={10} />}
                      </button>
                    ) : (
                      flexRender(h.column.columnDef.header, h.getContext())
                    )}
                  </TableHead>
                );
              })}
            </TableRow>
          ))}
        </TableHeader>
        <TableBody>
          {table.getRowModel().rows.map((row) => (
            <TableRow key={row.id}>
              {row.getVisibleCells().map((cell) => (
                <TableCell key={cell.id}>
                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
                </TableCell>
              ))}
            </TableRow>
          ))}
        </TableBody>
      </Table>

      <div className="flex items-center justify-between">
        <span className="text-sm text-gray-500">
          {DATA.length} 件中{" "}
          {table.getState().pagination.pageIndex * 5 + 1}
          {" – "}
          {Math.min((table.getState().pagination.pageIndex + 1) * 5, DATA.length)}
          {" "}件を表示
        </span>
        <div className="flex gap-2">
          <Button variant="secondary" size="s"
            onClick={() => table.previousPage()}
            disabled={!table.getCanPreviousPage()}>前へ</Button>
          <Button variant="secondary" size="s"
            onClick={() => table.nextPage()}
            disabled={!table.getCanNextPage()}>次へ</Button>
        </div>
      </div>
    </div>
  );
}

仕様

  • primitive: src/components/ui/table.tsx(shadcn 公式そのまま)
  • 行列ロジック: @tanstack/react-table(sorting / filtering / pagination / row selection を素朴な hook API で提供)
  • カラム定義(ColumnDef)で accessorKey / header / cell を宣言。cell に独自 React を返してアイコン付き表示などをカスタム可能
  • 使用箇所例: 会話履歴一覧、顧客一覧、設定の項目一覧 など

利用基準

  • 行数が多く、ソートや検索が必要な一覧 → Data Table
  • 5 行以下の単純な並び → 通常の <table> か Definition List で十分
  • 編集が中心の表 → 専用のスプレッドシート系コンポーネントを別途検討