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,430 @@
1
+ <script setup>
2
+ /**
3
+ * AppLayout - Generic admin layout with sidebar navigation
4
+ *
5
+ * Navigation is auto-built from moduleRegistry.
6
+ * Branding comes from createQdadm({ app: {...} }) config.
7
+ * Auth is optional via authAdapter.
8
+ *
9
+ * Usage:
10
+ * <AppLayout>
11
+ * <RouterView />
12
+ * </AppLayout>
13
+ */
14
+
15
+ import { ref, watch, onMounted, computed, inject, useSlots } from 'vue'
16
+ import { RouterLink, RouterView, useRouter, useRoute } from 'vue-router'
17
+ import { useNavigation } from '../../composables/useNavigation'
18
+ import { useApp } from '../../composables/useApp'
19
+ import { useAuth } from '../../composables/useAuth'
20
+ import { useGuardDialog } from '../../composables/useGuardStore'
21
+ import Button from 'primevue/button'
22
+ import UnsavedChangesDialog from '../dialogs/UnsavedChangesDialog.vue'
23
+ import qdadmLogo from '../../assets/logo.svg'
24
+ import { version as qdadmVersion } from '../../../package.json'
25
+
26
+ const features = inject('qdadmFeatures', { poweredBy: true })
27
+
28
+ // Guard dialog from shared store (registered by useBareForm/useForm when a form is active)
29
+ const guardDialog = useGuardDialog()
30
+
31
+ const router = useRouter()
32
+ const route = useRoute()
33
+ const app = useApp()
34
+ const { navSections, isNavActive, sectionHasActiveItem, handleNavClick } = useNavigation()
35
+ const { isAuthenticated, user, logout, authEnabled } = useAuth()
36
+
37
+ // LocalStorage key for collapsed sections state (namespaced by app)
38
+ const STORAGE_KEY = computed(() => `${app.shortName.toLowerCase()}_nav_collapsed`)
39
+
40
+ // Collapsed sections state (section title -> boolean)
41
+ const collapsedSections = ref({})
42
+
43
+ /**
44
+ * Load collapsed state from localStorage
45
+ */
46
+ function loadCollapsedState() {
47
+ try {
48
+ const stored = localStorage.getItem(STORAGE_KEY.value)
49
+ if (stored) {
50
+ collapsedSections.value = JSON.parse(stored)
51
+ }
52
+ } catch (e) {
53
+ console.warn('Failed to load nav state:', e)
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Save collapsed state to localStorage
59
+ */
60
+ function saveCollapsedState() {
61
+ try {
62
+ localStorage.setItem(STORAGE_KEY.value, JSON.stringify(collapsedSections.value))
63
+ } catch (e) {
64
+ console.warn('Failed to save nav state:', e)
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Toggle section collapsed state
70
+ */
71
+ function toggleSection(sectionTitle) {
72
+ collapsedSections.value[sectionTitle] = !collapsedSections.value[sectionTitle]
73
+ saveCollapsedState()
74
+ }
75
+
76
+ /**
77
+ * Check if section should be shown expanded
78
+ * - Never collapsed if it contains active item
79
+ * - Otherwise respect user preference
80
+ */
81
+ function isSectionExpanded(section) {
82
+ if (sectionHasActiveItem(section)) {
83
+ return true
84
+ }
85
+ return !collapsedSections.value[section.title]
86
+ }
87
+
88
+ // Load state on mount
89
+ onMounted(() => {
90
+ loadCollapsedState()
91
+ })
92
+
93
+ // Auto-expand section when navigating to an item in it
94
+ watch(() => route.path, () => {
95
+ for (const section of navSections.value) {
96
+ if (sectionHasActiveItem(section) && collapsedSections.value[section.title]) {
97
+ // Auto-expand but don't save (user can collapse again if they want)
98
+ collapsedSections.value[section.title] = false
99
+ }
100
+ }
101
+ })
102
+
103
+ const userInitials = computed(() => {
104
+ const username = user.value?.username
105
+ if (!username) return '?'
106
+ return username.substring(0, 2).toUpperCase()
107
+ })
108
+
109
+ const userDisplayName = computed(() => {
110
+ return user.value?.username || user.value?.name || 'User'
111
+ })
112
+
113
+ const userSubtitle = computed(() => {
114
+ return user.value?.email || user.value?.role || ''
115
+ })
116
+
117
+ function handleLogout() {
118
+ logout()
119
+ router.push({ name: 'login' })
120
+ }
121
+
122
+ // Check if slot content is provided
123
+ const slots = useSlots()
124
+ const hasSlotContent = computed(() => !!slots.default)
125
+ </script>
126
+
127
+ <template>
128
+ <div class="app-layout">
129
+ <!-- Sidebar -->
130
+ <aside class="sidebar">
131
+ <div class="sidebar-header">
132
+ <img v-if="app.logo" :src="app.logo" :alt="app.name" class="sidebar-logo" />
133
+ <h1 v-else>{{ app.name }}</h1>
134
+ <span v-if="app.version" class="version">v{{ app.version }}</span>
135
+ </div>
136
+
137
+ <nav class="sidebar-nav">
138
+ <div v-for="section in navSections" :key="section.title" class="nav-section">
139
+ <div
140
+ class="nav-section-title"
141
+ :class="{ 'nav-section-active': sectionHasActiveItem(section) }"
142
+ @click="toggleSection(section.title)"
143
+ >
144
+ <span>{{ section.title }}</span>
145
+ <i
146
+ class="nav-section-chevron pi"
147
+ :class="isSectionExpanded(section) ? 'pi-chevron-down' : 'pi-chevron-right'"
148
+ ></i>
149
+ </div>
150
+ <div class="nav-section-items" :class="{ 'nav-section-collapsed': !isSectionExpanded(section) }">
151
+ <RouterLink
152
+ v-for="item in section.items"
153
+ :key="item.route"
154
+ :to="{ name: item.route }"
155
+ class="nav-item"
156
+ :class="{ 'nav-item-active': isNavActive(item) }"
157
+ @click="handleNavClick($event, item)"
158
+ >
159
+ <i :class="item.icon"></i>
160
+ <span>{{ item.label }}</span>
161
+ </RouterLink>
162
+ </div>
163
+ </div>
164
+ </nav>
165
+
166
+ <div v-if="authEnabled" class="sidebar-footer">
167
+ <div class="user-info">
168
+ <div class="user-avatar">{{ userInitials }}</div>
169
+ <div class="user-details">
170
+ <div class="user-name">{{ userDisplayName }}</div>
171
+ <div class="user-role">{{ userSubtitle }}</div>
172
+ </div>
173
+ <Button
174
+ icon="pi pi-sign-out"
175
+ severity="secondary"
176
+ text
177
+ rounded
178
+ @click="handleLogout"
179
+ v-tooltip.top="'Logout'"
180
+ />
181
+ </div>
182
+ </div>
183
+
184
+ <div v-if="features.poweredBy" class="powered-by">
185
+ <img :src="qdadmLogo" alt="qdadm" class="powered-by-logo" />
186
+ <span class="powered-by-text">
187
+ powered by <strong>qdadm</strong> v{{ qdadmVersion }}
188
+ </span>
189
+ </div>
190
+ </aside>
191
+
192
+ <!-- Main content -->
193
+ <main class="main-content">
194
+ <div class="page-content">
195
+ <!-- Use slot if provided, otherwise RouterView for nested routes -->
196
+ <slot v-if="hasSlotContent" />
197
+ <RouterView v-else />
198
+ </div>
199
+ </main>
200
+
201
+ <!-- Unsaved Changes Dialog (auto-rendered when a form registers guardDialog) -->
202
+ <UnsavedChangesDialog
203
+ v-if="guardDialog"
204
+ :visible="guardDialog.visible.value"
205
+ :saving="guardDialog.saving.value"
206
+ :message="guardDialog.message"
207
+ :hasOnSave="guardDialog.hasOnSave"
208
+ @saveAndLeave="guardDialog.onSaveAndLeave"
209
+ @leave="guardDialog.onLeave"
210
+ @stay="guardDialog.onStay"
211
+ />
212
+ <!-- Note: guardDialog is a shallowRef, Vue auto-unwraps it in templates -->
213
+ </div>
214
+ </template>
215
+
216
+ <style scoped>
217
+ .app-layout {
218
+ display: flex;
219
+ min-height: 100vh;
220
+ }
221
+
222
+ .sidebar {
223
+ width: var(--fad-sidebar-width, 15rem);
224
+ background: var(--p-surface-800, #1e293b);
225
+ color: var(--p-surface-0, white);
226
+ display: flex;
227
+ flex-direction: column;
228
+ position: fixed;
229
+ top: 0;
230
+ left: 0;
231
+ bottom: 0;
232
+ z-index: 100;
233
+ }
234
+
235
+ .sidebar-header {
236
+ padding: 1.5rem;
237
+ border-bottom: 1px solid var(--p-surface-700, #334155);
238
+ display: flex;
239
+ align-items: center;
240
+ gap: 0.5rem;
241
+ }
242
+
243
+ .sidebar-header h1 {
244
+ font-size: 1.25rem;
245
+ font-weight: 600;
246
+ margin: 0;
247
+ }
248
+
249
+ .sidebar-logo {
250
+ max-height: 32px;
251
+ max-width: 160px;
252
+ }
253
+
254
+ .version {
255
+ font-size: 0.75rem;
256
+ color: var(--p-surface-400, #94a3b8);
257
+ background: var(--p-surface-700, #334155);
258
+ padding: 0.125rem 0.375rem;
259
+ border-radius: 0.25rem;
260
+ }
261
+
262
+ .sidebar-nav {
263
+ flex: 1;
264
+ overflow-y: auto;
265
+ padding: 1rem 0;
266
+ }
267
+
268
+ .nav-section {
269
+ margin-bottom: 0.5rem;
270
+ }
271
+
272
+ .nav-section-title {
273
+ padding: 0.5rem 1.5rem;
274
+ font-size: 0.75rem;
275
+ font-weight: 600;
276
+ text-transform: uppercase;
277
+ letter-spacing: 0.05em;
278
+ color: var(--p-surface-400, #94a3b8);
279
+ display: flex;
280
+ justify-content: space-between;
281
+ align-items: center;
282
+ cursor: pointer;
283
+ user-select: none;
284
+ transition: color 0.15s;
285
+ }
286
+
287
+ .nav-section-title:hover {
288
+ color: var(--p-surface-200, #e2e8f0);
289
+ }
290
+
291
+ .nav-section-active {
292
+ color: var(--p-primary-400, #60a5fa);
293
+ }
294
+
295
+ .nav-section-chevron {
296
+ font-size: 0.625rem;
297
+ transition: transform 0.2s;
298
+ }
299
+
300
+ .nav-section-items {
301
+ max-height: 500px;
302
+ overflow: hidden;
303
+ transition: max-height 0.2s ease-out, opacity 0.15s;
304
+ }
305
+
306
+ .nav-section-collapsed {
307
+ max-height: 0;
308
+ opacity: 0;
309
+ }
310
+
311
+ .nav-item {
312
+ display: flex;
313
+ align-items: center;
314
+ gap: 0.75rem;
315
+ padding: 0.625rem 1.5rem;
316
+ color: var(--p-surface-300, #cbd5e1);
317
+ text-decoration: none;
318
+ transition: all 0.15s;
319
+ }
320
+
321
+ .nav-item:hover {
322
+ background: var(--p-surface-700, #334155);
323
+ color: var(--p-surface-0, white);
324
+ }
325
+
326
+ .nav-item-active {
327
+ background: var(--p-primary-600, #2563eb);
328
+ color: var(--p-surface-0, white);
329
+ }
330
+
331
+ .nav-item i {
332
+ font-size: 1rem;
333
+ width: 1.25rem;
334
+ text-align: center;
335
+ }
336
+
337
+ .sidebar-footer {
338
+ padding: 1rem 1.5rem;
339
+ border-top: 1px solid var(--p-surface-700, #334155);
340
+ }
341
+
342
+ .user-info {
343
+ display: flex;
344
+ align-items: center;
345
+ gap: 0.75rem;
346
+ }
347
+
348
+ .user-avatar {
349
+ width: 36px;
350
+ height: 36px;
351
+ border-radius: 50%;
352
+ background: var(--p-primary-600, #2563eb);
353
+ display: flex;
354
+ align-items: center;
355
+ justify-content: center;
356
+ font-size: 0.875rem;
357
+ font-weight: 600;
358
+ }
359
+
360
+ .user-details {
361
+ flex: 1;
362
+ min-width: 0;
363
+ }
364
+
365
+ .user-name {
366
+ font-weight: 500;
367
+ font-size: 0.875rem;
368
+ }
369
+
370
+ .user-role {
371
+ font-size: 0.75rem;
372
+ color: var(--p-surface-400, #94a3b8);
373
+ overflow: hidden;
374
+ text-overflow: ellipsis;
375
+ white-space: nowrap;
376
+ }
377
+
378
+ .main-content {
379
+ flex: 1;
380
+ margin-left: var(--fad-sidebar-width, 15rem);
381
+ background: var(--p-surface-50, #f8fafc);
382
+ min-height: 100vh;
383
+ display: flex;
384
+ flex-direction: column;
385
+ }
386
+
387
+ .page-content {
388
+ flex: 1;
389
+ padding: 1.5rem;
390
+ overflow-y: auto;
391
+ }
392
+
393
+ /* Dark mode support */
394
+ .dark-mode .sidebar {
395
+ background: var(--p-surface-900);
396
+ }
397
+
398
+ .dark-mode .main-content {
399
+ background: var(--p-surface-900);
400
+ }
401
+
402
+ .powered-by {
403
+ padding: 0.75rem 1rem;
404
+ border-top: 1px solid var(--p-surface-700, #334155);
405
+ display: flex;
406
+ align-items: center;
407
+ gap: 0.5rem;
408
+ opacity: 0.6;
409
+ transition: opacity 0.15s;
410
+ }
411
+
412
+ .powered-by:hover {
413
+ opacity: 1;
414
+ }
415
+
416
+ .powered-by-logo {
417
+ width: 1.25rem;
418
+ height: 1.25rem;
419
+ }
420
+
421
+ .powered-by-text {
422
+ font-size: 0.625rem;
423
+ color: var(--p-surface-400, #94a3b8);
424
+ letter-spacing: 0.02em;
425
+ }
426
+
427
+ .powered-by-text strong {
428
+ color: var(--p-surface-300, #cbd5e1);
429
+ }
430
+ </style>
@@ -0,0 +1,106 @@
1
+ <script setup>
2
+ /**
3
+ * Breadcrumb - Navigation breadcrumb component
4
+ *
5
+ * Props:
6
+ * - items: Array of { label, to?, icon? }
7
+ *
8
+ * Last item is always rendered as current page (no link)
9
+ */
10
+ import { RouterLink } from 'vue-router'
11
+
12
+ defineProps({
13
+ items: {
14
+ type: Array,
15
+ default: () => []
16
+ }
17
+ })
18
+ </script>
19
+
20
+ <template>
21
+ <nav class="breadcrumb" aria-label="Breadcrumb">
22
+ <ol class="breadcrumb-list">
23
+ <li
24
+ v-for="(item, index) in items"
25
+ :key="index"
26
+ class="breadcrumb-item"
27
+ :class="{ 'is-current': index === items.length - 1 }"
28
+ >
29
+ <template v-if="index > 0">
30
+ <i class="pi pi-chevron-right breadcrumb-separator" ></i>
31
+ </template>
32
+
33
+ <RouterLink
34
+ v-if="item.to && index < items.length - 1"
35
+ :to="item.to"
36
+ class="breadcrumb-link"
37
+ >
38
+ <i v-if="item.icon" :class="item.icon" ></i>
39
+ <span>{{ item.label }}</span>
40
+ </RouterLink>
41
+
42
+ <span v-else class="breadcrumb-text">
43
+ <i v-if="item.icon" :class="item.icon" ></i>
44
+ <span>{{ item.label }}</span>
45
+ </span>
46
+ </li>
47
+ </ol>
48
+ </nav>
49
+ </template>
50
+
51
+ <style scoped>
52
+ .breadcrumb {
53
+ margin-bottom: 0.5rem;
54
+ }
55
+
56
+ .breadcrumb-list {
57
+ display: flex;
58
+ align-items: center;
59
+ gap: 0.5rem;
60
+ list-style: none;
61
+ margin: 0;
62
+ padding: 0;
63
+ font-size: 0.875rem;
64
+ }
65
+
66
+ .breadcrumb-item {
67
+ display: flex;
68
+ align-items: center;
69
+ gap: 0.5rem;
70
+ }
71
+
72
+ .breadcrumb-separator {
73
+ font-size: 0.75rem;
74
+ color: var(--p-text-muted-color, #888);
75
+ }
76
+
77
+ .breadcrumb-link {
78
+ display: flex;
79
+ align-items: center;
80
+ gap: 0.375rem;
81
+ color: var(--p-primary-color, #3b82f6);
82
+ text-decoration: none;
83
+ transition: color 0.2s;
84
+ }
85
+
86
+ .breadcrumb-link:hover {
87
+ color: var(--p-primary-hover-color, #2563eb);
88
+ text-decoration: underline;
89
+ }
90
+
91
+ .breadcrumb-link i {
92
+ font-size: 0.875rem;
93
+ }
94
+
95
+ .breadcrumb-text {
96
+ display: flex;
97
+ align-items: center;
98
+ gap: 0.375rem;
99
+ color: var(--p-text-muted-color, #888);
100
+ }
101
+
102
+ .breadcrumb-item.is-current .breadcrumb-text {
103
+ color: var(--p-text-color, #333);
104
+ font-weight: 500;
105
+ }
106
+ </style>
@@ -0,0 +1,75 @@
1
+ <script setup>
2
+ /**
3
+ * PageHeader - Reusable page header with breadcrumb, title and actions
4
+ *
5
+ * Props:
6
+ * - title: Page title (required)
7
+ * - breadcrumb: Array of { label, to?, icon? } - optional breadcrumb items
8
+ *
9
+ * Slots:
10
+ * - subtitle: Optional content next to title
11
+ * - actions: Action buttons on the right
12
+ */
13
+ import Breadcrumb from './Breadcrumb.vue'
14
+
15
+ defineProps({
16
+ title: {
17
+ type: String,
18
+ required: true
19
+ },
20
+ subtitle: {
21
+ type: String,
22
+ default: null
23
+ },
24
+ breadcrumb: {
25
+ type: Array,
26
+ default: null
27
+ }
28
+ })
29
+ </script>
30
+
31
+ <template>
32
+ <div class="page-header">
33
+ <div class="page-header-content">
34
+ <Breadcrumb v-if="breadcrumb?.length" :items="breadcrumb" />
35
+ <div class="page-header-row">
36
+ <div class="page-header-left">
37
+ <div>
38
+ <h1 class="page-title">{{ title }}</h1>
39
+ <p v-if="subtitle" class="page-subtitle">{{ subtitle }}</p>
40
+ </div>
41
+ <slot name="subtitle" ></slot>
42
+ </div>
43
+ <div class="header-actions">
44
+ <slot name="actions" ></slot>
45
+ </div>
46
+ </div>
47
+ </div>
48
+ </div>
49
+ </template>
50
+
51
+ <style scoped>
52
+ .page-header-content {
53
+ display: flex;
54
+ flex-direction: column;
55
+ gap: 0.25rem;
56
+ }
57
+
58
+ .page-header-row {
59
+ display: flex;
60
+ justify-content: space-between;
61
+ align-items: center;
62
+ }
63
+
64
+ .page-header-left {
65
+ display: flex;
66
+ align-items: center;
67
+ gap: 1rem;
68
+ }
69
+
70
+ .page-subtitle {
71
+ margin: 0.25rem 0 0 0;
72
+ font-size: 0.875rem;
73
+ color: var(--p-text-secondary);
74
+ }
75
+ </style>
@@ -0,0 +1,93 @@
1
+ <script setup>
2
+ /**
3
+ * PageLayout - Base layout for dashboard pages
4
+ *
5
+ * Provides:
6
+ * - Auto-generated breadcrumb
7
+ * - PageHeader with title and actions
8
+ * - CardsGrid zone (optional)
9
+ * - Main content slot
10
+ *
11
+ * Use this for custom pages that don't follow the standard list pattern.
12
+ * For CRUD list pages, use ListPage instead.
13
+ *
14
+ * Note: UnsavedChangesDialog is rendered automatically by AppLayout
15
+ * via provide/inject from useBareForm/useForm.
16
+ */
17
+ import { toRef } from 'vue'
18
+ import PageHeader from './PageHeader.vue'
19
+ import CardsGrid from '../display/CardsGrid.vue'
20
+ import Button from 'primevue/button'
21
+ import { useBreadcrumb } from '../../composables/useBreadcrumb'
22
+
23
+ const props = defineProps({
24
+ // Header
25
+ title: { type: String, required: true },
26
+ subtitle: { type: String, default: null },
27
+ headerActions: { type: Array, default: () => [] },
28
+ breadcrumb: { type: Array, default: null }, // Override auto breadcrumb
29
+
30
+ // Entity data for dynamic breadcrumb labels
31
+ entity: { type: Object, default: null },
32
+ manager: { type: Object, default: null }, // EntityManager - provides labelField automatically
33
+
34
+ // Cards
35
+ cards: { type: Array, default: () => [] },
36
+ cardsColumns: { type: [Number, String], default: 'auto' }
37
+ })
38
+
39
+ // Auto-generate breadcrumb from route, using entity data for labels
40
+ // getEntityLabel from manager handles both string field and callback
41
+ const { breadcrumbItems } = useBreadcrumb({
42
+ entity: toRef(() => props.entity), // Make reactive
43
+ getEntityLabel: props.manager
44
+ ? (e) => props.manager.getEntityLabel(e)
45
+ : (e) => e?.name || null // Fallback if no manager
46
+ })
47
+
48
+ function resolveLabel(label) {
49
+ return typeof label === 'function' ? label() : label
50
+ }
51
+ </script>
52
+
53
+ <template>
54
+ <div>
55
+ <PageHeader :title="title" :breadcrumb="props.breadcrumb || breadcrumbItems">
56
+ <template #subtitle>
57
+ <slot name="subtitle">
58
+ <span v-if="subtitle" class="page-subtitle">{{ subtitle }}</span>
59
+ </slot>
60
+ </template>
61
+ <template #actions>
62
+ <slot name="header-actions" ></slot>
63
+ <Button
64
+ v-for="action in headerActions"
65
+ :key="action.name"
66
+ :label="resolveLabel(action.label)"
67
+ :icon="action.icon"
68
+ :severity="action.severity"
69
+ :loading="action.isLoading"
70
+ @click="action.onClick"
71
+ />
72
+ </template>
73
+ </PageHeader>
74
+
75
+ <!-- Cards Zone -->
76
+ <CardsGrid v-if="cards.length > 0" :cards="cards" :columns="cardsColumns">
77
+ <template v-for="(_, slotName) in $slots" :key="slotName" #[slotName]="slotProps">
78
+ <slot :name="slotName" v-bind="slotProps" ></slot>
79
+ </template>
80
+ </CardsGrid>
81
+
82
+ <!-- Main Content -->
83
+ <slot ></slot>
84
+ </div>
85
+ </template>
86
+
87
+ <style scoped>
88
+ .page-subtitle {
89
+ color: var(--p-surface-500);
90
+ font-size: 0.9rem;
91
+ margin-left: 1rem;
92
+ }
93
+ </style>