qdadm 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +270 -0
  2. package/LICENSE +21 -0
  3. package/README.md +166 -0
  4. package/package.json +48 -0
  5. package/src/assets/logo.svg +6 -0
  6. package/src/components/BoolCell.vue +28 -0
  7. package/src/components/dialogs/BulkStatusDialog.vue +43 -0
  8. package/src/components/dialogs/MultiStepDialog.vue +321 -0
  9. package/src/components/dialogs/SimpleDialog.vue +108 -0
  10. package/src/components/dialogs/UnsavedChangesDialog.vue +87 -0
  11. package/src/components/display/CardsGrid.vue +155 -0
  12. package/src/components/display/CopyableId.vue +92 -0
  13. package/src/components/display/EmptyState.vue +114 -0
  14. package/src/components/display/IntensityBar.vue +171 -0
  15. package/src/components/display/RichCardsGrid.vue +220 -0
  16. package/src/components/editors/JsonEditorFoldable.vue +467 -0
  17. package/src/components/editors/JsonStructuredField.vue +218 -0
  18. package/src/components/editors/JsonViewer.vue +91 -0
  19. package/src/components/editors/KeyValueEditor.vue +314 -0
  20. package/src/components/editors/LanguageEditor.vue +245 -0
  21. package/src/components/editors/ScopeEditor.vue +341 -0
  22. package/src/components/editors/VanillaJsonEditor.vue +185 -0
  23. package/src/components/forms/FormActions.vue +104 -0
  24. package/src/components/forms/FormField.vue +64 -0
  25. package/src/components/forms/FormTab.vue +217 -0
  26. package/src/components/forms/FormTabs.vue +108 -0
  27. package/src/components/index.js +44 -0
  28. package/src/components/layout/AppLayout.vue +430 -0
  29. package/src/components/layout/Breadcrumb.vue +106 -0
  30. package/src/components/layout/PageHeader.vue +75 -0
  31. package/src/components/layout/PageLayout.vue +93 -0
  32. package/src/components/lists/ActionButtons.vue +41 -0
  33. package/src/components/lists/ActionColumn.vue +37 -0
  34. package/src/components/lists/FilterBar.vue +53 -0
  35. package/src/components/lists/ListPage.vue +319 -0
  36. package/src/composables/index.js +19 -0
  37. package/src/composables/useApp.js +43 -0
  38. package/src/composables/useAuth.js +49 -0
  39. package/src/composables/useBareForm.js +143 -0
  40. package/src/composables/useBreadcrumb.js +221 -0
  41. package/src/composables/useDirtyState.js +103 -0
  42. package/src/composables/useEntityTitle.js +121 -0
  43. package/src/composables/useForm.js +254 -0
  44. package/src/composables/useGuardStore.js +37 -0
  45. package/src/composables/useJsonSyntax.js +101 -0
  46. package/src/composables/useListPageBuilder.js +1176 -0
  47. package/src/composables/useNavigation.js +89 -0
  48. package/src/composables/usePageBuilder.js +334 -0
  49. package/src/composables/useStatus.js +146 -0
  50. package/src/composables/useSubEditor.js +165 -0
  51. package/src/composables/useTabSync.js +110 -0
  52. package/src/composables/useUnsavedChangesGuard.js +122 -0
  53. package/src/entity/EntityManager.js +540 -0
  54. package/src/entity/index.js +11 -0
  55. package/src/entity/storage/ApiStorage.js +146 -0
  56. package/src/entity/storage/LocalStorage.js +220 -0
  57. package/src/entity/storage/MemoryStorage.js +201 -0
  58. package/src/entity/storage/index.js +10 -0
  59. package/src/index.js +29 -0
  60. package/src/kernel/Kernel.js +234 -0
  61. package/src/kernel/index.js +7 -0
  62. package/src/module/index.js +16 -0
  63. package/src/module/moduleRegistry.js +222 -0
  64. package/src/orchestrator/Orchestrator.js +141 -0
  65. package/src/orchestrator/index.js +8 -0
  66. package/src/orchestrator/useOrchestrator.js +61 -0
  67. package/src/plugin.js +142 -0
  68. package/src/styles/_alerts.css +48 -0
  69. package/src/styles/_code.css +33 -0
  70. package/src/styles/_dialogs.css +17 -0
  71. package/src/styles/_markdown.css +82 -0
  72. package/src/styles/_show-pages.css +84 -0
  73. package/src/styles/index.css +16 -0
  74. package/src/styles/main.css +845 -0
  75. package/src/styles/theme/components.css +286 -0
  76. package/src/styles/theme/index.css +10 -0
  77. package/src/styles/theme/tokens.css +125 -0
  78. package/src/styles/theme/utilities.css +172 -0
  79. package/src/utils/debugInjector.js +261 -0
  80. package/src/utils/formatters.js +165 -0
  81. package/src/utils/index.js +35 -0
  82. package/src/utils/transformers.js +105 -0
@@ -0,0 +1,1176 @@
1
+ import { ref, computed, watch, onMounted, inject } from 'vue'
2
+ import { useRouter, useRoute } from 'vue-router'
3
+ import { useToast } from 'primevue/usetoast'
4
+ import { useConfirm } from 'primevue/useconfirm'
5
+ import { useBreadcrumb } from './useBreadcrumb'
6
+
7
+ // Cookie utilities for pagination persistence
8
+ const COOKIE_NAME = 'qdadm_pageSize'
9
+ const COOKIE_EXPIRY_DAYS = 365
10
+
11
+ function getCookie(name) {
12
+ const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'))
13
+ return match ? match[2] : null
14
+ }
15
+
16
+ function setCookie(name, value, days) {
17
+ const expires = new Date(Date.now() + days * 864e5).toUTCString()
18
+ document.cookie = `${name}=${value}; expires=${expires}; path=/; SameSite=Lax`
19
+ }
20
+
21
+ function getSavedPageSize(defaultSize) {
22
+ const saved = getCookie(COOKIE_NAME)
23
+ if (saved) {
24
+ const parsed = parseInt(saved, 10)
25
+ if ([10, 50, 100].includes(parsed)) return parsed
26
+ }
27
+ return defaultSize
28
+ }
29
+
30
+ // Default label fallback: convert snake_case to Title Case
31
+ function snakeToTitle(str) {
32
+ if (!str) return 'Unknown'
33
+ return String(str).split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
34
+ }
35
+
36
+ // Standard pagination options
37
+ export const PAGE_SIZE_OPTIONS = [10, 50, 100]
38
+
39
+ // Session storage utilities for filter persistence
40
+ const FILTER_SESSION_PREFIX = 'qdadm_filters_'
41
+
42
+ function getSessionFilters(key) {
43
+ try {
44
+ const stored = sessionStorage.getItem(FILTER_SESSION_PREFIX + key)
45
+ return stored ? JSON.parse(stored) : null
46
+ } catch {
47
+ return null
48
+ }
49
+ }
50
+
51
+ function setSessionFilters(key, filters) {
52
+ try {
53
+ sessionStorage.setItem(FILTER_SESSION_PREFIX + key, JSON.stringify(filters))
54
+ } catch {
55
+ // Ignore storage errors
56
+ }
57
+ }
58
+
59
+ /**
60
+ * useListPageBuilder - Unified procedural builder for CRUD list pages
61
+ *
62
+ * Provides a declarative/procedural API to build list pages with:
63
+ * - Cards zone (stats, custom content)
64
+ * - Filter bar with search and custom filters
65
+ * - DataTable with columns and actions
66
+ * - Bulk selection and operations
67
+ *
68
+ * ## Filter Modes
69
+ *
70
+ * The threshold decides everything:
71
+ * - `items >= threshold` → **manager mode**: delegate ALL filtering to EntityManager
72
+ * - `items < threshold` → **local mode**: filter client-side
73
+ *
74
+ * Modes:
75
+ * - `filterMode: 'manager'` - Always delegate to EntityManager
76
+ * - `filterMode: 'local'` - Always filter client-side
77
+ * - `filterMode: 'auto'` (default) - Switch to local if items < threshold
78
+ *
79
+ * Threshold priority: config `autoFilterThreshold` > `manager.localFilterThreshold` > 100
80
+ *
81
+ * ## Behavior Matrix
82
+ *
83
+ * | Type | Mode `manager` | Mode `local` |
84
+ * |-----------------------------|-----------------|---------------------------|
85
+ * | Filter standard | Manager handles | item[field] === value |
86
+ * | Filter + local_filter | Manager handles | local_filter(item, value) |
87
+ * | Filter + local_filter:false | Manager handles | (skipped) |
88
+ * | Search standard | Manager handles | field.includes(query) |
89
+ * | Search + local_search | Manager handles | local_search(item, query) |
90
+ * | Search + local_search:false | Manager handles | (skipped) |
91
+ *
92
+ * - `local_filter`/`local_search`: callback = HOW to filter locally
93
+ * - `local_filter: false` / `local_search: false`: manager only, skip in local mode
94
+ *
95
+ * ```js
96
+ * // Virtual filter (use callback in local mode)
97
+ * list.addFilter('status', {
98
+ * options: [{ label: 'Active', value: 'active' }],
99
+ * local_filter: (item, value) => value === 'active' ? !item.returned_at : true
100
+ * })
101
+ *
102
+ * // Search on related/computed fields (in local mode)
103
+ * list.setSearch({
104
+ * placeholder: 'Search by book...',
105
+ * local_search: (item, query) => booksMap[item.book_id]?.title.toLowerCase().includes(query)
106
+ * })
107
+ * ```
108
+ *
109
+ * ## Basic Usage
110
+ *
111
+ * ```js
112
+ * const list = useListPageBuilder({ entity: 'domains' })
113
+ * list.addFilter('status', { options: [...] })
114
+ * list.setSearch({ fields: ['name', 'email'] })
115
+ * list.addCreateAction()
116
+ * list.addEditAction()
117
+ * ```
118
+ */
119
+ export function useListPageBuilder(config = {}) {
120
+ const {
121
+ entity,
122
+ dataKey,
123
+ defaultSort = null,
124
+ defaultSortOrder = -1,
125
+ serverSide = false,
126
+ pageSize: defaultPageSize = 10,
127
+ loadOnMount = true,
128
+ persistFilters = true, // Save filters to sessionStorage
129
+ syncUrlParams = true, // Sync filters to URL query params
130
+
131
+ // Filter mode: 'auto' | 'manager' | 'local'
132
+ // auto: switch to local if items < autoFilterThreshold
133
+ filterMode = 'auto',
134
+ autoFilterThreshold = 100,
135
+
136
+ // Auto-load filters from registry
137
+ autoLoadFilters = true,
138
+
139
+ // Hooks for custom behavior
140
+ onBeforeLoad = null, // (params) => params | modified params
141
+ onAfterLoad = null, // (response, processedData) => void
142
+ transformResponse = null // (response) => { items, total, ...extras }
143
+ } = config
144
+
145
+ const router = useRouter()
146
+ const route = useRoute()
147
+ const toast = useToast()
148
+ const confirm = useConfirm()
149
+ const { breadcrumbItems } = useBreadcrumb()
150
+
151
+ // Get EntityManager via orchestrator
152
+ const orchestrator = inject('qdadmOrchestrator')
153
+ if (!orchestrator) {
154
+ throw new Error('[qdadm] Orchestrator not provided. Make sure to use createQdadm() with entityFactory.')
155
+ }
156
+ const manager = orchestrator.get(entity)
157
+
158
+ // Read config from manager with option overrides
159
+ const entityName = config.entityName ?? manager.label
160
+ const entityNamePlural = config.entityNamePlural ?? manager.labelPlural
161
+ const routePrefix = config.routePrefix ?? manager.routePrefix
162
+ const resolvedDataKey = dataKey ?? manager.idField ?? 'id'
163
+
164
+ // Session key for filter persistence (based on entity name)
165
+ const filterSessionKey = entity || entityName
166
+
167
+ // Entity filters registry (optional, provided by consuming app)
168
+ const entityFilters = inject('qdadmEntityFilters', {})
169
+
170
+ // ============ STATE ============
171
+ const items = ref([])
172
+ const loading = ref(false)
173
+ const selected = ref([])
174
+ const deleting = ref(false)
175
+
176
+ // Pagination (load from cookie if available)
177
+ const page = ref(1)
178
+ const pageSize = ref(getSavedPageSize(defaultPageSize))
179
+ const totalRecords = ref(0)
180
+ const rowsPerPageOptions = PAGE_SIZE_OPTIONS
181
+
182
+ // Sorting
183
+ const sortField = ref(defaultSort)
184
+ const sortOrder = ref(defaultSortOrder)
185
+
186
+ // Search
187
+ const searchQuery = ref('')
188
+ const searchConfig = ref({
189
+ placeholder: 'Search...',
190
+ fields: [],
191
+ debounce: 300
192
+ })
193
+
194
+ // ============ HEADER ACTIONS ============
195
+ const headerActionsMap = ref(new Map())
196
+
197
+ /**
198
+ * Add a header action button
199
+ * @param {string} name - Unique identifier
200
+ * @param {object} config - Button configuration
201
+ * @param {string} config.label - Button label
202
+ * @param {string} [config.icon] - PrimeVue icon
203
+ * @param {string} [config.severity] - Button severity
204
+ * @param {function} config.onClick - Click handler
205
+ * @param {function} [config.visible] - Optional (state) => boolean
206
+ * @param {function} [config.loading] - Optional (state) => boolean
207
+ */
208
+ function addHeaderAction(name, config) {
209
+ headerActionsMap.value.set(name, { name, ...config })
210
+ }
211
+
212
+ function removeHeaderAction(name) {
213
+ headerActionsMap.value.delete(name)
214
+ }
215
+
216
+ /**
217
+ * Get header actions with resolved state
218
+ */
219
+ function getHeaderActions() {
220
+ const state = { hasSelection: hasSelection.value, selectionCount: selectionCount.value, deleting: deleting.value }
221
+ const actions = []
222
+ for (const [, action] of headerActionsMap.value) {
223
+ if (action.visible && !action.visible(state)) continue
224
+ actions.push({
225
+ ...action,
226
+ isLoading: action.loading ? action.loading(state) : false
227
+ })
228
+ }
229
+ return actions
230
+ }
231
+
232
+ const headerActions = computed(() => getHeaderActions())
233
+
234
+ /**
235
+ * Add standard "Create" header action
236
+ * Respects manager.canCreate() for visibility
237
+ */
238
+ function addCreateAction(label = null) {
239
+ const createLabel = label || `Create ${entityName.charAt(0).toUpperCase() + entityName.slice(1)}`
240
+ addHeaderAction('create', {
241
+ label: createLabel,
242
+ icon: 'pi pi-plus',
243
+ onClick: goToCreate,
244
+ visible: () => manager.canCreate()
245
+ })
246
+ }
247
+
248
+ /**
249
+ * Add standard "Bulk Delete" header action (visible only when selection)
250
+ * Respects manager.canDelete() for visibility
251
+ */
252
+ function addBulkDeleteAction() {
253
+ addHeaderAction('bulk-delete', {
254
+ label: (state) => `Delete (${state.selectionCount})`,
255
+ icon: 'pi pi-trash',
256
+ severity: 'danger',
257
+ onClick: confirmBulkDelete,
258
+ visible: (state) => state.hasSelection && manager.canDelete(),
259
+ loading: (state) => state.deleting
260
+ })
261
+ }
262
+
263
+ // ============ BULK STATUS ACTION ============
264
+ /**
265
+ * Add a bulk status change action with dialog
266
+ * Returns state and functions for use with BulkStatusDialog component
267
+ *
268
+ * @param {object} bulkConfig - Configuration
269
+ * @param {string} [bulkConfig.statusField='status'] - Field name for status in API payload
270
+ * @param {string} [bulkConfig.idsField='ids'] - Field name for IDs in API payload
271
+ * @param {string} [bulkConfig.bulkEndpoint] - Custom endpoint (default: `${endpoint}/bulk/status`)
272
+ * @param {Array} bulkConfig.options - Status options array [{label, value}]
273
+ * @param {string} [bulkConfig.label='Change Status'] - Button label
274
+ * @param {string} [bulkConfig.icon='pi pi-sync'] - Button icon
275
+ * @param {string} [bulkConfig.dialogTitle='Change Status'] - Dialog title
276
+ * @returns {object} State and functions for the dialog
277
+ */
278
+ function addBulkStatusAction(bulkConfig = {}) {
279
+ const {
280
+ statusField = 'status',
281
+ idsField = 'ids',
282
+ bulkEndpoint = null,
283
+ options = [],
284
+ label = 'Change Status',
285
+ icon = 'pi pi-sync',
286
+ dialogTitle = 'Change Status'
287
+ } = bulkConfig
288
+
289
+ // Internal state for the dialog
290
+ const showDialog = ref(false)
291
+ const selectedStatus = ref(null)
292
+ const updating = ref(false)
293
+
294
+ // Add header action
295
+ addHeaderAction('bulk-status', {
296
+ label: (state) => `${label} (${state.selectionCount})`,
297
+ icon,
298
+ severity: 'info',
299
+ onClick: () => { showDialog.value = true },
300
+ visible: (state) => state.hasSelection,
301
+ loading: () => updating.value
302
+ })
303
+
304
+ // Execute bulk status change
305
+ async function execute() {
306
+ if (!selectedStatus.value || selected.value.length === 0) return
307
+
308
+ updating.value = true
309
+ try {
310
+ const ids = selected.value.map(item => item[resolvedDataKey])
311
+ const bulkPath = bulkEndpoint || 'bulk/status'
312
+
313
+ const payload = {
314
+ [idsField]: ids,
315
+ [statusField]: selectedStatus.value
316
+ }
317
+
318
+ const response = await manager.request('PATCH', bulkPath, { data: payload })
319
+
320
+ // Handle standard response format {updated, failed, errors}
321
+ if (response.updated > 0) {
322
+ toast.add({
323
+ severity: 'success',
324
+ summary: 'Updated',
325
+ detail: `${response.updated} ${response.updated > 1 ? entityNamePlural : entityName} updated`,
326
+ life: 3000
327
+ })
328
+ }
329
+ if (response.failed > 0) {
330
+ toast.add({
331
+ severity: 'warn',
332
+ summary: 'Partial Success',
333
+ detail: `${response.failed} ${response.failed > 1 ? entityNamePlural : entityName} failed`,
334
+ life: 5000
335
+ })
336
+ }
337
+
338
+ // Reset state
339
+ selected.value = []
340
+ selectedStatus.value = null
341
+ showDialog.value = false
342
+ loadItems({}, { force: true })
343
+ } catch (error) {
344
+ toast.add({
345
+ severity: 'error',
346
+ summary: 'Error',
347
+ detail: error.response?.data?.detail || 'Bulk update failed',
348
+ life: 5000
349
+ })
350
+ } finally {
351
+ updating.value = false
352
+ }
353
+ }
354
+
355
+ // Cancel and reset
356
+ function cancel() {
357
+ showDialog.value = false
358
+ selectedStatus.value = null
359
+ }
360
+
361
+ // Support reactive options (can be updated after initialization)
362
+ const statusOptions = ref(options)
363
+
364
+ function setOptions(newOptions) {
365
+ statusOptions.value = newOptions
366
+ }
367
+
368
+ return {
369
+ showDialog,
370
+ selectedStatus,
371
+ updating,
372
+ statusOptions, // Now a ref for reactivity
373
+ dialogTitle,
374
+ execute,
375
+ cancel,
376
+ setOptions // Allow updating options after async load
377
+ }
378
+ }
379
+
380
+ // ============ CARDS ============
381
+ const cardsMap = ref(new Map())
382
+
383
+ function addCard(name, cardConfig) {
384
+ cardsMap.value.set(name, { name, ...cardConfig })
385
+ }
386
+
387
+ function updateCard(name, cardConfig) {
388
+ const existing = cardsMap.value.get(name)
389
+ if (existing) {
390
+ cardsMap.value.set(name, { ...existing, ...cardConfig })
391
+ }
392
+ }
393
+
394
+ function removeCard(name) {
395
+ cardsMap.value.delete(name)
396
+ }
397
+
398
+ const cards = computed(() => Array.from(cardsMap.value.values()))
399
+
400
+ // ============ FILTERS ============
401
+ const filtersMap = ref(new Map())
402
+ // Load saved filters from session storage
403
+ const savedFilters = persistFilters ? getSessionFilters(filterSessionKey) : null
404
+ const filterValues = ref(savedFilters || {})
405
+
406
+ function addFilter(name, filterConfig) {
407
+ filtersMap.value.set(name, {
408
+ name,
409
+ type: 'select', // select, multiselect, date, checkbox
410
+ placeholder: name,
411
+ ...filterConfig
412
+ })
413
+ // Initialize filter value (respect saved value if present)
414
+ if (filterValues.value[name] === undefined) {
415
+ filterValues.value[name] = filterConfig.default ?? null
416
+ }
417
+ }
418
+
419
+ function removeFilter(name) {
420
+ filtersMap.value.delete(name)
421
+ delete filterValues.value[name]
422
+ }
423
+
424
+ function setFilterValue(name, value) {
425
+ // Replace entire object to trigger watch
426
+ filterValues.value = { ...filterValues.value, [name]: value }
427
+ }
428
+
429
+ function updateFilters(newValues) {
430
+ filterValues.value = { ...filterValues.value, ...newValues }
431
+ // Directly trigger reload and persistence (don't rely on watch)
432
+ onFiltersChanged()
433
+ }
434
+
435
+ function onFiltersChanged() {
436
+ page.value = 1
437
+ loadItems()
438
+ // Persist filters to session storage
439
+ if (persistFilters) {
440
+ const toPersist = {}
441
+ for (const [name, value] of Object.entries(filterValues.value)) {
442
+ const filterDef = filtersMap.value.get(name)
443
+ if (filterDef?.persist !== false && value !== null && value !== undefined && value !== '') {
444
+ toPersist[name] = value
445
+ }
446
+ }
447
+ setSessionFilters(filterSessionKey, toPersist)
448
+ }
449
+ // Sync to URL query params
450
+ if (syncUrlParams) {
451
+ const query = { ...route.query }
452
+ for (const [name, value] of Object.entries(filterValues.value)) {
453
+ if (value !== null && value !== undefined && value !== '') {
454
+ query[name] = String(value)
455
+ } else {
456
+ delete query[name]
457
+ }
458
+ }
459
+ if (searchQuery.value) {
460
+ query.search = searchQuery.value
461
+ } else {
462
+ delete query.search
463
+ }
464
+ router.replace({ query })
465
+ }
466
+ }
467
+
468
+ function clearFilters() {
469
+ // Build cleared filter values
470
+ const cleared = {}
471
+ for (const key of Object.keys(filterValues.value)) {
472
+ cleared[key] = null
473
+ }
474
+ filterValues.value = cleared
475
+ // Clear search
476
+ searchQuery.value = ''
477
+ // Clear session storage
478
+ if (persistFilters) {
479
+ sessionStorage.removeItem(FILTER_SESSION_PREFIX + filterSessionKey)
480
+ }
481
+ // Clear URL params (filters + search)
482
+ if (syncUrlParams) {
483
+ const query = { ...route.query }
484
+ for (const key of filtersMap.value.keys()) {
485
+ delete query[key]
486
+ }
487
+ delete query.search
488
+ router.replace({ query })
489
+ }
490
+ // Reload data
491
+ page.value = 1
492
+ loadItems()
493
+ }
494
+
495
+ const filters = computed(() => Array.from(filtersMap.value.values()))
496
+
497
+ // ============ AUTO-LOAD FROM REGISTRY ============
498
+ /**
499
+ * Initialize filters and search from entity registry
500
+ */
501
+ function initFromRegistry() {
502
+ if (!autoLoadFilters) return
503
+
504
+ const entityConfig = entityFilters[entityName]
505
+ if (!entityConfig) return
506
+
507
+ // Auto-configure search
508
+ if (entityConfig.search) {
509
+ setSearch(entityConfig.search)
510
+ }
511
+
512
+ // Auto-add filters
513
+ if (entityConfig.filters) {
514
+ for (const filterDef of entityConfig.filters) {
515
+ addFilter(filterDef.name, filterDef)
516
+ }
517
+ }
518
+ }
519
+
520
+ /**
521
+ * Load filter options from API endpoints (for filters with optionsEndpoint)
522
+ */
523
+ async function loadFilterOptions() {
524
+ const entityConfig = entityFilters[entityName]
525
+ if (!entityConfig?.filters) return
526
+
527
+ for (const filterDef of entityConfig.filters) {
528
+ if (!filterDef.optionsEndpoint) continue
529
+
530
+ try {
531
+ // Use manager.request for custom endpoints, or get another manager
532
+ const optionsManager = filterDef.optionsEntity
533
+ ? orchestrator.get(filterDef.optionsEntity)
534
+ : manager
535
+
536
+ let rawOptions
537
+ if (filterDef.optionsEntity) {
538
+ const response = await optionsManager.list({ page_size: 1000 })
539
+ rawOptions = response.items || []
540
+ } else {
541
+ rawOptions = await manager.request('GET', filterDef.optionsEndpoint)
542
+ rawOptions = Array.isArray(rawOptions) ? rawOptions : rawOptions?.items || []
543
+ }
544
+
545
+ // Build options array with "All" option first
546
+ let finalOptions = [{ label: 'All', value: null }]
547
+
548
+ // Add null option if configured
549
+ if (filterDef.includeNull) {
550
+ finalOptions.push(filterDef.includeNull)
551
+ }
552
+
553
+ // Map fetched options to standard { label, value } format
554
+ // optionLabelField/optionValueField specify which API fields to use
555
+ // labelMap: { value: 'Label' } for custom value-to-label mapping
556
+ // labelFallback: function(value) for unknown values (default: snakeToTitle)
557
+ const labelField = filterDef.optionLabelField || 'label'
558
+ const valueField = filterDef.optionValueField || 'value'
559
+ const labelMap = filterDef.labelMap || {}
560
+ const labelFallback = filterDef.labelFallback || snakeToTitle
561
+
562
+ const mappedOptions = rawOptions.map(opt => {
563
+ const value = opt[valueField] ?? opt.id ?? opt
564
+ // Priority: labelMap > API label field > fallback function
565
+ let label = labelMap[value]
566
+ if (!label) {
567
+ label = opt[labelField] || opt.name
568
+ }
569
+ if (!label) {
570
+ label = labelFallback(value)
571
+ }
572
+ return { label, value }
573
+ })
574
+
575
+ finalOptions = [...finalOptions, ...mappedOptions]
576
+
577
+ // Update filter options in map
578
+ const existing = filtersMap.value.get(filterDef.name)
579
+ if (existing) {
580
+ filtersMap.value.set(filterDef.name, { ...existing, options: finalOptions })
581
+ }
582
+ } catch (error) {
583
+ console.warn(`Failed to load options for filter ${filterDef.name}:`, error)
584
+ }
585
+ }
586
+ // Trigger Vue reactivity by replacing the Map reference
587
+ filtersMap.value = new Map(filtersMap.value)
588
+ }
589
+
590
+ /**
591
+ * Restore filter values from URL query params (priority) or session storage
592
+ */
593
+ function restoreFilters() {
594
+ // Priority 1: URL query params
595
+ const urlFilters = {}
596
+ for (const key of filtersMap.value.keys()) {
597
+ if (route.query[key] !== undefined) {
598
+ // Parse value (handle booleans, numbers, etc.)
599
+ let value = route.query[key]
600
+ if (value === 'true') value = true
601
+ else if (value === 'false') value = false
602
+ else if (value === 'null') value = null
603
+ else if (!isNaN(Number(value)) && value !== '') value = Number(value)
604
+ urlFilters[key] = value
605
+ }
606
+ }
607
+ // Restore search from URL
608
+ if (route.query.search) {
609
+ searchQuery.value = route.query.search
610
+ }
611
+
612
+ // Priority 2: Session storage (only for filters not in URL)
613
+ const sessionFilters = persistFilters ? getSessionFilters(filterSessionKey) : null
614
+
615
+ // Merge: URL takes priority over session
616
+ const restoredFilters = { ...sessionFilters, ...urlFilters }
617
+
618
+ // Apply restored values
619
+ for (const [name, value] of Object.entries(restoredFilters)) {
620
+ if (filtersMap.value.has(name)) {
621
+ filterValues.value[name] = value
622
+ }
623
+ }
624
+ }
625
+
626
+ // ============ ACTIONS ============
627
+ const actionsMap = ref(new Map())
628
+
629
+ function addAction(name, actionConfig) {
630
+ actionsMap.value.set(name, {
631
+ name,
632
+ severity: 'secondary',
633
+ ...actionConfig
634
+ })
635
+ }
636
+
637
+ function removeAction(name) {
638
+ actionsMap.value.delete(name)
639
+ }
640
+
641
+ function resolveValue(value, row) {
642
+ return typeof value === 'function' ? value(row) : value
643
+ }
644
+
645
+ function getActions(row) {
646
+ const actions = []
647
+ for (const [, action] of actionsMap.value) {
648
+ if (action.visible && !action.visible(row)) continue
649
+ actions.push({
650
+ name: action.name,
651
+ icon: resolveValue(action.icon, row),
652
+ tooltip: resolveValue(action.tooltip, row),
653
+ severity: resolveValue(action.severity, row) || 'secondary',
654
+ isDisabled: action.disabled ? action.disabled(row) : false,
655
+ handler: () => action.onClick(row)
656
+ })
657
+ }
658
+ return actions
659
+ }
660
+
661
+ // ============ SEARCH ============
662
+ function setSearch(searchCfg) {
663
+ searchConfig.value = { ...searchConfig.value, ...searchCfg }
664
+ }
665
+
666
+ // ============ SMART FILTER MODE ============
667
+ // Threshold priority: config option > manager.localFilterThreshold > default (100)
668
+ const resolvedThreshold = computed(() => {
669
+ if (autoFilterThreshold !== 100) return autoFilterThreshold // Explicit config
670
+ return manager?.localFilterThreshold ?? 100
671
+ })
672
+
673
+ const effectiveFilterMode = computed(() => {
674
+ if (filterMode !== 'auto') return filterMode
675
+ // Switch to local if we have few items
676
+ return items.value.length > 0 && items.value.length < resolvedThreshold.value ? 'local' : 'manager'
677
+ })
678
+
679
+ // Computed for local filtering
680
+ // Only applied when effectiveFilterMode === 'local'
681
+ // In manager mode, items are already filtered by the manager
682
+ const filteredItems = computed(() => {
683
+ const isLocalMode = effectiveFilterMode.value === 'local'
684
+
685
+ // In manager mode, return items as-is (manager already filtered)
686
+ if (!isLocalMode) {
687
+ return items.value
688
+ }
689
+
690
+ // Local mode: apply all filtering client-side
691
+ let result = [...items.value]
692
+
693
+ // Apply search
694
+ // local_search: false = manager only, skip in local mode
695
+ if (searchQuery.value && searchConfig.value.local_search !== false) {
696
+ const query = searchQuery.value.toLowerCase()
697
+ // Default: search on configured fields
698
+ const localSearch = searchConfig.value.local_search || (
699
+ searchConfig.value.fields?.length
700
+ ? (item, q) => searchConfig.value.fields.some(field =>
701
+ String(item[field] || '').toLowerCase().includes(q)
702
+ )
703
+ : null
704
+ )
705
+ if (localSearch) {
706
+ result = result.filter(item => localSearch(item, query))
707
+ }
708
+ }
709
+
710
+ // Apply filters
711
+ for (const [name, value] of Object.entries(filterValues.value)) {
712
+ if (value === null || value === undefined || value === '') continue
713
+ const filterDef = filtersMap.value.get(name)
714
+
715
+ // local_filter: false = manager only, skip in local mode
716
+ if (filterDef?.local_filter === false) continue
717
+
718
+ // Default: simple equality on field
719
+ const localFilter = filterDef?.local_filter || ((item, val) => {
720
+ const itemValue = item[name]
721
+ if (val === '__null__') return itemValue === null || itemValue === undefined
722
+ return itemValue === val
723
+ })
724
+ result = result.filter(item => localFilter(item, value))
725
+ }
726
+
727
+ return result
728
+ })
729
+
730
+ // Items to display - always use filteredItems (handles both modes correctly)
731
+ const displayItems = computed(() => filteredItems.value)
732
+
733
+ // ============ LOADING ============
734
+ let filterOptionsLoaded = false
735
+
736
+ async function loadItems(extraParams = {}, { force = false } = {}) {
737
+ if (!manager) return
738
+
739
+ // Skip API call if local filtering and data already loaded (unless forced)
740
+ if (!force && effectiveFilterMode.value === 'local' && items.value.length > 0) {
741
+ return
742
+ }
743
+
744
+ loading.value = true
745
+ try {
746
+ let params = { ...extraParams }
747
+
748
+ if (serverSide) {
749
+ params.page = page.value
750
+ params.page_size = pageSize.value
751
+ if (sortField.value) {
752
+ params.sort_by = sortField.value
753
+ params.sort_order = sortOrder.value === 1 ? 'asc' : 'desc'
754
+ }
755
+ }
756
+
757
+ // Add search param to manager (only if no local_search callback)
758
+ if (searchQuery.value && searchConfig.value.fields?.length && !searchConfig.value.local_search) {
759
+ params.search = searchQuery.value
760
+ }
761
+
762
+ // Add filter values to manager (skip filters with local_filter - they're local-only)
763
+ for (const [name, value] of Object.entries(filterValues.value)) {
764
+ if (value === null || value === undefined || value === '') continue
765
+ const filterDef = filtersMap.value.get(name)
766
+ // Skip virtual filters (those with local_filter) - not sent to manager
767
+ if (filterDef?.local_filter) continue
768
+ params[name] = value
769
+ }
770
+
771
+ // Hook: modify params before request
772
+ if (onBeforeLoad) {
773
+ params = onBeforeLoad(params) || params
774
+ }
775
+
776
+ const response = await manager.list(params)
777
+
778
+ // Hook: transform response
779
+ let processedData
780
+ if (transformResponse) {
781
+ processedData = transformResponse(response)
782
+ } else {
783
+ processedData = {
784
+ items: response.items || [],
785
+ total: response.total || response.items?.length || 0
786
+ }
787
+ }
788
+
789
+ items.value = processedData.items
790
+ totalRecords.value = processedData.total
791
+
792
+ // Hook: post-processing
793
+ if (onAfterLoad) {
794
+ onAfterLoad(response, processedData)
795
+ }
796
+ } catch (error) {
797
+ toast.add({
798
+ severity: 'error',
799
+ summary: 'Error',
800
+ detail: `Failed to load ${entityNamePlural}`,
801
+ life: 5000
802
+ })
803
+ console.error('Load error:', error)
804
+ } finally {
805
+ loading.value = false
806
+ }
807
+ }
808
+
809
+ // ============ PAGINATION & SORTING ============
810
+ function onPage(event) {
811
+ page.value = event.page + 1
812
+ pageSize.value = event.rows
813
+ // Save page size preference to cookie
814
+ setCookie(COOKIE_NAME, event.rows, COOKIE_EXPIRY_DAYS)
815
+ if (serverSide) loadItems()
816
+ }
817
+
818
+ function onSort(event) {
819
+ sortField.value = event.sortField
820
+ sortOrder.value = event.sortOrder
821
+ if (serverSide) loadItems()
822
+ }
823
+
824
+ // ============ NAVIGATION ============
825
+ function goToCreate() {
826
+ router.push({ name: `${routePrefix}-create` })
827
+ }
828
+
829
+ function goToEdit(item) {
830
+ router.push({ name: `${routePrefix}-edit`, params: { [resolvedDataKey]: item[resolvedDataKey] } })
831
+ }
832
+
833
+ function goToShow(item) {
834
+ router.push({ name: `${routePrefix}-show`, params: { [resolvedDataKey]: item[resolvedDataKey] } })
835
+ }
836
+
837
+ // ============ DELETE ============
838
+ async function deleteItem(item, labelField = 'name') {
839
+ try {
840
+ await manager.delete(item[resolvedDataKey])
841
+ toast.add({
842
+ severity: 'success',
843
+ summary: 'Deleted',
844
+ detail: `${entityName} "${item[labelField] || item[resolvedDataKey]}" deleted`,
845
+ life: 3000
846
+ })
847
+ loadItems({}, { force: true })
848
+ } catch (error) {
849
+ toast.add({
850
+ severity: 'error',
851
+ summary: 'Error',
852
+ detail: error.response?.data?.detail || `Failed to delete ${entityName}`,
853
+ life: 5000
854
+ })
855
+ }
856
+ }
857
+
858
+ function confirmDelete(item, labelField = 'name') {
859
+ confirm.require({
860
+ message: `Delete ${entityName} "${item[labelField] || item[resolvedDataKey]}"?`,
861
+ header: 'Confirm Delete',
862
+ icon: 'pi pi-exclamation-triangle',
863
+ acceptClass: 'p-button-danger',
864
+ accept: () => deleteItem(item, labelField)
865
+ })
866
+ }
867
+
868
+ // ============ BULK OPERATIONS ============
869
+ const hasSelection = computed(() => selected.value.length > 0)
870
+ const selectionCount = computed(() => selected.value.length)
871
+
872
+ async function bulkDelete() {
873
+ deleting.value = true
874
+ let successCount = 0
875
+ let errorCount = 0
876
+
877
+ for (const item of [...selected.value]) {
878
+ try {
879
+ await manager.delete(item[resolvedDataKey])
880
+ successCount++
881
+ } catch {
882
+ errorCount++
883
+ }
884
+ }
885
+
886
+ deleting.value = false
887
+ selected.value = []
888
+
889
+ if (successCount > 0) {
890
+ toast.add({
891
+ severity: 'success',
892
+ summary: 'Deleted',
893
+ detail: `${successCount} ${successCount > 1 ? entityNamePlural : entityName} deleted`,
894
+ life: 3000
895
+ })
896
+ }
897
+ if (errorCount > 0) {
898
+ toast.add({
899
+ severity: 'error',
900
+ summary: 'Error',
901
+ detail: `Failed to delete ${errorCount} ${errorCount > 1 ? entityNamePlural : entityName}`,
902
+ life: 5000
903
+ })
904
+ }
905
+
906
+ loadItems({}, { force: true })
907
+ }
908
+
909
+ function confirmBulkDelete() {
910
+ const count = selected.value.length
911
+ confirm.require({
912
+ message: `Delete ${count} ${count > 1 ? entityNamePlural : entityName}?`,
913
+ header: 'Confirm Bulk Delete',
914
+ icon: 'pi pi-exclamation-triangle',
915
+ acceptClass: 'p-button-danger',
916
+ accept: bulkDelete
917
+ })
918
+ }
919
+
920
+ // ============ WATCHERS ============
921
+ let searchTimeout = null
922
+ watch(searchQuery, () => {
923
+ clearTimeout(searchTimeout)
924
+ searchTimeout = setTimeout(() => {
925
+ // Use onFiltersChanged to also sync URL params
926
+ onFiltersChanged()
927
+ }, searchConfig.value.debounce)
928
+ })
929
+
930
+ // Note: filterValues changes are handled directly in updateFilters() and clearFilters()
931
+ // to avoid relying on watch reactivity which can be unreliable with object mutations
932
+
933
+ // ============ LIFECYCLE ============
934
+ // Initialize from registry immediately (sync)
935
+ initFromRegistry()
936
+
937
+ onMounted(async () => {
938
+ // Restore filters from URL/session after registry init
939
+ restoreFilters()
940
+
941
+ // Load filter options from API (async)
942
+ if (!filterOptionsLoaded) {
943
+ filterOptionsLoaded = true
944
+ await loadFilterOptions()
945
+ }
946
+
947
+ // Load data
948
+ if (loadOnMount && manager) {
949
+ loadItems()
950
+ }
951
+ })
952
+
953
+ // ============ UTILITIES ============
954
+ function formatDate(dateStr, options = {}) {
955
+ if (!dateStr) return '-'
956
+ const defaultOptions = {
957
+ day: '2-digit',
958
+ month: '2-digit',
959
+ year: 'numeric',
960
+ ...options
961
+ }
962
+ return new Date(dateStr).toLocaleDateString('fr-FR', defaultOptions)
963
+ }
964
+
965
+ // ============ STANDARD ACTIONS ============
966
+ /**
967
+ * Add standard "view" action
968
+ */
969
+ function addViewAction(options = {}) {
970
+ addAction('view', {
971
+ icon: 'pi pi-eye',
972
+ tooltip: 'View',
973
+ onClick: options.onClick || goToShow,
974
+ ...options
975
+ })
976
+ }
977
+
978
+ /**
979
+ * Add standard "edit" action
980
+ * Respects manager.canUpdate() for visibility
981
+ */
982
+ function addEditAction(options = {}) {
983
+ addAction('edit', {
984
+ icon: 'pi pi-pencil',
985
+ tooltip: 'Edit',
986
+ onClick: options.onClick || goToEdit,
987
+ visible: (row) => manager.canUpdate(row),
988
+ ...options
989
+ })
990
+ }
991
+
992
+ /**
993
+ * Add standard "delete" action
994
+ * Respects manager.canDelete() for visibility
995
+ */
996
+ function addDeleteAction(options = {}) {
997
+ const labelField = options.labelField || 'name'
998
+ addAction('delete', {
999
+ icon: 'pi pi-trash',
1000
+ tooltip: 'Delete',
1001
+ severity: 'danger',
1002
+ onClick: (row) => confirmDelete(row, labelField),
1003
+ visible: (row) => manager.canDelete(row),
1004
+ ...options
1005
+ })
1006
+ }
1007
+
1008
+ // ============ BULK ACTIONS DETECTION ============
1009
+ /**
1010
+ * Check if any header actions depend on selection (bulk actions)
1011
+ */
1012
+ const hasBulkActions = computed(() => {
1013
+ for (const [, action] of headerActionsMap.value) {
1014
+ // If action has visible function that checks hasSelection, it's a bulk action
1015
+ if (action.visible && typeof action.visible === 'function') {
1016
+ // Test with hasSelection: true to see if action would be visible
1017
+ const wouldShow = action.visible({ hasSelection: true, selectionCount: 1, deleting: false })
1018
+ const wouldHide = action.visible({ hasSelection: false, selectionCount: 0, deleting: false })
1019
+ if (wouldShow && !wouldHide) {
1020
+ return true
1021
+ }
1022
+ }
1023
+ }
1024
+ return false
1025
+ })
1026
+
1027
+ // ============ LIST PAGE PROPS ============
1028
+ /**
1029
+ * Props object for ListPage component
1030
+ * Use with v-bind: <ListPage v-bind="list.props">
1031
+ */
1032
+ const listProps = computed(() => ({
1033
+ // Header
1034
+ title: manager.labelPlural,
1035
+ breadcrumb: breadcrumbItems.value,
1036
+ headerActions: headerActions.value,
1037
+
1038
+ // Cards
1039
+ cards: cards.value,
1040
+
1041
+ // Table data
1042
+ items: displayItems.value,
1043
+ loading: loading.value,
1044
+ dataKey: resolvedDataKey,
1045
+
1046
+ // Selection - auto-enable if bulk actions available
1047
+ selected: selected.value,
1048
+ selectable: hasBulkActions.value,
1049
+
1050
+ // Pagination
1051
+ totalRecords: totalRecords.value,
1052
+ rows: pageSize.value,
1053
+ rowsPerPageOptions,
1054
+
1055
+ // Sorting
1056
+ sortField: sortField.value,
1057
+ sortOrder: sortOrder.value,
1058
+
1059
+ // Search
1060
+ searchQuery: searchQuery.value,
1061
+ searchPlaceholder: searchConfig.value.placeholder,
1062
+
1063
+ // Filters
1064
+ filters: filters.value,
1065
+ filterValues: filterValues.value,
1066
+
1067
+ // Row actions
1068
+ getActions
1069
+ }))
1070
+
1071
+ /**
1072
+ * Event handlers for ListPage
1073
+ * Use with v-on: <ListPage v-bind="list.props" v-on="list.events">
1074
+ */
1075
+ const listEvents = {
1076
+ 'update:selected': (value) => { selected.value = value },
1077
+ 'update:searchQuery': (value) => { searchQuery.value = value },
1078
+ 'update:filterValues': updateFilters,
1079
+ 'page': onPage,
1080
+ 'sort': onSort
1081
+ }
1082
+
1083
+ return {
1084
+ // Manager access
1085
+ manager,
1086
+
1087
+ // State
1088
+ items,
1089
+ displayItems, // Use this for rendering (handles local/API filtering)
1090
+ loading,
1091
+ selected,
1092
+ deleting,
1093
+
1094
+ // Pagination
1095
+ page,
1096
+ pageSize,
1097
+ totalRecords,
1098
+ rowsPerPageOptions,
1099
+ sortField,
1100
+ sortOrder,
1101
+ onPage,
1102
+ onSort,
1103
+
1104
+ // Search
1105
+ searchQuery,
1106
+ searchConfig,
1107
+ setSearch,
1108
+
1109
+ // Header Actions
1110
+ headerActions,
1111
+ addHeaderAction,
1112
+ removeHeaderAction,
1113
+ getHeaderActions,
1114
+ addCreateAction,
1115
+ addBulkDeleteAction,
1116
+ addBulkStatusAction,
1117
+ hasBulkActions,
1118
+
1119
+ // Cards
1120
+ cards,
1121
+ addCard,
1122
+ updateCard,
1123
+ removeCard,
1124
+
1125
+ // Filters
1126
+ filters,
1127
+ filterValues,
1128
+ filteredItems,
1129
+ effectiveFilterMode,
1130
+ addFilter,
1131
+ removeFilter,
1132
+ setFilterValue,
1133
+ updateFilters,
1134
+ clearFilters,
1135
+ loadFilterOptions,
1136
+ initFromRegistry,
1137
+ restoreFilters,
1138
+
1139
+ // Actions
1140
+ addAction,
1141
+ removeAction,
1142
+ getActions,
1143
+ addViewAction,
1144
+ addEditAction,
1145
+ addDeleteAction,
1146
+
1147
+ // Data
1148
+ loadItems,
1149
+
1150
+ // Navigation
1151
+ goToCreate,
1152
+ goToEdit,
1153
+ goToShow,
1154
+
1155
+ // Delete
1156
+ deleteItem,
1157
+ confirmDelete,
1158
+ bulkDelete,
1159
+ confirmBulkDelete,
1160
+ hasSelection,
1161
+ selectionCount,
1162
+
1163
+ // Utilities
1164
+ formatDate,
1165
+ toast,
1166
+ confirm,
1167
+ router,
1168
+
1169
+ // Breadcrumb
1170
+ breadcrumb: breadcrumbItems,
1171
+
1172
+ // ListPage integration
1173
+ props: listProps,
1174
+ events: listEvents
1175
+ }
1176
+ }