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-table

Code

'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>
	);
}