Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 108 additions & 74 deletions components/common/DataTable.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -10,119 +17,146 @@ import {
TableRow,
} from 'components/ui/table'

export interface ColumnDefinition<T> {
id: string
header: ReactNode
cell: (item: T) => ReactNode
width?: string
}

interface DataTableProps<T> {
columnDefinitions: ColumnDefinition<T>[]
items: T[]
trackBy?: keyof T | ((item: T) => string)
interface DataTableProps<TData> {
columns: ColumnDef<TData, unknown>[]
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<T>({
columnDefinitions,
items,
export default function DataTable<TData>({
columns,
data,
trackBy,
loading,
loadingText = '読み込み中...',
empty,
striped = false,
emptyText,
onRowClick,
maxHeight,
}: DataTableProps<T>) {
const getKey = (item: T, index: number): string => {
scrollAreaClassName = 'h-full overflow-auto',
}: DataTableProps<TData>) {
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 = (
<Table>
{columnDefinitions.some((col) => col.width) && (
<colgroup>
{columnDefinitions.map((col) => (
<col
key={col.id}
style={col.width ? { width: col.width } : undefined}
/>
))}
</colgroup>
)}
const colSpan = table.getAllLeafColumns().length

return (
<Table containerClassName={scrollAreaClassName}>
<TableHeader>
<TableRow>
{columnDefinitions.map((col) => (
<TableHead key={col.id}>{col.header}</TableHead>
))}
</TableRow>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const minWidthPx = header.column.columnDef.meta?.minWidthPx
const isSticky = header.column.columnDef.meta?.sticky === 'left'
return (
<TableHead
key={header.id}
className={cn(
isSticky && 'sticky left-0 z-30 bg-table-header',
)}
style={
minWidthPx ? { minWidth: `${minWidthPx}px` } : undefined
}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell
colSpan={columnDefinitions.length}
className="text-center py-8 text-muted-foreground"
colSpan={colSpan}
className="py-8 text-center text-muted-foreground"
>
{loadingText}
</TableCell>
</TableRow>
) : items.length === 0 ? (
) : table.getRowModel().rows.length === 0 ? (
<TableRow>
<TableCell
colSpan={columnDefinitions.length}
className="text-center py-8 text-muted-foreground"
colSpan={colSpan}
className="py-8 text-center text-muted-foreground"
>
{empty}
{emptyText}
</TableCell>
</TableRow>
) : (
items.map((item, index) => (
table.getRowModel().rows.map((row, index) => (
<TableRow
key={getKey(item, index)}
className={`${onRowClick ? 'cursor-pointer hover:bg-muted/50' : ''} ${striped && index % 2 === 1 ? 'bg-muted/30' : ''}`}
key={getRowKey(row.original, index)}
className={cn(onRowClick && 'cursor-pointer hover:bg-muted/50')}
tabIndex={onRowClick ? 0 : undefined}
role={onRowClick ? 'link' : undefined}
onClick={onRowClick ? () => 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) => (
<TableCell key={col.id}>{col.cell(item)}</TableCell>
))}
{row.getVisibleCells().map((cell) => {
const minWidthPx = cell.column.columnDef.meta?.minWidthPx
const cellStyle: CSSProperties | undefined = minWidthPx
? { minWidth: `${minWidthPx}px` }
: undefined
return (
<TableCell key={cell.id} style={cellStyle}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
)
})}
</TableRow>
))
)}
</TableBody>
</Table>
)

if (maxHeight) {
return (
<div
className="overflow-auto [&_[data-slot=table-container]]:overflow-visible"
style={{ maxHeight }}
>
{table}
</div>
)
}

return table
}
34 changes: 17 additions & 17 deletions components/common/PaginatedTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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件' },
Expand All @@ -21,12 +23,12 @@ const PAGE_SIZE_OPTIONS = [

interface PaginatedTableProps<T> {
title: string
columnDefinitions: ColumnDefinition<T>[]
columns: ColumnDef<T, unknown>[]
items: T[]
trackBy?: keyof T | ((item: T) => string)
loading?: boolean
loadingText?: string
empty?: string
emptyText?: ReactNode
currentPage: number
lastPage: number
perPage: number
Expand All @@ -38,12 +40,12 @@ interface PaginatedTableProps<T> {

export default function PaginatedTable<T>({
title,
columnDefinitions,
columns,
items,
trackBy,
loading,
loadingText,
empty,
emptyText,
currentPage,
lastPage,
perPage,
Expand Down Expand Up @@ -100,18 +102,16 @@ export default function PaginatedTable<T>({
</div>
</CardHeader>
<CardContent className="min-h-0 flex-1 p-0">
<div className="h-full overflow-auto [&>[data-slot=table-container]]:overflow-visible">
<DataTable
columnDefinitions={columnDefinitions}
items={items}
trackBy={trackBy}
loading={loading}
loadingText={loadingText}
empty={empty}
striped
onRowClick={onRowClick}
/>
</div>
<DataTable
columns={columns}
data={items}
trackBy={trackBy}
loading={loading}
loadingText={loadingText}
emptyText={emptyText}
onRowClick={onRowClick}
scrollAreaClassName="h-full overflow-auto"
/>
</CardContent>
</Card>
)
Expand Down
71 changes: 37 additions & 34 deletions components/medication/DrugListTable.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -11,6 +12,40 @@ interface Drug {
hasNote?: boolean
}

const columns: ColumnDef<Drug, unknown>[] = [
{
id: 'name',
header: '薬名',
cell: ({ row }) => (
<span className="inline-flex items-center gap-1">
{row.original.name || '-'}
{row.original.hasNote && (
<FileText className="size-3.5 text-muted-foreground" />
)}
</span>
),
},
{
id: 'url',
header: 'リンク',
cell: ({ row }) =>
row.original.url ? (
<a
href={row.original.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
詳細
<ExternalLink className="size-3" />
</a>
) : (
'-'
),
},
]

interface DrugListTableProps {
items: Drug[]
currentPage: number
Expand All @@ -35,42 +70,10 @@ export default function DrugListTable({
return (
<PaginatedTable
title="薬一覧"
columnDefinitions={[
{
id: 'name',
header: '薬名',
cell: (item) => (
<span className="inline-flex items-center gap-1">
{item.name || '-'}
{item.hasNote && (
<FileText className="size-3.5 text-muted-foreground" />
)}
</span>
),
},
{
id: 'url',
header: 'リンク',
cell: (item) =>
item.url ? (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
詳細
<ExternalLink className="size-3" />
</a>
) : (
'-'
),
},
]}
columns={columns}
items={items}
trackBy="id"
empty="薬が登録されていません"
emptyText="薬が登録されていません"
currentPage={currentPage}
lastPage={lastPage}
perPage={perPage}
Expand Down
Loading
Loading