create-ej-app 24.0.0 → 26.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.js +6 -0
- package/cli.ts +8 -0
- package/package.json +2 -2
- package/template/api/package.json +2 -2
- package/template/nextjs/gitignore +1 -0
- package/template/nextjs/package.json +20 -20
- package/template/nextjs/postcss.config.mjs +3 -1
- package/template/saas-starter/.dockerignore +7 -0
- package/template/saas-starter/.env.example +25 -0
- package/template/saas-starter/.vscode/extensions.json +11 -0
- package/template/saas-starter/.vscode/settings.json +3 -0
- package/template/saas-starter/Dockerfile +15 -0
- package/template/saas-starter/README.md +3 -0
- package/template/saas-starter/components.json +25 -0
- package/template/saas-starter/drizzle.config.ts +10 -0
- package/template/saas-starter/eslint.config.mjs +23 -0
- package/template/saas-starter/gitignore +9 -0
- package/template/saas-starter/next-env.d.ts +6 -0
- package/template/saas-starter/next.config.ts +14 -0
- package/template/saas-starter/package.json +92 -0
- package/template/saas-starter/postcss.config.mjs +7 -0
- package/template/saas-starter/prettier.config.mjs +11 -0
- package/template/saas-starter/public/placeholder.svg +1 -0
- package/template/saas-starter/src/actions/uploads.ts +28 -0
- package/template/saas-starter/src/actions/uploads.validation.ts +14 -0
- package/template/saas-starter/src/app/(admin)/actions/users.ts +97 -0
- package/template/saas-starter/src/app/(admin)/admin/_components/admin-header.tsx +21 -0
- package/template/saas-starter/src/app/(admin)/admin/_components/admin-search.tsx +16 -0
- package/template/saas-starter/src/app/(admin)/admin/_components/admin-sidebar.tsx +40 -0
- package/template/saas-starter/src/app/(admin)/admin/layout.tsx +57 -0
- package/template/saas-starter/src/app/(admin)/admin/page.tsx +3 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/create-user-dialog.tsx +183 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/edit-name-dialog.tsx +129 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/users-table.tsx +415 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/page.tsx +15 -0
- package/template/saas-starter/src/app/(app)/actions/billing.ts +152 -0
- package/template/saas-starter/src/app/(app)/actions/members.ts +163 -0
- package/template/saas-starter/src/app/(app)/actions/organization.ts +92 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/dashboard/page.tsx +20 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/layout.tsx +34 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/billing-email-form.tsx +100 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/danger-zone.tsx +364 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/general-settings-form.tsx +155 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/plans-section.tsx +232 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/billing/page.tsx +116 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/invite-member-dialog.tsx +169 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/members-tabs.tsx +571 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/page.tsx +40 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/page.tsx +69 -0
- package/template/saas-starter/src/app/(app)/app/_components/app-header.tsx +29 -0
- package/template/saas-starter/src/app/(app)/app/_components/app-search.tsx +17 -0
- package/template/saas-starter/src/app/(app)/app/_components/app-sidebar.tsx +27 -0
- package/template/saas-starter/src/app/(app)/app/_components/organization-context.tsx +41 -0
- package/template/saas-starter/src/app/(app)/app/_components/organization-switcher.tsx +140 -0
- package/template/saas-starter/src/app/(app)/app/layout.tsx +52 -0
- package/template/saas-starter/src/app/(app)/app/onboarding/_components/create-organization-form.tsx +129 -0
- package/template/saas-starter/src/app/(app)/app/onboarding/page.tsx +42 -0
- package/template/saas-starter/src/app/(app)/app/page.tsx +81 -0
- package/template/saas-starter/src/app/(auth)/login/email-login-form.tsx +109 -0
- package/template/saas-starter/src/app/(auth)/login/page.tsx +87 -0
- package/template/saas-starter/src/app/(auth)/login/social-login-buttons.tsx +75 -0
- package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx +88 -0
- package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/page.tsx +167 -0
- package/template/saas-starter/src/app/(marketing)/layout.tsx +14 -0
- package/template/saas-starter/src/app/(marketing)/page.tsx +3 -0
- package/template/saas-starter/src/app/(marketing)/privacy/page.tsx +3 -0
- package/template/saas-starter/src/app/(marketing)/terms/page.tsx +3 -0
- package/template/saas-starter/src/app/api/auth/[...all]/route.ts +4 -0
- package/template/saas-starter/src/app/api/webhooks/stripe/route.ts +94 -0
- package/template/saas-starter/src/app/favicon.ico +0 -0
- package/template/saas-starter/src/app/globals.css +240 -0
- package/template/saas-starter/src/app/layout.tsx +53 -0
- package/template/saas-starter/src/app/not-found.tsx +27 -0
- package/template/saas-starter/src/app/proxy.ts +28 -0
- package/template/saas-starter/src/app/robots.ts +32 -0
- package/template/saas-starter/src/app/sitemap.ts +20 -0
- package/template/saas-starter/src/components/dashboard-header.tsx +64 -0
- package/template/saas-starter/src/components/data-table/enum.ts +5 -0
- package/template/saas-starter/src/components/data-table/index.tsx +960 -0
- package/template/saas-starter/src/components/data-table/types.ts +75 -0
- package/template/saas-starter/src/components/error.tsx +26 -0
- package/template/saas-starter/src/components/file-upload.tsx +215 -0
- package/template/saas-starter/src/components/impersonation-banner.tsx +44 -0
- package/template/saas-starter/src/components/loader.tsx +25 -0
- package/template/saas-starter/src/components/navigation-search.tsx +140 -0
- package/template/saas-starter/src/components/navigation-sidebar.tsx +241 -0
- package/template/saas-starter/src/components/page-heading.tsx +32 -0
- package/template/saas-starter/src/components/react-query-provider.tsx +12 -0
- package/template/saas-starter/src/components/screen-size.tsx +40 -0
- package/template/saas-starter/src/components/site-footer.tsx +23 -0
- package/template/saas-starter/src/components/theme-provider.tsx +67 -0
- package/template/saas-starter/src/components/theme-toggle.tsx +22 -0
- package/template/saas-starter/src/components/ui/avatar.tsx +112 -0
- package/template/saas-starter/src/components/ui/badge.tsx +49 -0
- package/template/saas-starter/src/components/ui/button.tsx +67 -0
- package/template/saas-starter/src/components/ui/card.tsx +103 -0
- package/template/saas-starter/src/components/ui/checkbox.tsx +32 -0
- package/template/saas-starter/src/components/ui/collapsible.tsx +33 -0
- package/template/saas-starter/src/components/ui/command.tsx +192 -0
- package/template/saas-starter/src/components/ui/dialog.tsx +164 -0
- package/template/saas-starter/src/components/ui/dropdown-menu.tsx +273 -0
- package/template/saas-starter/src/components/ui/field.tsx +238 -0
- package/template/saas-starter/src/components/ui/input-group.tsx +156 -0
- package/template/saas-starter/src/components/ui/input.tsx +19 -0
- package/template/saas-starter/src/components/ui/label.tsx +24 -0
- package/template/saas-starter/src/components/ui/popover.tsx +89 -0
- package/template/saas-starter/src/components/ui/select.tsx +195 -0
- package/template/saas-starter/src/components/ui/separator.tsx +28 -0
- package/template/saas-starter/src/components/ui/sheet.tsx +143 -0
- package/template/saas-starter/src/components/ui/sidebar.tsx +702 -0
- package/template/saas-starter/src/components/ui/skeleton.tsx +13 -0
- package/template/saas-starter/src/components/ui/sonner.tsx +45 -0
- package/template/saas-starter/src/components/ui/switch.tsx +33 -0
- package/template/saas-starter/src/components/ui/table.tsx +116 -0
- package/template/saas-starter/src/components/ui/tabs.tsx +90 -0
- package/template/saas-starter/src/components/ui/textarea.tsx +18 -0
- package/template/saas-starter/src/components/ui/tooltip.tsx +57 -0
- package/template/saas-starter/src/components/user-menu.tsx +90 -0
- package/template/saas-starter/src/db/common.ts +31 -0
- package/template/saas-starter/src/db/enums.ts +26 -0
- package/template/saas-starter/src/db/index.ts +51 -0
- package/template/saas-starter/src/db/relations.ts +75 -0
- package/template/saas-starter/src/db/schema.ts +191 -0
- package/template/saas-starter/src/emails/components/email-layout.tsx +63 -0
- package/template/saas-starter/src/emails/invitation.tsx +56 -0
- package/template/saas-starter/src/emails/magic-link.tsx +49 -0
- package/template/saas-starter/src/emails/test.tsx +32 -0
- package/template/saas-starter/src/emails/welcome.tsx +34 -0
- package/template/saas-starter/src/env.ts +66 -0
- package/template/saas-starter/src/hooks/use-mobile.ts +19 -0
- package/template/saas-starter/src/instrumentation.ts +26 -0
- package/template/saas-starter/src/lib/actions.ts +21 -0
- package/template/saas-starter/src/lib/admin-navigation.ts +31 -0
- package/template/saas-starter/src/lib/app-navigation.ts +59 -0
- package/template/saas-starter/src/lib/auth-client.ts +29 -0
- package/template/saas-starter/src/lib/auth-permissions.ts +22 -0
- package/template/saas-starter/src/lib/auth.ts +211 -0
- package/template/saas-starter/src/lib/billing.ts +117 -0
- package/template/saas-starter/src/lib/cn.ts +4 -0
- package/template/saas-starter/src/lib/constants.ts +22 -0
- package/template/saas-starter/src/lib/delay.ts +2 -0
- package/template/saas-starter/src/lib/email-service.ts +89 -0
- package/template/saas-starter/src/lib/format-currency.ts +27 -0
- package/template/saas-starter/src/lib/format-date.ts +12 -0
- package/template/saas-starter/src/lib/nodemailer.ts +43 -0
- package/template/saas-starter/src/lib/organization-access.ts +93 -0
- package/template/saas-starter/src/lib/organization-plan-limits.ts +38 -0
- package/template/saas-starter/src/lib/otel.ts +27 -0
- package/template/saas-starter/src/lib/plans.ts +63 -0
- package/template/saas-starter/src/lib/safe-action-client.ts +75 -0
- package/template/saas-starter/src/lib/safe-action.ts +67 -0
- package/template/saas-starter/src/lib/siteConfig.ts +4 -0
- package/template/saas-starter/src/lib/storage.ts +181 -0
- package/template/saas-starter/src/lib/stripe.ts +6 -0
- package/template/saas-starter/src/lib/toast-message.ts +15 -0
- package/template/saas-starter/src/lib/validations.ts +12 -0
- package/template/saas-starter/src/scripts/reset-db.ts +22 -0
- package/template/saas-starter/src/scripts/seed.ts +11 -0
- package/template/saas-starter/src/scripts/setup-stripe-plans.ts +200 -0
- package/template/saas-starter/src/scripts/test-mail.ts +35 -0
- package/template/saas-starter/tsconfig.json +51 -0
|
@@ -0,0 +1,960 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Generic, feature-rich DataTable component built on TanStack Table.
|
|
3
|
+
*
|
|
4
|
+
* Supports:
|
|
5
|
+
* - Server-side (manual) or client-side pagination & sorting
|
|
6
|
+
* - Toolbar search input
|
|
7
|
+
* - Static and async (server-fetched) column filters
|
|
8
|
+
* - Column visibility toggle
|
|
9
|
+
* - Skeleton loading states
|
|
10
|
+
* - Active-filter pill bar with individual / bulk clear
|
|
11
|
+
*
|
|
12
|
+
* @module components/data-table
|
|
13
|
+
*/
|
|
14
|
+
'use client'
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
SortingState,
|
|
18
|
+
VisibilityState,
|
|
19
|
+
flexRender,
|
|
20
|
+
getCoreRowModel,
|
|
21
|
+
getPaginationRowModel,
|
|
22
|
+
getSortedRowModel,
|
|
23
|
+
useReactTable
|
|
24
|
+
} from '@tanstack/react-table'
|
|
25
|
+
import {
|
|
26
|
+
ArrowDown,
|
|
27
|
+
ArrowUp,
|
|
28
|
+
ArrowUpDown,
|
|
29
|
+
Check,
|
|
30
|
+
ChevronLeft,
|
|
31
|
+
ChevronRight,
|
|
32
|
+
ChevronsLeft,
|
|
33
|
+
ChevronsRight,
|
|
34
|
+
Filter,
|
|
35
|
+
Loader2,
|
|
36
|
+
Search,
|
|
37
|
+
SlidersHorizontal,
|
|
38
|
+
X
|
|
39
|
+
} from 'lucide-react'
|
|
40
|
+
import React, {
|
|
41
|
+
memo,
|
|
42
|
+
useCallback,
|
|
43
|
+
useEffect,
|
|
44
|
+
useId,
|
|
45
|
+
useMemo,
|
|
46
|
+
useRef,
|
|
47
|
+
useState
|
|
48
|
+
} from 'react'
|
|
49
|
+
import { Badge } from '~/components/ui/badge'
|
|
50
|
+
import { Button } from '~/components/ui/button'
|
|
51
|
+
import {
|
|
52
|
+
Command,
|
|
53
|
+
CommandEmpty,
|
|
54
|
+
CommandGroup,
|
|
55
|
+
CommandInput,
|
|
56
|
+
CommandItem,
|
|
57
|
+
CommandList
|
|
58
|
+
} from '~/components/ui/command'
|
|
59
|
+
import {
|
|
60
|
+
DropdownMenu,
|
|
61
|
+
DropdownMenuCheckboxItem,
|
|
62
|
+
DropdownMenuContent,
|
|
63
|
+
DropdownMenuItem,
|
|
64
|
+
DropdownMenuLabel,
|
|
65
|
+
DropdownMenuSeparator,
|
|
66
|
+
DropdownMenuSub,
|
|
67
|
+
DropdownMenuSubContent,
|
|
68
|
+
DropdownMenuSubTrigger,
|
|
69
|
+
DropdownMenuTrigger
|
|
70
|
+
} from '~/components/ui/dropdown-menu'
|
|
71
|
+
import { Input } from '~/components/ui/input'
|
|
72
|
+
import {
|
|
73
|
+
Select,
|
|
74
|
+
SelectContent,
|
|
75
|
+
SelectItem,
|
|
76
|
+
SelectTrigger,
|
|
77
|
+
SelectValue
|
|
78
|
+
} from '~/components/ui/select'
|
|
79
|
+
import {
|
|
80
|
+
Table,
|
|
81
|
+
TableBody,
|
|
82
|
+
TableCell,
|
|
83
|
+
TableHead,
|
|
84
|
+
TableHeader,
|
|
85
|
+
TableRow
|
|
86
|
+
} from '~/components/ui/table'
|
|
87
|
+
import {
|
|
88
|
+
Tooltip,
|
|
89
|
+
TooltipContent,
|
|
90
|
+
TooltipTrigger
|
|
91
|
+
} from '~/components/ui/tooltip'
|
|
92
|
+
import { cn } from '~/lib/cn'
|
|
93
|
+
import { useSafeActionQuery } from '~/lib/safe-action-client'
|
|
94
|
+
import { Skeleton } from '../ui/skeleton'
|
|
95
|
+
import { SortOrderEnum } from './enum'
|
|
96
|
+
import { DataTableFilter, DataTableProps } from './types'
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Filter sub-content for filters with pre-defined static options.
|
|
100
|
+
* Renders a searchable command list with checkboxes for multi-select.
|
|
101
|
+
*/
|
|
102
|
+
const StaticFilterSubContent = memo(function StaticFilterSubContent({
|
|
103
|
+
filter,
|
|
104
|
+
activeFilters,
|
|
105
|
+
onSelect
|
|
106
|
+
}: {
|
|
107
|
+
filter: DataTableFilter
|
|
108
|
+
activeFilters: Record<string, (string | boolean)[]>
|
|
109
|
+
onSelect: (values: (string | boolean)[]) => void
|
|
110
|
+
}) {
|
|
111
|
+
// Convert all selected values to strings for uniform Set comparison
|
|
112
|
+
const selectedValues = new Set((activeFilters[filter.id] || []).map(String))
|
|
113
|
+
return (
|
|
114
|
+
<Command>
|
|
115
|
+
<CommandInput placeholder={filter.label} />
|
|
116
|
+
<CommandList>
|
|
117
|
+
<CommandEmpty>No results found.</CommandEmpty>
|
|
118
|
+
<CommandGroup>
|
|
119
|
+
{filter.options?.map(option => {
|
|
120
|
+
const isSelected = selectedValues.has(String(option.value))
|
|
121
|
+
const isTruncated = option.label && option.label.length > 18
|
|
122
|
+
return (
|
|
123
|
+
<CommandItem
|
|
124
|
+
key={String(option.value)}
|
|
125
|
+
className="data-selected:bg-accent data-selected:text-accent-foreground"
|
|
126
|
+
onSelect={() => {
|
|
127
|
+
const newSet = new Set(selectedValues)
|
|
128
|
+
if (isSelected) {
|
|
129
|
+
newSet.delete(String(option.value))
|
|
130
|
+
} else {
|
|
131
|
+
newSet.add(String(option.value))
|
|
132
|
+
}
|
|
133
|
+
// Map back to original values
|
|
134
|
+
const values = Array.from(newSet).map(v => {
|
|
135
|
+
const op = filter.options?.find(o => String(o.value) === v)
|
|
136
|
+
return op ? op.value : v
|
|
137
|
+
})
|
|
138
|
+
onSelect(values)
|
|
139
|
+
}}
|
|
140
|
+
>
|
|
141
|
+
<div
|
|
142
|
+
className={cn(
|
|
143
|
+
'mr-2 flex size-4 shrink-0 items-center justify-center rounded-lg border transition-colors',
|
|
144
|
+
isSelected
|
|
145
|
+
? 'border-primary bg-primary text-primary-foreground'
|
|
146
|
+
: 'border-input bg-background text-transparent'
|
|
147
|
+
)}
|
|
148
|
+
>
|
|
149
|
+
<Check
|
|
150
|
+
className={cn(
|
|
151
|
+
'size-3',
|
|
152
|
+
isSelected
|
|
153
|
+
? 'text-primary-foreground!'
|
|
154
|
+
: 'text-transparent'
|
|
155
|
+
)}
|
|
156
|
+
/>
|
|
157
|
+
</div>
|
|
158
|
+
{option.icon && (
|
|
159
|
+
<option.icon className="text-muted-foreground mr-2 h-4 w-4" />
|
|
160
|
+
)}
|
|
161
|
+
{isTruncated ? (
|
|
162
|
+
<Tooltip>
|
|
163
|
+
<TooltipTrigger asChild>
|
|
164
|
+
<span>{option.label.slice(0, 18) + '...'}</span>
|
|
165
|
+
</TooltipTrigger>
|
|
166
|
+
<TooltipContent side="right">
|
|
167
|
+
<p className="max-w-75 break-all">{option.label}</p>
|
|
168
|
+
</TooltipContent>
|
|
169
|
+
</Tooltip>
|
|
170
|
+
) : (
|
|
171
|
+
<span>{option.label}</span>
|
|
172
|
+
)}
|
|
173
|
+
</CommandItem>
|
|
174
|
+
)
|
|
175
|
+
})}
|
|
176
|
+
</CommandGroup>
|
|
177
|
+
</CommandList>
|
|
178
|
+
</Command>
|
|
179
|
+
)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Filter sub-content for async filters whose options are fetched
|
|
184
|
+
* from the server via a safe-action. Includes debounced search input.
|
|
185
|
+
*/
|
|
186
|
+
const AsyncFilterSubContent = memo(function AsyncFilterSubContent({
|
|
187
|
+
filter,
|
|
188
|
+
activeFilters,
|
|
189
|
+
onSelect,
|
|
190
|
+
onCacheLabels
|
|
191
|
+
}: {
|
|
192
|
+
filter: DataTableFilter
|
|
193
|
+
activeFilters: Record<string, (string | boolean)[]>
|
|
194
|
+
onSelect: (values: string[]) => void
|
|
195
|
+
onCacheLabels: (labels: Record<string, string>) => void
|
|
196
|
+
}) {
|
|
197
|
+
const selectedValues = new Set((activeFilters[filter.id] || []).map(String))
|
|
198
|
+
const [search, setSearch] = useState('')
|
|
199
|
+
const [debouncedSearch, setDebouncedSearch] = useState('')
|
|
200
|
+
|
|
201
|
+
// Debounce the search input to avoid excessive server calls
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
const timer = setTimeout(() => setDebouncedSearch(search), 500)
|
|
204
|
+
return () => clearTimeout(timer)
|
|
205
|
+
}, [search])
|
|
206
|
+
|
|
207
|
+
// Fetch filter options from the server action with debounced search
|
|
208
|
+
const { data, isPending } = useSafeActionQuery(
|
|
209
|
+
filter.queryKey || filter.id,
|
|
210
|
+
filter.action,
|
|
211
|
+
{
|
|
212
|
+
search: debouncedSearch,
|
|
213
|
+
limit: 20,
|
|
214
|
+
page: 1
|
|
215
|
+
}
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
// Normalise various server response shapes into a flat array
|
|
219
|
+
const items = useMemo<{ label: string; value: string | number | boolean }[]>(
|
|
220
|
+
() =>
|
|
221
|
+
(data || []).map((item: any) => ({
|
|
222
|
+
label: item.label ?? item.name ?? item.id,
|
|
223
|
+
value: item.value ?? item.id
|
|
224
|
+
})),
|
|
225
|
+
[data]
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<Command shouldFilter={false}>
|
|
230
|
+
<CommandInput
|
|
231
|
+
placeholder={filter.label}
|
|
232
|
+
value={search}
|
|
233
|
+
onValueChange={setSearch}
|
|
234
|
+
/>
|
|
235
|
+
<CommandList>
|
|
236
|
+
{isPending && (
|
|
237
|
+
<div className="flex items-center justify-center py-4">
|
|
238
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
239
|
+
</div>
|
|
240
|
+
)}
|
|
241
|
+
{!isPending && items.length === 0 && (
|
|
242
|
+
<CommandEmpty>No results found.</CommandEmpty>
|
|
243
|
+
)}
|
|
244
|
+
<CommandGroup>
|
|
245
|
+
{items.map(item => {
|
|
246
|
+
const value = String(item.value)
|
|
247
|
+
const label = item.label
|
|
248
|
+
const isSelected = selectedValues.has(value)
|
|
249
|
+
const isTruncated = label && label.length > 18
|
|
250
|
+
return (
|
|
251
|
+
<CommandItem
|
|
252
|
+
key={value}
|
|
253
|
+
className="data-selected:bg-accent data-selected:text-accent-foreground"
|
|
254
|
+
onSelect={() => {
|
|
255
|
+
const newSet = new Set(selectedValues)
|
|
256
|
+
if (isSelected) {
|
|
257
|
+
newSet.delete(value)
|
|
258
|
+
} else {
|
|
259
|
+
newSet.add(value)
|
|
260
|
+
// Update cache when selecting an item
|
|
261
|
+
onCacheLabels({ [value]: label })
|
|
262
|
+
}
|
|
263
|
+
onSelect(Array.from(newSet))
|
|
264
|
+
}}
|
|
265
|
+
>
|
|
266
|
+
<div
|
|
267
|
+
className={cn(
|
|
268
|
+
'mr-2 flex size-4 shrink-0 items-center justify-center rounded-lg border transition-colors',
|
|
269
|
+
isSelected
|
|
270
|
+
? 'border-primary bg-primary text-primary-foreground'
|
|
271
|
+
: 'border-input bg-background text-transparent'
|
|
272
|
+
)}
|
|
273
|
+
>
|
|
274
|
+
<Check
|
|
275
|
+
className={cn(
|
|
276
|
+
'size-3',
|
|
277
|
+
isSelected
|
|
278
|
+
? 'text-primary-foreground!'
|
|
279
|
+
: 'text-transparent'
|
|
280
|
+
)}
|
|
281
|
+
/>
|
|
282
|
+
</div>
|
|
283
|
+
{/* Truncate long labels for better readability */}
|
|
284
|
+
{isTruncated ? (
|
|
285
|
+
<Tooltip>
|
|
286
|
+
<TooltipTrigger asChild>
|
|
287
|
+
<span>{label.slice(0, 18) + '...'}</span>
|
|
288
|
+
</TooltipTrigger>
|
|
289
|
+
<TooltipContent side="right">
|
|
290
|
+
<p className="max-w-75 break-all">{label}</p>
|
|
291
|
+
</TooltipContent>
|
|
292
|
+
</Tooltip>
|
|
293
|
+
) : (
|
|
294
|
+
<span>{label}</span>
|
|
295
|
+
)}
|
|
296
|
+
</CommandItem>
|
|
297
|
+
)
|
|
298
|
+
})}
|
|
299
|
+
</CommandGroup>
|
|
300
|
+
</CommandList>
|
|
301
|
+
</Command>
|
|
302
|
+
)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Generic DataTable component with toolbar, filters, sorting and pagination.
|
|
307
|
+
*
|
|
308
|
+
* @template TData Row data shape
|
|
309
|
+
* @template TValue Cell value shape
|
|
310
|
+
*/
|
|
311
|
+
export function DataTable<TData, TValue>({
|
|
312
|
+
columns,
|
|
313
|
+
data = [],
|
|
314
|
+
isLoading = false,
|
|
315
|
+
enableSearch = false,
|
|
316
|
+
searchPlaceholder = 'Search...',
|
|
317
|
+
initialSearch = '',
|
|
318
|
+
totalCount,
|
|
319
|
+
onParamsChange,
|
|
320
|
+
manualPagination = false,
|
|
321
|
+
manualSorting = false,
|
|
322
|
+
pageIndex: controlledPageIndex = 0,
|
|
323
|
+
pageSize: controlledPageSize = 10,
|
|
324
|
+
initialSorting = [],
|
|
325
|
+
filters = [],
|
|
326
|
+
activeFilters = {}
|
|
327
|
+
}: DataTableProps<TData, TValue>) {
|
|
328
|
+
const [sorting, setSorting] = useState<SortingState>(initialSorting)
|
|
329
|
+
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
|
330
|
+
const [searchValue, setSearchValue] = useState(initialSearch)
|
|
331
|
+
const [pagination, setPagination] = useState({
|
|
332
|
+
pageIndex: controlledPageIndex,
|
|
333
|
+
pageSize: controlledPageSize
|
|
334
|
+
})
|
|
335
|
+
/** Cache for async filter labels so pills display correctly after data refetch. */
|
|
336
|
+
const [labelCache, setLabelCache] = useState<Record<string, string>>({})
|
|
337
|
+
const searchInputId = useId()
|
|
338
|
+
const lastTotalCountRef = useRef<number | undefined>(totalCount)
|
|
339
|
+
/** Ref to always read the latest activeFilters inside async callbacks (avoids stale closures). */
|
|
340
|
+
const activeFiltersRef = useRef(activeFilters)
|
|
341
|
+
activeFiltersRef.current = activeFilters
|
|
342
|
+
/** Set by the prop-sync effect so the debounced effect can skip redundant fires. */
|
|
343
|
+
const isExternalPaginationSync = useRef(false)
|
|
344
|
+
|
|
345
|
+
// Keep internal pagination in sync with controlled props from parent.
|
|
346
|
+
// Without this, external resets (e.g. filter change → page 1) are ignored.
|
|
347
|
+
useEffect(() => {
|
|
348
|
+
setPagination(prev => {
|
|
349
|
+
if (
|
|
350
|
+
prev.pageIndex !== controlledPageIndex ||
|
|
351
|
+
prev.pageSize !== controlledPageSize
|
|
352
|
+
) {
|
|
353
|
+
// Flag so the debounced effect skips its next fire – the filter/clear
|
|
354
|
+
// handler that triggered this prop change already called onParamsChange.
|
|
355
|
+
isExternalPaginationSync.current = true
|
|
356
|
+
return {
|
|
357
|
+
pageIndex: controlledPageIndex,
|
|
358
|
+
pageSize: controlledPageSize
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return prev
|
|
362
|
+
})
|
|
363
|
+
}, [controlledPageIndex, controlledPageSize])
|
|
364
|
+
|
|
365
|
+
// Preserve totalCount during loading to avoid pagination flicker (e.g. "Page 1 of 1" when switching pages).
|
|
366
|
+
// Updating a ref synchronously during render is a safe, idempotent pattern documented by React
|
|
367
|
+
// because refs are not reactive and this does not trigger a re-render.
|
|
368
|
+
if (totalCount !== undefined && totalCount > 0) {
|
|
369
|
+
lastTotalCountRef.current = totalCount
|
|
370
|
+
}
|
|
371
|
+
const stableTotalCount = useMemo(
|
|
372
|
+
() =>
|
|
373
|
+
isLoading && totalCount === 0 && lastTotalCountRef.current !== undefined
|
|
374
|
+
? lastTotalCountRef.current
|
|
375
|
+
: totalCount,
|
|
376
|
+
// lastTotalCountRef is a ref - intentionally omitted from deps
|
|
377
|
+
[isLoading, totalCount]
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
// Initialise TanStack Table with manual pagination/sorting when enabled
|
|
381
|
+
const table = useReactTable({
|
|
382
|
+
data,
|
|
383
|
+
columns,
|
|
384
|
+
getCoreRowModel: getCoreRowModel(),
|
|
385
|
+
...(!manualPagination && {
|
|
386
|
+
getPaginationRowModel: getPaginationRowModel()
|
|
387
|
+
}),
|
|
388
|
+
...(!manualSorting && { getSortedRowModel: getSortedRowModel() }),
|
|
389
|
+
manualPagination,
|
|
390
|
+
manualSorting,
|
|
391
|
+
...(manualPagination && {
|
|
392
|
+
pageCount:
|
|
393
|
+
Math.ceil((stableTotalCount ?? data.length) / pagination.pageSize) || 1
|
|
394
|
+
}),
|
|
395
|
+
onSortingChange: setSorting,
|
|
396
|
+
onColumnVisibilityChange: setColumnVisibility,
|
|
397
|
+
onPaginationChange: setPagination,
|
|
398
|
+
state: {
|
|
399
|
+
sorting,
|
|
400
|
+
columnVisibility,
|
|
401
|
+
pagination
|
|
402
|
+
}
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
/** Skip the initial render to avoid firing onParamsChange on mount. */
|
|
406
|
+
const isFirstRender = useRef(true)
|
|
407
|
+
|
|
408
|
+
// Stable reference for onParamsChange to avoid unnecessary effect re-runs
|
|
409
|
+
const onParamsChangeRef = useRef(onParamsChange)
|
|
410
|
+
onParamsChangeRef.current = onParamsChange
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Build the params object sent to onParamsChange.
|
|
414
|
+
* Uses `activeFiltersRef` so async callbacks always read the latest value.
|
|
415
|
+
* Pass `overrides` to set page or filters explicitly (e.g. on filter change).
|
|
416
|
+
*/
|
|
417
|
+
const buildParams = useCallback(
|
|
418
|
+
(
|
|
419
|
+
overrides?: Partial<{
|
|
420
|
+
page: number
|
|
421
|
+
filters: Record<string, (string | boolean)[]> | undefined
|
|
422
|
+
}>
|
|
423
|
+
) => {
|
|
424
|
+
const sort = sorting[0]
|
|
425
|
+
const filtersValue =
|
|
426
|
+
overrides && 'filters' in overrides
|
|
427
|
+
? overrides.filters
|
|
428
|
+
: activeFiltersRef.current &&
|
|
429
|
+
Object.keys(activeFiltersRef.current).length > 0
|
|
430
|
+
? activeFiltersRef.current
|
|
431
|
+
: undefined
|
|
432
|
+
return {
|
|
433
|
+
page: overrides?.page ?? pagination.pageIndex + 1,
|
|
434
|
+
limit: pagination.pageSize,
|
|
435
|
+
sortBy: sort?.id,
|
|
436
|
+
sortOrder: sort
|
|
437
|
+
? sort.desc
|
|
438
|
+
? SortOrderEnum.DESC
|
|
439
|
+
: SortOrderEnum.ASC
|
|
440
|
+
: undefined,
|
|
441
|
+
search: searchValue || undefined,
|
|
442
|
+
filters: filtersValue
|
|
443
|
+
}
|
|
444
|
+
},
|
|
445
|
+
[pagination.pageIndex, pagination.pageSize, searchValue, sorting]
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
// Debounce param changes (300ms) to batch rapid user interactions.
|
|
449
|
+
// Only watches pagination, sorting, and searchValue – NOT activeFilters,
|
|
450
|
+
// because filter handlers already call onParamsChange directly.
|
|
451
|
+
useEffect(() => {
|
|
452
|
+
if (isFirstRender.current) {
|
|
453
|
+
isFirstRender.current = false
|
|
454
|
+
return
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Skip when the pagination change came from an external prop sync
|
|
458
|
+
// (e.g. parent reset page after a filter handler already called onParamsChange).
|
|
459
|
+
if (isExternalPaginationSync.current) {
|
|
460
|
+
isExternalPaginationSync.current = false
|
|
461
|
+
return
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (!onParamsChangeRef.current) return
|
|
465
|
+
|
|
466
|
+
const timeout = setTimeout(() => {
|
|
467
|
+
onParamsChangeRef.current?.(buildParams())
|
|
468
|
+
}, 300)
|
|
469
|
+
|
|
470
|
+
return () => clearTimeout(timeout)
|
|
471
|
+
}, [pagination, sorting, searchValue, buildParams])
|
|
472
|
+
|
|
473
|
+
/** Whether any filter has at least one selected value. */
|
|
474
|
+
const hasAnyFilter = useMemo(
|
|
475
|
+
() => Object.values(activeFilters).some(f => f.length > 0),
|
|
476
|
+
[activeFilters]
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
/** Sum of all selected filter values across all filters (shown as badge count). */
|
|
480
|
+
const totalActiveFilterCount = useMemo(
|
|
481
|
+
() =>
|
|
482
|
+
Object.values(activeFilters).reduce((acc, curr) => acc + curr.length, 0),
|
|
483
|
+
[activeFilters]
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
/** Stable callback for async filter sub-components to update label cache. */
|
|
487
|
+
const handleCacheLabels = useCallback((newLabels: Record<string, string>) => {
|
|
488
|
+
setLabelCache(prev => ({ ...prev, ...newLabels }))
|
|
489
|
+
}, [])
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Stable factory for per-filter onSelect handlers.
|
|
493
|
+
* Returns a memoized handler for each filter id so sub-components
|
|
494
|
+
* receive a stable reference and avoid unnecessary re-renders.
|
|
495
|
+
*/
|
|
496
|
+
const makeFilterSelectHandler = useCallback(
|
|
497
|
+
(filterId: string) => (values: (string | boolean)[]) => {
|
|
498
|
+
const newFilters: Record<string, (string | boolean)[]> = {
|
|
499
|
+
...(activeFilters || {})
|
|
500
|
+
}
|
|
501
|
+
if (values.length > 0) {
|
|
502
|
+
newFilters[filterId] = values
|
|
503
|
+
} else {
|
|
504
|
+
delete newFilters[filterId]
|
|
505
|
+
}
|
|
506
|
+
onParamsChange?.(
|
|
507
|
+
buildParams({
|
|
508
|
+
page: 1,
|
|
509
|
+
filters: Object.keys(newFilters).length > 0 ? newFilters : undefined
|
|
510
|
+
})
|
|
511
|
+
)
|
|
512
|
+
},
|
|
513
|
+
[activeFilters, buildParams, onParamsChange]
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
return (
|
|
517
|
+
<div className="space-y-4">
|
|
518
|
+
{/* ── Toolbar: search, filter dropdown, column visibility toggle ── */}
|
|
519
|
+
<div className="flex items-center justify-between gap-2">
|
|
520
|
+
<div className="flex flex-1 items-center gap-2">
|
|
521
|
+
{enableSearch && (
|
|
522
|
+
<div className="relative max-w-sm flex-1">
|
|
523
|
+
<Search
|
|
524
|
+
className="text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4"
|
|
525
|
+
aria-hidden
|
|
526
|
+
/>
|
|
527
|
+
<label htmlFor={searchInputId} className="sr-only">
|
|
528
|
+
{searchPlaceholder}
|
|
529
|
+
</label>
|
|
530
|
+
<Input
|
|
531
|
+
id={searchInputId}
|
|
532
|
+
type="search"
|
|
533
|
+
placeholder={searchPlaceholder}
|
|
534
|
+
value={searchValue}
|
|
535
|
+
onChange={event => {
|
|
536
|
+
setSearchValue(event.target.value)
|
|
537
|
+
// Reset to first page when search query changes
|
|
538
|
+
setPagination(prev => ({ ...prev, pageIndex: 0 }))
|
|
539
|
+
}}
|
|
540
|
+
className="pl-8"
|
|
541
|
+
aria-label={searchPlaceholder}
|
|
542
|
+
/>
|
|
543
|
+
</div>
|
|
544
|
+
)}
|
|
545
|
+
{filters.length > 0 && (
|
|
546
|
+
<DropdownMenu>
|
|
547
|
+
<DropdownMenuTrigger asChild>
|
|
548
|
+
<Button
|
|
549
|
+
variant="outline"
|
|
550
|
+
size="sm"
|
|
551
|
+
className="gap-1"
|
|
552
|
+
aria-label="Filter"
|
|
553
|
+
>
|
|
554
|
+
<Filter className="h-4 w-4" />
|
|
555
|
+
{totalActiveFilterCount > 0 && (
|
|
556
|
+
<Badge
|
|
557
|
+
variant="secondary"
|
|
558
|
+
className="pointer-events-none ml-1 h-5 min-w-5 px-1"
|
|
559
|
+
>
|
|
560
|
+
{totalActiveFilterCount}
|
|
561
|
+
</Badge>
|
|
562
|
+
)}
|
|
563
|
+
</Button>
|
|
564
|
+
</DropdownMenuTrigger>
|
|
565
|
+
<DropdownMenuContent align="start" className="w-50">
|
|
566
|
+
<DropdownMenuLabel>Filter by</DropdownMenuLabel>
|
|
567
|
+
<DropdownMenuSeparator />
|
|
568
|
+
{/* Render each filter as a sub-menu with either async or static content */}
|
|
569
|
+
{filters.map(filter => (
|
|
570
|
+
<DropdownMenuSub key={filter.id}>
|
|
571
|
+
<DropdownMenuSubTrigger>
|
|
572
|
+
{filter.label}
|
|
573
|
+
</DropdownMenuSubTrigger>
|
|
574
|
+
<DropdownMenuSubContent className="w-50 p-0">
|
|
575
|
+
{filter.action ? (
|
|
576
|
+
<AsyncFilterSubContent
|
|
577
|
+
filter={filter}
|
|
578
|
+
activeFilters={activeFilters}
|
|
579
|
+
onCacheLabels={handleCacheLabels}
|
|
580
|
+
onSelect={
|
|
581
|
+
makeFilterSelectHandler(filter.id) as (
|
|
582
|
+
values: string[]
|
|
583
|
+
) => void
|
|
584
|
+
}
|
|
585
|
+
/>
|
|
586
|
+
) : (
|
|
587
|
+
<StaticFilterSubContent
|
|
588
|
+
filter={filter}
|
|
589
|
+
activeFilters={activeFilters}
|
|
590
|
+
onSelect={makeFilterSelectHandler(filter.id)}
|
|
591
|
+
/>
|
|
592
|
+
)}
|
|
593
|
+
</DropdownMenuSubContent>
|
|
594
|
+
</DropdownMenuSub>
|
|
595
|
+
))}
|
|
596
|
+
{hasAnyFilter && (
|
|
597
|
+
<>
|
|
598
|
+
<DropdownMenuSeparator />
|
|
599
|
+
<DropdownMenuItem
|
|
600
|
+
onSelect={() => {
|
|
601
|
+
onParamsChange?.(
|
|
602
|
+
buildParams({ page: 1, filters: undefined })
|
|
603
|
+
)
|
|
604
|
+
}}
|
|
605
|
+
className="justify-center text-center"
|
|
606
|
+
>
|
|
607
|
+
Clear filters
|
|
608
|
+
</DropdownMenuItem>
|
|
609
|
+
</>
|
|
610
|
+
)}
|
|
611
|
+
</DropdownMenuContent>
|
|
612
|
+
</DropdownMenu>
|
|
613
|
+
)}
|
|
614
|
+
</div>
|
|
615
|
+
{/* Column visibility toggle dropdown */}
|
|
616
|
+
<DropdownMenu>
|
|
617
|
+
<DropdownMenuTrigger asChild>
|
|
618
|
+
<Button
|
|
619
|
+
variant="outline"
|
|
620
|
+
size="sm"
|
|
621
|
+
className="gap-1"
|
|
622
|
+
aria-label="Toggle columns"
|
|
623
|
+
>
|
|
624
|
+
<SlidersHorizontal className="h-4 w-4" />
|
|
625
|
+
</Button>
|
|
626
|
+
</DropdownMenuTrigger>
|
|
627
|
+
<DropdownMenuContent align="end" className="w-40">
|
|
628
|
+
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
|
|
629
|
+
<DropdownMenuSeparator />
|
|
630
|
+
{table
|
|
631
|
+
.getAllColumns()
|
|
632
|
+
.filter(column => column.getCanHide())
|
|
633
|
+
.map(column => {
|
|
634
|
+
return (
|
|
635
|
+
<DropdownMenuCheckboxItem
|
|
636
|
+
key={column.id}
|
|
637
|
+
className="capitalize"
|
|
638
|
+
checked={column.getIsVisible()}
|
|
639
|
+
onCheckedChange={value => column.toggleVisibility(!!value)}
|
|
640
|
+
onSelect={e => e.preventDefault()}
|
|
641
|
+
>
|
|
642
|
+
{column.id}
|
|
643
|
+
</DropdownMenuCheckboxItem>
|
|
644
|
+
)
|
|
645
|
+
})}
|
|
646
|
+
</DropdownMenuContent>
|
|
647
|
+
</DropdownMenu>
|
|
648
|
+
</div>
|
|
649
|
+
|
|
650
|
+
{/* ── Active filter pills bar ── */}
|
|
651
|
+
{hasAnyFilter && (
|
|
652
|
+
<div className="flex flex-wrap gap-2">
|
|
653
|
+
{filters.map(filter => {
|
|
654
|
+
const selected = activeFilters[filter.id]
|
|
655
|
+
if (!selected || selected.length === 0) return null
|
|
656
|
+
return (
|
|
657
|
+
<div
|
|
658
|
+
key={filter.id}
|
|
659
|
+
className="bg-accent/50 flex items-center gap-1 rounded-md px-2 py-1 text-sm"
|
|
660
|
+
>
|
|
661
|
+
<span className="font-medium">{filter.label}:</span>
|
|
662
|
+
<div className="flex gap-1">
|
|
663
|
+
{selected.length > 2 ? (
|
|
664
|
+
<Badge variant="secondary" className="px-1 font-normal">
|
|
665
|
+
{selected.length} selected
|
|
666
|
+
</Badge>
|
|
667
|
+
) : (
|
|
668
|
+
selected.map(val => {
|
|
669
|
+
const displayLabel =
|
|
670
|
+
filter.options?.find(
|
|
671
|
+
o => String(o.value) === String(val)
|
|
672
|
+
)?.label ||
|
|
673
|
+
labelCache[String(val)] ||
|
|
674
|
+
String(val)
|
|
675
|
+
const isTruncated =
|
|
676
|
+
displayLabel && displayLabel.length > 18
|
|
677
|
+
return isTruncated ? (
|
|
678
|
+
<Tooltip key={String(val)}>
|
|
679
|
+
<TooltipTrigger asChild>
|
|
680
|
+
<Badge
|
|
681
|
+
variant="secondary"
|
|
682
|
+
className="px-1 font-normal"
|
|
683
|
+
>
|
|
684
|
+
{displayLabel.slice(0, 18) + '...'}
|
|
685
|
+
</Badge>
|
|
686
|
+
</TooltipTrigger>
|
|
687
|
+
<TooltipContent>
|
|
688
|
+
<p className="max-w-75 break-all">{displayLabel}</p>
|
|
689
|
+
</TooltipContent>
|
|
690
|
+
</Tooltip>
|
|
691
|
+
) : (
|
|
692
|
+
<Badge
|
|
693
|
+
key={String(val)}
|
|
694
|
+
variant="secondary"
|
|
695
|
+
className="px-1 font-normal"
|
|
696
|
+
>
|
|
697
|
+
{displayLabel}
|
|
698
|
+
</Badge>
|
|
699
|
+
)
|
|
700
|
+
})
|
|
701
|
+
)}
|
|
702
|
+
</div>
|
|
703
|
+
<Button
|
|
704
|
+
variant="ghost"
|
|
705
|
+
className="hover:bg-muted ml-1 h-4 w-4 rounded-full p-0"
|
|
706
|
+
aria-label={`Clear ${filter.label} filter`}
|
|
707
|
+
onClick={() => {
|
|
708
|
+
const newFilters = { ...activeFilters }
|
|
709
|
+
delete newFilters[filter.id]
|
|
710
|
+
onParamsChange?.(
|
|
711
|
+
buildParams({
|
|
712
|
+
page: 1,
|
|
713
|
+
filters:
|
|
714
|
+
Object.keys(newFilters).length > 0
|
|
715
|
+
? newFilters
|
|
716
|
+
: undefined
|
|
717
|
+
})
|
|
718
|
+
)
|
|
719
|
+
}}
|
|
720
|
+
>
|
|
721
|
+
<X className="h-3 w-3" />
|
|
722
|
+
</Button>
|
|
723
|
+
</div>
|
|
724
|
+
)
|
|
725
|
+
})}
|
|
726
|
+
<Button
|
|
727
|
+
variant="ghost"
|
|
728
|
+
className="h-7 px-2 text-xs"
|
|
729
|
+
onClick={() => {
|
|
730
|
+
onParamsChange?.(buildParams({ page: 1, filters: undefined }))
|
|
731
|
+
}}
|
|
732
|
+
>
|
|
733
|
+
Reset
|
|
734
|
+
</Button>
|
|
735
|
+
</div>
|
|
736
|
+
)}
|
|
737
|
+
|
|
738
|
+
{/* ── Table body ── */}
|
|
739
|
+
<div className="bg-card relative rounded-md border">
|
|
740
|
+
{isLoading && data.length > 0 && (
|
|
741
|
+
<div
|
|
742
|
+
role="status"
|
|
743
|
+
aria-label="Loading"
|
|
744
|
+
aria-live="polite"
|
|
745
|
+
className="bg-background/50 absolute inset-0 z-10 flex items-center justify-center rounded-md"
|
|
746
|
+
>
|
|
747
|
+
<Loader2
|
|
748
|
+
className="text-muted-foreground h-6 w-6 animate-spin"
|
|
749
|
+
aria-hidden
|
|
750
|
+
/>
|
|
751
|
+
</div>
|
|
752
|
+
)}
|
|
753
|
+
<Table aria-busy={isLoading} aria-label="Data table">
|
|
754
|
+
<TableHeader>
|
|
755
|
+
{table.getHeaderGroups().map(headerGroup => (
|
|
756
|
+
<TableRow key={headerGroup.id}>
|
|
757
|
+
{headerGroup.headers.map(header => {
|
|
758
|
+
const isSortable = header.column.getCanSort()
|
|
759
|
+
const sortStatus = header.column.getIsSorted()
|
|
760
|
+
|
|
761
|
+
return (
|
|
762
|
+
<TableHead key={header.id} colSpan={header.colSpan}>
|
|
763
|
+
{header.isPlaceholder ? null : (
|
|
764
|
+
<div
|
|
765
|
+
role={isSortable ? 'button' : undefined}
|
|
766
|
+
tabIndex={isSortable ? 0 : undefined}
|
|
767
|
+
aria-sort={
|
|
768
|
+
isSortable
|
|
769
|
+
? sortStatus === 'asc'
|
|
770
|
+
? 'ascending'
|
|
771
|
+
: sortStatus === 'desc'
|
|
772
|
+
? 'descending'
|
|
773
|
+
: 'none'
|
|
774
|
+
: undefined
|
|
775
|
+
}
|
|
776
|
+
className={cn(
|
|
777
|
+
'flex items-center gap-2',
|
|
778
|
+
isSortable &&
|
|
779
|
+
'hover:text-foreground cursor-pointer select-none'
|
|
780
|
+
)}
|
|
781
|
+
onClick={header.column.getToggleSortingHandler()}
|
|
782
|
+
onKeyDown={
|
|
783
|
+
isSortable
|
|
784
|
+
? e => {
|
|
785
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
786
|
+
e.preventDefault()
|
|
787
|
+
header.column.getToggleSortingHandler()?.(
|
|
788
|
+
e as unknown as React.MouseEvent
|
|
789
|
+
)
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
: undefined
|
|
793
|
+
}
|
|
794
|
+
>
|
|
795
|
+
{flexRender(
|
|
796
|
+
header.column.columnDef.header,
|
|
797
|
+
header.getContext()
|
|
798
|
+
)}
|
|
799
|
+
{isSortable && (
|
|
800
|
+
<div className="flex w-4 items-center justify-center">
|
|
801
|
+
{sortStatus === 'asc' ? (
|
|
802
|
+
<ArrowUp
|
|
803
|
+
className="h-4 w-4"
|
|
804
|
+
aria-label="Sorted ascending"
|
|
805
|
+
/>
|
|
806
|
+
) : sortStatus === 'desc' ? (
|
|
807
|
+
<ArrowDown
|
|
808
|
+
className="h-4 w-4"
|
|
809
|
+
aria-label="Sorted descending"
|
|
810
|
+
/>
|
|
811
|
+
) : (
|
|
812
|
+
<ArrowUpDown
|
|
813
|
+
className="h-4 w-4 opacity-30"
|
|
814
|
+
aria-label="Not sorted"
|
|
815
|
+
/>
|
|
816
|
+
)}
|
|
817
|
+
</div>
|
|
818
|
+
)}
|
|
819
|
+
</div>
|
|
820
|
+
)}
|
|
821
|
+
</TableHead>
|
|
822
|
+
)
|
|
823
|
+
})}
|
|
824
|
+
</TableRow>
|
|
825
|
+
))}
|
|
826
|
+
</TableHeader>
|
|
827
|
+
<TableBody>
|
|
828
|
+
{table.getRowModel().rows.length ? (
|
|
829
|
+
table.getRowModel().rows.map(row => (
|
|
830
|
+
<TableRow
|
|
831
|
+
key={row.id}
|
|
832
|
+
data-state={row.getIsSelected() ? 'selected' : undefined}
|
|
833
|
+
>
|
|
834
|
+
{row.getVisibleCells().map(cell => (
|
|
835
|
+
<TableCell key={cell.id}>
|
|
836
|
+
{flexRender(
|
|
837
|
+
cell.column.columnDef.cell,
|
|
838
|
+
cell.getContext()
|
|
839
|
+
)}
|
|
840
|
+
</TableCell>
|
|
841
|
+
))}
|
|
842
|
+
</TableRow>
|
|
843
|
+
))
|
|
844
|
+
) : isLoading ? (
|
|
845
|
+
Array.from({ length: pagination.pageSize }).map((_, index) => (
|
|
846
|
+
// Using array index as key is acceptable for static skeleton placeholders
|
|
847
|
+
<TableRow key={index} aria-hidden>
|
|
848
|
+
{table.getVisibleFlatColumns().map(col => (
|
|
849
|
+
<TableCell key={col.id}>
|
|
850
|
+
<Skeleton className="h-4 w-full" />
|
|
851
|
+
</TableCell>
|
|
852
|
+
))}
|
|
853
|
+
</TableRow>
|
|
854
|
+
))
|
|
855
|
+
) : (
|
|
856
|
+
<TableRow>
|
|
857
|
+
<TableCell
|
|
858
|
+
colSpan={columns.length}
|
|
859
|
+
className="h-24 text-center"
|
|
860
|
+
role="cell"
|
|
861
|
+
>
|
|
862
|
+
No results
|
|
863
|
+
</TableCell>
|
|
864
|
+
</TableRow>
|
|
865
|
+
)}
|
|
866
|
+
</TableBody>
|
|
867
|
+
</Table>
|
|
868
|
+
</div>
|
|
869
|
+
|
|
870
|
+
{/* ── Pagination footer ── */}
|
|
871
|
+
<div className="flex flex-col items-center justify-between gap-4 px-2 sm:flex-row sm:gap-0">
|
|
872
|
+
<div className="text-muted-foreground flex-1 text-center text-sm sm:text-left">
|
|
873
|
+
{stableTotalCount !== undefined ? (
|
|
874
|
+
stableTotalCount === 0 ? (
|
|
875
|
+
<>No results</>
|
|
876
|
+
) : (
|
|
877
|
+
<>
|
|
878
|
+
Showing {pagination.pageIndex * pagination.pageSize + 1} to{' '}
|
|
879
|
+
{Math.min(
|
|
880
|
+
(pagination.pageIndex + 1) * pagination.pageSize,
|
|
881
|
+
stableTotalCount
|
|
882
|
+
)}{' '}
|
|
883
|
+
of {stableTotalCount} results
|
|
884
|
+
</>
|
|
885
|
+
)
|
|
886
|
+
) : (
|
|
887
|
+
<>{data.length} row(s) loaded.</>
|
|
888
|
+
)}
|
|
889
|
+
</div>
|
|
890
|
+
<div className="flex flex-col items-center gap-4 sm:flex-row sm:gap-6">
|
|
891
|
+
<div className="flex items-center gap-2">
|
|
892
|
+
<p className="text-sm font-medium whitespace-nowrap">
|
|
893
|
+
Rows per page
|
|
894
|
+
</p>
|
|
895
|
+
<Select
|
|
896
|
+
value={`${pagination.pageSize}`}
|
|
897
|
+
onValueChange={value => {
|
|
898
|
+
table.setPageSize(Number(value))
|
|
899
|
+
}}
|
|
900
|
+
>
|
|
901
|
+
<SelectTrigger className="h-8 w-20">
|
|
902
|
+
<SelectValue placeholder={pagination.pageSize} />
|
|
903
|
+
</SelectTrigger>
|
|
904
|
+
<SelectContent side="top">
|
|
905
|
+
{[10, 20, 50, 100].map(pageSize => (
|
|
906
|
+
<SelectItem key={pageSize} value={`${pageSize}`}>
|
|
907
|
+
{pageSize}
|
|
908
|
+
</SelectItem>
|
|
909
|
+
))}
|
|
910
|
+
</SelectContent>
|
|
911
|
+
</Select>
|
|
912
|
+
</div>
|
|
913
|
+
<div className="flex items-center gap-2">
|
|
914
|
+
<div className="flex w-25 items-center justify-center text-sm font-medium">
|
|
915
|
+
Page {pagination.pageIndex + 1} of {table.getPageCount() || 1}
|
|
916
|
+
</div>
|
|
917
|
+
<div className="flex items-center gap-1">
|
|
918
|
+
<Button
|
|
919
|
+
variant="outline"
|
|
920
|
+
className="h-8 w-8 p-0"
|
|
921
|
+
onClick={() => table.setPageIndex(0)}
|
|
922
|
+
disabled={!table.getCanPreviousPage()}
|
|
923
|
+
>
|
|
924
|
+
<span className="sr-only">Go to first page</span>
|
|
925
|
+
<ChevronsLeft className="h-4 w-4" />
|
|
926
|
+
</Button>
|
|
927
|
+
<Button
|
|
928
|
+
variant="outline"
|
|
929
|
+
className="h-8 w-8 p-0"
|
|
930
|
+
onClick={() => table.previousPage()}
|
|
931
|
+
disabled={!table.getCanPreviousPage()}
|
|
932
|
+
>
|
|
933
|
+
<span className="sr-only">Go to previous page</span>
|
|
934
|
+
<ChevronLeft className="h-4 w-4" />
|
|
935
|
+
</Button>
|
|
936
|
+
<Button
|
|
937
|
+
variant="outline"
|
|
938
|
+
className="h-8 w-8 p-0"
|
|
939
|
+
onClick={() => table.nextPage()}
|
|
940
|
+
disabled={!table.getCanNextPage()}
|
|
941
|
+
>
|
|
942
|
+
<span className="sr-only">Go to next page</span>
|
|
943
|
+
<ChevronRight className="h-4 w-4" />
|
|
944
|
+
</Button>
|
|
945
|
+
<Button
|
|
946
|
+
variant="outline"
|
|
947
|
+
className="h-8 w-8 p-0"
|
|
948
|
+
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
|
949
|
+
disabled={!table.getCanNextPage()}
|
|
950
|
+
>
|
|
951
|
+
<span className="sr-only">Go to last page</span>
|
|
952
|
+
<ChevronsRight className="h-4 w-4" />
|
|
953
|
+
</Button>
|
|
954
|
+
</div>
|
|
955
|
+
</div>
|
|
956
|
+
</div>
|
|
957
|
+
</div>
|
|
958
|
+
</div>
|
|
959
|
+
)
|
|
960
|
+
}
|