Data Grid
11 blocksData Grid 01
Preview
npx nateui@latest add DataGridOrderHistoryDark
import { useMemo, useState } from 'react'
import Avatar from '@/components/ui/Avatar'
import Button from '@/components/ui/Button'
import Card from '@/components/ui/Card'
import Dropdown from '@/components/ui/Dropdown'
import Input from '@/components/ui/Input'
import Tag from '@/components/ui/Tag'
import Container from '@/components/composites/Container'
import DataTable from '@/components/composites/DataTable'
import type { ColumnDef } from '@/components/composites/DataTable'
import {
PiBuildings,
PiCalendarBlank,
PiCaretDown,
PiCheck,
PiCheckCircleFill,
PiCheckSquare,
PiDotsThreeVerticalBold,
PiGlobe,
PiMagnifyingGlass,
PiPackage,
PiStorefront,
PiXCircleFill,
} from 'react-icons/pi'
const assetBase = 'https://statics.nateui.com/img'
type PaymentStatus = 'Paid' | 'Canceled'
type FulfillmentStatus = 'Fulfilled' | 'Unfulfilled'
type Channel = 'Storefront' | 'Marketplace' | 'Wholesale'
type PaymentFilter = PaymentStatus | 'All'
type FulfillmentFilter = FulfillmentStatus | 'All'
type DateRangeOption = {
id: string
label: string
start: Date | null
end: Date | null
}
type SortKey =
| 'orderId'
| 'total'
| 'paymentStatus'
| 'customer'
| 'orderDate'
| 'channel'
type OrderRow = {
orderId: string
total: number
paymentStatus: PaymentStatus
fulfillmentStatus: FulfillmentStatus | null
customer: string
customerAvatar: string
orderDate: string
orderDateLabel: string
channel: Channel
}
const orderRows: OrderRow[] = [
{ orderId: 'ORD-8401', total: 482.75, paymentStatus: 'Paid', fulfillmentStatus: 'Unfulfilled', customer: 'Mara Voss', customerAvatar: `${assetBase}/avatars/thumb-1.jpg`, orderDate: '2026-04-29', orderDateLabel: 'Wed 29 Apr, 2026', channel: 'Storefront' },
{ orderId: 'ORD-8400', total: 1260.4, paymentStatus: 'Paid', fulfillmentStatus: 'Fulfilled', customer: 'Jon Bellamy', customerAvatar: `${assetBase}/avatars/thumb-2.jpg`, orderDate: '2026-04-28', orderDateLabel: 'Tue 28 Apr, 2026', channel: 'Wholesale' },
{ orderId: 'ORD-8399', total: 94.5, paymentStatus: 'Canceled', fulfillmentStatus: null, customer: 'Talia Nunez', customerAvatar: `${assetBase}/avatars/thumb-3.jpg`, orderDate: '2026-04-27', orderDateLabel: 'Mon 27 Apr, 2026', channel: 'Marketplace' },
{ orderId: 'ORD-8398', total: 739.2, paymentStatus: 'Paid', fulfillmentStatus: 'Unfulfilled', customer: 'Evan Rios', customerAvatar: `${assetBase}/avatars/thumb-4.jpg`, orderDate: '2026-04-25', orderDateLabel: 'Sat 25 Apr, 2026', channel: 'Storefront' },
{ orderId: 'ORD-8397', total: 215.99, paymentStatus: 'Paid', fulfillmentStatus: 'Fulfilled', customer: 'Nina Carver', customerAvatar: `${assetBase}/avatars/thumb-5.jpg`, orderDate: '2026-04-24', orderDateLabel: 'Fri 24 Apr, 2026', channel: 'Marketplace' },
{ orderId: 'ORD-8396', total: 0, paymentStatus: 'Canceled', fulfillmentStatus: null, customer: 'Lena Ortiz', customerAvatar: `${assetBase}/avatars/thumb-6.jpg`, orderDate: '2026-04-23', orderDateLabel: 'Thu 23 Apr, 2026', channel: 'Wholesale' },
{ orderId: 'ORD-8395', total: 368.65, paymentStatus: 'Paid', fulfillmentStatus: 'Unfulfilled', customer: 'Owen Hale', customerAvatar: `${assetBase}/avatars/thumb-7.jpg`, orderDate: '2026-04-22', orderDateLabel: 'Wed 22 Apr, 2026', channel: 'Storefront' },
{ orderId: 'ORD-8394', total: 1098, paymentStatus: 'Paid', fulfillmentStatus: 'Fulfilled', customer: 'Aria Moss', customerAvatar: `${assetBase}/avatars/thumb-8.jpg`, orderDate: '2026-04-21', orderDateLabel: 'Tue 21 Apr, 2026', channel: 'Marketplace' },
{ orderId: 'ORD-8393', total: 56.25, paymentStatus: 'Paid', fulfillmentStatus: 'Unfulfilled', customer: 'Calder Reed', customerAvatar: `${assetBase}/avatars/thumb-9.jpg`, orderDate: '2026-04-20', orderDateLabel: 'Mon 20 Apr, 2026', channel: 'Wholesale' },
{ orderId: 'ORD-8392', total: 824.1, paymentStatus: 'Paid', fulfillmentStatus: 'Fulfilled', customer: 'Iris Vale', customerAvatar: `${assetBase}/avatars/thumb-10.jpg`, orderDate: '2026-04-18', orderDateLabel: 'Sat 18 Apr, 2026', channel: 'Storefront' },
{ orderId: 'ORD-8391', total: 175.8, paymentStatus: 'Canceled', fulfillmentStatus: null, customer: 'Micah Stone', customerAvatar: `${assetBase}/avatars/thumb-11.jpg`, orderDate: '2026-04-17', orderDateLabel: 'Fri 17 Apr, 2026', channel: 'Marketplace' },
{ orderId: 'ORD-8390', total: 432.6, paymentStatus: 'Paid', fulfillmentStatus: 'Unfulfilled', customer: 'Sora Kim', customerAvatar: `${assetBase}/avatars/thumb-12.jpg`, orderDate: '2026-04-16', orderDateLabel: 'Thu 16 Apr, 2026', channel: 'Wholesale' },
{ orderId: 'ORD-8389', total: 267.35, paymentStatus: 'Paid', fulfillmentStatus: 'Fulfilled', customer: 'Drew Palmer', customerAvatar: `${assetBase}/avatars/thumb-13.jpg`, orderDate: '2026-04-15', orderDateLabel: 'Wed 15 Apr, 2026', channel: 'Storefront' },
{ orderId: 'ORD-8388', total: 911.5, paymentStatus: 'Paid', fulfillmentStatus: 'Unfulfilled', customer: 'Rae Sullivan', customerAvatar: `${assetBase}/avatars/thumb-14.jpg`, orderDate: '2026-04-14', orderDateLabel: 'Tue 14 Apr, 2026', channel: 'Marketplace' },
{ orderId: 'ORD-8387', total: 0, paymentStatus: 'Canceled', fulfillmentStatus: null, customer: 'Theo Wren', customerAvatar: `${assetBase}/avatars/thumb-15.jpg`, orderDate: '2026-04-13', orderDateLabel: 'Mon 13 Apr, 2026', channel: 'Wholesale' },
{ orderId: 'ORD-8386', total: 148.75, paymentStatus: 'Paid', fulfillmentStatus: 'Fulfilled', customer: 'Cora Bennett', customerAvatar: `${assetBase}/avatars/thumb-16.jpg`, orderDate: '2026-04-11', orderDateLabel: 'Sat 11 Apr, 2026', channel: 'Storefront' },
{ orderId: 'ORD-8385', total: 689.9, paymentStatus: 'Paid', fulfillmentStatus: 'Unfulfilled', customer: 'Noel Avery', customerAvatar: `${assetBase}/avatars/thumb-17.jpg`, orderDate: '2026-04-10', orderDateLabel: 'Fri 10 Apr, 2026', channel: 'Marketplace' },
{ orderId: 'ORD-8384', total: 321.4, paymentStatus: 'Paid', fulfillmentStatus: 'Fulfilled', customer: 'Gia Hart', customerAvatar: `${assetBase}/avatars/thumb-18.jpg`, orderDate: '2026-04-09', orderDateLabel: 'Thu 9 Apr, 2026', channel: 'Wholesale' },
{ orderId: 'ORD-8383', total: 74.2, paymentStatus: 'Paid', fulfillmentStatus: 'Unfulfilled', customer: 'Milo Grant', customerAvatar: `${assetBase}/avatars/thumb-19.jpg`, orderDate: '2026-04-08', orderDateLabel: 'Wed 8 Apr, 2026', channel: 'Storefront' },
{ orderId: 'ORD-8382', total: 546.25, paymentStatus: 'Paid', fulfillmentStatus: 'Fulfilled', customer: 'Asha Ford', customerAvatar: `${assetBase}/avatars/thumb-20.jpg`, orderDate: '2026-04-07', orderDateLabel: 'Tue 7 Apr, 2026', channel: 'Marketplace' },
{ orderId: 'ORD-8381', total: 193.6, paymentStatus: 'Paid', fulfillmentStatus: 'Unfulfilled', customer: 'Luca Byrne', customerAvatar: `${assetBase}/avatars/thumb-21.jpg`, orderDate: '2026-04-06', orderDateLabel: 'Mon 6 Apr, 2026', channel: 'Wholesale' },
{ orderId: 'ORD-8380', total: 808.4, paymentStatus: 'Paid', fulfillmentStatus: 'Fulfilled', customer: 'Rina Cole', customerAvatar: `${assetBase}/avatars/thumb-22.jpg`, orderDate: '2026-04-04', orderDateLabel: 'Sat 4 Apr, 2026', channel: 'Storefront' },
{ orderId: 'ORD-8379', total: 129.99, paymentStatus: 'Canceled', fulfillmentStatus: null, customer: 'Pia Rowe', customerAvatar: `${assetBase}/avatars/thumb-23.jpg`, orderDate: '2026-04-03', orderDateLabel: 'Fri 3 Apr, 2026', channel: 'Marketplace' },
{ orderId: 'ORD-8378', total: 258.3, paymentStatus: 'Paid', fulfillmentStatus: 'Unfulfilled', customer: 'Sam Delaney', customerAvatar: `${assetBase}/avatars/thumb-24.jpg`, orderDate: '2026-04-02', orderDateLabel: 'Thu 2 Apr, 2026', channel: 'Wholesale' },
{ orderId: 'ORD-8377', total: 675.45, paymentStatus: 'Paid', fulfillmentStatus: 'Fulfilled', customer: 'Jules Park', customerAvatar: `${assetBase}/avatars/thumb-25.jpg`, orderDate: '2026-04-01', orderDateLabel: 'Wed 1 Apr, 2026', channel: 'Storefront' },
]
const sortableKeys: SortKey[] = [
'orderId',
'total',
'paymentStatus',
'customer',
'orderDate',
'channel',
]
const dateRangeOptions: DateRangeOption[] = [
{
id: 'april',
label: '1 Apr – 30 Apr',
start: new Date(2026, 3, 1),
end: new Date(2026, 3, 30),
},
{
id: 'late-april',
label: '15 Apr – 30 Apr',
start: new Date(2026, 3, 15),
end: new Date(2026, 3, 30),
},
{
id: 'early-april',
label: '1 Apr – 14 Apr',
start: new Date(2026, 3, 1),
end: new Date(2026, 3, 14),
},
{
id: 'all-dates',
label: 'All dates',
start: null,
end: null,
},
]
const formatCurrency = (value: number) =>
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(value)
const getChannelIcon = (channel: Channel) => {
switch (channel) {
case 'Storefront':
return PiStorefront
case 'Marketplace':
return PiGlobe
case 'Wholesale':
return PiBuildings
}
}
const renderPaymentTag = (paymentStatus: PaymentStatus) => {
const isPaid = paymentStatus === 'Paid'
return (
<Tag
className={
isPaid
? 'border-0 bg-success-soft text-success'
: 'border-0 bg-destructive-soft text-destructive'
}
prefix={
isPaid ? (
<PiCheckCircleFill className="mr-1 text-sm" />
) : (
<PiXCircleFill className="mr-1 text-sm" />
)
}
>
{paymentStatus}
</Tag>
)
}
const renderFulfillmentTag = (fulfillmentStatus: FulfillmentStatus | null) => {
if (!fulfillmentStatus) {
return null
}
const isFulfilled = fulfillmentStatus === 'Fulfilled'
return (
<Tag
className="gap-1 bg-card"
>
{
isFulfilled ? (
<PiCheckSquare />
) : (
<PiPackage />
)
}
{fulfillmentStatus}
</Tag>
)
}
const columns: ColumnDef<OrderRow>[] = [
{
header: 'Order ID',
accessorKey: 'orderId',
enableSorting: true,
cell: ({ row }) => (
<span className="truncate font-medium">{row.original.orderId}</span>
),
},
{
header: 'Total',
accessorKey: 'total',
enableSorting: true,
cell: ({ row }) => (
<span>
{formatCurrency(row.original.total)}
</span>
),
},
{
header: 'Status',
accessorKey: 'paymentStatus',
enableSorting: true,
cell: ({ row }) => (
<div className="flex flex-nowrap items-center gap-2">
{renderPaymentTag(row.original.paymentStatus)}
{renderFulfillmentTag(row.original.fulfillmentStatus)}
</div>
),
},
{
header: 'Customer',
accessorKey: 'customer',
enableSorting: true,
cell: ({ row }) => (
<div className="flex min-w-0 items-center gap-2">
<Avatar
alt={row.original.customer}
size={24}
src={row.original.customerAvatar}
/>
<span className="truncate font-medium">{row.original.customer}</span>
</div>
),
},
{
header: 'Order Date',
accessorKey: 'orderDate',
enableSorting: true,
cell: ({ row }) => (
<span className="whitespace-nowrap text-muted-foreground">
{row.original.orderDateLabel}
</span>
),
},
{
header: 'Channel',
accessorKey: 'channel',
enableSorting: true,
cell: ({ row }) => {
const ChannelIcon = getChannelIcon(row.original.channel)
return (
<span className="flex max-w-40 min-w-0 items-center gap-2">
<ChannelIcon className="shrink-0 text-base text-muted-foreground" />
<span className="truncate">{row.original.channel}</span>
</span>
)
},
},
{
id: 'actions',
header: '',
enableSorting: false,
size: 48,
minSize: 48,
maxSize: 48,
cell: ({ row }) => (
<div className="flex justify-end">
<Dropdown
placement="bottom-end"
renderTitle={
<Button
aria-label={`More actions for ${row.original.orderId}`}
icon={<PiDotsThreeVerticalBold />}
shape="circle"
size="sm"
variant="ghost"
/>
}
>
<Dropdown.Item eventKey="view">View order</Dropdown.Item>
<Dropdown.Item eventKey="duplicate">
Duplicate order
</Dropdown.Item>
<Dropdown.Item eventKey="archive">Archive order</Dropdown.Item>
</Dropdown>
</div>
),
},
]
export default function DataGridOrderHistory() {
const [searchQuery, setSearchQuery] = useState('')
const [paymentStatus, setPaymentStatus] = useState<PaymentFilter>('All')
const [fulfillmentStatus, setFulfillmentStatus] =
useState<FulfillmentFilter>('All')
const [dateRange, setDateRange] = useState<DateRangeOption>(
dateRangeOptions[0],
)
const [sortKey, setSortKey] = useState<SortKey | null>(null)
const [sortOrder, setSortOrder] = useState<'asc' | 'desc' | ''>('')
const [pageIndex, setPageIndex] = useState(1)
const [pageSize, setPageSize] = useState(10)
const tableData = useMemo(() => {
const filterMatchedOrders = orderRows.filter((order) => {
const matchesPayment =
paymentStatus === 'All' ||
order.paymentStatus === paymentStatus
const matchesFulfillment =
fulfillmentStatus === 'All' ||
order.fulfillmentStatus === fulfillmentStatus
const orderDate = new Date(`${order.orderDate}T12:00:00`)
const matchesDateRange =
(!dateRange.start || orderDate >= dateRange.start) &&
(!dateRange.end || orderDate <= dateRange.end)
return matchesPayment && matchesFulfillment && matchesDateRange
})
const normalizedSearchQuery = searchQuery.trim().toLowerCase()
const searchMatchedOrders = normalizedSearchQuery
? filterMatchedOrders.filter((order) =>
[order.orderId, order.customer, order.channel].some((value) =>
value.toLowerCase().includes(normalizedSearchQuery),
),
)
: filterMatchedOrders
const sortedOrders = [...searchMatchedOrders].sort((left, right) => {
if (!sortKey || !sortOrder) {
return 0
}
const comparison =
sortKey === 'total'
? left.total - right.total
: left[sortKey].localeCompare(right[sortKey])
return sortOrder === 'asc' ? comparison : -comparison
})
const pageStart = (pageIndex - 1) * pageSize
return {
rows: sortedOrders.slice(pageStart, pageStart + pageSize),
total: sortedOrders.length,
}
}, [
dateRange,
fulfillmentStatus,
pageIndex,
pageSize,
paymentStatus,
searchQuery,
sortKey,
sortOrder,
])
return (
<section
aria-labelledby="data-grid-order-history-title"
>
<Container className="space-y-4">
<h4 id="data-grid-order-history-title" className="font-semibold">
Order List
</h4>
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<Input
aria-label="Search orders or customers"
className="w-full md:w-80"
onChange={(event) => {
setSearchQuery(event.target.value)
setPageIndex(1)
}}
placeholder="Search orders or customers"
prefix={<PiMagnifyingGlass className="text-muted-foreground" />}
value={searchQuery}
/>
<div className="flex flex-wrap items-center gap-2">
<Dropdown
placement="bottom-end"
renderTitle={
<Button
icon={<PiCalendarBlank />}
iconAlignment="start"
>
{dateRange.label}
</Button>
}
>
{dateRangeOptions.map((option) => (
<Dropdown.Item
active={option.id === dateRange.id}
eventKey={option.id}
key={option.id}
onSelect={() => {
setDateRange(option)
setPageIndex(1)
}}
>
<span className="flex w-full items-center justify-between">
<span>{option.label}</span>
{option.id === dateRange.id && (
<PiCheck
aria-hidden="true"
className="shrink-0 text-base"
/>
)}
</span>
</Dropdown.Item>
))}
</Dropdown>
<Dropdown
placement="bottom-end"
renderTitle={
<Button
active={paymentStatus !== 'All'}
icon={<PiCaretDown />}
iconAlignment="end"
>
{paymentStatus === 'All'
? 'Payment Status'
: paymentStatus}
</Button>
}
>
<Dropdown.Item
active={paymentStatus === 'All'}
eventKey="all-payments"
onSelect={() => {
setPaymentStatus('All')
setPageIndex(1)
}}
>
<span className="flex w-full items-center justify-between">
<span>All payment statuses</span>
{paymentStatus === 'All' && (
<PiCheck
aria-hidden="true"
className="shrink-0 text-base"
/>
)}
</span>
</Dropdown.Item>
<Dropdown.Item
active={paymentStatus === 'Paid'}
eventKey="paid"
onSelect={() => {
setPaymentStatus('Paid')
setPageIndex(1)
}}
>
<span className="flex w-full items-center justify-between">
<span>Paid</span>
{paymentStatus === 'Paid' && (
<PiCheck
aria-hidden="true"
className="shrink-0 text-base"
/>
)}
</span>
</Dropdown.Item>
<Dropdown.Item
active={paymentStatus === 'Canceled'}
eventKey="canceled"
onSelect={() => {
setPaymentStatus('Canceled')
setPageIndex(1)
}}
>
<span className="flex w-full items-center justify-between">
<span>Canceled</span>
{paymentStatus === 'Canceled' && (
<PiCheck
aria-hidden="true"
className="shrink-0 text-base"
/>
)}
</span>
</Dropdown.Item>
</Dropdown>
<Dropdown
placement="bottom-end"
renderTitle={
<Button
active={fulfillmentStatus !== 'All'}
icon={<PiCaretDown />}
iconAlignment="end"
>
{fulfillmentStatus === 'All'
? 'Fulfillment'
: fulfillmentStatus}
</Button>
}
>
<Dropdown.Item
active={fulfillmentStatus === 'All'}
eventKey="all-fulfillment"
onSelect={() => {
setFulfillmentStatus('All')
setPageIndex(1)
}}
>
<span className="flex w-full items-center justify-between">
<span>All fulfillment statuses</span>
{fulfillmentStatus === 'All' && (
<PiCheck
aria-hidden="true"
className="shrink-0 text-base"
/>
)}
</span>
</Dropdown.Item>
<Dropdown.Item
active={fulfillmentStatus === 'Fulfilled'}
eventKey="fulfilled"
onSelect={() => {
setFulfillmentStatus('Fulfilled')
setPageIndex(1)
}}
>
<span className="flex w-full items-center justify-between">
<span>Fulfilled</span>
{fulfillmentStatus === 'Fulfilled' && (
<PiCheck
aria-hidden="true"
className="shrink-0 text-base"
/>
)}
</span>
</Dropdown.Item>
<Dropdown.Item
active={fulfillmentStatus === 'Unfulfilled'}
eventKey="unfulfilled"
onSelect={() => {
setFulfillmentStatus('Unfulfilled')
setPageIndex(1)
}}
>
<span className="flex w-full items-center justify-between">
<span>Unfulfilled</span>
{fulfillmentStatus === 'Unfulfilled' && (
<PiCheck
aria-hidden="true"
className="shrink-0 text-base"
/>
)}
</span>
</Dropdown.Item>
</Dropdown>
</div>
</div>
<Card bodyClass="px-0 pt-0 overflow-hidden">
<DataTable
columns={columns}
data={tableData.rows}
onPageSizeChange={(nextPageSize) => {
setPageSize(nextPageSize)
setPageIndex(1)
}}
onPaginationChange={setPageIndex}
onSort={(sort) => {
if (
typeof sort.sortKey === 'string' &&
sortableKeys.includes(sort.sortKey as SortKey)
) {
setSortKey(sort.sortKey as SortKey)
setSortOrder(sort.sortOrder)
setPageIndex(1)
}
}}
pagingData={{
total: tableData.total,
pageIndex,
pageSize,
}}
compact
hoverable={false}
className="min-w-[980px]"
/>
</Card>
</Container>
</section>
)
}
Data Grid 02
Preview
npx nateui@latest add DataGridCustomerListDark
import { useEffect, useMemo, useRef, useState } from 'react'
import AdvancedFilterBuilder, {
type FieldConfig,
type FilterTree,
type Rule,
type RuleGroup,
} from '@/components/composites/AdvancedFilterBuilder'
import DataTable, {
type ColumnDef,
type DataTableResetHandle,
type Row,
} from '@/components/composites/DataTable'
import ActionBar from '@/components/ui/ActionBar'
import Button from '@/components/ui/Button'
import Card from '@/components/ui/Card'
import Container from '@/components/composites/Container'
import Drawer from '@/components/ui/Drawer'
import Dropdown from '@/components/ui/Dropdown'
import Form from '@/components/ui/Form'
import Input from '@/components/ui/Input'
import Popover from '@/components/ui/Popover'
import Select from '@/components/ui/Select'
import {
PiArchive,
PiDotsThreeVerticalBold,
PiFunnel,
PiMagnifyingGlass,
PiPlus,
} from 'react-icons/pi'
const assetBase = 'https://statics.nateui.com/img'
type CustomerRow = {
id: string
organization: string
organizationLogo: string
email: string
userName: string
sessions: number
notes: string
outstanding: number
date: string
dateLabel: string
}
type CustomerSortKey =
| 'organization'
| 'userName'
| 'sessions'
| 'notes'
| 'outstanding'
type CustomerFilterValue = string | number
const pageSizes = [10, 20, 24]
const customerRows: CustomerRow[] = [
{
id: 'CUS-2401',
organization: 'Amazon',
organizationLogo: `${assetBase}/thumbs/brands/amazon.png`,
email: 'hello@northwind.example.com',
userName: 'Avery Chen',
sessions: 18,
notes: 'Renewal review',
outstanding: 314.28,
date: '2026-03-18',
dateLabel: '18-03-2026',
},
{
id: 'CUS-2402',
organization: 'Asana',
organizationLogo: `${assetBase}/thumbs/brands/asana.png`,
email: 'team@cedarfinch.example.com',
userName: 'Rowan Blake',
sessions: 9,
notes: 'Account review',
outstanding: 671.45,
date: '2026-03-16',
dateLabel: '16-03-2026',
},
{
id: 'CUS-2403',
organization: 'Atlassian',
organizationLogo: `${assetBase}/thumbs/brands/atlassian.png`,
email: 'ops@atlasworkshop.example.com',
userName: 'Sage Patel',
sessions: 27,
notes: 'Usage follow-up',
outstanding: 128.36,
date: '2026-03-14',
dateLabel: '14-03-2026',
},
{
id: 'CUS-2404',
organization: 'Canva',
organizationLogo: `${assetBase}/thumbs/brands/canva.png`,
email: 'admin@junipersignal.example.com',
userName: 'Mina Cole',
sessions: 14,
notes: 'Billing question',
outstanding: 944.17,
date: '2026-03-12',
dateLabel: '12-03-2026',
},
{
id: 'CUS-2405',
organization: 'Dropbox',
organizationLogo: `${assetBase}/thumbs/brands/dropbox.png`,
email: 'studio@morrowhouse.example.com',
userName: 'Theo Park',
sessions: 33,
notes: 'Onboarding',
outstanding: 83.52,
date: '2026-03-10',
dateLabel: '10-03-2026',
},
{
id: 'CUS-2406',
organization: 'eBay',
organizationLogo: `${assetBase}/thumbs/brands/ebay.png`,
email: 'contact@brightwellworks.example.com',
userName: 'Ivy Monroe',
sessions: 6,
notes: 'Invoice sent',
outstanding: 527.64,
date: '2026-03-08',
dateLabel: '08-03-2026',
},
{
id: 'CUS-2407',
organization: 'Figma',
organizationLogo: `${assetBase}/thumbs/brands/figma.png`,
email: 'team@harbormetric.example.com',
userName: 'Nico Reyes',
sessions: 21,
notes: 'Renewal review',
outstanding: 149.91,
date: '2026-03-06',
dateLabel: '06-03-2026',
},
{
id: 'CUS-2408',
organization: 'GitHub',
organizationLogo: `${assetBase}/thumbs/brands/github.png`,
email: 'hello@cindergrove.example.com',
userName: 'Elle Foster',
sessions: 42,
notes: 'Account review',
outstanding: 792.08,
date: '2026-03-04',
dateLabel: '04-03-2026',
},
{
id: 'CUS-2409',
organization: 'Google',
organizationLogo: `${assetBase}/thumbs/brands/google.png`,
email: 'desk@fieldstoneoffice.example.com',
userName: 'Jasper Kline',
sessions: 11,
notes: 'Usage follow-up',
outstanding: 266.73,
date: '2026-03-02',
dateLabel: '02-03-2026',
},
{
id: 'CUS-2410',
organization: 'HubSpot',
organizationLogo: `${assetBase}/thumbs/brands/hubspot.png`,
email: 'admin@solacecircuit.example.com',
userName: 'Wren Solis',
sessions: 16,
notes: 'Onboarding',
outstanding: 602.14,
date: '2026-02-28',
dateLabel: '28-02-2026',
},
{
id: 'CUS-2411',
organization: 'Jira',
organizationLogo: `${assetBase}/thumbs/brands/jira.png`,
email: 'team@meadowline.example.com',
userName: 'Luca Mercer',
sessions: 8,
notes: 'Billing question',
outstanding: 39.87,
date: '2026-02-26',
dateLabel: '26-02-2026',
},
{
id: 'CUS-2412',
organization: 'Microsoft',
organizationLogo: `${assetBase}/thumbs/brands/microsoft.png`,
email: 'ops@amberrelay.example.com',
userName: 'Zoe Calloway',
sessions: 24,
notes: 'Invoice sent',
outstanding: 871.22,
date: '2026-02-24',
dateLabel: '24-02-2026',
},
{
id: 'CUS-2413',
organization: 'Notion',
organizationLogo: `${assetBase}/thumbs/brands/notion.png`,
email: 'hello@pineharbor.example.com',
userName: 'Milo Finch',
sessions: 13,
notes: 'Renewal review',
outstanding: 458.63,
date: '2026-02-22',
dateLabel: '22-02-2026',
},
{
id: 'CUS-2414',
organization: 'Oracle',
organizationLogo: `${assetBase}/thumbs/brands/oracle.png`,
email: 'studio@lumenyard.example.com',
userName: 'Anika Brooks',
sessions: 37,
notes: 'Account review',
outstanding: 116.49,
date: '2026-02-20',
dateLabel: '20-02-2026',
},
{
id: 'CUS-2415',
organization: 'Salesforce',
organizationLogo: `${assetBase}/thumbs/brands/salesforce.png`,
email: 'contact@orchardlane.example.com',
userName: 'Rhea Dalton',
sessions: 5,
notes: 'Usage follow-up',
outstanding: 735.18,
date: '2026-02-18',
dateLabel: '18-02-2026',
},
{
id: 'CUS-2416',
organization: 'Shopify',
organizationLogo: `${assetBase}/thumbs/brands/shopify.png`,
email: 'team@copperfinch.example.com',
userName: 'Kian Mercer',
sessions: 29,
notes: 'Onboarding',
outstanding: 182.75,
date: '2026-02-16',
dateLabel: '16-02-2026',
},
{
id: 'CUS-2417',
organization: 'Slack',
organizationLogo: `${assetBase}/thumbs/brands/slack.png`,
email: 'hello@marblecurrent.example.com',
userName: 'Tess Rowan',
sessions: 19,
notes: 'Billing question',
outstanding: 989.41,
date: '2026-02-14',
dateLabel: '14-02-2026',
},
{
id: 'CUS-2418',
organization: 'Spotify',
organizationLogo: `${assetBase}/thumbs/brands/spotify.png`,
email: 'ops@rainierthread.example.com',
userName: 'Omar Vale',
sessions: 31,
notes: 'Invoice sent',
outstanding: 344.06,
date: '2026-02-12',
dateLabel: '12-02-2026',
},
{
id: 'CUS-2419',
organization: 'Stripe',
organizationLogo: `${assetBase}/thumbs/brands/stripe.png`,
email: 'admin@quietbeacon.example.com',
userName: 'Priya Knox',
sessions: 10,
notes: 'Renewal review',
outstanding: 628.57,
date: '2026-02-10',
dateLabel: '10-02-2026',
},
{
id: 'CUS-2420',
organization: 'Tesla',
organizationLogo: `${assetBase}/thumbs/brands/tesla.png`,
email: 'team@asterfoundry.example.com',
userName: 'Silas Reed',
sessions: 26,
notes: 'Account review',
outstanding: 72.94,
date: '2026-02-08',
dateLabel: '08-02-2026',
},
{
id: 'CUS-2421',
organization: 'TikTok',
organizationLogo: `${assetBase}/thumbs/brands/tiktok.png`,
email: 'hello@bluecedarlab.example.com',
userName: 'Nora Quinn',
sessions: 15,
notes: 'Usage follow-up',
outstanding: 809.35,
date: '2026-02-06',
dateLabel: '06-02-2026',
},
{
id: 'CUS-2422',
organization: 'Twitch',
organizationLogo: `${assetBase}/thumbs/brands/twitch.png`,
email: 'ops@kindredatlas.example.com',
userName: 'Cleo Hart',
sessions: 35,
notes: 'Onboarding',
outstanding: 231.68,
date: '2026-02-04',
dateLabel: '04-02-2026',
},
{
id: 'CUS-2423',
organization: 'WhatsApp',
organizationLogo: `${assetBase}/thumbs/brands/whatsapp.png`,
email: 'contact@willowindex.example.com',
userName: 'Bryn Ellis',
sessions: 7,
notes: 'Billing question',
outstanding: 514.82,
date: '2026-02-02',
dateLabel: '02-02-2026',
},
{
id: 'CUS-2424',
organization: 'Zapier',
organizationLogo: `${assetBase}/thumbs/brands/zapier.png`,
email: 'studio@signalorchard.example.com',
userName: 'Kai Monroe',
sessions: 22,
notes: 'Invoice sent',
outstanding: 947.53,
date: '2026-01-31',
dateLabel: '31-01-2026',
},
]
const noteOptions = [
{ value: 'Renewal review', label: 'Renewal review' },
{ value: 'Account review', label: 'Account review' },
{ value: 'Usage follow-up', label: 'Usage follow-up' },
{ value: 'Billing question', label: 'Billing question' },
{ value: 'Onboarding', label: 'Onboarding' },
{ value: 'Invoice sent', label: 'Invoice sent' },
]
const customerFilterFields: FieldConfig[] = [
{ key: 'organization', label: 'Organization', type: 'text' },
{ key: 'contact', label: 'Contact person', type: 'text' },
{ key: 'sessions', label: 'Sessions', type: 'number' },
{ key: 'notes', label: 'Notes', type: 'select', options: noteOptions },
{ key: 'outstanding', label: 'Outstanding amount', type: 'number' },
{ key: 'date', label: 'Date', type: 'date' },
]
const createEmptyFilterTree = (): FilterTree => ({
id: 'customer-filter-root',
type: 'group',
condition: 'AND',
children: [
{
id: 'customer-filter-rule',
type: 'rule',
field: 'organization',
operator: 'contains',
value: '',
},
],
})
const getFieldValue = (
row: CustomerRow,
field: string,
): CustomerFilterValue | undefined => {
switch (field) {
case 'organization':
return row.organization
case 'contact':
return row.userName
case 'sessions':
return row.sessions
case 'notes':
return row.notes
case 'outstanding':
return row.outstanding
case 'date':
return row.date
default:
return undefined
}
}
const compareFilterRule = (row: CustomerRow, rule: Rule) => {
const rowValue = getFieldValue(row, rule.field)
const ruleValue = rule.value
if (rowValue === undefined || ruleValue === '') {
return true
}
if (typeof rowValue === 'number') {
const numericValue = Number(ruleValue)
if (Number.isNaN(numericValue)) {
return true
}
switch (rule.operator) {
case 'is':
return rowValue === numericValue
case 'is_not':
return rowValue !== numericValue
case 'greater_than':
return rowValue > numericValue
case 'less_than':
return rowValue < numericValue
case 'greater_equal':
return rowValue >= numericValue
case 'less_equal':
return rowValue <= numericValue
default:
return true
}
}
if (rule.field === 'date') {
const toDateKey = (value: string) => {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return undefined
}
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0'),
].join('-')
}
const rowDate = toDateKey(`${String(rowValue)}T12:00:00`)
const ruleDate = toDateKey(String(ruleValue))
if (!rowDate || !ruleDate) {
return true
}
switch (rule.operator) {
case 'is':
return rowDate === ruleDate
case 'is_not':
return rowDate !== ruleDate
case 'greater_than':
return rowDate > ruleDate
case 'less_than':
return rowDate < ruleDate
case 'greater_equal':
return rowDate >= ruleDate
case 'less_equal':
return rowDate <= ruleDate
default:
return true
}
}
const normalizedRowValue = String(rowValue).toLowerCase()
const normalizedRuleValue = String(ruleValue).toLowerCase()
switch (rule.operator) {
case 'is':
return normalizedRowValue === normalizedRuleValue
case 'is_not':
return normalizedRowValue !== normalizedRuleValue
case 'contains':
return normalizedRowValue.includes(normalizedRuleValue)
case 'not_contains':
return !normalizedRowValue.includes(normalizedRuleValue)
case 'starts_with':
return normalizedRowValue.startsWith(normalizedRuleValue)
case 'ends_with':
return normalizedRowValue.endsWith(normalizedRuleValue)
case 'greater_than':
return normalizedRowValue > normalizedRuleValue
case 'less_than':
return normalizedRowValue < normalizedRuleValue
case 'greater_equal':
return normalizedRowValue >= normalizedRuleValue
case 'less_equal':
return normalizedRowValue <= normalizedRuleValue
default:
return true
}
}
const evaluateFilterNode = (
row: CustomerRow,
node: Rule | RuleGroup,
): boolean => {
if (node.type === 'rule') {
return compareFilterRule(row, node)
}
const results = node.children.map((child) => {
const result = evaluateFilterNode(row, child)
return child.logicalOperator === 'NOT' ? !result : result
})
if (results.length === 0) {
return true
}
return node.condition === 'OR'
? results.some(Boolean)
: results.every(Boolean)
}
const hasActiveFilter = (node: Rule | RuleGroup): boolean => {
if (node.type === 'rule') {
return node.value !== ''
}
return node.children.some(hasActiveFilter)
}
const formatOutstanding = (amount: number) =>
amount.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
const customerSortKeys: CustomerSortKey[] = [
'organization',
'userName',
'sessions',
'notes',
'outstanding',
]
const columns: ColumnDef<CustomerRow>[] = [
{
header: () => <span className="whitespace-nowrap">Customer Name</span>,
accessorKey: 'organization',
enableSorting: true,
size: 260,
minSize: 240,
cell: ({ row }) => (
<div className="flex min-w-0 items-center gap-2">
<img
alt={row.original.organization}
className="size-4"
src={row.original.organizationLogo}
/>
<div className="min-w-0">
<div className="whitespace-nowrap font-semibold">
{row.original.organization}
</div>
</div>
</div>
),
},
{
header: () => <span className="whitespace-nowrap">User Name</span>,
accessorKey: 'userName',
enableSorting: true,
size: 150,
cell: ({ row }) => (
<span className="whitespace-nowrap">{row.original.userName}</span>
),
},
{
header: () => <span className="whitespace-nowrap">Sessions</span>,
accessorKey: 'sessions',
enableSorting: true,
size: 100,
cell: ({ row }) => (
<span className="block tabular-nums">
{row.original.sessions}
</span>
),
},
{
header: () => <span className="whitespace-nowrap">Notes</span>,
accessorKey: 'notes',
enableSorting: true,
size: 160,
cell: ({ row }) => (
<span className="whitespace-nowrap">
{row.original.notes}
</span>
),
},
{
header: () => (
<span className="whitespace-nowrap">Total Outstanding</span>
),
accessorKey: 'outstanding',
enableSorting: true,
size: 170,
cell: ({ row }) => (
<span className="block whitespace-nowrap">
<span>$</span>
{formatOutstanding(row.original.outstanding)}
</span>
),
},
{
header: () => <span className="whitespace-nowrap">Date</span>,
accessorKey: 'date',
enableSorting: false,
size: 130,
cell: ({ row }) => (
<span className="whitespace-nowrap">
{row.original.dateLabel}
</span>
),
},
{
id: 'actions',
header: () => <span className="whitespace-nowrap">Actions</span>,
enableSorting: false,
size: 52,
minSize: 52,
maxSize: 52,
cell: ({ row }) => (
<div className="flex justify-end">
<Dropdown
placement="bottom-end"
renderTitle={
<Button
aria-label={`Actions for ${row.original.organization}`}
icon={<PiDotsThreeVerticalBold />}
shape="circle"
size="sm"
variant="ghost"
/>
}
>
<Dropdown.Item eventKey="view">View customer</Dropdown.Item>
<Dropdown.Item eventKey="edit">Edit customer</Dropdown.Item>
<Dropdown.Item eventKey="archive">
Archive customer
</Dropdown.Item>
</Dropdown>
</div>
),
},
]
const customerSegments = [
{ value: 'growth', label: 'Growth' },
{ value: 'scale', label: 'Scale' },
{ value: 'studio', label: 'Studio' },
]
export default function DataGridCustomerList() {
const [customerRowsData, setCustomerRowsData] = useState(customerRows)
const [searchText, setSearchText] = useState('')
const [appliedFilter, setAppliedFilter] = useState<FilterTree>(() =>
createEmptyFilterTree(),
)
const [filterDraft, setFilterDraft] = useState<FilterTree>(() =>
createEmptyFilterTree(),
)
const [sortKey, setSortKey] = useState<CustomerSortKey | null>(null)
const [sortOrder, setSortOrder] = useState<'asc' | 'desc' | ''>('')
const [pageIndex, setPageIndex] = useState(1)
const [pageSize, setPageSize] = useState(pageSizes[0])
const [filterOpen, setFilterOpen] = useState(false)
const [drawerOpen, setDrawerOpen] = useState(false)
const [selectedRowIds, setSelectedRowIds] = useState<string[]>([])
const dataTableRef = useRef<DataTableResetHandle>(null)
const [organizationName, setOrganizationName] = useState('')
const [contactName, setContactName] = useState('')
const [contactEmail, setContactEmail] = useState('')
const [customerSegment, setCustomerSegment] = useState(customerSegments[0])
useEffect(() => {
setPageIndex(1)
setSelectedRowIds([])
}, [searchText, appliedFilter])
const visibleData = useMemo(() => {
const filterMatchedRows = customerRowsData.filter((row) =>
evaluateFilterNode(row, appliedFilter),
)
const normalizedSearch = searchText.trim().toLowerCase()
const searchMatchedRows = normalizedSearch
? filterMatchedRows.filter((row) =>
[row.organization, row.email, row.userName].some((value) =>
value.toLowerCase().includes(normalizedSearch),
),
)
: filterMatchedRows
const sortedRows = [...searchMatchedRows].sort((left, right) => {
if (!sortKey || !sortOrder) {
return 0
}
const leftValue = left[sortKey]
const rightValue = right[sortKey]
const comparison =
typeof leftValue === 'number' && typeof rightValue === 'number'
? leftValue - rightValue
: String(leftValue).localeCompare(String(rightValue))
return sortOrder === 'asc' ? comparison : -comparison
})
const pageStart = (pageIndex - 1) * pageSize
return {
rows: sortedRows.slice(pageStart, pageStart + pageSize),
total: sortedRows.length,
}
}, [
appliedFilter,
customerRowsData,
pageIndex,
pageSize,
searchText,
sortKey,
sortOrder,
])
const handleSort = (sort: { sortKey: string | number; sortOrder: 'asc' | 'desc' | '' }) => {
if (
typeof sort.sortKey !== 'string' ||
!customerSortKeys.includes(sort.sortKey as CustomerSortKey)
) {
return
}
setSortKey(sort.sortOrder ? (sort.sortKey as CustomerSortKey) : null)
setSortOrder(sort.sortOrder)
setPageIndex(1)
setSelectedRowIds([])
}
const handleRowSelect = (checked: boolean, row: CustomerRow) => {
setSelectedRowIds((current) =>
checked
? Array.from(new Set([...current, row.id]))
: current.filter((id) => id !== row.id),
)
}
const handleAllRowSelect = (
checked: boolean,
rows: Row<CustomerRow>[],
) => {
const pageRowIds = rows.map((row) => row.original.id)
setSelectedRowIds((current) => {
if (checked) {
return Array.from(new Set([...current, ...pageRowIds]))
}
return current.filter((id) => !pageRowIds.includes(id))
})
}
const handleApplyFilter = (tree: FilterTree) => {
setAppliedFilter(tree)
setFilterDraft(tree)
setFilterOpen(false)
}
const handleResetFilter = (tree: FilterTree) => {
setAppliedFilter(tree)
setFilterDraft(tree)
setPageIndex(1)
setSelectedRowIds([])
}
const handlePageChange = (page: number) => {
setPageIndex(page)
setSelectedRowIds([])
}
const handlePageSizeChange = (nextPageSize: number) => {
setPageSize(nextPageSize)
setPageIndex(1)
setSelectedRowIds([])
}
const archiveSelectedCustomers = () => {
const selectedIds = new Set(selectedRowIds)
dataTableRef.current?.resetSelected()
setCustomerRowsData((current) =>
current.filter((row) => !selectedIds.has(row.id)),
)
setSelectedRowIds([])
setPageIndex(1)
}
return (
<section
aria-labelledby="data-grid-customer-list-title"
className="w-full"
>
<Container>
<Card bodyClass="min-w-0 px-0 pt-0">
<div className="border-b p-4 md:p-6">
<header className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<h4 id="data-grid-customer-list-title" className="font-semibold">
Customers List
</h4>
<p className="mt-1 text-sm text-muted-foreground">
{visibleData.total} customers
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input
aria-label="Search customers"
className="w-full sm:w-64"
onChange={(event) => setSearchText(event.target.value)}
placeholder="Search customers"
prefix={<PiMagnifyingGlass className="text-muted-foreground" />}
value={searchText}
/>
<Popover
open={filterOpen}
onOpenChange={setFilterOpen}
placement="bottom"
className="p-0"
renderTrigger={
<Button
active={hasActiveFilter(appliedFilter)}
aria-haspopup="dialog"
icon={<PiFunnel />}
>
Filter
</Button>
}
>
<AdvancedFilterBuilder
fields={customerFilterFields}
value={filterDraft}
onChange={(tree) => setFilterDraft(tree)}
onApply={(tree) => handleApplyFilter(tree)}
onReset={(tree) => handleResetFilter(tree)}
/>
</Popover>
<Button
icon={<PiPlus />}
onClick={() => setDrawerOpen(true)}
variant="solid"
>
New Customer
</Button>
</div>
</header>
</div>
<DataTable
ref={(
instance: DataTableResetHandle | HTMLTableElement | null,
) => {
if (instance && 'resetSelected' in instance) {
dataTableRef.current = instance
}
}}
columns={columns}
data={visibleData.rows}
indeterminateCheckboxChecked={(rows) => {
return (
rows.length > 0 &&
rows.every((row) =>
selectedRowIds.includes(row.original.id),
)
)
}}
onAllRowSelect={handleAllRowSelect}
onPageSizeChange={handlePageSizeChange}
onPaginationChange={handlePageChange}
onRowSelect={handleRowSelect}
onSort={handleSort}
pageSizes={pageSizes}
pagingData={{
total: visibleData.total,
pageIndex,
pageSize,
}}
selectable
checkboxChecked={(row) => selectedRowIds.includes(row.id)}
compact
hoverable
/>
<ActionBar open={selectedRowIds.length > 0} width={560}>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<span className="font-medium">
<span className="font-semibold text-foreground">
{selectedRowIds.length}{' '}
{selectedRowIds.length === 1
? 'customer'
: 'customers'}
</span>{' '}
selected
</span>
<Button
icon={<PiArchive />}
onClick={archiveSelectedCustomers}
destructive
>
Archive
</Button>
</div>
</ActionBar>
</Card>
{drawerOpen && (
<Drawer
isOpen={drawerOpen}
onClose={() => setDrawerOpen(false)}
placement="right"
title="New Customer"
width={420}
contentClassName="max-w-[calc(100vw-1rem)]"
footer={
<div className="flex w-full justify-end gap-2">
<Button
onClick={() => setDrawerOpen(false)}
variant="subtle"
>
Cancel
</Button>
<Button form="new-customer-form" type="submit" variant="solid">
Create customer
</Button>
</div>
}
>
<Form
id="new-customer-form"
onSubmit={(event) => {
event.preventDefault()
setDrawerOpen(false)
}}
size="md"
>
<Form.Field htmlFor="customer-organization" label="Organization name">
<Input
id="customer-organization"
onChange={(event) => setOrganizationName(event.target.value)}
value={organizationName}
/>
</Form.Field>
<Form.Field htmlFor="customer-contact" label="Contact person">
<Input
id="customer-contact"
onChange={(event) => setContactName(event.target.value)}
value={contactName}
/>
</Form.Field>
<Form.Field htmlFor="customer-email" label="Email">
<Input
id="customer-email"
onChange={(event) => setContactEmail(event.target.value)}
type="email"
value={contactEmail}
/>
</Form.Field>
<Form.Field htmlFor="customer-segment" label="Customer segment">
<Select
inputId="customer-segment"
onChange={(option) => {
if (option) {
setCustomerSegment(option)
}
}}
options={customerSegments}
value={customerSegment}
/>
</Form.Field>
</Form>
</Drawer>
)}
</Container>
</section>
)
}
Data Grid 03
Preview
npx nateui@latest add DataGridContactFilterBarDark
import { useEffect, useMemo, useRef, useState } from 'react'
import DataTable, {
type ColumnDef,
type DataTableResetHandle,
type Row,
} from '@/components/composites/DataTable'
import PopoverFilter from '@/components/composites/PopoverFilter'
import ActionBar from '@/components/ui/ActionBar'
import Avatar from '@/components/ui/Avatar'
import Button from '@/components/ui/Button'
import Calendar from '@/components/ui/Calendar'
import Checkbox from '@/components/ui/Checkbox'
import Dropdown from '@/components/ui/Dropdown'
import Input from '@/components/ui/Input'
import Popover from '@/components/ui/Popover'
import Tabs from '@/components/ui/Tabs'
import Tag from '@/components/ui/Tag'
import Tooltip from '@/components/ui/Tooltip'
import {
PiAddressBook,
PiArchive,
PiArrowUUpLeft,
PiCaretDown,
PiCopy,
PiDotsThreeVerticalBold,
PiEnvelopeSimple,
PiEnvelopeSimpleOpen,
PiExport,
PiMagnifyingGlass,
PiPlus,
PiUsersThree,
} from 'react-icons/pi'
const assetBase = 'https://statics.nateui.com/img'
const currentDate = new Date()
currentDate.setHours(12, 0, 0, 0)
const formatDateKey = (date: Date) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const relativeDateKey = (daysFromToday: number) => {
const date = new Date(currentDate)
date.setDate(date.getDate() + daysFromToday)
return formatDateKey(date)
}
type PaletteTone =
| 'emerald'
| 'rose'
| 'blue'
| 'cyan'
| 'orange'
| 'red'
| 'purple'
| 'yellow'
| 'lime'
| 'gray'
type LeadStatus =
| 'Open Deal'
| 'Close Deal'
| 'Negotiation'
| 'In Progress'
| 'Unqualified'
type ContactSegment = 'subscribed' | 'opted-out' | 'customer'
const contactViewTabs = [
{
value: 'all',
id: 'crm-contact-tab-directory',
label: 'Full directory',
icon: <PiAddressBook />,
},
{
value: 'subscribed',
id: 'crm-contact-tab-subscribed',
label: 'Newsletter members',
icon: <PiEnvelopeSimple />,
},
{
value: 'opted-out',
id: 'crm-contact-tab-opted-out',
label: 'Opted out',
icon: <PiEnvelopeSimpleOpen />,
},
{
value: 'customer',
id: 'crm-contact-tab-customers',
label: 'Customer accounts',
icon: <PiUsersThree />,
},
] as const
type ContactView = (typeof contactViewTabs)[number]['value']
const filterToolOptions = [
{ value: 'owner', label: 'Contact owner' },
{ value: 'created-date', label: 'Created date' },
{ value: 'last-activity', label: 'Last activity date' },
{ value: 'lead-status', label: 'Lead status' },
] as const
type FilterTool = (typeof filterToolOptions)[number]['value']
type ContactRow = {
id: string
name: string
email?: string
phone?: string
company: string
companyLogo: string
avatarSrc?: string
owner: string
segment: ContactSegment
createdAt: string
lastActivityDate: string
leadStatus?: LeadStatus
leadStatusTone?: PaletteTone
}
const pageSizes = [10, 20, 24]
const paletteClassMap: Record<
PaletteTone,
{ tag: string }
> = {
emerald: {
tag: 'border-0 bg-palette-emerald-soft text-palette-emerald-soft-foreground',
},
rose: {
tag: 'border-0 bg-palette-rose-soft text-palette-rose-soft-foreground',
},
blue: {
tag: 'border-0 bg-palette-blue-soft text-palette-blue-soft-foreground',
},
cyan: {
tag: 'border-0 bg-palette-cyan-soft text-palette-cyan-soft-foreground',
},
orange: {
tag: 'border-0 bg-palette-orange-soft text-palette-orange-soft-foreground',
},
red: {
tag: 'border-0 bg-palette-red-soft text-palette-red-soft-foreground',
},
purple: {
tag: 'border-0 bg-palette-purple-soft text-palette-purple-soft-foreground',
},
yellow: {
tag: 'border-0 bg-palette-yellow-soft text-palette-yellow-soft-foreground',
},
lime: {
tag: 'border-0 bg-palette-lime-soft text-palette-lime-soft-foreground',
},
gray: {
tag: 'border-0 bg-palette-gray-soft text-palette-gray-soft-foreground',
},
}
const contactRows: ContactRow[] = [
{
id: 'CNT-1001',
segment: 'subscribed',
name: 'Priya Nair',
email: 'priya.nair@example.com',
phone: '+60 12 487 2031',
company: 'Amazon',
companyLogo: `${assetBase}/thumbs/brands/amazon.png`,
avatarSrc: `${assetBase}/avatars/thumb-1.jpg`,
owner: 'Amara Singh',
createdAt: relativeDateKey(0),
lastActivityDate: relativeDateKey(0),
leadStatus: 'Open Deal',
leadStatusTone: 'emerald',
},
{
id: 'CNT-1002',
segment: 'customer',
avatarSrc: `${assetBase}/avatars/thumb-2.jpg`,
name: 'Marcus Lee',
email: 'marcus.lee@example.com',
phone: '+65 8123 0904',
company: 'Asana',
companyLogo: `${assetBase}/thumbs/brands/asana.png`,
owner: 'Ben Ortiz',
createdAt: relativeDateKey(-1),
lastActivityDate: relativeDateKey(-1),
leadStatus: 'Negotiation',
leadStatusTone: 'purple',
},
{
id: 'CNT-1003',
segment: 'subscribed',
name: 'Elena Torres',
email: 'elena.torres@example.com',
company: 'Atlassian',
companyLogo: `${assetBase}/thumbs/brands/atlassian.png`,
avatarSrc: `${assetBase}/avatars/thumb-3.jpg`,
owner: 'Camille Park',
createdAt: relativeDateKey(-2),
lastActivityDate: relativeDateKey(-2),
leadStatus: 'In Progress',
leadStatusTone: 'orange',
},
{
id: 'CNT-1004',
segment: 'opted-out',
avatarSrc: `${assetBase}/avatars/thumb-4.jpg`,
name: 'Noah Brooks',
email: 'noah.brooks@example.com',
phone: '+44 20 7946 0821',
company: 'Canva',
companyLogo: `${assetBase}/thumbs/brands/canva.png`,
owner: 'Devon Ellis',
createdAt: relativeDateKey(-3),
lastActivityDate: relativeDateKey(-3),
leadStatus: 'Close Deal',
leadStatusTone: 'blue',
},
{
id: 'CNT-1005',
segment: 'customer',
name: 'Hana Ito',
email: 'hana.ito@example.com',
phone: '+81 3 4510 2284',
company: 'Dropbox',
companyLogo: `${assetBase}/thumbs/brands/dropbox.png`,
avatarSrc: `${assetBase}/avatars/thumb-5.jpg`,
owner: 'Amara Singh',
createdAt: relativeDateKey(-4),
lastActivityDate: relativeDateKey(-4),
leadStatus: 'Open Deal',
leadStatusTone: 'emerald',
},
{
id: 'CNT-1006',
segment: 'subscribed',
avatarSrc: `${assetBase}/avatars/thumb-6.jpg`,
name: 'Julian Stone',
email: 'julian.stone@example.com',
phone: '+1 415 555 0138',
company: 'eBay',
companyLogo: `${assetBase}/thumbs/brands/ebay.png`,
owner: 'Ben Ortiz',
createdAt: relativeDateKey(-5),
lastActivityDate: relativeDateKey(-5),
leadStatus: 'Unqualified',
leadStatusTone: 'gray',
},
{
id: 'CNT-1007',
segment: 'subscribed',
name: 'Clara West',
email: 'clara.west@example.com',
company: 'Figma',
companyLogo: `${assetBase}/thumbs/brands/figma.png`,
avatarSrc: `${assetBase}/avatars/thumb-7.jpg`,
owner: 'Camille Park',
createdAt: relativeDateKey(-6),
lastActivityDate: relativeDateKey(-6),
leadStatus: 'In Progress',
leadStatusTone: 'orange',
},
{
id: 'CNT-1008',
segment: 'customer',
avatarSrc: `${assetBase}/avatars/thumb-8.jpg`,
name: 'Omar Reed',
email: 'omar.reed@example.com',
phone: '+1 312 555 0176',
company: 'GitHub',
companyLogo: `${assetBase}/thumbs/brands/github.png`,
owner: 'Devon Ellis',
createdAt: relativeDateKey(-7),
lastActivityDate: relativeDateKey(-7),
leadStatus: 'Negotiation',
leadStatusTone: 'purple',
},
{
id: 'CNT-1009',
segment: 'opted-out',
avatarSrc: `${assetBase}/avatars/thumb-9.jpg`,
name: 'Layla Cole',
email: 'layla.cole@example.com',
phone: '+61 2 8015 4498',
company: 'Google',
companyLogo: `${assetBase}/thumbs/brands/google.png`,
owner: 'Amara Singh',
createdAt: relativeDateKey(-8),
lastActivityDate: relativeDateKey(-8),
leadStatus: 'Open Deal',
leadStatusTone: 'emerald',
},
{
id: 'CNT-1010',
segment: 'subscribed',
avatarSrc: `${assetBase}/avatars/thumb-10.jpg`,
name: 'Dario Costa',
email: 'dario.costa@example.com',
phone: '+39 02 8734 1260',
company: 'HubSpot',
companyLogo: `${assetBase}/thumbs/brands/hubspot.png`,
owner: 'Ben Ortiz',
createdAt: relativeDateKey(-9),
lastActivityDate: relativeDateKey(-9),
leadStatus: 'Close Deal',
leadStatusTone: 'blue',
},
{
id: 'CNT-1011',
segment: 'customer',
name: 'Maya Green',
email: 'maya.green@example.com',
company: 'Jira',
companyLogo: `${assetBase}/thumbs/brands/jira.png`,
avatarSrc: `${assetBase}/avatars/thumb-11.jpg`,
owner: 'Camille Park',
createdAt: relativeDateKey(-10),
lastActivityDate: relativeDateKey(-10),
leadStatus: 'In Progress',
leadStatusTone: 'orange',
},
{
id: 'CNT-1012',
segment: 'subscribed',
avatarSrc: `${assetBase}/avatars/thumb-12.jpg`,
name: 'Theo Martin',
email: 'theo.martin@example.com',
phone: '+33 1 84 88 0312',
company: 'Microsoft',
companyLogo: `${assetBase}/thumbs/brands/microsoft.png`,
owner: 'Devon Ellis',
createdAt: relativeDateKey(-11),
lastActivityDate: relativeDateKey(-11),
leadStatus: 'Negotiation',
leadStatusTone: 'purple',
},
{
id: 'CNT-1013',
segment: 'customer',
name: 'Aisha Rahman',
email: 'aisha.rahman@example.com',
phone: '+971 4 555 0193',
company: 'Notion',
companyLogo: `${assetBase}/thumbs/brands/notion.png`,
avatarSrc: `${assetBase}/avatars/thumb-13.jpg`,
owner: 'Amara Singh',
createdAt: relativeDateKey(-12),
lastActivityDate: relativeDateKey(-12),
leadStatus: 'Open Deal',
leadStatusTone: 'emerald',
},
{
id: 'CNT-1014',
segment: 'opted-out',
avatarSrc: `${assetBase}/avatars/thumb-14.jpg`,
name: 'Felix Morgan',
email: 'felix.morgan@example.com',
phone: '+49 30 5550 1882',
company: 'Oracle',
companyLogo: `${assetBase}/thumbs/brands/oracle.png`,
owner: 'Ben Ortiz',
createdAt: relativeDateKey(-13),
lastActivityDate: relativeDateKey(-13),
leadStatus: 'Unqualified',
leadStatusTone: 'gray',
},
{
id: 'CNT-1015',
segment: 'subscribed',
avatarSrc: `${assetBase}/avatars/thumb-15.jpg`,
name: 'Sofia Marin',
email: 'sofia.marin@example.com',
company: 'Salesforce',
companyLogo: `${assetBase}/thumbs/brands/salesforce.png`,
owner: 'Camille Park',
createdAt: relativeDateKey(-14),
lastActivityDate: relativeDateKey(-14),
leadStatus: 'In Progress',
leadStatusTone: 'orange',
},
{
id: 'CNT-1016',
segment: 'customer',
avatarSrc: `${assetBase}/avatars/thumb-16.jpg`,
name: 'Evan Price',
email: 'evan.price@example.com',
phone: '+1 206 555 0107',
company: 'Shopify',
companyLogo: `${assetBase}/thumbs/brands/shopify.png`,
owner: 'Devon Ellis',
createdAt: relativeDateKey(-15),
lastActivityDate: relativeDateKey(-15),
leadStatus: 'Close Deal',
leadStatusTone: 'blue',
},
{
id: 'CNT-1017',
segment: 'subscribed',
name: 'Nadia Khan',
email: 'nadia.khan@example.com',
phone: '+92 21 555 0144',
company: 'Slack',
companyLogo: `${assetBase}/thumbs/brands/slack.png`,
avatarSrc: `${assetBase}/avatars/thumb-17.jpg`,
owner: 'Amara Singh',
createdAt: relativeDateKey(-16),
lastActivityDate: relativeDateKey(-16),
},
{
id: 'CNT-1018',
segment: 'opted-out',
avatarSrc: `${assetBase}/avatars/thumb-18.jpg`,
name: 'Jonah Bell',
email: 'jonah.bell@example.com',
phone: '+1 617 555 0159',
company: 'Spotify',
companyLogo: `${assetBase}/thumbs/brands/spotify.png`,
owner: 'Ben Ortiz',
createdAt: relativeDateKey(-17),
lastActivityDate: relativeDateKey(-17),
leadStatus: 'Negotiation',
leadStatusTone: 'purple',
},
{
id: 'CNT-1019',
segment: 'customer',
name: 'Mina Duarte',
email: 'mina.duarte@example.com',
phone: '+351 21 555 0177',
company: 'Stripe',
companyLogo: `${assetBase}/thumbs/brands/stripe.png`,
avatarSrc: `${assetBase}/avatars/thumb-19.jpg`,
owner: 'Camille Park',
createdAt: relativeDateKey(-18),
lastActivityDate: relativeDateKey(-18),
leadStatus: 'Open Deal',
leadStatusTone: 'emerald',
},
{
id: 'CNT-1020',
segment: 'subscribed',
avatarSrc: `${assetBase}/avatars/thumb-20.jpg`,
name: 'Ravi Mehta',
email: 'ravi.mehta@example.com',
phone: '+91 80 5550 2240',
company: 'Tesla',
companyLogo: `${assetBase}/thumbs/brands/tesla.png`,
owner: 'Devon Ellis',
createdAt: relativeDateKey(-19),
lastActivityDate: relativeDateKey(-19),
leadStatus: 'In Progress',
leadStatusTone: 'orange',
},
{
id: 'CNT-1021',
segment: 'customer',
name: 'Grace Liu',
email: 'grace.liu@example.com',
company: 'TikTok',
companyLogo: `${assetBase}/thumbs/brands/tiktok.png`,
avatarSrc: `${assetBase}/avatars/thumb-21.jpg`,
owner: 'Amara Singh',
createdAt: relativeDateKey(-20),
lastActivityDate: relativeDateKey(-20),
leadStatus: 'Close Deal',
leadStatusTone: 'blue',
},
{
id: 'CNT-1022',
segment: 'subscribed',
avatarSrc: `${assetBase}/avatars/thumb-22.jpg`,
name: 'Mateo Ruiz',
email: 'mateo.ruiz@example.com',
phone: '+34 91 555 0118',
company: 'Twitch',
companyLogo: `${assetBase}/thumbs/brands/twitch.png`,
owner: 'Ben Ortiz',
createdAt: relativeDateKey(-21),
lastActivityDate: relativeDateKey(-21),
leadStatus: 'Unqualified',
leadStatusTone: 'gray',
},
{
id: 'CNT-1023',
segment: 'opted-out',
name: 'Iris Novak',
email: 'iris.novak@example.com',
phone: '+420 2 555 0127',
company: 'WhatsApp',
companyLogo: `${assetBase}/thumbs/brands/whatsapp.png`,
avatarSrc: `${assetBase}/avatars/thumb-23.jpg`,
owner: 'Camille Park',
createdAt: relativeDateKey(-22),
lastActivityDate: relativeDateKey(-22),
leadStatus: 'Negotiation',
leadStatusTone: 'purple',
},
{
id: 'CNT-1024',
segment: 'customer',
avatarSrc: `${assetBase}/avatars/thumb-24.jpg`,
name: 'Samir Patel',
email: 'samir.patel@example.com',
company: 'Zapier',
companyLogo: `${assetBase}/thumbs/brands/zapier.png`,
owner: 'Devon Ellis',
createdAt: relativeDateKey(-23),
lastActivityDate: relativeDateKey(-23),
},
]
const ownerOptions = [
{ value: 'Amara Singh', label: 'Amara Singh' },
{ value: 'Ben Ortiz', label: 'Ben Ortiz' },
{ value: 'Camille Park', label: 'Camille Park' },
{ value: 'Devon Ellis', label: 'Devon Ellis' },
]
const lastActivityOptions = [
{ value: relativeDateKey(0), label: 'Today' },
{ value: relativeDateKey(-1), label: 'Yesterday' },
{ value: relativeDateKey(-2), label: 'Two days ago' },
{ value: relativeDateKey(-3), label: 'Three days ago' },
]
const leadStatusOptions: Array<{
value: LeadStatus
label: LeadStatus
tone: PaletteTone
}> = [
{ value: 'Open Deal', label: 'Open Deal', tone: 'emerald' },
{ value: 'Close Deal', label: 'Close Deal', tone: 'blue' },
{ value: 'Negotiation', label: 'Negotiation', tone: 'purple' },
{ value: 'In Progress', label: 'In Progress', tone: 'orange' },
{ value: 'Unqualified', label: 'Unqualified', tone: 'gray' },
]
const createPresetOptions = [
{
value: 'yesterday',
label: 'Yesterday',
start: relativeDateKey(-1),
end: relativeDateKey(-1),
},
{
value: 'last-week',
label: 'Last Week',
start: relativeDateKey(-7),
end: relativeDateKey(-1),
},
{
value: 'last-month',
label: 'Last Month',
start: relativeDateKey(-30),
end: relativeDateKey(-1),
},
] as const
type CreatePreset = (typeof createPresetOptions)[number]['value']
type DateRange = [Date | null, Date | null]
type SortKey = 'name' | 'email' | 'company' | 'leadStatus'
type OpenPopover = 'create' | 'lead' | null
const formatDateLabel = (dateKey: string) =>
new Intl.DateTimeFormat('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
}).format(new Date(`${dateKey}T12:00:00`))
const getSelectionLabel = (
fallback: string,
values: string[],
options: Array<{ value: string; label: string }>,
) => {
if (values.length === 1) {
return options.find((option) => option.value === values[0])?.label || fallback
}
return values.length > 1 ? `${fallback} (${values.length})` : fallback
}
const columns: ColumnDef<ContactRow>[] = [
{
header: () => <span className="whitespace-nowrap">Name</span>,
accessorKey: 'name',
enableSorting: true,
size: 210,
minSize: 190,
cell: ({ row }) => (
<div className="flex min-w-0 items-center gap-2">
<Avatar
alt={row.original.name}
size={24}
src={row.original.avatarSrc}
>
{row.original.name
.split(' ')
.map((part) => part[0])
.join('')}
</Avatar>
<span className="whitespace-nowrap font-medium">
{row.original.name}
</span>
</div>
),
},
{
header: () => <span className="whitespace-nowrap">Email</span>,
accessorKey: 'email',
enableSorting: true,
size: 220,
cell: ({ row }) => (
<span className="whitespace-nowrap">
{row.original.email || '--'}
</span>
),
},
{
header: () => <span className="whitespace-nowrap">Phone Number</span>,
accessorKey: 'phone',
enableSorting: false,
size: 170,
cell: ({ row }) => (
<span className="whitespace-nowrap">
{row.original.phone || '--'}
</span>
),
},
{
header: () => <span className="whitespace-nowrap">Company</span>,
accessorKey: 'company',
enableSorting: true,
size: 190,
cell: ({ row }) => (
<div className="flex items-center gap-2 whitespace-nowrap">
<img
alt=""
className="size-4"
src={row.original.companyLogo}
/>
<span>{row.original.company}</span>
</div>
),
},
{
header: () => <span className="whitespace-nowrap">Lead Status</span>,
accessorKey: 'leadStatus',
enableSorting: true,
size: 150,
cell: ({ row }) =>
row.original.leadStatus && row.original.leadStatusTone ? (
<Tag className={paletteClassMap[row.original.leadStatusTone].tag}>
{row.original.leadStatus}
</Tag>
) : (
<span className="text-muted-foreground">--</span>
),
},
{
id: 'actions',
header: '',
enableSorting: false,
size: 52,
minSize: 52,
maxSize: 52,
cell: ({ row }) => (
<div className="flex justify-end">
<Dropdown
placement="bottom-end"
renderTitle={
<Button
aria-label={`Actions for ${row.original.name}`}
icon={<PiDotsThreeVerticalBold />}
shape="circle"
size="sm"
variant="ghost"
/>
}
>
<Dropdown.Item eventKey="view">View contact</Dropdown.Item>
<Dropdown.Item eventKey="edit">Edit contact</Dropdown.Item>
<Dropdown.Item eventKey="archive">
Archive contact
</Dropdown.Item>
</Dropdown>
</div>
),
},
]
export default function DataGridContactFilterBar() {
const [contactRowsData, setContactRowsData] = useState(contactRows)
const [activeSegment, setActiveSegment] = useState<ContactView>('all')
const [searchText, setSearchText] = useState('')
const [ownerFilter, setOwnerFilter] = useState<string[]>([])
const [createPreset, setCreatePreset] = useState<CreatePreset | null>(null)
const [createDateRange, setCreateDateRange] = useState<DateRange>([
null,
null,
])
const [lastActivityFilter, setLastActivityFilter] = useState<string[]>([])
const [leadStatusFilter, setLeadStatusFilter] = useState<LeadStatus[]>([])
const [leadSearch, setLeadSearch] = useState('')
const [openPopover, setOpenPopover] = useState<OpenPopover>(null)
const [sortKey, setSortKey] = useState<SortKey | null>(null)
const [sortOrder, setSortOrder] = useState<'asc' | 'desc' | ''>('')
const [pageIndex, setPageIndex] = useState(1)
const [pageSize, setPageSize] = useState(pageSizes[0])
const [selectedRowIds, setSelectedRowIds] = useState<string[]>([])
const dataTableRef = useRef<DataTableResetHandle>(null)
const [visibleFilterTools, setVisibleFilterTools] = useState<FilterTool[]>(
filterToolOptions.map((option) => option.value),
)
const [copied, setCopied] = useState(false)
useEffect(() => {
if (!copied) {
return
}
const timeout = window.setTimeout(() => setCopied(false), 2000)
return () => window.clearTimeout(timeout)
}, [copied])
useEffect(() => {
setPageIndex(1)
setSelectedRowIds([])
}, [
activeSegment,
searchText,
ownerFilter,
createPreset,
createDateRange,
lastActivityFilter,
leadStatusFilter,
])
const visibleData = useMemo(() => {
const filteredRows = contactRowsData.filter((row) => {
const matchesSegment =
activeSegment === 'all' || row.segment === activeSegment
const matchesOwner =
ownerFilter.length === 0 || ownerFilter.includes(row.owner)
const matchesLastActivity =
lastActivityFilter.length === 0 ||
lastActivityFilter.includes(row.lastActivityDate)
const matchesLeadStatus =
leadStatusFilter.length === 0 ||
(row.leadStatus && leadStatusFilter.includes(row.leadStatus))
const matchesCreateDate = createDateRange[0]
? row.createdAt >= formatDateKey(createDateRange[0]) &&
(!createDateRange[1] ||
row.createdAt <= formatDateKey(createDateRange[1]))
: createPreset
? (() => {
const preset = createPresetOptions.find(
(option) => option.value === createPreset,
)
return Boolean(
preset &&
row.createdAt >= preset.start &&
row.createdAt <= preset.end,
)
})()
: true
return (
matchesSegment &&
matchesOwner &&
matchesLastActivity &&
matchesLeadStatus &&
matchesCreateDate
)
})
const normalizedSearch = searchText.trim().toLowerCase()
const searchedRows = normalizedSearch
? filteredRows.filter((row) =>
[row.name, row.email, row.phone].some((value) =>
value?.toLowerCase().includes(normalizedSearch),
),
)
: filteredRows
const sortedRows = [...searchedRows].sort((left, right) => {
if (!sortKey || !sortOrder) {
return 0
}
const leftValue = left[sortKey] || ''
const rightValue = right[sortKey] || ''
const comparison = String(leftValue).localeCompare(String(rightValue))
return sortOrder === 'asc' ? comparison : -comparison
})
const pageStart = (pageIndex - 1) * pageSize
return {
rows: sortedRows.slice(pageStart, pageStart + pageSize),
total: sortedRows.length,
}
}, [
activeSegment,
contactRowsData,
createDateRange,
createPreset,
lastActivityFilter,
leadStatusFilter,
ownerFilter,
pageIndex,
pageSize,
searchText,
sortKey,
sortOrder,
])
const handleSort = (sort: {
sortKey: string | number
sortOrder: 'asc' | 'desc' | ''
}) => {
if (
typeof sort.sortKey !== 'string' ||
!['name', 'email', 'company', 'leadStatus'].includes(
sort.sortKey,
)
) {
return
}
setSortKey(sort.sortOrder ? (sort.sortKey as SortKey) : null)
setSortOrder(sort.sortOrder)
setPageIndex(1)
setSelectedRowIds([])
}
const handleRowSelect = (checked: boolean, row: ContactRow) => {
setSelectedRowIds((current) =>
checked
? Array.from(new Set([...current, row.id]))
: current.filter((id) => id !== row.id),
)
}
const handleAllRowSelect = (
checked: boolean,
rows: Row<ContactRow>[],
) => {
const pageRowIds = rows.map((row) => row.original.id)
setSelectedRowIds((current) =>
checked
? Array.from(new Set([...current, ...pageRowIds]))
: current.filter((id) => !pageRowIds.includes(id)),
)
}
const handleSegmentChange = (value: string) => {
if (contactViewTabs.some((tab) => tab.value === value)) {
setActiveSegment(value as ContactView)
}
}
const activeTabId =
contactViewTabs.find((tab) => tab.value === activeSegment)?.id ||
contactViewTabs[0].id
const resetFilters = () => {
setSearchText('')
setOwnerFilter([])
setCreatePreset(null)
setCreateDateRange([null, null])
setLastActivityFilter([])
setLeadStatusFilter([])
setLeadSearch('')
setOpenPopover(null)
}
const toggleFilterTool = (filterTool: FilterTool) => {
setVisibleFilterTools((current) =>
current.includes(filterTool)
? current.filter((item) => item !== filterTool)
: [...current, filterTool],
)
setOpenPopover(null)
}
const archiveSelectedContacts = () => {
const selectedIds = new Set(selectedRowIds)
dataTableRef.current?.resetSelected()
setContactRowsData((current) =>
current.filter((row) => !selectedIds.has(row.id)),
)
setSelectedRowIds([])
setPageIndex(1)
}
const filteredLeadStatusOptions = leadStatusOptions.filter((option) =>
option.label.toLowerCase().includes(leadSearch.toLowerCase()),
)
const selectedCreateLabel = createDateRange[0]
? createDateRange[1]
? `${formatDateLabel(formatDateKey(createDateRange[0]))} – ${formatDateLabel(formatDateKey(createDateRange[1]))}`
: `${formatDateLabel(formatDateKey(createDateRange[0]))} – Select end`
: createPresetOptions.find((option) => option.value === createPreset)?.label
return (
<section
aria-labelledby="data-grid-contact-filter-bar-title"
className="w-full"
>
<h4
id="data-grid-contact-filter-bar-title"
className="px-4 pt-2 font-semibold"
>
Contact
</h4>
<div className="pb-4">
<Tabs
value={activeSegment}
onChange={handleSegmentChange}
variant="underline"
className="flex flex-wrap items-end gap-4 border-b px-4 pt-2"
>
<Tabs.TabList
aria-label="Contact segments"
className="min-w-0 flex-1 border-b-0"
>
{contactViewTabs.map((tab) => (
<Tabs.TabNav
key={tab.value}
id={tab.id}
icon={tab.icon}
value={tab.value}
className="whitespace-nowrap"
>
{tab.label}
</Tabs.TabNav>
))}
</Tabs.TabList>
</Tabs>
<div
aria-labelledby={activeTabId}
className="min-w-0"
role="tabpanel"
tabIndex={0}
>
<div className="border-b p-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{visibleFilterTools.includes('owner') && (
<PopoverFilter
data={ownerOptions}
placement="bottom-start"
value={ownerFilter}
onChange={(items) =>
setOwnerFilter(
items.map((item) => item.value),
)
}
renderTrigger={
<Button
active={ownerFilter.length > 0}
icon={<PiCaretDown />}
iconAlignment="end"
>
{getSelectionLabel(
'Contact owner',
ownerFilter,
ownerOptions,
)}
</Button>
}
/>
)}
{visibleFilterTools.includes('created-date') && (
<Popover
open={openPopover === 'create'}
onOpenChange={(open) =>
setOpenPopover(open ? 'create' : null)
}
placement="bottom-start"
className="p-0"
renderTrigger={
<Button
active={Boolean(selectedCreateLabel)}
icon={<PiCaretDown />}
iconAlignment="end"
>
{selectedCreateLabel || 'Created date'}
</Button>
}
>
<div className="flex flex-col sm:flex-row">
<div className="flex w-full flex-col gap-2 border-b pb-4 sm:w-40 sm:border-b-0 sm:border-r sm:py-4 sm:px-2">
{createPresetOptions.map((option) => (
<Button
active={createPreset === option.value}
className="justify-start"
key={option.value}
onClick={() => {
setCreatePreset(option.value)
setCreateDateRange([null, null])
}}
variant="ghost"
>
{option.label}
</Button>
))}
</div>
<div className="flex min-w-0 flex-col p-4">
<Calendar.Range
value={createDateRange}
defaultMonth={currentDate}
onChange={(range) => {
setCreateDateRange(range)
setCreatePreset(null)
}}
/>
</div>
</div>
</Popover>
)}
{visibleFilterTools.includes('last-activity') && (
<PopoverFilter
data={lastActivityOptions}
placement="bottom-start"
title="Last activity date"
value={lastActivityFilter}
onChange={(items) =>
setLastActivityFilter(
items.map((item) => item.value),
)
}
renderTrigger={
<Button
active={lastActivityFilter.length > 0}
icon={<PiCaretDown />}
iconAlignment="end"
>
{getSelectionLabel(
'Last activity date',
lastActivityFilter,
lastActivityOptions,
)}
</Button>
}
/>
)}
{visibleFilterTools.includes('lead-status') && (
<Popover
open={openPopover === 'lead'}
onOpenChange={(open) =>
setOpenPopover(open ? 'lead' : null)
}
placement="bottom-start"
className="p-0"
renderTrigger={
<Button
active={leadStatusFilter.length > 0}
icon={<PiCaretDown />}
iconAlignment="end"
>
{getSelectionLabel(
'Lead status',
leadStatusFilter,
leadStatusOptions,
)}
</Button>
}
>
<div className="w-64 p-2">
<Input
aria-label="Search lead statuses"
className="mb-2 w-full"
onChange={(event) =>
setLeadSearch(event.target.value)
}
placeholder="Search status"
prefix={
<PiMagnifyingGlass className="text-muted-foreground" />
}
value={leadSearch}
/>
<Checkbox.Group
className="w-full gap-0"
onChange={(next) =>
setLeadStatusFilter(next as LeadStatus[])
}
value={leadStatusFilter}
vertical
>
{filteredLeadStatusOptions.map((option) => (
<Checkbox
className="rounded-control-sm p-2 hover:bg-accent"
key={option.value}
value={option.value}
>
<Tag
className={
paletteClassMap[option.tone].tag
}
>
{option.label}
</Tag>
</Checkbox>
))}
</Checkbox.Group>
</div>
</Popover>
)}
<Dropdown
renderTitle={
<Button
aria-label="Configure visible contact filters"
icon={<PiPlus />}
shape="circle"
variant="ghost"
/>
}
>
{filterToolOptions.map((option) => (
<Dropdown.Item
className="w-full p-0"
closeOnClick={false}
eventKey={`filter-tool-${option.value}`}
key={option.value}
variant="custom"
>
<Checkbox
checked={visibleFilterTools.includes(
option.value,
)}
className="w-full rounded-control-sm p-2 hover:bg-accent"
onChange={() =>
toggleFilterTool(option.value)
}
>
{option.label}
</Checkbox>
</Dropdown.Item>
))}
</Dropdown>
</div>
<div className="flex items-center gap-2">
<Tooltip title="Reset">
<Button
aria-label="Reset filters"
icon={<PiArrowUUpLeft />}
onClick={resetFilters}
shape="circle"
variant="ghost"
/>
</Tooltip>
<Tooltip title={copied ? 'Copied' : 'Copy'}>
<Button
aria-label="Copy view"
icon={<PiCopy />}
onClick={() => setCopied(true)}
shape="circle"
variant="ghost"
/>
</Tooltip>
</div>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-4 border-b p-4">
<Input
aria-label="Search contacts by name, email, or phone"
className="w-full sm:w-96"
onChange={(event) => setSearchText(event.target.value)}
placeholder="Find a contact"
prefix={<PiMagnifyingGlass className="text-muted-foreground" />}
value={searchText}
/>
<div className="flex flex-wrap items-center gap-2">
<Button icon={<PiExport />}>Export</Button>
</div>
</div>
<DataTable
ref={(
instance: DataTableResetHandle | HTMLTableElement | null,
) => {
if (instance && 'resetSelected' in instance) {
dataTableRef.current = instance
}
}}
columns={columns}
data={visibleData.rows}
indeterminateCheckboxChecked={(rows) =>
rows.length > 0 &&
rows.every((row) => selectedRowIds.includes(row.original.id))
}
onAllRowSelect={handleAllRowSelect}
onPageSizeChange={(nextPageSize) => {
setPageSize(nextPageSize)
setPageIndex(1)
}}
onPaginationChange={(nextPage) => {
setPageIndex(nextPage)
setSelectedRowIds([])
}}
onRowSelect={handleRowSelect}
onSort={handleSort}
pageSizes={pageSizes}
pagingData={{
total: visibleData.total,
pageIndex,
pageSize,
}}
selectable
checkboxChecked={(row) => selectedRowIds.includes(row.id)}
compact
hoverable
/>
<ActionBar open={selectedRowIds.length > 0} width={560}>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<span className="font-medium">
<span className="font-semibold text-foreground">
{selectedRowIds.length}{' '}
{selectedRowIds.length === 1
? 'contact'
: 'contacts'}
</span>{' '}
selected
</span>
<Button
icon={<PiArchive />}
onClick={archiveSelectedContacts}
destructive
>
Archive
</Button>
</div>
</ActionBar>
</div>
</div>
</section>
)
}
Data Grid 04
Preview
npx nateui@latest add DataGridProductPinnedColumnsDark
import { useMemo, useRef, useState } from 'react'
import {
PiArchive,
PiFunnel,
PiMagnifyingGlass,
PiPencilSimple,
PiPlus,
PiPushPin,
PiTrash,
} from 'react-icons/pi'
import Avatar from '@/components/ui/Avatar'
import ActionBar from '@/components/ui/ActionBar'
import Button from '@/components/ui/Button'
import Card from '@/components/ui/Card'
import Checkbox from '@/components/ui/Checkbox'
import Container from '@/components/composites/Container'
import Input from '@/components/ui/Input'
import Popover from '@/components/ui/Popover'
import Progress from '@/components/ui/Progress'
import Segment from '@/components/ui/Segment'
import Slider from '@/components/ui/Slider'
import Tag from '@/components/ui/Tag'
import DataTable from '@/components/composites/DataTable'
import Historgram from '@/components/composites/Historgram'
import type {
ColumnDef,
DataTableResetHandle,
OnSortParam,
Row,
} from '@/components/composites/DataTable'
import type { ChangeEvent } from 'react'
const assetBase = 'https://statics.nateui.com/img'
const CATEGORIES = [
'Outdoor Gear',
'Home Office',
'Kitchen',
'Fitness',
'Audio',
'Lighting',
] as const
type Category = (typeof CATEGORIES)[number]
const STOCK_LEVELS = ['High', 'Medium', 'Low', 'Out of Stock'] as const
type StockLevel = (typeof STOCK_LEVELS)[number]
type Product = {
product: string
sku: string
price: number
category: Category
stock: number
image: number
}
type SortableKey = 'product' | 'sku' | 'price' | 'category' | 'stock'
type PinState = 'start' | 'end' | 'none'
type PinnableColumnId = 'product' | 'sku' | 'price' | 'category' | 'stock' | 'actions'
const PRODUCTS: Product[] = [
{ product: 'Trail Runner Backpack', sku: 'OG-1001', price: 89, category: 'Outdoor Gear', stock: 132, image: 1 },
{ product: 'Alpine Camp Stove', sku: 'OG-1002', price: 64.5, category: 'Outdoor Gear', stock: 18, image: 2 },
{ product: 'Rip-Stop Tent 2P', sku: 'OG-1003', price: 214, category: 'Outdoor Gear', stock: 0, image: 3 },
{ product: 'Insulated Water Flask', sku: 'OG-1004', price: 28, category: 'Outdoor Gear', stock: 76, image: 4 },
{ product: 'Adjustable Laptop Stand', sku: 'HO-2001', price: 52, category: 'Home Office', stock: 54, image: 5 },
{ product: 'Ergonomic Desk Chair', sku: 'HO-2002', price: 339, category: 'Home Office', stock: 12, image: 6 },
{ product: 'Dual Monitor Arm', sku: 'HO-2003', price: 78, category: 'Home Office', stock: 0, image: 7 },
{ product: 'Desk Lamp USB-C', sku: 'HO-2004', price: 36, category: 'Home Office', stock: 98, image: 8 },
{ product: 'Ceramic Knife Set', sku: 'KT-3001', price: 69, category: 'Kitchen', stock: 41, image: 9 },
{ product: 'Stainless Pour-Over Kettle', sku: 'KT-3002', price: 44, category: 'Kitchen', stock: 8, image: 10 },
{ product: 'Compact Air Fryer', sku: 'KT-3003', price: 129, category: 'Kitchen', stock: 63, image: 11 },
{ product: 'Bamboo Cutting Board', sku: 'KT-3004', price: 22, category: 'Kitchen', stock: 150, image: 12 },
{ product: 'Adjustable Dumbbell Pair', sku: 'FT-4001', price: 189, category: 'Fitness', stock: 27, image: 13 },
{ product: 'Foam Roller Pro', sku: 'FT-4002', price: 24, category: 'Fitness', stock: 0, image: 14 },
{ product: 'Resistance Band Kit', sku: 'FT-4003', price: 19, category: 'Fitness', stock: 112, image: 15 },
{ product: 'Yoga Mat Extra Thick', sku: 'FT-4004', price: 34, category: 'Fitness', stock: 47, image: 16 },
{ product: 'Wireless Earbuds Lite', sku: 'AU-5001', price: 58, category: 'Audio', stock: 84, image: 17 },
{ product: 'Studio Monitor Speaker', sku: 'AU-5002', price: 149, category: 'Audio', stock: 6, image: 18 },
{ product: 'Noise-Cancelling Headset', sku: 'AU-5003', price: 175, category: 'Audio', stock: 33, image: 19 },
{ product: 'USB Condenser Mic', sku: 'AU-5004', price: 99, category: 'Audio', stock: 0, image: 20 },
{ product: 'Smart Desk Lamp', sku: 'LT-6001', price: 46, category: 'Lighting', stock: 71, image: 7 },
{ product: 'LED Panel Light', sku: 'LT-6002', price: 58, category: 'Lighting', stock: 15, image: 14 },
{ product: 'Motion Sensor Nightlight', sku: 'LT-6003', price: 14, category: 'Lighting', stock: 140, image: 19 },
{ product: 'Dimmable Floor Lamp', sku: 'LT-6004', price: 88, category: 'Lighting', stock: 3, image: 6 },
]
const STOCK_LEVEL_STROKE: Record<StockLevel, string> = {
High: 'bg-success',
Medium: 'bg-info',
Low: 'bg-warning',
'Out of Stock': 'bg-destructive',
}
const STOCK_REFERENCE_MAX = 150
function getStockLevel(units: number): StockLevel {
if (units <= 0) return 'Out of Stock'
if (units <= 20) return 'Low'
if (units <= 70) return 'Medium'
return 'High'
}
function getStockPercent(units: number): number {
return Math.max(0, Math.min(100, Math.round((units / STOCK_REFERENCE_MAX) * 100)))
}
function formatPrice(value: number): string {
return value.toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
}
const PRICE_MIN = Math.min(...PRODUCTS.map((product) => product.price))
const PRICE_MAX = Math.max(...PRODUCTS.map((product) => product.price))
const PRICE_BIN_COUNT = 16
function buildPriceHistogram(prices: number[], min: number, max: number, binCount: number) {
const binSize = (max - min) / binCount
const counts = new Array(binCount).fill(0)
prices.forEach((price) => {
const index = Math.min(binCount - 1, Math.max(0, Math.floor((price - min) / binSize)))
counts[index] += 1
})
return counts.map((value, index) => ({
value,
label: `$${Math.round(min + index * binSize)}`,
}))
}
const PRICE_HISTOGRAM_DATA = buildPriceHistogram(
PRODUCTS.map((product) => product.price),
PRICE_MIN,
PRICE_MAX,
PRICE_BIN_COUNT,
)
const PINNABLE_COLUMNS: { id: PinnableColumnId; label: string }[] = [
{ id: 'product', label: 'Product' },
{ id: 'sku', label: 'SKU' },
{ id: 'price', label: 'Price' },
{ id: 'category', label: 'Category' },
{ id: 'stock', label: 'Stock' },
{ id: 'actions', label: 'Actions' },
]
const DEFAULT_PIN_CONFIG: Record<PinnableColumnId, PinState> = {
product: 'start',
sku: 'none',
price: 'none',
category: 'none',
stock: 'none',
actions: 'end',
}
const columns: ColumnDef<Product>[] = [
{
accessorKey: 'product',
header: 'Product',
size: 340,
cell: ({ row }) => (
<div className="flex min-w-0 items-center gap-3">
<Avatar
shape="round"
size="sm"
src={`${assetBase}/thumbs/products/product-${row.original.image}.jpg`}
alt=""
/>
<span className="truncate font-semibold text-foreground">
{row.original.product}
</span>
</div>
),
},
{
accessorKey: 'sku',
header: 'SKU',
size: 170,
cell: ({ row }) => (
<span>{row.original.sku}</span>
),
},
{
accessorKey: 'price',
header: 'Price',
size: 160,
cell: ({ row }) => (
<span className="tabular-nums font-medium text-foreground">
{formatPrice(row.original.price)}
</span>
),
},
{
accessorKey: 'category',
header: 'Category',
size: 200,
cell: ({ row }) => <Tag>{row.original.category}</Tag>,
},
{
accessorKey: 'stock',
header: 'Stock',
size: 280,
cell: ({ row }) => {
const level = getStockLevel(row.original.stock)
return (
<div className="flex min-w-0 flex-col gap-1.5">
<span>
<span className="font-medium text-foreground">
{row.original.stock} unit
</span>
<span className="text-muted-foreground"> · {level}</span>
</span>
<Progress
percent={getStockPercent(row.original.stock)}
size="sm"
showInfo={false}
strokeClass={STOCK_LEVEL_STROKE[level]}
/>
</div>
)
},
},
{
id: 'actions',
header: '',
size: 88,
enableSorting: false,
cell: ({ row }) => (
<div className="flex items-center justify-end gap-2">
<Button
size="sm"
variant="ghost"
icon={<PiPencilSimple />}
aria-label={`Edit ${row.original.product}`}
/>
<Button
size="sm"
variant="ghost"
destructive
icon={<PiTrash />}
aria-label={`Delete ${row.original.product}`}
/>
</div>
),
},
]
export default function DataGridProductPinnedColumns() {
const [productRows, setProductRows] = useState<Product[]>(PRODUCTS)
const [search, setSearch] = useState('')
const [priceRange, setPriceRange] = useState<[number, number]>([PRICE_MIN, PRICE_MAX])
const [selectedCategories, setSelectedCategories] = useState<Set<Category>>(new Set())
const [selectedStockLevels, setSelectedStockLevels] = useState<Set<StockLevel>>(new Set())
const [draftPriceRange, setDraftPriceRange] =
useState<[number, number]>([PRICE_MIN, PRICE_MAX])
const [draftCategories, setDraftCategories] = useState<Set<Category>>(new Set())
const [draftStockLevels, setDraftStockLevels] = useState<Set<StockLevel>>(new Set())
const [filterOpen, setFilterOpen] = useState(false)
const [pinConfig, setPinConfig] =
useState<Record<PinnableColumnId, PinState>>(DEFAULT_PIN_CONFIG)
const [sortKey, setSortKey] = useState('')
const [sortOrder, setSortOrder] = useState<'asc' | 'desc' | ''>('')
const [pageIndex, setPageIndex] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [selectedRows, setSelectedRows] = useState<Product[]>([])
const dataTableRef = useRef<DataTableResetHandle>(null)
const resetPageAndSelection = () => {
setPageIndex(1)
setSelectedRows([])
}
const filteredSorted = useMemo(() => {
const query = search.trim().toLowerCase()
let rows = productRows.filter((product) => {
if (product.price < priceRange[0] || product.price > priceRange[1]) {
return false
}
if (
selectedCategories.size > 0 &&
!selectedCategories.has(product.category)
) {
return false
}
if (
selectedStockLevels.size > 0 &&
!selectedStockLevels.has(getStockLevel(product.stock))
) {
return false
}
return true
})
if (query) {
rows = rows.filter(
(product) =>
product.product.toLowerCase().includes(query) ||
product.sku.toLowerCase().includes(query),
)
}
if (sortKey === 'price' || sortKey === 'stock') {
const key = sortKey as Extract<SortableKey, 'price' | 'stock'>
rows = [...rows].sort((a, b) => {
const diff = a[key] - b[key]
return sortOrder === 'desc' ? -diff : diff
})
} else if (sortKey === 'product' || sortKey === 'sku' || sortKey === 'category') {
const key = sortKey as Extract<SortableKey, 'product' | 'sku' | 'category'>
rows = [...rows].sort((a, b) => {
const diff = a[key].localeCompare(b[key])
return sortOrder === 'desc' ? -diff : diff
})
}
return rows
}, [
productRows,
search,
priceRange,
selectedCategories,
selectedStockLevels,
sortKey,
sortOrder,
])
const total = filteredSorted.length
const pageRows = useMemo(() => {
const start = (pageIndex - 1) * pageSize
return filteredSorted.slice(start, start + pageSize)
}, [filteredSorted, pageIndex, pageSize])
const columnPinning = useMemo(() => {
const start: string[] = []
const end: string[] = []
PINNABLE_COLUMNS.forEach(({ id }) => {
if (pinConfig[id] === 'start') start.push(id)
if (pinConfig[id] === 'end') end.push(id)
})
return { start, end }
}, [pinConfig])
const handleSearchChange = (e: ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value)
resetPageAndSelection()
}
const handleFilterOpenChange = (open: boolean) => {
if (open) {
setDraftPriceRange(priceRange)
setDraftCategories(new Set(selectedCategories))
setDraftStockLevels(new Set(selectedStockLevels))
}
setFilterOpen(open)
}
const handleDraftPriceRangeChange = (value: [number, number]) => {
setDraftPriceRange(value)
}
const handleDraftPriceMinChange = (e: ChangeEvent<HTMLInputElement>) => {
const next = Number(e.target.value)
if (Number.isNaN(next)) return
const clamped = Math.min(Math.max(next, PRICE_MIN), draftPriceRange[1])
setDraftPriceRange([clamped, draftPriceRange[1]])
}
const handleDraftPriceMaxChange = (e: ChangeEvent<HTMLInputElement>) => {
const next = Number(e.target.value)
if (Number.isNaN(next)) return
const clamped = Math.max(Math.min(next, PRICE_MAX), draftPriceRange[0])
setDraftPriceRange([draftPriceRange[0], clamped])
}
const toggleDraftCategory = (category: Category) => {
setDraftCategories((prev) => {
const next = new Set(prev)
if (next.has(category)) {
next.delete(category)
} else {
next.add(category)
}
return next
})
}
const toggleDraftStockLevel = (level: StockLevel) => {
setDraftStockLevels((prev) => {
const next = new Set(prev)
if (next.has(level)) {
next.delete(level)
} else {
next.add(level)
}
return next
})
}
const handleApplyFilters = () => {
setPriceRange(draftPriceRange)
setSelectedCategories(new Set(draftCategories))
setSelectedStockLevels(new Set(draftStockLevels))
resetPageAndSelection()
setFilterOpen(false)
}
const handleResetFilters = () => {
setDraftPriceRange([PRICE_MIN, PRICE_MAX])
setDraftCategories(new Set())
setDraftStockLevels(new Set())
setPriceRange([PRICE_MIN, PRICE_MAX])
setSelectedCategories(new Set())
setSelectedStockLevels(new Set())
resetPageAndSelection()
}
const handleSort = (sort: OnSortParam) => {
setSortKey(String(sort.sortKey))
setSortOrder(sort.sortOrder)
setPageIndex(1)
}
const handlePaginationChange = (page: number) => {
setPageIndex(page)
}
const handlePageSizeChange = (size: number) => {
setPageSize(size)
setPageIndex(1)
}
const archiveSelectedProducts = () => {
const selectedSkus = new Set(selectedRows.map((row) => row.sku))
dataTableRef.current?.resetSelected()
setProductRows((current) =>
current.filter((product) => !selectedSkus.has(product.sku)),
)
setSelectedRows([])
setPageIndex(1)
}
const isRowSelected = (row: Product) =>
selectedRows.some((item) => item.sku === row.sku)
const handleRowSelect = (checked: boolean, row: Product) => {
setSelectedRows((prev) =>
checked ? [...prev, row] : prev.filter((item) => item.sku !== row.sku),
)
}
const handleAllRowSelect = (checked: boolean, rows: Row<Product>[]) => {
setSelectedRows(checked ? rows.map((row) => row.original) : [])
}
const handlePinChange = (id: PinnableColumnId, value: PinState) => {
setPinConfig((prev) => ({ ...prev, [id]: value }))
}
return (
<section aria-labelledby="product-inventory-heading">
<Container>
<Card bodyClass="p-0">
<div className="flex flex-col gap-4 border-b p-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h4 id="product-inventory-heading">Product Inventory</h4>
<p className="text-sm text-muted-foreground">
Monitor stock levels and manage catalog items in one place
</p>
</div>
<Button variant="solid" icon={<PiPlus />}>
Add Product
</Button>
</div>
<div className="flex flex-col gap-2 border-b p-4 sm:flex-row sm:items-center sm:justify-between">
<Input
prefix={<PiMagnifyingGlass />}
placeholder="Search by name or SKU"
aria-label="Search by name or SKU"
className="w-full sm:max-w-xs"
value={search}
onChange={handleSearchChange}
/>
<div className="flex flex-wrap items-center gap-2">
<Popover
open={filterOpen}
onOpenChange={handleFilterOpenChange}
width={320}
renderTrigger={
<Button icon={<PiFunnel />}>
Filter
</Button>
}
>
<div className="flex flex-col gap-4">
<div>
<h6 className="mb-2">Price Range</h6>
<Historgram
data={PRICE_HISTOGRAM_DATA}
range={draftPriceRange}
min={PRICE_MIN}
max={PRICE_MAX}
/>
<div className="mx-1.5 mt-2">
<Slider.Range
min={PRICE_MIN}
max={PRICE_MAX}
value={draftPriceRange}
onChange={handleDraftPriceRangeChange}
/>
</div>
<div className="mt-4 flex items-center gap-2">
<Input
size="sm"
type="number"
prefix="$"
className="min-w-0 flex-1"
value={draftPriceRange[0]}
onChange={handleDraftPriceMinChange}
aria-label="Minimum price"
/>
<Input
size="sm"
type="number"
prefix="$"
className="min-w-0 flex-1"
value={draftPriceRange[1]}
onChange={handleDraftPriceMaxChange}
aria-label="Maximum price"
/>
</div>
</div>
<div>
<h6 className="mb-2">Categories</h6>
<div className="flex flex-col gap-2">
{CATEGORIES.map((category) => (
<Checkbox
key={category}
checked={draftCategories.has(category)}
onChange={() => toggleDraftCategory(category)}
>
{category}
</Checkbox>
))}
</div>
</div>
<div>
<h6 className="mb-2">Stock Level</h6>
<div className="flex flex-col gap-2">
{STOCK_LEVELS.map((level) => (
<Checkbox
key={level}
checked={draftStockLevels.has(level)}
onChange={() => toggleDraftStockLevel(level)}
>
{level}
</Checkbox>
))}
</div>
</div>
<div className="flex items-center justify-end gap-2 border-t pt-2">
<Button size="sm" variant="default" onClick={handleResetFilters}>
Reset
</Button>
<Button size="sm" variant="solid" onClick={handleApplyFilters}>
Apply
</Button>
</div>
</div>
</Popover>
<Popover
width={320}
renderTrigger={
<Button icon={<PiPushPin />}>
Pin columns
</Button>
}
>
<div className="flex flex-col divide-y divide-border">
{PINNABLE_COLUMNS.map(({ id, label }) => (
<div
key={id}
className="flex items-center justify-between gap-4 py-2 first:pt-0 last:pb-0"
>
<span className="text-sm font-medium text-foreground">
{label}
</span>
<Segment
size="sm"
role="group"
aria-label={`Pin ${label} column`}
value={pinConfig[id]}
onChange={(value) =>
handlePinChange(id, value as PinState)
}
>
<Segment.Item value="start">Start</Segment.Item>
<Segment.Item value="none">Off</Segment.Item>
<Segment.Item value="end">End</Segment.Item>
</Segment>
</div>
))}
</div>
</Popover>
</div>
</div>
<DataTable<Product>
ref={(
instance: DataTableResetHandle | HTMLTableElement | null,
) => {
if (instance && 'resetSelected' in instance) {
dataTableRef.current = instance
}
}}
selectable
columns={columns}
data={pageRows}
columnPinning={columnPinning}
pagingData={{ total, pageIndex, pageSize }}
onPaginationChange={handlePaginationChange}
onPageSizeChange={handlePageSizeChange}
onSort={handleSort}
onRowSelect={handleRowSelect}
onAllRowSelect={handleAllRowSelect}
checkboxChecked={isRowSelected}
indeterminateCheckboxChecked={(rows: Row<Product>[]) =>
rows.length > 0 && rows.every((row) => isRowSelected(row.original))
}
/>
<ActionBar open={selectedRows.length > 0} width={560}>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<span className="font-medium">
<span className="font-semibold text-foreground">
{selectedRows.length}{' '}
{selectedRows.length === 1
? 'product'
: 'products'}
</span>{' '}
selected
</span>
<Button
icon={<PiArchive />}
onClick={archiveSelectedProducts}
destructive
>
Archive
</Button>
</div>
</ActionBar>
</Card>
</Container>
</section>
)
}
Data Grid 05
Preview
npx nateui@latest add DataGridTicketListDark
import { useMemo, useRef, useState } from 'react'
import type { ColumnDef, DataTableResetHandle, OnSortParam, Row } from '@/components/composites/DataTable'
import DataTable from '@/components/composites/DataTable'
import ActionBar from '@/components/ui/ActionBar'
import Avatar from '@/components/ui/Avatar'
import Button from '@/components/ui/Button'
import Card from '@/components/ui/Card'
import Dropdown from '@/components/ui/Dropdown'
import Tag from '@/components/ui/Tag'
import {
PiArchive,
PiBug,
PiCalendarBlank,
PiCaretDown,
PiCheck,
PiCircleFill,
PiDotsThreeVerticalBold,
PiFlagFill,
PiFlag,
PiListChecks,
PiQuestion,
PiTag,
PiWarningFill,
PiWrench,
} from 'react-icons/pi'
type Priority = 'Urgent' | 'Medium' | 'Low'
type TicketType = 'Incident' | 'Question' | 'Request' | 'Task'
type Cadence = 'Daily' | 'Weekly' | 'Monthly'
type RaisedDateFilter = 'all' | 'today' | 'week' | 'month'
type SortKey = 'ticketId' | 'type'
const assetBase = 'https://statics.nateui.com/img'
const getAvatarSrc = (ticketId: string) => {
const ticketNumber = Number(ticketId.replace('#TC-', ''))
return `${assetBase}/avatars/thumb-${(ticketNumber % 25) + 1}.jpg`
}
type TicketRow = {
id: string
ticketId: string
subject: string
intent: string
type: TicketType
assignee: string
priority: Priority
raisedDaysAgo: number
}
const ticketRows: TicketRow[] = [
{
id: 'ticket-240',
ticketId: '#TC-240',
subject: 'Export pauses after selecting a long date range',
intent: 'Report export',
type: 'Incident',
assignee: 'Alex Morgan',
priority: 'Urgent',
raisedDaysAgo: 0,
},
{
id: 'ticket-239',
ticketId: '#TC-239',
subject: 'Which seats count toward the renewal total?',
intent: 'Renewal policy',
type: 'Question',
assignee: 'Priya Shah',
priority: 'Medium',
raisedDaysAgo: 1,
},
{
id: 'ticket-238',
ticketId: '#TC-238',
subject: 'Add an approval step for regional managers',
intent: 'Approval workflow',
type: 'Request',
assignee: 'Jamie Chen',
priority: 'Low',
raisedDaysAgo: 2,
},
{
id: 'ticket-237',
ticketId: '#TC-237',
subject: 'Activity totals do not match the detail view',
intent: 'Activity dashboard',
type: 'Incident',
assignee: 'Rowan Ellis',
priority: 'Urgent',
raisedDaysAgo: 3,
},
{
id: 'ticket-236',
ticketId: '#TC-236',
subject: 'Where is the workspace audit history stored?',
intent: 'Audit history',
type: 'Question',
assignee: 'Samira Cole',
priority: 'Medium',
raisedDaysAgo: 4,
},
{
id: 'ticket-235',
ticketId: '#TC-235',
subject: 'Set a default owner for new members',
intent: 'Workspace setup',
type: 'Task',
assignee: 'Theo Bennett',
priority: 'Low',
raisedDaysAgo: 5,
},
{
id: 'ticket-234',
ticketId: '#TC-234',
subject: 'Webhook deliveries are timing out',
intent: 'Webhook delivery',
type: 'Incident',
assignee: 'Noor Rahman',
priority: 'Urgent',
raisedDaysAgo: 0,
},
{
id: 'ticket-233',
ticketId: '#TC-233',
subject: 'Can billing contacts receive usage alerts?',
intent: 'Billing profile',
type: 'Question',
assignee: 'Elena Park',
priority: 'Medium',
raisedDaysAgo: 1,
},
{
id: 'ticket-232',
ticketId: '#TC-232',
subject: 'Create a read-only role for analytics',
intent: 'Access roles',
type: 'Request',
assignee: 'Miles Carter',
priority: 'Low',
raisedDaysAgo: 2,
},
{
id: 'ticket-231',
ticketId: '#TC-231',
subject: 'Import validation stops at the address field',
intent: 'Data import',
type: 'Incident',
assignee: 'Casey Nguyen',
priority: 'Urgent',
raisedDaysAgo: 3,
},
{
id: 'ticket-230',
ticketId: '#TC-230',
subject: 'Update the notification digest schedule',
intent: 'Notifications',
type: 'Task',
assignee: 'Jordan Brooks',
priority: 'Medium',
raisedDaysAgo: 4,
},
{
id: 'ticket-229',
ticketId: '#TC-229',
subject: 'Add a custom field to the profile form',
intent: 'Customer profile',
type: 'Request',
assignee: 'Taylor Reed',
priority: 'Low',
raisedDaysAgo: 5,
},
{
id: 'ticket-228',
ticketId: '#TC-228',
subject: 'Yesterday’s activity entries are missing',
intent: 'Activity feed',
type: 'Incident',
assignee: 'Morgan Diaz',
priority: 'Urgent',
raisedDaysAgo: 0,
},
{
id: 'ticket-227',
ticketId: '#TC-227',
subject: 'Share a saved view with another team',
intent: 'Saved views',
type: 'Question',
assignee: 'Avery King',
priority: 'Medium',
raisedDaysAgo: 1,
},
{
id: 'ticket-226',
ticketId: '#TC-226',
subject: 'Schedule a data retention reminder',
intent: 'Data retention',
type: 'Task',
assignee: 'Quinn Foster',
priority: 'Low',
raisedDaysAgo: 2,
},
{
id: 'ticket-225',
ticketId: '#TC-225',
subject: 'The export preview shows an empty page',
intent: 'Report export',
type: 'Incident',
assignee: 'Remy Laurent',
priority: 'Urgent',
raisedDaysAgo: 3,
},
{
id: 'ticket-224',
ticketId: '#TC-224',
subject: 'Confirm the grace period for paused seats',
intent: 'Renewal policy',
type: 'Question',
assignee: 'Blair Hayes',
priority: 'Medium',
raisedDaysAgo: 4,
},
{
id: 'ticket-223',
ticketId: '#TC-223',
subject: 'Add a reviewer notification to approvals',
intent: 'Approval workflow',
type: 'Request',
assignee: 'Devon Woods',
priority: 'Low',
raisedDaysAgo: 5,
},
{
id: 'ticket-222',
ticketId: '#TC-222',
subject: 'Dashboard refreshes with an outdated count',
intent: 'Activity dashboard',
type: 'Incident',
assignee: 'Kai Patel',
priority: 'Urgent',
raisedDaysAgo: 0,
},
{
id: 'ticket-221',
ticketId: '#TC-221',
subject: 'Which roles can view audit events?',
intent: 'Audit history',
type: 'Question',
assignee: 'Sloane Murphy',
priority: 'Medium',
raisedDaysAgo: 2,
},
{
id: 'ticket-220',
ticketId: '#TC-220',
subject: 'Webhook retries need a longer backoff',
intent: 'Webhook delivery',
type: 'Task',
assignee: 'Harper Lin',
priority: 'Low',
raisedDaysAgo: 8,
},
{
id: 'ticket-219',
ticketId: '#TC-219',
subject: 'Change the owner on an existing workspace',
intent: 'Workspace setup',
type: 'Request',
assignee: 'Luca Bennett',
priority: 'Medium',
raisedDaysAgo: 10,
},
{
id: 'ticket-218',
ticketId: '#TC-218',
subject: 'Billing digest is arriving twice',
intent: 'Billing profile',
type: 'Incident',
assignee: 'Reese Allen',
priority: 'Urgent',
raisedDaysAgo: 12,
},
{
id: 'ticket-217',
ticketId: '#TC-217',
subject: 'Request a new analytics permission set',
intent: 'Access roles',
type: 'Request',
assignee: 'Dana Ortiz',
priority: 'Low',
raisedDaysAgo: 14,
},
{
id: 'ticket-216',
ticketId: '#TC-216',
subject: 'Import preview skips the final contact',
intent: 'Data import',
type: 'Incident',
assignee: 'Chris Bell',
priority: 'Urgent',
raisedDaysAgo: 17,
},
{
id: 'ticket-215',
ticketId: '#TC-215',
subject: 'Can digest emails include a custom footer?',
intent: 'Notifications',
type: 'Question',
assignee: 'River Stone',
priority: 'Medium',
raisedDaysAgo: 21,
},
{
id: 'ticket-214',
ticketId: '#TC-214',
subject: 'Add a second address to a customer record',
intent: 'Customer profile',
type: 'Task',
assignee: 'Sydney Blake',
priority: 'Low',
raisedDaysAgo: 25,
},
{
id: 'ticket-213',
ticketId: '#TC-213',
subject: 'Activity feed filters reset after refresh',
intent: 'Activity feed',
type: 'Incident',
assignee: 'Jules Martin',
priority: 'Urgent',
raisedDaysAgo: 29,
},
{
id: 'ticket-212',
ticketId: '#TC-212',
subject: 'What is included in a saved view?',
intent: 'Saved views',
type: 'Question',
assignee: 'Finley Grant',
priority: 'Medium',
raisedDaysAgo: 35,
},
{
id: 'ticket-211',
ticketId: '#TC-211',
subject: 'Set a quarterly retention reminder',
intent: 'Data retention',
type: 'Task',
assignee: 'Cameron Avery',
priority: 'Low',
raisedDaysAgo: 42,
},
]
const dateRangeOptions = [3, 6, 9, 12]
const cadenceOptions: Cadence[] = ['Daily', 'Weekly', 'Monthly']
const priorityOptions: Array<Priority | 'All'> = [
'All',
'Urgent',
'Medium',
'Low',
]
const typeOptions: Array<TicketType | 'All'> = [
'All',
'Incident',
'Question',
'Request',
'Task',
]
const raisedDateOptions: Array<{ value: RaisedDateFilter; label: string }> = [
{ value: 'all', label: 'Any date' },
{ value: 'today', label: 'Today' },
{ value: 'week', label: 'This week' },
{ value: 'month', label: 'This month' },
]
const priorityConfig = {
Urgent: {
icon: PiWarningFill,
iconClass: 'text-destructive',
tagClass: 'bg-destructive-soft text-destructive border-0',
},
Medium: {
icon: PiFlagFill,
iconClass: 'text-warning',
tagClass: 'bg-warning-soft text-warning border-0',
},
Low: {
icon: PiCircleFill,
iconClass: 'text-success',
tagClass: 'bg-success-soft text-success border-0',
},
}
const ticketTypeConfig = {
Incident: { icon: PiBug },
Question: { icon: PiQuestion },
Request: { icon: PiWrench },
Task: { icon: PiListChecks },
}
const cadenceDays: Record<Cadence, number> = {
Daily: 1,
Weekly: 7,
Monthly: 30,
}
const cadenceUnit: Record<Cadence, string> = {
Daily: 'Day',
Weekly: 'Week',
Monthly: 'Month',
}
const getTypeIcon = (type: TicketType) => {
return ticketTypeConfig[type].icon
}
const renderFilterItem = (
label: string,
active: boolean,
Icon?: typeof PiFlagFill,
iconClass = 'text-muted-foreground',
) => (
<span className="flex w-full items-center justify-between gap-4">
<span className="flex min-w-0 items-center gap-2">
{Icon && (
<Icon aria-hidden="true" className={`shrink-0 text-base ${iconClass}`} />
)}
<span className="truncate">{label}</span>
</span>
{active && (
<PiCheck aria-hidden="true" className="shrink-0 text-base" />
)}
</span>
)
const createColumns = (
onArchive: (ticketId: string) => void,
): ColumnDef<TicketRow>[] => [
{
header: 'Ticket ID',
accessorKey: 'ticketId',
size: 190,
minSize: 160,
enableSorting: true,
cell: ({ row }) => {
const priority = priorityConfig[row.original.priority]
const PriorityIcon = priority.icon
return (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-semibold">{row.original.ticketId}</span>
<Tag
className={`gap-1 ${priority.tagClass}`}
>
<span aria-hidden="true" className="inline-flex">
<PriorityIcon className={priority.iconClass} />
</span>
{row.original.priority}
</Tag>
</div>
)
},
},
{
header: 'Subject',
accessorKey: 'subject',
size: 260,
minSize: 220,
enableSorting: false,
cell: ({ row }) => (
<span className="block min-w-0 truncate">{row.original.subject}</span>
),
},
{
header: 'Intent',
accessorKey: 'intent',
size: 160,
minSize: 140,
enableSorting: false,
cell: ({ row }) => <span className="truncate">{row.original.intent}</span>,
},
{
header: 'Type',
accessorKey: 'type',
size: 140,
minSize: 120,
enableSorting: true,
cell: ({ row }) => {
const TypeIcon = getTypeIcon(row.original.type)
return (
<span className="flex items-center gap-2 whitespace-nowrap">
<TypeIcon aria-hidden="true" className="text-lg text-muted-foreground" />
<span>{row.original.type}</span>
</span>
)
},
},
{
header: 'Assignees',
accessorKey: 'assignee',
size: 190,
minSize: 160,
enableSorting: false,
cell: ({ row }) => (
<span className="flex min-w-0 items-center gap-2">
<Avatar
alt={row.original.assignee}
size={24}
src={getAvatarSrc(row.original.ticketId)}
/>
<span className="min-w-0 truncate">{row.original.assignee}</span>
</span>
),
},
{
header: () => (
<div className="flex h-full items-center justify-end border-l">
<span className="sr-only">Actions</span>
</div>
),
id: 'actions',
size: 72,
minSize: 64,
maxSize: 72,
enableSorting: false,
cell: ({ row }) => (
<div className="flex justify-end border-l">
<Dropdown
placement="bottom-end"
onSelect={(eventKey) => {
if (eventKey === 'archive') {
onArchive(row.original.id)
}
}}
renderTitle={
<Button
aria-label={`Actions for ${row.original.ticketId}`}
icon={<PiDotsThreeVerticalBold />}
shape="circle"
size="sm"
variant="ghost"
/>
}
>
<Dropdown.Item eventKey="archive">Archive ticket</Dropdown.Item>
</Dropdown>
</div>
),
},
]
export default function DataGridTicketList() {
const [rows, setRows] = useState(ticketRows)
const [rangeCount, setRangeCount] = useState(6)
const [cadence, setCadence] = useState<Cadence>('Daily')
const [priorityFilter, setPriorityFilter] = useState<Priority | 'All'>('All')
const [typeFilter, setTypeFilter] = useState<TicketType | 'All'>('All')
const [raisedDateFilter, setRaisedDateFilter] =
useState<RaisedDateFilter>('all')
const [sortKey, setSortKey] = useState<SortKey | null>(null)
const [sortOrder, setSortOrder] = useState<'asc' | 'desc' | ''>('')
const [pageIndex, setPageIndex] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [selectedRowIds, setSelectedRowIds] = useState<string[]>([])
const dataTableRef = useRef<DataTableResetHandle>(null)
const resetSelection = () => {
dataTableRef.current?.resetSelected()
setSelectedRowIds([])
}
const resetPageAndSelection = () => {
setPageIndex(1)
resetSelection()
}
const visibleRows = useMemo(() => {
const maxDays = rangeCount * cadenceDays[cadence]
const filtered = rows.filter((row) => {
if (row.raisedDaysAgo >= maxDays) return false
if (priorityFilter !== 'All' && row.priority !== priorityFilter) {
return false
}
if (typeFilter !== 'All' && row.type !== typeFilter) return false
if (raisedDateFilter === 'today' && row.raisedDaysAgo !== 0) {
return false
}
if (raisedDateFilter === 'week' && row.raisedDaysAgo >= 7) {
return false
}
if (raisedDateFilter === 'month' && row.raisedDaysAgo >= 30) {
return false
}
return true
})
if (!sortKey || !sortOrder) return filtered
return [...filtered].sort((a, b) => {
const comparison = a[sortKey].localeCompare(b[sortKey])
return sortOrder === 'desc' ? -comparison : comparison
})
}, [cadence, priorityFilter, raisedDateFilter, rangeCount, rows, sortKey, sortOrder, typeFilter])
const pageRows = useMemo(() => {
const start = (pageIndex - 1) * pageSize
return visibleRows.slice(start, start + pageSize)
}, [pageIndex, pageSize, visibleRows])
const handleSort = ({ sortKey: nextKey, sortOrder: nextOrder }: OnSortParam) => {
if (nextKey !== 'ticketId' && nextKey !== 'type') return
setSortKey(nextOrder ? (nextKey as SortKey) : null)
setSortOrder(nextOrder)
resetPageAndSelection()
}
const handleRowSelect = (checked: boolean, row: TicketRow) => {
setSelectedRowIds((current) =>
checked
? Array.from(new Set([...current, row.id]))
: current.filter((id) => id !== row.id),
)
}
const handleAllRowSelect = (checked: boolean, selectedRows: Row<TicketRow>[]) => {
const pageRowIds = selectedRows.map((row) => row.original.id)
setSelectedRowIds((current) =>
checked
? Array.from(new Set([...current, ...pageRowIds]))
: current.filter((id) => !pageRowIds.includes(id)),
)
}
const archiveRows = (ids: string[]) => {
const archivedIds = new Set(ids)
dataTableRef.current?.resetSelected()
setRows((current) => current.filter((row) => !archivedIds.has(row.id)))
setSelectedRowIds([])
setPageIndex(1)
}
const handleArchiveSelected = () => archiveRows(selectedRowIds)
const columns = createColumns((ticketId) => archiveRows([ticketId]))
const rangeLabel = `Last ${rangeCount} ${cadenceUnit[cadence]}${rangeCount === 1 ? '' : 's'}`
const PriorityToolbarIcon =
priorityFilter === 'All' ? PiFlag : priorityConfig[priorityFilter].icon
const TypeToolbarIcon =
typeFilter === 'All' ? PiTag : ticketTypeConfig[typeFilter].icon
return (
<section
aria-labelledby="data-grid-ticket-list-title"
className="w-full space-y-4"
>
<h4 id="data-grid-ticket-list-title" className="font-semibold">
Tickets
</h4>
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-2">
<Dropdown
activeKey={String(rangeCount)}
onSelect={(eventKey) => {
setRangeCount(Number(eventKey))
resetPageAndSelection()
}}
renderTitle={
<Button
type="button"
icon={<PiCaretDown aria-hidden="true" />}
iconAlignment="end"
size="sm"
variant="default"
>
<span className="flex items-center gap-2">{rangeLabel}</span>
</Button>
}
>
{dateRangeOptions.map((value) => (
<Dropdown.Item
key={value}
active={value === rangeCount}
eventKey={String(value)}
>
{renderFilterItem(
`Last ${value} ${cadenceUnit[cadence]}${value === 1 ? '' : 's'}`,
value === rangeCount,
)}
</Dropdown.Item>
))}
</Dropdown>
<Dropdown
activeKey={cadence}
onSelect={(eventKey) => {
setCadence(eventKey as Cadence)
resetPageAndSelection()
}}
renderTitle={
<Button
type="button"
icon={<PiCaretDown aria-hidden="true" />}
iconAlignment="end"
size="sm"
variant="default"
>
<span className="flex items-center gap-2">{cadence}</span>
</Button>
}
>
{cadenceOptions.map((option) => (
<Dropdown.Item
key={option}
active={option === cadence}
eventKey={option}
>
{renderFilterItem(option, option === cadence)}
</Dropdown.Item>
))}
</Dropdown>
</div>
<div className="flex flex-wrap items-center gap-2 md:ml-auto">
<Dropdown
activeKey={priorityFilter}
onSelect={(eventKey) => {
setPriorityFilter(eventKey as Priority | 'All')
resetPageAndSelection()
}}
placement="bottom-end"
renderTitle={
<Button
type="button"
icon={<PiCaretDown aria-hidden="true" />}
iconAlignment="end"
size="sm"
variant="default"
>
<span className="flex items-center gap-2">
<PriorityToolbarIcon
aria-hidden="true"
className="text-base text-muted-foreground"
/>
Priority
</span>
</Button>
}
>
{priorityOptions.map((option) => (
<Dropdown.Item
key={option}
active={option === priorityFilter}
eventKey={option}
>
{renderFilterItem(
option === 'All' ? 'All priorities' : option,
option === priorityFilter,
option === 'All'
? PiFlag
: priorityConfig[option].icon,
option === 'All'
? ''
: priorityConfig[option].iconClass,
)}
</Dropdown.Item>
))}
</Dropdown>
<Dropdown
activeKey={typeFilter}
onSelect={(eventKey) => {
setTypeFilter(eventKey as TicketType | 'All')
resetPageAndSelection()
}}
placement="bottom-end"
renderTitle={
<Button
type="button"
icon={<PiCaretDown aria-hidden="true" />}
iconAlignment="end"
size="sm"
variant="default"
>
<span className="flex items-center gap-2">
<TypeToolbarIcon
aria-hidden="true"
className="text-base text-muted-foreground"
/>
Type
</span>
</Button>
}
>
{typeOptions.map((option) => (
<Dropdown.Item
key={option}
active={option === typeFilter}
eventKey={option}
>
{renderFilterItem(
option === 'All' ? 'All types' : option,
option === typeFilter,
option === 'All'
? PiTag
: ticketTypeConfig[option].icon,
)}
</Dropdown.Item>
))}
</Dropdown>
<Dropdown
activeKey={raisedDateFilter}
onSelect={(eventKey) => {
setRaisedDateFilter(eventKey as RaisedDateFilter)
resetPageAndSelection()
}}
placement="bottom-end"
renderTitle={
<Button
type="button"
icon={<PiCaretDown aria-hidden="true" />}
iconAlignment="end"
size="sm"
variant="default"
>
<span className="flex items-center gap-2">
<PiCalendarBlank
aria-hidden="true"
className="text-base text-muted-foreground"
/>
Raised Date
</span>
</Button>
}
>
{raisedDateOptions.map((option) => (
<Dropdown.Item
key={option.value}
active={option.value === raisedDateFilter}
eventKey={option.value}
>
{renderFilterItem(option.label, option.value === raisedDateFilter)}
</Dropdown.Item>
))}
</Dropdown>
</div>
</div>
<Card bodyClass="min-w-0 px-0 pt-0">
<DataTable<TicketRow>
ref={(
instance: DataTableResetHandle | HTMLTableElement | null,
) => {
if (instance && 'resetSelected' in instance) {
dataTableRef.current = instance
}
}}
columns={columns}
columnResize={{ mode: 'onChange' }}
data={pageRows}
indeterminateCheckboxChecked={(tableRows) =>
tableRows.length > 0 &&
tableRows.every((row) => selectedRowIds.includes(row.original.id))
}
noData={pageRows.length === 0}
onAllRowSelect={handleAllRowSelect}
onPageSizeChange={(nextPageSize) => {
setPageSize(nextPageSize)
resetPageAndSelection()
}}
onPaginationChange={(nextPage) => {
setPageIndex(nextPage)
resetSelection()
}}
onRowSelect={handleRowSelect}
onSort={handleSort}
pageSizes={[10, 20, 50]}
pagingData={{
total: visibleRows.length,
pageIndex,
pageSize,
}}
selectable
checkboxChecked={(row) => selectedRowIds.includes(row.id)}
compact
hoverable
/>
<ActionBar open={selectedRowIds.length > 0} width={560}>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<span className="font-medium">
<span className="font-semibold text-foreground">
{selectedRowIds.length}{' '}
{selectedRowIds.length === 1 ? 'ticket' : 'tickets'}
</span>{' '}
selected
</span>
<Button
destructive
icon={<PiArchive />}
onClick={handleArchiveSelected}
>
Archive
</Button>
</div>
</ActionBar>
</Card>
</section>
)
}
Data Grid 06
Preview
npx nateui@latest add DataGridSubscribersDark
import { useMemo, useState } from 'react'
import {
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import type { CSSProperties, ReactNode } from 'react'
import AdvancedFilterBuilder, {
type FieldConfig,
type FilterTree,
type Rule,
type RuleGroup,
} from '@/components/composites/AdvancedFilterBuilder'
import DataTable, {
type ColumnDef,
type OnSortParam,
} from '@/components/composites/DataTable'
import DebounceInput from '@/components/composites/DebounceInput'
import Button from '@/components/ui/Button'
import CloseButton from '@/components/ui/CloseButton'
import Drawer from '@/components/ui/Drawer'
import Input from '@/components/ui/Input'
import Popover from '@/components/ui/Popover'
import Scroll from '@/components/ui/Scroll'
import Switcher from '@/components/ui/Switcher'
import Tag from '@/components/ui/Tag'
import classNames from '@/utils/classNames'
import formatCurrency from '@/utils/formatCurrency'
import {
PiAt,
PiCalendarBlank,
PiCircle,
PiCreditCard,
PiCurrencyDollar,
PiDeviceMobile,
PiDeviceTablet,
PiDownloadSimple,
PiDotsSixVertical,
PiGridNine,
PiHash,
PiListNumbers,
PiMagnifyingGlass,
PiMapPin,
PiMapTrifold,
PiMonitor,
PiMoneyWavy,
PiSlidersHorizontal,
PiUser,
PiX,
} from 'react-icons/pi'
const assetBase = 'https://statics.nateui.com/img'
type Plan = 'Basic' | 'Standard' | 'Pro'
type PaymentMethod = 'Credit Card' | 'PayPal' | 'Stripe' | 'Visa'
type Status = 'Paid' | 'Pending' | 'Failed' | 'Refunded'
type Device = 'Desktop' | 'Mobile' | 'Tablet'
type CountryCode =
| 'US'
| 'UK'
| 'CA'
| 'DE'
| 'FR'
| 'JP'
| 'AU'
| 'BR'
| 'IN'
| 'NL'
| 'SE'
| 'ES'
type SubscriberRow = {
id: string
plan: Plan
customer: string
email: string
amount: number
paymentMethod: PaymentMethod
status: Status
signupDate: string
lastActive: string
mrrRange: string
autoRenewal: boolean
featureUsed: string[]
device: Device
country: string
countryCode: CountryCode
}
const subscriberRows: SubscriberRow[] = [
{
id: 'SUB-1001',
plan: 'Basic',
customer: 'Mina Patel',
email: 'mina.patel@example.test',
amount: 24.5,
paymentMethod: 'Credit Card',
status: 'Paid',
signupDate: 'Mar 14, 2026',
lastActive: 'Today',
mrrRange: '$0-$50',
autoRenewal: true,
featureUsed: ['Dashboard View', 'Profile Update'],
device: 'Desktop',
country: 'United States',
countryCode: 'US',
},
{
id: 'SUB-1002',
plan: 'Standard',
customer: 'Jon Bell',
email: 'jon.bell@example.test',
amount: 649,
paymentMethod: 'PayPal',
status: 'Pending',
signupDate: 'Mar 10, 2026',
lastActive: '1 day ago',
mrrRange: '$500-$1000',
autoRenewal: true,
featureUsed: ['Report Generation', 'Data Export'],
device: 'Mobile',
country: 'United Kingdom',
countryCode: 'UK',
},
{
id: 'SUB-1003',
plan: 'Pro',
customer: 'Rina Sato',
email: 'rina.sato@example.test',
amount: 7200,
paymentMethod: 'Visa',
status: 'Failed',
signupDate: 'Mar 08, 2026',
lastActive: '2 days ago',
mrrRange: '$5000-$10000',
autoRenewal: false,
featureUsed: ['Analytics View', 'Custom Charts'],
device: 'Tablet',
country: 'Japan',
countryCode: 'JP',
},
{
id: 'SUB-1004',
plan: 'Basic',
customer: 'Owen Brooks',
email: 'owen.brooks@example.test',
amount: 88,
paymentMethod: 'Stripe',
status: 'Refunded',
signupDate: 'Mar 03, 2026',
lastActive: '4 days ago',
mrrRange: '$50-$100',
autoRenewal: true,
featureUsed: ['File Upload'],
device: 'Desktop',
country: 'Germany',
countryCode: 'DE',
},
{
id: 'SUB-1005',
plan: 'Standard',
customer: 'Clara Nguyen',
email: 'clara.nguyen@example.test',
amount: 1250,
paymentMethod: 'Credit Card',
status: 'Paid',
signupDate: 'Feb 27, 2026',
lastActive: '5 days ago',
mrrRange: '$1000-$2000',
autoRenewal: true,
featureUsed: ['User Management', 'Team Collaboration'],
device: 'Mobile',
country: 'France',
countryCode: 'FR',
},
{
id: 'SUB-1006',
plan: 'Pro',
customer: 'Elias Romero',
email: 'elias.romero@example.test',
amount: 3400,
paymentMethod: 'PayPal',
status: 'Pending',
signupDate: 'Feb 22, 2026',
lastActive: '1 week ago',
mrrRange: '$2000-$5000',
autoRenewal: true,
featureUsed: ['API Access', 'Data Import'],
device: 'Desktop',
country: 'Canada',
countryCode: 'CA',
},
{
id: 'SUB-1007',
plan: 'Basic',
customer: 'Nadia Foster',
email: 'nadia.foster@example.test',
amount: 42,
paymentMethod: 'Visa',
status: 'Failed',
signupDate: 'Feb 18, 2026',
lastActive: '1 week ago',
mrrRange: '$0-$50',
autoRenewal: false,
featureUsed: ['Notification Settings', 'Profile Update'],
device: 'Tablet',
country: 'Australia',
countryCode: 'AU',
},
{
id: 'SUB-1008',
plan: 'Standard',
customer: 'Marco Silva',
email: 'marco.silva@example.test',
amount: 780,
paymentMethod: 'Stripe',
status: 'Refunded',
signupDate: 'Feb 12, 2026',
lastActive: '2 weeks ago',
mrrRange: '$500-$1000',
autoRenewal: true,
featureUsed: ['Dashboard View', 'File Upload'],
device: 'Mobile',
country: 'Brazil',
countryCode: 'BR',
},
{
id: 'SUB-1009',
plan: 'Pro',
customer: 'Isha Mehta',
email: 'isha.mehta@example.test',
amount: 9800,
paymentMethod: 'Credit Card',
status: 'Paid',
signupDate: 'Feb 05, 2026',
lastActive: '2 weeks ago',
mrrRange: '$5000-$10000',
autoRenewal: true,
featureUsed: ['Custom Charts', 'Analytics View', 'API Access'],
device: 'Desktop',
country: 'India',
countryCode: 'IN',
},
{
id: 'SUB-1010',
plan: 'Basic',
customer: 'Nora Jensen',
email: 'nora.jensen@example.test',
amount: 110,
paymentMethod: 'PayPal',
status: 'Pending',
signupDate: 'Jan 29, 2026',
lastActive: '3 weeks ago',
mrrRange: '$100-$200',
autoRenewal: true,
featureUsed: ['Report Generation'],
device: 'Mobile',
country: 'Netherlands',
countryCode: 'NL',
},
{
id: 'SUB-1011',
plan: 'Standard',
customer: 'Felix Anders',
email: 'felix.anders@example.test',
amount: 360,
paymentMethod: 'Credit Card',
status: 'Paid',
signupDate: 'Jan 22, 2026',
lastActive: '3 weeks ago',
mrrRange: '$200-$500',
autoRenewal: true,
featureUsed: ['Data Export', 'User Management'],
device: 'Desktop',
country: 'Sweden',
countryCode: 'SE',
},
{
id: 'SUB-1012',
plan: 'Pro',
customer: 'Leah Carter',
email: 'leah.carter@example.test',
amount: 15500,
paymentMethod: 'Stripe',
status: 'Refunded',
signupDate: 'Jan 15, 2026',
lastActive: '1 month ago',
mrrRange: '$10000+',
autoRenewal: true,
featureUsed: ['Team Collaboration', 'Data Import'],
device: 'Tablet',
country: 'Spain',
countryCode: 'ES',
},
{
id: 'SUB-1013',
plan: 'Basic',
customer: 'Arun Das',
email: 'arun.das@example.test',
amount: 61,
paymentMethod: 'Visa',
status: 'Paid',
signupDate: 'Jan 09, 2026',
lastActive: '1 month ago',
mrrRange: '$50-$100',
autoRenewal: true,
featureUsed: ['Profile Update', 'Notification Settings'],
device: 'Desktop',
country: 'United States',
countryCode: 'US',
},
{
id: 'SUB-1014',
plan: 'Standard',
customer: 'Mara Collins',
email: 'mara.collins@example.test',
amount: 520,
paymentMethod: 'PayPal',
status: 'Pending',
signupDate: 'Dec 28, 2025',
lastActive: '1 month ago',
mrrRange: '$500-$1000',
autoRenewal: true,
featureUsed: ['Dashboard View', 'Report Generation'],
device: 'Mobile',
country: 'United Kingdom',
countryCode: 'UK',
},
{
id: 'SUB-1015',
plan: 'Pro',
customer: 'Theo Wallace',
email: 'theo.wallace@example.test',
amount: 2300,
paymentMethod: 'Credit Card',
status: 'Failed',
signupDate: 'Dec 18, 2025',
lastActive: '2 months ago',
mrrRange: '$2000-$5000',
autoRenewal: false,
featureUsed: ['Custom Charts', 'File Upload'],
device: 'Tablet',
country: 'Canada',
countryCode: 'CA',
},
{
id: 'SUB-1016',
plan: 'Basic',
customer: 'Sofia Marin',
email: 'sofia.marin@example.test',
amount: 18,
paymentMethod: 'Stripe',
status: 'Refunded',
signupDate: 'Dec 11, 2025',
lastActive: '2 months ago',
mrrRange: '$0-$50',
autoRenewal: true,
featureUsed: ['File Upload', 'Profile Update'],
device: 'Mobile',
country: 'France',
countryCode: 'FR',
},
{
id: 'SUB-1017',
plan: 'Standard',
customer: 'Darius King',
email: 'darius.king@example.test',
amount: 890,
paymentMethod: 'Visa',
status: 'Paid',
signupDate: 'Dec 04, 2025',
lastActive: '2 months ago',
mrrRange: '$500-$1000',
autoRenewal: true,
featureUsed: ['Analytics View', 'Data Export'],
device: 'Desktop',
country: 'Germany',
countryCode: 'DE',
},
{
id: 'SUB-1018',
plan: 'Pro',
customer: 'Hana Ito',
email: 'hana.ito@example.test',
amount: 5100,
paymentMethod: 'PayPal',
status: 'Pending',
signupDate: 'Nov 25, 2025',
lastActive: '3 months ago',
mrrRange: '$5000-$10000',
autoRenewal: true,
featureUsed: ['API Access', 'Team Collaboration'],
device: 'Desktop',
country: 'Japan',
countryCode: 'JP',
},
{
id: 'SUB-1019',
plan: 'Basic',
customer: 'Brennan Lee',
email: 'brennan.lee@example.test',
amount: 145,
paymentMethod: 'Credit Card',
status: 'Paid',
signupDate: 'Nov 17, 2025',
lastActive: '3 months ago',
mrrRange: '$100-$200',
autoRenewal: true,
featureUsed: ['User Management'],
device: 'Tablet',
country: 'Australia',
countryCode: 'AU',
},
{
id: 'SUB-1020',
plan: 'Standard',
customer: 'Lena Duarte',
email: 'lena.duarte@example.test',
amount: 1020,
paymentMethod: 'Stripe',
status: 'Refunded',
signupDate: 'Nov 09, 2025',
lastActive: '4 months ago',
mrrRange: '$1000-$2000',
autoRenewal: true,
featureUsed: ['Notification Settings', 'Data Import'],
device: 'Mobile',
country: 'Brazil',
countryCode: 'BR',
},
{
id: 'SUB-1021',
plan: 'Pro',
customer: 'Caleb Stone',
email: 'caleb.stone@example.test',
amount: 11200,
paymentMethod: 'Visa',
status: 'Failed',
signupDate: 'Oct 30, 2025',
lastActive: '4 months ago',
mrrRange: '$10000+',
autoRenewal: false,
featureUsed: ['Custom Charts', 'Report Generation'],
device: 'Desktop',
country: 'India',
countryCode: 'IN',
},
{
id: 'SUB-1022',
plan: 'Basic',
customer: 'Uma Shah',
email: 'uma.shah@example.test',
amount: 74,
paymentMethod: 'PayPal',
status: 'Paid',
signupDate: 'Oct 21, 2025',
lastActive: '5 months ago',
mrrRange: '$50-$100',
autoRenewal: true,
featureUsed: ['Dashboard View', 'File Upload'],
device: 'Mobile',
country: 'Netherlands',
countryCode: 'NL',
},
{
id: 'SUB-1023',
plan: 'Standard',
customer: 'Pavel Novak',
email: 'pavel.novak@example.test',
amount: 410,
paymentMethod: 'Credit Card',
status: 'Pending',
signupDate: 'Oct 13, 2025',
lastActive: '5 months ago',
mrrRange: '$200-$500',
autoRenewal: true,
featureUsed: ['Profile Update', 'Team Collaboration'],
device: 'Tablet',
country: 'Sweden',
countryCode: 'SE',
},
{
id: 'SUB-1024',
plan: 'Pro',
customer: 'Maya Ortiz',
email: 'maya.ortiz@example.test',
amount: 4300,
paymentMethod: 'Stripe',
status: 'Refunded',
signupDate: 'Oct 04, 2025',
lastActive: '6 months ago',
mrrRange: '$2000-$5000',
autoRenewal: true,
featureUsed: ['API Access', 'Data Export'],
device: 'Desktop',
country: 'Spain',
countryCode: 'ES',
},
{
id: 'SUB-1025',
plan: 'Basic',
customer: 'Niko Fraser',
email: 'niko.fraser@example.test',
amount: 35,
paymentMethod: 'Visa',
status: 'Failed',
signupDate: 'Sep 25, 2025',
lastActive: '6 months ago',
mrrRange: '$0-$50',
autoRenewal: false,
featureUsed: ['Notification Settings'],
device: 'Mobile',
country: 'United States',
countryCode: 'US',
},
{
id: 'SUB-1026',
plan: 'Standard',
customer: 'Ada Laurent',
email: 'ada.laurent@example.test',
amount: 1500,
paymentMethod: 'PayPal',
status: 'Paid',
signupDate: 'Sep 16, 2025',
lastActive: '7 months ago',
mrrRange: '$1000-$2000',
autoRenewal: true,
featureUsed: ['Analytics View', 'Custom Charts'],
device: 'Desktop',
country: 'United Kingdom',
countryCode: 'UK',
},
{
id: 'SUB-1027',
plan: 'Pro',
customer: 'Ravi Menon',
email: 'ravi.menon@example.test',
amount: 6700,
paymentMethod: 'Credit Card',
status: 'Pending',
signupDate: 'Sep 08, 2025',
lastActive: '7 months ago',
mrrRange: '$5000-$10000',
autoRenewal: true,
featureUsed: ['User Management', 'Data Import'],
device: 'Tablet',
country: 'Canada',
countryCode: 'CA',
},
{
id: 'SUB-1028',
plan: 'Basic',
customer: 'Tessa Green',
email: 'tessa.green@example.test',
amount: 96,
paymentMethod: 'Stripe',
status: 'Refunded',
signupDate: 'Aug 30, 2025',
lastActive: '8 months ago',
mrrRange: '$50-$100',
autoRenewal: true,
featureUsed: ['File Upload', 'Profile Update'],
device: 'Desktop',
country: 'France',
countryCode: 'FR',
},
{
id: 'SUB-1029',
plan: 'Standard',
customer: 'Yuki Mori',
email: 'yuki.mori@example.test',
amount: 245,
paymentMethod: 'Visa',
status: 'Paid',
signupDate: 'Aug 21, 2025',
lastActive: '8 months ago',
mrrRange: '$200-$500',
autoRenewal: true,
featureUsed: ['Report Generation', 'Notification Settings'],
device: 'Mobile',
country: 'Japan',
countryCode: 'JP',
},
{
id: 'SUB-1030',
plan: 'Pro',
customer: 'Drew Morgan',
email: 'drew.morgan@example.test',
amount: 2140,
paymentMethod: 'PayPal',
status: 'Failed',
signupDate: 'Aug 12, 2025',
lastActive: '9 months ago',
mrrRange: '$2000-$5000',
autoRenewal: false,
featureUsed: ['Dashboard View', 'API Access'],
device: 'Desktop',
country: 'Australia',
countryCode: 'AU',
},
{
id: 'SUB-1031',
plan: 'Basic',
customer: 'Evan Kim',
email: 'evan.kim@example.test',
amount: 125,
paymentMethod: 'Credit Card',
status: 'Paid',
signupDate: 'Aug 03, 2025',
lastActive: '9 months ago',
mrrRange: '$100-$200',
autoRenewal: true,
featureUsed: ['Team Collaboration'],
device: 'Tablet',
country: 'Brazil',
countryCode: 'BR',
},
{
id: 'SUB-1032',
plan: 'Standard',
customer: 'Kira Owens',
email: 'kira.owens@example.test',
amount: 960,
paymentMethod: 'Stripe',
status: 'Pending',
signupDate: 'Jul 25, 2025',
lastActive: '10 months ago',
mrrRange: '$500-$1000',
autoRenewal: true,
featureUsed: ['Data Export', 'Data Import'],
device: 'Mobile',
country: 'India',
countryCode: 'IN',
},
{
id: 'SUB-1033',
plan: 'Pro',
customer: 'Luca Moretti',
email: 'luca.moretti@example.test',
amount: 10400,
paymentMethod: 'Visa',
status: 'Refunded',
signupDate: 'Jul 16, 2025',
lastActive: '10 months ago',
mrrRange: '$10000+',
autoRenewal: false,
featureUsed: ['Custom Charts', 'Analytics View'],
device: 'Desktop',
country: 'Netherlands',
countryCode: 'NL',
},
{
id: 'SUB-1034',
plan: 'Basic',
customer: 'Sara Lind',
email: 'sara.lind@example.test',
amount: 48,
paymentMethod: 'PayPal',
status: 'Failed',
signupDate: 'Jul 07, 2025',
lastActive: '11 months ago',
mrrRange: '$0-$50',
autoRenewal: false,
featureUsed: ['Profile Update', 'File Upload'],
device: 'Mobile',
country: 'Sweden',
countryCode: 'SE',
},
{
id: 'SUB-1035',
plan: 'Standard',
customer: 'Milo Cruz',
email: 'milo.cruz@example.test',
amount: 680,
paymentMethod: 'Credit Card',
status: 'Paid',
signupDate: 'Jun 29, 2025',
lastActive: '11 months ago',
mrrRange: '$500-$1000',
autoRenewal: true,
featureUsed: ['User Management', 'Dashboard View'],
device: 'Tablet',
country: 'Spain',
countryCode: 'ES',
},
{
id: 'SUB-1036',
plan: 'Pro',
customer: 'Amina Yusuf',
email: 'amina.yusuf@example.test',
amount: 3800,
paymentMethod: 'Stripe',
status: 'Pending',
signupDate: 'Jun 20, 2025',
lastActive: '12 months ago',
mrrRange: '$2000-$5000',
autoRenewal: true,
featureUsed: ['API Access', 'Team Collaboration'],
device: 'Desktop',
country: 'United States',
countryCode: 'US',
},
{
id: 'SUB-1037',
plan: 'Basic',
customer: 'Ivy Zhang',
email: 'ivy.zhang@example.test',
amount: 82,
paymentMethod: 'Visa',
status: 'Refunded',
signupDate: 'Jun 11, 2025',
lastActive: '12 months ago',
mrrRange: '$50-$100',
autoRenewal: true,
featureUsed: ['Notification Settings'],
device: 'Mobile',
country: 'United Kingdom',
countryCode: 'UK',
},
{
id: 'SUB-1038',
plan: 'Standard',
customer: 'Noel Harper',
email: 'noel.harper@example.test',
amount: 1320,
paymentMethod: 'PayPal',
status: 'Failed',
signupDate: 'Jun 02, 2025',
lastActive: '13 months ago',
mrrRange: '$1000-$2000',
autoRenewal: true,
featureUsed: ['Report Generation', 'Custom Charts'],
device: 'Tablet',
country: 'Canada',
countryCode: 'CA',
},
{
id: 'SUB-1039',
plan: 'Pro',
customer: 'Faye Laurent',
email: 'faye.laurent@example.test',
amount: 8400,
paymentMethod: 'Credit Card',
status: 'Paid',
signupDate: 'May 24, 2025',
lastActive: '13 months ago',
mrrRange: '$5000-$10000',
autoRenewal: true,
featureUsed: ['Analytics View', 'Data Export', 'API Access'],
device: 'Desktop',
country: 'France',
countryCode: 'FR',
},
{
id: 'SUB-1040',
plan: 'Basic',
customer: 'Omar Reed',
email: 'omar.reed@example.test',
amount: 199,
paymentMethod: 'Stripe',
status: 'Pending',
signupDate: 'May 15, 2025',
lastActive: '14 months ago',
mrrRange: '$100-$200',
autoRenewal: true,
featureUsed: ['Data Import', 'Profile Update'],
device: 'Mobile',
country: 'Japan',
countryCode: 'JP',
},
]
const statusClasses: Record<Status, string> = {
Paid: 'bg-palette-emerald-soft text-palette-emerald-soft-foreground border-0',
Pending: 'bg-palette-yellow-soft text-palette-yellow-soft-foreground border-0',
Failed: 'bg-palette-red-soft text-palette-red-soft-foreground border-0',
Refunded: 'bg-palette-blue-soft text-palette-blue-soft-foreground border-0',
}
const paymentMethodAssets: Record<PaymentMethod, string> = {
'Credit Card': `${assetBase}/thumbs/payment/creditCard.png`,
PayPal: `${assetBase}/thumbs/payment/paypal.png`,
Stripe: `${assetBase}/thumbs/brands/stripe.png`,
Visa: `${assetBase}/thumbs/payment/visa.png`,
}
const deviceIcons = {
Desktop: PiMonitor,
Mobile: PiDeviceMobile,
Tablet: PiDeviceTablet,
}
const columnTypeIcons = {
identifier: <PiHash aria-hidden="true" />,
person: <PiUser aria-hidden="true" />,
contact: <PiAt aria-hidden="true" />,
date: <PiCalendarBlank aria-hidden="true" />,
currency: <PiCurrencyDollar aria-hidden="true" />,
payment: <PiCreditCard aria-hidden="true" />,
state: <PiCircle aria-hidden="true" />,
text: <PiListNumbers aria-hidden="true" />,
device: <PiMonitor aria-hidden="true" />,
place: <PiMapPin aria-hidden="true" />,
map: <PiMapTrifold aria-hidden="true" />,
money: <PiMoneyWavy aria-hidden="true" />,
} satisfies Record<string, ReactNode>
type ColumnType = keyof typeof columnTypeIcons
type ColumnId =
| 'plan'
| 'customer'
| 'email'
| 'amount'
| 'paymentMethod'
| 'status'
| 'signupDate'
| 'lastActive'
| 'mrrRange'
| 'autoRenewal'
| 'featureUsed'
| 'device'
| 'country'
type ColumnDefinition = {
id: ColumnId
label: string
type: ColumnType
locked?: boolean
}
const renderColumnHeader = (label: string) => () => (
<span className="whitespace-nowrap">{label}</span>
)
const columnDefinitions: ColumnDefinition[] = [
{ id: 'plan', label: 'Plan', type: 'identifier' },
{ id: 'customer', label: 'Customer', type: 'person', locked: true },
{ id: 'email', label: 'Email', type: 'contact' },
{ id: 'amount', label: 'Amount', type: 'currency' },
{ id: 'paymentMethod', label: 'Payment Method', type: 'payment' },
{ id: 'status', label: 'Status', type: 'state' },
{ id: 'signupDate', label: 'Signup Date', type: 'date' },
{ id: 'lastActive', label: 'Last Active', type: 'date' },
{ id: 'mrrRange', label: 'MRR Range', type: 'money' },
{ id: 'autoRenewal', label: 'Auto Renewal', type: 'state' },
{ id: 'featureUsed', label: 'Features Used', type: 'text' },
{ id: 'device', label: 'Device', type: 'device' },
{ id: 'country', label: 'Country', type: 'map' },
]
const columnDefById: Record<ColumnId, ColumnDef<SubscriberRow>> = {
plan: {
id: 'plan',
header: renderColumnHeader('Plan'),
accessorKey: 'plan',
enableSorting: true,
cell: ({ row }) => (
<span className="flex min-w-0 items-center gap-2 whitespace-nowrap">
<img
alt={`${row.original.plan} plan`}
className="h-5 w-5 shrink-0"
src={`${assetBase}/thumbs/plans/${row.original.plan.toLowerCase()}.svg`}
/>
<span className="font-medium">{row.original.plan}</span>
</span>
),
},
customer: {
id: 'customer',
header: renderColumnHeader('Customer'),
accessorKey: 'customer',
enableSorting: true,
cell: ({ row }) => (
<span className="whitespace-nowrap font-medium">
{row.original.customer}
</span>
),
},
email: {
id: 'email',
header: renderColumnHeader('Email'),
accessorKey: 'email',
enableSorting: true,
cell: ({ row }) => (
<span className="block min-w-0 truncate text-muted-foreground">
{row.original.email}
</span>
),
},
amount: {
id: 'amount',
header: renderColumnHeader('Amount'),
accessorKey: 'amount',
enableSorting: true,
cell: ({ row }) => (
<span className="whitespace-nowrap font-medium">
{formatCurrency(row.original.amount, 'USD', 'en-US', 2)}
</span>
),
},
paymentMethod: {
id: 'paymentMethod',
header: renderColumnHeader('Payment Method'),
accessorKey: 'paymentMethod',
enableSorting: true,
cell: ({ row }) => (
<span className="flex items-center gap-2 whitespace-nowrap">
<img
alt={`${row.original.paymentMethod} logo`}
className="h-4 w-auto shrink-0"
src={paymentMethodAssets[row.original.paymentMethod]}
/>
<span>{row.original.paymentMethod}</span>
</span>
),
},
status: {
id: 'status',
header: renderColumnHeader('Status'),
accessorKey: 'status',
enableSorting: true,
cell: ({ row }) => (
<Tag className={statusClasses[row.original.status]}>
{row.original.status}
</Tag>
),
},
signupDate: {
id: 'signupDate',
header: renderColumnHeader('Signup Date'),
accessorKey: 'signupDate',
enableSorting: true,
cell: ({ row }) => (
<span className="whitespace-nowrap">{row.original.signupDate}</span>
),
},
lastActive: {
id: 'lastActive',
header: renderColumnHeader('Last Active'),
accessorKey: 'lastActive',
enableSorting: true,
cell: ({ row }) => (
<span className="whitespace-nowrap">{row.original.lastActive}</span>
),
},
mrrRange: {
id: 'mrrRange',
header: renderColumnHeader('MRR Range'),
accessorKey: 'mrrRange',
enableSorting: true,
cell: ({ row }) => (
<Tag className="border-0">{row.original.mrrRange}</Tag>
),
},
autoRenewal: {
id: 'autoRenewal',
header: renderColumnHeader('Auto Renewal'),
accessorKey: 'autoRenewal',
enableSorting: false,
cell: ({ row }) => (
<Tag
className={classNames(
'bg-card',
row.original.autoRenewal
? 'text-success'
: 'text-destructive',
)}
>
{row.original.autoRenewal ? 'Enabled' : 'Disabled'}
</Tag>
),
},
featureUsed: {
id: 'featureUsed',
header: renderColumnHeader('Features Used'),
accessorKey: 'featureUsed',
enableSorting: true,
cell: ({ row }) => (
<span className="flex items-center gap-1 whitespace-nowrap">
{row.original.featureUsed.map((feature) => (
<Tag key={feature} className="border-0">
{feature}
</Tag>
))}
</span>
),
},
device: {
id: 'device',
header: renderColumnHeader('Device'),
accessorKey: 'device',
enableSorting: true,
cell: ({ row }) => {
const DeviceIcon = deviceIcons[row.original.device]
return (
<span className="flex items-center gap-2 whitespace-nowrap">
<DeviceIcon
aria-hidden="true"
className="text-lg text-muted-foreground"
/>
<span>{row.original.device}</span>
</span>
)
},
},
country: {
id: 'country',
header: renderColumnHeader('Country'),
accessorKey: 'country',
enableSorting: true,
cell: ({ row }) => (
<span className="flex items-center gap-2 whitespace-nowrap">
<img
alt={`${row.original.country} flag`}
className="h-4 w-4 shrink-0"
src={`${assetBase}/countries/${row.original.countryCode.toUpperCase()}.png`}
/>
<span>{row.original.country}</span>
</span>
),
},
}
const initialShownColumnIds: ColumnId[] = [
'plan',
'customer',
'email',
'amount',
'status',
'featureUsed',
]
const initialHiddenColumnIds: ColumnId[] = columnDefinitions
.map((column) => column.id)
.filter((id) => !initialShownColumnIds.includes(id))
const filterFields: FieldConfig[] = [
{ key: 'signupDate', label: 'Signup Date', type: 'date' },
{ key: 'lastActive', label: 'Last Active', type: 'date' },
{
key: 'plan',
label: 'Plan',
type: 'select',
options: [
{ label: 'Basic', value: 'Basic' },
{ label: 'Standard', value: 'Standard' },
{ label: 'Pro', value: 'Pro' },
],
},
{
key: 'status',
label: 'Status',
type: 'select',
options: [
{ label: 'Paid', value: 'Paid' },
{ label: 'Pending', value: 'Pending' },
{ label: 'Failed', value: 'Failed' },
{ label: 'Refunded', value: 'Refunded' },
],
},
{
key: 'paymentMethod',
label: 'Payment Method',
type: 'select',
options: [
{ label: 'Credit Card', value: 'Credit Card' },
{ label: 'PayPal', value: 'PayPal' },
{ label: 'Stripe', value: 'Stripe' },
{ label: 'Visa', value: 'Visa' },
],
},
{ key: 'amount', label: 'Amount', type: 'number' },
{
key: 'feature',
label: 'Feature',
type: 'select',
options: [
{ label: 'Dashboard View', value: 'Dashboard View' },
{ label: 'Report Generation', value: 'Report Generation' },
{ label: 'Data Export', value: 'Data Export' },
{ label: 'User Management', value: 'User Management' },
{ label: 'Analytics View', value: 'Analytics View' },
{ label: 'File Upload', value: 'File Upload' },
{ label: 'API Access', value: 'API Access' },
{ label: 'Custom Charts', value: 'Custom Charts' },
{ label: 'Team Collaboration', value: 'Team Collaboration' },
{ label: 'Data Import', value: 'Data Import' },
{ label: 'Notification Settings', value: 'Notification Settings' },
{ label: 'Profile Update', value: 'Profile Update' },
],
},
{
key: 'device',
label: 'Device',
type: 'select',
options: [
{ label: 'Desktop', value: 'Desktop' },
{ label: 'Mobile', value: 'Mobile' },
{ label: 'Tablet', value: 'Tablet' },
],
},
{
key: 'country',
label: 'Country',
type: 'select',
options: [
{ label: 'United States', value: 'US' },
{ label: 'United Kingdom', value: 'UK' },
{ label: 'Canada', value: 'CA' },
{ label: 'Germany', value: 'DE' },
{ label: 'France', value: 'FR' },
{ label: 'Japan', value: 'JP' },
{ label: 'Australia', value: 'AU' },
{ label: 'Brazil', value: 'BR' },
{ label: 'India', value: 'IN' },
{ label: 'Netherlands', value: 'NL' },
{ label: 'Sweden', value: 'SE' },
{ label: 'Spain', value: 'ES' },
],
},
]
const createEmptyFilterTree = (): FilterTree => ({
id: 'subscriber-filter-root',
type: 'group',
condition: 'AND',
children: [
{
id: 'subscriber-filter-rule',
type: 'rule',
field: 'plan',
operator: 'is',
value: '',
},
],
})
const getFilterValue = (
row: SubscriberRow,
field: string,
): string | number | boolean | string[] | undefined => {
switch (field) {
case 'signupDate':
return row.signupDate
case 'lastActive':
return row.lastActive
case 'plan':
return row.plan
case 'status':
return row.status
case 'paymentMethod':
return row.paymentMethod
case 'amount':
return row.amount
case 'feature':
return row.featureUsed
case 'device':
return row.device
case 'country':
return row.countryCode
default:
return undefined
}
}
const dateKey = (value: string) => {
const directMatch = value.match(/^(\d{4})-(\d{2})-(\d{2})$/)
if (directMatch) return value
const displayMatch = value.match(/^([A-Za-z]{3}) (\d{1,2}), (\d{4})$/)
if (!displayMatch) return value.toLowerCase()
const months: Record<string, string> = {
jan: '01',
feb: '02',
mar: '03',
apr: '04',
may: '05',
jun: '06',
jul: '07',
aug: '08',
sep: '09',
oct: '10',
nov: '11',
dec: '12',
}
const month = months[displayMatch[1].toLowerCase()]
if (!month) return value.toLowerCase()
return `${displayMatch[3]}-${month}-${displayMatch[2].padStart(2, '0')}`
}
const compareRule = (row: SubscriberRow, rule: Rule) => {
const rowValue = getFilterValue(row, rule.field)
const ruleValue = String(rule.value).trim()
if (rowValue === undefined || ruleValue === '') return true
if (Array.isArray(rowValue)) {
const includes = rowValue.some(
(value) => value.toLowerCase() === ruleValue.toLowerCase(),
)
return rule.operator === 'is_not' ? !includes : includes
}
if (typeof rowValue === 'number') {
const numericValue = Number(ruleValue)
if (Number.isNaN(numericValue)) return true
switch (rule.operator) {
case 'is':
return rowValue === numericValue
case 'is_not':
return rowValue !== numericValue
case 'greater_than':
return rowValue > numericValue
case 'less_than':
return rowValue < numericValue
case 'greater_equal':
return rowValue >= numericValue
case 'less_equal':
return rowValue <= numericValue
default:
return true
}
}
const left = typeof rowValue === 'boolean' ? String(rowValue) : rowValue
const right = rule.field === 'signupDate' || rule.field === 'lastActive'
? dateKey(ruleValue)
: ruleValue.toLowerCase()
const normalizedLeft =
rule.field === 'signupDate' || rule.field === 'lastActive'
? dateKey(String(left))
: String(left).toLowerCase()
switch (rule.operator) {
case 'is':
return normalizedLeft === right
case 'is_not':
return normalizedLeft !== right
case 'contains':
return normalizedLeft.includes(right)
case 'not_contains':
return !normalizedLeft.includes(right)
case 'starts_with':
return normalizedLeft.startsWith(right)
case 'ends_with':
return normalizedLeft.endsWith(right)
case 'greater_than':
return normalizedLeft > right
case 'less_than':
return normalizedLeft < right
case 'greater_equal':
return normalizedLeft >= right
case 'less_equal':
return normalizedLeft <= right
default:
return true
}
}
const evaluateFilter = (row: SubscriberRow, node: Rule | RuleGroup): boolean => {
if (node.type === 'rule') return compareRule(row, node)
if (node.children.length === 0) return true
const results = node.children.map((child) => {
const result = evaluateFilter(row, child)
return child.logicalOperator === 'NOT' ? !result : result
})
return node.condition === 'OR'
? results.some(Boolean)
: results.every(Boolean)
}
const hasActiveFilter = (node: Rule | RuleGroup): boolean => {
if (node.type === 'rule') return String(node.value).trim() !== ''
return node.children.some(hasActiveFilter)
}
const escapeCsv = (value: string) =>
/[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value
const getExportValue = (row: SubscriberRow, id: ColumnId) => {
switch (id) {
case 'amount':
return formatCurrency(row.amount, 'USD', 'en-US', 2)
case 'featureUsed':
return row.featureUsed.join(', ')
case 'autoRenewal':
return row.autoRenewal ? 'Enabled' : 'Disabled'
case 'plan':
return row.plan
case 'customer':
return row.customer
case 'email':
return row.email
case 'paymentMethod':
return row.paymentMethod
case 'status':
return row.status
case 'signupDate':
return row.signupDate
case 'lastActive':
return row.lastActive
case 'mrrRange':
return row.mrrRange
case 'device':
return row.device
case 'country':
return row.country
}
}
const ColumnRow = ({
column,
checked,
dragHandle,
onToggle,
rowRef,
style,
}: {
column: ColumnDefinition
checked: boolean
dragHandle: ReactNode
onToggle: (checked: boolean) => void
rowRef?: (node: HTMLLIElement | null) => void
style?: CSSProperties
}) => (
<li
ref={rowRef}
className="flex min-w-0 items-center gap-2 rounded-control px-1 py-1.5"
style={style}
data-column-id={column.id}
>
<span className="flex h-5 w-5 shrink-0 items-center justify-center">
{dragHandle}
</span>
<span
className={classNames(
'flex shrink-0 items-center justify-center text-xl',
column.locked
? 'text-control-disabled-foreground'
: 'text-foreground',
)}
>
{columnTypeIcons[column.type]}
</span>
<span
className={classNames(
'min-w-0 flex-1 truncate',
column.locked && 'text-control-disabled-foreground',
)}
>
{column.label}
</span>
<span className="shrink-0">
<Switcher
aria-label={`${checked ? 'Hide' : 'Show'} ${column.label}`}
checked={checked}
disabled={column.locked}
onChange={onToggle}
/>
</span>
</li>
)
const InertGrip = () => (
<span
aria-hidden="true"
className="flex h-5 w-5 items-center justify-center text-control-disabled-foreground"
>
<PiDotsSixVertical aria-hidden="true" />
</span>
)
const SortableColumnRow = ({
column,
disabled,
onToggle,
}: {
column: ColumnDefinition
disabled: boolean
onToggle: (checked: boolean) => void
}) => {
const canDrag = !disabled && !column.locked
const {
attributes,
listeners,
setActivatorNodeRef,
setNodeRef,
transform,
transition,
} = useSortable({ id: column.id, disabled: !canDrag })
return (
<ColumnRow
checked
column={column}
onToggle={onToggle}
rowRef={setNodeRef}
style={{
transform: CSS.Translate.toString(transform),
transition,
}}
dragHandle={
canDrag ? (
<button
ref={setActivatorNodeRef}
aria-label={`Reorder ${column.label}`}
className="flex h-5 w-5 cursor-grab items-center justify-center rounded-control text-foreground/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary active:cursor-grabbing"
type="button"
{...attributes}
{...listeners}
>
<PiDotsSixVertical aria-hidden="true" />
</button>
) : (
<InertGrip />
)
}
/>
)
}
export default function DataGridSubscribers() {
const [searchText, setSearchText] = useState('')
const [appliedFilter, setAppliedFilter] = useState<FilterTree>(() =>
createEmptyFilterTree(),
)
const [filterDraft, setFilterDraft] = useState<FilterTree>(() =>
createEmptyFilterTree(),
)
const [filterOpen, setFilterOpen] = useState(false)
const [columnManagerOpen, setColumnManagerOpen] = useState(false)
const [columnQuery, setColumnQuery] = useState('')
const [shownColumnIds, setShownColumnIds] = useState(initialShownColumnIds)
const [hiddenColumnIds, setHiddenColumnIds] = useState(initialHiddenColumnIds)
const [sortKey, setSortKey] = useState<ColumnId | null>(null)
const [sortOrder, setSortOrder] = useState<'asc' | 'desc' | ''>('')
const [pageIndex, setPageIndex] = useState(1)
const [pageSize, setPageSize] = useState(10)
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
)
const normalizedQuery = columnQuery.trim().toLowerCase()
const matchesColumn = (id: ColumnId) =>
!normalizedQuery ||
columnDefinitions
.find((column) => column.id === id)
?.label.toLowerCase()
.includes(normalizedQuery)
const filteredShownColumnIds = useMemo(
() => shownColumnIds.filter(matchesColumn),
[normalizedQuery, shownColumnIds],
)
const filteredHiddenColumnIds = useMemo(
() => hiddenColumnIds.filter(matchesColumn),
[hiddenColumnIds, normalizedQuery],
)
const columns = useMemo(
() => shownColumnIds.map((id) => columnDefById[id]),
[shownColumnIds],
)
const filteredRows = useMemo(() => {
const search = searchText.trim().toLowerCase()
const searchRows = search
? subscriberRows.filter((row) =>
[row.customer, row.email, row.plan, row.status].some((value) =>
value.toLowerCase().includes(search),
),
)
: subscriberRows
const filterRows = searchRows.filter((row) =>
evaluateFilter(row, appliedFilter),
)
if (!sortKey || !sortOrder) return filterRows
return [...filterRows].sort((left, right) => {
const leftValue = left[sortKey]
const rightValue = right[sortKey]
const leftText = Array.isArray(leftValue)
? leftValue.join(', ')
: String(leftValue)
const rightText = Array.isArray(rightValue)
? rightValue.join(', ')
: String(rightValue)
const comparison =
typeof leftValue === 'number' && typeof rightValue === 'number'
? leftValue - rightValue
: leftText.localeCompare(rightText)
return sortOrder === 'asc' ? comparison : -comparison
})
}, [appliedFilter, searchText, sortKey, sortOrder])
const pageRows = useMemo(() => {
const start = (pageIndex - 1) * pageSize
return filteredRows.slice(start, start + pageSize)
}, [filteredRows, pageIndex, pageSize])
const handleSort = (sort: OnSortParam) => {
const nextKey = typeof sort.sortKey === 'string'
? (sort.sortKey as ColumnId)
: null
setSortKey(sort.sortOrder && nextKey ? nextKey : null)
setSortOrder(sort.sortOrder)
setPageIndex(1)
}
const handleApplyFilter = (tree: FilterTree) => {
setAppliedFilter(tree)
setFilterDraft(tree)
setFilterOpen(false)
setPageIndex(1)
}
const handleResetFilter = (tree: FilterTree) => {
setAppliedFilter(tree)
setFilterDraft(tree)
setFilterOpen(false)
setPageIndex(1)
}
const handleColumnToggle = (id: ColumnId, checked: boolean) => {
if (id === 'customer') return
if (checked) {
setHiddenColumnIds((current) => current.filter((item) => item !== id))
setShownColumnIds((current) =>
current.includes(id) ? current : [...current, id],
)
return
}
setShownColumnIds((current) => current.filter((item) => item !== id))
setHiddenColumnIds((current) =>
current.includes(id) ? current : [...current, id],
)
}
const handleDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id || normalizedQuery) return
const oldIndex = shownColumnIds.indexOf(String(active.id) as ColumnId)
const newIndex = shownColumnIds.indexOf(String(over.id) as ColumnId)
if (oldIndex === -1 || newIndex === -1) return
setShownColumnIds((current) => arrayMove(current, oldIndex, newIndex))
}
const handleHideAll = () => {
const remaining = shownColumnIds.filter((id) => id === 'customer')
setHiddenColumnIds((current) => [
...current,
...shownColumnIds.filter(
(id) => id !== 'customer' && !current.includes(id),
),
])
setShownColumnIds(remaining)
}
const handleShowAll = () => {
setShownColumnIds((current) => [
...current,
...hiddenColumnIds.filter((id) => !current.includes(id)),
])
setHiddenColumnIds([])
}
const handleExport = () => {
const header = shownColumnIds.map((id) => columnDefinitions.find((column) => column.id === id)?.label || id)
const rows = filteredRows.map((row) =>
shownColumnIds.map((id) => escapeCsv(getExportValue(row, id))),
)
const csv = [header.map(escapeCsv), ...rows]
.map((row) => row.join(','))
.join('\n')
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = 'subscribers.csv'
anchor.click()
URL.revokeObjectURL(url)
}
const shownMatch = filteredShownColumnIds.length > 0
const hiddenMatch = filteredHiddenColumnIds.length > 0
return (
<section aria-labelledby="data-grid-subscribers-title" className="w-full pb-4">
<header className="flex flex-col gap-4 border-b p-4 md:flex-row md:items-center md:justify-between">
<h4 id="data-grid-subscribers-title">Subscribers</h4>
<Button
disabled={pageRows.length === 0}
icon={<PiDownloadSimple aria-hidden="true" />}
onClick={handleExport}
>
Export CSV
</Button>
</header>
<div className="flex flex-col gap-4 border-b p-4 lg:flex-row lg:items-center lg:justify-between">
<DebounceInput
aria-label="Search subscribers"
className="w-full lg:max-w-xs"
placeholder="Search..."
prefix={<PiMagnifyingGlass aria-hidden="true" />}
wait={300}
onChange={(event) => {
setSearchText(event.target.value)
setPageIndex(1)
}}
/>
<div className="flex flex-wrap items-center gap-2">
<Button
icon={<PiGridNine aria-hidden="true" />}
onClick={() => setColumnManagerOpen(true)}
>
Column View
</Button>
<Popover
className="p-0"
open={filterOpen}
placement="bottom-end"
width="auto"
onOpenChange={setFilterOpen}
renderTrigger={
<Button
active={hasActiveFilter(appliedFilter)}
aria-haspopup="dialog"
icon={<PiSlidersHorizontal aria-hidden="true" />}
>
Filter
</Button>
}
>
<AdvancedFilterBuilder
fields={filterFields}
value={filterDraft}
onApply={(tree) => handleApplyFilter(tree)}
onChange={(tree) => setFilterDraft(tree)}
onReset={(tree) => handleResetFilter(tree)}
/>
</Popover>
</div>
</div>
<DataTable
compact
columns={columns}
data={pageRows}
noData={pageRows.length === 0}
pageSizes={[10, 20, 50]}
pagingData={{
total: filteredRows.length,
pageIndex,
pageSize,
}}
verticalDivider={{ head: true, body: true, footer: true }}
onPageSizeChange={(nextPageSize) => {
setPageSize(nextPageSize)
setPageIndex(1)
}}
onPaginationChange={(nextPage) => setPageIndex(nextPage)}
onSort={handleSort}
/>
<Drawer
aria-label="Column manager"
bodyClass="flex min-h-0 flex-1 flex-col overflow-hidden p-0"
closable={false}
contentClassName="max-w-[calc(100vw-1rem)]"
isOpen={columnManagerOpen}
placement="right"
shouldCloseOnEsc
shouldCloseOnOverlayClick
width={400}
onClose={() => setColumnManagerOpen(false)}
>
<header className="shrink-0 px-4 pt-4">
<div className="flex items-center justify-between gap-4">
<h5>Column manager</h5>
<CloseButton
aria-label="Close column manager"
onClick={() => setColumnManagerOpen(false)}
/>
</div>
<Input
aria-label="Search columns"
className="mt-4 w-full"
placeholder="Search columns"
prefix={<PiMagnifyingGlass aria-hidden="true" />}
suffix={
columnQuery ? (
<Button
aria-label="Clear column search"
icon={<PiX aria-hidden="true" />}
shape="circle"
size="sm"
type="button"
variant="ghost"
onClick={() => setColumnQuery('')}
/>
) : undefined
}
value={columnQuery}
onChange={(event) => setColumnQuery(event.target.value)}
/>
</header>
<Scroll
className="min-h-0 flex-1"
contentClassName="p-4"
scrollbars="vertical"
type="auto"
>
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<section aria-labelledby="subscribers-columns-shown">
<div className="flex items-center justify-between gap-4">
<h6 id="subscribers-columns-shown">Shown</h6>
<Button
className="px-0 font-normal text-muted-foreground/80 hover:text-foreground"
disabled={shownColumnIds.length === 1}
size="sm"
type="button"
variant="link"
onClick={handleHideAll}
>
Hide all
</Button>
</div>
<ul className="mt-2">
<SortableContext
items={shownColumnIds}
strategy={verticalListSortingStrategy}
>
{shownMatch
? filteredShownColumnIds.map((id) => (
<SortableColumnRow
key={id}
column={columnDefinitions.find(
(column) => column.id === id,
) as ColumnDefinition}
disabled={Boolean(normalizedQuery)}
onToggle={(checked) =>
handleColumnToggle(id, checked)
}
/>
))
: null}
</SortableContext>
</ul>
</section>
<section
aria-labelledby="subscribers-columns-hidden"
className="mt-2 border-t pt-2"
>
<div className="flex items-center justify-between gap-4">
<h6 id="subscribers-columns-hidden">Hidden</h6>
<Button
className="px-0 font-normal text-muted-foreground/80 hover:text-foreground"
disabled={hiddenColumnIds.length === 0}
size="sm"
type="button"
variant="link"
onClick={handleShowAll}
>
Show all
</Button>
</div>
<ul className="mt-2">
{hiddenMatch
? filteredHiddenColumnIds.map((id) => (
<ColumnRow
key={id}
checked={false}
column={columnDefinitions.find(
(column) => column.id === id,
) as ColumnDefinition}
dragHandle={<InertGrip />}
onToggle={(checked) =>
handleColumnToggle(id, checked)
}
/>
))
: null}
</ul>
</section>
{normalizedQuery && !shownMatch && !hiddenMatch && (
<p className="text-muted-foreground">
No columns match this search.
</p>
)}
</DndContext>
</Scroll>
</Drawer>
</section>
)
}
Data Grid 07
Preview
npx nateui@latest add DataGridTaskReorderDark
import { createPortal } from 'react-dom'
import { useMemo, useState } from 'react'
import {
DndContext,
DragOverlay,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type Active,
type DragEndEvent,
type DragOverEvent,
type DragStartEvent,
type Over,
} from '@dnd-kit/core'
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import Avatar from '@/components/ui/Avatar'
import Button from '@/components/ui/Button'
import Checkbox from '@/components/ui/Checkbox'
import Dropdown from '@/components/ui/Dropdown'
import Table from '@/components/ui/Table'
import Tag from '@/components/ui/Tag'
import Container from '@/components/composites/Container'
import classNames from '@/utils/classNames'
import {
PiCaretDown,
PiCaretRight,
PiCheck,
PiChatCircle,
PiDotsSixVertical,
PiListChecks,
PiPlus,
} from 'react-icons/pi'
type SectionId = 'discovery' | 'build' | 'rollout'
type ProjectName = 'Platform' | 'Internal'
type LabelName = 'Frontend' | 'Backend' | 'Review' | 'Docs' | 'Urgent'
type SortKey = 'deadline' | 'project' | 'labels'
type SortOrder = 'asc' | 'desc'
type TaskFilter = 'all' | 'open' | 'completed'
type SortState = {
key: SortKey | null
order: SortOrder
}
type Section = {
id: SectionId
name: string
dotClassName: string
collapsed: boolean
sort: SortState
}
type Task = {
id: string
sectionId: SectionId
title: string
deadline: string
deadlineIso: string
project: ProjectName
labels: LabelName[]
progress: string
commentCount: number
overdue: boolean
completed: boolean
}
type SectionDragData = {
type: 'Section'
section: Section
}
type TaskDragData = {
type: 'Task'
task: Task
}
type DraggableData = SectionDragData | TaskDragData
const emptySort: SortState = { key: null, order: 'asc' }
const taskFilterLabels: Record<TaskFilter, string> = {
all: 'All',
open: 'Open',
completed: 'Completed',
}
const initialSections: Section[] = [
{
id: 'discovery',
name: 'Discovery',
dotClassName: 'bg-palette-blue',
collapsed: false,
sort: emptySort,
},
{
id: 'build',
name: 'Build',
dotClassName: 'bg-palette-purple',
collapsed: false,
sort: emptySort,
},
{
id: 'rollout',
name: 'Rollout',
dotClassName: 'bg-palette-emerald',
collapsed: false,
sort: emptySort,
},
]
const assetBase = 'https://statics.nateui.com/img'
const projectImages: Record<ProjectName, string> = {
Platform: 'img-1.jpg',
Internal: 'img-3.jpg',
}
const labelTagClasses: Record<LabelName, string> = {
Frontend: 'bg-palette-orange-soft text-palette-orange-soft-foreground',
Backend: 'bg-palette-cyan-soft text-palette-cyan-soft-foreground',
Review: 'bg-palette-purple-soft text-palette-purple-soft-foreground',
Docs: 'bg-palette-blue-soft text-palette-blue-soft-foreground',
Urgent: 'bg-palette-rose-soft text-palette-rose-soft-foreground',
}
const initialTasks: Task[] = [
{
id: 'discovery-outline',
sectionId: 'discovery',
title: 'Draft requirement outline',
deadline: '12 Mar 2026',
deadlineIso: '2026-03-12',
project: 'Platform',
labels: ['Frontend', 'Review', 'Docs', 'Urgent'],
progress: '0/4',
commentCount: 8,
overdue: false,
completed: false,
},
{
id: 'discovery-stakeholders',
sectionId: 'discovery',
title: 'Collect stakeholder input',
deadline: '24 Mar 2026',
deadlineIso: '2026-03-24',
project: 'Platform',
labels: ['Frontend'],
progress: '1/4',
commentCount: 5,
overdue: false,
completed: false,
},
{
id: 'discovery-data-flow',
sectionId: 'discovery',
title: 'Map current data flow',
deadline: '22 Mar 2026',
deadlineIso: '2026-03-22',
project: 'Platform',
labels: [],
progress: '2/6',
commentCount: 3,
overdue: false,
completed: false,
},
{
id: 'discovery-metrics',
sectionId: 'discovery',
title: 'Agree on success metrics',
deadline: '16 Mar 2026',
deadlineIso: '2026-03-16',
project: 'Internal',
labels: ['Review', 'Docs'],
progress: '0/3',
commentCount: 12,
overdue: false,
completed: false,
},
{
id: 'build-settings',
sectionId: 'build',
title: 'Wire the settings service',
deadline: '16 Apr 2026',
deadlineIso: '2026-04-16',
project: 'Platform',
labels: ['Backend', 'Docs'],
progress: '1/5',
commentCount: 4,
overdue: false,
completed: false,
},
{
id: 'build-audit',
sectionId: 'build',
title: 'Add audit trail events',
deadline: '11 Apr 2026',
deadlineIso: '2026-04-11',
project: 'Internal',
labels: ['Backend'],
progress: '3/5',
commentCount: 9,
overdue: false,
completed: false,
},
{
id: 'build-legacy',
sectionId: 'build',
title: 'Migrate legacy records',
deadline: '8 Apr 2026',
deadlineIso: '2026-04-08',
project: 'Platform',
labels: ['Backend', 'Review', 'Urgent'],
progress: '0/8',
commentCount: 2,
overdue: false,
completed: false,
},
{
id: 'rollout-notes',
sectionId: 'rollout',
title: 'Prepare release notes',
deadline: '2 Mar 2026',
deadlineIso: '2026-03-02',
project: 'Internal',
labels: ['Docs'],
progress: '0/2',
commentCount: 6,
overdue: true,
completed: false,
},
{
id: 'rollout-enablement',
sectionId: 'rollout',
title: 'Schedule enablement session',
deadline: '28 Apr 2026',
deadlineIso: '2026-04-28',
project: 'Platform',
labels: ['Review', 'Docs'],
progress: '1/3',
commentCount: 7,
overdue: false,
completed: false,
},
]
function hasDraggableData(
entry: Active | Over | null | undefined,
): entry is (Active | Over) & { data: { current: DraggableData } } {
const type = (entry?.data.current as { type?: unknown } | undefined)?.type
return type === 'Section' || type === 'Task'
}
function sortTasks(tasks: Task[], sort: SortState) {
if (!sort.key) {
return tasks
}
return [...tasks].sort((left, right) => {
let comparison = 0
if (sort.key === 'deadline') {
comparison = left.deadlineIso.localeCompare(right.deadlineIso)
}
if (sort.key === 'project') {
comparison = left.project.localeCompare(right.project)
}
if (sort.key === 'labels') {
const leftLabel = left.labels[0] ?? ''
const rightLabel = right.labels[0] ?? ''
if (!leftLabel && rightLabel) return 1
if (leftLabel && !rightLabel) return -1
comparison = leftLabel.localeCompare(rightLabel)
}
return sort.order === 'asc' ? comparison : -comparison
})
}
function TaskTableColumns() {
return (
<colgroup>
<col className="w-2/5" />
<col className="w-1/5" />
<col className="w-1/5" />
<col className="w-1/5" />
</colgroup>
)
}
type SortHeaderButtonConfig = {
label: string
sectionId: SectionId
sortKey: SortKey
sort: SortState
onSort: (sectionId: SectionId, sortKey: SortKey) => void
}
function SortHeaderButton({
label,
sectionId,
sortKey,
sort,
onSort,
}: SortHeaderButtonConfig) {
const currentSort = sort.key === sortKey ? sort.order : true
return (
<button
type="button"
className="inline-flex items-center gap-2 rounded-control-sm outline-none focus-visible:ring-2 focus-visible:ring-primary"
onClick={() => onSort(sectionId, sortKey)}
>
<span>{label}</span>
<Table.Sorter sort={currentSort} />
</button>
)
}
type TaskRowConfig = {
task: Task
isOverlay?: boolean
onToggleTask: (taskId: string, completed: boolean) => void
}
function TaskRow({ task, isOverlay, onToggleTask }: TaskRowConfig) {
const {
setNodeRef,
attributes,
listeners,
transform,
transition,
isDragging,
} = useSortable({
id: task.id,
data: { type: 'Task', task } satisfies TaskDragData,
attributes: { roleDescription: 'Task' },
})
const style = {
transform: CSS.Translate.toString(transform),
transition,
}
const visibleLabels = task.labels.slice(0, 2)
const remainingLabels = task.labels.length - visibleLabels.length
return (
<Table.Tr
ref={setNodeRef}
style={style}
className={classNames(
isOverlay
? 'ring-2 ring-primary'
: isDragging
? 'ring-2 opacity-30'
: '',
task.completed && 'opacity-50',
)}
{...attributes}
>
<Table.Td>
<div className="flex min-w-0 items-center gap-2">
<button
type="button"
aria-label="Drag task"
className="shrink-0 cursor-grab rounded-control-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary"
{...listeners}
>
<PiDotsSixVertical />
</button>
<Checkbox
aria-label={`${task.completed ? 'Reopen' : 'Complete'} ${task.title}`}
checked={task.completed}
onChange={(checked) => onToggleTask(task.id, checked)}
/>
<div className="flex min-w-0 flex-1 items-center justify-between gap-2">
<span
className={classNames(
'min-w-0 truncate font-medium',
task.completed &&
'line-through text-muted-foreground',
)}
>
{task.title}
</span>
<span className="flex shrink-0 items-center gap-2">
<span className="flex items-center gap-1">
<PiListChecks aria-hidden="true" />
{task.progress}
</span>
<span className="flex items-center gap-1">
<PiChatCircle aria-hidden="true" />
{task.commentCount}
</span>
</span>
</div>
</div>
</Table.Td>
<Table.Td>
<span
className={classNames(
'whitespace-nowrap',
task.overdue &&
!task.completed &&
'font-medium text-destructive',
task.completed && 'text-muted-foreground',
)}
>
{task.deadline}
</span>
</Table.Td>
<Table.Td>
<span className="flex items-center gap-2 whitespace-nowrap">
<Avatar
alt={`${task.project} project`}
className="border-0"
src={`${assetBase}/thumbs/projects/${projectImages[task.project]}`}
shape="round"
size={20}
/>
<span>{task.project}</span>
</span>
</Table.Td>
<Table.Td>
<span className="flex flex-nowrap items-center gap-2 whitespace-nowrap">
{visibleLabels.map((label) => (
<Tag
key={label}
className={classNames(
'border-0',
labelTagClasses[label],
)}
>
{label}
</Tag>
))}
{remainingLabels > 0 && (
<Tag className="px-1">
+{remainingLabels}
</Tag>
)}
</span>
</Table.Td>
</Table.Tr>
)
}
type SectionTableConfig = {
section: Section
tasks: Task[]
isOverlay?: boolean
onSort: (sectionId: SectionId, sortKey: SortKey) => void
onToggleCollapsed: (sectionId: SectionId) => void
onToggleTask: (taskId: string, completed: boolean) => void
}
function SectionTable({
section,
tasks,
isOverlay,
onSort,
onToggleCollapsed,
onToggleTask,
}: SectionTableConfig) {
const {
setNodeRef,
attributes,
listeners,
transform,
transition,
isDragging,
} = useSortable({
id: section.id,
data: { type: 'Section', section } satisfies SectionDragData,
attributes: { roleDescription: `Section: ${section.name}` },
})
const taskIds = useMemo(() => tasks.map((task) => task.id), [tasks])
const style = {
transform: CSS.Translate.toString(transform),
transition,
}
return (
<div
ref={setNodeRef}
style={style}
className={classNames(
'flex min-w-0 items-start gap-2',
isDragging && 'opacity-30',
)}
{...attributes}
>
<div className="flex shrink-0 items-center gap-2 pt-2">
<button
type="button"
aria-label="Drag section"
className="cursor-grab rounded-control-sm text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-primary"
{...listeners}
>
<PiDotsSixVertical />
</button>
<Button
aria-label={
section.collapsed
? 'Expand section'
: 'Collapse section'
}
icon={
section.collapsed ? (
<PiCaretRight aria-hidden="true" />
) : (
<PiCaretDown aria-hidden="true" />
)
}
size="sm"
type="button"
variant="subtle"
className="size-6! text-xs"
onClick={(event) => {
event.stopPropagation()
onToggleCollapsed(section.id)
}}
onMouseDown={(event) => event.stopPropagation()}
/>
</div>
<div
className={classNames(
'min-w-0 flex-1 overflow-x-auto rounded-card',
isOverlay && 'ring-2 ring-primary',
)}
>
<Table
bordered
overflow={false}
className="w-full"
style={{ tableLayout: 'fixed' }}
>
<TaskTableColumns />
<Table.THead>
<Table.Tr className="border-b bg-transparent">
<Table.Th scope="col">
<span className="flex min-w-0 items-center gap-2 text-foreground">
<span
className={classNames(
'h-2 w-2 shrink-0 rounded-full',
section.dotClassName,
)}
/>
<span className="min-w-0 truncate font-medium">
{section.name}
</span>
<span className="shrink-0 rounded-control-sm border px-1 text-xs font-medium">
{tasks.length}
</span>
</span>
</Table.Th>
<Table.Th scope="col">
<SortHeaderButton
label="Deadline"
sectionId={section.id}
sortKey="deadline"
sort={section.sort}
onSort={onSort}
/>
</Table.Th>
<Table.Th scope="col">
<SortHeaderButton
label="Projects"
sectionId={section.id}
sortKey="project"
sort={section.sort}
onSort={onSort}
/>
</Table.Th>
<Table.Th scope="col">
<SortHeaderButton
label="Labels"
sectionId={section.id}
sortKey="labels"
sort={section.sort}
onSort={onSort}
/>
</Table.Th>
</Table.Tr>
</Table.THead>
{!section.collapsed && (
<Table.TBody>
<SortableContext
items={taskIds}
strategy={verticalListSortingStrategy}
>
{tasks.map((task) => (
<TaskRow
key={task.id}
task={task}
onToggleTask={onToggleTask}
/>
))}
</SortableContext>
</Table.TBody>
)}
</Table>
</div>
</div>
)
}
export default function DataGridTaskReorder() {
const [sections, setSections] = useState<Section[]>(initialSections)
const [tasks, setTasks] = useState<Task[]>(initialTasks)
const [activeSection, setActiveSection] = useState<Section | null>(null)
const [activeTask, setActiveTask] = useState<Task | null>(null)
const [taskFilter, setTaskFilter] = useState<TaskFilter>('all')
const sectionIds = useMemo(
() => sections.map((section) => section.id),
[sections],
)
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
)
const getSectionTasks = (section: Section) =>
sortTasks(
tasks.filter(
(task) =>
task.sectionId === section.id &&
(taskFilter === 'all' ||
(taskFilter === 'open' && !task.completed) ||
(taskFilter === 'completed' && task.completed)),
),
section.sort,
)
const resetSectionSort = (sectionIdsToReset: SectionId[]) => {
setSections((current) =>
current.map((section) =>
sectionIdsToReset.includes(section.id)
? { ...section, sort: { ...emptySort } }
: section,
),
)
}
const handleSort = (sectionId: SectionId, sortKey: SortKey) => {
setSections((current) =>
current.map((section) => {
if (section.id !== sectionId) return section
const nextSort: SortState =
section.sort.key !== sortKey
? { key: sortKey, order: 'asc' }
: section.sort.order === 'asc'
? { key: sortKey, order: 'desc' }
: { ...emptySort }
return { ...section, sort: nextSort }
}),
)
}
const handleToggleCollapsed = (sectionId: SectionId) => {
setSections((current) =>
current.map((section) =>
section.id === sectionId
? { ...section, collapsed: !section.collapsed }
: section,
),
)
}
const handleToggleTask = (taskId: string, completed: boolean) => {
setTasks((current) =>
current.map((task) =>
task.id === taskId ? { ...task, completed } : task,
),
)
}
const handleDragStart = (event: DragStartEvent) => {
if (!hasDraggableData(event.active)) return
const data = event.active.data.current
if (data.type === 'Section') {
setActiveSection({ ...data.section, sort: { ...emptySort } })
return
}
setActiveTask(data.task)
}
const handleDragOver = (event: DragOverEvent) => {
const { active, over } = event
if (!over) return
if (!hasDraggableData(active) || !hasDraggableData(over)) return
if (active.id === over.id) return
const activeData = active.data.current
const overData = over.data.current
if (activeData.type !== 'Task') return
if (overData.type === 'Task') {
const activeSectionId = activeData.task.sectionId
const overSectionId = overData.task.sectionId
if (activeSectionId !== overSectionId) {
resetSectionSort([activeSectionId, overSectionId])
setTasks((current) => {
const activeIndex = current.findIndex(
(task) => task.id === active.id,
)
const overIndex = current.findIndex(
(task) => task.id === over.id,
)
const overTask = current[overIndex]
if (activeIndex === -1 || overIndex === -1 || !overTask) {
return current
}
const next = current.map((task, index) =>
index === activeIndex
? { ...task, sectionId: overTask.sectionId }
: task,
)
return arrayMove(next, activeIndex, overIndex - 1)
})
return
}
resetSectionSort([activeSectionId])
setTasks((current) => {
const activeIndex = current.findIndex(
(task) => task.id === active.id,
)
const overIndex = current.findIndex(
(task) => task.id === over.id,
)
if (activeIndex === -1 || overIndex === -1) return current
return arrayMove(current, activeIndex, overIndex)
})
return
}
resetSectionSort([activeData.task.sectionId])
setTasks((current) => {
const activeTaskIndex = current.findIndex(
(task) => task.id === active.id,
)
if (activeTaskIndex === -1) return current
const next = current.map((task, index) =>
index === activeTaskIndex
? { ...task, sectionId: overData.section.id }
: task,
)
return arrayMove(next, activeTaskIndex, activeTaskIndex)
})
}
const handleDragEnd = (event: DragEndEvent) => {
setActiveSection(null)
setActiveTask(null)
const { active, over } = event
if (!over) return
if (!hasDraggableData(active) || !hasDraggableData(over)) return
const activeData = active.data.current
const overData = over.data.current
if (activeData.type !== 'Section' || overData.type !== 'Section') return
if (active.id === over.id) return
resetSectionSort([activeData.section.id])
setSections((current) => {
const activeIndex = current.findIndex(
(section) => section.id === active.id,
)
const overIndex = current.findIndex(
(section) => section.id === over.id,
)
if (activeIndex === -1 || overIndex === -1) return current
return arrayMove(current, activeIndex, overIndex)
})
}
return (
<section
aria-labelledby="data-grid-task-reorder-title"
className="w-full space-y-4"
>
<Container size="lg">
<header className="flex flex-wrap items-center justify-between gap-4 mb-4 pb-4 border-b">
<h4
id="data-grid-task-reorder-title"
className="text-xl font-semibold text-foreground"
>
My Task
</h4>
<div className="flex basis-full items-center gap-2 sm:basis-auto">
<Dropdown
activeKey={taskFilter}
onSelect={(eventKey) => {
if (
eventKey === 'all' ||
eventKey === 'open' ||
eventKey === 'completed'
) {
setTaskFilter(eventKey)
}
}}
placement="bottom-end"
renderTitle={
<Button
variant="ghost"
size="sm"
icon={<PiCaretDown aria-hidden="true" />}
iconAlignment="end"
>
Task: {taskFilterLabels[taskFilter]}
</Button>
}
>
<Dropdown.Item
active={taskFilter === 'all'}
eventKey="all"
>
<span className="flex w-full items-center justify-between">
<span>All</span>
{taskFilter === 'all' && (
<PiCheck
aria-hidden="true"
className="shrink-0 text-base"
/>
)}
</span>
</Dropdown.Item>
<Dropdown.Item
active={taskFilter === 'open'}
eventKey="open"
>
<span className="flex w-full items-center justify-between">
<span>Open</span>
{taskFilter === 'open' && (
<PiCheck
aria-hidden="true"
className="shrink-0 text-base"
/>
)}
</span>
</Dropdown.Item>
<Dropdown.Item
active={taskFilter === 'completed'}
eventKey="completed"
>
<span className="flex w-full items-center justify-between">
<span>Completed</span>
{taskFilter === 'completed' && (
<PiCheck
aria-hidden="true"
className="shrink-0 text-base"
/>
)}
</span>
</Dropdown.Item>
</Dropdown>
<Button
type="button"
variant="solid"
size="sm"
icon={<PiPlus aria-hidden="true" />}
className="focus-visible:ring-2 focus-visible:ring-primary"
>
Add Task
</Button>
</div>
</header>
<DndContext
id="data-grid-task-reorder"
sensors={sensors}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragStart={handleDragStart}
>
<SortableContext
items={sectionIds}
strategy={verticalListSortingStrategy}
>
<div className="space-y-4">
{sections.map((section) => (
<SectionTable
key={section.id}
section={section}
tasks={getSectionTasks(section)}
onSort={handleSort}
onToggleCollapsed={handleToggleCollapsed}
onToggleTask={handleToggleTask}
/>
))}
</div>
</SortableContext>
{typeof document !== 'undefined' &&
createPortal(
<DragOverlay>
{activeSection && (
<SectionTable
isOverlay
section={activeSection}
tasks={getSectionTasks(activeSection)}
onSort={handleSort}
onToggleCollapsed={handleToggleCollapsed}
onToggleTask={handleToggleTask}
/>
)}
{activeTask && (
<Table
hoverable={false}
className="w-full"
style={{ tableLayout: 'fixed' }}
>
<TaskTableColumns />
<Table.TBody>
<TaskRow
isOverlay
task={activeTask}
onToggleTask={handleToggleTask}
/>
</Table.TBody>
</Table>
)}
</DragOverlay>,
document.body,
)}
</DndContext>
</Container>
</section>
)
}
Data Grid 08
Preview
npx nateui@latest add DataGridFileTreeDark
import { useCallback, useMemo, useRef, useState } from 'react'
import classNames from '@/utils/classNames'
import ActionBar from '@/components/ui/ActionBar'
import Avatar from '@/components/ui/Avatar'
import Button from '@/components/ui/Button'
import Dropdown from '@/components/ui/Dropdown'
import Tag from '@/components/ui/Tag'
import DataTable from '@/components/composites/DataTable'
import FileIcon from '@/components/composites/FileIcon'
import type {
ColumnDef,
DataTableResetHandle,
Row,
} from '@/components/composites/DataTable'
import { colors } from '@/configs/colors.config'
import {
PiArrowCounterClockwise,
PiArrowSquareOut,
PiArrowsInSimple,
PiArrowsOutSimple,
PiCaretRight,
PiDotsThreeVerticalBold,
PiPencilSimple,
PiTrash,
} from 'react-icons/pi'
const assetBase = 'https://statics.nateui.com/img'
type FileType = 'directory' | 'png' | 'csv' | 'pdf' | 'docx' | 'xlsx'
type FileNode = {
id: string
name: string
type: FileType
size: string
childCount?: number
ownerName: string
ownerAvatar: string
modified: string
relativeModified: string
parentId: string | null
depth: number
}
const nodes: FileNode[] = [
{
id: 'design-system',
name: 'Design System',
type: 'directory',
size: '412.6 MB',
childCount: 3,
ownerName: 'A. Rivera',
ownerAvatar: `${assetBase}/avatars/thumb-4.jpg`,
modified: '3 Aug 2026',
relativeModified: 'Yesterday',
parentId: null,
depth: 0,
},
{
id: 'tokens',
name: 'Tokens',
type: 'directory',
size: '88.2 KB',
childCount: 2,
ownerName: 'A. Rivera',
ownerAvatar: `${assetBase}/avatars/thumb-4.jpg`,
modified: '9 Jul 2026',
relativeModified: 'Last month',
parentId: 'design-system',
depth: 1,
},
{
id: 'color-palette',
name: 'Color Palette.png',
type: 'png',
size: '1.8 MB',
ownerName: 'A. Rivera',
ownerAvatar: `${assetBase}/avatars/thumb-4.jpg`,
modified: '9 Jul 2026',
relativeModified: 'Last month',
parentId: 'tokens',
depth: 2,
},
{
id: 'token-export',
name: 'Token Export.csv',
type: 'csv',
size: '42 KB',
ownerName: 'M. Lindqvist',
ownerAvatar: `${assetBase}/avatars/thumb-11.jpg`,
modified: '2 Jul 2026',
relativeModified: 'Last month',
parentId: 'tokens',
depth: 2,
},
{
id: 'components',
name: 'Components',
type: 'directory',
size: '402.1 MB',
childCount: 4,
ownerName: 'J. Okafor',
ownerAvatar: `${assetBase}/avatars/thumb-7.jpg`,
modified: '1 Aug 2026',
relativeModified: '3 days ago',
parentId: 'design-system',
depth: 1,
},
{
id: 'handoff-notes',
name: 'Handoff Notes.pdf',
type: 'pdf',
size: '6.4 MB',
ownerName: 'M. Lindqvist',
ownerAvatar: `${assetBase}/avatars/thumb-11.jpg`,
modified: '28 Jul 2026',
relativeModified: 'Last week',
parentId: 'design-system',
depth: 1,
},
{
id: 'research',
name: 'Research',
type: 'directory',
size: '1.2 GB',
childCount: 2,
ownerName: 'S. Haddad',
ownerAvatar: `${assetBase}/avatars/thumb-16.jpg`,
modified: '4 Aug 2026',
relativeModified: 'Yesterday',
parentId: null,
depth: 0,
},
{
id: 'interview-recordings',
name: 'Interview Recordings',
type: 'directory',
size: '1.1 GB',
childCount: 12,
ownerName: 'S. Haddad',
ownerAvatar: `${assetBase}/avatars/thumb-16.jpg`,
modified: '4 Aug 2026',
relativeModified: 'Yesterday',
parentId: 'research',
depth: 1,
},
{
id: 'findings-summary',
name: 'Findings Summary.docx',
type: 'docx',
size: '3.1 MB',
ownerName: 'J. Okafor',
ownerAvatar: `${assetBase}/avatars/thumb-7.jpg`,
modified: '30 Jul 2026',
relativeModified: 'Last week',
parentId: 'research',
depth: 1,
},
{
id: 'release-checklist',
name: 'Release Checklist.xlsx',
type: 'xlsx',
size: '820 KB',
ownerName: 'T. Nakamura',
ownerAvatar: `${assetBase}/avatars/thumb-21.jpg`,
modified: '26 Jul 2026',
relativeModified: '2 weeks ago',
parentId: null,
depth: 0,
},
]
const initialExpandedIds = ['design-system', 'tokens']
const typeStyles: Record<
FileType,
{ label: string; color: keyof typeof colors }
> = {
directory: { label: 'Folder', color: 'yellow' },
png: { label: 'Image', color: 'purple' },
csv: { label: 'Spreadsheet', color: 'emerald' },
xlsx: { label: 'Spreadsheet', color: 'emerald' },
pdf: { label: 'Document', color: 'blue' },
docx: { label: 'Document', color: 'blue' },
}
function isVisible(
node: FileNode,
expanded: Set<string>,
nodesById: Map<string, FileNode>,
) {
let current = node
while (current.parentId) {
const parent = nodesById.get(current.parentId)
if (!parent || !expanded.has(parent.id)) {
return false
}
current = parent
}
return true
}
export default function DataGridFileTree() {
const [fileNodes, setFileNodes] = useState<FileNode[]>(nodes)
const [expanded, setExpanded] = useState<Set<string>>(
new Set(initialExpandedIds),
)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const dataTableRef = useRef<DataTableResetHandle>(null)
const nodesById = useMemo(
() => new Map(fileNodes.map((node) => [node.id, node])),
[fileNodes],
)
const visibleNodes = useMemo(
() =>
fileNodes.filter((node) =>
isVisible(node, expanded, nodesById),
),
[expanded, fileNodes, nodesById],
)
const clearSelection = useCallback(() => {
setSelectedIds(new Set())
dataTableRef.current?.resetSelected()
}, [])
const toggle = useCallback(
(id: string) => {
setExpanded((previous) => {
const next = new Set(previous)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
clearSelection()
},
[clearSelection],
)
const expandAll = useCallback(() => {
setExpanded(
new Set(
fileNodes
.filter((node) => node.type === 'directory')
.map((node) => node.id),
),
)
clearSelection()
}, [clearSelection, fileNodes])
const collapseAll = useCallback(() => {
setExpanded(new Set())
clearSelection()
}, [clearSelection])
const reset = useCallback(() => {
setExpanded(new Set(initialExpandedIds))
clearSelection()
}, [clearSelection])
const removeSelected = useCallback(() => {
if (selectedIds.size === 0) {
return
}
setFileNodes((current) => {
const idsToRemove = new Set(selectedIds)
let changed = true
while (changed) {
changed = false
current.forEach((node) => {
if (
node.parentId &&
idsToRemove.has(node.parentId) &&
!idsToRemove.has(node.id)
) {
idsToRemove.add(node.id)
changed = true
}
})
}
return current.filter((node) => !idsToRemove.has(node.id))
})
setExpanded((current) =>
new Set(
[...current].filter((id) => !selectedIds.has(id)),
),
)
clearSelection()
}, [clearSelection, selectedIds])
const handleRowSelect = useCallback((checked: boolean, row: FileNode) => {
setSelectedIds((previous) => {
const next = new Set(previous)
if (checked) {
next.add(row.id)
} else {
next.delete(row.id)
}
return next
})
}, [])
const handleAllRowSelect = useCallback(
(checked: boolean, rows: Row<FileNode>[]) => {
setSelectedIds(
checked
? new Set(rows.map((row) => row.original.id))
: new Set(),
)
},
[],
)
const columns: ColumnDef<FileNode>[] = useMemo(
() => [
{
accessorKey: 'name',
header: 'Name',
enableSorting: false,
size: 300,
cell: ({ row }) => {
const node = row.original
const isFolder = node.type === 'directory'
const isOpen = expanded.has(node.id)
return (
<div
className="flex min-w-0 items-center gap-2"
style={{ paddingLeft: node.depth * 20 }}
>
{isFolder ? (
<button
type="button"
aria-label={
isOpen
? `Collapse ${node.name}`
: `Expand ${node.name}`
}
className="inline-flex shrink-0 cursor-pointer text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
onClick={() => toggle(node.id)}
>
<PiCaretRight
className={
isOpen
? 'rotate-90 transition'
: 'transition'
}
/>
</button>
) : (
<span
className="inline-block w-4 shrink-0"
aria-hidden="true"
/>
)}
<span className="shrink-0">
<FileIcon type={node.type} />
</span>
<span
className={classNames(
isFolder && 'font-medium',
'min-w-0 truncate text-foreground',
)}
>
{node.name}
</span>
</div>
)
},
},
{
accessorKey: 'type',
header: 'Type',
enableSorting: false,
size: 100,
cell: ({ row }) => {
const style = typeStyles[row.original.type]
const color = colors[style.color]
return (
<Tag
className={classNames(
color.tuneBg,
color.tuneFg,
'border-0',
)}
>
{style.label}
</Tag>
)
},
},
{
accessorKey: 'size',
header: 'Size',
enableSorting: false,
size: 130,
cell: ({ row }) => (
<div className="flex flex-col">
<span className="text-foreground">{row.original.size}</span>
{row.original.type === 'directory' && (
<span className="text-xs text-muted-foreground">
{row.original.childCount} items
</span>
)}
</div>
),
},
{
accessorKey: 'ownerName',
header: 'Owner',
enableSorting: false,
size: 180,
cell: ({ row }) => (
<div className="flex min-w-0 items-center gap-2">
<Avatar
size={24}
src={row.original.ownerAvatar}
alt={row.original.ownerName}
/>
<span className="truncate text-foreground font-medium">
{row.original.ownerName}
</span>
</div>
),
},
{
accessorKey: 'modified',
header: 'Modified',
enableSorting: false,
size: 160,
cell: ({ row }) => (
<div className="flex flex-col">
<span className="text-foreground">{row.original.modified}</span>
<span className="text-xs text-muted-foreground">
{row.original.relativeModified}
</span>
</div>
),
},
{
id: 'actions',
header: '',
enableSorting: false,
size: 60,
cell: ({ row }) => (
<div className="flex justify-end">
<Dropdown
placement="bottom-end"
renderTitle={
<Button
variant="ghost"
shape="circle"
size="sm"
icon={<PiDotsThreeVerticalBold />}
aria-label={`More actions for ${row.original.name}`}
/>
}
>
<Dropdown.Item
className="flex items-center gap-2"
eventKey="open"
>
<PiArrowSquareOut
aria-hidden="true"
className="text-base text-muted-foreground"
/>
<span>Open</span>
</Dropdown.Item>
<Dropdown.Item
className="flex items-center gap-2"
eventKey="rename"
>
<PiPencilSimple
aria-hidden="true"
className="text-base text-muted-foreground"
/>
<span>Rename</span>
</Dropdown.Item>
<Dropdown.Item
className="flex items-center gap-2"
eventKey="delete"
>
<PiTrash
aria-hidden="true"
className="text-base text-muted-foreground"
/>
<span>Delete</span>
</Dropdown.Item>
</Dropdown>
</div>
),
},
],
[expanded, toggle],
)
return (
<section
aria-labelledby="data-grid-file-tree-heading"
className="w-full space-y-4"
>
<header className="flex flex-wrap items-center justify-between gap-4">
<div>
<h4 id="data-grid-file-tree-heading">
Design Library
</h4>
<p className="text-muted-foreground">
{fileNodes.length} items · 1.6 GB of 5 GB used
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="default"
size="sm"
icon={<PiArrowsOutSimple />}
onClick={expandAll}
>
Expand all
</Button>
<Button
variant="default"
size="sm"
icon={<PiArrowsInSimple />}
onClick={collapseAll}
>
Collapse all
</Button>
<Button
variant="default"
size="sm"
icon={<PiArrowCounterClockwise />}
onClick={reset}
>
Reset
</Button>
</div>
</header>
<DataTable<FileNode>
ref={(
instance: DataTableResetHandle | HTMLTableElement | null,
) => {
if (instance && 'resetSelected' in instance) {
dataTableRef.current = instance
}
}}
overflowClass="border rounded-card"
columns={columns}
data={visibleNodes}
selectable
onRowSelect={handleRowSelect}
onAllRowSelect={handleAllRowSelect}
checkboxChecked={(row) => selectedIds.has(row.id)}
pagingData={{
total: visibleNodes.length,
pageIndex: 1,
pageSize: 25,
}}
/>
<ActionBar open={selectedIds.size > 0} width={560}>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<span className="font-medium">
<span className="font-semibold text-foreground">
{selectedIds.size}{' '}
{selectedIds.size === 1 ? 'item' : 'items'}
</span>{' '}
selected
</span>
<Button
destructive
icon={<PiTrash />}
onClick={removeSelected}
>
Delete
</Button>
</div>
</ActionBar>
</section>
)
}
Data Grid 09
Preview
npx nateui@latest add DataGridExpenseApprovalsDark
import { useMemo, useState } from 'react'
import Avatar from '@/components/ui/Avatar'
import Button from '@/components/ui/Button'
import Dropdown from '@/components/ui/Dropdown'
import Input from '@/components/ui/Input'
import Table from '@/components/ui/Table'
import {
PiCaretDown,
PiCheck,
PiCheckCircleFill,
PiClockCountdownFill,
PiExport,
PiMagnifyingGlass,
PiDotsThreeVerticalBold,
PiReceiptFill,
} from 'react-icons/pi'
const assetBase = 'https://statics.nateui.com/img'
type ApprovalStatus = 'Needs receipt' | 'Pending approval' | 'Approved'
type ExpenseCategory = 'Software' | 'Travel' | 'Office' | 'Support' | 'Fees'
type StatusFilter = ApprovalStatus | 'All'
type CategoryFilter = ExpenseCategory | 'All'
type DateRangeFilter = 'Last 30 days' | 'Last 90 days' | 'This year'
type SortKey = 'merchant' | 'amount' | 'submitted'
type SortOrder = 'asc' | 'desc'
type ExpenseRow = {
id: string
merchant: string
logo: string
category: ExpenseCategory
status: ApprovalStatus
amount: number
submitted: string
submittedAt: string
submitter: string
submitterEmail: string
submitterAvatar: string
report: string
}
const expenseRows: ExpenseRow[] = [
{
id: 'expense-2481',
merchant: 'Adobe',
logo: `${assetBase}/thumbs/brands/adobe.png`,
category: 'Software',
status: 'Needs receipt',
amount: 89,
submitted: 'Aug 18, 2026',
submittedAt: '2026-08-18',
submitter: 'Dana Whitfield',
submitterEmail: 'dana.whitfield@example.com',
submitterAvatar: `${assetBase}/avatars/thumb-1.jpg`,
report: 'EXP-2481',
},
{
id: 'expense-2475',
merchant: 'Dropbox',
logo: `${assetBase}/thumbs/brands/dropbox.png`,
category: 'Software',
status: 'Needs receipt',
amount: 60,
submitted: 'Aug 17, 2026',
submittedAt: '2026-08-17',
submitter: 'Priya Raghavan',
submitterEmail: 'priya.r@example.com',
submitterAvatar: `${assetBase}/avatars/thumb-3.jpg`,
report: 'EXP-2475',
},
{
id: 'expense-2461',
merchant: 'Airbnb',
logo: `${assetBase}/thumbs/brands/airbnb.png`,
category: 'Travel',
status: 'Pending approval',
amount: 2480,
submitted: 'Aug 14, 2026',
submittedAt: '2026-08-14',
submitter: 'Dana Whitfield',
submitterEmail: 'dana.whitfield@example.com',
submitterAvatar: `${assetBase}/avatars/thumb-1.jpg`,
report: 'EXP-2461',
},
{
id: 'expense-2458',
merchant: 'Shopify',
logo: `${assetBase}/thumbs/brands/shopify.png`,
category: 'Software',
status: 'Pending approval',
amount: 79,
submitted: 'Aug 14, 2026',
submittedAt: '2026-08-14',
submitter: 'Marcus Ferreira',
submitterEmail: 'marcus.f@example.com',
submitterAvatar: `${assetBase}/avatars/thumb-2.jpg`,
report: 'EXP-2458',
},
{
id: 'expense-2452',
merchant: 'Notion',
logo: `${assetBase}/thumbs/brands/notion.png`,
category: 'Software',
status: 'Pending approval',
amount: 192,
submitted: 'Aug 12, 2026',
submittedAt: '2026-08-12',
submitter: 'Priya Raghavan',
submitterEmail: 'priya.r@example.com',
submitterAvatar: `${assetBase}/avatars/thumb-3.jpg`,
report: 'EXP-2452',
},
{
id: 'expense-2447',
merchant: 'Figma',
logo: `${assetBase}/thumbs/brands/figma.png`,
category: 'Software',
status: 'Approved',
amount: 540,
submitted: 'Aug 11, 2026',
submittedAt: '2026-08-11',
submitter: 'Tomas Lindqvist',
submitterEmail: 'tomas.l@example.com',
submitterAvatar: `${assetBase}/avatars/thumb-4.jpg`,
report: 'EXP-2447',
},
{
id: 'expense-2441',
merchant: 'GitHub',
logo: `${assetBase}/thumbs/brands/github.png`,
category: 'Software',
status: 'Approved',
amount: 210,
submitted: 'Aug 10, 2026',
submittedAt: '2026-08-10',
submitter: 'Renee Okafor',
submitterEmail: 'renee.okafor@example.com',
submitterAvatar: `${assetBase}/avatars/thumb-5.jpg`,
report: 'EXP-2441',
},
{
id: 'expense-2436',
merchant: 'Amazon',
logo: `${assetBase}/thumbs/brands/amazon.png`,
category: 'Office',
status: 'Approved',
amount: 436.5,
submitted: 'Aug 08, 2026',
submittedAt: '2026-08-08',
submitter: 'Dana Whitfield',
submitterEmail: 'dana.whitfield@example.com',
submitterAvatar: `${assetBase}/avatars/thumb-1.jpg`,
report: 'EXP-2436',
},
{
id: 'expense-2430',
merchant: 'Stripe',
logo: `${assetBase}/thumbs/brands/stripe.png`,
category: 'Fees',
status: 'Approved',
amount: 1018.2,
submitted: 'Aug 07, 2026',
submittedAt: '2026-08-07',
submitter: 'Marcus Ferreira',
submitterEmail: 'marcus.f@example.com',
submitterAvatar: `${assetBase}/avatars/thumb-2.jpg`,
report: 'EXP-2430',
},
]
const statusOptions: StatusFilter[] = [
'All',
'Needs receipt',
'Pending approval',
'Approved',
]
const categoryOptions: CategoryFilter[] = [
'All',
'Software',
'Travel',
'Office',
'Support',
'Fees',
]
const dateRangeOptions: DateRangeFilter[] = [
'Last 30 days',
'Last 90 days',
'This year',
]
const dateRangeCutoffs: Record<DateRangeFilter, string> = {
'Last 30 days': '2026-07-23',
'Last 90 days': '2026-05-24',
'This year': '2026-01-01',
}
const currencyFormatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
})
const groupDefinitions = [
{ key: 'Needs receipt', icon: PiReceiptFill, iconClassName: 'bg-palette-yellow' },
{
key: 'Pending approval',
icon: PiClockCountdownFill,
iconClassName: 'bg-palette-purple',
},
{ key: 'Approved', icon: PiCheckCircleFill, iconClassName: 'bg-palette-emerald' },
] as const
const compareRows = (
rows: ExpenseRow[],
sortKey: SortKey | null,
sortOrder: SortOrder | null,
) => {
if (!sortKey || !sortOrder) {
return rows
}
return [...rows].sort((left, right) => {
const comparison =
sortKey === 'amount'
? left.amount - right.amount
: sortKey === 'submitted'
? left.submittedAt.localeCompare(right.submittedAt)
: left.merchant.localeCompare(right.merchant)
return sortOrder === 'asc' ? comparison : -comparison
})
}
export default function DataGridExpenseApprovals() {
const [searchQuery, setSearchQuery] = useState('')
const [statusFilter, setStatusFilter] = useState<StatusFilter>('All')
const [categoryFilter, setCategoryFilter] =
useState<CategoryFilter>('All')
const [dateRange, setDateRange] =
useState<DateRangeFilter>('Last 30 days')
const [sortKey, setSortKey] = useState<SortKey | null>(null)
const [sortOrder, setSortOrder] = useState<SortOrder | null>(null)
const filteredRows = useMemo(() => {
const normalizedQuery = searchQuery.trim().toLowerCase()
const dateCutoff = dateRangeCutoffs[dateRange]
return expenseRows.filter((row) => {
const matchesSearch =
normalizedQuery.length === 0 ||
row.merchant.toLowerCase().includes(normalizedQuery) ||
row.report.toLowerCase().includes(normalizedQuery)
const matchesStatus =
statusFilter === 'All' || row.status === statusFilter
const matchesCategory =
categoryFilter === 'All' || row.category === categoryFilter
const matchesDate = row.submittedAt >= dateCutoff
return (
matchesSearch &&
matchesStatus &&
matchesCategory &&
matchesDate
)
})
}, [categoryFilter, dateRange, searchQuery, statusFilter])
const groupedRows = useMemo(
() =>
groupDefinitions.map((group) => ({
...group,
rows: compareRows(
filteredRows.filter((row) => row.status === group.key),
sortKey,
sortOrder,
),
})),
[filteredRows, sortKey, sortOrder],
)
const handleSort = (nextSortKey: SortKey) => {
if (sortKey !== nextSortKey) {
setSortKey(nextSortKey)
setSortOrder('asc')
return
}
if (sortOrder === 'asc') {
setSortOrder('desc')
return
}
setSortKey(null)
setSortOrder(null)
}
const getAriaSort = (key: SortKey): 'ascending' | 'descending' | 'none' =>
sortKey === key && sortOrder
? sortOrder === 'asc'
? 'ascending'
: 'descending'
: 'none'
const renderSortableHeader = (label: string, key: SortKey) => (
<button
type="button"
className="flex items-center gap-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
onClick={() => handleSort(key)}
>
<span>{label}</span>
<Table.Sorter
sort={sortKey === key && sortOrder ? sortOrder : false}
/>
</button>
)
return (
<section
aria-labelledby="data-grid-expense-approvals-title"
className="w-full"
>
<div>
<div className="p-4">
<h4
id="data-grid-expense-approvals-title"
className="font-semibold"
>
Expense Approvals
</h4>
<div className="mt-4 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<Input
aria-label="Search expenses by merchant or report"
className="w-full md:w-80"
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search merchant or report"
prefix={
<PiMagnifyingGlass className="text-muted-foreground" />
}
value={searchQuery}
/>
<div className="flex flex-wrap items-center gap-2">
<Dropdown
placement="bottom-end"
renderTitle={
<Button
icon={<PiCaretDown aria-hidden="true" />}
iconAlignment="end"
>
{statusFilter === 'All'
? 'Status'
: statusFilter}
</Button>
}
>
{statusOptions.map((option) => (
<Dropdown.Item
active={statusFilter === option}
eventKey={option}
key={option}
onSelect={() => setStatusFilter(option)}
>
<span className="flex w-full items-center justify-between">
<span>{option}</span>
{statusFilter === option && (
<PiCheck
aria-hidden="true"
className="shrink-0 text-base"
/>
)}
</span>
</Dropdown.Item>
))}
</Dropdown>
<Dropdown
placement="bottom-end"
renderTitle={
<Button
icon={<PiCaretDown aria-hidden="true" />}
iconAlignment="end"
>
{categoryFilter === 'All'
? 'Category'
: categoryFilter}
</Button>
}
>
{categoryOptions.map((option) => (
<Dropdown.Item
active={categoryFilter === option}
eventKey={option}
key={option}
onSelect={() => setCategoryFilter(option)}
>
<span className="flex w-full items-center justify-between">
<span>{option}</span>
{categoryFilter === option && (
<PiCheck
aria-hidden="true"
className="shrink-0 text-base"
/>
)}
</span>
</Dropdown.Item>
))}
</Dropdown>
<Dropdown
placement="bottom-end"
renderTitle={
<Button
icon={<PiCaretDown aria-hidden="true" />}
iconAlignment="end"
>
{dateRange}
</Button>
}
>
{dateRangeOptions.map((option) => (
<Dropdown.Item
active={dateRange === option}
eventKey={option}
key={option}
onSelect={() => setDateRange(option)}
>
<span className="flex w-full items-center justify-between">
<span>{option}</span>
{dateRange === option && (
<PiCheck
aria-hidden="true"
className="shrink-0 text-base"
/>
)}
</span>
</Dropdown.Item>
))}
</Dropdown>
<Button
icon={<PiExport aria-hidden="true" />}
iconAlignment="start"
>
Export
</Button>
</div>
</div>
</div>
<div className="overflow-hidden">
<Table
className="border-t border-b"
verticalDivider={{
body: true,
head: true
}}
>
<Table.THead>
<Table.Tr className="bg-transparent">
<Table.Th
scope="col"
className="whitespace-nowrap text-foreground"
aria-sort={getAriaSort('merchant')}
>
{renderSortableHeader('MERCHANT', 'merchant')}
</Table.Th>
<Table.Th
scope="col"
className="whitespace-nowrap text-foreground"
aria-sort={getAriaSort('amount')}
>
{renderSortableHeader('AMOUNT', 'amount')}
</Table.Th>
<Table.Th
scope="col"
className="whitespace-nowrap text-foreground"
aria-sort={getAriaSort('submitted')}
>
{renderSortableHeader('SUBMITTED', 'submitted')}
</Table.Th>
<Table.Th scope="col" className="whitespace-nowrap text-foreground">
Submitted by
</Table.Th>
<Table.Th scope="col" className="whitespace-nowrap text-foreground">
Report
</Table.Th>
<Table.Th scope="col" className="whitespace-nowrap text-foreground">
<span className="sr-only">Actions</span>
</Table.Th>
</Table.Tr>
</Table.THead>
{groupedRows.map((group) => {
const GroupIcon = group.icon
return (
<Table.TBody key={group.key}>
<Table.Tr className="border-b bg-muted">
<Table.Td colSpan={6} className="py-2">
<span className="flex items-center gap-2">
<span className={`min-w-0 size-4 flex items-center justify-center rounded ${group.iconClassName}`}>
<GroupIcon
aria-hidden="true"
className="text-xs text-white"
/>
</span>
<span className="font-medium">
{group.key}
</span>
<span className="text-muted-foreground">
{group.rows.length}
</span>
</span>
</Table.Td>
</Table.Tr>
{group.rows.map((row) => (
<Table.Tr key={row.id}>
<Table.Td className="whitespace-nowrap">
<div className="flex min-w-0 items-center gap-4">
<div>
<img
alt=""
className="size-6 object-contain"
src={row.logo}
/>
</div>
<span className="min-w-0">
<span className="block truncate font-medium">
{row.merchant}
</span>
<span className="block truncate text-muted-foreground">
{row.category}
</span>
</span>
</div>
</Table.Td>
<Table.Td className="whitespace-nowrap">
<span className="font-medium">
{currencyFormatter.format(row.amount)}
</span>
</Table.Td>
<Table.Td className="whitespace-nowrap">
{row.submitted}
</Table.Td>
<Table.Td className="whitespace-nowrap">
<div className="flex items-center gap-2">
<Avatar
alt={row.submitter}
size={32}
src={row.submitterAvatar}
/>
<span className="min-w-0">
<span className="block truncate font-medium">
{row.submitter}
</span>
<span className="block truncate text-muted-foreground">
{row.submitterEmail}
</span>
</span>
</div>
</Table.Td>
<Table.Td className="whitespace-nowrap">
{row.report}
</Table.Td>
<Table.Td className="whitespace-nowrap">
<div className="flex justify-end">
<Dropdown
placement="bottom-end"
renderTitle={
<Button
aria-label={`More actions for ${row.report}`}
icon={
<PiDotsThreeVerticalBold aria-hidden="true" />
}
shape="circle"
size="sm"
variant="ghost"
/>
}
>
{row.status === 'Needs receipt' ? (
<>
<Dropdown.Item eventKey="add-receipt">
Add receipt
</Dropdown.Item>
<Dropdown.Item
className="text-destructive"
eventKey="discard"
>
Discard
</Dropdown.Item>
</>
) : row.status === 'Pending approval' ? (
<>
<Dropdown.Item eventKey="approve">
Approve
</Dropdown.Item>
<Dropdown.Item
className="text-destructive"
eventKey="reject"
>
Reject
</Dropdown.Item>
</>
) : (
<>
<Dropdown.Item eventKey="view-receipt">
View receipt
</Dropdown.Item>
<Dropdown.Item eventKey="download-pdf">
Download PDF
</Dropdown.Item>
<Dropdown.Item
eventKey="approved-divider"
variant="divider"
/>
<Dropdown.Item eventKey="reopen">
Reopen
</Dropdown.Item>
</>
)}
</Dropdown>
</div>
</Table.Td>
</Table.Tr>
))}
</Table.TBody>
)
})}
</Table>
</div>
</div>
</section>
)
}
Data Grid 10
Preview
npx nateui@latest add DataGridShipmentInlineEditDark
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import Button from '@/components/ui/Button'
import Calendar from '@/components/ui/Calendar'
import Checkbox from '@/components/ui/Checkbox'
import Dropdown from '@/components/ui/Dropdown'
import Input from '@/components/ui/Input'
import Pagination from '@/components/ui/Pagination'
import Popover from '@/components/ui/Popover'
import Table from '@/components/ui/Table'
import Tag from '@/components/ui/Tag'
import {
PiArchive,
PiCalendarBlank,
PiCheck,
PiCircleDashed,
PiCopy,
PiCube,
PiDotsThreeVertical,
PiEye,
PiExport,
PiMagnifyingGlass,
PiMapPin,
PiPackage,
PiPencilSimple,
PiPlus,
PiScales,
PiTrash,
PiTruck,
} from 'react-icons/pi'
import type { ComponentType, KeyboardEvent } from 'react'
type ShipmentStatus = 'In transit' | 'Delivered' | 'Customs hold' | 'Delayed'
type DateFilter = 'All dates' | 'Next 7 days' | 'Next 14 days'
type SortKey =
| 'shipmentId'
| 'origin'
| 'destination'
| 'carrier'
| 'status'
| 'eta'
| 'weight'
type SortOrder = 'asc' | 'desc'
type EditableField = 'shipmentId' | 'origin' | 'destination' | 'weight'
type ActiveColumn = EditableField | 'carrier' | 'status' | 'eta'
type ActiveCell = { rowId: string; columnKey: ActiveColumn } | null
type ShipmentRow = {
id: string
shipmentId: string
origin: string
destination: string
carrier: Carrier
status: ShipmentStatus
eta: Date
weight: string
}
type HeaderIcon = ComponentType<{
className?: string
'aria-hidden'?: boolean
}>
const assetBase = 'https://statics.nateui.com/img'
const carrierOptions = ['DHL', 'Evri', 'FedEx', 'UPS'] as const
type Carrier = (typeof carrierOptions)[number]
const carrierAssets: Record<Carrier, string> = {
DHL: 'dhl.png',
Evri: 'evri.png',
FedEx: 'fedex.png',
UPS: 'ups.png',
}
const citySuggestions = [
'Auckland',
'Brno',
'Cork',
'Dakar',
'Genoa',
'Hobart',
'Izmir',
'Kyoto',
'Malmo',
'Osaka',
'Pune',
'Turin',
'Reykjavik',
'Lisbon',
'Wellington',
'Warsaw',
'Nairobi',
'Prague',
'Quito',
'Tallinn',
'Valencia',
'Helsinki',
'Bratislava',
'Marrakesh',
'Bergen',
'Zagreb',
'Sapporo',
'Dublin',
'Lima',
'Riga',
'Adelaide',
'Vienna',
'Tbilisi',
'Aarhus',
'Kigali',
'Ljubljana',
]
const getCitySuggestions = (query: string) => {
const normalizedQuery = query.trim().toLowerCase()
if (normalizedQuery.length === 0) return []
return citySuggestions.filter((city) =>
city.toLowerCase().includes(normalizedQuery),
)
}
const shipmentRows: ShipmentRow[] = [
{
id: 'shipment-1001',
shipmentId: 'SHP-73014',
origin: 'Reykjavik',
destination: 'Lisbon',
carrier: 'DHL',
status: 'In transit',
eta: new Date(2026, 7, 19),
weight: '18.4 kg',
},
{
id: 'shipment-1002',
shipmentId: 'SHP-73029',
origin: 'Wellington',
destination: 'Warsaw',
carrier: 'Evri',
status: 'Delivered',
eta: new Date(2026, 7, 20),
weight: '42.8 kg',
},
{
id: 'shipment-1003',
shipmentId: 'SHP-73041',
origin: 'Nairobi',
destination: 'Prague',
carrier: 'FedEx',
status: 'Customs hold',
eta: new Date(2026, 7, 21),
weight: '7.6 kg',
},
{
id: 'shipment-1004',
shipmentId: 'SHP-73056',
origin: 'Quito',
destination: 'Tallinn',
carrier: 'UPS',
status: 'Delivered',
eta: new Date(2026, 7, 22),
weight: '63.2 kg',
},
{
id: 'shipment-1005',
shipmentId: 'SHP-73068',
origin: 'Valencia',
destination: 'Helsinki',
carrier: 'DHL',
status: 'In transit',
eta: new Date(2026, 7, 23),
weight: '11.9 kg',
},
{
id: 'shipment-1006',
shipmentId: 'SHP-73077',
origin: 'Bratislava',
destination: 'Marrakesh',
carrier: 'Evri',
status: 'Customs hold',
eta: new Date(2026, 7, 24),
weight: '28.7 kg',
},
{
id: 'shipment-1007',
shipmentId: 'SHP-73089',
origin: 'Bergen',
destination: 'Zagreb',
carrier: 'FedEx',
status: 'Delayed',
eta: new Date(2026, 7, 25),
weight: '9.3 kg',
},
{
id: 'shipment-1008',
shipmentId: 'SHP-73102',
origin: 'Sapporo',
destination: 'Dublin',
carrier: 'UPS',
status: 'Delivered',
eta: new Date(2026, 7, 26),
weight: '54.1 kg',
},
{
id: 'shipment-1009',
shipmentId: 'SHP-73118',
origin: 'Lima',
destination: 'Riga',
carrier: 'DHL',
status: 'In transit',
eta: new Date(2026, 7, 27),
weight: '36.5 kg',
},
{
id: 'shipment-1010',
shipmentId: 'SHP-73126',
origin: 'Adelaide',
destination: 'Vienna',
carrier: 'Evri',
status: 'Delivered',
eta: new Date(2026, 7, 29),
weight: '81.4 kg',
},
{
id: 'shipment-1011',
shipmentId: 'SHP-73139',
origin: 'Tbilisi',
destination: 'Aarhus',
carrier: 'FedEx',
status: 'Delayed',
eta: new Date(2026, 8, 1),
weight: '15.8 kg',
},
{
id: 'shipment-1012',
shipmentId: 'SHP-73144',
origin: 'Kigali',
destination: 'Ljubljana',
carrier: 'UPS',
status: 'In transit',
eta: new Date(2026, 8, 4),
weight: '24.6 kg',
},
{
id: 'shipment-1013',
shipmentId: 'SHP-73155',
origin: 'Auckland',
destination: 'Brno',
carrier: 'DHL',
status: 'In transit',
eta: new Date(2026, 7, 28),
weight: '22.1 kg',
},
{
id: 'shipment-1014',
shipmentId: 'SHP-73163',
origin: 'Cork',
destination: 'Dakar',
carrier: 'Evri',
status: 'Delivered',
eta: new Date(2026, 7, 30),
weight: '31.6 kg',
},
{
id: 'shipment-1015',
shipmentId: 'SHP-73171',
origin: 'Genoa',
destination: 'Hobart',
carrier: 'FedEx',
status: 'Customs hold',
eta: new Date(2026, 8, 2),
weight: '13.8 kg',
},
{
id: 'shipment-1016',
shipmentId: 'SHP-73186',
origin: 'Izmir',
destination: 'Kyoto',
carrier: 'UPS',
status: 'Delayed',
eta: new Date(2026, 8, 3),
weight: '46.2 kg',
},
{
id: 'shipment-1017',
shipmentId: 'SHP-73194',
origin: 'Malmo',
destination: 'Osaka',
carrier: 'DHL',
status: 'In transit',
eta: new Date(2026, 8, 5),
weight: '19.7 kg',
},
{
id: 'shipment-1018',
shipmentId: 'SHP-73203',
origin: 'Pune',
destination: 'Turin',
carrier: 'Evri',
status: 'Delivered',
eta: new Date(2026, 8, 6),
weight: '58.3 kg',
},
{
id: 'shipment-1019',
shipmentId: 'SHP-73211',
origin: 'Reykjavik',
destination: 'Wellington',
carrier: 'FedEx',
status: 'Customs hold',
eta: new Date(2026, 8, 7),
weight: '8.4 kg',
},
{
id: 'shipment-1020',
shipmentId: 'SHP-73224',
origin: 'Lisbon',
destination: 'Nairobi',
carrier: 'UPS',
status: 'Delayed',
eta: new Date(2026, 8, 8),
weight: '67.5 kg',
},
{
id: 'shipment-1021',
shipmentId: 'SHP-73231',
origin: 'Warsaw',
destination: 'Quito',
carrier: 'DHL',
status: 'In transit',
eta: new Date(2026, 8, 9),
weight: '27.3 kg',
},
{
id: 'shipment-1022',
shipmentId: 'SHP-73245',
origin: 'Prague',
destination: 'Valencia',
carrier: 'Evri',
status: 'Delivered',
eta: new Date(2026, 8, 10),
weight: '44.9 kg',
},
{
id: 'shipment-1023',
shipmentId: 'SHP-73252',
origin: 'Tallinn',
destination: 'Helsinki',
carrier: 'FedEx',
status: 'Customs hold',
eta: new Date(2026, 8, 11),
weight: '12.6 kg',
},
{
id: 'shipment-1024',
shipmentId: 'SHP-73268',
origin: 'Marrakesh',
destination: 'Bergen',
carrier: 'UPS',
status: 'Delayed',
eta: new Date(2026, 8, 12),
weight: '39.2 kg',
},
{
id: 'shipment-1025',
shipmentId: 'SHP-73274',
origin: 'Zagreb',
destination: 'Sapporo',
carrier: 'DHL',
status: 'In transit',
eta: new Date(2026, 8, 13),
weight: '16.5 kg',
},
{
id: 'shipment-1026',
shipmentId: 'SHP-73289',
origin: 'Dublin',
destination: 'Lima',
carrier: 'Evri',
status: 'Delivered',
eta: new Date(2026, 8, 14),
weight: '72.8 kg',
},
{
id: 'shipment-1027',
shipmentId: 'SHP-73296',
origin: 'Riga',
destination: 'Adelaide',
carrier: 'FedEx',
status: 'Customs hold',
eta: new Date(2026, 8, 15),
weight: '6.9 kg',
},
{
id: 'shipment-1028',
shipmentId: 'SHP-73305',
origin: 'Vienna',
destination: 'Tbilisi',
carrier: 'UPS',
status: 'Delayed',
eta: new Date(2026, 8, 16),
weight: '51.4 kg',
},
{
id: 'shipment-1029',
shipmentId: 'SHP-73317',
origin: 'Aarhus',
destination: 'Kigali',
carrier: 'DHL',
status: 'In transit',
eta: new Date(2026, 8, 17),
weight: '25.7 kg',
},
{
id: 'shipment-1030',
shipmentId: 'SHP-73328',
origin: 'Ljubljana',
destination: 'Auckland',
carrier: 'Evri',
status: 'Delivered',
eta: new Date(2026, 8, 18),
weight: '33.1 kg',
},
{
id: 'shipment-1031',
shipmentId: 'SHP-73334',
origin: 'Brno',
destination: 'Cork',
carrier: 'FedEx',
status: 'Customs hold',
eta: new Date(2026, 8, 19),
weight: '10.2 kg',
},
{
id: 'shipment-1032',
shipmentId: 'SHP-73349',
origin: 'Dakar',
destination: 'Genoa',
carrier: 'UPS',
status: 'Delayed',
eta: new Date(2026, 8, 20),
weight: '60.6 kg',
},
{
id: 'shipment-1033',
shipmentId: 'SHP-73356',
origin: 'Hobart',
destination: 'Izmir',
carrier: 'DHL',
status: 'In transit',
eta: new Date(2026, 8, 21),
weight: '14.8 kg',
},
{
id: 'shipment-1034',
shipmentId: 'SHP-73361',
origin: 'Kyoto',
destination: 'Malmo',
carrier: 'Evri',
status: 'Delivered',
eta: new Date(2026, 8, 22),
weight: '48.5 kg',
},
{
id: 'shipment-1035',
shipmentId: 'SHP-73377',
origin: 'Osaka',
destination: 'Pune',
carrier: 'FedEx',
status: 'Customs hold',
eta: new Date(2026, 8, 23),
weight: '21.9 kg',
},
{
id: 'shipment-1036',
shipmentId: 'SHP-73388',
origin: 'Turin',
destination: 'Reykjavik',
carrier: 'UPS',
status: 'Delayed',
eta: new Date(2026, 8, 24),
weight: '74.2 kg',
},
{
id: 'shipment-1037',
shipmentId: 'SHP-73395',
origin: 'Wellington',
destination: 'Lisbon',
carrier: 'DHL',
status: 'In transit',
eta: new Date(2026, 8, 25),
weight: '29.4 kg',
},
{
id: 'shipment-1038',
shipmentId: 'SHP-73402',
origin: 'Nairobi',
destination: 'Warsaw',
carrier: 'Evri',
status: 'Delivered',
eta: new Date(2026, 8, 26),
weight: '56.7 kg',
},
{
id: 'shipment-1039',
shipmentId: 'SHP-73418',
origin: 'Quito',
destination: 'Prague',
carrier: 'FedEx',
status: 'Customs hold',
eta: new Date(2026, 8, 27),
weight: '17.2 kg',
},
{
id: 'shipment-1040',
shipmentId: 'SHP-73427',
origin: 'Valencia',
destination: 'Tallinn',
carrier: 'UPS',
status: 'Delayed',
eta: new Date(2026, 8, 28),
weight: '41.8 kg',
},
]
const statusOptions: ShipmentStatus[] = [
'In transit',
'Delivered',
'Customs hold',
'Delayed',
]
const dateFilterOptions: DateFilter[] = [
'All dates',
'Next 7 days',
'Next 14 days',
]
const filterAnchor = new Date(2026, 7, 18)
const initialSelectedIds = new Set<string>()
const statusDotClasses: Record<ShipmentStatus, string> = {
'In transit': 'bg-info',
Delivered: 'bg-success',
'Customs hold': 'bg-warning',
Delayed: 'bg-destructive',
}
const formatDate = (value: Date) =>
new Intl.DateTimeFormat('en-US', {
day: '2-digit',
month: 'short',
year: 'numeric',
}).format(value)
const compareRows = (
rows: ShipmentRow[],
sortKey: SortKey | null,
sortOrder: SortOrder | null,
) => {
if (!sortKey || !sortOrder) return rows
return [...rows].sort((left, right) => {
let comparison = 0
if (sortKey === 'eta') {
comparison = left.eta.getTime() - right.eta.getTime()
} else if (sortKey === 'weight') {
comparison =
Number.parseFloat(left.weight) - Number.parseFloat(right.weight)
} else {
comparison = left[sortKey].localeCompare(right[sortKey])
}
return sortOrder === 'asc' ? comparison : -comparison
})
}
const matchesDateFilter = (date: Date, filter: DateFilter) => {
if (filter === 'All dates') return true
const days = filter === 'Next 7 days' ? 7 : 14
const cutoff = new Date(filterAnchor)
cutoff.setDate(cutoff.getDate() + days)
return date >= filterAnchor && date <= cutoff
}
const getEditableValue = (row: ShipmentRow, field: EditableField) =>
row[field]
const updateEditableValue = (
row: ShipmentRow,
field: EditableField,
value: string,
) => ({ ...row, [field]: value })
export default function DataGridShipmentInlineEdit() {
const pageSize = 12
const [rows, setRows] = useState<ShipmentRow[]>(shipmentRows)
const [searchQuery, setSearchQuery] = useState('')
const [selectedIds, setSelectedIds] = useState<Set<string>>(
initialSelectedIds,
)
const [dateFilter, setDateFilter] = useState<DateFilter>('All dates')
const [statusFilter, setStatusFilter] = useState<ShipmentStatus | 'All'>(
'All',
)
const [sortKey, setSortKey] = useState<SortKey | null>(null)
const [sortOrder, setSortOrder] = useState<SortOrder | null>(null)
const [editingCell, setEditingCell] = useState<{
rowId: string
field: EditableField
} | null>(null)
const [draftValue, setDraftValue] = useState('')
const [openDateCell, setOpenDateCell] = useState<string | null>(null)
const [activeCell, setActiveCell] = useState<ActiveCell>(null)
const [openDropdownCell, setOpenDropdownCell] =
useState<ActiveCell>(null)
const [autocompleteOpen, setAutocompleteOpen] = useState(false)
const [suggestionHighlight, setSuggestionHighlight] = useState(0)
const [autocompleteQuery, setAutocompleteQuery] = useState('')
const autocompleteInputRef = useRef<HTMLInputElement>(null)
const suggestionRefs = useRef<Array<HTMLDivElement | null>>([])
const [pageIndex, setPageIndex] = useState(1)
const filteredRows = useMemo(() => {
const normalizedQuery = searchQuery.trim().toLowerCase()
return rows.filter((row) => {
const matchesSearch =
normalizedQuery.length === 0 ||
[
row.shipmentId,
row.origin,
row.destination,
row.carrier,
].some((value) => value.toLowerCase().includes(normalizedQuery))
const matchesStatus =
statusFilter === 'All' || row.status === statusFilter
return (
matchesSearch &&
matchesStatus &&
matchesDateFilter(row.eta, dateFilter)
)
})
}, [dateFilter, rows, searchQuery, statusFilter])
const visibleRows = useMemo(
() => compareRows(filteredRows, sortKey, sortOrder),
[filteredRows, sortKey, sortOrder],
)
const pageCount = Math.max(1, Math.ceil(visibleRows.length / pageSize))
const pagedRows = useMemo(
() =>
visibleRows.slice(
(pageIndex - 1) * pageSize,
pageIndex * pageSize,
),
[pageIndex, visibleRows],
)
useEffect(() => {
setPageIndex(1)
}, [dateFilter, searchQuery, statusFilter])
useEffect(() => {
setPageIndex((current) => Math.min(current, pageCount))
}, [pageCount])
useLayoutEffect(() => {
if (!autocompleteOpen) return
const revealTimer = window.setTimeout(() => {
autocompleteInputRef.current?.focus()
if (!editingCell) return
const suggestionListId = `suggestions-${editingCell.rowId}-${editingCell.field}`
const popover = document
.getElementById(suggestionListId)
?.closest('.popover')
if (popover instanceof HTMLElement) {
popover.style.transition = 'none'
popover.style.opacity = '1'
popover.style.transform = 'none'
}
}, 0)
return () => window.clearTimeout(revealTimer)
}, [autocompleteOpen, editingCell])
const visibleSelectedCount = pagedRows.filter((row) =>
selectedIds.has(row.id),
).length
const allVisibleSelected =
pagedRows.length > 0 && visibleSelectedCount === pagedRows.length
const someVisibleSelected =
visibleSelectedCount > 0 && !allVisibleSelected
const handleSort = (nextSortKey: SortKey) => {
if (sortKey !== nextSortKey) {
setSortKey(nextSortKey)
setSortOrder('asc')
return
}
if (sortOrder === 'asc') {
setSortOrder('desc')
return
}
setSortKey(null)
setSortOrder(null)
}
const startEditing = (row: ShipmentRow, field: EditableField) => {
setEditingCell({ rowId: row.id, field })
setDraftValue(getEditableValue(row, field))
setActiveCell({ rowId: row.id, columnKey: field })
setAutocompleteQuery('')
suggestionRefs.current = []
setSuggestionHighlight(
field === 'origin' || field === 'destination'
? Math.max(0, citySuggestions.indexOf(getEditableValue(row, field)))
: 0,
)
setAutocompleteOpen(false)
}
const commitEditing = (nextValue = draftValue) => {
if (!editingCell) return
setRows((current) =>
current.map((row) =>
row.id === editingCell.rowId
? updateEditableValue(
row,
editingCell.field,
nextValue.trim(),
)
: row,
),
)
setEditingCell(null)
setAutocompleteOpen(false)
setAutocompleteQuery('')
setActiveCell(null)
}
const cancelEditing = () => {
setEditingCell(null)
setAutocompleteOpen(false)
setAutocompleteQuery('')
setActiveCell(null)
}
const toggleRowSelection = (rowId: string, checked: boolean) => {
setSelectedIds((current) => {
const next = new Set(current)
if (checked) next.add(rowId)
else next.delete(rowId)
return next
})
}
const toggleAllVisible = (checked: boolean) => {
setSelectedIds((current) => {
const next = new Set(current)
pagedRows.forEach((row) => {
if (checked) next.add(row.id)
else next.delete(row.id)
})
return next
})
}
const renderSortableHeader = (
label: string,
key: SortKey,
Icon: HeaderIcon,
) => (
<button
type="button"
className="flex h-full w-full min-w-0 items-center gap-2 px-4 text-left uppercase outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary"
onClick={() => handleSort(key)}
>
<span className="flex items-center gap-2">
<Icon
aria-hidden={true}
className="shrink-0 text-base text-muted-foreground"
/>
<span className="min-w-0 truncate">{label}</span>
</span>
<Table.Sorter
sort={sortKey === key && sortOrder ? sortOrder : false}
/>
</button>
)
const renderTextCell = (row: ShipmentRow, field: EditableField) => {
const isEditing =
editingCell?.rowId === row.id && editingCell.field === field
const isAutocompleteField = field === 'origin' || field === 'destination'
const suggestions = isAutocompleteField
? getCitySuggestions(autocompleteQuery)
: []
const suggestionListId = `suggestions-${row.id}-${field}`
const isAutocompleteOpen = autocompleteOpen
const activeSuggestionId =
isAutocompleteOpen && suggestions[suggestionHighlight]
? `${suggestionListId}-${suggestionHighlight}`
: undefined
const activeClass =
activeCell?.rowId === row.id && activeCell.columnKey === field
? 'ring-2 ring-inset ring-primary'
: ''
if (isEditing) {
return (
<Table.Td className="h-12 p-0">
{isAutocompleteField ? (
<Popover
open={isAutocompleteOpen}
onOpenChange={(open) => {
if (
!open &&
editingCell?.rowId === row.id &&
editingCell.field === field
) {
return
}
setAutocompleteOpen(open)
if (!open) setActiveCell(null)
}}
placement="bottom-start"
className="max-h-80 overflow-y-auto px-1 py-2"
renderTrigger={
<div
className={`relative flex h-full w-full min-w-0 items-center px-4 hover:bg-muted ${activeClass}`}
onMouseDown={() =>
setActiveCell({
rowId: row.id,
columnKey: field,
})
}
>
<input
ref={autocompleteInputRef}
aria-activedescendant={activeSuggestionId}
aria-autocomplete="list"
aria-controls={
isAutocompleteOpen
? suggestionListId
: undefined
}
aria-expanded={isAutocompleteOpen}
aria-haspopup="listbox"
aria-label={`Edit ${field} for ${row.shipmentId}`}
autoFocus
className="relative z-10 w-full min-w-0 border-0 bg-transparent px-0 outline-none"
onBlur={() => {
setTimeout(() => {
const focused = document.activeElement
const cell = focused?.closest('td')
const listbox = document.getElementById(
suggestionListId,
)
const popover = document.querySelector(
'.popover-wrapper',
)
if (
cell?.contains(focused) ||
listbox?.contains(focused) ||
popover?.contains(focused)
) {
return
}
commitEditing()
}, 0)
}}
onChange={(event) => {
const nextValue = event.target.value
const nextSuggestions =
getCitySuggestions(nextValue)
setDraftValue(nextValue)
setAutocompleteQuery(nextValue)
setSuggestionHighlight(0)
setAutocompleteOpen(
nextSuggestions.length > 0,
)
}}
onClick={() => {
setActiveCell({
rowId: row.id,
columnKey: field,
})
}}
onFocus={() =>
setActiveCell({
rowId: row.id,
columnKey: field,
})
}
onKeyDown={(event) => {
if (
event.key === 'Tab' &&
!event.shiftKey &&
isAutocompleteOpen &&
suggestions.length > 0
) {
const option = suggestionRefs.current[
suggestionHighlight
]
if (option) {
event.preventDefault()
option.focus()
return
}
}
if (
event.key === 'ArrowDown' &&
suggestions.length > 0
) {
event.preventDefault()
setAutocompleteOpen(true)
setSuggestionHighlight((current) =>
Math.min(
current + 1,
suggestions.length - 1,
),
)
}
if (
event.key === 'ArrowUp' &&
suggestions.length > 0
) {
event.preventDefault()
setAutocompleteOpen(true)
setSuggestionHighlight((current) =>
Math.max(current - 1, 0),
)
}
if (event.key === 'Enter') {
event.preventDefault()
commitEditing()
}
if (event.key === 'Escape') {
event.preventDefault()
cancelEditing()
}
}}
role="combobox"
value={draftValue}
/>
</div>
}
>
<div
className="grid"
id={suggestionListId}
role="listbox"
>
{suggestions.map((suggestion, index) => (
<div
aria-selected={index === suggestionHighlight}
className="select-option mx-1 flex items-center rounded-control-sm px-3 py-2 font-medium leading-5 text-left text-popover-foreground whitespace-nowrap outline-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-muted focus-visible:text-foreground"
id={`${suggestionListId}-${index}`}
key={suggestion}
onBlur={() => {
setTimeout(() => {
const focused = document.activeElement
const listbox = document.getElementById(
suggestionListId,
)
if (
focused ===
autocompleteInputRef.current ||
listbox?.contains(focused)
) {
return
}
commitEditing(suggestion)
}, 0)
}}
onFocus={() => setSuggestionHighlight(index)}
onKeyDown={(event) => {
if (event.key === 'ArrowDown') {
event.preventDefault()
const nextIndex = Math.min(
index + 1,
suggestions.length - 1,
)
setSuggestionHighlight(nextIndex)
suggestionRefs.current[
nextIndex
]?.focus()
}
if (event.key === 'ArrowUp') {
event.preventDefault()
const nextIndex = Math.max(index - 1, 0)
setSuggestionHighlight(nextIndex)
suggestionRefs.current[
nextIndex
]?.focus()
}
if (event.key === 'Enter') {
event.preventDefault()
setDraftValue(suggestion)
commitEditing(suggestion)
}
if (event.key === 'Escape') {
event.preventDefault()
cancelEditing()
}
if (event.key === 'Tab') {
event.preventDefault()
if (event.shiftKey) {
autocompleteInputRef.current?.focus()
return
}
const nextControl =
autocompleteInputRef.current
?.closest('td')
?.nextElementSibling?.querySelector<HTMLElement>(
'button, input, [tabindex="0"]',
)
commitEditing(suggestion)
window.requestAnimationFrame(() =>
nextControl?.focus(),
)
}
}}
onMouseDown={(event) => {
event.preventDefault()
setDraftValue(suggestion)
commitEditing(suggestion)
}}
role="option"
ref={(node) => {
suggestionRefs.current[index] = node
}}
tabIndex={-1}
>
{suggestion}
</div>
))}
</div>
</Popover>
) : (
<div
className={`flex h-full w-full min-w-0 items-center px-4 ${activeClass}`}
>
<input
aria-label={`Edit ${field} for ${row.shipmentId}`}
autoFocus
className="w-full min-w-0 border-0 bg-transparent px-0 outline-none"
onBlur={() => commitEditing()}
onChange={(event) => setDraftValue(event.target.value)}
onFocus={() =>
setActiveCell({
rowId: row.id,
columnKey: field,
})
}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
commitEditing()
}
if (event.key === 'Escape') {
event.preventDefault()
cancelEditing()
}
}}
value={draftValue}
/>
</div>
)}
</Table.Td>
)
}
return (
<Table.Td className="h-12 p-0">
<button
type="button"
className={`flex h-full w-full min-w-0 items-center px-4 text-left whitespace-nowrap outline-none hover:bg-muted focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary ${activeClass}`}
onClick={() => startEditing(row, field)}
onFocus={() =>
setActiveCell({ rowId: row.id, columnKey: field })
}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
startEditing(row, field)
}
}}
>
{getEditableValue(row, field)}
</button>
</Table.Td>
)
}
const renderCarrierCell = (row: ShipmentRow) => (
<Table.Td className="h-12 p-0">
<Dropdown
placement="bottom-start"
aria-label={`Change carrier for ${row.shipmentId}`}
role="button"
tabIndex={0}
toggleClassName={`flex h-full w-full min-w-0 items-center px-4 hover:bg-muted focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary ${openDropdownCell?.rowId === row.id && openDropdownCell.columnKey === 'carrier' ? 'ring-2 ring-inset ring-primary' : ''}`}
activeKey={row.carrier}
onOpen={(open: boolean) => {
setOpenDropdownCell((current) => {
if (open) {
return { rowId: row.id, columnKey: 'carrier' }
}
return current?.rowId === row.id &&
current.columnKey === 'carrier'
? null
: current
})
if (open) {
setActiveCell({ rowId: row.id, columnKey: 'carrier' })
}
}}
onSelect={(eventKey) => {
if (!carrierOptions.includes(eventKey as Carrier)) return
setRows((current) =>
current.map((item) =>
item.id === row.id
? { ...item, carrier: eventKey as Carrier }
: item,
),
)
}}
onFocus={() =>
setActiveCell({ rowId: row.id, columnKey: 'carrier' })
}
onClick={() =>
setActiveCell({ rowId: row.id, columnKey: 'carrier' })
}
onKeyDown={(event: KeyboardEvent<HTMLElement>) => {
if (event.key === 'Escape') setActiveCell(null)
}}
renderTitle={
<span
aria-label={`Change carrier for ${row.shipmentId}`}
className="flex h-full w-full min-w-0 items-center gap-2 text-left whitespace-nowrap"
onFocus={() =>
setActiveCell({ rowId: row.id, columnKey: 'carrier' })
}
onMouseDown={() =>
setActiveCell({
rowId: row.id,
columnKey: 'carrier',
})
}
>
<img
alt=""
className="h-4 w-4 shrink-0 object-contain"
src={`${assetBase}/thumbs/brands/${carrierAssets[row.carrier]}`}
/>
{row.carrier}
</span>
}
>
{carrierOptions.map((option) => (
<Dropdown.Item
active={row.carrier === option}
eventKey={option}
key={option}
>
<span className="flex w-full items-center justify-between gap-4">
<span className="flex items-center gap-2">
<img
alt=""
className="h-4 w-4 shrink-0 object-contain"
src={`${assetBase}/thumbs/brands/${carrierAssets[option]}`}
/>
<span>{option}</span>
</span>
{row.carrier === option && (
<PiCheck
aria-hidden="true"
className="shrink-0 text-base"
/>
)}
</span>
</Dropdown.Item>
))}
</Dropdown>
</Table.Td>
)
const renderStatusCell = (row: ShipmentRow) => (
<Table.Td className="h-12 p-0">
<Dropdown
placement="bottom-start"
aria-label={`Change status for ${row.shipmentId}`}
role="button"
tabIndex={0}
toggleClassName={`flex h-full w-full min-w-0 items-center px-4 hover:bg-muted focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary ${openDropdownCell?.rowId === row.id && openDropdownCell.columnKey === 'status' ? 'ring-2 ring-inset ring-primary' : ''}`}
activeKey={row.status}
onOpen={(open: boolean) => {
setOpenDropdownCell((current) => {
if (open) {
return { rowId: row.id, columnKey: 'status' }
}
return current?.rowId === row.id &&
current.columnKey === 'status'
? null
: current
})
if (open) {
setActiveCell({ rowId: row.id, columnKey: 'status' })
}
}}
onSelect={(eventKey) => {
if (!statusOptions.includes(eventKey as ShipmentStatus)) {
return
}
setRows((current) =>
current.map((item) =>
item.id === row.id
? {
...item,
status: eventKey as ShipmentStatus,
}
: item,
),
)
}}
onFocus={() =>
setActiveCell({ rowId: row.id, columnKey: 'status' })
}
onClick={() =>
setActiveCell({ rowId: row.id, columnKey: 'status' })
}
onKeyDown={(event: KeyboardEvent<HTMLElement>) => {
if (event.key === 'Escape') setActiveCell(null)
}}
renderTitle={
<span
aria-label={`Change status for ${row.shipmentId}`}
className="flex h-full w-full min-w-0 items-center text-left whitespace-nowrap"
onFocus={() =>
setActiveCell({ rowId: row.id, columnKey: 'status' })
}
onMouseDown={() =>
setActiveCell({
rowId: row.id,
columnKey: 'status',
})
}
>
<Tag
className="bg-card"
prefix
prefixClass={statusDotClasses[row.status]}
>
{row.status}
</Tag>
</span>
}
>
{statusOptions.map((option) => (
<Dropdown.Item
active={row.status === option}
eventKey={option}
key={option}
>
<span className="flex w-full items-center justify-between gap-4">
<Tag
className="bg-card"
prefix
prefixClass={statusDotClasses[option]}
>
{option}
</Tag>
{row.status === option && (
<PiCheck
aria-hidden="true"
className="shrink-0 text-base"
/>
)}
</span>
</Dropdown.Item>
))}
</Dropdown>
</Table.Td>
)
const renderDateCell = (row: ShipmentRow) => (
<Table.Td className="h-12 p-0">
<Popover
open={openDateCell === row.id}
onOpenChange={(open) => {
setOpenDateCell(open ? row.id : null)
if (!open) setActiveCell(null)
}}
placement="bottom-start"
renderTrigger={
<button
type="button"
aria-label={`Change ETA for ${row.shipmentId}`}
className={`flex h-full w-full min-w-0 items-center px-4 text-left whitespace-nowrap outline-none hover:bg-muted focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary ${activeCell?.rowId === row.id && activeCell.columnKey === 'eta' ? 'ring-2 ring-inset ring-primary' : ''}`}
onClick={() =>
setActiveCell({ rowId: row.id, columnKey: 'eta' })
}
onFocus={() =>
setActiveCell({ rowId: row.id, columnKey: 'eta' })
}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setOpenDateCell(null)
setActiveCell(null)
}
}}
>
{formatDate(row.eta)}
</button>
}
>
<Calendar
defaultMonth={row.eta}
value={row.eta}
onChange={(date) => {
if (!(date instanceof Date)) return
setRows((current) =>
current.map((item) =>
item.id === row.id
? { ...item, eta: date }
: item,
),
)
setOpenDateCell(null)
}}
/>
</Popover>
</Table.Td>
)
return (
<section
aria-labelledby="data-grid-shipment-inline-edit-title"
className="w-full min-w-0 overflow-hidden"
onBlur={(event) => {
if (openDateCell || autocompleteOpen) return
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setActiveCell(null)
}
}}
>
<header className="flex flex-wrap items-center justify-between gap-4 border-b px-4 py-3">
<h4
id="data-grid-shipment-inline-edit-title"
className="truncate font-semibold"
>
Shipment overview
</h4>
<div className="flex flex-wrap items-center gap-2">
<Button
icon={<PiExport aria-hidden="true" />}
>
Export rows
</Button>
<Button
icon={<PiPlus aria-hidden="true" />}
iconAlignment="end"
variant="solid"
>
Add shipment
</Button>
</div>
</header>
<div className="flex flex-wrap items-center justify-between gap-4 border-b p-4">
<Input
aria-label="Search shipment records"
className="min-w-0 flex-1 basis-full sm:basis-auto max-w-sm"
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search shipment records"
prefix={
<PiMagnifyingGlass className="text-muted-foreground" />
}
value={searchQuery}
/>
<div className="flex flex-wrap items-center gap-4">
{selectedIds.size > 0 && (
<div className="flex shrink-0 items-center rounded-control border px-2 py-0.5">
<Checkbox
aria-label="Selected shipments"
checked={true}
onChange={() => setSelectedIds(new Set())}
/>
<span className="whitespace-nowrap font-medium mx-2">
{selectedIds.size} Selected
</span>
<span aria-hidden="true" className="h-4 border-l" />
<Button
aria-label="Edit selected shipments"
icon={<PiPencilSimple aria-hidden="true" />}
size="sm"
variant="ghost"
/>
<Button
aria-label="Remove selected shipments"
destructive
icon={<PiTrash aria-hidden="true" />}
size="sm"
variant="ghost"
/>
</div>
)}
<div className="ml-auto flex flex-wrap items-center gap-2">
<Dropdown
placement="bottom-end"
activeKey={dateFilter}
onSelect={(eventKey) => {
if (dateFilterOptions.includes(eventKey as DateFilter)) {
setDateFilter(eventKey as DateFilter)
}
}}
renderTitle={
<Button
icon={<PiCalendarBlank aria-hidden="true" />}
iconAlignment="start"
>
{dateFilter === 'All dates'
? 'Date Range'
: dateFilter}
</Button>
}
>
{dateFilterOptions.map((option) => (
<Dropdown.Item
active={dateFilter === option}
eventKey={option}
key={option}
>
<span className="flex w-full items-center justify-between">
<span>{option}</span>
{dateFilter === option && (
<PiCheck
aria-hidden="true"
className="shrink-0 text-base"
/>
)}
</span>
</Dropdown.Item>
))}
</Dropdown>
<Dropdown
placement="bottom-end"
activeKey={statusFilter}
onSelect={(eventKey) => {
if (eventKey === 'All') {
setStatusFilter('All')
} else if (
statusOptions.includes(
eventKey as ShipmentStatus,
)
) {
setStatusFilter(eventKey as ShipmentStatus)
}
}}
renderTitle={
<Button
icon={<PiCircleDashed aria-hidden="true" />}
iconAlignment="start"
>
{statusFilter === 'All' ? 'Status' : statusFilter}
</Button>
}
>
{(['All', ...statusOptions] as const).map((option) => (
<Dropdown.Item
active={statusFilter === option}
eventKey={option}
key={option}
>
<span className="flex w-full items-center justify-between">
<span>{option}</span>
{statusFilter === option && (
<PiCheck
aria-hidden="true"
className="shrink-0 text-base"
/>
)}
</span>
</Dropdown.Item>
))}
</Dropdown>
</div>
</div>
</div>
<div className="min-w-0 overflow-x-auto">
<Table
className="table-fixed border-t border-b"
hoverable={false}
overflow={false}
verticalDivider={{ body: true, head: true }}
>
<Table.THead>
<Table.Tr className="h-12">
<Table.Th className="w-12 h-12 p-0" scope="col">
<div className="flex h-full items-center justify-center px-4">
<Checkbox
aria-label="Select all shipments"
checked={allVisibleSelected}
indeterminate={someVisibleSelected}
onChange={toggleAllVisible}
/>
</div>
</Table.Th>
<Table.Th className="h-12 w-40 p-0" scope="col">
{renderSortableHeader(
'Shipment ID',
'shipmentId',
PiPackage,
)}
</Table.Th>
<Table.Th className="h-12 w-40 p-0" scope="col">
{renderSortableHeader('Origin', 'origin', PiMapPin)}
</Table.Th>
<Table.Th className="h-12 w-40 p-0" scope="col">
{renderSortableHeader(
'Destination',
'destination',
PiMapPin,
)}
</Table.Th>
<Table.Th className="h-12 w-40 p-0" scope="col">
{renderSortableHeader('Carrier', 'carrier', PiTruck)}
</Table.Th>
<Table.Th className="h-12 w-40 p-0" scope="col">
{renderSortableHeader('Status', 'status', PiCircleDashed)}
</Table.Th>
<Table.Th className="h-12 w-40 p-0" scope="col">
{renderSortableHeader('ETA', 'eta', PiCalendarBlank)}
</Table.Th>
<Table.Th className="h-12 w-32 p-0" scope="col">
{renderSortableHeader('Weight', 'weight', PiScales)}
</Table.Th>
<Table.Th className="w-12 h-12 p-0" scope="col">
<div className="flex h-full items-center justify-center px-4">
<span className="sr-only">Actions</span>
</div>
</Table.Th>
</Table.Tr>
</Table.THead>
<Table.TBody>
{pagedRows.map((row) => (
<Table.Tr className="h-12" key={row.id}>
<Table.Td className="w-12 h-12 p-0">
<div className="flex h-full items-center justify-center px-4">
<Checkbox
aria-label={`Select ${row.shipmentId}`}
checked={selectedIds.has(row.id)}
onChange={(checked) =>
toggleRowSelection(row.id, checked)
}
/>
</div>
</Table.Td>
{renderTextCell(row, 'shipmentId')}
{renderTextCell(row, 'origin')}
{renderTextCell(row, 'destination')}
{renderCarrierCell(row)}
{renderStatusCell(row)}
{renderDateCell(row)}
{renderTextCell(row, 'weight')}
<Table.Td className="w-12 h-12 p-0">
<div className="flex h-full items-center justify-center px-4">
<Dropdown
placement="bottom-end"
renderTitle={
<Button
aria-label={`More actions for ${row.shipmentId}`}
icon={
<PiDotsThreeVertical aria-hidden="true" />
}
size="sm"
variant="ghost"
/>
}
>
<Dropdown.Item
className="flex items-center gap-2"
eventKey={`view-${row.id}`}
>
<PiEye
aria-hidden="true"
className="text-base text-muted-foreground"
/>
<span>View shipment</span>
</Dropdown.Item>
<Dropdown.Item
className="flex items-center gap-2"
eventKey={`duplicate-${row.id}`}
>
<PiCopy
aria-hidden="true"
className="text-base text-muted-foreground"
/>
<span>Duplicate shipment</span>
</Dropdown.Item>
<Dropdown.Item
className="flex items-center gap-2"
eventKey={`archive-${row.id}`}
>
<PiArchive
aria-hidden="true"
className="text-base text-muted-foreground"
/>
<span>Archive shipment</span>
</Dropdown.Item>
</Dropdown>
</div>
</Table.Td>
</Table.Tr>
))}
</Table.TBody>
</Table>
</div>
<footer className="flex flex-wrap items-center justify-between gap-4 border-t px-4 py-3">
<p className="text-sm text-muted-foreground">
Showing{' '}
<span className="text-foreground">
{pagedRows.length === 0
? '0'
: `${(pageIndex - 1) * pageSize + 1}–${Math.min(pageIndex * pageSize, visibleRows.length)}`}
</span>{' '}
of {visibleRows.length} shipments
</p>
<Pagination
currentPage={pageIndex}
onChange={setPageIndex}
pageSize={pageSize}
total={visibleRows.length}
/>
</footer>
</section>
)
}
Data Grid 11
Preview
npx nateui@latest add DataGridOrderExpandableDark
import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
import Avatar from '@/components/ui/Avatar'
import Button from '@/components/ui/Button'
import Calendar from '@/components/ui/Calendar'
import Checkbox from '@/components/ui/Checkbox'
import Collapsible from '@/components/ui/Collapsible'
import Dropdown from '@/components/ui/Dropdown'
import Input from '@/components/ui/Input'
import Popover from '@/components/ui/Popover'
import Table from '@/components/ui/Table'
import Tag from '@/components/ui/Tag'
import DataTable from '@/components/composites/DataTable'
import { colors } from '@/configs/colors.config'
import type { ComponentType, ReactNode } from 'react'
import {
PiBuildings,
PiCalendarBlank,
PiCaretDown,
PiCaretRight,
PiCheck,
PiCircleDashed,
PiCreditCard,
PiCurrencyCircleDollar,
PiDotsThreeVertical,
PiDownloadSimple,
PiEnvelope,
PiExport,
PiMagnifyingGlass,
PiMapPin,
PiPackage,
PiPhone,
PiPlus,
PiPrinter,
PiTruck,
PiUser,
} from 'react-icons/pi'
const assetBase = 'https://statics.nateui.com/img'
const columnCount = 7
const pageSize = 6
type Client =
| 'Northstar Labs'
| 'Pine & Co.'
| 'Terra Goods'
| 'Harbor Market'
const statusOptions = [
'Ready to ship',
'Processing',
'Delivered',
'On hold',
] as const
type OrderStatus = (typeof statusOptions)[number]
type StatusFilter = OrderStatus | 'All statuses'
type DateRange = [Date | null, Date | null]
type PaymentBrand =
| 'visa'
| 'master'
| 'amex'
| 'paypal'
| 'applePay'
| 'googlePay'
| 'unionPay'
| 'creditCard'
type OrderItem = {
id: string
name: string
code: string
quantity: number
price: number
image: string
}
type OrderRow = {
id: string
orderNo: string
date: Date
customerName: string
customerAvatar: string
client: Client
address: string
status: OrderStatus
email: string
phone: string
paymentBrand: PaymentBrand
paymentLabel: string
shippingMethod: string
shippingAddress: string
billingAddress: string
deliveryFee: number
items: OrderItem[]
}
type PanelIcon = ComponentType<{
className?: string
'aria-hidden'?: boolean
}>
const statusTagClasses: Record<OrderStatus, string> = {
'Ready to ship': `${colors.blue.tuneBg} ${colors.blue.tuneFg}`,
Processing: `${colors.orange.tuneBg} ${colors.orange.tuneFg}`,
Delivered: `${colors.emerald.tuneBg} ${colors.emerald.tuneFg}`,
'On hold': `${colors.red.tuneBg} ${colors.red.tuneFg}`,
}
const statusDotClasses: Record<OrderStatus, string> = {
'Ready to ship': colors.blue.bg,
Processing: colors.orange.bg,
Delivered: colors.emerald.bg,
'On hold': colors.red.bg,
}
const currentDate = new Date()
currentDate.setHours(12, 0, 0, 0)
const createOrderDate = (daysAgo: number) => {
const date = new Date(currentDate)
date.setDate(date.getDate() - daysAgo)
return date
}
const formatOrderDate = (date: Date) =>
new Intl.DateTimeFormat('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
}).format(date)
const formatDateRange = (range: DateRange) => {
if (!range[0]) return 'Date range'
if (!range[1]) return 'Select end date'
return `${formatOrderDate(range[0])} – ${formatOrderDate(range[1])}`
}
const dateValue = (date: Date) =>
new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()
const createItem = (
id: string,
name: string,
code: string,
quantity: number,
price: number,
imageNumber: number,
): OrderItem => ({
id,
name,
code,
quantity,
price,
image: `${assetBase}/thumbs/products/product-${imageNumber}.jpg`,
})
const orderRows: OrderRow[] = [
{
id: 'order-47281',
orderNo: 'ORD-47281',
date: createOrderDate(5),
customerName: 'Avery Morgan',
customerAvatar: `${assetBase}/avatars/thumb-1.jpg`,
client: 'Northstar Labs',
address: '24 Harbor View, Helsinki',
status: 'Ready to ship',
email: 'avery.morgan@example.com',
phone: '+358 40 218 6401',
paymentBrand: 'visa',
paymentLabel: 'Visa •••• 4821',
shippingMethod: 'Priority courier',
shippingAddress: '24 Harbor View\nHelsinki 00150\nFinland',
billingAddress: 'Northstar Labs\n8 Meridian Road\nCopenhagen 2100\nDenmark',
deliveryFee: 18,
items: [
createItem('item-47281-a', 'Slate notebook', 'SLT-204', 2, 24.5, 1),
createItem('item-47281-b', 'Field cable', 'FCL-118', 1, 16.75, 2),
],
},
{
id: 'order-47276',
orderNo: 'ORD-47276',
date: createOrderDate(6),
customerName: 'Jordan Lee',
customerAvatar: `${assetBase}/avatars/thumb-2.jpg`,
client: 'Pine & Co.',
address: '9 Birch Lane, Riga',
status: 'Processing',
email: 'jordan.lee@example.com',
phone: '+371 26 402 187',
paymentBrand: 'master',
paymentLabel: 'Mastercard •••• 7610',
shippingMethod: 'Standard freight',
shippingAddress: '9 Birch Lane\nRiga LV-1010\nLatvia',
billingAddress: 'Pine & Co.\n15 Market Street\nTallinn 10111\nEstonia',
deliveryFee: 12,
items: [
createItem('item-47276-a', 'Cedar stand', 'CDR-390', 1, 38, 3),
createItem('item-47276-b', 'Signal case', 'SGN-442', 2, 17.25, 4),
],
},
{
id: 'order-47269',
orderNo: 'ORD-47269',
date: createOrderDate(7),
customerName: 'Taylor Smith',
customerAvatar: `${assetBase}/avatars/thumb-3.jpg`,
client: 'Terra Goods',
address: '72 Rowan Street, Gothenburg',
status: 'Ready to ship',
email: 'taylor.smith@example.com',
phone: '+46 70 318 5502',
paymentBrand: 'amex',
paymentLabel: 'Amex •••• 1056',
shippingMethod: 'Economy parcel',
shippingAddress: '72 Rowan Street\nGothenburg 413 04\nSweden',
billingAddress: 'Terra Goods\n31 Fjord Avenue\nOslo 0250\nNorway',
deliveryFee: 9,
items: [
createItem('item-47269-a', 'Studio lamp', 'STL-812', 1, 64.9, 5),
createItem('item-47269-b', 'Harbor light', 'HBL-246', 1, 42.4, 6),
],
},
{
id: 'order-47261',
orderNo: 'ORD-47261',
date: createOrderDate(8),
customerName: 'Morgan Chen',
customerAvatar: `${assetBase}/avatars/thumb-4.jpg`,
client: 'Harbor Market',
address: '18 Lucerne Way, Lyon',
status: 'Delivered',
email: 'morgan.chen@example.com',
phone: '+33 6 42 19 8031',
paymentBrand: 'paypal',
paymentLabel: 'PayPal account',
shippingMethod: 'Standard courier',
shippingAddress: '18 Lucerne Way\nLyon 69002\nFrance',
billingAddress: 'Harbor Market\n5 Quay Road\nZurich 8005\nSwitzerland',
deliveryFee: 15,
items: [
createItem('item-47261-a', 'Canvas organiser', 'CVO-165', 3, 19.8, 7),
createItem('item-47261-b', 'Maple tray', 'MPT-511', 1, 44.25, 8),
],
},
{
id: 'order-47254',
orderNo: 'ORD-47254',
date: createOrderDate(9),
customerName: 'Riley Patel',
customerAvatar: `${assetBase}/avatars/thumb-5.jpg`,
client: 'Northstar Labs',
address: '6 Linden Court, Bratislava',
status: 'Ready to ship',
email: 'riley.patel@example.com',
phone: '+421 905 114 683',
paymentBrand: 'applePay',
paymentLabel: 'Apple Pay •••• 2844',
shippingMethod: 'Priority courier',
shippingAddress: '6 Linden Court\nBratislava 811 01\nSlovakia',
billingAddress: 'Northstar Labs\n8 Meridian Road\nVienna 1030\nAustria',
deliveryFee: 11,
items: [
createItem('item-47254-a', 'Quartz timer', 'QTM-702', 2, 28.75, 9),
createItem('item-47254-b', 'Desk caddy', 'DSC-330', 1, 31.5, 10),
],
},
{
id: 'order-47249',
orderNo: 'ORD-47249',
date: createOrderDate(10),
customerName: 'Casey Kim',
customerAvatar: `${assetBase}/avatars/thumb-6.jpg`,
client: 'Pine & Co.',
address: '42 Namu Road, Busan',
status: 'Processing',
email: 'casey.kim@example.com',
phone: '+82 10 4281 6602',
paymentBrand: 'googlePay',
paymentLabel: 'Google Pay •••• 6098',
shippingMethod: 'Standard freight',
shippingAddress: '42 Namu Road\nBusan 48058\nSouth Korea',
billingAddress: 'Pine & Co.\n19 Han River Lane\nSeoul 04524\nSouth Korea',
deliveryFee: 13,
items: [
createItem('item-47249-a', 'Orbit pouch', 'OBP-428', 1, 27.4, 11),
createItem('item-47249-b', 'Studio lamp', 'STL-812', 2, 64.9, 12),
],
},
{
id: 'order-47243',
orderNo: 'ORD-47243',
date: createOrderDate(11),
customerName: 'Drew Wilson',
customerAvatar: `${assetBase}/avatars/thumb-7.jpg`,
client: 'Terra Goods',
address: '11 Cedar Walk, Porto',
status: 'On hold',
email: 'drew.wilson@example.com',
phone: '+351 912 644 083',
paymentBrand: 'creditCard',
paymentLabel: 'Card •••• 3375',
shippingMethod: 'Economy parcel',
shippingAddress: '11 Cedar Walk\nPorto 4050-321\nPortugal',
billingAddress: 'Terra Goods\n4 Atlas Road\nLisbon 1100-017\nPortugal',
deliveryFee: 8,
items: [
createItem('item-47243-a', 'Field cable', 'FCL-118', 4, 16.75, 13),
createItem('item-47243-b', 'Slate notebook', 'SLT-204', 2, 24.5, 14),
],
},
{
id: 'order-47238',
orderNo: 'ORD-47238',
date: createOrderDate(12),
customerName: 'Alex Rivera',
customerAvatar: `${assetBase}/avatars/thumb-8.jpg`,
client: 'Harbor Market',
address: '55 Orange Street, Valencia',
status: 'Ready to ship',
email: 'alex.rivera@example.com',
phone: '+34 644 207 519',
paymentBrand: 'visa',
paymentLabel: 'Visa •••• 9016',
shippingMethod: 'Priority courier',
shippingAddress: '55 Orange Street\nValencia 46002\nSpain',
billingAddress: 'Harbor Market\n17 Prado Lane\nMadrid 28014\nSpain',
deliveryFee: 14,
items: [
createItem('item-47238-a', 'Cedar stand', 'CDR-390', 2, 38, 15),
createItem('item-47238-b', 'Maple tray', 'MPT-511', 1, 44.25, 16),
],
},
{
id: 'order-47231',
orderNo: 'ORD-47231',
date: createOrderDate(13),
customerName: 'Jamie Park',
customerAvatar: `${assetBase}/avatars/thumb-9.jpg`,
client: 'Northstar Labs',
address: '3 Kumo Avenue, Nagoya',
status: 'Processing',
email: 'jamie.park@example.com',
phone: '+81 90 2714 6842',
paymentBrand: 'master',
paymentLabel: 'Mastercard •••• 2239',
shippingMethod: 'Standard freight',
shippingAddress: '3 Kumo Avenue\nNagoya 460-0008\nJapan',
billingAddress: 'Northstar Labs\n29 Sakura Lane\nTokyo 100-0001\nJapan',
deliveryFee: 17,
items: [
createItem('item-47231-a', 'Signal case', 'SGN-442', 1, 17.25, 17),
createItem('item-47231-b', 'Canvas organiser', 'CVO-165', 2, 19.8, 18),
],
},
{
id: 'order-47224',
orderNo: 'ORD-47224',
date: createOrderDate(14),
customerName: 'Cameron Brooks',
customerAvatar: `${assetBase}/avatars/thumb-10.jpg`,
client: 'Pine & Co.',
address: '88 Rowan Quay, Cork',
status: 'Delivered',
email: 'cameron.brooks@example.com',
phone: '+353 85 214 9027',
paymentBrand: 'paypal',
paymentLabel: 'PayPal account',
shippingMethod: 'Standard courier',
shippingAddress: '88 Rowan Quay\nCork T12 K6R2\nIreland',
billingAddress: 'Pine & Co.\n6 River Road\nDublin D02 YX44\nIreland',
deliveryFee: 10,
items: [
createItem('item-47224-a', 'Harbor light', 'HBL-246', 2, 42.4, 19),
createItem('item-47224-b', 'Desk caddy', 'DSC-330', 1, 31.5, 20),
],
},
{
id: 'order-47218',
orderNo: 'ORD-47218',
date: createOrderDate(15),
customerName: 'Quinn Davis',
customerAvatar: `${assetBase}/avatars/thumb-11.jpg`,
client: 'Terra Goods',
address: '16 Fern Crescent, Glasgow',
status: 'Ready to ship',
email: 'quinn.davis@example.com',
phone: '+44 7700 184 621',
paymentBrand: 'amex',
paymentLabel: 'Amex •••• 7781',
shippingMethod: 'Priority courier',
shippingAddress: '16 Fern Crescent\nGlasgow G2 4AA\nUnited Kingdom',
billingAddress: 'Terra Goods\n7 Castle Street\nEdinburgh EH2 3AT\nUnited Kingdom',
deliveryFee: 16,
items: [
createItem('item-47218-a', 'Quartz timer', 'QTM-702', 1, 28.75, 21),
createItem('item-47218-b', 'Orbit pouch', 'OBP-428', 3, 27.4, 22),
],
},
{
id: 'order-47212',
orderNo: 'ORD-47212',
date: createOrderDate(16),
customerName: 'Sam Taylor',
customerAvatar: `${assetBase}/avatars/thumb-12.jpg`,
client: 'Harbor Market',
address: '41 Elm Street, Providence',
status: 'On hold',
email: 'sam.taylor@example.com',
phone: '+1 401 555 0138',
paymentBrand: 'googlePay',
paymentLabel: 'Google Pay •••• 4172',
shippingMethod: 'Economy parcel',
shippingAddress: '41 Elm Street\nProvidence, RI 02903\nUnited States',
billingAddress: 'Harbor Market\n12 Beacon Road\nBoston, MA 02108\nUnited States',
deliveryFee: 7,
items: [
createItem('item-47212-a', 'Maple tray', 'MPT-511', 1, 44.25, 23),
createItem('item-47212-b', 'Field cable', 'FCL-118', 2, 16.75, 24),
],
},
{
id: 'order-47206',
orderNo: 'ORD-47206',
date: createOrderDate(17),
customerName: 'Parker Reed',
customerAvatar: `${assetBase}/avatars/thumb-13.jpg`,
client: 'Northstar Labs',
address: '90 Cedar Road, Ottawa',
status: 'Ready to ship',
email: 'parker.reed@example.com',
phone: '+1 613 555 0194',
paymentBrand: 'visa',
paymentLabel: 'Visa •••• 6440',
shippingMethod: 'Priority courier',
shippingAddress: '90 Cedar Road\nOttawa, ON K1P 1J1\nCanada',
billingAddress: 'Northstar Labs\n22 Queen Street\nToronto, ON M5H 2N2\nCanada',
deliveryFee: 19,
items: [
createItem('item-47206-a', 'Studio lamp', 'STL-812', 1, 64.9, 25),
createItem('item-47206-b', 'Slate notebook', 'SLT-204', 3, 24.5, 26),
],
},
{
id: 'order-47199',
orderNo: 'ORD-47199',
date: createOrderDate(18),
customerName: 'Elliot Stone',
customerAvatar: `${assetBase}/avatars/thumb-14.jpg`,
client: 'Pine & Co.',
address: '7 Wattle Drive, Geelong',
status: 'Delivered',
email: 'elliot.stone@example.com',
phone: '+61 412 580 774',
paymentBrand: 'creditCard',
paymentLabel: 'Card •••• 5301',
shippingMethod: 'Standard freight',
shippingAddress: '7 Wattle Drive\nGeelong VIC 3220\nAustralia',
billingAddress: 'Pine & Co.\n14 Collins Street\nMelbourne VIC 3000\nAustralia',
deliveryFee: 21,
items: [
createItem('item-47199-a', 'Cedar stand', 'CDR-390', 1, 38, 27),
createItem('item-47199-b', 'Harbor light', 'HBL-246', 1, 42.4, 28),
],
},
{
id: 'order-47192',
orderNo: 'ORD-47192',
date: createOrderDate(19),
customerName: 'Rowan Bell',
customerAvatar: `${assetBase}/avatars/thumb-15.jpg`,
client: 'Terra Goods',
address: '33 Kauri Lane, Hamilton',
status: 'Processing',
email: 'rowan.bell@example.com',
phone: '+64 21 804 311',
paymentBrand: 'master',
paymentLabel: 'Mastercard •••• 3094',
shippingMethod: 'Economy parcel',
shippingAddress: '33 Kauri Lane\nHamilton 3204\nNew Zealand',
billingAddress: 'Terra Goods\n52 Queen Street\nAuckland 1010\nNew Zealand',
deliveryFee: 8,
items: [
createItem('item-47192-a', 'Signal case', 'SGN-442', 2, 17.25, 29),
createItem('item-47192-b', 'Canvas organiser', 'CVO-165', 1, 19.8, 30),
],
},
{
id: 'order-47185',
orderNo: 'ORD-47185',
date: createOrderDate(20),
customerName: 'Skyler Cole',
customerAvatar: `${assetBase}/avatars/thumb-16.jpg`,
client: 'Harbor Market',
address: '20 Protea Road, Stellenbosch',
status: 'Ready to ship',
email: 'skyler.cole@example.com',
phone: '+27 72 481 6902',
paymentBrand: 'paypal',
paymentLabel: 'PayPal account',
shippingMethod: 'Priority courier',
shippingAddress: '20 Protea Road\nStellenbosch 7600\nSouth Africa',
billingAddress: 'Harbor Market\n4 Signal Hill Road\nCape Town 8001\nSouth Africa',
deliveryFee: 15,
items: [
createItem('item-47185-a', 'Desk caddy', 'DSC-330', 2, 31.5, 31),
createItem('item-47185-b', 'Quartz timer', 'QTM-702', 1, 28.75, 32),
],
},
{
id: 'order-47178',
orderNo: 'ORD-47178',
date: createOrderDate(21),
customerName: 'Emerson Hart',
customerAvatar: `${assetBase}/avatars/thumb-17.jpg`,
client: 'Northstar Labs',
address: '63 Lake Avenue, Milwaukee',
status: 'On hold',
email: 'emerson.hart@example.com',
phone: '+1 414 555 0182',
paymentBrand: 'amex',
paymentLabel: 'Amex •••• 8621',
shippingMethod: 'Standard courier',
shippingAddress: '63 Lake Avenue\nMilwaukee, WI 53202\nUnited States',
billingAddress: 'Northstar Labs\n120 State Street\nChicago, IL 60601\nUnited States',
deliveryFee: 10,
items: [
createItem('item-47178-a', 'Orbit pouch', 'OBP-428', 1, 27.4, 33),
createItem('item-47178-b', 'Maple tray', 'MPT-511', 2, 44.25, 34),
],
},
{
id: 'order-47170',
orderNo: 'ORD-47170',
date: createOrderDate(22),
customerName: 'Finley Gray',
customerAvatar: `${assetBase}/avatars/thumb-18.jpg`,
client: 'Pine & Co.',
address: '5 Willow Passage, Reims',
status: 'Delivered',
email: 'finley.gray@example.com',
phone: '+33 6 18 42 7609',
paymentBrand: 'unionPay',
paymentLabel: 'UnionPay •••• 1902',
shippingMethod: 'Economy parcel',
shippingAddress: '5 Willow Passage\nReims 51100\nFrance',
billingAddress: 'Pine & Co.\n44 Rue du Bac\nParis 75007\nFrance',
deliveryFee: 9,
items: [
createItem('item-47170-a', 'Field cable', 'FCL-118', 1, 16.75, 35),
createItem('item-47170-b', 'Studio lamp', 'STL-812', 1, 64.9, 36),
],
},
]
const formatCurrency = (value: number) =>
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(value)
const calculateOrderSummary = (order: OrderRow) => {
const subtotal = Math.round(
order.items.reduce((sum, item) => sum + item.price * item.quantity, 0) * 100,
) / 100
const tax = Math.round(subtotal * 0.08 * 100) / 100
const total = Math.round((subtotal + tax + order.deliveryFee) * 100) / 100
return { subtotal, tax, total }
}
const statusTag = (status: OrderStatus) => (
<Tag
className={`border-0 ${statusTagClasses[status]}`}
prefix
prefixClass={statusDotClasses[status]}
>
{status}
</Tag>
)
const PanelHeading = ({
Icon,
children,
}: {
Icon: PanelIcon
children: ReactNode
}) => (
<h5 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-foreground">
<Icon aria-hidden={true} className="text-base text-muted-foreground" />
{children}
</h5>
)
const DetailField = ({
Icon,
label,
value,
}: {
Icon: PanelIcon
label: string
value: ReactNode
}) => (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
<div className="flex shrink-0 items-center gap-2 text-muted-foreground">
<Icon aria-hidden={true} className="text-base" />
<span className="font-medium">{label}</span>
</div>
<span className="min-w-0 break-words font-medium sm:ml-0 ml-4">
{value}
</span>
</div>
)
const OrderDetailsPanel = ({ order }: { order: OrderRow }) => {
const summary = calculateOrderSummary(order)
return (
<div className="bg-card p-4">
<div className="flex items-center justify-end gap-2">
<Button
aria-label={`Download details for ${order.orderNo}`}
icon={<PiDownloadSimple aria-hidden="true" />}
shape="circle"
size="sm"
variant="ghost"
/>
<Button
aria-label={`Print details for ${order.orderNo}`}
icon={<PiPrinter aria-hidden="true" />}
shape="circle"
size="sm"
variant="ghost"
/>
</div>
<div className="mt-4 grid grid-cols-1 gap-8 md:grid-cols-2">
<section className="space-y-4" aria-labelledby={`${order.id}-contact`}>
<PanelHeading Icon={PiUser}>
<span id={`${order.id}-contact`}>Contact Information</span>
</PanelHeading>
<div className="space-y-4">
<DetailField
Icon={PiBuildings}
label="Client"
value={order.client}
/>
<DetailField
Icon={PiMapPin}
label="Address"
value={order.address}
/>
<DetailField
Icon={PiEnvelope}
label="Email"
value={order.email}
/>
<DetailField
Icon={PiPhone}
label="Phone"
value={order.phone}
/>
</div>
</section>
<section className="space-y-4" aria-labelledby={`${order.id}-payment-shipping`}>
<PanelHeading Icon={PiCreditCard}>
<span id={`${order.id}-payment-shipping`}>
Payment & Shipping
</span>
</PanelHeading>
<div className="space-y-4">
<DetailField
Icon={PiCurrencyCircleDollar}
label="Payment method"
value={
<span className="flex items-center gap-2">
<img
alt=""
className="h-6 w-auto object-contain"
src={`${assetBase}/thumbs/payment/${order.paymentBrand}.png`}
/>
{order.paymentLabel}
</span>
}
/>
<DetailField
Icon={PiTruck}
label="Shipping method"
value={order.shippingMethod}
/>
</div>
</section>
</div>
<div className="mt-8 grid grid-cols-1 gap-8 md:grid-cols-2">
<section className="space-y-4" aria-labelledby={`${order.id}-shipping-address`}>
<PanelHeading Icon={PiMapPin}>
<span id={`${order.id}-shipping-address`}>
Shipping Address
</span>
</PanelHeading>
<p className="whitespace-pre-line font-medium">
{order.shippingAddress}
</p>
</section>
<section className="space-y-4" aria-labelledby={`${order.id}-billing-address`}>
<PanelHeading Icon={PiMapPin}>
<span id={`${order.id}-billing-address`}>
Billing Address
</span>
</PanelHeading>
<p className="whitespace-pre-line font-medium">
{order.billingAddress}
</p>
</section>
</div>
<section className="mt-8 space-y-4" aria-labelledby={`${order.id}-items`}>
<PanelHeading Icon={PiPackage}>
<span id={`${order.id}-items`}>
Order Items ({order.items.length})
</span>
</PanelHeading>
<div className="hidden min-w-0 overflow-x-auto md:block">
<Table className="w-full min-w-full" hoverable overflow={false}>
<Table.THead>
<Table.Tr>
<Table.Th>Product</Table.Th>
<Table.Th>Code</Table.Th>
<Table.Th className="text-center">Quantity</Table.Th>
<Table.Th className="text-right">Price</Table.Th>
<Table.Th className="text-right">Total</Table.Th>
</Table.Tr>
</Table.THead>
<Table.TBody>
{order.items.map((item) => (
<Table.Tr key={item.id}>
<Table.Td>
<div className="flex min-w-0 items-center gap-4">
<img
alt={item.name}
className="h-10 w-10 shrink-0 rounded-control-sm object-cover"
src={item.image}
/>
<span className="min-w-0 truncate font-medium">
{item.name}
</span>
</div>
</Table.Td>
<Table.Td>{item.code}</Table.Td>
<Table.Td className="text-center">
<span className="font-medium">{item.quantity}</span>
</Table.Td>
<Table.Td className="text-right">
<span className="font-medium">
{formatCurrency(item.price)}
</span>
</Table.Td>
<Table.Td className="text-right">
<span className="font-semibold">
{formatCurrency(item.price * item.quantity)}
</span>
</Table.Td>
</Table.Tr>
))}
</Table.TBody>
</Table>
</div>
<div className="divide-y divide-border overflow-hidden rounded-card border md:hidden">
{order.items.map((item) => (
<div className="flex gap-4 p-4" key={item.id}>
<img
alt={item.name}
className="h-10 w-10 shrink-0 rounded-control-sm object-cover"
src={item.image}
/>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{item.name}</div>
<div className="text-xs text-muted-foreground">
{item.code}
</div>
<div className="mt-2 flex items-center justify-between gap-4">
<span className="text-sm">
{formatCurrency(item.price)} × {item.quantity}
</span>
<span className="font-semibold">
{formatCurrency(item.price * item.quantity)}
</span>
</div>
</div>
</div>
))}
</div>
</section>
<section className="mt-8 space-y-4" aria-labelledby={`${order.id}-summary`}>
<PanelHeading Icon={PiCurrencyCircleDollar}>
<span id={`${order.id}-summary`}>Payment Summary</span>
</PanelHeading>
<div className="grid gap-2 rounded-card bg-muted p-4">
<div className="flex items-center justify-between gap-4">
<span className="font-medium">Subtotal</span>
<span className="font-medium">{formatCurrency(summary.subtotal)}</span>
</div>
<div className="flex items-center justify-between gap-4">
<span className="font-medium">Tax</span>
<span className="font-medium">{formatCurrency(summary.tax)}</span>
</div>
<div className="flex items-center justify-between gap-4">
<span className="font-medium">Delivery fees</span>
<span className="font-medium">{formatCurrency(order.deliveryFee)}</span>
</div>
<div className="border-t pt-4">
<div className="flex items-center justify-between gap-4">
<span className="text-base font-semibold">Total</span>
<span className="text-base font-semibold">
{formatCurrency(summary.total)}
</span>
</div>
</div>
</div>
</section>
</div>
)
}
export default function DataGridOrderExpandable() {
const [searchQuery, setSearchQuery] = useState('')
const [dateRange, setDateRange] = useState<DateRange>([null, null])
const [dateRangeOpen, setDateRangeOpen] = useState(false)
const [statusFilter, setStatusFilter] =
useState<StatusFilter>('Ready to ship')
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [expandedIds, setExpandedIds] = useState<Set<string>>(
() => new Set([orderRows[0].id]),
)
const [pageIndex, setPageIndex] = useState(1)
const [scrollerWidth, setScrollerWidth] = useState<number | null>(null)
const scrollerRef = useRef<HTMLDivElement>(null)
const filteredRows = useMemo(() => {
const normalizedQuery = searchQuery.trim().toLowerCase()
return orderRows.filter((order) => {
const matchesSearch =
normalizedQuery.length === 0 ||
[
order.orderNo,
order.customerName,
order.client,
order.address,
].some((value) => value.toLowerCase().includes(normalizedQuery))
const matchesDate =
!dateRange[0] ||
!dateRange[1] ||
(dateValue(order.date) >= dateValue(dateRange[0]) &&
dateValue(order.date) <= dateValue(dateRange[1]))
const matchesStatus =
statusFilter === 'All statuses' || order.status === statusFilter
return matchesSearch && matchesDate && matchesStatus
})
}, [dateRange, searchQuery, statusFilter])
const availableOrderDates = useMemo(() => {
const normalizedQuery = searchQuery.trim().toLowerCase()
return new Set(
orderRows
.filter((order) => {
const matchesSearch =
normalizedQuery.length === 0 ||
[
order.orderNo,
order.customerName,
order.client,
order.address,
].some((value) =>
value.toLowerCase().includes(normalizedQuery),
)
const matchesStatus =
statusFilter === 'All statuses' ||
order.status === statusFilter
return matchesSearch && matchesStatus
})
.map((order) => dateValue(order.date)),
)
}, [searchQuery, statusFilter])
const pageCount = Math.max(1, Math.ceil(filteredRows.length / pageSize))
const pagedRows = useMemo(
() =>
filteredRows.slice(
(pageIndex - 1) * pageSize,
pageIndex * pageSize,
),
[filteredRows, pageIndex],
)
useEffect(() => {
setPageIndex(1)
}, [dateRange, searchQuery, statusFilter])
useEffect(() => {
setPageIndex((current) => Math.min(current, pageCount))
}, [pageCount])
useEffect(() => {
const root = document.documentElement
const previousScrollbarGutter = root.style.scrollbarGutter
root.style.scrollbarGutter = 'stable'
return () => {
root.style.scrollbarGutter = previousScrollbarGutter
}
}, [])
useEffect(() => {
const scroller = scrollerRef.current
if (!scroller) return
const updateScrollerWidth = () => setScrollerWidth(scroller.clientWidth)
const observer = new ResizeObserver(updateScrollerWidth)
updateScrollerWidth()
observer.observe(scroller)
return () => observer.disconnect()
}, [])
const updateSelectedIds = (rowId: string, checked: boolean) => {
setSelectedIds((current) => {
const next = new Set(current)
if (checked) next.add(rowId)
else next.delete(rowId)
return next
})
}
const visibleSelectedCount = pagedRows.filter((row) =>
selectedIds.has(row.id),
).length
const allVisibleSelected =
pagedRows.length > 0 && visibleSelectedCount === pagedRows.length
const someVisibleSelected =
visibleSelectedCount > 0 && !allVisibleSelected
const updateAllVisibleSelection = (checked: boolean) => {
setSelectedIds((current) => {
const next = new Set(current)
pagedRows.forEach((row) => {
if (checked) next.add(row.id)
else next.delete(row.id)
})
return next
})
}
const toggleExpanded = (rowId: string) => {
setExpandedIds((current) => {
const next = new Set(current)
if (next.has(rowId)) next.delete(rowId)
else next.add(rowId)
return next
})
}
const applyDateRange = (nextRange: DateRange) => {
setDateRange(nextRange)
if (nextRange[0] && nextRange[1]) setDateRangeOpen(false)
}
const applyStatusFilter = (value: string) => {
const nextValue = value as StatusFilter
setStatusFilter(nextValue)
}
const rangeStart = filteredRows.length === 0 ? 0 : (pageIndex - 1) * pageSize + 1
const rangeEnd = Math.min(pageIndex * pageSize, filteredRows.length)
return (
<section
aria-labelledby="data-grid-order-expandable-title"
className="w-full min-w-0 overflow-hidden"
>
<header className="flex flex-wrap items-center justify-between gap-4 border-b px-4 py-4">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<h4
className="truncate font-semibold"
id="data-grid-order-expandable-title"
>
Orders
</h4>
</div>
<p className="text-muted-foreground">
Review order details without leaving the list.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button icon={<PiExport aria-hidden="true" />}>
Export list
</Button>
<Button
icon={<PiPlus aria-hidden="true" />}
iconAlignment="end"
variant="solid"
>
New order
</Button>
</div>
</header>
<div className="flex flex-wrap items-center justify-between gap-4 px-4 py-4">
<div className="flex flex-wrap items-center gap-2">
<Popover
open={dateRangeOpen}
onOpenChange={setDateRangeOpen}
placement="bottom-start"
className="p-0"
renderTrigger={
<Button
icon={<PiCalendarBlank aria-hidden="true" />}
>
{formatDateRange(dateRange)}
</Button>
}
>
<div className="p-4">
<Calendar.Range
defaultMonth={currentDate}
disableDate={(date) =>
!availableOrderDates.has(dateValue(date))
}
value={dateRange}
onChange={applyDateRange}
/>
</div>
</Popover>
<Dropdown
activeKey={statusFilter}
onSelect={applyStatusFilter}
placement="bottom-start"
renderTitle={
<Button
icon={<PiCaretDown aria-hidden="true" />}
iconAlignment="end"
>
{statusFilter === 'All statuses' ? 'Status' : statusFilter}
</Button>
}
>
<Dropdown.Item active={statusFilter === 'All statuses'} eventKey="All statuses">
<span className="flex w-full items-center justify-between gap-4">
<span>All statuses</span>
{statusFilter === 'All statuses' && <PiCheck aria-hidden="true" />}
</span>
</Dropdown.Item>
{statusOptions.map((option) => (
<Dropdown.Item
active={statusFilter === option}
eventKey={option}
key={option}
>
<span className="flex w-full items-center justify-between gap-4">
<span>{option}</span>
{statusFilter === option && <PiCheck aria-hidden="true" />}
</span>
</Dropdown.Item>
))}
</Dropdown>
</div>
<Input
aria-label="Search orders"
className="w-full sm:w-60"
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search orders"
prefix={<PiMagnifyingGlass className="text-muted-foreground" />}
value={searchQuery}
/>
</div>
<DataTable<OrderRow>
columns={[]}
data={pagedRows}
onPaginationChange={setPageIndex}
pageSizes={[pageSize]}
pagingData={{
total: filteredRows.length,
pageIndex,
pageSize,
}}
overflow={false}
hoverable={false}
children={() => (
<>
<div className="min-w-0 overflow-x-auto" ref={scrollerRef}>
<Table
className="table-fixed border-t border-b"
hoverable={false}
overflow={false}
>
<Table.THead>
<Table.Tr className="h-12">
<Table.Th
className="h-12 w-12 p-0"
scope="col"
>
<div className="flex h-full items-center justify-center px-4">
<Checkbox
aria-label="Select all visible orders"
checked={allVisibleSelected}
indeterminate={someVisibleSelected}
onChange={updateAllVisibleSelection}
/>
</div>
</Table.Th>
<Table.Th
className="h-12 w-12 p-0 normal-case tracking-normal"
scope="col"
>
<div className="relative flex h-full items-center justify-center px-4">
<span className="sr-only">Expand</span>
</div>
</Table.Th>
<Table.Th
className="h-12 w-32 p-0 normal-case tracking-normal"
scope="col"
>
<div className="flex h-full items-center px-4">Order No</div>
</Table.Th>
<Table.Th
className="h-12 w-28 p-0 normal-case tracking-normal"
scope="col"
>
<div className="flex h-full items-center px-4">Date</div>
</Table.Th>
<Table.Th
className="h-12 w-64 p-0 normal-case tracking-normal"
scope="col"
>
<div className="flex h-full items-center px-4">Name</div>
</Table.Th>
<Table.Th
className="h-12 w-36 p-0 normal-case tracking-normal"
scope="col"
>
<div className="flex h-full items-center px-4">Status</div>
</Table.Th>
<Table.Th
className="h-12 w-12 p-0 normal-case tracking-normal"
scope="col"
>
<div className="relative flex h-full items-center justify-center px-4">
<span className="sr-only">Actions</span>
</div>
</Table.Th>
</Table.Tr>
</Table.THead>
<Table.TBody>
{pagedRows.length === 0 && (
<Table.Tr>
<Table.Td
className="p-4 text-center text-muted-foreground"
colSpan={columnCount}
>
No orders match the current filters.
</Table.Td>
</Table.Tr>
)}
{pagedRows.map((row) => {
const isExpanded = expandedIds.has(row.id)
const panelId = `${row.id}-details`
return (
<Fragment key={row.id}>
<Table.Tr className="h-12">
<Table.Td className="h-12 w-12 p-0">
<div className="flex h-full items-center justify-center px-4">
<Checkbox
aria-label={`Select ${row.orderNo}`}
checked={selectedIds.has(row.id)}
onChange={(checked) =>
updateSelectedIds(row.id, checked)
}
/>
</div>
</Table.Td>
<Table.Td className="h-12 w-12 p-0">
<button
aria-controls={panelId}
aria-expanded={isExpanded}
aria-label={`${isExpanded ? 'Collapse' : 'Expand'} details for ${row.orderNo}`}
className="flex h-full w-full items-center justify-center text-muted-foreground hover:text-accent-foreground focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary"
onClick={() => toggleExpanded(row.id)}
>
<PiCaretRight
aria-hidden="true"
className={`transition-transform duration-150 ${isExpanded ? 'rotate-90' : ''}`}
/>
</button>
</Table.Td>
<Table.Td className="h-12 p-0">
<div className="flex h-full items-center px-4 font-medium whitespace-nowrap">
{row.orderNo}
</div>
</Table.Td>
<Table.Td className="h-12 p-0">
<div className="flex h-full items-center px-4 whitespace-nowrap">
{formatOrderDate(row.date)}
</div>
</Table.Td>
<Table.Td className="h-12 p-0">
<div className="flex h-full min-w-0 items-center gap-2 px-4">
<Avatar
alt={row.customerName}
className="shrink-0"
size={32}
src={row.customerAvatar}
/>
<span className="min-w-0 truncate font-medium">
{row.customerName}
</span>
</div>
</Table.Td>
<Table.Td className="h-12 p-0">
<div className="flex h-full items-center px-4">
{statusTag(row.status)}
</div>
</Table.Td>
<Table.Td className="h-12 w-12 p-0">
<div className="flex h-full items-center justify-center px-4">
<Dropdown
placement="bottom-end"
renderTitle={
<Button
aria-label={`More actions for ${row.orderNo}`}
icon={<PiDotsThreeVertical aria-hidden="true" />}
size="sm"
variant="ghost"
/>
}
>
<Dropdown.Item eventKey={`view-${row.id}`}>
View order
</Dropdown.Item>
<Dropdown.Item eventKey={`duplicate-${row.id}`}>
Duplicate order
</Dropdown.Item>
<Dropdown.Item eventKey={`archive-${row.id}`}>
Archive order
</Dropdown.Item>
</Dropdown>
</div>
</Table.Td>
</Table.Tr>
{isExpanded && (
<Table.Tr>
<Table.Td className="p-0" colSpan={columnCount}>
<div
className="sticky start-0"
id={panelId}
style={
scrollerWidth === null
? undefined
: { width: scrollerWidth }
}
>
<Collapsible open={isExpanded}>
<OrderDetailsPanel order={row} />
</Collapsible>
</div>
</Table.Td>
</Table.Tr>
)}
</Fragment>
)
})}
</Table.TBody>
</Table>
</div>
<footer className="flex flex-wrap items-center justify-between gap-4 border-t px-4 py-4">
<p aria-live="polite" className="text-sm text-muted-foreground">
Showing <span className="text-foreground">{rangeStart}–{rangeEnd}</span> of{' '}
{filteredRows.length} orders
</p>
</footer>
</>
)}
/>
</section>
)
}