- Admin accounts: replace dense card grid with compact DataTable - Account settings: remove redundant hero card, stat grid, header pills - Fix bg-accent (orange) misuse on decorative elements across 7 files - Add ConfirmButton for destructive actions (deactivate, remove member) - Replace single-field modals with inline editing (plan, trial) - Add contextual help: display code tooltip, improved empty states - Non-owner aside explanation for hidden owner-only sections - Admin sidebar: group 11 items into 5 labeled sections - Rename UsersPage.tsx → AccountsPage.tsx to match route - Fix border radius consistency, hide zero-count badges Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
127 lines
3.8 KiB
TypeScript
127 lines
3.8 KiB
TypeScript
import { useState, type ReactNode } from 'react'
|
|
import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
export interface Column<T> {
|
|
key: string
|
|
header: string
|
|
render: (item: T) => ReactNode
|
|
sortable?: boolean
|
|
className?: string
|
|
}
|
|
|
|
interface DataTableProps<T> {
|
|
columns: Column<T>[]
|
|
data: T[]
|
|
keyExtractor: (item: T) => string
|
|
isLoading?: boolean
|
|
skeletonRows?: number
|
|
onSort?: (key: string, direction: 'asc' | 'desc') => void
|
|
sortKey?: string
|
|
sortDirection?: 'asc' | 'desc'
|
|
emptyState?: ReactNode
|
|
}
|
|
|
|
export function DataTable<T>({
|
|
columns,
|
|
data,
|
|
keyExtractor,
|
|
isLoading = false,
|
|
skeletonRows = 5,
|
|
onSort,
|
|
sortKey,
|
|
sortDirection,
|
|
emptyState,
|
|
}: DataTableProps<T>) {
|
|
const [localSortKey, setLocalSortKey] = useState<string | null>(null)
|
|
const [localSortDir, setLocalSortDir] = useState<'asc' | 'desc'>('asc')
|
|
|
|
const activeSortKey = sortKey ?? localSortKey
|
|
const activeSortDir = sortDirection ?? localSortDir
|
|
|
|
const handleSort = (key: string) => {
|
|
const newDir = activeSortKey === key && activeSortDir === 'asc' ? 'desc' : 'asc'
|
|
if (onSort) {
|
|
onSort(key, newDir)
|
|
} else {
|
|
setLocalSortKey(key)
|
|
setLocalSortDir(newDir)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="overflow-x-auto rounded-lg border border-border">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-border bg-elevated">
|
|
{columns.map((col) => (
|
|
<th
|
|
key={col.key}
|
|
className={cn(
|
|
'px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-muted-foreground',
|
|
col.sortable && 'cursor-pointer select-none hover:text-foreground',
|
|
col.className
|
|
)}
|
|
onClick={col.sortable ? () => handleSort(col.key) : undefined}
|
|
>
|
|
<div className="flex items-center gap-1">
|
|
{col.header}
|
|
{col.sortable && (
|
|
<span className="inline-flex">
|
|
{activeSortKey === col.key ? (
|
|
activeSortDir === 'asc' ? (
|
|
<ChevronUp className="h-4 w-4" />
|
|
) : (
|
|
<ChevronDown className="h-4 w-4" />
|
|
)
|
|
) : (
|
|
<ChevronsUpDown className="h-3.5 w-3.5 opacity-40" />
|
|
)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{isLoading ? (
|
|
Array.from({ length: skeletonRows }).map((_, i) => (
|
|
<tr key={i} className="border-b border-border last:border-0">
|
|
{columns.map((col) => (
|
|
<td key={col.key} className="px-4 py-3">
|
|
<div className="h-4 w-3/4 animate-pulse rounded bg-muted" />
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))
|
|
) : data.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={columns.length} className="px-4 py-12 text-center">
|
|
{emptyState || (
|
|
<span className="text-muted-foreground">No data found</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
data.map((item) => (
|
|
<tr
|
|
key={keyExtractor(item)}
|
|
className="border-b border-border last:border-0 hover:bg-elevated transition-colors"
|
|
>
|
|
{columns.map((col) => (
|
|
<td key={col.key} className={cn('px-4 py-3', col.className)}>
|
|
{col.render(item)}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default DataTable
|