@open-mercato/ui 0.4.2-canary-c02407ff85

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 (319) hide show
  1. package/build.mjs +62 -0
  2. package/dist/backend/AppShell.js +902 -0
  3. package/dist/backend/AppShell.js.map +7 -0
  4. package/dist/backend/ConfirmDialog.js +17 -0
  5. package/dist/backend/ConfirmDialog.js.map +7 -0
  6. package/dist/backend/ContextHelp.js +31 -0
  7. package/dist/backend/ContextHelp.js.map +7 -0
  8. package/dist/backend/CrudForm.js +2028 -0
  9. package/dist/backend/CrudForm.js.map +7 -0
  10. package/dist/backend/DataTable.js +1363 -0
  11. package/dist/backend/DataTable.js.map +7 -0
  12. package/dist/backend/EmptyState.js +52 -0
  13. package/dist/backend/EmptyState.js.map +7 -0
  14. package/dist/backend/FilterBar.js +140 -0
  15. package/dist/backend/FilterBar.js.map +7 -0
  16. package/dist/backend/FilterOverlay.js +279 -0
  17. package/dist/backend/FilterOverlay.js.map +7 -0
  18. package/dist/backend/FlashMessages.js +66 -0
  19. package/dist/backend/FlashMessages.js.map +7 -0
  20. package/dist/backend/JsonBuilder.js +322 -0
  21. package/dist/backend/JsonBuilder.js.map +7 -0
  22. package/dist/backend/JsonDisplay.js +203 -0
  23. package/dist/backend/JsonDisplay.js.map +7 -0
  24. package/dist/backend/Page.js +27 -0
  25. package/dist/backend/Page.js.map +7 -0
  26. package/dist/backend/PerspectiveSidebar.js +282 -0
  27. package/dist/backend/PerspectiveSidebar.js.map +7 -0
  28. package/dist/backend/RowActions.js +148 -0
  29. package/dist/backend/RowActions.js.map +7 -0
  30. package/dist/backend/TruncatedCell.js +92 -0
  31. package/dist/backend/TruncatedCell.js.map +7 -0
  32. package/dist/backend/UserMenu.js +107 -0
  33. package/dist/backend/UserMenu.js.map +7 -0
  34. package/dist/backend/ValueIcons.js +34 -0
  35. package/dist/backend/ValueIcons.js.map +7 -0
  36. package/dist/backend/custom-fields/FieldDefinitionsEditor.js +1264 -0
  37. package/dist/backend/custom-fields/FieldDefinitionsEditor.js.map +7 -0
  38. package/dist/backend/custom-fields/FieldDefinitionsManager.js +332 -0
  39. package/dist/backend/custom-fields/FieldDefinitionsManager.js.map +7 -0
  40. package/dist/backend/dashboard/DashboardScreen.js +578 -0
  41. package/dist/backend/dashboard/DashboardScreen.js.map +7 -0
  42. package/dist/backend/dashboard/index.js +5 -0
  43. package/dist/backend/dashboard/index.js.map +7 -0
  44. package/dist/backend/dashboard/widgetRegistry.js +55 -0
  45. package/dist/backend/dashboard/widgetRegistry.js.map +7 -0
  46. package/dist/backend/detail/ActivitiesSection.js +962 -0
  47. package/dist/backend/detail/ActivitiesSection.js.map +7 -0
  48. package/dist/backend/detail/AddressEditor.js +413 -0
  49. package/dist/backend/detail/AddressEditor.js.map +7 -0
  50. package/dist/backend/detail/AddressTiles.js +437 -0
  51. package/dist/backend/detail/AddressTiles.js.map +7 -0
  52. package/dist/backend/detail/AddressesSection.js +264 -0
  53. package/dist/backend/detail/AddressesSection.js.map +7 -0
  54. package/dist/backend/detail/AttachmentDeleteDialog.js +41 -0
  55. package/dist/backend/detail/AttachmentDeleteDialog.js.map +7 -0
  56. package/dist/backend/detail/AttachmentMetadataDialog.js +517 -0
  57. package/dist/backend/detail/AttachmentMetadataDialog.js.map +7 -0
  58. package/dist/backend/detail/AttachmentsSection.js +367 -0
  59. package/dist/backend/detail/AttachmentsSection.js.map +7 -0
  60. package/dist/backend/detail/CustomDataSection.js +433 -0
  61. package/dist/backend/detail/CustomDataSection.js.map +7 -0
  62. package/dist/backend/detail/DetailFieldsSection.js +75 -0
  63. package/dist/backend/detail/DetailFieldsSection.js.map +7 -0
  64. package/dist/backend/detail/ErrorMessage.js +28 -0
  65. package/dist/backend/detail/ErrorMessage.js.map +7 -0
  66. package/dist/backend/detail/InlineEditors.js +681 -0
  67. package/dist/backend/detail/InlineEditors.js.map +7 -0
  68. package/dist/backend/detail/LoadingMessage.js +14 -0
  69. package/dist/backend/detail/LoadingMessage.js.map +7 -0
  70. package/dist/backend/detail/NotesSection.js +1032 -0
  71. package/dist/backend/detail/NotesSection.js.map +7 -0
  72. package/dist/backend/detail/TabEmptyState.js +25 -0
  73. package/dist/backend/detail/TabEmptyState.js.map +7 -0
  74. package/dist/backend/detail/TagsSection.js +254 -0
  75. package/dist/backend/detail/TagsSection.js.map +7 -0
  76. package/dist/backend/detail/addressFormat.js +77 -0
  77. package/dist/backend/detail/addressFormat.js.map +7 -0
  78. package/dist/backend/detail/index.js +34 -0
  79. package/dist/backend/detail/index.js.map +7 -0
  80. package/dist/backend/fields/registry.generated.js +8 -0
  81. package/dist/backend/fields/registry.generated.js.map +7 -0
  82. package/dist/backend/fields/registry.js +29 -0
  83. package/dist/backend/fields/registry.js.map +7 -0
  84. package/dist/backend/indexes/PartialIndexBanner.js +58 -0
  85. package/dist/backend/indexes/PartialIndexBanner.js.map +7 -0
  86. package/dist/backend/indexes/store.js +62 -0
  87. package/dist/backend/indexes/store.js.map +7 -0
  88. package/dist/backend/injection/InjectionSpot.js +179 -0
  89. package/dist/backend/injection/InjectionSpot.js.map +7 -0
  90. package/dist/backend/injection/PageInjectionBoundary.js +26 -0
  91. package/dist/backend/injection/PageInjectionBoundary.js.map +7 -0
  92. package/dist/backend/injection/helpers.js +26 -0
  93. package/dist/backend/injection/helpers.js.map +7 -0
  94. package/dist/backend/injection/widgetRegistry.js +55 -0
  95. package/dist/backend/injection/widgetRegistry.js.map +7 -0
  96. package/dist/backend/inputs/ComboboxInput.js +225 -0
  97. package/dist/backend/inputs/ComboboxInput.js.map +7 -0
  98. package/dist/backend/inputs/LookupSelect.js +191 -0
  99. package/dist/backend/inputs/LookupSelect.js.map +7 -0
  100. package/dist/backend/inputs/PhoneNumberField.js +100 -0
  101. package/dist/backend/inputs/PhoneNumberField.js.map +7 -0
  102. package/dist/backend/inputs/SwitchableMarkdownInput.js +92 -0
  103. package/dist/backend/inputs/SwitchableMarkdownInput.js.map +7 -0
  104. package/dist/backend/inputs/TagsInput.js +222 -0
  105. package/dist/backend/inputs/TagsInput.js.map +7 -0
  106. package/dist/backend/inputs/index.js +6 -0
  107. package/dist/backend/inputs/index.js.map +7 -0
  108. package/dist/backend/operations/LastOperationBanner.js +80 -0
  109. package/dist/backend/operations/LastOperationBanner.js.map +7 -0
  110. package/dist/backend/operations/store.js +183 -0
  111. package/dist/backend/operations/store.js.map +7 -0
  112. package/dist/backend/schedule/ScheduleAgenda.js +107 -0
  113. package/dist/backend/schedule/ScheduleAgenda.js.map +7 -0
  114. package/dist/backend/schedule/ScheduleGrid.js +107 -0
  115. package/dist/backend/schedule/ScheduleGrid.js.map +7 -0
  116. package/dist/backend/schedule/ScheduleToolbar.js +166 -0
  117. package/dist/backend/schedule/ScheduleToolbar.js.map +7 -0
  118. package/dist/backend/schedule/ScheduleView.js +165 -0
  119. package/dist/backend/schedule/ScheduleView.js.map +7 -0
  120. package/dist/backend/schedule/index.js +6 -0
  121. package/dist/backend/schedule/index.js.map +7 -0
  122. package/dist/backend/schedule/recurrence.js +83 -0
  123. package/dist/backend/schedule/recurrence.js.map +7 -0
  124. package/dist/backend/schedule/types.js +1 -0
  125. package/dist/backend/schedule/types.js.map +7 -0
  126. package/dist/backend/upgrades/UpgradeActionBanner.js +91 -0
  127. package/dist/backend/upgrades/UpgradeActionBanner.js.map +7 -0
  128. package/dist/backend/utils/api.js +127 -0
  129. package/dist/backend/utils/api.js.map +7 -0
  130. package/dist/backend/utils/apiCall.js +48 -0
  131. package/dist/backend/utils/apiCall.js.map +7 -0
  132. package/dist/backend/utils/crud.js +126 -0
  133. package/dist/backend/utils/crud.js.map +7 -0
  134. package/dist/backend/utils/customFieldColumns.js +56 -0
  135. package/dist/backend/utils/customFieldColumns.js.map +7 -0
  136. package/dist/backend/utils/customFieldDefs.js +143 -0
  137. package/dist/backend/utils/customFieldDefs.js.map +7 -0
  138. package/dist/backend/utils/customFieldFilters.js +126 -0
  139. package/dist/backend/utils/customFieldFilters.js.map +7 -0
  140. package/dist/backend/utils/customFieldForms.js +162 -0
  141. package/dist/backend/utils/customFieldForms.js.map +7 -0
  142. package/dist/backend/utils/customFieldValues.js +26 -0
  143. package/dist/backend/utils/customFieldValues.js.map +7 -0
  144. package/dist/backend/utils/flash.js +16 -0
  145. package/dist/backend/utils/flash.js.map +7 -0
  146. package/dist/backend/utils/nav.js +185 -0
  147. package/dist/backend/utils/nav.js.map +7 -0
  148. package/dist/backend/utils/serverErrors.js +230 -0
  149. package/dist/backend/utils/serverErrors.js.map +7 -0
  150. package/dist/frontend/AuthFooter.js +23 -0
  151. package/dist/frontend/AuthFooter.js.map +7 -0
  152. package/dist/frontend/LanguageSwitcher.js +57 -0
  153. package/dist/frontend/LanguageSwitcher.js.map +7 -0
  154. package/dist/frontend/Layout.js +14 -0
  155. package/dist/frontend/Layout.js.map +7 -0
  156. package/dist/index.js +32 -0
  157. package/dist/index.js.map +7 -0
  158. package/dist/primitives/DataLoader.js +67 -0
  159. package/dist/primitives/DataLoader.js.map +7 -0
  160. package/dist/primitives/ErrorNotice.js +20 -0
  161. package/dist/primitives/ErrorNotice.js.map +7 -0
  162. package/dist/primitives/alert.js +38 -0
  163. package/dist/primitives/alert.js.map +7 -0
  164. package/dist/primitives/badge.js +28 -0
  165. package/dist/primitives/badge.js.map +7 -0
  166. package/dist/primitives/button.js +44 -0
  167. package/dist/primitives/button.js.map +7 -0
  168. package/dist/primitives/card.js +91 -0
  169. package/dist/primitives/card.js.map +7 -0
  170. package/dist/primitives/checkbox.js +28 -0
  171. package/dist/primitives/checkbox.js.map +7 -0
  172. package/dist/primitives/dialog.js +90 -0
  173. package/dist/primitives/dialog.js.map +7 -0
  174. package/dist/primitives/input.js +22 -0
  175. package/dist/primitives/input.js.map +7 -0
  176. package/dist/primitives/label.js +21 -0
  177. package/dist/primitives/label.js.map +7 -0
  178. package/dist/primitives/separator.js +9 -0
  179. package/dist/primitives/separator.js.map +7 -0
  180. package/dist/primitives/spinner.js +24 -0
  181. package/dist/primitives/spinner.js.map +7 -0
  182. package/dist/primitives/switch.js +80 -0
  183. package/dist/primitives/switch.js.map +7 -0
  184. package/dist/primitives/table.js +29 -0
  185. package/dist/primitives/table.js.map +7 -0
  186. package/dist/primitives/tabs.js +87 -0
  187. package/dist/primitives/tabs.js.map +7 -0
  188. package/dist/primitives/textarea.js +21 -0
  189. package/dist/primitives/textarea.js.map +7 -0
  190. package/dist/primitives/tooltip.js +60 -0
  191. package/dist/primitives/tooltip.js.map +7 -0
  192. package/dist/theme/QueryProvider.js +44 -0
  193. package/dist/theme/QueryProvider.js.map +7 -0
  194. package/dist/theme/ThemeProvider.js +95 -0
  195. package/dist/theme/ThemeProvider.js.map +7 -0
  196. package/dist/theme/ThemeToggle.js +88 -0
  197. package/dist/theme/ThemeToggle.js.map +7 -0
  198. package/dist/theme/index.js +10 -0
  199. package/dist/theme/index.js.map +7 -0
  200. package/dist/types/react-big-calendar.d.js +1 -0
  201. package/dist/types/react-big-calendar.d.js.map +7 -0
  202. package/jest.config.cjs +23 -0
  203. package/jest.setup.ts +55 -0
  204. package/package.json +105 -0
  205. package/src/backend/AppShell.tsx +1096 -0
  206. package/src/backend/ConfirmDialog.tsx +19 -0
  207. package/src/backend/ContextHelp.tsx +38 -0
  208. package/src/backend/CrudForm.tsx +2503 -0
  209. package/src/backend/DataTable.tsx +1730 -0
  210. package/src/backend/EmptyState.tsx +65 -0
  211. package/src/backend/FilterBar.tsx +161 -0
  212. package/src/backend/FilterOverlay.tsx +328 -0
  213. package/src/backend/FlashMessages.tsx +82 -0
  214. package/src/backend/JsonBuilder.tsx +362 -0
  215. package/src/backend/JsonDisplay.tsx +254 -0
  216. package/src/backend/Page.tsx +30 -0
  217. package/src/backend/PerspectiveSidebar.tsx +337 -0
  218. package/src/backend/RowActions.tsx +151 -0
  219. package/src/backend/TruncatedCell.tsx +133 -0
  220. package/src/backend/UserMenu.tsx +118 -0
  221. package/src/backend/ValueIcons.tsx +48 -0
  222. package/src/backend/__tests__/AppShell.test.tsx +115 -0
  223. package/src/backend/__tests__/CrudForm.render.test.tsx +30 -0
  224. package/src/backend/__tests__/DataTable.render.test.tsx +48 -0
  225. package/src/backend/__tests__/custom-field-filters.test.ts +72 -0
  226. package/src/backend/__tests__/custom-field-forms.test.ts +54 -0
  227. package/src/backend/__tests__/serverErrors.test.ts +83 -0
  228. package/src/backend/custom-fields/FieldDefinitionsEditor.tsx +1292 -0
  229. package/src/backend/custom-fields/FieldDefinitionsManager.tsx +381 -0
  230. package/src/backend/dashboard/DashboardScreen.tsx +684 -0
  231. package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +112 -0
  232. package/src/backend/dashboard/index.ts +1 -0
  233. package/src/backend/dashboard/widgetRegistry.ts +68 -0
  234. package/src/backend/detail/ActivitiesSection.tsx +1284 -0
  235. package/src/backend/detail/AddressEditor.tsx +472 -0
  236. package/src/backend/detail/AddressTiles.tsx +587 -0
  237. package/src/backend/detail/AddressesSection.tsx +346 -0
  238. package/src/backend/detail/AttachmentDeleteDialog.tsx +56 -0
  239. package/src/backend/detail/AttachmentMetadataDialog.tsx +672 -0
  240. package/src/backend/detail/AttachmentsSection.tsx +414 -0
  241. package/src/backend/detail/CustomDataSection.tsx +530 -0
  242. package/src/backend/detail/DetailFieldsSection.tsx +147 -0
  243. package/src/backend/detail/ErrorMessage.tsx +32 -0
  244. package/src/backend/detail/InlineEditors.tsx +877 -0
  245. package/src/backend/detail/LoadingMessage.tsx +14 -0
  246. package/src/backend/detail/NotesSection.tsx +1275 -0
  247. package/src/backend/detail/TabEmptyState.tsx +48 -0
  248. package/src/backend/detail/TagsSection.tsx +314 -0
  249. package/src/backend/detail/addressFormat.tsx +121 -0
  250. package/src/backend/detail/index.ts +44 -0
  251. package/src/backend/fields/registry.generated.ts +8 -0
  252. package/src/backend/fields/registry.ts +38 -0
  253. package/src/backend/indexes/PartialIndexBanner.tsx +68 -0
  254. package/src/backend/indexes/store.ts +88 -0
  255. package/src/backend/injection/InjectionSpot.tsx +236 -0
  256. package/src/backend/injection/PageInjectionBoundary.tsx +31 -0
  257. package/src/backend/injection/helpers.ts +35 -0
  258. package/src/backend/injection/widgetRegistry.ts +68 -0
  259. package/src/backend/inputs/ComboboxInput.tsx +269 -0
  260. package/src/backend/inputs/LookupSelect.tsx +247 -0
  261. package/src/backend/inputs/PhoneNumberField.tsx +129 -0
  262. package/src/backend/inputs/SwitchableMarkdownInput.tsx +128 -0
  263. package/src/backend/inputs/TagsInput.tsx +259 -0
  264. package/src/backend/inputs/index.ts +5 -0
  265. package/src/backend/operations/LastOperationBanner.tsx +85 -0
  266. package/src/backend/operations/__tests__/LastOperationBanner.test.tsx +99 -0
  267. package/src/backend/operations/store.ts +230 -0
  268. package/src/backend/schedule/ScheduleAgenda.tsx +136 -0
  269. package/src/backend/schedule/ScheduleGrid.tsx +136 -0
  270. package/src/backend/schedule/ScheduleToolbar.tsx +178 -0
  271. package/src/backend/schedule/ScheduleView.tsx +198 -0
  272. package/src/backend/schedule/index.ts +5 -0
  273. package/src/backend/schedule/recurrence.ts +99 -0
  274. package/src/backend/schedule/types.ts +26 -0
  275. package/src/backend/upgrades/UpgradeActionBanner.tsx +128 -0
  276. package/src/backend/utils/__tests__/apiCall.test.ts +109 -0
  277. package/src/backend/utils/__tests__/crud.test.ts +87 -0
  278. package/src/backend/utils/__tests__/customFieldDefs.test.ts +25 -0
  279. package/src/backend/utils/__tests__/customFieldValues.test.ts +35 -0
  280. package/src/backend/utils/api.ts +149 -0
  281. package/src/backend/utils/apiCall.ts +96 -0
  282. package/src/backend/utils/crud.ts +174 -0
  283. package/src/backend/utils/customFieldColumns.ts +71 -0
  284. package/src/backend/utils/customFieldDefs.ts +245 -0
  285. package/src/backend/utils/customFieldFilters.ts +145 -0
  286. package/src/backend/utils/customFieldForms.ts +196 -0
  287. package/src/backend/utils/customFieldValues.ts +41 -0
  288. package/src/backend/utils/flash.ts +17 -0
  289. package/src/backend/utils/nav.ts +238 -0
  290. package/src/backend/utils/serverErrors.ts +302 -0
  291. package/src/frontend/AuthFooter.tsx +29 -0
  292. package/src/frontend/LanguageSwitcher.tsx +66 -0
  293. package/src/frontend/Layout.tsx +13 -0
  294. package/src/index.ts +32 -0
  295. package/src/primitives/DataLoader.tsx +92 -0
  296. package/src/primitives/ErrorNotice.tsx +26 -0
  297. package/src/primitives/alert.tsx +52 -0
  298. package/src/primitives/badge.tsx +31 -0
  299. package/src/primitives/button.tsx +47 -0
  300. package/src/primitives/card.tsx +92 -0
  301. package/src/primitives/checkbox.tsx +28 -0
  302. package/src/primitives/dialog.tsx +110 -0
  303. package/src/primitives/input.tsx +20 -0
  304. package/src/primitives/label.tsx +18 -0
  305. package/src/primitives/separator.tsx +7 -0
  306. package/src/primitives/spinner.tsx +27 -0
  307. package/src/primitives/switch.tsx +86 -0
  308. package/src/primitives/table.tsx +27 -0
  309. package/src/primitives/tabs.tsx +128 -0
  310. package/src/primitives/textarea.tsx +20 -0
  311. package/src/primitives/tooltip.tsx +85 -0
  312. package/src/theme/QueryProvider.tsx +46 -0
  313. package/src/theme/ThemeProvider.tsx +120 -0
  314. package/src/theme/ThemeToggle.tsx +88 -0
  315. package/src/theme/index.ts +3 -0
  316. package/src/types/react-big-calendar.d.ts +16 -0
  317. package/tsconfig.build.json +11 -0
  318. package/tsconfig.json +9 -0
  319. package/watch.mjs +6 -0
@@ -0,0 +1,1730 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import { useRouter } from 'next/navigation'
4
+ import { useReactTable, getCoreRowModel, getSortedRowModel, flexRender, type ColumnDef, type SortingState, type Column as TableColumn, type VisibilityState } from '@tanstack/react-table'
5
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
6
+ import { RefreshCw, Loader2, SlidersHorizontal, MoreHorizontal, Circle } from 'lucide-react'
7
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../primitives/table'
8
+ import { Button } from '../primitives/button'
9
+ import { Spinner } from '../primitives/spinner'
10
+ import { TooltipProvider } from '../primitives/tooltip'
11
+ import { TruncatedCell } from './TruncatedCell'
12
+ import { FilterBar, type FilterDef, type FilterValues } from './FilterBar'
13
+ import { useCustomFieldFilterDefs } from './utils/customFieldFilters'
14
+ import { fetchCustomFieldDefinitionsPayload, type CustomFieldsetDto } from './utils/customFieldDefs'
15
+ import { type RowActionItem } from './RowActions'
16
+ import { subscribeOrganizationScopeChanged, type OrganizationScopeChangedDetail } from '@open-mercato/shared/lib/frontend/organizationEvents'
17
+ import { InjectionSpot } from './injection/InjectionSpot'
18
+ import { serializeExport, defaultExportFilename, type PreparedExport } from '@open-mercato/shared/lib/crud/exporters'
19
+ import { apiCall } from './utils/apiCall'
20
+ import { raiseCrudError } from './utils/serverErrors'
21
+ import { PerspectiveSidebar } from './PerspectiveSidebar'
22
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
23
+ import type {
24
+ PerspectiveDto,
25
+ RolePerspectiveDto,
26
+ PerspectivesIndexResponse,
27
+ PerspectiveSettings,
28
+ PerspectiveSaveResponse,
29
+ } from '@open-mercato/shared/modules/perspectives/types'
30
+
31
+ let refreshScheduled = false
32
+ function scheduleRouterRefresh(router: ReturnType<typeof useRouter>) {
33
+ if (refreshScheduled) return
34
+ refreshScheduled = true
35
+ if (typeof window === 'undefined') {
36
+ refreshScheduled = false
37
+ return
38
+ }
39
+ window.requestAnimationFrame(() => {
40
+ refreshScheduled = false
41
+ try { router.refresh() } catch {}
42
+ })
43
+ }
44
+
45
+ export type PaginationProps = {
46
+ page: number
47
+ pageSize: number
48
+ total: number
49
+ totalPages: number
50
+ onPageChange: (page: number) => void
51
+ durationMs?: number | null
52
+ cacheStatus?: 'hit' | 'miss' | null
53
+ }
54
+
55
+ export type DataTableRefreshButton = {
56
+ onRefresh: () => void
57
+ label: string
58
+ isRefreshing?: boolean
59
+ disabled?: boolean
60
+ }
61
+
62
+ // Helper function to extract edit action from RowActions items
63
+ function extractEditAction(items: RowActionItem[]): RowActionItem | null {
64
+ const byId = items.find((item) => item.id === 'edit' && (item.href || item.onSelect))
65
+ if (byId) return byId
66
+ return items.find((item) =>
67
+ item.label.toLowerCase() === 'edit' &&
68
+ (item.href || item.onSelect)
69
+ ) || null
70
+ }
71
+
72
+ export type DataTableExportFormat = 'csv' | 'json' | 'xml' | 'markdown'
73
+
74
+ export type DataTableExportSectionConfig = {
75
+ title?: string
76
+ description?: string
77
+ getUrl?: (format: DataTableExportFormat) => string
78
+ prepare?: (format: DataTableExportFormat) => Promise<PreparedExport | { prepared: PreparedExport; filename?: string } | null> | PreparedExport | { prepared: PreparedExport; filename?: string } | null
79
+ formats?: DataTableExportFormat[]
80
+ disabled?: boolean
81
+ filename?: (format: DataTableExportFormat) => string
82
+ }
83
+
84
+ export type DataTableExportConfig = {
85
+ label?: string
86
+ disabled?: boolean
87
+ formats?: DataTableExportFormat[]
88
+ getUrl?: (format: DataTableExportFormat) => string
89
+ sections?: DataTableExportSectionConfig[]
90
+ view?: DataTableExportSectionConfig
91
+ full?: DataTableExportSectionConfig
92
+ filename?: (format: DataTableExportFormat) => string
93
+ }
94
+
95
+ export type DataTablePerspectiveConfig = {
96
+ tableId: string
97
+ initialState?: {
98
+ response?: PerspectivesIndexResponse
99
+ activePerspectiveId?: string | null
100
+ initialSettings?: PerspectiveSettings | null
101
+ }
102
+ }
103
+
104
+ export type DataTableProps<T> = {
105
+ columns: ColumnDef<T, any>[]
106
+ data: T[]
107
+ toolbar?: React.ReactNode
108
+ title?: React.ReactNode
109
+ actions?: React.ReactNode
110
+ refreshButton?: DataTableRefreshButton
111
+ sortable?: boolean
112
+ sorting?: SortingState
113
+ onSortingChange?: (s: SortingState) => void
114
+ pagination?: PaginationProps
115
+ isLoading?: boolean
116
+ emptyState?: React.ReactNode
117
+ error?: React.ReactNode | string | null
118
+ // Optional per-row actions renderer. When provided, an extra trailing column is rendered.
119
+ rowActions?: (row: T) => React.ReactNode
120
+ // Optional row click handler. When provided, rows become clickable and show pointer cursor.
121
+ // If not provided but rowActions contains an 'Edit' action, it will be used as the default row click handler.
122
+ onRowClick?: (row: T) => void
123
+ // Disable row click navigation when rowActions are present.
124
+ disableRowClick?: boolean
125
+
126
+ // Auto FilterBar options (rendered as toolbar when provided and no custom toolbar passed)
127
+ searchValue?: string
128
+ onSearchChange?: (v: string) => void
129
+ searchPlaceholder?: string
130
+ searchAlign?: 'left' | 'right'
131
+ filters?: FilterDef[]
132
+ filterValues?: FilterValues
133
+ onFiltersApply?: (values: FilterValues) => void
134
+ onFiltersClear?: () => void
135
+ // When provided, DataTable will fetch custom field definitions and append filter controls for filterable ones.
136
+ entityId?: string
137
+ entityIds?: string[]
138
+ exporter?: DataTableExportConfig | false
139
+ perspective?: DataTablePerspectiveConfig
140
+ embedded?: boolean
141
+ onCustomFieldFilterFieldsetChange?: (fieldset: string | null, entityId?: string) => void
142
+ customFieldFilterKeyExtras?: Array<string | number | boolean | null | undefined>
143
+ injectionSpotId?: string
144
+ injectionContext?: Record<string, unknown>
145
+ }
146
+
147
+ const DEFAULT_EXPORT_FORMATS: DataTableExportFormat[] = ['csv', 'json', 'xml', 'markdown']
148
+ const EXPORT_LABELS: Record<DataTableExportFormat, string> = {
149
+ csv: 'CSV',
150
+ json: 'JSON',
151
+ xml: 'XML',
152
+ markdown: 'Markdown',
153
+ }
154
+ const EMPTY_FILTER_DEFS: FilterDef[] = []
155
+ const EMPTY_FILTER_VALUES: FilterValues = Object.freeze({}) as FilterValues
156
+
157
+ type ResolvedExportSection = {
158
+ key: string
159
+ title: string
160
+ description?: string
161
+ formats: DataTableExportFormat[]
162
+ getUrl?: (format: DataTableExportFormat) => string
163
+ prepare?: (format: DataTableExportFormat) => Promise<{ prepared: PreparedExport; filename?: string } | null> | { prepared: PreparedExport; filename?: string } | null
164
+ filename?: (format: DataTableExportFormat) => string
165
+ disabled: boolean
166
+ }
167
+
168
+ function resolveExportSections(config: DataTableExportConfig | null | undefined): ResolvedExportSection[] {
169
+ if (!config) return []
170
+ const sections: ResolvedExportSection[] = []
171
+ const baseFormats = config.formats && config.formats.length > 0 ? config.formats : DEFAULT_EXPORT_FORMATS
172
+ const addSection = (key: string, section: DataTableExportSectionConfig | undefined | null, fallbackTitle: string) => {
173
+ if (!section || (!section.getUrl && !section.prepare)) return
174
+ const title = section.title?.trim().length ? section.title!.trim() : fallbackTitle
175
+ const seen = new Set<DataTableExportFormat>()
176
+ const formatsSource = section.formats && section.formats.length > 0 ? section.formats : baseFormats
177
+ const formats = formatsSource.filter((format) => {
178
+ if (seen.has(format)) return false
179
+ seen.add(format)
180
+ return true
181
+ })
182
+ if (formats.length === 0) return
183
+ sections.push({
184
+ key,
185
+ title,
186
+ description: section.description,
187
+ formats,
188
+ getUrl: section.getUrl,
189
+ prepare: section.prepare
190
+ ? async (format: DataTableExportFormat) => {
191
+ const result = await section.prepare!(format)
192
+ if (!result) return null
193
+ if ('prepared' in result) return result
194
+ return { prepared: result }
195
+ }
196
+ : undefined,
197
+ filename: section.filename,
198
+ disabled: Boolean(config.disabled || section.disabled),
199
+ })
200
+ }
201
+
202
+ // Allow legacy config (getUrl without sections/view)
203
+ const hasExplicitSections = Array.isArray(config.sections) && config.sections.length > 0
204
+ if (!config.view && !config.full && !hasExplicitSections && config.getUrl) {
205
+ addSection('view', { getUrl: config.getUrl, formats: config.formats }, 'Export what you view')
206
+ } else {
207
+ addSection('view', config.view, 'Export what you view')
208
+ }
209
+
210
+ if (hasExplicitSections) {
211
+ config.sections!.forEach((section, idx) => {
212
+ addSection(`section-${idx}`, section, section.title?.trim().length ? section.title! : `Export ${idx + 1}`)
213
+ })
214
+ }
215
+
216
+ addSection('full', config.full, 'Full data export')
217
+ return sections
218
+ }
219
+
220
+ const PERSPECTIVE_COOKIE_PREFIX = 'om_table_perspective'
221
+ const PERSPECTIVE_STORAGE_PREFIX = 'om_table_perspective_snapshot'
222
+
223
+ function formatDurationLabel(durationMs?: number | null): string {
224
+ if (durationMs == null) return ''
225
+ if (!Number.isFinite(durationMs)) return ''
226
+ if (durationMs < 0) return ''
227
+ if (durationMs < 1000) return `${Math.round(durationMs)}ms`
228
+ if (durationMs < 10_000) return `${(durationMs / 1000).toFixed(1)}s`
229
+ if (durationMs < 60_000) return `${Math.round(durationMs / 1000)}s`
230
+ if (durationMs < 3_600_000) return `${(durationMs / 60_000).toFixed(durationMs < 600_000 ? 1 : 0)}m`
231
+ return `${(durationMs / 3_600_000).toFixed(durationMs < 7_200_000 ? 1 : 0)}h`
232
+ }
233
+
234
+ type PerspectiveSnapshot = {
235
+ perspectiveId: string | null
236
+ settings: PerspectiveSettings
237
+ updatedAt: number
238
+ }
239
+
240
+ function readPerspectiveCookie(tableId: string): string | null {
241
+ if (typeof document === 'undefined') return null
242
+ const key = `${PERSPECTIVE_COOKIE_PREFIX}:${tableId}`
243
+ const pattern = new RegExp(`(?:^|;\\s*)${key}=([^;]+)`)
244
+ const match = document.cookie.match(pattern)
245
+ return match ? decodeURIComponent(match[1]) : null
246
+ }
247
+
248
+ function writePerspectiveCookie(tableId: string, perspectiveId: string | null): void {
249
+ if (typeof document === 'undefined') return
250
+ const key = `${PERSPECTIVE_COOKIE_PREFIX}:${tableId}`
251
+ const expires = perspectiveId ? 'Max-Age=31536000' : 'Max-Age=0'
252
+ const value = perspectiveId ? encodeURIComponent(perspectiveId) : ''
253
+ document.cookie = `${key}=${value}; Path=/; ${expires}; SameSite=Lax`
254
+ }
255
+
256
+ function readPerspectiveSnapshot(tableId: string): PerspectiveSnapshot | null {
257
+ if (typeof window === 'undefined') return null
258
+ try {
259
+ const raw = window.localStorage.getItem(`${PERSPECTIVE_STORAGE_PREFIX}:${tableId}`)
260
+ if (!raw) return null
261
+ const parsed = JSON.parse(raw)
262
+ if (!parsed || typeof parsed !== 'object') return null
263
+ const perspectiveId =
264
+ typeof parsed.perspectiveId === 'string' && parsed.perspectiveId.trim().length > 0
265
+ ? parsed.perspectiveId
266
+ : null
267
+ const settings = typeof parsed.settings === 'object' && parsed.settings !== null
268
+ ? parsed.settings as PerspectiveSettings
269
+ : null
270
+ const updatedAt = typeof parsed.updatedAt === 'number' ? parsed.updatedAt : Date.now()
271
+ if (!settings) return null
272
+ return { perspectiveId, settings, updatedAt }
273
+ } catch {
274
+ return null
275
+ }
276
+ }
277
+
278
+ function writePerspectiveSnapshot(tableId: string, snapshot: PerspectiveSnapshot | null) {
279
+ if (typeof window === 'undefined') return
280
+ const key = `${PERSPECTIVE_STORAGE_PREFIX}:${tableId}`
281
+ try {
282
+ if (!snapshot) {
283
+ window.localStorage.removeItem(key)
284
+ return
285
+ }
286
+ window.localStorage.setItem(key, JSON.stringify(snapshot))
287
+ } catch {
288
+ // ignore storage errors
289
+ }
290
+ }
291
+
292
+ function sanitizePerspectiveSettings(source?: PerspectiveSettings | null): PerspectiveSettings | null {
293
+ if (!source || typeof source !== 'object') return null
294
+ const forbidden = new Set(['__proto__', 'prototype', 'constructor'])
295
+ const result: PerspectiveSettings = {}
296
+
297
+ if (Array.isArray(source.columnOrder)) {
298
+ const seen = new Set<string>()
299
+ const order = source.columnOrder
300
+ .map((id) => (typeof id === 'string' ? id.trim() : ''))
301
+ .filter((id) => id.length > 0 && !seen.has(id) && (seen.add(id), true))
302
+ if (order.length) result.columnOrder = order
303
+ }
304
+
305
+ if (source.columnVisibility && typeof source.columnVisibility === 'object') {
306
+ const entries = Object.entries(source.columnVisibility)
307
+ .filter(([key, value]) => typeof key === 'string' && key.trim().length > 0 && !forbidden.has(key) && typeof value === 'boolean')
308
+ if (entries.length) {
309
+ const visibility: Record<string, boolean> = {}
310
+ entries.forEach(([key, value]) => { visibility[key] = value })
311
+ result.columnVisibility = visibility
312
+ }
313
+ }
314
+
315
+ if (Array.isArray(source.sorting)) {
316
+ const sorting = source.sorting
317
+ .map((item) => {
318
+ const id = typeof item?.id === 'string' ? item.id.trim() : ''
319
+ if (!id || forbidden.has(id)) return null
320
+ return { id, desc: Boolean(item?.desc) }
321
+ })
322
+ .filter((item): item is { id: string; desc: boolean } => item !== null)
323
+ if (sorting.length) result.sorting = sorting
324
+ }
325
+
326
+ if (typeof source.pageSize === 'number' && Number.isFinite(source.pageSize)) {
327
+ const pageSize = Math.max(1, Math.min(500, Math.floor(source.pageSize)))
328
+ result.pageSize = pageSize
329
+ }
330
+
331
+ if (typeof source.searchValue === 'string' && source.searchValue.trim().length > 0) {
332
+ result.searchValue = source.searchValue.trim().slice(0, 200)
333
+ }
334
+
335
+ if (source.filters && typeof source.filters === 'object') {
336
+ const filters: Record<string, unknown> = {}
337
+ for (const [key, value] of Object.entries(source.filters)) {
338
+ if (typeof key === 'string') {
339
+ const trimmed = key.trim()
340
+ if (trimmed.length > 0 && !forbidden.has(trimmed)) filters[trimmed] = value
341
+ }
342
+ }
343
+ if (Object.keys(filters).length) result.filters = filters
344
+ }
345
+
346
+ return Object.keys(result).length ? result : null
347
+ }
348
+
349
+ function normalizeLabel(input: string): string {
350
+ if (!input) return ''
351
+ return input
352
+ .replace(/^cf[_:]/, '')
353
+ .replace(/[_:\-]+/g, ' ')
354
+ .replace(/\b\w/g, (ch) => ch.toUpperCase())
355
+ }
356
+
357
+ // Column width configuration based on column type
358
+ type ColumnTruncateConfig = {
359
+ maxWidth: string
360
+ truncate: boolean
361
+ }
362
+
363
+ type ColumnTruncateMeta = {
364
+ truncate?: boolean
365
+ maxWidth?: string
366
+ }
367
+
368
+ function getColumnTruncateConfig(columnId: string, accessorKey?: string, columnMeta?: ColumnTruncateMeta): ColumnTruncateConfig {
369
+ const key = accessorKey || columnId
370
+ const metaMaxWidth = typeof columnMeta?.maxWidth === 'string' ? columnMeta.maxWidth.trim() : ''
371
+
372
+ // Custom fields get narrower width
373
+ if (key.startsWith('cf_') || key.startsWith('cf:')) {
374
+ return {
375
+ maxWidth: metaMaxWidth || '120px',
376
+ truncate: typeof columnMeta?.truncate === 'boolean' ? columnMeta.truncate : true,
377
+ }
378
+ }
379
+
380
+ // Core informative columns get wider width
381
+ const wideColumns = ['title', 'name', 'description', 'source', 'companies', 'people']
382
+ if (wideColumns.includes(key)) {
383
+ return {
384
+ maxWidth: metaMaxWidth || '250px',
385
+ truncate: typeof columnMeta?.truncate === 'boolean' ? columnMeta.truncate : true,
386
+ }
387
+ }
388
+
389
+ // Medium width for status-like columns
390
+ const mediumColumns = ['status', 'pipelineStage', 'pipeline_stage', 'type', 'category']
391
+ if (mediumColumns.includes(key)) {
392
+ return {
393
+ maxWidth: metaMaxWidth || '180px',
394
+ truncate: typeof columnMeta?.truncate === 'boolean' ? columnMeta.truncate : true,
395
+ }
396
+ }
397
+
398
+ // Date columns
399
+ if (key.endsWith('_at') || key.endsWith('At') || key.includes('date') || key.includes('Date')) {
400
+ return {
401
+ maxWidth: metaMaxWidth || '120px',
402
+ truncate: typeof columnMeta?.truncate === 'boolean' ? columnMeta.truncate : true,
403
+ }
404
+ }
405
+
406
+ // Default for other columns
407
+ return {
408
+ maxWidth: metaMaxWidth || '150px',
409
+ truncate: typeof columnMeta?.truncate === 'boolean' ? columnMeta.truncate : true,
410
+ }
411
+ }
412
+
413
+ // Check if a column should skip truncation (e.g., actions column)
414
+ function shouldSkipTruncation(columnId: string): boolean {
415
+ const skipColumns = ['actions', 'select', 'checkbox', 'expand']
416
+ return skipColumns.includes(columnId.toLowerCase())
417
+ }
418
+
419
+ function ExportMenu({ config, sections }: { config: DataTableExportConfig; sections: ResolvedExportSection[] }) {
420
+ const t = useT()
421
+ const { label } = config
422
+ const defaultLabel = label ?? t('ui.dataTable.export.label', 'Export')
423
+ const disabled = Boolean(config.disabled)
424
+ const hasSections = sections.length > 0
425
+ const [open, setOpen] = React.useState(false)
426
+ const buttonRef = React.useRef<HTMLButtonElement>(null)
427
+ const menuRef = React.useRef<HTMLDivElement>(null)
428
+
429
+ React.useEffect(() => {
430
+ if (!open || !hasSections) return
431
+ const onDocClick = (event: MouseEvent) => {
432
+ const target = event.target as Node
433
+ if (menuRef.current && !menuRef.current.contains(target) && buttonRef.current && !buttonRef.current.contains(target)) {
434
+ setOpen(false)
435
+ }
436
+ }
437
+ const onKeyDown = (event: KeyboardEvent) => {
438
+ if (event.key === 'Escape') {
439
+ setOpen(false)
440
+ buttonRef.current?.focus()
441
+ }
442
+ }
443
+ document.addEventListener('mousedown', onDocClick)
444
+ document.addEventListener('keydown', onKeyDown)
445
+ return () => {
446
+ document.removeEventListener('mousedown', onDocClick)
447
+ document.removeEventListener('keydown', onKeyDown)
448
+ }
449
+ }, [hasSections, open])
450
+
451
+ if (!hasSections) return null
452
+
453
+ const handleSelect = async (section: ResolvedExportSection, format: DataTableExportFormat) => {
454
+ try {
455
+ if (section.prepare) {
456
+ const preparedResult = await section.prepare(format)
457
+ if (!preparedResult) return
458
+ const prepared = preparedResult.prepared
459
+ const serialized = serializeExport(prepared, format)
460
+ const filename =
461
+ preparedResult.filename
462
+ ?? section.filename?.(format)
463
+ ?? config.filename?.(format)
464
+ ?? defaultExportFilename(section.title, format)
465
+ if (typeof window !== 'undefined') {
466
+ const blob = new Blob([serialized.body], { type: serialized.contentType })
467
+ const href = URL.createObjectURL(blob)
468
+ const a = document.createElement('a')
469
+ a.href = href
470
+ a.download = filename
471
+ document.body.appendChild(a)
472
+ a.click()
473
+ document.body.removeChild(a)
474
+ URL.revokeObjectURL(href)
475
+ }
476
+ } else if (section.getUrl) {
477
+ const url = section.getUrl(format)
478
+ if (url && typeof window !== 'undefined') {
479
+ window.open(url, '_blank', 'noopener,noreferrer')
480
+ }
481
+ }
482
+ } catch {
483
+ // ignore export errors
484
+ } finally {
485
+ setOpen(false)
486
+ }
487
+ }
488
+
489
+ return (
490
+ <div className="relative inline-block">
491
+ <Button
492
+ ref={buttonRef}
493
+ variant="outline"
494
+ size="sm"
495
+ type="button"
496
+ onClick={() => {
497
+ if (disabled) return
498
+ setOpen((prev) => !prev)
499
+ }}
500
+ aria-haspopup="menu"
501
+ aria-expanded={open}
502
+ disabled={disabled}
503
+ >
504
+ {defaultLabel}
505
+ </Button>
506
+ {open ? (
507
+ <div
508
+ ref={menuRef}
509
+ role="menu"
510
+ className="absolute right-0 mt-2 w-60 rounded-md border bg-background py-2 shadow z-20"
511
+ >
512
+ {sections.map((section, idx) => (
513
+ <div key={section.key} className={idx > 0 ? 'mt-2 border-t pt-3' : ''}>
514
+ <div className="px-3">
515
+ <div className="text-xs font-semibold uppercase text-muted-foreground">{section.title}</div>
516
+ {section.description ? (
517
+ <p className="mt-1 text-xs text-muted-foreground leading-snug">{section.description}</p>
518
+ ) : null}
519
+ </div>
520
+ <div className="mt-2 space-y-1 px-2 pb-1">
521
+ {section.formats.map((format) => (
522
+ <button
523
+ key={`${section.key}-${format}`}
524
+ type="button"
525
+ className="block w-full rounded px-2 py-1 text-left text-sm hover:bg-accent"
526
+ onClick={() => void handleSelect(section, format)}
527
+ disabled={section.disabled}
528
+ >
529
+ {EXPORT_LABELS[format]}
530
+ </button>
531
+ ))}
532
+ </div>
533
+ </div>
534
+ ))}
535
+ </div>
536
+ ) : null}
537
+ </div>
538
+ )
539
+ }
540
+
541
+ export function DataTable<T>({
542
+ columns,
543
+ data,
544
+ toolbar,
545
+ title,
546
+ actions,
547
+ refreshButton,
548
+ sortable,
549
+ sorting: sortingProp,
550
+ onSortingChange,
551
+ pagination,
552
+ isLoading,
553
+ emptyState,
554
+ error,
555
+ rowActions,
556
+ onRowClick,
557
+ disableRowClick = false,
558
+ searchValue,
559
+ onSearchChange,
560
+ searchPlaceholder,
561
+ searchAlign = 'right',
562
+ filters: baseFilters = EMPTY_FILTER_DEFS,
563
+ filterValues = EMPTY_FILTER_VALUES,
564
+ onFiltersApply,
565
+ onFiltersClear,
566
+ entityId,
567
+ entityIds,
568
+ exporter,
569
+ perspective,
570
+ embedded = false,
571
+ onCustomFieldFilterFieldsetChange,
572
+ customFieldFilterKeyExtras,
573
+ injectionSpotId,
574
+ injectionContext,
575
+ }: DataTableProps<T>) {
576
+ const t = useT()
577
+ const router = useRouter()
578
+ const lastScopeRef = React.useRef<OrganizationScopeChangedDetail | null>(null)
579
+ const hasInitializedScopeRef = React.useRef(false)
580
+ React.useEffect(() => {
581
+ return subscribeOrganizationScopeChanged((detail) => {
582
+ const prev = lastScopeRef.current
583
+ lastScopeRef.current = detail
584
+ if (!hasInitializedScopeRef.current) {
585
+ hasInitializedScopeRef.current = true
586
+ return
587
+ }
588
+ if (
589
+ prev &&
590
+ prev.organizationId === detail.organizationId &&
591
+ prev.tenantId === detail.tenantId
592
+ ) {
593
+ return
594
+ }
595
+ scheduleRouterRefresh(router)
596
+ })
597
+ }, [router])
598
+ const queryClient = useQueryClient()
599
+ const perspectiveConfig = perspective ?? null
600
+ const perspectiveTableId = perspectiveConfig?.tableId ?? null
601
+ const perspectiveEnabled = Boolean(perspectiveTableId)
602
+ const initialSnapshotRef = React.useRef<PerspectiveSnapshot | null>(null)
603
+ const snapshotTableIdRef = React.useRef<string | null>(null)
604
+ if (typeof window !== 'undefined') {
605
+ if (perspectiveTableId !== snapshotTableIdRef.current) {
606
+ initialSnapshotRef.current = perspectiveTableId ? readPerspectiveSnapshot(perspectiveTableId) : null
607
+ snapshotTableIdRef.current = perspectiveTableId ?? null
608
+ }
609
+ } else if (snapshotTableIdRef.current !== perspectiveTableId) {
610
+ snapshotTableIdRef.current = perspectiveTableId ?? null
611
+ initialSnapshotRef.current = null
612
+ }
613
+ const initialSnapshot = initialSnapshotRef.current
614
+ const initialSettingsFromConfig = sanitizePerspectiveSettings(perspectiveConfig?.initialState?.initialSettings ?? null)
615
+ const initialSettingsFromSnapshot = sanitizePerspectiveSettings(initialSnapshot?.settings ?? null)
616
+ const mergedInitialSettings = initialSettingsFromConfig ?? initialSettingsFromSnapshot ?? null
617
+ const initialActiveId = perspectiveConfig?.initialState?.activePerspectiveId ?? initialSnapshot?.perspectiveId ?? null
618
+ const [isPerspectiveOpen, setPerspectiveOpen] = React.useState(false)
619
+ const [activePerspectiveId, setActivePerspectiveId] = React.useState<string | null>(initialActiveId)
620
+ const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>(() => mergedInitialSettings?.columnVisibility ?? {})
621
+ const [columnOrder, setColumnOrder] = React.useState<string[]>(() => mergedInitialSettings?.columnOrder ?? [])
622
+ const [deletingIds, setDeletingIds] = React.useState<string[]>([])
623
+ const [roleClearingIds, setRoleClearingIds] = React.useState<string[]>([])
624
+ const [perspectiveApiMissing, setPerspectiveApiMissing] = React.useState(false)
625
+
626
+ const perspectiveFeatureQuery = useQuery<{ use: boolean; roleDefaults: boolean }>({
627
+ queryKey: ['feature-check', 'perspectives'],
628
+ enabled: perspectiveEnabled,
629
+ staleTime: 5 * 60 * 1000,
630
+ queryFn: async () => {
631
+ try {
632
+ const call = await apiCall<{ granted?: unknown[] }>(
633
+ '/api/auth/feature-check',
634
+ {
635
+ method: 'POST',
636
+ headers: { 'Content-Type': 'application/json' },
637
+ body: JSON.stringify({ features: ['perspectives.use', 'perspectives.role_defaults'] }),
638
+ },
639
+ )
640
+ if (!call.ok) throw new Error(`feature-check failed (${call.status})`)
641
+ const data = call.result ?? {}
642
+ const granted = Array.isArray(data?.granted) ? data.granted.map((f: any) => String(f)) : []
643
+ const has = (feature: string) => granted.some((grantedFeature: string) => {
644
+ if (grantedFeature === '*') return true
645
+ if (grantedFeature === feature) return true
646
+ if (grantedFeature.endsWith('.*')) {
647
+ const prefix = grantedFeature.slice(0, -2)
648
+ return feature === prefix || feature.startsWith(`${prefix}.`)
649
+ }
650
+ return false
651
+ })
652
+ return {
653
+ use: has('perspectives.use'),
654
+ roleDefaults: has('perspectives.role_defaults'),
655
+ }
656
+ } catch {
657
+ return {
658
+ use: true,
659
+ roleDefaults: true,
660
+ }
661
+ }
662
+ },
663
+ })
664
+ const perspectivePermissions = perspectiveFeatureQuery.data
665
+ const canUsePerspectives = perspectiveEnabled && Boolean(perspectivePermissions?.use)
666
+ const canUseRoleDefaultsFeature = Boolean(perspectivePermissions?.roleDefaults)
667
+
668
+ React.useEffect(() => {
669
+ if (!canUsePerspectives && isPerspectiveOpen) {
670
+ setPerspectiveOpen(false)
671
+ }
672
+ }, [canUsePerspectives, isPerspectiveOpen])
673
+
674
+ React.useEffect(() => {
675
+ if (!perspectiveTableId) return
676
+ if (!mergedInitialSettings) return
677
+ const snapshot: PerspectiveSnapshot = {
678
+ perspectiveId: initialActiveId,
679
+ settings: mergedInitialSettings,
680
+ updatedAt: Date.now(),
681
+ }
682
+ writePerspectiveSnapshot(perspectiveTableId, snapshot)
683
+ initialSnapshotRef.current = snapshot
684
+ }, [perspectiveTableId, mergedInitialSettings, initialActiveId])
685
+
686
+ const perspectiveQuery = useQuery<PerspectivesIndexResponse>({
687
+ queryKey: ['table-perspectives', perspectiveTableId],
688
+ queryFn: async () => {
689
+ if (!perspectiveTableId) throw new Error('Missing table id')
690
+ const call = await apiCall<PerspectivesIndexResponse>(`/api/perspectives/${encodeURIComponent(perspectiveTableId)}`)
691
+ if (call.status === 404) {
692
+ setPerspectiveApiMissing(true)
693
+ return {
694
+ tableId: perspectiveTableId,
695
+ perspectives: [],
696
+ defaultPerspectiveId: null,
697
+ rolePerspectives: [],
698
+ roles: [],
699
+ canApplyToRoles: false,
700
+ }
701
+ }
702
+ if (!call.ok) {
703
+ await raiseCrudError(call.response, t('ui.dataTable.perspectives.error.load', 'Failed to load perspectives'))
704
+ }
705
+ setPerspectiveApiMissing(false)
706
+ const payload = call.result
707
+ if (!payload) throw new Error(t('ui.dataTable.perspectives.error.load', 'Failed to load perspectives'))
708
+ return payload
709
+ },
710
+ enabled: canUsePerspectives,
711
+ initialData: perspectiveConfig?.initialState?.response,
712
+ staleTime: 5 * 60 * 1000,
713
+ gcTime: 10 * 60 * 1000,
714
+ })
715
+ const perspectiveData = perspectiveQuery.data
716
+ const initialPerspectiveAppliedRef = React.useRef(Boolean(mergedInitialSettings))
717
+
718
+ // Date formatting setup
719
+ const DATE_FORMAT = (process.env.NEXT_PUBLIC_DATE_FORMAT || 'YYYY-MM-DD HH:mm') as string
720
+
721
+ const pad2 = (n: number) => (n < 10 ? `0${n}` : String(n))
722
+ const simpleFormat = (d: Date, fmt: string) => {
723
+ // Supports tokens: YYYY, MM, DD, HH, mm, ss
724
+ const YYYY = String(d.getFullYear())
725
+ const MM = pad2(d.getMonth() + 1)
726
+ const DD = pad2(d.getDate())
727
+ const HH = pad2(d.getHours())
728
+ const mm = pad2(d.getMinutes())
729
+ const ss = pad2(d.getSeconds())
730
+ return fmt
731
+ .replace(/YYYY/g, YYYY)
732
+ .replace(/MM/g, MM)
733
+ .replace(/DD/g, DD)
734
+ .replace(/HH/g, HH)
735
+ .replace(/mm/g, mm)
736
+ .replace(/ss/g, ss)
737
+ }
738
+
739
+ const tryParseDate = (v: unknown): Date | null => {
740
+ if (v == null) return null
741
+ if (v instanceof Date) return isNaN(v.getTime()) ? null : v
742
+ if (typeof v === 'number') {
743
+ const d = new Date(v)
744
+ return isNaN(d.getTime()) ? null : d
745
+ }
746
+ if (typeof v === 'string') {
747
+ const s = v.trim()
748
+ if (!s) return null
749
+ // ISO-like detection (YYYY-MM-DD ...)
750
+ if (/^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?$/.test(s)) {
751
+ const d = new Date(s)
752
+ return isNaN(d.getTime()) ? null : d
753
+ }
754
+ // Fallback: Date.parse
755
+ const d = new Date(s)
756
+ return isNaN(d.getTime()) ? null : d
757
+ }
758
+ return null
759
+ }
760
+
761
+ // Guess date columns once using first non-empty row
762
+ const [dateColumnIds, setDateColumnIds] = React.useState<Set<string> | null>(null)
763
+ React.useEffect(() => {
764
+ if (dateColumnIds) return
765
+ if (!data || data.length === 0) return
766
+ // Build a cheap row accessor using column defs
767
+ const accessors = columns.map((c) => {
768
+ const key = (c as any).accessorKey as string | undefined
769
+ const id = (c as any).id as string | undefined
770
+ return { id: id || key || '', key }
771
+ })
772
+ const guessed = new Set<string>()
773
+ accessors.forEach((a) => {
774
+ if (!a.id) return
775
+ const name = a.id
776
+ // Name-based guess: snake_case '_at' suffix
777
+ if (name.endsWith('_at')) {
778
+ guessed.add(name)
779
+ return
780
+ }
781
+ })
782
+ setDateColumnIds(guessed)
783
+ }, [dateColumnIds, data, columns])
784
+ // Map column meta.priority (1..6) to Tailwind responsive visibility
785
+ // 1 => always visible, 2 => hidden <sm, 3 => hidden <md, 4 => hidden <lg, 5 => hidden <xl, 6 => hidden <2xl
786
+ const responsiveClass = (priority?: number, hidden?: boolean) => {
787
+ if (hidden) return 'hidden'
788
+ switch (priority) {
789
+ case 2: return 'hidden sm:table-cell'
790
+ case 3: return 'hidden md:table-cell'
791
+ case 4: return 'hidden lg:table-cell'
792
+ case 5: return 'hidden xl:table-cell'
793
+ case 6: return 'hidden 2xl:table-cell'
794
+ default: return '' // priority 1 or undefined: always visible
795
+ }
796
+ }
797
+
798
+ const resolvePriority = React.useCallback((column: TableColumn<T, unknown>) => {
799
+ const meta = (column.columnDef as any)?.meta
800
+ const rawPriority = typeof meta?.priority === 'number' ? meta.priority : undefined
801
+ if (rawPriority && rawPriority > 0) return rawPriority
802
+ const index = column.getIndex()
803
+ return index <= 1 ? 1 : 2
804
+ }, [])
805
+
806
+ const initialSorting = React.useMemo<SortingState>(() => {
807
+ if (mergedInitialSettings?.sorting) {
808
+ return mergedInitialSettings.sorting.map((item) => ({ id: item.id, desc: Boolean(item.desc) }))
809
+ }
810
+ return []
811
+ }, [mergedInitialSettings])
812
+ const [sorting, setSorting] = React.useState<SortingState>(() => {
813
+ if (sortingProp && sortingProp.length) return sortingProp
814
+ if (initialSorting.length) return initialSorting
815
+ return []
816
+ })
817
+ const table = useReactTable<T>({
818
+ data,
819
+ columns,
820
+ getCoreRowModel: getCoreRowModel(),
821
+ ...(sortable ? { getSortedRowModel: getSortedRowModel() } : {}),
822
+ state: { sorting, columnVisibility, columnOrder },
823
+ onSortingChange: (updater) => {
824
+ const next = typeof updater === 'function' ? updater(sorting) : updater
825
+ setSorting(next)
826
+ onSortingChange?.(next)
827
+ },
828
+ onColumnVisibilityChange: (updater) => {
829
+ const next = typeof updater === 'function' ? updater(columnVisibility) : updater
830
+ setColumnVisibility(next)
831
+ },
832
+ onColumnOrderChange: (updater) => {
833
+ const next = typeof updater === 'function' ? updater(columnOrder) : updater
834
+ setColumnOrder(next)
835
+ },
836
+ })
837
+ React.useEffect(() => { if (sortingProp) setSorting(sortingProp) }, [sortingProp])
838
+ React.useEffect(() => {
839
+ const ids = table.getAllLeafColumns().map((column) => column.id)
840
+ if (!ids.length) return
841
+ setColumnOrder((prev) => {
842
+ if (!prev.length) return ids
843
+ const allowed = ids
844
+ const filtered = prev.filter((id) => allowed.includes(id))
845
+ const seen = new Set(filtered)
846
+ for (const id of allowed) {
847
+ if (!seen.has(id)) {
848
+ filtered.push(id)
849
+ seen.add(id)
850
+ }
851
+ }
852
+ const changed = filtered.length !== prev.length || filtered.some((id, index) => id !== prev[index])
853
+ return changed ? filtered : prev
854
+ })
855
+ }, [table, columns])
856
+
857
+ const initialVisibilityApplied = React.useRef(Boolean(mergedInitialSettings?.columnVisibility))
858
+ React.useEffect(() => {
859
+ if (initialVisibilityApplied.current) return
860
+ const hidden: VisibilityState = {}
861
+ table.getAllLeafColumns().forEach((column) => {
862
+ const hiddenMeta = (column.columnDef as any)?.meta?.hidden
863
+ if (hiddenMeta) hidden[column.id] = false
864
+ })
865
+ if (Object.keys(hidden).length) {
866
+ setColumnVisibility((prev) => ({ ...hidden, ...prev }))
867
+ }
868
+ initialVisibilityApplied.current = true
869
+ }, [table, columns])
870
+
871
+ const getCurrentSettings = React.useCallback((): PerspectiveSettings => {
872
+ const visibility: Record<string, boolean> = {}
873
+ for (const [key, value] of Object.entries(columnVisibility)) {
874
+ if (typeof key === 'string' && typeof value === 'boolean') {
875
+ visibility[key] = value
876
+ }
877
+ }
878
+ const filtersRecord: Record<string, unknown> = {}
879
+ for (const [key, value] of Object.entries(filterValues ?? {})) {
880
+ if (typeof key === 'string') filtersRecord[key] = value
881
+ }
882
+ const candidate: PerspectiveSettings = {
883
+ columnOrder,
884
+ columnVisibility: visibility,
885
+ sorting,
886
+ filters: filtersRecord,
887
+ searchValue,
888
+ }
889
+ return sanitizePerspectiveSettings(candidate) ?? {}
890
+ }, [columnOrder, columnVisibility, sorting, filterValues, searchValue])
891
+
892
+ const applyPerspectiveSettings = React.useCallback((settings: PerspectiveSettings, nextId: string | null) => {
893
+ const normalized = sanitizePerspectiveSettings(settings) ?? {}
894
+ if (normalized.columnOrder && normalized.columnOrder.length) {
895
+ setColumnOrder(normalized.columnOrder)
896
+ } else {
897
+ const ids = table.getAllLeafColumns().map((column) => column.id)
898
+ if (ids.length) setColumnOrder(ids)
899
+ }
900
+ if (normalized.columnVisibility) setColumnVisibility(normalized.columnVisibility)
901
+ else setColumnVisibility({})
902
+ if (normalized.sorting) {
903
+ const sortingState: SortingState = normalized.sorting.map((item) => ({
904
+ id: item.id,
905
+ desc: item.desc === true,
906
+ }))
907
+ setSorting(sortingState)
908
+ onSortingChange?.(sortingState)
909
+ } else {
910
+ setSorting([])
911
+ onSortingChange?.([])
912
+ }
913
+ if (onFiltersApply) {
914
+ onFiltersApply((normalized.filters ?? {}) as FilterValues)
915
+ }
916
+ if (onSearchChange) {
917
+ onSearchChange(normalized.searchValue ?? '')
918
+ }
919
+ setActivePerspectiveId(nextId)
920
+ if (perspectiveTableId) {
921
+ writePerspectiveCookie(perspectiveTableId, nextId)
922
+ if (nextId) {
923
+ const snapshot: PerspectiveSnapshot = { perspectiveId: nextId, settings: normalized, updatedAt: Date.now() }
924
+ writePerspectiveSnapshot(perspectiveTableId, snapshot)
925
+ initialSnapshotRef.current = snapshot
926
+ } else {
927
+ writePerspectiveSnapshot(perspectiveTableId, null)
928
+ initialSnapshotRef.current = null
929
+ }
930
+ }
931
+ }, [onFiltersApply, onSearchChange, onSortingChange, perspectiveTableId, table])
932
+
933
+ type SavePerspectivePayload = {
934
+ name: string
935
+ isDefault: boolean
936
+ applyToRoles: string[]
937
+ setRoleDefault: boolean
938
+ perspectiveId?: string | null
939
+ }
940
+
941
+ const perspectiveQueryKey: [string, string | null] = ['table-perspectives', perspectiveTableId]
942
+ const savePerspectiveMutation = useMutation<PerspectiveSaveResponse, Error, SavePerspectivePayload>({
943
+ mutationFn: async (input) => {
944
+ if (!perspectiveTableId) throw new Error('Missing table id')
945
+ const payload = {
946
+ perspectiveId: input.perspectiveId ?? undefined,
947
+ name: input.name,
948
+ settings: getCurrentSettings(),
949
+ isDefault: input.isDefault,
950
+ applyToRoles: input.applyToRoles,
951
+ setRoleDefault: input.setRoleDefault,
952
+ }
953
+ if (process.env.NODE_ENV !== 'production') {
954
+ // eslint-disable-next-line no-console
955
+ console.debug('[DataTable] perspective payload', payload)
956
+ }
957
+ const call = await apiCall<PerspectiveSaveResponse>(
958
+ `/api/perspectives/${encodeURIComponent(perspectiveTableId)}`,
959
+ {
960
+ method: 'POST',
961
+ headers: { 'Content-Type': 'application/json' },
962
+ body: JSON.stringify(payload),
963
+ },
964
+ )
965
+ if (call.status === 404) {
966
+ throw new Error(t('ui.dataTable.perspectives.error.apiUnavailable', 'Perspectives API is not available. Run `npm run modules:prepare` to regenerate module routes and restart the dev server.'))
967
+ }
968
+ if (!call.ok) {
969
+ await raiseCrudError(call.response, t('ui.dataTable.perspectives.error.save', 'Failed to save perspective'))
970
+ }
971
+ const result = call.result
972
+ if (!result) throw new Error(t('ui.dataTable.perspectives.error.save', 'Failed to save perspective'))
973
+ return result
974
+ },
975
+ onSuccess: (data) => {
976
+ if (perspectiveTableId) {
977
+ void queryClient.invalidateQueries({ queryKey: perspectiveQueryKey })
978
+ }
979
+ if (data.perspective) {
980
+ applyPerspectiveSettings(data.perspective.settings, data.perspective.id)
981
+ }
982
+ },
983
+ })
984
+
985
+ const resolveColumnLabel = React.useCallback((column: TableColumn<T, unknown>): string => {
986
+ const meta = (column.columnDef as any)?.meta
987
+ if (typeof meta?.label === 'string' && meta.label.trim().length > 0) return meta.label.trim()
988
+ if (typeof meta?.title === 'string' && meta.title.trim().length > 0) return meta.title.trim()
989
+ const header = column.columnDef.header
990
+ if (typeof header === 'string') return header
991
+ if (typeof header === 'function') return normalizeLabel(column.id)
992
+ return normalizeLabel(column.id)
993
+ }, [])
994
+
995
+ const columnOptions = React.useMemo(() => {
996
+ const leaves = table.getAllLeafColumns()
997
+ const baseOrder = columnOrder.length ? columnOrder : leaves.map((column) => column.id)
998
+ const seen = new Set<string>()
999
+ const ordered = baseOrder
1000
+ .map((id) => {
1001
+ const col = leaves.find((column) => column.id === id)
1002
+ if (!col) return null
1003
+ seen.add(id)
1004
+ return col
1005
+ })
1006
+ .filter(Boolean) as Array<TableColumn<T, unknown>>
1007
+ leaves.forEach((column) => { if (!seen.has(column.id)) ordered.push(column) })
1008
+ return ordered.map((column) => ({
1009
+ id: column.id,
1010
+ label: resolveColumnLabel(column),
1011
+ visible: columnVisibility[column.id] ?? column.getIsVisible(),
1012
+ canHide: column.getCanHide(),
1013
+ }))
1014
+ }, [table, columnOrder, resolveColumnLabel, columnVisibility, columns])
1015
+
1016
+ const activePersonalPerspectiveId = React.useMemo(() => {
1017
+ if (!perspectiveData || !activePerspectiveId) return null
1018
+ const found = perspectiveData.perspectives.find((p) => p.id === activePerspectiveId)
1019
+ return found ? found.id : null
1020
+ }, [perspectiveData, activePerspectiveId])
1021
+
1022
+
1023
+ const deletePerspectiveMutation = useMutation<void, Error, { perspectiveId: string }>({
1024
+ mutationFn: async ({ perspectiveId }) => {
1025
+ if (!perspectiveTableId) throw new Error('Missing table id')
1026
+ const call = await apiCall(
1027
+ `/api/perspectives/${encodeURIComponent(perspectiveTableId)}/${encodeURIComponent(perspectiveId)}`,
1028
+ { method: 'DELETE' },
1029
+ )
1030
+ if (call.status === 404) throw new Error(t('ui.dataTable.perspectives.error.apiUnavailable', 'Perspectives API is not available. Run `npm run modules:prepare` and restart the dev server.'))
1031
+ if (!call.ok) {
1032
+ await raiseCrudError(call.response, t('ui.dataTable.perspectives.error.delete', 'Failed to delete perspective'))
1033
+ }
1034
+ },
1035
+ onMutate: ({ perspectiveId }) => {
1036
+ setDeletingIds((prev) => prev.includes(perspectiveId) ? prev : [...prev, perspectiveId])
1037
+ },
1038
+ onSettled: (_data, _error, variables) => {
1039
+ setDeletingIds((prev) => prev.filter((id) => id !== variables.perspectiveId))
1040
+ },
1041
+ onSuccess: (_data, variables) => {
1042
+ const removedActive = activePerspectiveId === variables.perspectiveId
1043
+ if (perspectiveTableId) {
1044
+ void queryClient.invalidateQueries({ queryKey: perspectiveQueryKey })
1045
+ if (removedActive) {
1046
+ setActivePerspectiveId(null)
1047
+ writePerspectiveCookie(perspectiveTableId, null)
1048
+ writePerspectiveSnapshot(perspectiveTableId, null)
1049
+ initialSnapshotRef.current = null
1050
+ initialPerspectiveAppliedRef.current = false
1051
+ }
1052
+ } else if (removedActive) {
1053
+ setActivePerspectiveId(null)
1054
+ initialPerspectiveAppliedRef.current = false
1055
+ }
1056
+ },
1057
+ })
1058
+
1059
+ const clearRoleMutation = useMutation<void, Error, { roleId: string }>({
1060
+ mutationFn: async ({ roleId }) => {
1061
+ if (!perspectiveTableId) throw new Error('Missing table id')
1062
+ const call = await apiCall(
1063
+ `/api/perspectives/${encodeURIComponent(perspectiveTableId)}/roles/${encodeURIComponent(roleId)}`,
1064
+ { method: 'DELETE' },
1065
+ )
1066
+ if (call.status === 404) throw new Error(t('ui.dataTable.perspectives.error.apiUnavailable', 'Perspectives API is not available. Run `npm run modules:prepare` and restart the dev server.'))
1067
+ if (!call.ok) {
1068
+ await raiseCrudError(call.response, t('ui.dataTable.perspectives.error.clearRoles', 'Failed to clear role perspectives'))
1069
+ }
1070
+ },
1071
+ onMutate: ({ roleId }) => {
1072
+ setRoleClearingIds((prev) => prev.includes(roleId) ? prev : [...prev, roleId])
1073
+ },
1074
+ onSettled: (_data, _error, variables) => {
1075
+ setRoleClearingIds((prev) => prev.filter((id) => id !== variables.roleId))
1076
+ },
1077
+ onSuccess: (_data, variables) => {
1078
+ if (perspectiveTableId) {
1079
+ void queryClient.invalidateQueries({ queryKey: perspectiveQueryKey })
1080
+ }
1081
+ if (activePerspectiveId) {
1082
+ const current = queryClient.getQueryData<PerspectivesIndexResponse>(perspectiveQueryKey)
1083
+ const match = current?.rolePerspectives.find((rp) => rp.id === activePerspectiveId)
1084
+ if (match && match.roleId === variables.roleId) {
1085
+ setActivePerspectiveId(null)
1086
+ if (perspectiveTableId) writePerspectiveCookie(perspectiveTableId, null)
1087
+ if (perspectiveTableId) writePerspectiveSnapshot(perspectiveTableId, null)
1088
+ initialSnapshotRef.current = null
1089
+ initialPerspectiveAppliedRef.current = false
1090
+ }
1091
+ }
1092
+ },
1093
+ })
1094
+
1095
+ const handlePerspectiveActivate = React.useCallback((item: PerspectiveDto | RolePerspectiveDto, _source?: 'personal' | 'role') => {
1096
+ applyPerspectiveSettings(item.settings, item.id)
1097
+ setPerspectiveOpen(false)
1098
+ }, [applyPerspectiveSettings])
1099
+
1100
+ const handlePerspectiveSave = React.useCallback(async (input: { name: string; isDefault: boolean; applyToRoles: string[]; setRoleDefault: boolean }) => {
1101
+ const normalizedRoles = Array.from(new Set(input.applyToRoles))
1102
+ await savePerspectiveMutation.mutateAsync({
1103
+ name: input.name.trim(),
1104
+ isDefault: input.isDefault,
1105
+ applyToRoles: normalizedRoles,
1106
+ setRoleDefault: normalizedRoles.length > 0 ? input.setRoleDefault : false,
1107
+ perspectiveId: activePersonalPerspectiveId,
1108
+ })
1109
+ }, [savePerspectiveMutation, activePersonalPerspectiveId])
1110
+
1111
+ const handlePerspectiveDelete = React.useCallback(async (perspectiveId: string) => {
1112
+ await deletePerspectiveMutation.mutateAsync({ perspectiveId })
1113
+ }, [deletePerspectiveMutation])
1114
+
1115
+ const handleClearRole = React.useCallback(async (roleId: string) => {
1116
+ await clearRoleMutation.mutateAsync({ roleId })
1117
+ }, [clearRoleMutation])
1118
+
1119
+ const handleToggleColumn = React.useCallback((columnId: string, visible: boolean) => {
1120
+ const column = table.getColumn(columnId)
1121
+ if (!column) return
1122
+ setColumnVisibility((prev) => {
1123
+ const next = { ...prev }
1124
+ if (visible) delete next[columnId]
1125
+ else next[columnId] = false
1126
+ return next
1127
+ })
1128
+ column.toggleVisibility(visible)
1129
+ }, [table])
1130
+
1131
+ const handleMoveColumn = React.useCallback((columnId: string, direction: 'up' | 'down') => {
1132
+ setColumnOrder((prev) => {
1133
+ const idx = prev.indexOf(columnId)
1134
+ if (idx === -1) return prev
1135
+ const swap = direction === 'up' ? idx - 1 : idx + 1
1136
+ if (swap < 0 || swap >= prev.length) return prev
1137
+ const next = [...prev]
1138
+ const tmp = next[swap]
1139
+ next[swap] = next[idx]
1140
+ next[idx] = tmp
1141
+ table.setColumnOrder(next)
1142
+ return next
1143
+ })
1144
+ }, [table])
1145
+
1146
+ const perspectiveApiWarning = perspectiveApiMissing && canUsePerspectives
1147
+ ? t('ui.dataTable.perspectives.warning.apiUnavailable', 'Perspectives API is not available yet. Run `npm run modules:prepare` to regenerate module routes, then restart the server.')
1148
+ : null
1149
+
1150
+ const loadStartRef = React.useRef<number | null>(null)
1151
+ const [measuredDurationMs, setMeasuredDurationMs] = React.useState<number | null>(null)
1152
+
1153
+ React.useEffect(() => {
1154
+ if (typeof isLoading !== 'boolean') return
1155
+ if (isLoading) {
1156
+ if (loadStartRef.current === null) {
1157
+ const now = typeof performance !== 'undefined' && typeof performance.now === 'function'
1158
+ ? performance.now()
1159
+ : Date.now()
1160
+ loadStartRef.current = now
1161
+ }
1162
+ return
1163
+ }
1164
+ if (loadStartRef.current !== null) {
1165
+ const now = typeof performance !== 'undefined' && typeof performance.now === 'function'
1166
+ ? performance.now()
1167
+ : Date.now()
1168
+ setMeasuredDurationMs(now - loadStartRef.current)
1169
+ loadStartRef.current = null
1170
+ }
1171
+ }, [isLoading])
1172
+
1173
+ React.useLayoutEffect(() => {
1174
+ if (!canUsePerspectives) return
1175
+ if (!perspectiveTableId) return
1176
+ if (initialPerspectiveAppliedRef.current && activePerspectiveId != null) return
1177
+
1178
+ const source = perspectiveData ?? perspectiveConfig?.initialState?.response
1179
+ if (!source) return
1180
+
1181
+ const tryResolve = (id: string | null | undefined): PerspectiveDto | RolePerspectiveDto | undefined => {
1182
+ if (!id) return undefined
1183
+ return source.perspectives.find((p) => p.id === id)
1184
+ ?? source.rolePerspectives.find((p) => p.id === id)
1185
+ }
1186
+
1187
+ let target: PerspectiveDto | RolePerspectiveDto | undefined
1188
+ if (activePerspectiveId) {
1189
+ target = tryResolve(activePerspectiveId)
1190
+ }
1191
+ const cookieId = readPerspectiveCookie(perspectiveTableId)
1192
+ if (!target && cookieId) target = tryResolve(cookieId)
1193
+ if (!target && source.defaultPerspectiveId) {
1194
+ target = tryResolve(source.defaultPerspectiveId)
1195
+ }
1196
+ if (!target) {
1197
+ target = source.rolePerspectives.find((p) => p.isDefault)
1198
+ }
1199
+ if (!target) {
1200
+ target = source.perspectives[0]
1201
+ }
1202
+ if (target) {
1203
+ applyPerspectiveSettings(target.settings, target.id)
1204
+ }
1205
+ initialPerspectiveAppliedRef.current = true
1206
+ }, [canUsePerspectives, perspectiveData, perspectiveTableId, perspectiveConfig, applyPerspectiveSettings, activePerspectiveId])
1207
+
1208
+ const renderPagination = () => {
1209
+ if (!pagination) return null
1210
+
1211
+ const { page, totalPages, onPageChange, durationMs, cacheStatus } = pagination
1212
+ const startItem = (page - 1) * pagination.pageSize + 1
1213
+ const endItem = Math.min(page * pagination.pageSize, pagination.total)
1214
+ const effectiveDuration = (typeof durationMs === 'number' && Number.isFinite(durationMs) && durationMs >= 0)
1215
+ ? durationMs
1216
+ : measuredDurationMs ?? undefined
1217
+ const durationLabel = formatDurationLabel(effectiveDuration)
1218
+ const normalizedCacheStatus = cacheStatus === 'hit' || cacheStatus === 'miss' ? cacheStatus : null
1219
+ const cacheBadge = normalizedCacheStatus ? (
1220
+ <span
1221
+ className="inline-flex items-center justify-center"
1222
+ aria-label={t('ui.dataTable.pagination.cache.ariaLabel', 'Cache {status}', { status: normalizedCacheStatus.toUpperCase() })}
1223
+ title={t('ui.dataTable.pagination.cache.title', 'Cache {status}', { status: normalizedCacheStatus.toUpperCase() })}
1224
+ >
1225
+ <Circle
1226
+ className={`h-3.5 w-3.5 ${normalizedCacheStatus === 'hit' ? 'text-emerald-500' : 'text-amber-500'}`}
1227
+ strokeWidth={3}
1228
+ />
1229
+ <span className="sr-only">{t('ui.dataTable.pagination.cache.srOnly', 'Cache {status}', { status: normalizedCacheStatus.toUpperCase() })}</span>
1230
+ </span>
1231
+ ) : null
1232
+
1233
+ return (
1234
+ <div className="flex items-center justify-between px-4 py-3 border-t">
1235
+ <div className="text-sm text-muted-foreground flex items-center gap-2">
1236
+ <span>
1237
+ {durationLabel
1238
+ ? t('ui.dataTable.pagination.resultsWithDuration', 'Showing {start} to {end} of {total} results in {duration}', { start: startItem, end: endItem, total: pagination.total, duration: durationLabel })
1239
+ : t('ui.dataTable.pagination.results', 'Showing {start} to {end} of {total} results', { start: startItem, end: endItem, total: pagination.total })
1240
+ }
1241
+ </span>
1242
+ {cacheBadge}
1243
+ </div>
1244
+ <div className="flex items-center gap-2">
1245
+ <Button
1246
+ variant="outline"
1247
+ size="sm"
1248
+ onClick={() => onPageChange(page - 1)}
1249
+ disabled={page <= 1}
1250
+ >
1251
+ {t('ui.dataTable.pagination.previous', 'Previous')}
1252
+ </Button>
1253
+ <span className="text-sm">
1254
+ {t('ui.dataTable.pagination.pageInfo', 'Page {page} of {totalPages}', { page, totalPages })}
1255
+ </span>
1256
+ <Button
1257
+ variant="outline"
1258
+ size="sm"
1259
+ onClick={() => onPageChange(page + 1)}
1260
+ disabled={page >= totalPages}
1261
+ >
1262
+ {t('ui.dataTable.pagination.next', 'Next')}
1263
+ </Button>
1264
+ </div>
1265
+ </div>
1266
+ )
1267
+ }
1268
+
1269
+ // Auto filters: fetch custom field defs when requested
1270
+ const resolvedEntityIds = React.useMemo(() => {
1271
+ if (Array.isArray(entityIds) && entityIds.length) {
1272
+ const dedup = new Set<string>()
1273
+ const list: string[] = []
1274
+ entityIds.forEach((id) => {
1275
+ const trimmed = typeof id === 'string' ? id.trim() : ''
1276
+ if (!trimmed || dedup.has(trimmed)) return
1277
+ dedup.add(trimmed)
1278
+ list.push(trimmed)
1279
+ })
1280
+ return list
1281
+ }
1282
+ if (typeof entityId === 'string' && entityId.trim().length > 0) {
1283
+ return [entityId.trim()]
1284
+ }
1285
+ return []
1286
+ }, [entityId, entityIds])
1287
+ const entityKey = React.useMemo(() => (resolvedEntityIds.length ? resolvedEntityIds.join('|') : null), [resolvedEntityIds])
1288
+ const customFieldFilterExtrasSignature = React.useMemo(
1289
+ () => JSON.stringify(customFieldFilterKeyExtras ?? []),
1290
+ [customFieldFilterKeyExtras]
1291
+ )
1292
+
1293
+ const [cfFilterFieldsetsByEntity, setCfFilterFieldsetsByEntity] = React.useState<Record<string, CustomFieldsetDto[]>>({})
1294
+ const [cfFilterFieldsetSelection, setCfFilterFieldsetSelection] = React.useState<Record<string, string | null>>({})
1295
+
1296
+ React.useEffect(() => {
1297
+ if (!entityKey) {
1298
+ setCfFilterFieldsetsByEntity({})
1299
+ setCfFilterFieldsetSelection({})
1300
+ return
1301
+ }
1302
+ let cancelled = false
1303
+ const loadFieldsets = async () => {
1304
+ try {
1305
+ const payload = await fetchCustomFieldDefinitionsPayload(resolvedEntityIds)
1306
+ if (cancelled) return
1307
+ const fieldsets = payload.fieldsetsByEntity ?? {}
1308
+ setCfFilterFieldsetsByEntity(fieldsets)
1309
+ const selectionChanges: Array<[string, string | null]> = []
1310
+ let shouldNotify = false
1311
+ setCfFilterFieldsetSelection((prev) => {
1312
+ const next: Record<string, string | null> = {}
1313
+ let changed = false
1314
+ resolvedEntityIds.forEach((entityId) => {
1315
+ const list = fieldsets[entityId] ?? []
1316
+ if (!list.length) {
1317
+ if (prev[entityId] !== undefined) changed = true
1318
+ return
1319
+ }
1320
+ const existing = prev[entityId]
1321
+ const fallback = list[0]?.code ?? null
1322
+ const isValidExisting = existing ? list.some((entry) => entry.code === existing) : false
1323
+ const value = isValidExisting ? existing : fallback ?? null
1324
+ next[entityId] = value
1325
+ if (value !== existing) {
1326
+ changed = true
1327
+ selectionChanges.push([entityId, value])
1328
+ }
1329
+ })
1330
+ if (Object.keys(prev).length !== Object.keys(next).length) changed = true
1331
+ if (changed) {
1332
+ shouldNotify = true
1333
+ return next
1334
+ }
1335
+ return prev
1336
+ })
1337
+ if (shouldNotify && selectionChanges.length && onCustomFieldFilterFieldsetChange) {
1338
+ selectionChanges.forEach(([entityId, value]) => onCustomFieldFilterFieldsetChange(value, entityId))
1339
+ }
1340
+ } catch {
1341
+ if (!cancelled) {
1342
+ setCfFilterFieldsetsByEntity({})
1343
+ setCfFilterFieldsetSelection({})
1344
+ }
1345
+ }
1346
+ }
1347
+ loadFieldsets()
1348
+ return () => {
1349
+ cancelled = true
1350
+ }
1351
+ }, [customFieldFilterExtrasSignature, entityKey, onCustomFieldFilterFieldsetChange, resolvedEntityIds])
1352
+
1353
+ const supportsCustomFieldFilterFieldsets =
1354
+ resolvedEntityIds.length === 1 &&
1355
+ (cfFilterFieldsetsByEntity[resolvedEntityIds[0]]?.length ?? 0) > 0
1356
+ const activeCustomFieldFilterFieldset = supportsCustomFieldFilterFieldsets
1357
+ ? cfFilterFieldsetSelection[resolvedEntityIds[0]] ?? cfFilterFieldsetsByEntity[resolvedEntityIds[0]]?.[0]?.code ?? null
1358
+ : null
1359
+
1360
+ const handleCustomFieldFilterFieldsetChange = React.useCallback(
1361
+ (value: string) => {
1362
+ if (!supportsCustomFieldFilterFieldsets) return
1363
+ const entityId = resolvedEntityIds[0]
1364
+ const nextValue = value || null
1365
+ setCfFilterFieldsetSelection((prev) => {
1366
+ if (prev[entityId] === nextValue) return prev
1367
+ return { ...prev, [entityId]: nextValue }
1368
+ })
1369
+ if (onCustomFieldFilterFieldsetChange) {
1370
+ onCustomFieldFilterFieldsetChange(nextValue, entityId)
1371
+ }
1372
+ },
1373
+ [onCustomFieldFilterFieldsetChange, resolvedEntityIds, supportsCustomFieldFilterFieldsets],
1374
+ )
1375
+
1376
+ const { data: cfFilters = [] } = useCustomFieldFilterDefs(entityKey ? resolvedEntityIds : [], {
1377
+ enabled: !!entityKey,
1378
+ fieldset: supportsCustomFieldFilterFieldsets ? activeCustomFieldFilterFieldset ?? undefined : undefined,
1379
+ keyExtras: customFieldFilterKeyExtras,
1380
+ })
1381
+
1382
+ const builtToolbar = React.useMemo(() => {
1383
+ if (toolbar) return toolbar
1384
+ const anySearch = onSearchChange != null
1385
+ const anyFilters = (baseFilters && baseFilters.length > 0) || (cfFilters && cfFilters.length > 0)
1386
+ if (!anySearch && !anyFilters) return null
1387
+ // Merge base filters with CF filters, preferring base definitions when ids collide
1388
+ const baseList = baseFilters || []
1389
+ const existing = new Set(baseList.map((f) => f.id))
1390
+ const cfOnly = (cfFilters || []).filter((f) => !existing.has(f.id))
1391
+ const combined: FilterDef[] = [...baseList, ...cfOnly]
1392
+ const perspectiveButton = canUsePerspectives ? (
1393
+ <Button variant="outline" className="h-9" onClick={() => setPerspectiveOpen(true)}>
1394
+ <SlidersHorizontal className="mr-2 h-4 w-4" />
1395
+ {t('ui.dataTable.perspectives.button', 'Perspectives')}
1396
+ </Button>
1397
+ ) : null
1398
+ const fieldsetSelector =
1399
+ supportsCustomFieldFilterFieldsets && resolvedEntityIds.length === 1
1400
+ ? (
1401
+ <div className="space-y-1">
1402
+ <div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Fieldset</div>
1403
+ <select
1404
+ className="w-full rounded border bg-background px-2 py-2 text-sm"
1405
+ value={activeCustomFieldFilterFieldset ?? ''}
1406
+ onChange={(event) => handleCustomFieldFilterFieldsetChange(event.target.value)}
1407
+ >
1408
+ {(cfFilterFieldsetsByEntity[resolvedEntityIds[0]] ?? []).map((fieldset) => (
1409
+ <option key={fieldset.code} value={fieldset.code}>
1410
+ {fieldset.label}
1411
+ </option>
1412
+ ))}
1413
+ </select>
1414
+ </div>
1415
+ )
1416
+ : null
1417
+ const leadingItems = perspectiveButton ? <div className="flex items-center gap-2">{perspectiveButton}</div> : null
1418
+ return (
1419
+ <FilterBar
1420
+ searchValue={searchValue}
1421
+ onSearchChange={onSearchChange}
1422
+ searchPlaceholder={searchPlaceholder}
1423
+ searchAlign={searchAlign}
1424
+ filters={combined}
1425
+ values={filterValues}
1426
+ onApply={onFiltersApply}
1427
+ onClear={onFiltersClear}
1428
+ leadingItems={leadingItems}
1429
+ filtersExtraContent={fieldsetSelector}
1430
+ layout={embedded ? 'inline' : 'stacked'}
1431
+ className={embedded ? 'min-h-[2.25rem]' : undefined}
1432
+ />
1433
+ )
1434
+ }, [
1435
+ toolbar,
1436
+ searchValue,
1437
+ onSearchChange,
1438
+ searchPlaceholder,
1439
+ searchAlign,
1440
+ baseFilters,
1441
+ cfFilters,
1442
+ filterValues,
1443
+ onFiltersApply,
1444
+ onFiltersClear,
1445
+ canUsePerspectives,
1446
+ embedded,
1447
+ supportsCustomFieldFilterFieldsets,
1448
+ resolvedEntityIds,
1449
+ activeCustomFieldFilterFieldset,
1450
+ handleCustomFieldFilterFieldsetChange,
1451
+ cfFilterFieldsetsByEntity,
1452
+ ])
1453
+
1454
+ const hasTitle = title != null
1455
+ const hasActions = actions !== undefined && actions !== null && actions !== false
1456
+ const shouldReserveActionsSpace = actions === null || actions === false
1457
+ const exportConfig = exporter === false ? null : exporter || null
1458
+ const resolvedExportSections = React.useMemo(() => resolveExportSections(exportConfig), [exportConfig])
1459
+ const hasExport = resolvedExportSections.length > 0
1460
+ const refreshButtonConfig = refreshButton
1461
+ const hasRefreshButton = Boolean(refreshButtonConfig)
1462
+ const hasToolbar = builtToolbar != null
1463
+ const shouldRenderActionsWrapper = hasActions || hasRefreshButton || shouldReserveActionsSpace || hasExport
1464
+ const renderToolbarInline = embedded && hasToolbar
1465
+ const shouldRenderToolbarBelow = hasToolbar && !renderToolbarInline
1466
+ const shouldRenderHeader = hasTitle || renderToolbarInline || shouldRenderActionsWrapper || shouldRenderToolbarBelow
1467
+ const resolvedInjectionSpotId = injectionSpotId ?? (perspective?.tableId ? `data-table:${perspective.tableId}` : null)
1468
+ const resolvedInjectionContext = React.useMemo(
1469
+ () => injectionContext ?? { tableId: perspective?.tableId ?? null, title: typeof title === 'string' ? title : undefined },
1470
+ [injectionContext, perspective?.tableId, title]
1471
+ )
1472
+ const headerInjectionSpotId = React.useMemo(
1473
+ () => (resolvedInjectionSpotId ? `${resolvedInjectionSpotId}:header` : null),
1474
+ [resolvedInjectionSpotId]
1475
+ )
1476
+ const footerInjectionSpotId = React.useMemo(
1477
+ () => (resolvedInjectionSpotId ? `${resolvedInjectionSpotId}:footer` : null),
1478
+ [resolvedInjectionSpotId]
1479
+ )
1480
+
1481
+ const containerClassName = embedded ? '' : 'rounded-lg border bg-card'
1482
+ const headerWrapperClassName = embedded ? 'pb-3' : 'px-4 py-3 border-b'
1483
+ const headerContentClassName = 'flex items-center justify-between gap-2'
1484
+ const toolbarWrapperClassName = embedded ? 'mt-2' : 'mt-3 pt-3 border-t'
1485
+ const tableScrollWrapperClassName = embedded ? '' : 'overflow-auto'
1486
+
1487
+ const titleContent = hasTitle ? (
1488
+ <div className="text-base font-semibold leading-tight min-h-[2.25rem] flex items-center">
1489
+ {typeof title === 'string' ? <h2 className="text-base font-semibold">{title}</h2> : title}
1490
+ </div>
1491
+ ) : <div className="min-h-[2.25rem]" />
1492
+
1493
+ return (
1494
+ <TooltipProvider delayDuration={300}>
1495
+ <div className={containerClassName}>
1496
+ {shouldRenderHeader && (
1497
+ <div className={headerWrapperClassName}>
1498
+ {(hasTitle || shouldRenderActionsWrapper || renderToolbarInline) && (
1499
+ <div className={headerContentClassName}>
1500
+ <div className="flex-1 min-w-0">
1501
+ {renderToolbarInline ? builtToolbar : titleContent}
1502
+ </div>
1503
+ {shouldRenderActionsWrapper ? (
1504
+ <div className="flex items-center gap-2 min-h-[2.25rem]">
1505
+ {refreshButtonConfig ? (
1506
+ <Button
1507
+ type="button"
1508
+ variant="ghost"
1509
+ size="icon"
1510
+ onClick={refreshButtonConfig.onRefresh}
1511
+ aria-label={refreshButtonConfig.label}
1512
+ title={refreshButtonConfig.label}
1513
+ disabled={refreshButtonConfig.disabled || refreshButtonConfig.isRefreshing}
1514
+ >
1515
+ {refreshButtonConfig.isRefreshing ? (
1516
+ <Loader2 className="h-4 w-4 animate-spin" />
1517
+ ) : (
1518
+ <RefreshCw className="h-4 w-4" />
1519
+ )}
1520
+ <span className="sr-only">{refreshButtonConfig.label}</span>
1521
+ </Button>
1522
+ ) : null}
1523
+ {canUsePerspectives ? (
1524
+ <Button
1525
+ type="button"
1526
+ variant="ghost"
1527
+ size="icon"
1528
+ onClick={() => setPerspectiveOpen(true)}
1529
+ aria-label={t('ui.dataTable.customizeColumns.ariaLabel', 'Customize columns')}
1530
+ title={t('ui.dataTable.customizeColumns.title', 'Customize columns')}
1531
+ >
1532
+ <MoreHorizontal className="h-4 w-4" />
1533
+ <span className="sr-only">{t('ui.dataTable.customizeColumns.srOnly', 'Customize columns')}</span>
1534
+ </Button>
1535
+ ) : null}
1536
+ {exportConfig && hasExport ? <ExportMenu config={exportConfig} sections={resolvedExportSections} /> : null}
1537
+ {hasActions ? actions : null}
1538
+ </div>
1539
+ ) : null}
1540
+ </div>
1541
+ )}
1542
+ {shouldRenderToolbarBelow ? <div className={toolbarWrapperClassName}>{builtToolbar}</div> : null}
1543
+ {headerInjectionSpotId ? (
1544
+ <div className={embedded ? 'mt-2' : 'mt-3'}>
1545
+ <InjectionSpot spotId={headerInjectionSpotId} context={resolvedInjectionContext} />
1546
+ </div>
1547
+ ) : null}
1548
+ </div>
1549
+ )}
1550
+ <div className={tableScrollWrapperClassName}>
1551
+ <Table>
1552
+ <TableHeader>
1553
+ {table.getHeaderGroups().map((hg) => (
1554
+ <TableRow key={hg.id}>
1555
+ {hg.headers.map((header) => {
1556
+ const columnMeta = (header.column.columnDef as any)?.meta
1557
+ const priority = resolvePriority(header.column)
1558
+ return (
1559
+ <TableHead key={header.id} className={responsiveClass(priority, columnMeta?.hidden)}>
1560
+ {header.isPlaceholder ? null : (
1561
+ <button
1562
+ type="button"
1563
+ className={`inline-flex items-center gap-1 ${sortable && header.column.getCanSort?.() ? 'cursor-pointer select-none' : ''}`}
1564
+ onClick={() => sortable && header.column.toggleSorting?.(header.column.getIsSorted() === 'asc')}
1565
+ >
1566
+ {flexRender(header.column.columnDef.header, header.getContext())}
1567
+ {sortable && header.column.getIsSorted?.() ? (
1568
+ <span className="text-xs text-muted-foreground">{header.column.getIsSorted() === 'asc' ? '▲' : '▼'}</span>
1569
+ ) : null}
1570
+ </button>
1571
+ )}
1572
+ </TableHead>
1573
+ )
1574
+ })}
1575
+ {rowActions ? (
1576
+ <TableHead className="w-0 text-right">
1577
+ {t('ui.dataTable.actionsColumn', 'Actions')}
1578
+ </TableHead>
1579
+ ) : null}
1580
+ </TableRow>
1581
+ ))}
1582
+ </TableHeader>
1583
+ <TableBody>
1584
+ {isLoading ? (
1585
+ <TableRow>
1586
+ <TableCell colSpan={columns.length + (rowActions ? 1 : 0)} className="h-24 text-center">
1587
+ <div className="flex items-center justify-center gap-2">
1588
+ <Spinner size="md" />
1589
+ <span className="text-muted-foreground">Loading data...</span>
1590
+ </div>
1591
+ </TableCell>
1592
+ </TableRow>
1593
+ ) : error ? (
1594
+ <TableRow>
1595
+ <TableCell colSpan={columns.length + (rowActions ? 1 : 0)} className="h-24 text-center text-destructive">
1596
+ {typeof error === 'string' ? error : error}
1597
+ </TableCell>
1598
+ </TableRow>
1599
+ ) : table.getRowModel().rows.length ? (
1600
+ table.getRowModel().rows.map((row) => {
1601
+ const isClickable = !disableRowClick && (onRowClick || (rowActions && rowActions(row.original as T)))
1602
+
1603
+ return (
1604
+ <TableRow
1605
+ key={row.id}
1606
+ data-state={row.getIsSelected() && 'selected'}
1607
+ className={isClickable ? 'cursor-pointer hover:bg-muted/50 transition-colors' : ''}
1608
+ onClick={isClickable ? (e) => {
1609
+ // Don't trigger row click if clicking on actions cell
1610
+ if ((e.target as HTMLElement).closest('[data-actions-cell]')) {
1611
+ return
1612
+ }
1613
+
1614
+ if (onRowClick) {
1615
+ onRowClick(row.original as T)
1616
+ } else if (rowActions) {
1617
+ // Auto-extract and execute edit action
1618
+ const rowActionsElement = rowActions(row.original as T)
1619
+ if (React.isValidElement(rowActionsElement) &&
1620
+ 'items' in (rowActionsElement.props as any) &&
1621
+ Array.isArray((rowActionsElement.props as any).items)) {
1622
+ const editAction = extractEditAction((rowActionsElement.props as any).items as RowActionItem[])
1623
+ if (editAction) {
1624
+ if (editAction.href) {
1625
+ router.push(editAction.href)
1626
+ } else if (editAction.onSelect) {
1627
+ editAction.onSelect()
1628
+ }
1629
+ }
1630
+ }
1631
+ }
1632
+ } : undefined}
1633
+ >
1634
+ {row.getVisibleCells().map((cell) => {
1635
+ const columnMeta = (cell.column.columnDef as any)?.meta
1636
+ const priority = resolvePriority(cell.column)
1637
+ const hasCustomCell = Boolean(cell.column.columnDef.cell)
1638
+ const columnId = String((cell.column as any).id || '')
1639
+ const accessorKey = String((cell.column.columnDef as any)?.accessorKey || '')
1640
+ const isDateCol = dateColumnIds ? dateColumnIds.has(columnId) : false
1641
+
1642
+ let content: React.ReactNode
1643
+ if (isDateCol) {
1644
+ const raw = cell.getValue() as any
1645
+ const d = tryParseDate(raw)
1646
+ content = d ? simpleFormat(d, DATE_FORMAT) : (raw as any)
1647
+ } else {
1648
+ content = flexRender(cell.column.columnDef.cell, cell.getContext())
1649
+ }
1650
+
1651
+ // Get truncation configuration for this column
1652
+ const skipTruncation = shouldSkipTruncation(columnId)
1653
+ // Get truncation configuration for this column
1654
+ const truncateConfig = getColumnTruncateConfig(columnId, accessorKey, columnMeta)
1655
+ const shouldTruncate = truncateConfig.truncate && !skipTruncation
1656
+ const maxWidth = truncateConfig.maxWidth
1657
+
1658
+ // Wrap content with TruncatedCell if truncation is enabled
1659
+ // Get raw cell value for tooltip - flexRender returns React elements
1660
+ // that cannot have their text extracted, so we pass the raw value directly
1661
+ // Check for custom tooltip content function in column meta for complex cells
1662
+ const cellValue = cell.getValue()
1663
+ const metaTooltipContent = columnMeta?.tooltipContent as ((row: unknown) => string | undefined) | undefined
1664
+ const tooltipText = metaTooltipContent
1665
+ ? metaTooltipContent(row.original)
1666
+ : (cellValue != null ? String(cellValue) : undefined)
1667
+
1668
+ const wrappedContent = shouldTruncate ? (
1669
+ <TruncatedCell maxWidth={maxWidth} tooltipContent={tooltipText}>
1670
+ {content}
1671
+ </TruncatedCell>
1672
+ ) : content
1673
+
1674
+ return (
1675
+ <TableCell key={cell.id} className={responsiveClass(priority, columnMeta?.hidden)}>
1676
+ {wrappedContent}
1677
+ </TableCell>
1678
+ )
1679
+ })}
1680
+ {rowActions ? (
1681
+ <TableCell className="text-right whitespace-nowrap" data-actions-cell>
1682
+ {rowActions(row.original as T)}
1683
+ </TableCell>
1684
+ ) : null}
1685
+ </TableRow>
1686
+ )
1687
+ })
1688
+ ) : (
1689
+ <TableRow>
1690
+ <TableCell colSpan={columns.length + (rowActions ? 1 : 0)} className="h-24 text-center text-muted-foreground">
1691
+ {emptyState ?? t('ui.dataTable.emptyState.default', 'No results.')}
1692
+ </TableCell>
1693
+ </TableRow>
1694
+ )}
1695
+ </TableBody>
1696
+ </Table>
1697
+ </div>
1698
+ {footerInjectionSpotId ? (
1699
+ <div className={embedded ? 'mt-3' : 'px-4 py-3 border-t'}>
1700
+ <InjectionSpot spotId={footerInjectionSpotId} context={resolvedInjectionContext} />
1701
+ </div>
1702
+ ) : null}
1703
+ {renderPagination()}
1704
+ {canUsePerspectives ? (
1705
+ <PerspectiveSidebar
1706
+ open={isPerspectiveOpen}
1707
+ onOpenChange={setPerspectiveOpen}
1708
+ loading={perspectiveQuery.isFetching && !perspectiveQuery.data}
1709
+ perspectives={perspectiveData?.perspectives ?? []}
1710
+ rolePerspectives={perspectiveData?.rolePerspectives ?? []}
1711
+ roles={perspectiveData?.roles ?? []}
1712
+ activePerspectiveId={activePerspectiveId}
1713
+ onActivatePerspective={handlePerspectiveActivate}
1714
+ onDeletePerspective={handlePerspectiveDelete}
1715
+ onClearRole={handleClearRole}
1716
+ onSave={handlePerspectiveSave}
1717
+ canApplyToRoles={Boolean(perspectiveData?.canApplyToRoles && canUseRoleDefaultsFeature)}
1718
+ columnOptions={columnOptions}
1719
+ onToggleColumn={handleToggleColumn}
1720
+ onMoveColumn={handleMoveColumn}
1721
+ saving={savePerspectiveMutation.isPending}
1722
+ deletingIds={deletingIds}
1723
+ roleClearingIds={roleClearingIds}
1724
+ apiWarning={perspectiveApiWarning}
1725
+ />
1726
+ ) : null}
1727
+ </div>
1728
+ </TooltipProvider>
1729
+ )
1730
+ }