All templates
Data table
Table - Payments
Payments history table with tabs and filter chips, inside the sidebar layout. Table, data table, list, sales, transactions, invoices, billing, records, or filters.
npx @adaptavant/edscli templates data-tablePreview
Open in new tabCode
'use client';
import {
Avatar,
CalDynamicIcon,
Chip,
DropdownMenu,
DropdownMenuItem,
DropdownMenuList,
DropdownMenuPopover,
Heading,
IconButton,
type IconProps,
MenuItem,
TabContent,
TabItem,
Table,
type TableProps,
TabList,
Tabs,
Text,
TextLink,
Tooltip,
} from '@adaptavant/eds-core';
import {
AddIcon,
ChevronLeftIcon,
ConnectIcon,
CustomerIcon,
GrowYourBrandIcon,
IntegrationsIcon,
MenuIcon,
MoneyIcon,
ServicesIcon,
SetmoreIcon,
SettingsIcon,
UpdatesIcon,
} from '@adaptavant/eds-icons/icons';
import * as React from 'react';
const NAV_ITEMS = [
{
label: 'Calendar',
icon: ({ size }: IconProps) => (
<CalDynamicIcon size={size}>{new Date().getDate()}</CalDynamicIcon>
),
},
{ label: 'Services', icon: ServicesIcon },
{ label: 'Connect', icon: ConnectIcon },
{ label: 'Customers', icon: CustomerIcon },
{ label: 'Payments', icon: MoneyIcon },
{ label: 'Integrations', icon: IntegrationsIcon },
{ label: 'Updates', icon: UpdatesIcon },
{ label: 'Settings', icon: SettingsIcon },
];
/**
* The app frame: a collapsible navigation sidebar beside a main area that
* scrolls on its own. Pass the current page as `children`.
*/
export function AppShell({ children }: { children: React.ReactNode }) {
const [activeNav, setActiveNav] = React.useState('Payments');
const [isNavCollapsed, setIsNavCollapsed] = React.useState(false);
return (
<div className="flex h-screen overflow-hidden bg-canvas text-primary">
<NavSidebar
activeNav={activeNav}
isCollapsed={isNavCollapsed}
onNavigate={setActiveNav}
onToggleCollapsed={() => setIsNavCollapsed((collapsed) => !collapsed)}
/>
{/* The sidebar is fixed, so the main area is offset by its width. */}
<main
className={`min-w-0 flex-1 overflow-y-auto transition-[margin] duration-200 ease-in-out ${
isNavCollapsed ? 'ms-[57px]' : 'ms-[216px]'
}`}
>
{children}
</main>
</div>
);
}
type Sale = {
id: string;
createdOn: string;
customer: string;
status: 'Paid' | 'Pending' | 'Refunded';
value: number;
bookingId: string;
};
const SALES: Sale[] = [
{
id: '1',
createdOn: '11 Aug 2026 · 04:25 PM',
customer: 'Leo Parker',
status: 'Paid',
value: 77,
bookingId: 'JUTCKAVR',
},
{
id: '2',
createdOn: '11 Aug 2026 · 04:25 PM',
customer: 'Mukesh Rao',
status: 'Paid',
value: 877,
bookingId: 'MT4EYMJT',
},
{
id: '3',
createdOn: '14 May 2026 · 07:44 PM',
customer: 'Mukesh Rao',
status: 'Pending',
value: 66,
bookingId: 'YN9RNHTW',
},
{
id: '4',
createdOn: '14 May 2026 · 07:44 PM',
customer: 'Max Verstappen',
status: 'Paid',
value: 26400,
bookingId: 'MWLTAXKX',
},
{
id: '5',
createdOn: '14 May 2026 · 07:43 PM',
customer: 'Carlos Sainz',
status: 'Paid',
value: 80.18,
bookingId: 'APTJYWRW',
},
{
id: '6',
createdOn: '14 May 2026 · 07:42 PM',
customer: 'Fernando Alonso',
status: 'Refunded',
value: 846,
bookingId: 'FFKPHHPC',
},
{
id: '7',
createdOn: '14 May 2026 · 07:36 PM',
customer: 'Daniel Ricciardo',
status: 'Paid',
value: 99.47,
bookingId: 'NA9REXNN',
},
{
id: '8',
createdOn: '14 May 2026 · 07:33 PM',
customer: 'Xenia Garcia',
status: 'Paid',
value: 1266.63,
bookingId: 'KM3VRYYH',
},
{
id: '9',
createdOn: '02 Apr 2026 · 11:10 AM',
customer: 'Lando Norris',
status: 'Paid',
value: 240,
bookingId: 'QZ7PLMDA',
},
{
id: '10',
createdOn: '28 Mar 2026 · 09:05 AM',
customer: 'Oscar Piastri',
status: 'Pending',
value: 135.5,
bookingId: 'HB2WKTRE',
},
];
const currency = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
const COLUMNS: TableProps<Sale>['columns'] = [
{ header: 'Sale created on', accessorKey: 'createdOn', size: 180 },
{ header: 'Customer', accessorKey: 'customer', size: 220 },
{ header: 'Status', accessorKey: 'status', size: 120 },
{
header: 'Sale value',
accessorKey: 'value',
size: 120,
cell: ({ getValue }) => currency.format(getValue<number>()),
},
{ header: 'Booking ID', accessorKey: 'bookingId', size: 140 },
{
header: 'Receipt',
id: 'receipt',
size: 100,
enableSorting: false,
cell: ({ row }) => (
<TextLink
aria-label={`View receipt for ${row.original.bookingId}`}
href="#"
>
View
</TextLink>
),
},
];
/** Each filter chip narrows the table to rows whose value matches. */
const FILTERS: { label: string; getValue: (sale: Sale) => string }[] = [
// "11 Aug 2026 · 04:25 PM" filters by "Aug 2026".
{
label: 'Date',
getValue: (sale) => sale.createdOn.split(' ').slice(1, 3).join(' '),
},
{ label: 'Customer', getValue: (sale) => sale.customer },
{ label: 'Booking ID', getValue: (sale) => sale.bookingId },
{ label: 'Status', getValue: (sale) => sale.status },
];
const PAYMENT_TABS = ['Sales', 'Transactions', 'Payment links'];
/** Preview: the payments history table in the shell's slot. */
export default function DataTableTemplate() {
const [filters, setFilters] = React.useState<Record<string, string>>({});
const sales = SALES.filter((sale) =>
FILTERS.every(
({ label, getValue }) =>
!filters[label] || getValue(sale) === filters[label]
)
);
const setFilter = (label: string, value?: string) =>
setFilters(({ [label]: _, ...rest }) =>
value ? { ...rest, [label]: value } : rest
);
return (
<AppShell>
<section
aria-label="Payments history"
className="flex flex-col gap-6 p-6"
>
<Heading as="h1" className="text-heading-20">
Payments history
</Heading>
<Tabs defaultValue="Sales">
<TabList>
{PAYMENT_TABS.map((tab) => (
<TabItem key={tab} value={tab}>
{tab}
</TabItem>
))}
</TabList>
<TabContent className="flex flex-col gap-4 pt-6" value="Sales">
<div className="flex flex-wrap gap-2">
{FILTERS.map(({ label, getValue }) => (
<FilterChip
key={label}
label={label}
onChange={(value) => setFilter(label, value)}
options={[...new Set(SALES.map(getValue))]}
value={filters[label]}
/>
))}
</div>
<Table
columns={COLUMNS}
data={sales}
emptyTemplate={
<Text className="py-6 text-center text-body-14 text-secondary">
No sales match these filters.
</Text>
}
getRowId={(sale) => sale.id}
role="grid"
/>
</TabContent>
{PAYMENT_TABS.slice(1).map((tab) => (
<TabContent className="pt-6" key={tab} value={tab}>
<Text className="text-body-14 text-secondary">
No {tab.toLowerCase()} yet.
</Text>
</TabContent>
))}
</Tabs>
</section>
</AppShell>
);
}
/**
* A dashed "+ Label" chip that opens the filter's options. Once a value is
* picked, the chip is pressed and shows it.
*/
function FilterChip({
label,
options,
value,
onChange,
}: {
label: string;
options: string[];
value?: string;
onChange: (value?: string) => void;
}) {
return (
<DropdownMenu popoverMaxWidth={240} popoverPlacement="bottom-start">
{({ triggerProps }) => (
<>
<Chip
className={value ? '' : 'border border-dashed border-secondary'}
isPressed={Boolean(value)}
{...triggerProps}
>
{/* Chip renders children inline: the row keeps the icon centred. */}
<span className="flex items-center gap-1">
{value ? null : <AddIcon size="16" />}
{value ? `${label}: ${value}` : label}
</span>
</Chip>
<DropdownMenuPopover>
<DropdownMenuList>
<DropdownMenuItem
isSelected={!value}
onClick={() => onChange(undefined)}
>
All
</DropdownMenuItem>
{options.map((option) => (
<DropdownMenuItem
isSelected={option === value}
key={option}
onClick={() => onChange(option)}
>
{option}
</DropdownMenuItem>
))}
</DropdownMenuList>
</DropdownMenuPopover>
</>
)}
</DropdownMenu>
);
}
function NavSidebar({
activeNav,
isCollapsed,
onNavigate,
onToggleCollapsed,
}: {
activeNav: string;
isCollapsed: boolean;
onNavigate: (label: string) => void;
onToggleCollapsed: () => void;
}) {
const navItem = (
label: string,
Icon: (typeof NAV_ITEMS)[number]['icon'],
isActive = false
) => {
const item = (triggerProps = {}) => (
<MenuItem
{...triggerProps}
aria-current={isActive ? 'page' : undefined}
aria-label={isCollapsed ? label : undefined}
as="a"
className={
isCollapsed
? 'justify-center origin-left overflow-hidden whitespace-nowrap rounded-8px px-2 text-body-12'
: 'origin-left overflow-hidden whitespace-nowrap rounded-8px text-body-12'
}
classNames={isCollapsed ? { center: 'hidden' } : undefined}
href={`#${label.toLowerCase()}`}
isHighlighted={isActive}
key={label}
onClick={() => onNavigate(label)}
railStart={<Icon size="16" />}
size="large"
>
{label}
</MenuItem>
);
// Collapsed, only the icon shows: the tooltip names it.
return isCollapsed ? (
<Tooltip content={label} key={label} placement="right">
{({ triggerProps }) => item(triggerProps)}
</Tooltip>
) : (
item()
);
};
// Collapsed, the rail's inner width is 32px: padding sizes each row to it.
return (
<aside
className={`fixed left-0 top-0 z-[2] flex h-screen origin-left flex-col items-start gap-6 border-r border-solid border-tertiary bg-neutral pb-4 pe-3 ps-3 pt-4 transition-[width] duration-200 ease-in-out ${
isCollapsed ? 'w-[57px]' : 'w-[216px]'
}`}
>
{/* Collapsed, the header is only the menu button that expands it. */}
<div
className={`flex w-full items-center gap-2 overflow-hidden whitespace-nowrap ${
isCollapsed ? 'justify-center' : 'px-2'
}`}
>
{isCollapsed ? (
<IconButton
aria-label="Expand sidebar"
icon={MenuIcon}
onClick={onToggleCollapsed}
variant="neutralTertiary"
/>
) : (
<>
<SetmoreIcon className="shrink-0" size="24" />
<Text className="where:block text-primary text-body-12 font-stronger line-clamp-2 w-[110px] flex-shrink-0 !leading-4">
AnywhereWorks
</Text>
<IconButton
aria-label="Collapse sidebar"
icon={ChevronLeftIcon}
onClick={onToggleCollapsed}
variant="neutralTertiary"
/>
</>
)}
</div>
<nav aria-label="Primary" className="flex w-full flex-col gap-1">
{navItem('Grow your brand', GrowYourBrandIcon)}
<div className="my-2" />
{NAV_ITEMS.map((item) =>
navItem(item.label, item.icon, item.label === activeNav)
)}
</nav>
<div className="mt-auto flex w-full flex-col gap-1">
<MenuItem
aria-label={isCollapsed ? 'Alex Morgan' : undefined}
className={
isCollapsed
? 'justify-start overflow-hidden whitespace-nowrap rounded-8px px-1 text-body-12'
: ' overflow-hidden justify-start whitespace-nowrap rounded-8px text-body-12'
}
classNames={{ center: isCollapsed ? 'w-0' : 'w-full ' }}
railStart={<Avatar name="Alex Morgan" size="24" status="available" />}
size="large"
>
{isCollapsed ? null : 'Alex Morgan'}
</MenuItem>
</div>
</aside>
);
}