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.
- package/CHANGELOG.md +270 -0
- package/LICENSE +21 -0
- package/README.md +166 -0
- package/package.json +48 -0
- package/src/assets/logo.svg +6 -0
- package/src/components/BoolCell.vue +28 -0
- package/src/components/dialogs/BulkStatusDialog.vue +43 -0
- package/src/components/dialogs/MultiStepDialog.vue +321 -0
- package/src/components/dialogs/SimpleDialog.vue +108 -0
- package/src/components/dialogs/UnsavedChangesDialog.vue +87 -0
- package/src/components/display/CardsGrid.vue +155 -0
- package/src/components/display/CopyableId.vue +92 -0
- package/src/components/display/EmptyState.vue +114 -0
- package/src/components/display/IntensityBar.vue +171 -0
- package/src/components/display/RichCardsGrid.vue +220 -0
- package/src/components/editors/JsonEditorFoldable.vue +467 -0
- package/src/components/editors/JsonStructuredField.vue +218 -0
- package/src/components/editors/JsonViewer.vue +91 -0
- package/src/components/editors/KeyValueEditor.vue +314 -0
- package/src/components/editors/LanguageEditor.vue +245 -0
- package/src/components/editors/ScopeEditor.vue +341 -0
- package/src/components/editors/VanillaJsonEditor.vue +185 -0
- package/src/components/forms/FormActions.vue +104 -0
- package/src/components/forms/FormField.vue +64 -0
- package/src/components/forms/FormTab.vue +217 -0
- package/src/components/forms/FormTabs.vue +108 -0
- package/src/components/index.js +44 -0
- package/src/components/layout/AppLayout.vue +430 -0
- package/src/components/layout/Breadcrumb.vue +106 -0
- package/src/components/layout/PageHeader.vue +75 -0
- package/src/components/layout/PageLayout.vue +93 -0
- package/src/components/lists/ActionButtons.vue +41 -0
- package/src/components/lists/ActionColumn.vue +37 -0
- package/src/components/lists/FilterBar.vue +53 -0
- package/src/components/lists/ListPage.vue +319 -0
- package/src/composables/index.js +19 -0
- package/src/composables/useApp.js +43 -0
- package/src/composables/useAuth.js +49 -0
- package/src/composables/useBareForm.js +143 -0
- package/src/composables/useBreadcrumb.js +221 -0
- package/src/composables/useDirtyState.js +103 -0
- package/src/composables/useEntityTitle.js +121 -0
- package/src/composables/useForm.js +254 -0
- package/src/composables/useGuardStore.js +37 -0
- package/src/composables/useJsonSyntax.js +101 -0
- package/src/composables/useListPageBuilder.js +1176 -0
- package/src/composables/useNavigation.js +89 -0
- package/src/composables/usePageBuilder.js +334 -0
- package/src/composables/useStatus.js +146 -0
- package/src/composables/useSubEditor.js +165 -0
- package/src/composables/useTabSync.js +110 -0
- package/src/composables/useUnsavedChangesGuard.js +122 -0
- package/src/entity/EntityManager.js +540 -0
- package/src/entity/index.js +11 -0
- package/src/entity/storage/ApiStorage.js +146 -0
- package/src/entity/storage/LocalStorage.js +220 -0
- package/src/entity/storage/MemoryStorage.js +201 -0
- package/src/entity/storage/index.js +10 -0
- package/src/index.js +29 -0
- package/src/kernel/Kernel.js +234 -0
- package/src/kernel/index.js +7 -0
- package/src/module/index.js +16 -0
- package/src/module/moduleRegistry.js +222 -0
- package/src/orchestrator/Orchestrator.js +141 -0
- package/src/orchestrator/index.js +8 -0
- package/src/orchestrator/useOrchestrator.js +61 -0
- package/src/plugin.js +142 -0
- package/src/styles/_alerts.css +48 -0
- package/src/styles/_code.css +33 -0
- package/src/styles/_dialogs.css +17 -0
- package/src/styles/_markdown.css +82 -0
- package/src/styles/_show-pages.css +84 -0
- package/src/styles/index.css +16 -0
- package/src/styles/main.css +845 -0
- package/src/styles/theme/components.css +286 -0
- package/src/styles/theme/index.css +10 -0
- package/src/styles/theme/tokens.css +125 -0
- package/src/styles/theme/utilities.css +172 -0
- package/src/utils/debugInjector.js +261 -0
- package/src/utils/formatters.js +165 -0
- package/src/utils/index.js +35 -0
- 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
|
+
}
|