create-ej-app 25.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.
Files changed (160) hide show
  1. package/cli.js +4 -0
  2. package/cli.ts +5 -0
  3. package/package.json +2 -2
  4. package/template/api/package.json +2 -2
  5. package/template/nextjs/gitignore +1 -0
  6. package/template/nextjs/package.json +18 -18
  7. package/template/saas-starter/.dockerignore +7 -0
  8. package/template/saas-starter/.env.example +25 -0
  9. package/template/saas-starter/.vscode/extensions.json +11 -0
  10. package/template/saas-starter/.vscode/settings.json +3 -0
  11. package/template/saas-starter/Dockerfile +15 -0
  12. package/template/saas-starter/README.md +3 -0
  13. package/template/saas-starter/components.json +25 -0
  14. package/template/saas-starter/drizzle.config.ts +10 -0
  15. package/template/saas-starter/eslint.config.mjs +23 -0
  16. package/template/saas-starter/gitignore +9 -0
  17. package/template/saas-starter/next-env.d.ts +6 -0
  18. package/template/saas-starter/next.config.ts +14 -0
  19. package/template/saas-starter/package.json +92 -0
  20. package/template/saas-starter/postcss.config.mjs +7 -0
  21. package/template/saas-starter/prettier.config.mjs +11 -0
  22. package/template/saas-starter/public/placeholder.svg +1 -0
  23. package/template/saas-starter/src/actions/uploads.ts +28 -0
  24. package/template/saas-starter/src/actions/uploads.validation.ts +14 -0
  25. package/template/saas-starter/src/app/(admin)/actions/users.ts +97 -0
  26. package/template/saas-starter/src/app/(admin)/admin/_components/admin-header.tsx +21 -0
  27. package/template/saas-starter/src/app/(admin)/admin/_components/admin-search.tsx +16 -0
  28. package/template/saas-starter/src/app/(admin)/admin/_components/admin-sidebar.tsx +40 -0
  29. package/template/saas-starter/src/app/(admin)/admin/layout.tsx +57 -0
  30. package/template/saas-starter/src/app/(admin)/admin/page.tsx +3 -0
  31. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/create-user-dialog.tsx +183 -0
  32. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/edit-name-dialog.tsx +129 -0
  33. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/users-table.tsx +415 -0
  34. package/template/saas-starter/src/app/(admin)/admin/settings/users/page.tsx +15 -0
  35. package/template/saas-starter/src/app/(app)/actions/billing.ts +152 -0
  36. package/template/saas-starter/src/app/(app)/actions/members.ts +163 -0
  37. package/template/saas-starter/src/app/(app)/actions/organization.ts +92 -0
  38. package/template/saas-starter/src/app/(app)/app/[orgId]/dashboard/page.tsx +20 -0
  39. package/template/saas-starter/src/app/(app)/app/[orgId]/layout.tsx +34 -0
  40. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/billing-email-form.tsx +100 -0
  41. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/danger-zone.tsx +364 -0
  42. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/general-settings-form.tsx +155 -0
  43. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/plans-section.tsx +232 -0
  44. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/billing/page.tsx +116 -0
  45. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/invite-member-dialog.tsx +169 -0
  46. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/members-tabs.tsx +571 -0
  47. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/page.tsx +40 -0
  48. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/page.tsx +69 -0
  49. package/template/saas-starter/src/app/(app)/app/_components/app-header.tsx +29 -0
  50. package/template/saas-starter/src/app/(app)/app/_components/app-search.tsx +17 -0
  51. package/template/saas-starter/src/app/(app)/app/_components/app-sidebar.tsx +27 -0
  52. package/template/saas-starter/src/app/(app)/app/_components/organization-context.tsx +41 -0
  53. package/template/saas-starter/src/app/(app)/app/_components/organization-switcher.tsx +140 -0
  54. package/template/saas-starter/src/app/(app)/app/layout.tsx +52 -0
  55. package/template/saas-starter/src/app/(app)/app/onboarding/_components/create-organization-form.tsx +129 -0
  56. package/template/saas-starter/src/app/(app)/app/onboarding/page.tsx +42 -0
  57. package/template/saas-starter/src/app/(app)/app/page.tsx +81 -0
  58. package/template/saas-starter/src/app/(auth)/login/email-login-form.tsx +109 -0
  59. package/template/saas-starter/src/app/(auth)/login/page.tsx +87 -0
  60. package/template/saas-starter/src/app/(auth)/login/social-login-buttons.tsx +75 -0
  61. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx +88 -0
  62. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/page.tsx +167 -0
  63. package/template/saas-starter/src/app/(marketing)/layout.tsx +14 -0
  64. package/template/saas-starter/src/app/(marketing)/page.tsx +3 -0
  65. package/template/saas-starter/src/app/(marketing)/privacy/page.tsx +3 -0
  66. package/template/saas-starter/src/app/(marketing)/terms/page.tsx +3 -0
  67. package/template/saas-starter/src/app/api/auth/[...all]/route.ts +4 -0
  68. package/template/saas-starter/src/app/api/webhooks/stripe/route.ts +94 -0
  69. package/template/saas-starter/src/app/favicon.ico +0 -0
  70. package/template/saas-starter/src/app/globals.css +240 -0
  71. package/template/saas-starter/src/app/layout.tsx +53 -0
  72. package/template/saas-starter/src/app/not-found.tsx +27 -0
  73. package/template/saas-starter/src/app/proxy.ts +28 -0
  74. package/template/saas-starter/src/app/robots.ts +32 -0
  75. package/template/saas-starter/src/app/sitemap.ts +20 -0
  76. package/template/saas-starter/src/components/dashboard-header.tsx +64 -0
  77. package/template/saas-starter/src/components/data-table/enum.ts +5 -0
  78. package/template/saas-starter/src/components/data-table/index.tsx +960 -0
  79. package/template/saas-starter/src/components/data-table/types.ts +75 -0
  80. package/template/saas-starter/src/components/error.tsx +26 -0
  81. package/template/saas-starter/src/components/file-upload.tsx +215 -0
  82. package/template/saas-starter/src/components/impersonation-banner.tsx +44 -0
  83. package/template/saas-starter/src/components/loader.tsx +25 -0
  84. package/template/saas-starter/src/components/navigation-search.tsx +140 -0
  85. package/template/saas-starter/src/components/navigation-sidebar.tsx +241 -0
  86. package/template/saas-starter/src/components/page-heading.tsx +32 -0
  87. package/template/saas-starter/src/components/react-query-provider.tsx +12 -0
  88. package/template/saas-starter/src/components/screen-size.tsx +40 -0
  89. package/template/saas-starter/src/components/site-footer.tsx +23 -0
  90. package/template/saas-starter/src/components/theme-provider.tsx +67 -0
  91. package/template/saas-starter/src/components/theme-toggle.tsx +22 -0
  92. package/template/saas-starter/src/components/ui/avatar.tsx +112 -0
  93. package/template/saas-starter/src/components/ui/badge.tsx +49 -0
  94. package/template/saas-starter/src/components/ui/button.tsx +67 -0
  95. package/template/saas-starter/src/components/ui/card.tsx +103 -0
  96. package/template/saas-starter/src/components/ui/checkbox.tsx +32 -0
  97. package/template/saas-starter/src/components/ui/collapsible.tsx +33 -0
  98. package/template/saas-starter/src/components/ui/command.tsx +192 -0
  99. package/template/saas-starter/src/components/ui/dialog.tsx +164 -0
  100. package/template/saas-starter/src/components/ui/dropdown-menu.tsx +273 -0
  101. package/template/saas-starter/src/components/ui/field.tsx +238 -0
  102. package/template/saas-starter/src/components/ui/input-group.tsx +156 -0
  103. package/template/saas-starter/src/components/ui/input.tsx +19 -0
  104. package/template/saas-starter/src/components/ui/label.tsx +24 -0
  105. package/template/saas-starter/src/components/ui/popover.tsx +89 -0
  106. package/template/saas-starter/src/components/ui/select.tsx +195 -0
  107. package/template/saas-starter/src/components/ui/separator.tsx +28 -0
  108. package/template/saas-starter/src/components/ui/sheet.tsx +143 -0
  109. package/template/saas-starter/src/components/ui/sidebar.tsx +702 -0
  110. package/template/saas-starter/src/components/ui/skeleton.tsx +13 -0
  111. package/template/saas-starter/src/components/ui/sonner.tsx +45 -0
  112. package/template/saas-starter/src/components/ui/switch.tsx +33 -0
  113. package/template/saas-starter/src/components/ui/table.tsx +116 -0
  114. package/template/saas-starter/src/components/ui/tabs.tsx +90 -0
  115. package/template/saas-starter/src/components/ui/textarea.tsx +18 -0
  116. package/template/saas-starter/src/components/ui/tooltip.tsx +57 -0
  117. package/template/saas-starter/src/components/user-menu.tsx +90 -0
  118. package/template/saas-starter/src/db/common.ts +31 -0
  119. package/template/saas-starter/src/db/enums.ts +26 -0
  120. package/template/saas-starter/src/db/index.ts +51 -0
  121. package/template/saas-starter/src/db/relations.ts +75 -0
  122. package/template/saas-starter/src/db/schema.ts +191 -0
  123. package/template/saas-starter/src/emails/components/email-layout.tsx +63 -0
  124. package/template/saas-starter/src/emails/invitation.tsx +56 -0
  125. package/template/saas-starter/src/emails/magic-link.tsx +49 -0
  126. package/template/saas-starter/src/emails/test.tsx +32 -0
  127. package/template/saas-starter/src/emails/welcome.tsx +34 -0
  128. package/template/saas-starter/src/env.ts +66 -0
  129. package/template/saas-starter/src/hooks/use-mobile.ts +19 -0
  130. package/template/saas-starter/src/instrumentation.ts +26 -0
  131. package/template/saas-starter/src/lib/actions.ts +21 -0
  132. package/template/saas-starter/src/lib/admin-navigation.ts +31 -0
  133. package/template/saas-starter/src/lib/app-navigation.ts +59 -0
  134. package/template/saas-starter/src/lib/auth-client.ts +29 -0
  135. package/template/saas-starter/src/lib/auth-permissions.ts +22 -0
  136. package/template/saas-starter/src/lib/auth.ts +211 -0
  137. package/template/saas-starter/src/lib/billing.ts +117 -0
  138. package/template/saas-starter/src/lib/cn.ts +4 -0
  139. package/template/saas-starter/src/lib/constants.ts +22 -0
  140. package/template/saas-starter/src/lib/delay.ts +2 -0
  141. package/template/saas-starter/src/lib/email-service.ts +89 -0
  142. package/template/saas-starter/src/lib/format-currency.ts +27 -0
  143. package/template/saas-starter/src/lib/format-date.ts +12 -0
  144. package/template/saas-starter/src/lib/nodemailer.ts +43 -0
  145. package/template/saas-starter/src/lib/organization-access.ts +93 -0
  146. package/template/saas-starter/src/lib/organization-plan-limits.ts +38 -0
  147. package/template/saas-starter/src/lib/otel.ts +27 -0
  148. package/template/saas-starter/src/lib/plans.ts +63 -0
  149. package/template/saas-starter/src/lib/safe-action-client.ts +75 -0
  150. package/template/saas-starter/src/lib/safe-action.ts +67 -0
  151. package/template/saas-starter/src/lib/siteConfig.ts +4 -0
  152. package/template/saas-starter/src/lib/storage.ts +181 -0
  153. package/template/saas-starter/src/lib/stripe.ts +6 -0
  154. package/template/saas-starter/src/lib/toast-message.ts +15 -0
  155. package/template/saas-starter/src/lib/validations.ts +12 -0
  156. package/template/saas-starter/src/scripts/reset-db.ts +22 -0
  157. package/template/saas-starter/src/scripts/seed.ts +11 -0
  158. package/template/saas-starter/src/scripts/setup-stripe-plans.ts +200 -0
  159. package/template/saas-starter/src/scripts/test-mail.ts +35 -0
  160. 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
+ }