diff --git a/components/common/DataTable.tsx b/components/common/DataTable.tsx index de51070..c6d307d 100644 --- a/components/common/DataTable.tsx +++ b/components/common/DataTable.tsx @@ -1,6 +1,13 @@ 'use client' -import { type ReactNode } from 'react' +import type { CSSProperties, ReactNode } from 'react' +import { + type ColumnDef, + useReactTable, + getCoreRowModel, + flexRender, +} from '@tanstack/react-table' +import { cn } from '@/lib/utils' import { Table, TableBody, @@ -10,119 +17,146 @@ import { TableRow, } from 'components/ui/table' -export interface ColumnDefinition { - id: string - header: ReactNode - cell: (item: T) => ReactNode - width?: string -} - -interface DataTableProps { - columnDefinitions: ColumnDefinition[] - items: T[] - trackBy?: keyof T | ((item: T) => string) +interface DataTableProps { + columns: ColumnDef[] + data: TData[] + trackBy?: keyof TData | ((row: TData) => string) loading?: boolean loadingText?: string - empty?: ReactNode - striped?: boolean - onRowClick?: (item: T) => void - maxHeight?: string + emptyText?: ReactNode + onRowClick?: (row: TData) => void + scrollAreaClassName?: string } -export default function DataTable({ - columnDefinitions, - items, +export default function DataTable({ + columns, + data, trackBy, loading, loadingText = '読み込み中...', - empty, - striped = false, + emptyText, onRowClick, - maxHeight, -}: DataTableProps) { - const getKey = (item: T, index: number): string => { + scrollAreaClassName = 'h-full overflow-auto', +}: DataTableProps) { + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + manualSorting: true, + manualFiltering: true, + }) + + const getRowKey = (row: TData, index: number): string => { if (!trackBy) return String(index) - if (typeof trackBy === 'function') return trackBy(item) - return String(item[trackBy]) + if (typeof trackBy === 'function') return trackBy(row) + return String(row[trackBy]) } - const table = ( - - {columnDefinitions.some((col) => col.width) && ( - - {columnDefinitions.map((col) => ( - - ))} - - )} + const colSpan = table.getAllLeafColumns().length + + return ( +
- - {columnDefinitions.map((col) => ( - {col.header} - ))} - + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const minWidthPx = header.column.columnDef.meta?.minWidthPx + const isSticky = header.column.columnDef.meta?.sticky === 'left' + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ) + })} + + ))} {loading ? ( {loadingText} - ) : items.length === 0 ? ( + ) : table.getRowModel().rows.length === 0 ? ( - {empty} + {emptyText} ) : ( - items.map((item, index) => ( + table.getRowModel().rows.map((row, index) => ( onRowClick(item) : undefined} + role={onRowClick ? 'button' : undefined} + onClick={ + onRowClick + ? (e) => { + const target = e.target as HTMLElement + if ( + target.closest( + 'a, button, input, select, textarea, [data-slot="checkbox"]', + ) + ) + return + onRowClick(row.original) + } + : undefined + } onKeyDown={ onRowClick ? (e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - onRowClick(item) - } + if (e.key !== 'Enter' && e.key !== ' ') return + const target = e.target as HTMLElement + if ( + target.closest( + 'a, button, input, select, textarea, [data-slot="checkbox"]', + ) + ) + return + e.preventDefault() + onRowClick(row.original) } : undefined } > - {columnDefinitions.map((col) => ( - {col.cell(item)} - ))} + {row.getVisibleCells().map((cell) => { + const minWidthPx = cell.column.columnDef.meta?.minWidthPx + const cellStyle: CSSProperties | undefined = minWidthPx + ? { minWidth: `${minWidthPx}px` } + : undefined + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ) + })} )) )}
) - - if (maxHeight) { - return ( -
- {table} -
- ) - } - - return table } diff --git a/components/common/PaginatedTable.tsx b/components/common/PaginatedTable.tsx index 0a86b05..3dc2813 100644 --- a/components/common/PaginatedTable.tsx +++ b/components/common/PaginatedTable.tsx @@ -10,7 +10,9 @@ import { } from 'components/ui/select' import { Card, CardContent, CardHeader, CardTitle } from 'components/ui/card' import { ChevronLeft, ChevronRight } from 'lucide-react' -import DataTable, { type ColumnDefinition } from 'components/common/DataTable' +import { type ReactNode } from 'react' +import { type ColumnDef } from '@tanstack/react-table' +import DataTable from 'components/common/DataTable' const PAGE_SIZE_OPTIONS = [ { value: '25', label: '25件' }, @@ -21,12 +23,12 @@ const PAGE_SIZE_OPTIONS = [ interface PaginatedTableProps { title: string - columnDefinitions: ColumnDefinition[] + columns: ColumnDef[] items: T[] trackBy?: keyof T | ((item: T) => string) loading?: boolean loadingText?: string - empty?: string + emptyText?: ReactNode currentPage: number lastPage: number perPage: number @@ -38,12 +40,12 @@ interface PaginatedTableProps { export default function PaginatedTable({ title, - columnDefinitions, + columns, items, trackBy, loading, loadingText, - empty, + emptyText, currentPage, lastPage, perPage, @@ -100,18 +102,16 @@ export default function PaginatedTable({ -
- -
+
) diff --git a/components/medication/DrugListTable.tsx b/components/medication/DrugListTable.tsx index 30769d9..08ba0fc 100644 --- a/components/medication/DrugListTable.tsx +++ b/components/medication/DrugListTable.tsx @@ -1,6 +1,7 @@ 'use client' import { useRouter } from 'next/navigation' +import { type ColumnDef } from '@tanstack/react-table' import { FileText, ExternalLink } from 'lucide-react' import PaginatedTable from 'components/common/PaginatedTable' @@ -11,6 +12,40 @@ interface Drug { hasNote?: boolean } +const columns: ColumnDef[] = [ + { + id: 'name', + header: '薬名', + cell: ({ row }) => ( + + {row.original.name || '-'} + {row.original.hasNote && ( + + )} + + ), + }, + { + id: 'url', + header: 'リンク', + cell: ({ row }) => + row.original.url ? ( + e.stopPropagation()} + > + 詳細 + + + ) : ( + '-' + ), + }, +] + interface DrugListTableProps { items: Drug[] currentPage: number @@ -35,42 +70,10 @@ export default function DrugListTable({ return ( ( - - {item.name || '-'} - {item.hasNote && ( - - )} - - ), - }, - { - id: 'url', - header: 'リンク', - cell: (item) => - item.url ? ( - e.stopPropagation()} - > - 詳細 - - - ) : ( - '-' - ), - }, - ]} + columns={columns} items={items} trackBy="id" - empty="薬が登録されていません" + emptyText="薬が登録されていません" currentPage={currentPage} lastPage={lastPage} perPage={perPage} diff --git a/components/medication/MedicationHistoryTable.tsx b/components/medication/MedicationHistoryTable.tsx index ddffa63..a0c8df3 100644 --- a/components/medication/MedicationHistoryTable.tsx +++ b/components/medication/MedicationHistoryTable.tsx @@ -1,6 +1,7 @@ 'use client' import { useRouter } from 'next/navigation' +import { type ColumnDef } from '@tanstack/react-table' import { FileText } from 'lucide-react' import PaginatedTable from 'components/common/PaginatedTable' @@ -12,6 +13,31 @@ interface MedicationRecord { hasNote?: boolean } +const columns: ColumnDef[] = [ + { + id: 'name', + header: '薬名', + cell: ({ row }) => ( + + {row.original.name || '-'} + {row.original.hasNote && ( + + )} + + ), + }, + { + id: 'amount', + header: '服薬量(mg)', + cell: ({ row }) => `${row.original.amount}mg`, + }, + { + id: 'takenAt', + header: '服薬日時', + cell: ({ row }) => row.original.takenAt, + }, +] + interface MedicationHistoryTableProps { items: MedicationRecord[] loading?: boolean @@ -38,34 +64,11 @@ export default function MedicationHistoryTable({ return ( ( - - {item.name || '-'} - {item.hasNote && ( - - )} - - ), - }, - { - id: 'amount', - header: '服薬量(mg)', - cell: (item) => `${item.amount}mg`, - }, - { - id: 'takenAt', - header: '服薬日時', - cell: (item) => item.takenAt, - }, - ]} + columns={columns} items={items} trackBy="id" loading={loading} - empty="服薬履歴がありません" + emptyText="服薬履歴がありません" currentPage={currentPage} lastPage={lastPage} perPage={perPage} diff --git a/components/page-component/DashboardPage.tsx b/components/page-component/DashboardPage.tsx index e49575c..ae08ca7 100644 --- a/components/page-component/DashboardPage.tsx +++ b/components/page-component/DashboardPage.tsx @@ -11,6 +11,7 @@ import { SelectValue, } from 'components/ui/select' import { Tabs, TabsContent, TabsList, TabsTrigger } from 'components/ui/tabs' +import { type ColumnDef } from '@tanstack/react-table' import DataTable from 'components/common/DataTable' import DiscordLinkPrompt from 'components/common/DiscordLinkPrompt' import { @@ -315,9 +316,34 @@ function WeeklyCountChart({ histories }: { histories: MedicationHistory[] }) { ) } +interface RecentHistoryItem { + id: string + name: string + amount: number + takenAt: string +} + +const recentHistoryColumns: ColumnDef[] = [ + { + id: 'name', + header: '薬名', + cell: ({ row }) => row.original.name, + }, + { + id: 'amount', + header: '服薬量(mg)', + cell: ({ row }) => `${row.original.amount}mg`, + }, + { + id: 'takenAt', + header: '服薬日時', + cell: ({ row }) => row.original.takenAt, + }, +] + function RecentHistoryTable({ histories }: { histories: MedicationHistory[] }) { const router = useRouter() - const recentItems = histories.map((h) => ({ + const recentItems: RecentHistoryItem[] = histories.map((h) => ({ id: String(h.id), name: h.drugName, amount: h.amount, @@ -331,32 +357,12 @@ function RecentHistoryTable({ histories }: { histories: MedicationHistory[] }) { item.name, - width: '40%', - }, - { - id: 'amount', - header: '服薬量(mg)', - cell: (item) => `${item.amount}mg`, - width: '25%', - }, - { - id: 'takenAt', - header: '服薬日時', - cell: (item) => item.takenAt, - width: '35%', - }, - ]} - items={recentItems} + columns={recentHistoryColumns} + data={recentItems} trackBy="id" - striped - empty="服薬履歴はありません" + emptyText="服薬履歴はありません" onRowClick={(item) => router.push(`/medication/history/${item.id}`)} - maxHeight="500px" + scrollAreaClassName="max-h-[500px] overflow-auto" /> diff --git a/components/ui/table.tsx b/components/ui/table.tsx index b9593e1..3eddda4 100644 --- a/components/ui/table.tsx +++ b/components/ui/table.tsx @@ -4,11 +4,15 @@ import * as React from 'react' import { cn } from '@/lib/utils' -function Table({ className, ...props }: React.ComponentProps<'table'>) { +function Table({ + className, + containerClassName, + ...props +}: React.ComponentProps<'table'> & { containerClassName?: string }) { return (
[] = [ + { id: 'name', header: '薬名', cell: ({ row }) => row.original.name }, + { + id: 'amount', + header: '服薬量(mg)', + cell: ({ row }) => `${row.original.amount}mg`, + }, +] + +const linkColumns: ColumnDef[] = [ + { id: 'name', header: '薬名', cell: ({ row }) => row.original.name }, + { + id: 'url', + header: 'リンク', + cell: ({ row }) => + row.original.url ? ( + e.stopPropagation()} + > + 詳細 + + + ) : ( + '-' + ), + }, +] + +const rows: Row[] = [ + { id: '1', name: 'レボチロキシン', amount: 1 }, + { id: '2', name: 'ロキソプロフェン', amount: 2 }, + { id: '3', name: 'アムロジピン', amount: 5 }, +] + +const meta = { + title: 'Components/DataTable', + component: DataTable, + parameters: { + layout: 'padded', + }, +} satisfies Meta> + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + columns, + data: rows, + trackBy: 'id', + onRowClick: (row) => alert(row.name), + }, +} + +export const Empty: Story = { + args: { + columns, + data: [], + emptyText: 'データがありません', + }, +} + +export const Loading: Story = { + args: { + columns, + data: [], + loading: true, + }, +} + +export const ClickableWithLink: Story = { + args: { + columns: linkColumns, + data: [ + { + id: '1', + name: 'レボチロキシン', + amount: 1, + url: 'https://example.com', + }, + { id: '2', name: 'ロキソプロフェン', amount: 2, url: '' }, + ], + trackBy: 'id', + onRowClick: (row) => alert(row.name), + }, +} diff --git a/types/reactTable.d.ts b/types/reactTable.d.ts new file mode 100644 index 0000000..e7eb37f --- /dev/null +++ b/types/reactTable.d.ts @@ -0,0 +1,12 @@ +import type { RowData } from '@tanstack/react-table' + +declare module '@tanstack/react-table' { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface ColumnMeta { + sortingField?: string + truncate?: boolean + sticky?: 'left' + minWidthPx?: number + stickyOffsetPx?: number + } +} diff --git a/yarn.lock b/yarn.lock index a5d8265..ade279e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2842,6 +2842,18 @@ postcss "^8.5.15" tailwindcss "4.3.2" +"@tanstack/react-table@^8.21.3": + version "8.21.3" + resolved "https://registry.yarnpkg.com/@tanstack/react-table/-/react-table-8.21.3.tgz#2c38c747a5731c1a07174fda764b9c2b1fb5e91b" + integrity sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww== + dependencies: + "@tanstack/table-core" "8.21.3" + +"@tanstack/table-core@8.21.3": + version "8.21.3" + resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.21.3.tgz#2977727d8fc8dfa079112d9f4d4c019110f1732c" + integrity sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg== + "@testing-library/jest-dom@^6.9.1": version "6.9.1" resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz#7613a04e146dd2976d24ddf019730d57a89d56c2"