@quazardous/qdadm 1.19.3

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 (291) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +111 -0
  3. package/package.json +101 -0
  4. package/src/assets/logo.svg +6 -0
  5. package/src/auth/SessionAuthAdapter.ts +459 -0
  6. package/src/auth/index.ts +15 -0
  7. package/src/chain/ActiveStack.ts +154 -0
  8. package/src/chain/StackHydrator.ts +292 -0
  9. package/src/chain/index.ts +25 -0
  10. package/src/chain/useActiveStack.ts +120 -0
  11. package/src/chain/useStackHydrator.ts +155 -0
  12. package/src/components/BoolCell.vue +34 -0
  13. package/src/components/InfoBanner.vue +73 -0
  14. package/src/components/QdadmRoot.vue +52 -0
  15. package/src/components/SeverityTag.vue +86 -0
  16. package/src/components/dialogs/BulkStatusDialog.vue +60 -0
  17. package/src/components/dialogs/MultiStepDialog.vue +331 -0
  18. package/src/components/dialogs/SimpleDialog.vue +122 -0
  19. package/src/components/dialogs/UnsavedChangesDialog.vue +80 -0
  20. package/src/components/display/BannerZone.vue +65 -0
  21. package/src/components/display/CardsGrid.vue +72 -0
  22. package/src/components/display/CopyableId.vue +84 -0
  23. package/src/components/display/EmptyState.vue +62 -0
  24. package/src/components/display/IntensityBar.vue +190 -0
  25. package/src/components/display/RichCardsGrid.vue +101 -0
  26. package/src/components/edit/FormActions.vue +78 -0
  27. package/src/components/edit/FormField.vue +82 -0
  28. package/src/components/edit/FormInput.vue +158 -0
  29. package/src/components/edit/FormPage.vue +260 -0
  30. package/src/components/edit/FormTab.vue +217 -0
  31. package/src/components/edit/FormTabs.vue +120 -0
  32. package/src/components/edit/LookupField.vue +406 -0
  33. package/src/components/edit/LookupPickerDialog.vue +230 -0
  34. package/src/components/editors/JsonEditorFoldable.vue +470 -0
  35. package/src/components/editors/JsonStructuredField.vue +264 -0
  36. package/src/components/editors/JsonViewer.vue +51 -0
  37. package/src/components/editors/KeyValueEditor.vue +314 -0
  38. package/src/components/editors/LanguageEditor.vue +259 -0
  39. package/src/components/editors/PermissionEditor.vue +558 -0
  40. package/src/components/editors/ScopeEditor.vue +372 -0
  41. package/src/components/editors/VanillaJsonEditor.vue +183 -0
  42. package/src/components/index.ts +83 -0
  43. package/src/components/item/FieldGroups.vue +392 -0
  44. package/src/components/layout/AppLayout.vue +1032 -0
  45. package/src/components/layout/BaseLayout.vue +190 -0
  46. package/src/components/layout/Breadcrumb.vue +55 -0
  47. package/src/components/layout/DashboardLayout.vue +104 -0
  48. package/src/components/layout/FormLayout.vue +256 -0
  49. package/src/components/layout/ListLayout.vue +278 -0
  50. package/src/components/layout/PageHeader.vue +156 -0
  51. package/src/components/layout/PageLayout.vue +126 -0
  52. package/src/components/layout/PageNav.vue +192 -0
  53. package/src/components/layout/SidebarBox.vue +87 -0
  54. package/src/components/layout/Zone.vue +182 -0
  55. package/src/components/layout/defaults/DefaultBreadcrumb.vue +149 -0
  56. package/src/components/layout/defaults/DefaultFooter.vue +89 -0
  57. package/src/components/layout/defaults/DefaultFormActions.vue +67 -0
  58. package/src/components/layout/defaults/DefaultHeader.vue +78 -0
  59. package/src/components/layout/defaults/DefaultMenu.vue +188 -0
  60. package/src/components/layout/defaults/DefaultPagination.vue +75 -0
  61. package/src/components/layout/defaults/DefaultTable.vue +137 -0
  62. package/src/components/layout/defaults/DefaultUserInfo.vue +111 -0
  63. package/src/components/layout/defaults/index.ts +16 -0
  64. package/src/components/lists/ActionButtons.vue +49 -0
  65. package/src/components/lists/ActionColumn.vue +40 -0
  66. package/src/components/lists/FilterBar.vue +54 -0
  67. package/src/components/lists/ListPage.vue +467 -0
  68. package/src/components/pages/LoginPage.vue +334 -0
  69. package/src/components/pages/NotFoundPage.vue +98 -0
  70. package/src/components/show/ShowDisplay.vue +343 -0
  71. package/src/components/show/ShowField.vue +67 -0
  72. package/src/components/show/ShowPage.vue +431 -0
  73. package/src/composables/index.ts +155 -0
  74. package/src/composables/useApp.ts +67 -0
  75. package/src/composables/useAuth.ts +98 -0
  76. package/src/composables/useBareForm.ts +319 -0
  77. package/src/composables/useBreadcrumb.ts +252 -0
  78. package/src/composables/useChildPage.ts +113 -0
  79. package/src/composables/useCurrentEntity.ts +60 -0
  80. package/src/composables/useDeferred.ts +94 -0
  81. package/src/composables/useDirtyState.ts +138 -0
  82. package/src/composables/useEntityItemFormPage.ts +942 -0
  83. package/src/composables/useEntityItemFormPage.types.ts +451 -0
  84. package/src/composables/useEntityItemPage.ts +438 -0
  85. package/src/composables/useEntityItemShowPage.ts +722 -0
  86. package/src/composables/useFieldManager.ts +692 -0
  87. package/src/composables/useForm.ts +544 -0
  88. package/src/composables/useGuardStore.ts +53 -0
  89. package/src/composables/useHooks.ts +56 -0
  90. package/src/composables/useInfoBanner.ts +217 -0
  91. package/src/composables/useJsonSyntax.ts +122 -0
  92. package/src/composables/useLayoutResolver.ts +329 -0
  93. package/src/composables/useListPage.ts +1535 -0
  94. package/src/composables/useListPage.types.ts +437 -0
  95. package/src/composables/useListPage.utils.ts +101 -0
  96. package/src/composables/useNavContext.ts +439 -0
  97. package/src/composables/useNavigation.ts +216 -0
  98. package/src/composables/useOptionsLookup.ts +323 -0
  99. package/src/composables/usePageTitle.ts +84 -0
  100. package/src/composables/useSSEBridge.ts +142 -0
  101. package/src/composables/useSemanticBreadcrumb.ts +222 -0
  102. package/src/composables/useSidebarState.ts +24 -0
  103. package/src/composables/useSignals.ts +52 -0
  104. package/src/composables/useUnsavedChangesGuard.ts +155 -0
  105. package/src/composables/useUserImpersonator.ts +317 -0
  106. package/src/composables/useZoneRegistry.ts +252 -0
  107. package/src/core/bundles.ts +558 -0
  108. package/src/core/decorator.ts +420 -0
  109. package/src/core/extension.ts +470 -0
  110. package/src/core/index.ts +59 -0
  111. package/src/deferred/DeferredRegistry.ts +353 -0
  112. package/src/deferred/index.ts +15 -0
  113. package/src/editors/index.ts +12 -0
  114. package/src/entity/EntityManager.cache.ts +530 -0
  115. package/src/entity/EntityManager.crud.ts +615 -0
  116. package/src/entity/EntityManager.interface.ts +78 -0
  117. package/src/entity/EntityManager.query.ts +221 -0
  118. package/src/entity/EntityManager.relations.ts +151 -0
  119. package/src/entity/EntityManager.ts +944 -0
  120. package/src/entity/EntityManager.types.ts +288 -0
  121. package/src/entity/auth/CompositeAuthAdapter.ts +194 -0
  122. package/src/entity/auth/EntityAuthAdapter.ts +119 -0
  123. package/src/entity/auth/PermissiveAdapter.ts +43 -0
  124. package/src/entity/auth/RoleHierarchy.ts +128 -0
  125. package/src/entity/auth/SecurityChecker.ts +202 -0
  126. package/src/entity/auth/factory.ts +203 -0
  127. package/src/entity/auth/index.ts +47 -0
  128. package/src/entity/factory.test.js +189 -0
  129. package/src/entity/factory.ts +146 -0
  130. package/src/entity/index.ts +49 -0
  131. package/src/entity/storage/ApiStorage.ts +228 -0
  132. package/src/entity/storage/IStorage.ts +100 -0
  133. package/src/entity/storage/LocalStorage.ts +200 -0
  134. package/src/entity/storage/MemoryStorage.ts +184 -0
  135. package/src/entity/storage/MockApiStorage.ts +318 -0
  136. package/src/entity/storage/SdkStorage.ts +377 -0
  137. package/src/entity/storage/factory.test.js +159 -0
  138. package/src/entity/storage/factory.ts +209 -0
  139. package/src/entity/storage/index.ts +75 -0
  140. package/src/gen/FieldMapper.ts +122 -0
  141. package/src/gen/StorageProfileFactory.ts +93 -0
  142. package/src/gen/connectors/BaseConnector.ts +150 -0
  143. package/src/gen/connectors/ManualConnector.test.js +499 -0
  144. package/src/gen/connectors/ManualConnector.ts +414 -0
  145. package/src/gen/connectors/OpenAPIConnector.test.js +836 -0
  146. package/src/gen/connectors/OpenAPIConnector.ts +693 -0
  147. package/src/gen/connectors/__fixtures__/sample-openapi.json +311 -0
  148. package/src/gen/connectors/index.ts +27 -0
  149. package/src/gen/createManagers.ts +245 -0
  150. package/src/gen/decorators.ts +134 -0
  151. package/src/gen/generateManagers.test.js +540 -0
  152. package/src/gen/generateManagers.ts +398 -0
  153. package/src/gen/index.ts +66 -0
  154. package/src/gen/schema.ts +248 -0
  155. package/src/gen/vite-plugin.ts +114 -0
  156. package/src/generated/managers/testManager.js +45 -0
  157. package/src/generated/managers/testManager.ts +46 -0
  158. package/src/hooks/HookRegistry.ts +16 -0
  159. package/src/hooks/index.ts +15 -0
  160. package/src/i18n/I18n.ts +344 -0
  161. package/src/i18n/IncrementalDomainProvider.ts +153 -0
  162. package/src/i18n/InlineTranslationProvider.ts +4 -0
  163. package/src/i18n/LazyTranslationProvider.ts +102 -0
  164. package/src/i18n/MessagesRegistry.ts +4 -0
  165. package/src/i18n/Resolver.ts +4 -0
  166. package/src/i18n/__tests__/I18n.test.ts +169 -0
  167. package/src/i18n/__tests__/IncrementalDomainProvider.test.ts +146 -0
  168. package/src/i18n/__tests__/LazyTranslationProvider.test.ts +100 -0
  169. package/src/i18n/__tests__/Resolver.test.ts +271 -0
  170. package/src/i18n/defaults/DefaultCoreProvider.ts +28 -0
  171. package/src/i18n/defaults/core.en.yml +55 -0
  172. package/src/i18n/defaults/core.fr.yml +55 -0
  173. package/src/i18n/index.ts +55 -0
  174. package/src/i18n/loaders/raw-modules.d.ts +15 -0
  175. package/src/i18n/loaders/yaml.ts +35 -0
  176. package/src/i18n/strategies.ts +4 -0
  177. package/src/i18n/types.ts +34 -0
  178. package/src/i18n/useI18n.ts +34 -0
  179. package/src/index.ts +453 -0
  180. package/src/kernel/EventRouter.ts +23 -0
  181. package/src/kernel/Kernel.api.ts +79 -0
  182. package/src/kernel/Kernel.auth.ts +120 -0
  183. package/src/kernel/Kernel.i18n.ts +29 -0
  184. package/src/kernel/Kernel.modules.ts +170 -0
  185. package/src/kernel/Kernel.registries.ts +237 -0
  186. package/src/kernel/Kernel.routing.ts +334 -0
  187. package/src/kernel/Kernel.ts +460 -0
  188. package/src/kernel/Kernel.types.ts +263 -0
  189. package/src/kernel/Kernel.vue.ts +304 -0
  190. package/src/kernel/KernelContext.entities.ts +80 -0
  191. package/src/kernel/KernelContext.events.ts +57 -0
  192. package/src/kernel/KernelContext.i18n.ts +37 -0
  193. package/src/kernel/KernelContext.permissions.ts +38 -0
  194. package/src/kernel/KernelContext.routing.ts +280 -0
  195. package/src/kernel/KernelContext.ts +252 -0
  196. package/src/kernel/KernelContext.types.ts +173 -0
  197. package/src/kernel/KernelContext.zones.ts +54 -0
  198. package/src/kernel/Module.ts +169 -0
  199. package/src/kernel/ModuleLoader.ts +645 -0
  200. package/src/kernel/SSEBridge.ts +11 -0
  201. package/src/kernel/SignalBus.ts +48 -0
  202. package/src/kernel/index.ts +56 -0
  203. package/src/module/index.ts +33 -0
  204. package/src/module/moduleRegistry.ts +492 -0
  205. package/src/modules/debug/AuthCollector.ts +501 -0
  206. package/src/modules/debug/Collector.ts +20 -0
  207. package/src/modules/debug/DebugBridge.ts +14 -0
  208. package/src/modules/debug/DebugModule.ts +220 -0
  209. package/src/modules/debug/EntitiesCollector.ts +839 -0
  210. package/src/modules/debug/ErrorCollector.ts +4 -0
  211. package/src/modules/debug/I18nCollector.ts +9 -0
  212. package/src/modules/debug/LocalStorageAdapter.ts +7 -0
  213. package/src/modules/debug/RouterCollector.ts +449 -0
  214. package/src/modules/debug/SignalCollector.ts +4 -0
  215. package/src/modules/debug/ToastCollector.ts +4 -0
  216. package/src/modules/debug/ZonesCollector.ts +447 -0
  217. package/src/modules/debug/components/DebugBar.vue +31 -0
  218. package/src/modules/debug/components/adminPanelsConfig.ts +42 -0
  219. package/src/modules/debug/components/index.ts +9 -0
  220. package/src/modules/debug/components/panels/AuthPanel.vue +267 -0
  221. package/src/modules/debug/components/panels/EntitiesPanel.vue +572 -0
  222. package/src/modules/debug/components/panels/I18nPanel.vue +738 -0
  223. package/src/modules/debug/components/panels/RouterPanel.vue +440 -0
  224. package/src/modules/debug/components/panels/ZonesPanel.vue +133 -0
  225. package/src/modules/debug/components/panels/index.ts +16 -0
  226. package/src/modules/debug/index.ts +58 -0
  227. package/src/modules/debug/styles.scss +2671 -0
  228. package/src/notifications/NotificationBadge.vue +35 -0
  229. package/src/notifications/NotificationListener.vue +71 -0
  230. package/src/notifications/NotificationModule.ts +76 -0
  231. package/src/notifications/NotificationPanel.vue +478 -0
  232. package/src/notifications/NotificationStore.ts +288 -0
  233. package/src/notifications/index.ts +42 -0
  234. package/src/notifications/styles.scss +32 -0
  235. package/src/orchestrator/Orchestrator.ts +427 -0
  236. package/src/orchestrator/index.ts +8 -0
  237. package/src/orchestrator/useOrchestrator.ts +80 -0
  238. package/src/plugin.ts +251 -0
  239. package/src/query/FilterQuery.ts +306 -0
  240. package/src/query/QueryExecutor.ts +341 -0
  241. package/src/query/index.ts +16 -0
  242. package/src/security/EntityRolesProvider.ts +385 -0
  243. package/src/security/PermissionMatcher.ts +152 -0
  244. package/src/security/PermissionRegistry.ts +273 -0
  245. package/src/security/PersistableRolesProvider.ts +584 -0
  246. package/src/security/RolesManager.ts +136 -0
  247. package/src/security/RolesProvider.ts +167 -0
  248. package/src/security/RolesProviderStorage.ts +211 -0
  249. package/src/security/SecurityModule.ts +94 -0
  250. package/src/security/StaticRolesProvider.ts +117 -0
  251. package/src/security/UsersManager.ts +163 -0
  252. package/src/security/index.ts +77 -0
  253. package/src/security/pages/RoleForm.vue +276 -0
  254. package/src/security/pages/RoleList.vue +123 -0
  255. package/src/styles/_alerts.scss +95 -0
  256. package/src/styles/_buttons.scss +391 -0
  257. package/src/styles/_cards.scss +241 -0
  258. package/src/styles/_code.scss +78 -0
  259. package/src/styles/_dialogs.scss +55 -0
  260. package/src/styles/_filter-bar.scss +70 -0
  261. package/src/styles/_forms.scss +434 -0
  262. package/src/styles/_helpers.scss +153 -0
  263. package/src/styles/_lists.scss +225 -0
  264. package/src/styles/_main.scss +356 -0
  265. package/src/styles/_markdown.scss +88 -0
  266. package/src/styles/_responsive.scss +255 -0
  267. package/src/styles/_show-pages.scss +186 -0
  268. package/src/styles/_sidebar.scss +380 -0
  269. package/src/styles/_states.scss +115 -0
  270. package/src/styles/_stats.scss +47 -0
  271. package/src/styles/_variables.scss +224 -0
  272. package/src/styles/index.scss +38 -0
  273. package/src/styles/theme/_components.scss +331 -0
  274. package/src/styles/theme/_index.scss +10 -0
  275. package/src/styles/theme/_tokens.scss +147 -0
  276. package/src/styles/theme/_utilities.scss +175 -0
  277. package/src/toast/ToastBridgeModule.ts +68 -0
  278. package/src/toast/ToastListener.vue +55 -0
  279. package/src/toast/index.ts +26 -0
  280. package/src/toast/useSignalToast.ts +154 -0
  281. package/src/types/index.ts +285 -0
  282. package/src/types/pluralize.d.ts +19 -0
  283. package/src/types/quarkernel.d.ts +50 -0
  284. package/src/utils/formatters.ts +174 -0
  285. package/src/utils/index.ts +35 -0
  286. package/src/utils/transformers.ts +131 -0
  287. package/src/vite/qdadmDebugPlugin.ts +401 -0
  288. package/src/vite-env.d.ts +50 -0
  289. package/src/zones/ZoneRegistry.ts +934 -0
  290. package/src/zones/index.ts +29 -0
  291. package/src/zones/zones.ts +191 -0
@@ -0,0 +1,1535 @@
1
+ /**
2
+ * useListPage - Unified procedural builder for CRUD list pages
3
+ *
4
+ * Provides a declarative/procedural API to build list pages with:
5
+ * - Cards zone (stats, custom content)
6
+ * - Filter bar with search and custom filters
7
+ * - DataTable with columns and actions
8
+ * - Bulk selection and operations
9
+ *
10
+ * ## Filter Modes
11
+ *
12
+ * The threshold decides everything:
13
+ * - `items >= threshold` → **manager mode**: delegate ALL filtering to EntityManager
14
+ * - `items < threshold` → **local mode**: filter client-side
15
+ *
16
+ * Modes:
17
+ * - `filterMode: 'manager'` - Always delegate to EntityManager
18
+ * - `filterMode: 'local'` - Always filter client-side
19
+ * - `filterMode: 'auto'` (default) - Switch to local if items < threshold
20
+ */
21
+ import {
22
+ ref,
23
+ computed,
24
+ watch,
25
+ onMounted,
26
+ onUnmounted,
27
+ inject,
28
+ provide,
29
+ type Ref,
30
+ } from 'vue'
31
+ import { useRouter, useRoute } from 'vue-router'
32
+ import { useConfirm } from 'primevue/useconfirm'
33
+ import { useHooks } from './useHooks.js'
34
+ import { useEntityItemPage, type ParentConfig, type UseEntityItemPageReturn } from './useEntityItemPage.js'
35
+ import { useActiveStack } from '../chain/useActiveStack.js'
36
+ import { FilterQuery } from '../query/FilterQuery'
37
+ import { useI18n } from '../i18n/useI18n'
38
+
39
+ // Import types from dedicated types file
40
+ import type {
41
+ Orchestrator,
42
+ ToastHelper,
43
+ ConfirmService,
44
+ ColumnConfig,
45
+ FilterConfig,
46
+ ActionConfig,
47
+ ResolvedAction,
48
+ HeaderActionState,
49
+ HeaderActionConfig,
50
+ ResolvedHeaderAction,
51
+ CardConfig,
52
+ SearchConfig,
53
+ EntityContext,
54
+ BulkStatusActionReturn,
55
+ ListResponse,
56
+ AxiosError,
57
+ UseListPageOptions,
58
+ ListPageProps,
59
+ ListPageEvents,
60
+ CreateActionOptions,
61
+ BulkStatusActionOptions,
62
+ UseListPageReturn,
63
+ } from './useListPage.types'
64
+
65
+ // Re-export public types
66
+ export type {
67
+ ColumnConfig,
68
+ FilterConfig,
69
+ ActionConfig,
70
+ ResolvedAction,
71
+ HeaderActionConfig,
72
+ ResolvedHeaderAction,
73
+ CardConfig,
74
+ SearchConfig,
75
+ BulkStatusActionReturn,
76
+ UseListPageOptions,
77
+ ListPageProps,
78
+ ListPageEvents,
79
+ CreateActionOptions,
80
+ BulkStatusActionOptions,
81
+ UseListPageReturn,
82
+ } from './useListPage.types'
83
+
84
+ // Stateless utilities (cookies, session storage, formatters, constants).
85
+ import {
86
+ PAGE_SIZE_OPTIONS,
87
+ SMART_FILTER_THRESHOLD,
88
+ clearSessionFilters,
89
+ getSavedPageSize,
90
+ getSessionFilters,
91
+ persistPageSize,
92
+ setSessionFilters,
93
+ snakeToTitle,
94
+ } from './useListPage.utils'
95
+
96
+ // Re-export PAGE_SIZE_OPTIONS so existing consumers (qdadm/index.ts,
97
+ // composables/index.ts) keep their import path.
98
+ export { PAGE_SIZE_OPTIONS } from './useListPage.utils'
99
+
100
+
101
+ /**
102
+ * Unified procedural builder for CRUD list pages
103
+ *
104
+ * @param config - Configuration options
105
+ * @returns List page API
106
+ */
107
+ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseListPageReturn<T> {
108
+ const {
109
+ entity,
110
+ dataKey,
111
+ defaultSort = null,
112
+ defaultSortOrder = -1,
113
+ pageSize: defaultPageSize = 10,
114
+ loadOnMount = true,
115
+ persistFilters = true,
116
+ syncUrlParams = true,
117
+ autoLoadFilters = true,
118
+ onBeforeLoad = null,
119
+ onAfterLoad = null,
120
+ transformResponse = null,
121
+ } = config
122
+
123
+ const router = useRouter()
124
+ const route = useRoute()
125
+ const confirm = useConfirm() as ConfirmService
126
+
127
+ // Mobile detection (viewport width < 768px)
128
+ const isMobile = ref(window.innerWidth < 768)
129
+
130
+ // i18n integration — column headers resolve via entities.{entity}.fields.{field}
131
+ const { i18n: kernelI18n, locale: i18nLocale } = useI18n()
132
+
133
+ // Get EntityManager via orchestrator
134
+ const orchestrator = inject<Orchestrator | null>('qdadmOrchestrator')
135
+
136
+ // Toast helper - wraps orchestrator.toast for legacy compatibility
137
+ const toast: ToastHelper = {
138
+ add({ severity, summary, detail, emitter }) {
139
+ orchestrator?.toast[severity]?.(summary, detail, emitter)
140
+ },
141
+ }
142
+
143
+ if (!orchestrator) {
144
+ throw new Error(
145
+ '[qdadm] Orchestrator not provided.\n' +
146
+ 'Possible causes:\n' +
147
+ '1. Kernel not initialized - ensure createKernel().createApp() is called before mounting\n' +
148
+ '2. Component used outside of qdadm app context\n' +
149
+ '3. Missing entityFactory in Kernel options'
150
+ )
151
+ }
152
+ const manager = orchestrator.get(entity)
153
+
154
+ // Provide entity context for child components (e.g., SeverityTag auto-discovery)
155
+ provide('mainEntity', entity)
156
+
157
+ // ============ PARENT CHAIN SUPPORT ============
158
+
159
+ const parentConfigComputed = computed<ParentConfig | null>(
160
+ () => (route.meta?.parent as ParentConfig) || null
161
+ )
162
+
163
+ // Use ActiveStack for synchronous route-based context
164
+ useActiveStack()
165
+
166
+ // Create useEntityItemPage for PARENT entity (not for list's own entity)
167
+ let parentPage: UseEntityItemPageReturn | null = null
168
+ if (route.meta?.parent) {
169
+ const parentMeta = route.meta.parent as ParentConfig
170
+ parentPage = useEntityItemPage({
171
+ entity: parentMeta.entity,
172
+ loadOnMount: true,
173
+ autoLoadParent: true,
174
+ })
175
+ }
176
+
177
+ // Parent data and loading state (null if no parent)
178
+ const parentData = computed(() => parentPage?.data.value || null)
179
+ const parentId = computed(() => parentPage?.entityId.value || null)
180
+ const parentLoading = computed(() => parentPage?.loading.value || false)
181
+ const parentChain = computed(() => parentPage?.parentChain.value || new Map())
182
+
183
+ /**
184
+ * Build entity context with parentChain array for multi-storage routing
185
+ */
186
+ const entityContext = computed<EntityContext | null>(() => {
187
+ if (!parentConfigComputed.value) {
188
+ return null
189
+ }
190
+
191
+ const chain: Array<{ entity: string; id: string | number }> = []
192
+ let currentConfig: ParentConfig | undefined = parentConfigComputed.value
193
+
194
+ while (currentConfig) {
195
+ const entityId = route.params[currentConfig.param] as string
196
+ if (!entityId) break
197
+
198
+ chain.unshift({
199
+ entity: currentConfig.entity,
200
+ id: entityId,
201
+ })
202
+ currentConfig = currentConfig.parent
203
+ }
204
+
205
+ return chain.length > 0 ? { parentChain: chain } : null
206
+ })
207
+
208
+ // Read config from manager with option overrides
209
+ const entityName = config.entityName ?? manager.label ?? entity
210
+ const entityNamePlural = config.entityNamePlural ?? manager.labelPlural ?? `${entityName}s`
211
+ const routePrefix = config.routePrefix ?? manager.routePrefix ?? entity
212
+ const resolvedDataKey = dataKey ?? manager.idField ?? 'id'
213
+
214
+ // Session key for filter persistence (based on entity name)
215
+ const filterSessionKey = entity || entityName
216
+
217
+ // Entity filters registry (optional, provided by consuming app)
218
+ const entityFilters = inject<Record<string, { search?: SearchConfig; filters?: FilterConfig[] }>>(
219
+ 'qdadmEntityFilters',
220
+ {}
221
+ )
222
+
223
+ // Get HookRegistry for list:alter hook (optional, may not exist in tests)
224
+ const hooks = useHooks()
225
+
226
+ // ============ SESSION RESTORE ============
227
+ const savedSession = persistFilters ? getSessionFilters(filterSessionKey) : null
228
+ const savedSearch = (savedSession?._search as string) || ''
229
+ const savedFilters: Record<string, unknown> | null = savedSession ? { ...savedSession } : null
230
+ if (savedFilters) delete savedFilters._search
231
+
232
+ // ============ STATE ============
233
+ const items = ref<T[]>([]) as Ref<T[]>
234
+ const loading = ref(false)
235
+ const selected = ref<T[]>([]) as Ref<T[]>
236
+ const deleting = ref(false)
237
+
238
+ // Pagination
239
+ const page = ref(1)
240
+ const pageSize = ref(getSavedPageSize(defaultPageSize))
241
+ const totalRecords = ref(0)
242
+ const rowsPerPageOptions = PAGE_SIZE_OPTIONS
243
+
244
+ // Sorting
245
+ const sortField = ref<string | null>(defaultSort)
246
+ const sortOrder = ref(defaultSortOrder)
247
+
248
+ // Search
249
+ const searchQuery = ref(savedSearch)
250
+ const searchConfig = ref<SearchConfig>({
251
+ placeholder: 'Search...',
252
+ fields: [],
253
+ debounce: 300,
254
+ })
255
+
256
+ // ============ COLUMNS ============
257
+ const columnsMap = ref<Map<string, ColumnConfig>>(new Map())
258
+ // Track inline-supplied column headers so relabel() can fall back to them
259
+ // when the i18n bundle has no translation for the field.
260
+ const columnInlineHeaders = new Map<string, string | undefined>()
261
+
262
+ /**
263
+ * Resolve a column header: i18n hit on entities.{entity}.fields.{field} wins,
264
+ * otherwise the inline `header` option, otherwise a capitalized field name.
265
+ */
266
+ function resolveColumnHeader(field: string, inline?: string): string {
267
+ if (entity && kernelI18n) {
268
+ const trace = kernelI18n.resolve(`entities.${entity}.fields.${field}`)
269
+ if (trace.hit) return trace.result
270
+ }
271
+ return inline || field.charAt(0).toUpperCase() + field.slice(1)
272
+ }
273
+
274
+ function addColumn(field: string, columnConfig: Partial<ColumnConfig> = {}): void {
275
+ columnInlineHeaders.set(field, columnConfig.header)
276
+ columnsMap.value.set(field, {
277
+ field,
278
+ header: resolveColumnHeader(field, columnConfig.header),
279
+ ...columnConfig,
280
+ // Re-apply resolved header after spread so it wins over a stale inline.
281
+ ...(columnConfig.header ? {} : { header: resolveColumnHeader(field) }),
282
+ })
283
+ }
284
+
285
+ // Re-resolve column headers when locale changes.
286
+ watch(i18nLocale, () => {
287
+ if (!entity || !kernelI18n) return
288
+ for (const [field, current] of columnsMap.value.entries()) {
289
+ const newHeader = resolveColumnHeader(field, columnInlineHeaders.get(field))
290
+ if (current.header !== newHeader) {
291
+ columnsMap.value.set(field, { ...current, header: newHeader })
292
+ }
293
+ }
294
+ })
295
+
296
+ function removeColumn(field: string): void {
297
+ columnsMap.value.delete(field)
298
+ }
299
+
300
+ function updateColumn(field: string, updates: Partial<ColumnConfig>): void {
301
+ const existing = columnsMap.value.get(field)
302
+ if (existing) {
303
+ columnsMap.value.set(field, { ...existing, ...updates })
304
+ }
305
+ }
306
+
307
+ const columns = computed(() => Array.from(columnsMap.value.values()))
308
+
309
+ // ============ HEADER ACTIONS ============
310
+ const headerActionsMap = ref<Map<string, HeaderActionConfig>>(new Map())
311
+
312
+ function addHeaderAction(name: string, config: Omit<HeaderActionConfig, 'name'>): void {
313
+ headerActionsMap.value.set(name, { name, ...config })
314
+ }
315
+
316
+ function removeHeaderAction(name: string): void {
317
+ headerActionsMap.value.delete(name)
318
+ }
319
+
320
+ function getHeaderActions(): ResolvedHeaderAction[] {
321
+ const state: HeaderActionState = {
322
+ hasSelection: hasSelection.value,
323
+ selectionCount: selectionCount.value,
324
+ deleting: deleting.value,
325
+ }
326
+ const actions: ResolvedHeaderAction[] = []
327
+ for (const [, action] of headerActionsMap.value) {
328
+ if (action.visible && !action.visible(state)) continue
329
+ actions.push({
330
+ ...action,
331
+ isLoading: action.loading ? action.loading(state) : false,
332
+ })
333
+ }
334
+ return actions
335
+ }
336
+
337
+ const headerActions = computed(() => getHeaderActions())
338
+
339
+ // ============ PERMISSION STATE ============
340
+
341
+ const canCreate = computed(() => manager.canCreate())
342
+ const canDelete = computed(() => manager.canDelete())
343
+
344
+ function canEditRow(row: unknown): boolean {
345
+ return manager.canUpdate(row)
346
+ }
347
+
348
+ function canDeleteRow(row: unknown): boolean {
349
+ return manager.canDelete(row)
350
+ }
351
+
352
+ function getRowActions(row: unknown): ResolvedAction[] {
353
+ return getActions(row)
354
+ }
355
+
356
+ function addCreateAction(labelOrOptions: string | CreateActionOptions | null = null): void {
357
+ const options: CreateActionOptions =
358
+ typeof labelOrOptions === 'string' ? { label: labelOrOptions } : labelOrOptions || {}
359
+
360
+ const createLabel =
361
+ options.label || `Create ${entityName.charAt(0).toUpperCase() + entityName.slice(1)}`
362
+
363
+ const onClick = options.routeName
364
+ ? () =>
365
+ router.push({
366
+ name: options.routeName!,
367
+ params: { ...route.params, ...options.params },
368
+ } as { name: string; params: Record<string, string> })
369
+ : goToCreate
370
+
371
+ addHeaderAction('create', {
372
+ label: createLabel,
373
+ icon: 'pi pi-plus',
374
+ onClick,
375
+ visible: () => manager.canCreate(),
376
+ })
377
+ }
378
+
379
+ function addBulkDeleteAction(): void {
380
+ addHeaderAction('bulk-delete', {
381
+ label: (state) => `Delete (${state.selectionCount})`,
382
+ icon: 'pi pi-trash',
383
+ severity: 'danger',
384
+ onClick: confirmBulkDelete,
385
+ visible: (state) => state.hasSelection && manager.canDelete(),
386
+ loading: (state) => state.deleting,
387
+ })
388
+ }
389
+
390
+ // ============ BULK STATUS ACTION ============
391
+
392
+ function addBulkStatusAction(bulkConfig: BulkStatusActionOptions = {}): BulkStatusActionReturn {
393
+ const {
394
+ statusField = 'status',
395
+ idsField = 'ids',
396
+ bulkEndpoint = null,
397
+ options = [],
398
+ label = 'Change Status',
399
+ icon = 'pi pi-sync',
400
+ dialogTitle = 'Change Status',
401
+ } = bulkConfig
402
+
403
+ const showDialog = ref(false)
404
+ const selectedStatus = ref<unknown>(null)
405
+ const updating = ref(false)
406
+
407
+ addHeaderAction('bulk-status', {
408
+ label: (state) => `${label} (${state.selectionCount})`,
409
+ icon,
410
+ severity: 'info',
411
+ onClick: () => {
412
+ showDialog.value = true
413
+ },
414
+ visible: (state) => state.hasSelection,
415
+ loading: () => updating.value,
416
+ })
417
+
418
+ async function execute(): Promise<void> {
419
+ if (!selectedStatus.value || selected.value.length === 0) return
420
+
421
+ updating.value = true
422
+ try {
423
+ const ids = selected.value.map((item) => (item as Record<string, unknown>)[resolvedDataKey])
424
+ const bulkPath = bulkEndpoint || 'bulk/status'
425
+
426
+ const payload = {
427
+ [idsField]: ids,
428
+ [statusField]: selectedStatus.value,
429
+ }
430
+
431
+ const response = (await manager.request('PATCH', bulkPath, { data: payload })) as {
432
+ updated?: number
433
+ failed?: number
434
+ }
435
+
436
+ if (response.updated && response.updated > 0) {
437
+ toast.add({
438
+ severity: 'success',
439
+ summary: 'Updated',
440
+ detail: `${response.updated} ${response.updated > 1 ? entityNamePlural : entityName} updated`,
441
+ life: 3000,
442
+ })
443
+ }
444
+ if (response.failed && response.failed > 0) {
445
+ toast.add({
446
+ severity: 'warn',
447
+ summary: 'Partial Success',
448
+ detail: `${response.failed} ${response.failed > 1 ? entityNamePlural : entityName} failed`,
449
+ life: 5000,
450
+ })
451
+ }
452
+
453
+ selected.value = []
454
+ selectedStatus.value = null
455
+ showDialog.value = false
456
+ loadItems({}, { force: true })
457
+ } catch (error) {
458
+ const axiosError = error as AxiosError
459
+ toast.add({
460
+ severity: 'error',
461
+ summary: 'Error',
462
+ detail: axiosError.response?.data?.detail || 'Bulk update failed',
463
+ life: 5000,
464
+ })
465
+ } finally {
466
+ updating.value = false
467
+ }
468
+ }
469
+
470
+ function cancel(): void {
471
+ showDialog.value = false
472
+ selectedStatus.value = null
473
+ }
474
+
475
+ const statusOptions = ref(options)
476
+
477
+ function setOptions(newOptions: Array<{ label: string; value: unknown }>): void {
478
+ statusOptions.value = newOptions
479
+ }
480
+
481
+ return {
482
+ showDialog,
483
+ selectedStatus,
484
+ updating,
485
+ statusOptions,
486
+ dialogTitle,
487
+ execute,
488
+ cancel,
489
+ setOptions,
490
+ }
491
+ }
492
+
493
+ // ============ CARDS ============
494
+ const cardsMap = ref<Map<string, CardConfig>>(new Map())
495
+
496
+ function addCard(name: string, cardConfig: Omit<CardConfig, 'name'>): void {
497
+ cardsMap.value.set(name, { name, ...cardConfig })
498
+ }
499
+
500
+ function updateCard(name: string, cardConfig: Partial<CardConfig>): void {
501
+ const existing = cardsMap.value.get(name)
502
+ if (existing) {
503
+ cardsMap.value.set(name, { ...existing, ...cardConfig })
504
+ }
505
+ }
506
+
507
+ function removeCard(name: string): void {
508
+ cardsMap.value.delete(name)
509
+ }
510
+
511
+ const cards = computed(() => Array.from(cardsMap.value.values()))
512
+
513
+ // ============ FILTERS ============
514
+ const filtersMap = ref<Map<string, FilterConfig>>(new Map())
515
+ const filterValues = ref<Record<string, unknown>>(savedFilters || {})
516
+
517
+ function addFilter(name: string, filterConfig: Omit<FilterConfig, 'name'>): void {
518
+ filtersMap.value.set(name, {
519
+ name,
520
+ type: 'select',
521
+ placeholder: name,
522
+ ...filterConfig,
523
+ })
524
+ if (filterValues.value[name] === undefined) {
525
+ filterValues.value[name] = filterConfig.default ?? null
526
+ }
527
+ }
528
+
529
+ function removeFilter(name: string): void {
530
+ filtersMap.value.delete(name)
531
+ delete filterValues.value[name]
532
+ }
533
+
534
+ function setFilterValue(name: string, value: unknown): void {
535
+ filterValues.value = { ...filterValues.value, [name]: value }
536
+ }
537
+
538
+ function updateFilters(newValues: Record<string, unknown>): void {
539
+ filterValues.value = { ...filterValues.value, ...newValues }
540
+ onFiltersChanged()
541
+ }
542
+
543
+ function onFiltersChanged(): void {
544
+ page.value = 1
545
+ loadItems()
546
+ if (persistFilters) {
547
+ const toPersist: Record<string, unknown> = {}
548
+ for (const [name, value] of Object.entries(filterValues.value)) {
549
+ const filterDef = filtersMap.value.get(name)
550
+ if (
551
+ filterDef?.persist !== false &&
552
+ value !== null &&
553
+ value !== undefined &&
554
+ value !== ''
555
+ ) {
556
+ toPersist[name] = value
557
+ }
558
+ }
559
+ if (searchQuery.value) {
560
+ toPersist._search = searchQuery.value
561
+ }
562
+ setSessionFilters(filterSessionKey, toPersist)
563
+ }
564
+ if (syncUrlParams) {
565
+ const query = { ...route.query } as Record<string, string>
566
+ for (const [name, value] of Object.entries(filterValues.value)) {
567
+ if (value !== null && value !== undefined && value !== '') {
568
+ query[name] = String(value)
569
+ } else {
570
+ delete query[name]
571
+ }
572
+ }
573
+ if (searchQuery.value) {
574
+ query.search = searchQuery.value
575
+ } else {
576
+ delete query.search
577
+ }
578
+ router.replace({ query })
579
+ }
580
+ }
581
+
582
+ function clearFilters(): void {
583
+ const cleared: Record<string, unknown> = {}
584
+ for (const [key, filterDef] of filtersMap.value.entries()) {
585
+ cleared[key] = filterDef.default ?? null
586
+ }
587
+ filterValues.value = cleared
588
+ searchQuery.value = ''
589
+ if (persistFilters) {
590
+ clearSessionFilters(filterSessionKey)
591
+ }
592
+ if (syncUrlParams) {
593
+ const query = { ...route.query } as Record<string, string>
594
+ for (const key of filtersMap.value.keys()) {
595
+ delete query[key]
596
+ }
597
+ delete query.search
598
+ router.replace({ query })
599
+ }
600
+ page.value = 1
601
+ loadItems()
602
+ }
603
+
604
+ const filters = computed(() => Array.from(filtersMap.value.values()))
605
+
606
+ /**
607
+ * Check if a filter is at its default value
608
+ * Used for styling: default = blue (info), modified = orange (warning)
609
+ */
610
+ function isFilterAtDefault(name: string): boolean {
611
+ const filterDef = filtersMap.value.get(name)
612
+ if (!filterDef) return true
613
+ const currentValue = filterValues.value[name]
614
+ const defaultValue = filterDef.default ?? null
615
+ return currentValue === defaultValue
616
+ }
617
+
618
+ /**
619
+ * Check if any filter is NOT at its default value (or search is active)
620
+ * Useful to show a "clear filters" button only when needed
621
+ */
622
+ const hasActiveFilters = computed(() => {
623
+ if (searchQuery.value) return true
624
+ for (const [name, filterDef] of filtersMap.value.entries()) {
625
+ const currentValue = filterValues.value[name]
626
+ const defaultValue = filterDef.default ?? null
627
+ if (currentValue !== defaultValue) return true
628
+ }
629
+ return false
630
+ })
631
+
632
+ // ============ AUTO-LOAD FROM REGISTRY ============
633
+
634
+ function initFromRegistry(): void {
635
+ if (!autoLoadFilters) return
636
+
637
+ const entityConfig = entityFilters[entityName]
638
+ if (!entityConfig) return
639
+
640
+ if (entityConfig.search) {
641
+ setSearch(entityConfig.search)
642
+ }
643
+
644
+ if (entityConfig.filters) {
645
+ for (const filterDef of entityConfig.filters) {
646
+ addFilter(filterDef.name, filterDef)
647
+ }
648
+ }
649
+ }
650
+
651
+ async function loadFilterOptions(): Promise<void> {
652
+ // Process filters configured directly via addFilter() (smart filter modes)
653
+ for (const [filterName, filterDef] of filtersMap.value) {
654
+ if (filterDef.options && filterDef.options.length > 1) continue
655
+ if (filterDef.optionsFromCache) continue
656
+
657
+ try {
658
+ let rawOptions: Array<{ label: string; value: unknown }> | null = null
659
+
660
+ // Mode 1: optionsEntity - fetch from related EntityManager via FilterQuery
661
+ if (filterDef.optionsEntity) {
662
+ const filterQuery = new FilterQuery({
663
+ source: 'entity',
664
+ entity: filterDef.optionsEntity,
665
+ label: filterDef.optionLabel || 'name',
666
+ value: filterDef.optionValue || 'id',
667
+ })
668
+
669
+ rawOptions = (await filterQuery.getOptions(orchestrator)) as Array<{
670
+ label: string
671
+ value: unknown
672
+ }>
673
+ filterDef._filterQuery = filterQuery
674
+ }
675
+ // Mode 2: optionsEndpoint - fetch from API endpoint
676
+ else if (filterDef.optionsEndpoint) {
677
+ const endpoint =
678
+ filterDef.optionsEndpoint === true
679
+ ? `distinct/${filterName}`
680
+ : filterDef.optionsEndpoint
681
+ const response = await manager.request('GET', endpoint as string)
682
+ const data = Array.isArray(response)
683
+ ? response
684
+ : ((response as Record<string, unknown>)?.items as unknown[]) || []
685
+ rawOptions = data.map((opt) => {
686
+ if (typeof opt === 'object' && opt !== null) {
687
+ const o = opt as Record<string, unknown>
688
+ return {
689
+ label: (o.label || o.name || String(o.value ?? o.id)) as string,
690
+ value: o.value ?? o.id,
691
+ }
692
+ }
693
+ return { label: snakeToTitle(String(opt)), value: opt }
694
+ })
695
+ }
696
+
697
+ if (rawOptions !== null) {
698
+ const cacheOptions = filterDef.cacheOptions ?? 'auto'
699
+ let shouldCache = cacheOptions === true
700
+
701
+ if (cacheOptions === 'auto') {
702
+ shouldCache = rawOptions.length <= SMART_FILTER_THRESHOLD
703
+ }
704
+
705
+ const componentType =
706
+ filterDef.component || (shouldCache ? 'dropdown' : 'autocomplete')
707
+ const allLabel =
708
+ filterDef.allLabel || filterDef.placeholder || `All ${snakeToTitle(filterName)}`
709
+ let finalOptions = [{ label: allLabel, value: null as unknown }, ...rawOptions]
710
+
711
+ if (typeof filterDef.processor === 'function') {
712
+ finalOptions = filterDef.processor(finalOptions)
713
+ }
714
+
715
+ const updatedFilter: FilterConfig = {
716
+ ...filterDef,
717
+ options: finalOptions,
718
+ type: componentType,
719
+ _cacheOptions: shouldCache,
720
+ _optionsLoaded: shouldCache,
721
+ }
722
+ delete updatedFilter.optionLabel
723
+ delete updatedFilter.optionValue
724
+ filtersMap.value.set(filterName, updatedFilter)
725
+ }
726
+ } catch (error) {
727
+ console.warn(`[qdadm] Failed to load options for filter "${filterName}":`, error)
728
+ }
729
+ }
730
+
731
+ // Invoke filter:alter hooks after all options are loaded
732
+ await invokeFilterAlterHook()
733
+
734
+ // Trigger Vue reactivity
735
+ filtersMap.value = new Map(filtersMap.value)
736
+ }
737
+
738
+ async function updateCacheBasedFilters(): Promise<void> {
739
+ if (items.value.length === 0) return
740
+
741
+ let hasChanges = false
742
+
743
+ for (const [filterName, filterDef] of filtersMap.value) {
744
+ if (!filterDef.optionsFromCache) continue
745
+ if (filterDef._optionsLoaded) continue
746
+ // Skip if filter has an explicit query property (advanced usage)
747
+ if (filterDef.query) continue
748
+
749
+ const currentValue = filterValues.value[filterName]
750
+ if (currentValue !== null && currentValue !== undefined && currentValue !== '') {
751
+ continue
752
+ }
753
+
754
+ const fieldName =
755
+ typeof filterDef.optionsFromCache === 'string' ? filterDef.optionsFromCache : filterName
756
+
757
+ const filterQuery = new FilterQuery({
758
+ source: 'field',
759
+ field: fieldName,
760
+ })
761
+
762
+ // Provide a minimal mock manager with cached data for field-based filtering
763
+ filterQuery.setParentManager({
764
+ _cache: items.value,
765
+ list: async () => ({ items: items.value }),
766
+ })
767
+
768
+ const rawOptions = (await filterQuery.getOptions()) as Array<{ label: string; value: unknown }>
769
+
770
+ const cacheOptions = filterDef.cacheOptions ?? 'auto'
771
+ let shouldCache = true
772
+
773
+ if (cacheOptions === 'auto') {
774
+ shouldCache = rawOptions.length <= SMART_FILTER_THRESHOLD
775
+ } else if (cacheOptions === false) {
776
+ shouldCache = false
777
+ }
778
+
779
+ const componentType =
780
+ filterDef.component ||
781
+ (rawOptions.length <= SMART_FILTER_THRESHOLD ? 'dropdown' : 'autocomplete')
782
+
783
+ const allLabel =
784
+ filterDef.allLabel || filterDef.placeholder || `All ${snakeToTitle(filterName)}`
785
+ let finalOptions = [
786
+ { label: allLabel, value: null as unknown },
787
+ ...rawOptions.map((opt) => ({
788
+ label: snakeToTitle(String(opt.label)),
789
+ value: opt.value,
790
+ })),
791
+ ]
792
+
793
+ if (typeof filterDef.processor === 'function') {
794
+ finalOptions = filterDef.processor(finalOptions)
795
+ }
796
+
797
+ const updatedFilter: FilterConfig = {
798
+ ...filterDef,
799
+ options: finalOptions,
800
+ type: componentType,
801
+ _cacheOptions: shouldCache,
802
+ _optionsLoaded: true,
803
+ _filterQuery: filterQuery,
804
+ }
805
+
806
+ filtersMap.value.set(filterName, updatedFilter)
807
+ hasChanges = true
808
+ }
809
+
810
+ if (hasChanges) {
811
+ filtersMap.value = new Map(filtersMap.value)
812
+ }
813
+ }
814
+
815
+ function restoreFilters(): void {
816
+ for (const key of filtersMap.value.keys()) {
817
+ if (route.query[key] !== undefined) {
818
+ let value: unknown = route.query[key]
819
+ if (value === 'true') value = true
820
+ else if (value === 'false') value = false
821
+ else if (value === 'null') value = null
822
+ else if (!isNaN(Number(value)) && value !== '') value = Number(value)
823
+ filterValues.value[key] = value
824
+ }
825
+ }
826
+ if (route.query.search) {
827
+ searchQuery.value = route.query.search as string
828
+ }
829
+ }
830
+
831
+ // ============ ACTIONS ============
832
+ const actionsMap = ref<Map<string, ActionConfig>>(new Map())
833
+
834
+ function addAction(name: string, actionConfig: Omit<ActionConfig, 'name'>): void {
835
+ actionsMap.value.set(name, {
836
+ name,
837
+ severity: 'secondary',
838
+ ...actionConfig,
839
+ } as ActionConfig)
840
+ }
841
+
842
+ function removeAction(name: string): void {
843
+ actionsMap.value.delete(name)
844
+ }
845
+
846
+ function resolveValue<T>(value: T | ((row: unknown) => T), row: unknown): T {
847
+ return typeof value === 'function' ? (value as (row: unknown) => T)(row) : value
848
+ }
849
+
850
+ function getActions(row: unknown): ResolvedAction[] {
851
+ const actions: ResolvedAction[] = []
852
+ for (const [, action] of actionsMap.value) {
853
+ if (action.visible && !action.visible(row)) continue
854
+ actions.push({
855
+ name: action.name,
856
+ icon: action.icon ? resolveValue(action.icon, row) : undefined,
857
+ tooltip: action.tooltip ? resolveValue(action.tooltip, row) : undefined,
858
+ severity: resolveValue(action.severity || 'secondary', row),
859
+ isDisabled: action.disabled ? action.disabled(row) : false,
860
+ handler: () => action.onClick(row),
861
+ })
862
+ }
863
+ return actions
864
+ }
865
+
866
+ // ============ SEARCH ============
867
+ function setSearch(searchCfg: Partial<SearchConfig>): void {
868
+ searchConfig.value = { ...searchConfig.value, ...searchCfg }
869
+ }
870
+
871
+ // ============ CACHE MODE ============
872
+ const fromCache = ref(false)
873
+
874
+ const filteredItems = computed(() => {
875
+ let result = [...items.value]
876
+
877
+ // local_filter: post-filter hack for edge cases
878
+ for (const [name, value] of Object.entries(filterValues.value)) {
879
+ if (value === null || value === undefined || value === '') continue
880
+ const filterDef = filtersMap.value.get(name)
881
+ if (typeof filterDef?.local_filter !== 'function') continue
882
+ result = result.filter((item) => filterDef.local_filter!(item, value))
883
+ }
884
+
885
+ // local_search: post-filter hack for external lookups
886
+ if (searchQuery.value && typeof searchConfig.value.local_search === 'function') {
887
+ const query = searchQuery.value.toLowerCase()
888
+ result = result.filter((item) => searchConfig.value.local_search!(item, query))
889
+ }
890
+
891
+ return result
892
+ })
893
+
894
+ const displayItems = computed(() => filteredItems.value)
895
+
896
+ // ============ LOADING ============
897
+ let filterOptionsLoaded = false
898
+
899
+ async function loadItems(
900
+ extraParams: Record<string, unknown> = {},
901
+ { force = false } = {}
902
+ ): Promise<void> {
903
+ if (!manager) return
904
+
905
+ if (force && manager.invalidateCache) {
906
+ manager.invalidateCache()
907
+ }
908
+
909
+ loading.value = true
910
+ try {
911
+ let params: Record<string, unknown> = { ...extraParams }
912
+
913
+ params.page = page.value
914
+ params.page_size = pageSize.value
915
+ if (sortField.value) {
916
+ params.sort_by = sortField.value
917
+ params.sort_order = sortOrder.value === 1 ? 'asc' : 'desc'
918
+ }
919
+
920
+ if (searchQuery.value && typeof searchConfig.value.local_search !== 'function') {
921
+ params.search = searchQuery.value
922
+ if (searchConfig.value.fields && searchConfig.value.fields.length > 0) {
923
+ params.searchFields = searchConfig.value.fields
924
+ }
925
+ }
926
+
927
+ const filtersObj: Record<string, unknown> = {}
928
+ for (const [name, value] of Object.entries(filterValues.value)) {
929
+ if (value === null || value === undefined || value === '') continue
930
+ const filterDef = filtersMap.value.get(name)
931
+ if (typeof filterDef?.local_filter === 'function') continue
932
+
933
+ if (typeof filterDef?.toQuery === 'function') {
934
+ const query = filterDef.toQuery(value)
935
+ if (query && typeof query === 'object') {
936
+ Object.assign(filtersObj, query)
937
+ }
938
+ } else {
939
+ filtersObj[name] = value
940
+ }
941
+ }
942
+
943
+ if (parentConfigComputed.value?.foreignKey && parentConfigComputed.value?.param) {
944
+ const parentIdFromRoute = route.params[parentConfigComputed.value.param]
945
+ if (parentIdFromRoute) {
946
+ filtersObj[parentConfigComputed.value.foreignKey] = parentIdFromRoute
947
+ }
948
+ }
949
+
950
+ if (Object.keys(filtersObj).length > 0) {
951
+ params.filters = filtersObj
952
+ }
953
+
954
+ if (onBeforeLoad) {
955
+ params = onBeforeLoad(params) || params
956
+ }
957
+
958
+ const response = manager.query
959
+ ? await manager.query(params, { routingContext: entityContext.value })
960
+ : await manager.list(params, entityContext.value)
961
+
962
+ fromCache.value = response.fromCache || false
963
+
964
+ let processedData: { items: T[]; total: number }
965
+ if (transformResponse) {
966
+ processedData = transformResponse(response as ListResponse<T>)
967
+ } else {
968
+ processedData = {
969
+ items: (response.items || []) as T[],
970
+ total: response.total || response.items?.length || 0,
971
+ }
972
+ }
973
+
974
+ items.value = processedData.items
975
+ totalRecords.value = processedData.total
976
+
977
+ if (onAfterLoad) {
978
+ onAfterLoad(response as ListResponse<T>, processedData)
979
+ }
980
+ } catch (error) {
981
+ toast.add({
982
+ severity: 'error',
983
+ summary: 'Error',
984
+ detail: `Failed to load ${entityNamePlural}`,
985
+ life: 5000,
986
+ })
987
+ console.error('Load error:', error)
988
+ } finally {
989
+ loading.value = false
990
+ }
991
+ }
992
+
993
+ // ============ PAGINATION & SORTING ============
994
+ function onPage(event: { page: number; rows: number }): void {
995
+ page.value = event.page + 1
996
+ pageSize.value = event.rows
997
+ persistPageSize(event.rows)
998
+ loadItems()
999
+ }
1000
+
1001
+ function onSort(event: { sortField: string; sortOrder: number }): void {
1002
+ sortField.value = event.sortField
1003
+ sortOrder.value = event.sortOrder as 1 | -1
1004
+ loadItems()
1005
+ }
1006
+
1007
+ // ============ NAVIGATION ============
1008
+
1009
+ function findCreateRoute(): { name: string; params?: Record<string, unknown> } {
1010
+ const parentConfigVal = route.meta?.parent as ParentConfig | undefined
1011
+
1012
+ if (parentConfigVal) {
1013
+ const currentMatched = route.matched[route.matched.length - 1]
1014
+ if (currentMatched) {
1015
+ const createPath = `${currentMatched.path}/create`
1016
+ const createRoute = router.getRoutes().find((r) => r.path === createPath)
1017
+ if (createRoute?.name) {
1018
+ return { name: createRoute.name as string, params: route.params as Record<string, unknown> }
1019
+ }
1020
+ }
1021
+
1022
+ const currentName = route.name as string
1023
+ if (currentName && currentName.endsWith('s')) {
1024
+ const singularName = currentName.slice(0, -1)
1025
+ const createRouteName = `${singularName}-create`
1026
+ if (router.hasRoute(createRouteName)) {
1027
+ return { name: createRouteName, params: route.params as Record<string, unknown> }
1028
+ }
1029
+ }
1030
+ }
1031
+
1032
+ return { name: `${routePrefix}-create` }
1033
+ }
1034
+
1035
+ function goToCreate(): void {
1036
+ router.push(findCreateRoute() as { name: string })
1037
+ }
1038
+
1039
+ function goToEdit(item: unknown): void {
1040
+ router.push({
1041
+ name: `${routePrefix}-edit`,
1042
+ params: { [manager.idField]: (item as Record<string, unknown>)[resolvedDataKey] as string },
1043
+ })
1044
+ }
1045
+
1046
+ function goToShow(item: unknown): void {
1047
+ router.push({
1048
+ name: `${routePrefix}-show`,
1049
+ params: { [manager.idField]: (item as Record<string, unknown>)[resolvedDataKey] as string },
1050
+ })
1051
+ }
1052
+
1053
+ // ============ MOBILE ROW TAP ============
1054
+
1055
+ function getPrimaryRowAction(): 'edit' | 'show' | null {
1056
+ const editRouteName = `${routePrefix}-edit`
1057
+ const showRouteName = `${routePrefix}-show`
1058
+
1059
+ if (router.hasRoute(editRouteName)) {
1060
+ return 'edit'
1061
+ }
1062
+ if (router.hasRoute(showRouteName)) {
1063
+ return 'show'
1064
+ }
1065
+ return null
1066
+ }
1067
+
1068
+ function onRowClick(item: unknown, originalEvent: Event): void {
1069
+ if (!isMobile.value) return
1070
+ if (hasBulkActions.value && selected.value.length > 0) return
1071
+
1072
+ const target = originalEvent?.target as HTMLElement
1073
+ if (target?.closest('button, a, .p-button, .table-actions, input, .p-checkbox')) {
1074
+ return
1075
+ }
1076
+
1077
+ const action = getPrimaryRowAction()
1078
+ if (action === 'edit') {
1079
+ goToEdit(item)
1080
+ } else if (action === 'show') {
1081
+ goToShow(item)
1082
+ }
1083
+ }
1084
+
1085
+ const hasRowTapAction = computed(() => isMobile.value && getPrimaryRowAction() !== null)
1086
+
1087
+ // ============ DELETE ============
1088
+ async function deleteItem(item: unknown, labelField = 'name'): Promise<void> {
1089
+ try {
1090
+ const itemRecord = item as Record<string, unknown>
1091
+ await manager.delete(itemRecord[resolvedDataKey] as string | number)
1092
+ toast.add({
1093
+ severity: 'success',
1094
+ summary: 'Deleted',
1095
+ detail: `${entityName} "${itemRecord[labelField] || itemRecord[resolvedDataKey]}" deleted`,
1096
+ life: 3000,
1097
+ })
1098
+ loadItems({}, { force: true })
1099
+ } catch (error) {
1100
+ const axiosError = error as AxiosError
1101
+ toast.add({
1102
+ severity: 'error',
1103
+ summary: 'Error',
1104
+ detail: axiosError.response?.data?.detail || `Failed to delete ${entityName}`,
1105
+ life: 5000,
1106
+ })
1107
+ }
1108
+ }
1109
+
1110
+ function confirmDelete(item: unknown, labelField = 'name'): void {
1111
+ const itemRecord = item as Record<string, unknown>
1112
+ confirm.require({
1113
+ message: `Delete ${entityName} "${itemRecord[labelField] || itemRecord[resolvedDataKey]}"?`,
1114
+ header: 'Confirm Delete',
1115
+ icon: 'pi pi-exclamation-triangle',
1116
+ acceptClass: 'p-button-danger',
1117
+ accept: () => deleteItem(item, labelField),
1118
+ })
1119
+ }
1120
+
1121
+ // ============ BULK OPERATIONS ============
1122
+ const hasSelection = computed(() => selected.value.length > 0)
1123
+ const selectionCount = computed(() => selected.value.length)
1124
+
1125
+ async function bulkDelete(): Promise<void> {
1126
+ deleting.value = true
1127
+ let successCount = 0
1128
+ let errorCount = 0
1129
+
1130
+ for (const item of [...selected.value]) {
1131
+ try {
1132
+ const itemRecord = item as Record<string, unknown>
1133
+ await manager.delete(itemRecord[resolvedDataKey] as string | number)
1134
+ successCount++
1135
+ } catch {
1136
+ errorCount++
1137
+ }
1138
+ }
1139
+
1140
+ deleting.value = false
1141
+ selected.value = []
1142
+
1143
+ if (successCount > 0) {
1144
+ toast.add({
1145
+ severity: 'success',
1146
+ summary: 'Deleted',
1147
+ detail: `${successCount} ${successCount > 1 ? entityNamePlural : entityName} deleted`,
1148
+ life: 3000,
1149
+ })
1150
+ }
1151
+ if (errorCount > 0) {
1152
+ toast.add({
1153
+ severity: 'error',
1154
+ summary: 'Error',
1155
+ detail: `Failed to delete ${errorCount} ${errorCount > 1 ? entityNamePlural : entityName}`,
1156
+ life: 5000,
1157
+ })
1158
+ }
1159
+
1160
+ loadItems({}, { force: true })
1161
+ }
1162
+
1163
+ function confirmBulkDelete(): void {
1164
+ const count = selected.value.length
1165
+ confirm.require({
1166
+ message: `Delete ${count} ${count > 1 ? entityNamePlural : entityName}?`,
1167
+ header: 'Confirm Bulk Delete',
1168
+ icon: 'pi pi-exclamation-triangle',
1169
+ acceptClass: 'p-button-danger',
1170
+ accept: bulkDelete,
1171
+ })
1172
+ }
1173
+
1174
+ // ============ WATCHERS ============
1175
+ let searchTimeout: ReturnType<typeof setTimeout> | null = null
1176
+ watch(searchQuery, () => {
1177
+ if (searchTimeout) clearTimeout(searchTimeout)
1178
+ searchTimeout = setTimeout(() => {
1179
+ onFiltersChanged()
1180
+ }, searchConfig.value.debounce || 300)
1181
+ })
1182
+
1183
+ watch(items, async () => {
1184
+ try {
1185
+ await updateCacheBasedFilters()
1186
+ } catch (error) {
1187
+ console.warn('[qdadm] Failed to update cache-based filters:', error)
1188
+ }
1189
+ })
1190
+
1191
+ // ============ HOOKS ============
1192
+
1193
+ async function invokeListAlterHook(): Promise<void> {
1194
+ if (!hooks) return
1195
+
1196
+ const configSnapshot = {
1197
+ entity,
1198
+ columns: Array.from(columnsMap.value.values()),
1199
+ filters: Array.from(filtersMap.value.values()),
1200
+ actions: Array.from(actionsMap.value.values()),
1201
+ headerActions: Array.from(headerActionsMap.value.values()),
1202
+ }
1203
+
1204
+ // Include entity context in the snapshot for hooks
1205
+ const configWithContext = { ...configSnapshot, entity, manager }
1206
+
1207
+ let alteredConfig = await hooks.alter('list:alter', configWithContext)
1208
+
1209
+ const entityHookName = `${entity}:list:alter`
1210
+ if (hooks.hasHook(entityHookName)) {
1211
+ alteredConfig = await hooks.alter(entityHookName, alteredConfig)
1212
+ }
1213
+
1214
+ applyAlteredConfig(alteredConfig)
1215
+ }
1216
+
1217
+ async function invokeFilterAlterHook(): Promise<void> {
1218
+ if (!hooks) return
1219
+
1220
+ const filterSnapshot = {
1221
+ entity,
1222
+ filters: Array.from(filtersMap.value.values()),
1223
+ }
1224
+
1225
+ let alteredFilters = (await hooks.alter('filter:alter', filterSnapshot)) as typeof filterSnapshot
1226
+
1227
+ const entityHookName = `${entity}:filter:alter`
1228
+ if (hooks.hasHook(entityHookName)) {
1229
+ alteredFilters = (await hooks.alter(entityHookName, alteredFilters)) as typeof filterSnapshot
1230
+ }
1231
+
1232
+ if (alteredFilters.filters) {
1233
+ filtersMap.value.clear()
1234
+ for (const filter of alteredFilters.filters) {
1235
+ filtersMap.value.set(filter.name, filter)
1236
+ if (filterValues.value[filter.name] === undefined) {
1237
+ filterValues.value[filter.name] = filter.default ?? null
1238
+ }
1239
+ }
1240
+ }
1241
+ }
1242
+
1243
+ function applyAlteredConfig(alteredConfig: {
1244
+ columns?: ColumnConfig[]
1245
+ filters?: FilterConfig[]
1246
+ actions?: ActionConfig[]
1247
+ headerActions?: HeaderActionConfig[]
1248
+ }): void {
1249
+ if (alteredConfig.columns) {
1250
+ columnsMap.value.clear()
1251
+ for (const col of alteredConfig.columns) {
1252
+ columnsMap.value.set(col.field, col)
1253
+ }
1254
+ }
1255
+
1256
+ if (alteredConfig.filters) {
1257
+ filtersMap.value.clear()
1258
+ for (const filter of alteredConfig.filters) {
1259
+ filtersMap.value.set(filter.name, filter)
1260
+ if (filterValues.value[filter.name] === undefined) {
1261
+ filterValues.value[filter.name] = filter.default ?? null
1262
+ }
1263
+ }
1264
+ }
1265
+
1266
+ if (alteredConfig.actions) {
1267
+ actionsMap.value.clear()
1268
+ for (const action of alteredConfig.actions) {
1269
+ actionsMap.value.set(action.name, action)
1270
+ }
1271
+ }
1272
+
1273
+ if (alteredConfig.headerActions) {
1274
+ headerActionsMap.value.clear()
1275
+ for (const action of alteredConfig.headerActions) {
1276
+ headerActionsMap.value.set(action.name, action)
1277
+ }
1278
+ }
1279
+ }
1280
+
1281
+ // ============ LIFECYCLE ============
1282
+ initFromRegistry()
1283
+
1284
+ function handleResize(): void {
1285
+ isMobile.value = window.innerWidth < 768
1286
+ }
1287
+
1288
+ onMounted(async () => {
1289
+ window.addEventListener('resize', handleResize)
1290
+
1291
+ restoreFilters()
1292
+
1293
+ if (!filterOptionsLoaded) {
1294
+ filterOptionsLoaded = true
1295
+ await loadFilterOptions()
1296
+ }
1297
+
1298
+ await invokeListAlterHook()
1299
+
1300
+ if (loadOnMount && manager) {
1301
+ loadItems()
1302
+ }
1303
+ })
1304
+
1305
+ onUnmounted(() => {
1306
+ window.removeEventListener('resize', handleResize)
1307
+ })
1308
+
1309
+ // ============ UTILITIES ============
1310
+ function formatDate(
1311
+ dateStr: string | null | undefined,
1312
+ options: Intl.DateTimeFormatOptions = {}
1313
+ ): string {
1314
+ if (!dateStr) return '-'
1315
+ const defaultOptions: Intl.DateTimeFormatOptions = {
1316
+ day: '2-digit',
1317
+ month: '2-digit',
1318
+ year: 'numeric',
1319
+ ...options,
1320
+ }
1321
+ return new Date(dateStr).toLocaleDateString('fr-FR', defaultOptions)
1322
+ }
1323
+
1324
+ // ============ STANDARD ACTIONS ============
1325
+
1326
+ function addViewAction(options: Partial<ActionConfig> = {}): void {
1327
+ addAction('view', {
1328
+ icon: 'pi pi-eye',
1329
+ tooltip: 'View',
1330
+ onClick: options.onClick || goToShow,
1331
+ ...options,
1332
+ })
1333
+ }
1334
+
1335
+ function addEditAction(options: Partial<ActionConfig> = {}): void {
1336
+ addAction('edit', {
1337
+ icon: 'pi pi-pencil',
1338
+ tooltip: 'Edit',
1339
+ onClick: options.onClick || goToEdit,
1340
+ visible: (row) => manager.canUpdate(row),
1341
+ ...options,
1342
+ })
1343
+ }
1344
+
1345
+ function addDeleteAction(options: Partial<ActionConfig> & { labelField?: string } = {}): void {
1346
+ const labelField = options.labelField || 'name'
1347
+ addAction('delete', {
1348
+ icon: 'pi pi-trash',
1349
+ tooltip: 'Delete',
1350
+ severity: 'danger',
1351
+ onClick: (row) => confirmDelete(row, labelField),
1352
+ visible: (row) => manager.canDelete(row),
1353
+ ...options,
1354
+ })
1355
+ }
1356
+
1357
+ // ============ BULK ACTIONS DETECTION ============
1358
+
1359
+ const hasBulkActions = computed(() => {
1360
+ for (const [, action] of headerActionsMap.value) {
1361
+ if (action.visible && typeof action.visible === 'function') {
1362
+ const wouldShow = action.visible({ hasSelection: true, selectionCount: 1, deleting: false })
1363
+ const wouldHide = action.visible({
1364
+ hasSelection: false,
1365
+ selectionCount: 0,
1366
+ deleting: false,
1367
+ })
1368
+ if (wouldShow && !wouldHide) {
1369
+ return true
1370
+ }
1371
+ }
1372
+ }
1373
+ return false
1374
+ })
1375
+
1376
+ // ============ LIST PAGE PROPS ============
1377
+
1378
+ const listProps = computed<ListPageProps>(() => ({
1379
+ title: manager.labelPlural,
1380
+ headerActions: headerActions.value,
1381
+ cards: cards.value,
1382
+ columns: columns.value,
1383
+ items: displayItems.value,
1384
+ loading: loading.value,
1385
+ dataKey: resolvedDataKey,
1386
+ selected: selected.value,
1387
+ selectable: hasBulkActions.value,
1388
+ lazy: true,
1389
+ totalRecords: totalRecords.value,
1390
+ rows: pageSize.value,
1391
+ rowsPerPageOptions,
1392
+ sortField: sortField.value,
1393
+ sortOrder: sortOrder.value,
1394
+ searchQuery: searchQuery.value,
1395
+ searchPlaceholder: searchConfig.value.placeholder || 'Search...',
1396
+ filters: filters.value,
1397
+ filterValues: filterValues.value,
1398
+ isFilterAtDefault,
1399
+ getActions,
1400
+ hasRowTapAction: hasRowTapAction.value,
1401
+ }))
1402
+
1403
+ const listEvents: ListPageEvents = {
1404
+ 'update:selected': (value) => {
1405
+ selected.value = value as T[]
1406
+ },
1407
+ 'update:searchQuery': (value) => {
1408
+ searchQuery.value = value
1409
+ },
1410
+ 'update:filterValues': updateFilters,
1411
+ page: onPage,
1412
+ sort: onSort,
1413
+ 'row-click': onRowClick,
1414
+ }
1415
+
1416
+ return {
1417
+ // Manager access
1418
+ manager,
1419
+
1420
+ // Parent chain
1421
+ parentConfig: parentConfigComputed,
1422
+ parentId,
1423
+ parentData,
1424
+ parentLoading,
1425
+ parentChain,
1426
+ parentPage,
1427
+
1428
+ // State
1429
+ items,
1430
+ displayItems,
1431
+ loading,
1432
+ selected,
1433
+ deleting,
1434
+
1435
+ // Pagination
1436
+ page,
1437
+ pageSize,
1438
+ totalRecords,
1439
+ rowsPerPageOptions,
1440
+ sortField,
1441
+ sortOrder,
1442
+ onPage,
1443
+ onSort,
1444
+
1445
+ // Search
1446
+ searchQuery,
1447
+ searchConfig,
1448
+ setSearch,
1449
+
1450
+ // Columns
1451
+ columns,
1452
+ addColumn,
1453
+ removeColumn,
1454
+ updateColumn,
1455
+
1456
+ // Header Actions
1457
+ headerActions,
1458
+ addHeaderAction,
1459
+ removeHeaderAction,
1460
+ getHeaderActions,
1461
+ addCreateAction,
1462
+ addBulkDeleteAction,
1463
+ addBulkStatusAction,
1464
+ hasBulkActions,
1465
+
1466
+ // Cards
1467
+ cards,
1468
+ addCard,
1469
+ updateCard,
1470
+ removeCard,
1471
+
1472
+ // Filters
1473
+ filters,
1474
+ filterValues,
1475
+ filteredItems,
1476
+ fromCache,
1477
+ addFilter,
1478
+ removeFilter,
1479
+ setFilterValue,
1480
+ updateFilters,
1481
+ clearFilters,
1482
+ isFilterAtDefault,
1483
+ hasActiveFilters,
1484
+ loadFilterOptions,
1485
+ initFromRegistry,
1486
+ restoreFilters,
1487
+
1488
+ // Actions
1489
+ addAction,
1490
+ removeAction,
1491
+ getActions,
1492
+ getRowActions,
1493
+ addViewAction,
1494
+ addEditAction,
1495
+ addDeleteAction,
1496
+
1497
+ // Permissions
1498
+ canCreate,
1499
+ canDelete,
1500
+ canEditRow,
1501
+ canDeleteRow,
1502
+
1503
+ // Data
1504
+ loadItems,
1505
+
1506
+ // Navigation
1507
+ goToCreate,
1508
+ goToEdit,
1509
+ goToShow,
1510
+
1511
+ // Mobile row tap
1512
+ isMobile,
1513
+ hasRowTapAction,
1514
+ getPrimaryRowAction,
1515
+ onRowClick,
1516
+
1517
+ // Delete
1518
+ deleteItem,
1519
+ confirmDelete,
1520
+ bulkDelete,
1521
+ confirmBulkDelete,
1522
+ hasSelection,
1523
+ selectionCount,
1524
+
1525
+ // Utilities
1526
+ formatDate,
1527
+ toast,
1528
+ confirm,
1529
+ router,
1530
+
1531
+ // ListPage integration
1532
+ props: listProps,
1533
+ events: listEvents,
1534
+ }
1535
+ }