'use client'
import * as React from 'react'
import {
type Column,
type ColumnDef,
type SortingState,
type VisibilityState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table'
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@appica/ui-react/table'
import { ScrollArea } from '@appica/ui-react/scroll-area'
import { Checkbox } from '@appica/ui-react/checkbox'
import { Thumbnail } from '@appica/ui-react/thumbnail'
import { Badge } from '@appica/ui-react/badge'
import { Input } from '@appica/ui-react/input'
import { Button } from '@appica/ui-react/button'
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuGroupLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuSeparator,
} from '@appica/ui-react/dropdown-menu'
import {
Search,
LayoutColumns,
ChevronDown,
ChevronUp,
ChevronsUpDown,
ChevronLeft,
ChevronRight,
DotsVertical,
BriefcaseFilled,
BoltFilled,
DropletHalf2Filled,
Eye,
Copy,
Pencil,
Trash,
} from '@appica/icons-react'
type Category = 'Income' | 'Entertainment' | 'Bill' | 'Medical' | 'Food & Drinks' | 'Transport'
type Transaction = {
id: string
date: string
description: string
thumb: { type: 'image'; src: string } | { type: 'icon'; icon: React.ReactNode }
account: string
category: Category
status: 'Completed' | 'Pending'
amount: number
}
const CATEGORY_DOT: Record<Category, string> = {
Income: 'bg-lime-500',
Entertainment: 'bg-violet-500',
Bill: 'bg-info-emphasis',
Medical: 'bg-success-emphasis',
'Food & Drinks': 'bg-pink-500',
Transport: 'bg-warning-emphasis',
}
const COLUMN_LABELS: Record<string, string> = {
date: 'Date',
description: 'Description',
account: 'Account',
category: 'Category',
status: 'Status',
amount: 'Amount',
}
const TRANSACTIONS: Transaction[] = [
{
id: 'TXN-1042',
date: '2026-08-30T17:23:21',
description: 'Salary - TechNova Inc.',
thumb: { type: 'icon', icon: <BriefcaseFilled /> },
account: '1842',
category: 'Income',
status: 'Completed',
amount: 4150,
},
{
id: 'TXN-1041',
date: '2026-08-24T07:18:53',
description: 'Netflix Subscription',
thumb: { type: 'image', src: '/data-table/netflix.jpg' },
account: '1842',
category: 'Entertainment',
status: 'Pending',
amount: -20,
},
{
id: 'TXN-1040',
date: '2026-08-17T09:15:00',
description: 'Utility Bill - Electricity',
thumb: { type: 'icon', icon: <BoltFilled /> },
account: '1842',
category: 'Bill',
status: 'Pending',
amount: -186.35,
},
{
id: 'TXN-1039',
date: '2026-08-17T08:34:46',
description: 'Utility Bill - Water',
thumb: { type: 'icon', icon: <DropletHalf2Filled /> },
account: '1842',
category: 'Bill',
status: 'Completed',
amount: -39.78,
},
{
id: 'TXN-1038',
date: '2026-08-13T00:27:19',
description: 'Health Insurance',
thumb: { type: 'image', src: '/data-table/health.jpg' },
account: '1842',
category: 'Medical',
status: 'Completed',
amount: -248,
},
{
id: 'TXN-1037',
date: '2026-08-10T02:30:52',
description: "McDonald's",
thumb: { type: 'image', src: '/data-table/mcdonalds.jpg' },
account: '1842',
category: 'Food & Drinks',
status: 'Completed',
amount: -32.67,
},
{
id: 'TXN-1034',
date: '2026-08-07T22:50:13',
description: 'Uber Taxi',
thumb: { type: 'image', src: '/data-table/uber.jpg' },
account: '1842',
category: 'Transport',
status: 'Completed',
amount: -25.4,
},
{
id: 'TXN-1036',
date: '2026-08-05T04:45:21',
description: 'Starbucks',
thumb: { type: 'image', src: '/data-table/starbucks.jpg' },
account: '1842',
category: 'Food & Drinks',
status: 'Completed',
amount: -7.99,
},
{
id: 'TXN-1035',
date: '2026-07-30T18:19:00',
description: 'Salary - TechNova Inc.',
thumb: { type: 'icon', icon: <BriefcaseFilled /> },
account: '1842',
category: 'Income',
status: 'Completed',
amount: 3980,
},
{
id: 'TXN-1033',
date: '2026-07-21T15:15:40',
description: 'Burger King',
thumb: { type: 'image', src: '/data-table/burger-king.jpg' },
account: '1842',
category: 'Food & Drinks',
status: 'Completed',
amount: -14.99,
},
{
id: 'TXN-1032',
date: '2026-07-19T19:36:23',
description: 'Uklon Taxi',
thumb: { type: 'image', src: '/data-table/uklon.jpg' },
account: '1842',
category: 'Transport',
status: 'Completed',
amount: -18.5,
},
{
id: 'TXN-1031',
date: '2026-07-12T08:05:11',
description: 'Uber Taxi',
thumb: { type: 'image', src: '/data-table/uber.jpg' },
account: '1842',
category: 'Transport',
status: 'Completed',
amount: -31.2,
},
{
id: 'TXN-1030',
date: '2026-07-08T13:22:47',
description: 'Starbucks',
thumb: { type: 'image', src: '/data-table/starbucks.jpg' },
account: '1842',
category: 'Food & Drinks',
status: 'Pending',
amount: -6.45,
},
{
id: 'TXN-1029',
date: '2026-06-30T17:00:00',
description: 'Salary - TechNova Inc.',
thumb: { type: 'icon', icon: <BriefcaseFilled /> },
account: '1842',
category: 'Income',
status: 'Completed',
amount: 4150,
},
]
function formatDate(iso: string) {
const date = new Date(iso)
const datePart = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(date)
const timePart = new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
}).format(date)
return `${datePart} ${timePart}`
}
function formatAmount(amount: number) {
const value = Math.abs(amount).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
return `${amount > 0 ? '+' : '-'} $${value}`
}
function TransactionThumb({ transaction }: { transaction: Transaction }) {
if (transaction.thumb.type === 'image') {
return <Thumbnail size="sm" shape="circle" src={transaction.thumb.src} alt={transaction.description} />
}
return (
<Thumbnail size="sm" shape="circle" variant="icon-soft">
{transaction.thumb.icon}
</Thumbnail>
)
}
function SortableHeader({ column, children }: { column: Column<Transaction>; children: React.ReactNode }) {
const sorted = column.getIsSorted()
return (
<button
type="button"
onClick={() => column.toggleSorting(sorted === 'asc')}
className="hover:text-foreground-intense focus-visible:outline-ring rounded-3xs -mx-1 inline-flex cursor-pointer items-center gap-1 px-1 outline-offset-2 focus-visible:outline-2 [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:stroke-[1.85]"
>
{children}
{sorted === 'asc' ? (
<ChevronUp />
) : sorted === 'desc' ? (
<ChevronDown />
) : (
<ChevronsUpDown className="text-foreground-muted" />
)}
</button>
)
}
function RowActions() {
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon-sm" aria-label="Open actions menu">
<DotsVertical />
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuItem>
<Eye data-icon="start" />
View details
</DropdownMenuItem>
<DropdownMenuItem>
<Copy data-icon="start" />
Copy transaction ID
</DropdownMenuItem>
<DropdownMenuItem>
<Pencil data-icon="start" />
Edit
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-error-emphasis! data-highlighted:before:bg-error-subtle!">
<Trash data-icon="start" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
const columns: ColumnDef<Transaction>[] = [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}
onCheckedChange={(checked) => table.toggleAllPageRowsSelected(checked === true)}
aria-label="Select all rows"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(checked) => row.toggleSelected(checked === true)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: 'date',
header: ({ column }) => <SortableHeader column={column}>Date</SortableHeader>,
cell: ({ row }) => formatDate(row.original.date),
},
{
accessorKey: 'description',
header: ({ column }) => <SortableHeader column={column}>Description</SortableHeader>,
cell: ({ row }) => (
<div className="flex items-center gap-3">
<TransactionThumb transaction={row.original} />
<span className="text-foreground-intense font-medium">{row.original.description}</span>
</div>
),
},
{
accessorKey: 'account',
header: ({ column }) => <SortableHeader column={column}>Account</SortableHeader>,
cell: ({ row }) => (
<span>
Checking <span className="text-foreground-muted">****{row.original.account}</span>
</span>
),
},
{
accessorKey: 'category',
header: ({ column }) => <SortableHeader column={column}>Category</SortableHeader>,
cell: ({ row }) => (
<Badge variant="outline" className="gap-1.25">
<span className={`size-2 shrink-0 rounded-full ${CATEGORY_DOT[row.original.category]}`} />
{row.original.category}
</Badge>
),
},
{
accessorKey: 'status',
header: ({ column }) => <SortableHeader column={column}>Status</SortableHeader>,
cell: ({ row }) => row.original.status,
},
{
accessorKey: 'amount',
header: ({ column }) => <SortableHeader column={column}>Amount</SortableHeader>,
cell: ({ row }) => (
<span
className={
row.original.status === 'Pending'
? 'text-foreground-muted font-semibold'
: 'text-foreground-intense font-semibold'
}
>
{formatAmount(row.original.amount)}
</span>
),
},
{
id: 'actions',
cell: () => <RowActions />,
enableSorting: false,
enableHiding: false,
},
]
export default function DataTableExample() {
const [sorting, setSorting] = React.useState<SortingState>([])
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
const [rowSelection, setRowSelection] = React.useState({})
const [globalFilter, setGlobalFilter] = React.useState('')
const table = useReactTable({
data: TRANSACTIONS,
columns,
state: { sorting, columnVisibility, rowSelection, globalFilter },
onSortingChange: setSorting,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
onGlobalFilterChange: setGlobalFilter,
globalFilterFn: 'includesString',
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize: 8 } },
})
const rows = table.getRowModel().rows
return (
<div className="flex w-full flex-col gap-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Input
placeholder="Search transactions..."
value={globalFilter}
onChange={(event) => setGlobalFilter(event.target.value)}
startSlot={<Search className="text-foreground-muted size-4" />}
clearable
onClear={() => setGlobalFilter('')}
className="sm:max-w-2xs"
/>
<DropdownMenu>
<DropdownMenuTrigger
className="group/columns"
render={
<Button variant="outline">
<LayoutColumns data-icon="start" />
Columns
<ChevronDown
data-icon="end"
className="transition-transform duration-200 ease-out group-data-popup-open/columns:rotate-180 motion-reduce:transition-none"
/>
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuGroupLabel>Toggle columns</DropdownMenuGroupLabel>
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => (
<DropdownMenuCheckboxItem
key={column.id}
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(value === true)}
>
{COLUMN_LABELS[column.id] ?? column.id}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
<ScrollArea
orientation="horizontal"
scrollbarVisibility="auto"
className="[&_td]:whitespace-nowrap [&_th]:whitespace-nowrap"
>
<Table hoverableRows>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} className={header.column.id === 'amount' ? 'text-end' : undefined}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{rows.length ? (
rows.map((row) => (
<TableRow key={row.id} highlighted={row.getIsSelected()}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className={cell.column.id === 'amount' ? 'text-end' : undefined}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="text-foreground-muted h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</ScrollArea>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-foreground-muted text-sm">
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s)
selected.
</p>
<div className="flex items-center gap-2">
<span className="text-foreground-muted text-sm">
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
</span>
<Button
variant="outline"
size="sm"
disabled={!table.getCanPreviousPage()}
onClick={() => table.previousPage()}
>
<ChevronLeft data-icon="start" className="-ms-1" />
Previous
</Button>
<Button variant="outline" size="sm" disabled={!table.getCanNextPage()} onClick={() => table.nextPage()}>
Next
<ChevronRight data-icon="end" className="-me-1" />
</Button>
</div>
</div>
</div>
)
}