@omnitend/dashboard-for-laravel 0.4.7

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 (149) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +397 -0
  3. package/dist/components/base/DAccordion.vue.d.ts +12 -0
  4. package/dist/components/base/DAccordionItem.vue.d.ts +12 -0
  5. package/dist/components/base/DAlert.vue.d.ts +12 -0
  6. package/dist/components/base/DAvatar.vue.d.ts +12 -0
  7. package/dist/components/base/DBadge.vue.d.ts +12 -0
  8. package/dist/components/base/DBreadcrumb.vue.d.ts +12 -0
  9. package/dist/components/base/DButton.vue.d.ts +29 -0
  10. package/dist/components/base/DButtonGroup.vue.d.ts +12 -0
  11. package/dist/components/base/DButtonToolbar.vue.d.ts +12 -0
  12. package/dist/components/base/DCard.vue.d.ts +12 -0
  13. package/dist/components/base/DCarousel.vue.d.ts +12 -0
  14. package/dist/components/base/DCarouselSlide.vue.d.ts +12 -0
  15. package/dist/components/base/DCol.vue.d.ts +12 -0
  16. package/dist/components/base/DCollapse.vue.d.ts +12 -0
  17. package/dist/components/base/DContainer.vue.d.ts +12 -0
  18. package/dist/components/base/DDropdown.vue.d.ts +12 -0
  19. package/dist/components/base/DDropdownDivider.vue.d.ts +2 -0
  20. package/dist/components/base/DDropdownItem.vue.d.ts +12 -0
  21. package/dist/components/base/DForm.vue.d.ts +12 -0
  22. package/dist/components/base/DFormCheckbox.vue.d.ts +12 -0
  23. package/dist/components/base/DFormGroup.vue.d.ts +12 -0
  24. package/dist/components/base/DFormInput.vue.d.ts +2 -0
  25. package/dist/components/base/DFormInvalidFeedback.vue.d.ts +12 -0
  26. package/dist/components/base/DFormRadio.vue.d.ts +12 -0
  27. package/dist/components/base/DFormSelect.vue.d.ts +12 -0
  28. package/dist/components/base/DFormSpinbutton.vue.d.ts +12 -0
  29. package/dist/components/base/DFormTags.vue.d.ts +12 -0
  30. package/dist/components/base/DFormText.vue.d.ts +12 -0
  31. package/dist/components/base/DFormTextarea.vue.d.ts +2 -0
  32. package/dist/components/base/DImage.vue.d.ts +12 -0
  33. package/dist/components/base/DInputGroup.vue.d.ts +12 -0
  34. package/dist/components/base/DLink.vue.d.ts +12 -0
  35. package/dist/components/base/DListGroup.vue.d.ts +12 -0
  36. package/dist/components/base/DListGroupItem.vue.d.ts +12 -0
  37. package/dist/components/base/DModal.vue.d.ts +12 -0
  38. package/dist/components/base/DNav.vue.d.ts +12 -0
  39. package/dist/components/base/DNavItem.vue.d.ts +12 -0
  40. package/dist/components/base/DNavbar.vue.d.ts +12 -0
  41. package/dist/components/base/DNavbarBrand.vue.d.ts +12 -0
  42. package/dist/components/base/DNavbarNav.vue.d.ts +12 -0
  43. package/dist/components/base/DNavbarToggle.vue.d.ts +12 -0
  44. package/dist/components/base/DOffcanvas.vue.d.ts +12 -0
  45. package/dist/components/base/DOverlay.vue.d.ts +12 -0
  46. package/dist/components/base/DPagination.vue.d.ts +2 -0
  47. package/dist/components/base/DPlaceholder.vue.d.ts +12 -0
  48. package/dist/components/base/DPopover.vue.d.ts +12 -0
  49. package/dist/components/base/DProgress.vue.d.ts +12 -0
  50. package/dist/components/base/DRow.vue.d.ts +12 -0
  51. package/dist/components/base/DSpinner.vue.d.ts +2 -0
  52. package/dist/components/base/DTab.vue.d.ts +12 -0
  53. package/dist/components/base/DTable.vue.d.ts +26 -0
  54. package/dist/components/base/DTabs.vue.d.ts +12 -0
  55. package/dist/components/base/DToast.vue.d.ts +12 -0
  56. package/dist/components/base/DToaster.vue.d.ts +12 -0
  57. package/dist/components/base/DTooltip.vue.d.ts +12 -0
  58. package/dist/components/extended/DXBasicForm.vue.d.ts +39 -0
  59. package/dist/components/extended/DXDashboard.vue.d.ts +52 -0
  60. package/dist/components/extended/DXDashboardNavbar.vue.d.ts +53 -0
  61. package/dist/components/extended/DXDashboardSidebar.vue.d.ts +37 -0
  62. package/dist/components/extended/DXForm.vue.d.ts +31 -0
  63. package/dist/components/extended/DXTable.vue.d.ts +190 -0
  64. package/dist/composables/defineForm.d.ts +35 -0
  65. package/dist/composables/useForm.d.ts +46 -0
  66. package/dist/composables/useToast.d.ts +1 -0
  67. package/dist/dashboard-for-laravel.js +17748 -0
  68. package/dist/dashboard-for-laravel.js.map +1 -0
  69. package/dist/dashboard-for-laravel.umd.cjs +11 -0
  70. package/dist/dashboard-for-laravel.umd.cjs.map +1 -0
  71. package/dist/index.d.ts +73 -0
  72. package/dist/style.css +5 -0
  73. package/dist/types/index.d.ts +37 -0
  74. package/dist/types/navigation.d.ts +17 -0
  75. package/dist/utils/api.d.ts +30 -0
  76. package/docs/public/api-reference.json +1932 -0
  77. package/docs/public/docs-map.md +85 -0
  78. package/docs/public/llms.txt +110 -0
  79. package/package.json +116 -0
  80. package/resources/css/theme.scss +219 -0
  81. package/resources/js/components/base/DAccordion.vue +21 -0
  82. package/resources/js/components/base/DAccordionItem.vue +14 -0
  83. package/resources/js/components/base/DAlert.vue +14 -0
  84. package/resources/js/components/base/DAvatar.vue +21 -0
  85. package/resources/js/components/base/DBadge.vue +14 -0
  86. package/resources/js/components/base/DBreadcrumb.vue +21 -0
  87. package/resources/js/components/base/DButton.vue +58 -0
  88. package/resources/js/components/base/DButtonGroup.vue +21 -0
  89. package/resources/js/components/base/DButtonToolbar.vue +21 -0
  90. package/resources/js/components/base/DCard.vue +35 -0
  91. package/resources/js/components/base/DCarousel.vue +21 -0
  92. package/resources/js/components/base/DCarouselSlide.vue +14 -0
  93. package/resources/js/components/base/DCol.vue +14 -0
  94. package/resources/js/components/base/DCollapse.vue +34 -0
  95. package/resources/js/components/base/DContainer.vue +14 -0
  96. package/resources/js/components/base/DDropdown.vue +16 -0
  97. package/resources/js/components/base/DDropdownDivider.vue +7 -0
  98. package/resources/js/components/base/DDropdownItem.vue +14 -0
  99. package/resources/js/components/base/DForm.vue +21 -0
  100. package/resources/js/components/base/DFormCheckbox.vue +14 -0
  101. package/resources/js/components/base/DFormGroup.vue +11 -0
  102. package/resources/js/components/base/DFormInput.vue +7 -0
  103. package/resources/js/components/base/DFormInvalidFeedback.vue +16 -0
  104. package/resources/js/components/base/DFormRadio.vue +21 -0
  105. package/resources/js/components/base/DFormSelect.vue +14 -0
  106. package/resources/js/components/base/DFormSpinbutton.vue +21 -0
  107. package/resources/js/components/base/DFormTags.vue +21 -0
  108. package/resources/js/components/base/DFormText.vue +16 -0
  109. package/resources/js/components/base/DFormTextarea.vue +7 -0
  110. package/resources/js/components/base/DImage.vue +21 -0
  111. package/resources/js/components/base/DInputGroup.vue +21 -0
  112. package/resources/js/components/base/DLink.vue +21 -0
  113. package/resources/js/components/base/DListGroup.vue +21 -0
  114. package/resources/js/components/base/DListGroupItem.vue +14 -0
  115. package/resources/js/components/base/DModal.vue +11 -0
  116. package/resources/js/components/base/DNav.vue +14 -0
  117. package/resources/js/components/base/DNavItem.vue +14 -0
  118. package/resources/js/components/base/DNavbar.vue +21 -0
  119. package/resources/js/components/base/DNavbarBrand.vue +14 -0
  120. package/resources/js/components/base/DNavbarNav.vue +14 -0
  121. package/resources/js/components/base/DNavbarToggle.vue +14 -0
  122. package/resources/js/components/base/DOffcanvas.vue +11 -0
  123. package/resources/js/components/base/DOverlay.vue +21 -0
  124. package/resources/js/components/base/DPagination.vue +7 -0
  125. package/resources/js/components/base/DPlaceholder.vue +21 -0
  126. package/resources/js/components/base/DPopover.vue +21 -0
  127. package/resources/js/components/base/DProgress.vue +21 -0
  128. package/resources/js/components/base/DRow.vue +14 -0
  129. package/resources/js/components/base/DSpinner.vue +7 -0
  130. package/resources/js/components/base/DTab.vue +14 -0
  131. package/resources/js/components/base/DTable.vue +62 -0
  132. package/resources/js/components/base/DTabs.vue +21 -0
  133. package/resources/js/components/base/DToast.vue +16 -0
  134. package/resources/js/components/base/DToaster.vue +16 -0
  135. package/resources/js/components/base/DTooltip.vue +21 -0
  136. package/resources/js/components/extended/DXBasicForm.vue +177 -0
  137. package/resources/js/components/extended/DXDashboard.vue +208 -0
  138. package/resources/js/components/extended/DXDashboardNavbar.vue +112 -0
  139. package/resources/js/components/extended/DXDashboardSidebar.vue +233 -0
  140. package/resources/js/components/extended/DXForm.vue +44 -0
  141. package/resources/js/components/extended/DXTable.vue +1345 -0
  142. package/resources/js/composables/defineForm.ts +78 -0
  143. package/resources/js/composables/useForm.ts +272 -0
  144. package/resources/js/composables/useToast.ts +1 -0
  145. package/resources/js/index.ts +118 -0
  146. package/resources/js/types/index.ts +61 -0
  147. package/resources/js/types/navigation.ts +19 -0
  148. package/resources/js/utils/api.ts +182 -0
  149. package/scripts/mcp-server.mjs +359 -0
@@ -0,0 +1,1345 @@
1
+ <template>
2
+ <DContainer :fluid="fluid" :class="containerClass">
3
+ <DRow class="justify-content-center">
4
+ <DCol :md="columnSize">
5
+ <DCard>
6
+ <template v-if="title || $slots.header" #header>
7
+ <slot name="header">
8
+ <div class="d-flex justify-content-between align-items-center">
9
+ <h4 class="mb-0">{{ title }}</h4>
10
+ </div>
11
+ </slot>
12
+ </template>
13
+
14
+ <div v-if="effectiveBusy && !isProviderMode" class="text-center py-5">
15
+ <DSpinner variant="primary" />
16
+ <p class="mt-2">{{ loadingText }}</p>
17
+ </div>
18
+
19
+ <div v-else-if="error || apiError" class="alert alert-danger">
20
+ {{ error || apiError }}
21
+ </div>
22
+
23
+ <!-- Provider Mode: Use BTable's provider pattern -->
24
+ <DTable
25
+ v-else-if="isProviderMode"
26
+ ref="tableRef"
27
+ :provider="effectiveProvider"
28
+ :fields="fields"
29
+ :sort-by="effectiveSortBy"
30
+ :per-page="effectivePerPage"
31
+ :current-page="apiCurrentPage"
32
+ :multisort="false"
33
+ :no-sortable-icon="true"
34
+ :striped="striped"
35
+ :hover="hover"
36
+ :responsive="responsive"
37
+ :busy="busy"
38
+ @update:sort-by="handleSortChange"
39
+ @update:current-page="apiCurrentPage = $event"
40
+ @update:busy="handleBusyChange"
41
+ @row-clicked="handleRowClick"
42
+ >
43
+ <!-- Inline Filter Row -->
44
+ <template v-if="hasFilters" #thead-top>
45
+ <tr class="filter-row">
46
+ <th v-for="field in fields" :key="`filter-${field.key}`" class="p-2">
47
+ <!-- Text Filter -->
48
+ <DFormInput
49
+ v-if="field.filter === 'text'"
50
+ :model-value="effectiveFilters[field.key] || ''"
51
+ :placeholder="field.filterPlaceholder || `Search ${field.label || field.key}...`"
52
+ size="sm"
53
+ @update:model-value="handleFilterChange(field.key, $event as string)"
54
+ />
55
+
56
+ <!-- Select Filter -->
57
+ <DFormSelect
58
+ v-else-if="field.filter === 'select'"
59
+ :model-value="effectiveFilters[field.key] || ''"
60
+ :options="[{ value: '', text: 'All' }, ...getFieldFilterOptions(field)]"
61
+ size="sm"
62
+ @update:model-value="handleFilterChange(field.key, $event as string)"
63
+ />
64
+
65
+ <!-- Number Filter -->
66
+ <DFormInput
67
+ v-else-if="field.filter === 'number'"
68
+ :model-value="effectiveFilters[field.key] || ''"
69
+ :placeholder="field.filterPlaceholder || `Filter ${field.label || field.key}...`"
70
+ type="number"
71
+ size="sm"
72
+ @update:model-value="handleFilterChange(field.key, $event as string)"
73
+ />
74
+
75
+ <!-- Date Filter -->
76
+ <DFormInput
77
+ v-else-if="field.filter === 'date'"
78
+ :model-value="effectiveFilters[field.key] || ''"
79
+ type="date"
80
+ size="sm"
81
+ @update:model-value="handleFilterChange(field.key, $event as string)"
82
+ />
83
+
84
+ <!-- No filter for this column -->
85
+ <div v-else></div>
86
+ </th>
87
+ </tr>
88
+ </template>
89
+
90
+ <!-- Custom headers for all fields -->
91
+ <template v-for="field in fields" :key="`head-${field.key}`" #[`head(${field.key})`]="{ label }">
92
+ <div class="d-flex align-items-center justify-content-between gap-2">
93
+ <div class="flex-grow-1">
94
+ <div class="fw-semibold">{{ label || field.key }}</div>
95
+ <small v-if="field.hint" class="text-muted d-block" style="font-weight: normal;">{{ field.hint }}</small>
96
+ </div>
97
+ <div v-if="field.sortable" class="sort-indicator text-muted flex-shrink-0" style="font-size: 0.75rem; line-height: 1.1; display: flex; flex-direction: column; align-items: center;">
98
+ <span :style="{ opacity: getFieldSortState(field.key) === 'asc' ? 1 : 0.3 }">▲</span>
99
+ <span :style="{ opacity: getFieldSortState(field.key) === 'desc' ? 1 : 0.3 }">▼</span>
100
+ </div>
101
+ </div>
102
+ </template>
103
+
104
+ <!-- Pass through all cell slots -->
105
+ <template
106
+ v-for="(_, name) in $slots"
107
+ #[name]="slotProps"
108
+ >
109
+ <slot
110
+ v-if="typeof name === 'string' && name.startsWith('cell')"
111
+ :name="name"
112
+ v-bind="slotProps"
113
+ />
114
+ </template>
115
+ </DTable>
116
+
117
+ <!-- Inertia Mode: Use items prop -->
118
+ <DTable
119
+ v-else
120
+ :items="items"
121
+ :fields="fields"
122
+ :sort-by="effectiveSortBy"
123
+ :multisort="false"
124
+ :no-local-sorting="true"
125
+ :no-sortable-icon="true"
126
+ :striped="striped"
127
+ :hover="hover"
128
+ :responsive="responsive"
129
+ :busy="effectiveBusy"
130
+ @update:sort-by="handleSortChange"
131
+ @row-clicked="handleRowClick"
132
+ >
133
+ <!-- Inline Filter Row -->
134
+ <template v-if="hasFilters" #thead-top>
135
+ <tr class="filter-row">
136
+ <th v-for="field in fields" :key="`filter-${field.key}`" class="p-2">
137
+ <!-- Text Filter -->
138
+ <DFormInput
139
+ v-if="field.filter === 'text'"
140
+ :model-value="effectiveFilters[field.key] || ''"
141
+ :placeholder="field.filterPlaceholder || `Search ${field.label || field.key}...`"
142
+ size="sm"
143
+ @update:model-value="handleFilterChange(field.key, $event as string)"
144
+ />
145
+
146
+ <!-- Select Filter -->
147
+ <DFormSelect
148
+ v-else-if="field.filter === 'select'"
149
+ :model-value="effectiveFilters[field.key] || ''"
150
+ :options="[{ value: '', text: 'All' }, ...getFieldFilterOptions(field)]"
151
+ size="sm"
152
+ @update:model-value="handleFilterChange(field.key, $event as string)"
153
+ />
154
+
155
+ <!-- Number Filter -->
156
+ <DFormInput
157
+ v-else-if="field.filter === 'number'"
158
+ :model-value="effectiveFilters[field.key] || ''"
159
+ :placeholder="field.filterPlaceholder || `Filter ${field.label || field.key}...`"
160
+ type="number"
161
+ size="sm"
162
+ @update:model-value="handleFilterChange(field.key, $event as string)"
163
+ />
164
+
165
+ <!-- Date Filter -->
166
+ <DFormInput
167
+ v-else-if="field.filter === 'date'"
168
+ :model-value="effectiveFilters[field.key] || ''"
169
+ type="date"
170
+ size="sm"
171
+ @update:model-value="handleFilterChange(field.key, $event as string)"
172
+ />
173
+
174
+ <!-- No filter for this column -->
175
+ <div v-else></div>
176
+ </th>
177
+ </tr>
178
+ </template>
179
+
180
+ <!-- Custom headers for all fields -->
181
+ <template v-for="field in fields" :key="`head-${field.key}`" #[`head(${field.key})`]="{ label }">
182
+ <div class="d-flex align-items-center justify-content-between gap-2">
183
+ <div class="flex-grow-1">
184
+ <div class="fw-semibold">{{ label || field.key }}</div>
185
+ <small v-if="field.hint" class="text-muted d-block" style="font-weight: normal;">{{ field.hint }}</small>
186
+ </div>
187
+ <div v-if="field.sortable" class="sort-indicator text-muted flex-shrink-0" style="font-size: 0.75rem; line-height: 1.1; display: flex; flex-direction: column; align-items: center;">
188
+ <span :style="{ opacity: getFieldSortState(field.key) === 'asc' ? 1 : 0.3 }">▲</span>
189
+ <span :style="{ opacity: getFieldSortState(field.key) === 'desc' ? 1 : 0.3 }">▼</span>
190
+ </div>
191
+ </div>
192
+ </template>
193
+
194
+ <!-- Pass through all cell slots -->
195
+ <template
196
+ v-for="(_, name) in $slots"
197
+ #[name]="slotProps"
198
+ >
199
+ <slot
200
+ v-if="typeof name === 'string' && name.startsWith('cell')"
201
+ :name="name"
202
+ v-bind="slotProps"
203
+ />
204
+ </template>
205
+ </DTable>
206
+
207
+ <!-- Pagination and Controls (Inertia mode) -->
208
+ <div v-if="isInertiaMode && pagination" class="mt-3">
209
+ <!-- Top row: Pagination and Per-page selector -->
210
+ <div class="d-flex justify-content-between align-items-center mb-2">
211
+ <!-- Pagination controls (only when multiple pages) -->
212
+ <DPagination
213
+ v-if="showPagination && pagination.total > pagination.per_page"
214
+ :model-value="pagination.current_page"
215
+ :total-rows="pagination.total"
216
+ :per-page="pagination.per_page"
217
+ size="sm"
218
+ @update:model-value="handlePageChange"
219
+ />
220
+ <div v-else></div>
221
+
222
+ <!-- Per-page selector -->
223
+ <div v-if="shouldShowPerPageSelector" class="d-flex align-items-center gap-2">
224
+ <label for="perPageSelect" class="mb-0 small text-muted">Per page</label>
225
+ <DFormSelect
226
+ id="perPageSelect"
227
+ :model-value="effectivePerPage"
228
+ :options="perPageOptions.map(n => ({ value: n, text: n.toString() }))"
229
+ size="sm"
230
+ style="width: 85px;"
231
+ @update:model-value="handlePerPageChange"
232
+ />
233
+ </div>
234
+ </div>
235
+
236
+ <!-- Bottom row: Info text -->
237
+ <div class="small text-muted">
238
+ <div>
239
+ <template v-if="pagination.total > pagination.per_page">
240
+ {{ pagination.from }} to {{ pagination.to }} out of {{ pagination.total }} {{ pagination.total === 1 ? singularItemName : pluralItemName }}.
241
+ </template>
242
+ <template v-else-if="pagination.total === 1">
243
+ {{ pagination.total }} {{ singularItemName }}.
244
+ </template>
245
+ <template v-else>
246
+ {{ pagination.total }} {{ pluralItemName }}.
247
+ </template>
248
+ </div>
249
+ <div v-if="hasActiveFilters && pagination.total_unfiltered">
250
+ <small>Filtered from {{ pagination.total_unfiltered }} {{ pagination.total_unfiltered === 1 ? singularItemName : pluralItemName }}.</small>
251
+ </div>
252
+ </div>
253
+ </div>
254
+
255
+ <!-- Pagination and Controls (API mode) -->
256
+ <div v-if="isProviderMode && apiPaginationMeta" class="mt-3">
257
+ <!-- Top row: Pagination and Per-page selector -->
258
+ <div class="d-flex justify-content-between align-items-center mb-2">
259
+ <!-- Pagination controls (only when multiple pages) -->
260
+ <DPagination
261
+ v-if="showPagination && apiPaginationMeta.total > apiPaginationMeta.per_page"
262
+ :model-value="apiPaginationMeta.current_page"
263
+ :total-rows="apiPaginationMeta.total"
264
+ :per-page="apiPaginationMeta.per_page"
265
+ size="sm"
266
+ @update:model-value="handleApiPageChange"
267
+ />
268
+ <div v-else></div>
269
+
270
+ <!-- Per-page selector -->
271
+ <div v-if="shouldShowPerPageSelector" class="d-flex align-items-center gap-2">
272
+ <label for="perPageSelectApi" class="mb-0 small text-muted">Per page</label>
273
+ <DFormSelect
274
+ id="perPageSelectApi"
275
+ :model-value="effectivePerPage"
276
+ :options="perPageOptions.map(n => ({ value: n, text: n.toString() }))"
277
+ size="sm"
278
+ style="width: 85px;"
279
+ @update:model-value="handlePerPageChange"
280
+ />
281
+ </div>
282
+ </div>
283
+
284
+ <!-- Bottom row: Info text -->
285
+ <div class="small text-muted">
286
+ <div>
287
+ <template v-if="apiPaginationMeta.total > apiPaginationMeta.per_page">
288
+ {{ apiPaginationMeta.from }} to {{ apiPaginationMeta.to }} out of {{ apiPaginationMeta.total }} {{ apiPaginationMeta.total === 1 ? singularItemName : pluralItemName }}.
289
+ </template>
290
+ <template v-else-if="apiPaginationMeta.total === 1">
291
+ {{ apiPaginationMeta.total }} {{ singularItemName }}.
292
+ </template>
293
+ <template v-else>
294
+ {{ apiPaginationMeta.total }} {{ pluralItemName }}.
295
+ </template>
296
+ </div>
297
+ <div v-if="hasActiveFilters && apiPaginationMeta.total_unfiltered">
298
+ <small>Filtered from {{ apiPaginationMeta.total_unfiltered }} {{ apiPaginationMeta.total_unfiltered === 1 ? singularItemName : pluralItemName }}.</small>
299
+ </div>
300
+ </div>
301
+ </div>
302
+ </DCard>
303
+ </DCol>
304
+ </DRow>
305
+
306
+ <!-- Edit Modal (if editFields provided) -->
307
+ <DModal
308
+ v-if="editFields && editFields.length > 0"
309
+ v-model="showEditModal"
310
+ :title="computedModalTitle"
311
+ :size="editModalSize"
312
+ >
313
+ <!-- Tabbed view (if editTabs provided) -->
314
+ <template v-if="editTabs && editTabs.length > 0 && editForm">
315
+ <DTabs v-model="activeTabIndex">
316
+ <DTab
317
+ v-for="(tab, index) in visibleTabs"
318
+ :key="tab.key"
319
+ :title="tab.label || tab.key"
320
+ :lazy="tab.lazy"
321
+ :active="index === 0"
322
+ >
323
+ <!-- Custom tab content slot -->
324
+ <slot
325
+ v-if="$slots[`tab-content(${tab.key})`]"
326
+ :name="`tab-content(${tab.key})`"
327
+ :item="selectedItem"
328
+ :tab="tab"
329
+ />
330
+
331
+ <!-- Default: render fields for this tab -->
332
+ <div v-else class="p-3">
333
+ <!-- Before slot -->
334
+ <slot :name="`tab-before(${tab.key})`" :item="selectedItem" :tab="tab" />
335
+
336
+ <!-- Form fields for this tab -->
337
+ <template v-for="fieldKey in tab.fieldKeys" :key="fieldKey">
338
+ <div v-if="getField(fieldKey).span" class="mb-3">
339
+ <!-- Full-width span field -->
340
+ <slot
341
+ :name="`edit-span(${fieldKey})`"
342
+ :item="selectedItem"
343
+ :value="editForm.data[fieldKey]"
344
+ :update="(v: any) => editForm.data[fieldKey] = v"
345
+ :close="handleEditCancel"
346
+ />
347
+ </div>
348
+ <!-- Checkbox (no label wrapper needed) -->
349
+ <div v-else-if="getField(fieldKey).type === 'checkbox'" class="mb-3">
350
+ <!-- Custom value slot -->
351
+ <slot
352
+ v-if="$slots[`edit-value(${fieldKey})`]"
353
+ :name="`edit-value(${fieldKey})`"
354
+ :item="selectedItem"
355
+ :value="editForm.data[fieldKey]"
356
+ :update="(v: any) => editForm.data[fieldKey] = v"
357
+ :field="getField(fieldKey)"
358
+ />
359
+ <DFormCheckbox
360
+ v-else
361
+ v-model="editForm.data[fieldKey]"
362
+ >
363
+ {{ getField(fieldKey).label || fieldKey }}
364
+ </DFormCheckbox>
365
+ </div>
366
+ <!-- Other field types with label -->
367
+ <DFormGroup
368
+ v-else
369
+ :label="getField(fieldKey).label || fieldKey"
370
+ class="mb-3"
371
+ >
372
+ <!-- Custom value slot -->
373
+ <slot
374
+ v-if="$slots[`edit-value(${fieldKey})`]"
375
+ :name="`edit-value(${fieldKey})`"
376
+ :item="selectedItem"
377
+ :value="editForm.data[fieldKey]"
378
+ :update="(v: any) => editForm.data[fieldKey] = v"
379
+ :field="getField(fieldKey)"
380
+ />
381
+ <DFormTextarea
382
+ v-else-if="getField(fieldKey).type === 'textarea'"
383
+ v-model="editForm.data[fieldKey]"
384
+ :required="getField(fieldKey).required"
385
+ :rows="getField(fieldKey).rows || 3"
386
+ />
387
+ <DFormInput
388
+ v-else
389
+ v-model="editForm.data[fieldKey]"
390
+ :type="getField(fieldKey).type || 'text'"
391
+ :required="getField(fieldKey).required"
392
+ />
393
+ </DFormGroup>
394
+ </template>
395
+
396
+ <!-- After slot -->
397
+ <slot :name="`tab-after(${tab.key})`" :item="selectedItem" :tab="tab" />
398
+ </div>
399
+ </DTab>
400
+ </DTabs>
401
+ </template>
402
+
403
+ <!-- Fallback: no tabs, render flat form (current behavior) -->
404
+ <DXBasicForm
405
+ v-else-if="editForm"
406
+ :form="editForm"
407
+ :fields="editFields"
408
+ :show-submit="false"
409
+ @submit="handleEditSave"
410
+ />
411
+
412
+ <template #footer>
413
+ <div class="d-flex justify-content-between w-100">
414
+ <div>
415
+ <DButton
416
+ v-if="deleteUrl"
417
+ variant="danger"
418
+ :disabled="editForm?.processing"
419
+ @click="handleDelete"
420
+ >
421
+ {{ editForm?.processing ? 'Deleting...' : 'Delete' }}
422
+ </DButton>
423
+ </div>
424
+ <div class="d-flex gap-2">
425
+ <DButton variant="secondary" @click="handleEditCancel">
426
+ Cancel
427
+ </DButton>
428
+ <DButton
429
+ variant="primary"
430
+ :disabled="editForm?.processing"
431
+ @click="handleEditSave"
432
+ >
433
+ {{ editForm?.processing ? 'Saving...' : 'Save Changes' }}
434
+ </DButton>
435
+ </div>
436
+ </div>
437
+ </template>
438
+ </DModal>
439
+ </DContainer>
440
+ </template>
441
+
442
+ <script setup lang="ts" generic="T = any">
443
+ import { computed, ref, watch } from "vue";
444
+ import { router } from "@inertiajs/vue3";
445
+ import axios from "axios";
446
+ import pluralize from "pluralize";
447
+ import { useToast } from "../../composables/useToast";
448
+ import DContainer from "../base/DContainer.vue";
449
+ import DRow from "../base/DRow.vue";
450
+ import DCol from "../base/DCol.vue";
451
+ import DCard from "../base/DCard.vue";
452
+ import DSpinner from "../base/DSpinner.vue";
453
+ import DTable from "../base/DTable.vue";
454
+ import DPagination from "../base/DPagination.vue";
455
+ import DFormInput from "../base/DFormInput.vue";
456
+ import DFormSelect from "../base/DFormSelect.vue";
457
+ import DModal from "../base/DModal.vue";
458
+ import DButton from "../base/DButton.vue";
459
+ import DTabs from "../base/DTabs.vue";
460
+ import DTab from "../base/DTab.vue";
461
+ import DFormGroup from "../base/DFormGroup.vue";
462
+ import DFormTextarea from "../base/DFormTextarea.vue";
463
+ import DFormCheckbox from "../base/DFormCheckbox.vue";
464
+ import DXBasicForm from "./DXBasicForm.vue";
465
+ export type FilterType = 'text' | 'select' | 'number' | 'date' | false;
466
+
467
+ export interface FilterOption {
468
+ value: string;
469
+ text: string;
470
+ }
471
+
472
+ export interface TableField {
473
+ key: string;
474
+ label?: string;
475
+ sortable?: boolean;
476
+ hint?: string;
477
+ filter?: FilterType;
478
+ filterOptions?: FilterOption[];
479
+ filterPlaceholder?: string;
480
+ [key: string]: any;
481
+ }
482
+
483
+ export interface PaginationData {
484
+ current_page: number;
485
+ per_page: number;
486
+ total: number;
487
+ from: number;
488
+ to: number;
489
+ last_page?: number;
490
+ total_unfiltered?: number;
491
+ }
492
+
493
+ export interface BTableSortBy {
494
+ key: string;
495
+ order?: 'asc' | 'desc';
496
+ }
497
+
498
+ export interface BTableProviderContext {
499
+ sortBy?: BTableSortBy[];
500
+ filter?: string;
501
+ currentPage: number;
502
+ perPage: number;
503
+ }
504
+
505
+ export type BTableProvider<T = any> = (
506
+ context: Readonly<BTableProviderContext>
507
+ ) => Promise<T[] | undefined> | T[] | undefined;
508
+
509
+ export interface EditTab {
510
+ /** Unique key for this tab */
511
+ key: string;
512
+
513
+ /** Display label (optional, auto-derived from key if omitted) */
514
+ label?: string;
515
+
516
+ /** Field keys to display in this tab (from editFields) */
517
+ fieldKeys: string[];
518
+
519
+ /** Conditional display (optional) */
520
+ when?: boolean | ((item: any) => boolean);
521
+
522
+ /** Lazy load tab content (optional, default false) */
523
+ lazy?: boolean;
524
+ }
525
+
526
+ export interface Props<TItem = any> {
527
+ /** Table title */
528
+ title?: string;
529
+
530
+ /** Name for item (singular) - automatically pluralized (e.g., "product" → "products") */
531
+ itemName?: string;
532
+
533
+ /** Table data items (Inertia mode) */
534
+ items?: TItem[];
535
+
536
+ /** Provider function for API mode (alternative to items/apiUrl) */
537
+ provider?: BTableProvider<TItem>;
538
+
539
+ /** API endpoint URL for auto-provider mode (alternative to provider function) */
540
+ apiUrl?: string;
541
+
542
+ /** Table field definitions */
543
+ fields: TableField[];
544
+
545
+ /** Sort configuration (v-model support) */
546
+ sortBy?: BTableSortBy[];
547
+
548
+ /** Filter values (v-model support) - key is field key, value is filter string */
549
+ filters?: Record<string, string>;
550
+
551
+ /** Dynamic filter options from server - key is field key, value is array of values */
552
+ filterValues?: Record<string, string[]>;
553
+
554
+ /** Inertia route URL (if provided, handles navigation automatically) */
555
+ inertiaUrl?: string;
556
+
557
+ /** Loading/busy state (v-model support) */
558
+ busy?: boolean;
559
+
560
+ /** Loading state (deprecated, use busy instead) */
561
+ loading?: boolean;
562
+
563
+ /** Loading text */
564
+ loadingText?: string;
565
+
566
+ /** Error message */
567
+ error?: string | null;
568
+
569
+ /** Pagination data (Inertia mode) */
570
+ pagination?: PaginationData;
571
+
572
+ /** Show pagination controls */
573
+ showPagination?: boolean;
574
+
575
+ /** Show per-page selector */
576
+ showPerPageSelector?: boolean;
577
+
578
+ /** Per-page options for selector */
579
+ perPageOptions?: number[];
580
+
581
+ /** Current page (for provider mode) */
582
+ currentPage?: number;
583
+
584
+ /** Items per page (for provider mode, v-model support) */
585
+ perPage?: number;
586
+
587
+ /** Striped rows */
588
+ striped?: boolean;
589
+
590
+ /** Hover effect on rows */
591
+ hover?: boolean;
592
+
593
+ /** Responsive table */
594
+ responsive?: boolean;
595
+
596
+ /** Fluid container */
597
+ fluid?: boolean;
598
+
599
+ /** Container CSS class */
600
+ containerClass?: string;
601
+
602
+ /** Column size (Bootstrap grid) */
603
+ columnSize?: string | number;
604
+
605
+ // Edit Modal Props
606
+ /** Form field definitions for edit modal (if provided, enables edit on row click) */
607
+ editFields?: any[]; // FieldDefinition[] - using any to avoid circular import
608
+
609
+ /** Tab definitions for organizing edit modal content */
610
+ editTabs?: EditTab[];
611
+
612
+ /** Edit modal title (can be function for dynamic titles) */
613
+ editModalTitle?: string | ((item: any) => string);
614
+
615
+ /** Edit modal size */
616
+ editModalSize?: 'sm' | 'md' | 'lg' | 'xl';
617
+
618
+ /** API endpoint pattern for updates (e.g., "/api/products/:id") */
619
+ editUrl?: string;
620
+
621
+ /** API endpoint pattern for deletions (e.g., "/api/products/:id") */
622
+ deleteUrl?: string;
623
+ }
624
+
625
+ const props = withDefaults(defineProps<Props<T>>(), {
626
+ itemName: "item",
627
+ loading: false,
628
+ busy: false,
629
+ loadingText: "Loading...",
630
+ error: null,
631
+ pagination: () => ({
632
+ current_page: 1,
633
+ per_page: 15,
634
+ total: 0,
635
+ from: 0,
636
+ to: 0,
637
+ }),
638
+ showPagination: true,
639
+ showPerPageSelector: true,
640
+ perPageOptions: () => [10, 25, 50, 100],
641
+ currentPage: 1,
642
+ // perPage: 10, // Don't set default - let internalPerPage handle it
643
+ striped: true,
644
+ hover: true,
645
+ responsive: true,
646
+ fluid: false,
647
+ containerClass: "py-5",
648
+ columnSize: "12",
649
+ editModalSize: "lg",
650
+ });
651
+
652
+ const emit = defineEmits<{
653
+ pageChange: [page: number];
654
+ sortChange: [sort: { key: string; order: 'asc' | 'desc' }];
655
+ filterChange: [filters: Record<string, string>];
656
+ perPageChange: [perPage: number];
657
+ rowClicked: [item: T, index: number, event: MouseEvent];
658
+ rowUpdated: [item: T, response: any];
659
+ editError: [item: T, error: any];
660
+ rowDeleted: [item: T, response: any];
661
+ deleteError: [item: T, error: any];
662
+ 'update:sortBy': [sortBy: BTableSortBy[]];
663
+ 'update:filters': [filters: Record<string, string>];
664
+ 'update:perPage': [perPage: number];
665
+ 'update:busy': [busy: boolean];
666
+ }>();
667
+
668
+ // Mode detection
669
+ const isProviderMode = computed(() => !!props.provider || !!props.apiUrl);
670
+ const isInertiaMode = computed(() => !props.provider && !props.apiUrl && !!props.items);
671
+ const hasInertiaUrl = computed(() => !!props.inertiaUrl);
672
+
673
+ // Computed for effective busy state (provider mode uses 'busy', inertia uses 'loading')
674
+ const effectiveBusy = computed(() => isProviderMode.value ? props.busy : props.loading);
675
+
676
+ // Internal sortBy state for auto modes
677
+ const internalSortBy = ref<BTableSortBy[]>([]);
678
+
679
+ // Computed effective sortBy (use external if provided, otherwise internal)
680
+ const effectiveSortBy = computed(() => props.sortBy !== undefined ? props.sortBy : internalSortBy.value);
681
+
682
+ // Internal filters state for auto modes
683
+ const internalFilters = ref<Record<string, string>>({});
684
+
685
+ // Computed effective filters (use external if provided, otherwise internal)
686
+ const effectiveFilters = computed(() => props.filters !== undefined ? props.filters : internalFilters.value);
687
+
688
+ // Computed: check if any field has filtering enabled
689
+ const hasFilters = computed(() => props.fields.some(field => field.filter !== false && field.filter !== undefined));
690
+
691
+ // Computed: check if any filters are currently active
692
+ const hasActiveFilters = computed(() => {
693
+ const filters = effectiveFilters.value;
694
+ return Object.keys(filters).some(key => filters[key] && filters[key].trim() !== '');
695
+ });
696
+
697
+ // API mode pagination metadata (extracted from responses)
698
+ const apiPaginationMeta = ref<PaginationData | null>(null);
699
+
700
+ // API mode filter values (extracted from responses)
701
+ const apiFilterValues = ref<Record<string, string[]>>({});
702
+
703
+ // Computed: Get effective filter options for a field
704
+ const getFieldFilterOptions = (field: TableField): FilterOption[] => {
705
+ // If field has static filterOptions, use those
706
+ if (field.filterOptions && field.filterOptions.length > 0) {
707
+ return field.filterOptions;
708
+ }
709
+
710
+ // Otherwise, check for server-provided values
711
+ const serverValues = props.filterValues?.[field.key] || apiFilterValues.value[field.key];
712
+
713
+ if (serverValues && serverValues.length > 0) {
714
+ // Convert string array to FilterOption array
715
+ return serverValues.map(value => ({ value, text: value }));
716
+ }
717
+
718
+ return [];
719
+ };
720
+
721
+ // LocalStorage key for perPage preference
722
+ const perPageStorageKey = computed(() => {
723
+ const url = props.inertiaUrl || props.apiUrl || 'table';
724
+ return `dxtable-perpage-${url.replace(/\//g, '-')}`;
725
+ });
726
+
727
+ // Load perPage from localStorage or use default
728
+ const getInitialPerPage = (): number => {
729
+ if (typeof window === 'undefined') return props.perPage || 10;
730
+
731
+ try {
732
+ const saved = localStorage.getItem(perPageStorageKey.value);
733
+ if (saved !== null) {
734
+ const parsed = parseInt(saved, 10);
735
+ // Validate it's in the allowed options
736
+ if (props.perPageOptions.includes(parsed)) {
737
+ return parsed;
738
+ }
739
+ }
740
+ } catch (error) {
741
+ console.error('Error loading perPage from localStorage:', error);
742
+ }
743
+
744
+ return props.perPage || 10;
745
+ };
746
+
747
+ // Internal perPage state
748
+ const internalPerPage = ref<number>(getInitialPerPage());
749
+
750
+ // Watch pagination.per_page and sync with internalPerPage (after Inertia navigation)
751
+ watch(() => props.pagination?.per_page, (newPerPage) => {
752
+ if (newPerPage && newPerPage !== internalPerPage.value) {
753
+ internalPerPage.value = newPerPage;
754
+ // Also update localStorage to stay in sync
755
+ if (typeof window !== 'undefined') {
756
+ try {
757
+ localStorage.setItem(perPageStorageKey.value, newPerPage.toString());
758
+ } catch (error) {
759
+ console.error('Error saving perPage from watcher:', error);
760
+ }
761
+ }
762
+ }
763
+ });
764
+
765
+ // Watch for external filter changes (when filters prop is controlled by parent)
766
+ watch(() => props.filters, (newFilters, oldFilters) => {
767
+ // Only trigger refresh if filters prop is being used (not internal state)
768
+ if (props.filters === undefined) return;
769
+
770
+ // Only refresh if filters actually changed
771
+ if (JSON.stringify(newFilters) === JSON.stringify(oldFilters)) return;
772
+
773
+ // Refresh data for the new filters
774
+ if (isProviderMode.value) {
775
+ refresh();
776
+ } else if (hasInertiaUrl.value && isInertiaMode.value && router) {
777
+ // Inertia mode - trigger navigation with new filters
778
+ const currentSort = effectiveSortBy.value[0] || { key: 'created_at', order: 'desc' };
779
+ router.get(
780
+ props.inertiaUrl!,
781
+ {
782
+ page: 1, // Reset to first page when filters change
783
+ sortBy: currentSort.key,
784
+ sortOrder: currentSort.order,
785
+ filters: newFilters,
786
+ perPage: effectivePerPage.value,
787
+ },
788
+ { preserveState: true }
789
+ );
790
+ }
791
+ }, { deep: true });
792
+
793
+ // Watch apiUrl changes to reset filter cache (prevents stale dropdown options)
794
+ watch(() => props.apiUrl, (newUrl, oldUrl) => {
795
+ if (newUrl !== oldUrl && isProviderMode.value) {
796
+ // Clear cached filter values and pagination when API endpoint changes
797
+ apiFilterValues.value = {};
798
+ apiPaginationMeta.value = null;
799
+ apiError.value = null;
800
+ // Next provider call will request fresh filter values
801
+ }
802
+ });
803
+
804
+ // Computed effective perPage (use external if provided, otherwise internal)
805
+ const effectivePerPage = computed(() => {
806
+ // If external perPage prop is provided, use it
807
+ if (props.perPage !== undefined) {
808
+ return props.perPage;
809
+ }
810
+
811
+ // For Inertia mode, prefer pagination.per_page (actual server value)
812
+ if (isInertiaMode.value && props.pagination?.per_page) {
813
+ return props.pagination.per_page;
814
+ }
815
+
816
+ // For API mode, use internal state (which gets updated immediately on change)
817
+ // Don't use apiPaginationMeta.per_page here because it's from the previous request
818
+ // and causes the select to flicker when user changes it
819
+ return internalPerPage.value;
820
+ });
821
+
822
+ // Computed: determine if per-page selector should be shown
823
+ // Hide it when total items is less than the smallest page size option
824
+ const shouldShowPerPageSelector = computed(() => {
825
+ if (!props.showPerPageSelector) return false;
826
+
827
+ const smallestOption = Math.min(...props.perPageOptions);
828
+ const total = isInertiaMode.value
829
+ ? props.pagination?.total || 0
830
+ : apiPaginationMeta.value?.total || 0;
831
+
832
+ return total >= smallestOption;
833
+ });
834
+
835
+ // Detect which fields need server filter values
836
+ const fieldsNeedingFilterValues = computed(() => {
837
+ return props.fields
838
+ .filter(field => field.filter === 'select' && (!field.filterOptions || field.filterOptions.length === 0))
839
+ .map(field => field.key);
840
+ });
841
+
842
+ // Error state for API mode
843
+ const apiError = ref<string | null>(null);
844
+
845
+ // Internal provider function when apiUrl is provided
846
+ const internalProvider: BTableProvider<T> = async (context: Readonly<BTableProviderContext>) => {
847
+ if (!props.apiUrl) return [];
848
+
849
+ try {
850
+ // Clear previous error
851
+ apiError.value = null;
852
+
853
+ const sort = context.sortBy && context.sortBy.length > 0
854
+ ? context.sortBy[0]
855
+ : { key: 'created_at', order: 'desc' };
856
+
857
+ // Build request parameters
858
+ const params: any = {
859
+ page: context.currentPage,
860
+ perPage: effectivePerPage.value,
861
+ sortBy: sort.key,
862
+ sortOrder: sort.order || 'desc',
863
+ filters: effectiveFilters.value,
864
+ };
865
+
866
+ // Request filter values on initial load
867
+ if (context.currentPage === 1 && fieldsNeedingFilterValues.value.length > 0 && Object.keys(apiFilterValues.value).length === 0) {
868
+ params.filterValues = fieldsNeedingFilterValues.value;
869
+ }
870
+
871
+ const response = await axios.get(props.apiUrl, { params });
872
+
873
+ // Extract and store pagination metadata for display
874
+ if (response.data.pagination) {
875
+ apiPaginationMeta.value = response.data.pagination;
876
+ }
877
+
878
+ // Extract and store filter values
879
+ if (response.data.filterValues) {
880
+ apiFilterValues.value = { ...apiFilterValues.value, ...response.data.filterValues };
881
+ }
882
+
883
+ return response.data.data;
884
+ } catch (error: any) {
885
+ console.error('Failed to fetch data from API:', error);
886
+
887
+ // Surface error to user
888
+ const errorMessage = error?.response?.data?.message
889
+ || error?.message
890
+ || 'Failed to load data. Please try again.';
891
+ apiError.value = errorMessage;
892
+
893
+ return [];
894
+ }
895
+ };
896
+
897
+ // Computed effective provider (use external if provided, otherwise internal)
898
+ const effectiveProvider = computed(() => props.provider || (props.apiUrl ? internalProvider : undefined));
899
+
900
+ const handlePageChange = (page: number) => {
901
+ // If inertiaUrl provided, handle navigation automatically
902
+ if (hasInertiaUrl.value && isInertiaMode.value && router) {
903
+ const currentSort = effectiveSortBy.value[0] || { key: 'created_at', order: 'desc' };
904
+ router.get(
905
+ props.inertiaUrl!,
906
+ {
907
+ page,
908
+ sortBy: currentSort.key,
909
+ sortOrder: currentSort.order,
910
+ filters: effectiveFilters.value,
911
+ perPage: effectivePerPage.value,
912
+ },
913
+ { preserveState: true }
914
+ );
915
+ }
916
+
917
+ // Always emit event for backward compatibility
918
+ emit("pageChange", page);
919
+ };
920
+
921
+ // API mode page change - update BTable's internal current page
922
+ const apiCurrentPage = ref(1);
923
+
924
+ const handleApiPageChange = (page: number) => {
925
+ apiCurrentPage.value = page;
926
+ // BTable should automatically call provider when currentPage prop changes
927
+ };
928
+
929
+ const handleSortChange = (sortBy: BTableSortBy[]) => {
930
+ // ENFORCE single-column sorting: keep only the last clicked column
931
+ // BTable may send multiple columns despite multisort: false
932
+ let normalizedSortBy = sortBy;
933
+ if (sortBy && sortBy.length > 1) {
934
+ // Find the column with an order (most recently clicked)
935
+ const withOrder = sortBy.filter(s => s.order);
936
+ if (withOrder.length > 0) {
937
+ // Use the last one with an order
938
+ normalizedSortBy = [withOrder[withOrder.length - 1]];
939
+ } else {
940
+ // Just use the last item
941
+ normalizedSortBy = [sortBy[sortBy.length - 1]];
942
+ }
943
+ }
944
+
945
+ // Update internal state if not using external sortBy
946
+ if (props.sortBy === undefined) {
947
+ internalSortBy.value = normalizedSortBy;
948
+ }
949
+
950
+ // Emit v-model update with normalized value
951
+ emit('update:sortBy', normalizedSortBy);
952
+
953
+ // Handle Inertia navigation automatically if URL provided
954
+ if (hasInertiaUrl.value && isInertiaMode.value && router) {
955
+ // Build params based on whether sort is active
956
+ const params: any = {
957
+ page: props.pagination?.current_page || 1,
958
+ filters: effectiveFilters.value,
959
+ perPage: effectivePerPage.value,
960
+ };
961
+
962
+ // Add sort params only if sorting is active
963
+ if (normalizedSortBy && normalizedSortBy.length > 0 && normalizedSortBy[0].key) {
964
+ params.sortBy = normalizedSortBy[0].key;
965
+ params.sortOrder = normalizedSortBy[0].order || 'asc';
966
+ }
967
+
968
+ router.get(props.inertiaUrl!, params, { preserveState: true });
969
+ }
970
+
971
+ // Emit simplified sortChange event for backward compatibility
972
+ if (isInertiaMode.value && normalizedSortBy && normalizedSortBy.length > 0 && normalizedSortBy[0].key) {
973
+ emit('sortChange', {
974
+ key: normalizedSortBy[0].key,
975
+ order: normalizedSortBy[0].order || 'asc'
976
+ });
977
+ }
978
+ };
979
+
980
+ const handleBusyChange = (busy: boolean) => {
981
+ emit('update:busy', busy);
982
+ };
983
+
984
+ // Debounce timer for filter changes
985
+ let filterDebounceTimer: ReturnType<typeof setTimeout> | null = null;
986
+
987
+ const handlePerPageChange = (newPerPage: number | string) => {
988
+ const perPageNum = typeof newPerPage === 'string' ? parseInt(newPerPage, 10) : newPerPage;
989
+
990
+ // Update internal state if not using external perPage
991
+ if (props.perPage === undefined) {
992
+ internalPerPage.value = perPageNum;
993
+ }
994
+
995
+ // Save to localStorage
996
+ if (typeof window !== 'undefined') {
997
+ try {
998
+ localStorage.setItem(perPageStorageKey.value, perPageNum.toString());
999
+ } catch (error) {
1000
+ console.error('Error saving perPage to localStorage:', error);
1001
+ }
1002
+ }
1003
+
1004
+ // Emit v-model update
1005
+ emit('update:perPage', perPageNum);
1006
+
1007
+ // Handle navigation automatically
1008
+ if (hasInertiaUrl.value && isInertiaMode.value && router) {
1009
+ const currentSort = effectiveSortBy.value[0] || { key: 'created_at', order: 'desc' };
1010
+ router.get(
1011
+ props.inertiaUrl!,
1012
+ {
1013
+ page: 1, // Reset to first page when changing perPage
1014
+ sortBy: currentSort.key,
1015
+ sortOrder: currentSort.order,
1016
+ filters: effectiveFilters.value,
1017
+ perPage: perPageNum,
1018
+ },
1019
+ { preserveState: true }
1020
+ );
1021
+ }
1022
+
1023
+ // For API mode, trigger provider refresh
1024
+ if (isProviderMode.value && tableRef.value) {
1025
+ refresh();
1026
+ }
1027
+
1028
+ // Emit event for backward compatibility
1029
+ emit('perPageChange', perPageNum);
1030
+ };
1031
+
1032
+ const handleFilterChange = (fieldKey: string, value: string) => {
1033
+ // Update filters
1034
+ const newFilters = { ...effectiveFilters.value, [fieldKey]: value };
1035
+
1036
+ // Remove empty filters
1037
+ if (!value || value.trim() === '') {
1038
+ delete newFilters[fieldKey];
1039
+ }
1040
+
1041
+ // Update internal state if not using external filters
1042
+ if (props.filters === undefined) {
1043
+ internalFilters.value = newFilters;
1044
+ }
1045
+
1046
+ // Emit v-model update
1047
+ emit('update:filters', newFilters);
1048
+
1049
+ // Debounce server requests for text inputs
1050
+ if (filterDebounceTimer) {
1051
+ clearTimeout(filterDebounceTimer);
1052
+ }
1053
+
1054
+ filterDebounceTimer = setTimeout(() => {
1055
+ // Handle Inertia navigation automatically if URL provided
1056
+ if (hasInertiaUrl.value && isInertiaMode.value && router) {
1057
+ const currentSort = effectiveSortBy.value[0] || { key: 'created_at', order: 'desc' };
1058
+ router.get(
1059
+ props.inertiaUrl!,
1060
+ {
1061
+ page: 1, // Reset to first page when filtering
1062
+ sortBy: currentSort.key,
1063
+ sortOrder: currentSort.order,
1064
+ filters: newFilters,
1065
+ },
1066
+ { preserveState: true }
1067
+ );
1068
+ }
1069
+
1070
+ // For API mode, provider will be called automatically by BTable
1071
+ // when we trigger a refresh
1072
+ if (isProviderMode.value && tableRef.value) {
1073
+ refresh();
1074
+ }
1075
+
1076
+ // Emit filterChange event for backward compatibility
1077
+ emit('filterChange', newFilters);
1078
+ }, 300); // 300ms debounce
1079
+ };
1080
+
1081
+ // Reference to the DTable component (for exposing refresh method)
1082
+ const tableRef = ref<InstanceType<typeof DTable> | null>(null);
1083
+
1084
+ // Expose refresh method for both modes
1085
+ const refresh = () => {
1086
+ // Provider/API mode: call refresh on BTable
1087
+ if (isProviderMode.value && tableRef.value && typeof (tableRef.value as any).refresh === 'function') {
1088
+ (tableRef.value as any).refresh();
1089
+ }
1090
+ // Inertia mode: reload current page to refresh data
1091
+ else if (isInertiaMode.value && props.inertiaUrl && router) {
1092
+ router.reload();
1093
+ }
1094
+ };
1095
+
1096
+ // Edit Modal State
1097
+ const showEditModal = ref(false);
1098
+ const selectedItem = ref<T | null>(null);
1099
+ const editForm = ref<any>(null);
1100
+ const activeTabIndex = ref(0);
1101
+
1102
+ // Toast (may not be available in test environment)
1103
+ let createToast: ((obj: any) => any) | undefined;
1104
+ try {
1105
+ const toast = useToast();
1106
+ createToast = toast.create;
1107
+ } catch (e) {
1108
+ // BApp not available (test environment or missing setup)
1109
+ createToast = undefined;
1110
+ }
1111
+
1112
+ // Computed: Visible tabs (respects when condition)
1113
+ const visibleTabs = computed(() => {
1114
+ if (!props.editTabs || props.editTabs.length === 0) return [];
1115
+
1116
+ return props.editTabs.filter(tab => {
1117
+ if (tab.when === undefined) return true;
1118
+ return typeof tab.when === 'function'
1119
+ ? tab.when(selectedItem.value)
1120
+ : tab.when;
1121
+ });
1122
+ });
1123
+
1124
+ // Helper: Get field by key
1125
+ const getField = (key: string) => {
1126
+ return props.editFields?.find(f => f.key === key) || { key };
1127
+ };
1128
+
1129
+ // Computed: Singular and plural item names
1130
+ const singularItemName = computed(() => props.itemName);
1131
+ const pluralItemName = computed(() => pluralize(props.itemName));
1132
+
1133
+ // Computed: Modal title (supports function)
1134
+ const computedModalTitle = computed(() => {
1135
+ if (!selectedItem.value) {
1136
+ return `Edit ${singularItemName.value}`;
1137
+ }
1138
+ if (!props.editModalTitle) {
1139
+ return `Edit ${singularItemName.value}`;
1140
+ }
1141
+ return typeof props.editModalTitle === 'function'
1142
+ ? props.editModalTitle(selectedItem.value)
1143
+ : props.editModalTitle;
1144
+ });
1145
+
1146
+ // Helper: Get current sort state for a field
1147
+ const getFieldSortState = (fieldKey: string) => {
1148
+ const currentSort = effectiveSortBy.value.find(s => s.key === fieldKey);
1149
+ return currentSort?.order || null;
1150
+ };
1151
+
1152
+ // Handle row click for editing
1153
+ const handleRowClick = (item: T, index: number, event: MouseEvent) => {
1154
+ // Always emit rowClicked for custom handling
1155
+ emit('rowClicked', item, index, event);
1156
+
1157
+ // If editFields provided, open edit modal
1158
+ if (props.editFields && props.editFields.length > 0) {
1159
+ // Set selected item FIRST before any rendering
1160
+ selectedItem.value = item;
1161
+
1162
+ // Reset to first tab
1163
+ activeTabIndex.value = 0;
1164
+
1165
+ // Initialize form with item data
1166
+ if (!editForm.value) {
1167
+ // Dynamically import useForm to avoid circular dependency
1168
+ import('../../composables/useForm').then(({ useForm }) => {
1169
+ const formData: Record<string, any> = {};
1170
+ props.editFields!.forEach(field => {
1171
+ formData[field.key] = (item as any)[field.key] ?? field.default ?? '';
1172
+ });
1173
+ editForm.value = useForm(formData);
1174
+
1175
+ // Open modal
1176
+ showEditModal.value = true;
1177
+ });
1178
+ } else {
1179
+ // Update existing form
1180
+ props.editFields.forEach(field => {
1181
+ editForm.value.data[field.key] = (item as any)[field.key] ?? field.default ?? '';
1182
+ });
1183
+ editForm.value.clearErrors();
1184
+
1185
+ // Open modal
1186
+ showEditModal.value = true;
1187
+ }
1188
+ }
1189
+ };
1190
+
1191
+ // Handle save from edit modal
1192
+ const handleEditSave = async () => {
1193
+ if (!editForm.value || !selectedItem.value) return;
1194
+
1195
+ try {
1196
+ // If editUrl provided, handle API call internally
1197
+ if (props.editUrl) {
1198
+ const itemId = (selectedItem.value as any).id;
1199
+ const url = props.editUrl.replace(':id', itemId);
1200
+
1201
+ await editForm.value.put(url, {
1202
+ onSuccess: (data: any) => {
1203
+ // Show success toast
1204
+ createToast?.({
1205
+ title: 'Success',
1206
+ body: `${singularItemName.value} updated successfully`,
1207
+ variant: 'success',
1208
+ modelValue: 3000, // Auto-dismiss after 3 seconds
1209
+ });
1210
+
1211
+ emit('rowUpdated', selectedItem.value as T, data);
1212
+ showEditModal.value = false;
1213
+ selectedItem.value = null;
1214
+
1215
+ // Refresh table data to show updated values
1216
+ refresh();
1217
+ },
1218
+ onError: (errors: any) => {
1219
+ // Show error toast
1220
+ createToast?.({
1221
+ title: 'Error',
1222
+ body: 'Failed to update. Please check the form for errors.',
1223
+ variant: 'danger',
1224
+ modelValue: 5000, // Auto-dismiss after 5 seconds
1225
+ });
1226
+
1227
+ // Switch to tab containing error field
1228
+ if (props.editTabs && props.editTabs.length > 0) {
1229
+ const errorKeys = Object.keys(errors);
1230
+ const tabIndex = visibleTabs.value.findIndex(tab =>
1231
+ tab.fieldKeys.some(key => errorKeys.includes(key))
1232
+ );
1233
+ if (tabIndex !== -1) {
1234
+ activeTabIndex.value = tabIndex;
1235
+ }
1236
+ }
1237
+
1238
+ emit('editError', selectedItem.value as T, errors);
1239
+ }
1240
+ });
1241
+ } else {
1242
+ // No editUrl - just emit event for custom handling
1243
+ emit('rowUpdated', selectedItem.value as T, editForm.value.data);
1244
+ showEditModal.value = false;
1245
+ selectedItem.value = null;
1246
+ }
1247
+ } catch (error) {
1248
+ emit('editError', selectedItem.value as T, error);
1249
+ }
1250
+ };
1251
+
1252
+ // Handle edit modal close
1253
+ const handleEditCancel = () => {
1254
+ showEditModal.value = false;
1255
+ selectedItem.value = null;
1256
+ activeTabIndex.value = 0; // Reset tab for next time
1257
+ if (editForm.value) {
1258
+ editForm.value.clearErrors();
1259
+ }
1260
+ };
1261
+
1262
+ // Handle delete from edit modal
1263
+ const handleDelete = async () => {
1264
+ if (!editForm.value || !selectedItem.value || !props.deleteUrl) return;
1265
+
1266
+ // Confirm deletion
1267
+ const itemName = (selectedItem.value as any).name || (selectedItem.value as any).title || singularItemName.value;
1268
+ const confirmed = window.confirm(`Are you sure you want to delete "${itemName}"? This action cannot be undone.`);
1269
+
1270
+ if (!confirmed) return;
1271
+
1272
+ try {
1273
+ const itemId = (selectedItem.value as any).id;
1274
+ const url = props.deleteUrl.replace(':id', itemId);
1275
+
1276
+ await editForm.value.delete(url, {
1277
+ onSuccess: (data: any) => {
1278
+ // Show success toast
1279
+ createToast?.({
1280
+ title: 'Success',
1281
+ body: `${singularItemName.value} deleted successfully`,
1282
+ variant: 'success',
1283
+ modelValue: 3000, // Auto-dismiss after 3 seconds
1284
+ });
1285
+
1286
+ emit('rowDeleted', selectedItem.value as T, data);
1287
+ showEditModal.value = false;
1288
+ selectedItem.value = null;
1289
+
1290
+ // Refresh table data to remove deleted item
1291
+ refresh();
1292
+ },
1293
+ onError: (error: any) => {
1294
+ // Extract error message from server response
1295
+ const errorData = error?.response?.data ?? error?.data ?? error;
1296
+ const errorMessage = errorData?.message ?? 'Failed to delete. Please try again.';
1297
+
1298
+ // Show error toast with server message
1299
+ createToast?.({
1300
+ title: 'Error',
1301
+ body: errorMessage,
1302
+ variant: 'danger',
1303
+ modelValue: 5000, // Auto-dismiss after 5 seconds
1304
+ });
1305
+
1306
+ emit('deleteError', selectedItem.value as T, error);
1307
+ }
1308
+ });
1309
+ } catch (error) {
1310
+ emit('deleteError', selectedItem.value as T, error);
1311
+ }
1312
+ };
1313
+
1314
+ defineExpose({
1315
+ refresh,
1316
+ });
1317
+ </script>
1318
+
1319
+ <style scoped>
1320
+ /* Add pointer cursor to table rows when editFields is enabled */
1321
+ :deep(tbody tr) {
1322
+ cursor: v-bind('editFields && editFields.length > 0 ? "pointer" : "default"');
1323
+ }
1324
+
1325
+ :deep(tbody tr:hover) {
1326
+ background-color: v-bind('editFields && editFields.length > 0 ? "var(--bs-table-hover-bg)" : "inherit"');
1327
+ }
1328
+
1329
+ /* Improve pagination button sizing to match form controls */
1330
+ :deep(.pagination) {
1331
+ margin-bottom: 0;
1332
+ }
1333
+
1334
+ :deep(.pagination-sm .page-link) {
1335
+ min-width: 2.25rem;
1336
+ height: auto;
1337
+ }
1338
+
1339
+ /* Make disabled pagination buttons more subtle */
1340
+ :deep(.pagination .page-item.disabled .page-link) {
1341
+ background-color: transparent;
1342
+ border-color: transparent;
1343
+ opacity: 0.3;
1344
+ }
1345
+ </style>