@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.
- package/LICENSE +21 -0
- package/README.md +397 -0
- package/dist/components/base/DAccordion.vue.d.ts +12 -0
- package/dist/components/base/DAccordionItem.vue.d.ts +12 -0
- package/dist/components/base/DAlert.vue.d.ts +12 -0
- package/dist/components/base/DAvatar.vue.d.ts +12 -0
- package/dist/components/base/DBadge.vue.d.ts +12 -0
- package/dist/components/base/DBreadcrumb.vue.d.ts +12 -0
- package/dist/components/base/DButton.vue.d.ts +29 -0
- package/dist/components/base/DButtonGroup.vue.d.ts +12 -0
- package/dist/components/base/DButtonToolbar.vue.d.ts +12 -0
- package/dist/components/base/DCard.vue.d.ts +12 -0
- package/dist/components/base/DCarousel.vue.d.ts +12 -0
- package/dist/components/base/DCarouselSlide.vue.d.ts +12 -0
- package/dist/components/base/DCol.vue.d.ts +12 -0
- package/dist/components/base/DCollapse.vue.d.ts +12 -0
- package/dist/components/base/DContainer.vue.d.ts +12 -0
- package/dist/components/base/DDropdown.vue.d.ts +12 -0
- package/dist/components/base/DDropdownDivider.vue.d.ts +2 -0
- package/dist/components/base/DDropdownItem.vue.d.ts +12 -0
- package/dist/components/base/DForm.vue.d.ts +12 -0
- package/dist/components/base/DFormCheckbox.vue.d.ts +12 -0
- package/dist/components/base/DFormGroup.vue.d.ts +12 -0
- package/dist/components/base/DFormInput.vue.d.ts +2 -0
- package/dist/components/base/DFormInvalidFeedback.vue.d.ts +12 -0
- package/dist/components/base/DFormRadio.vue.d.ts +12 -0
- package/dist/components/base/DFormSelect.vue.d.ts +12 -0
- package/dist/components/base/DFormSpinbutton.vue.d.ts +12 -0
- package/dist/components/base/DFormTags.vue.d.ts +12 -0
- package/dist/components/base/DFormText.vue.d.ts +12 -0
- package/dist/components/base/DFormTextarea.vue.d.ts +2 -0
- package/dist/components/base/DImage.vue.d.ts +12 -0
- package/dist/components/base/DInputGroup.vue.d.ts +12 -0
- package/dist/components/base/DLink.vue.d.ts +12 -0
- package/dist/components/base/DListGroup.vue.d.ts +12 -0
- package/dist/components/base/DListGroupItem.vue.d.ts +12 -0
- package/dist/components/base/DModal.vue.d.ts +12 -0
- package/dist/components/base/DNav.vue.d.ts +12 -0
- package/dist/components/base/DNavItem.vue.d.ts +12 -0
- package/dist/components/base/DNavbar.vue.d.ts +12 -0
- package/dist/components/base/DNavbarBrand.vue.d.ts +12 -0
- package/dist/components/base/DNavbarNav.vue.d.ts +12 -0
- package/dist/components/base/DNavbarToggle.vue.d.ts +12 -0
- package/dist/components/base/DOffcanvas.vue.d.ts +12 -0
- package/dist/components/base/DOverlay.vue.d.ts +12 -0
- package/dist/components/base/DPagination.vue.d.ts +2 -0
- package/dist/components/base/DPlaceholder.vue.d.ts +12 -0
- package/dist/components/base/DPopover.vue.d.ts +12 -0
- package/dist/components/base/DProgress.vue.d.ts +12 -0
- package/dist/components/base/DRow.vue.d.ts +12 -0
- package/dist/components/base/DSpinner.vue.d.ts +2 -0
- package/dist/components/base/DTab.vue.d.ts +12 -0
- package/dist/components/base/DTable.vue.d.ts +26 -0
- package/dist/components/base/DTabs.vue.d.ts +12 -0
- package/dist/components/base/DToast.vue.d.ts +12 -0
- package/dist/components/base/DToaster.vue.d.ts +12 -0
- package/dist/components/base/DTooltip.vue.d.ts +12 -0
- package/dist/components/extended/DXBasicForm.vue.d.ts +39 -0
- package/dist/components/extended/DXDashboard.vue.d.ts +52 -0
- package/dist/components/extended/DXDashboardNavbar.vue.d.ts +53 -0
- package/dist/components/extended/DXDashboardSidebar.vue.d.ts +37 -0
- package/dist/components/extended/DXForm.vue.d.ts +31 -0
- package/dist/components/extended/DXTable.vue.d.ts +190 -0
- package/dist/composables/defineForm.d.ts +35 -0
- package/dist/composables/useForm.d.ts +46 -0
- package/dist/composables/useToast.d.ts +1 -0
- package/dist/dashboard-for-laravel.js +17748 -0
- package/dist/dashboard-for-laravel.js.map +1 -0
- package/dist/dashboard-for-laravel.umd.cjs +11 -0
- package/dist/dashboard-for-laravel.umd.cjs.map +1 -0
- package/dist/index.d.ts +73 -0
- package/dist/style.css +5 -0
- package/dist/types/index.d.ts +37 -0
- package/dist/types/navigation.d.ts +17 -0
- package/dist/utils/api.d.ts +30 -0
- package/docs/public/api-reference.json +1932 -0
- package/docs/public/docs-map.md +85 -0
- package/docs/public/llms.txt +110 -0
- package/package.json +116 -0
- package/resources/css/theme.scss +219 -0
- package/resources/js/components/base/DAccordion.vue +21 -0
- package/resources/js/components/base/DAccordionItem.vue +14 -0
- package/resources/js/components/base/DAlert.vue +14 -0
- package/resources/js/components/base/DAvatar.vue +21 -0
- package/resources/js/components/base/DBadge.vue +14 -0
- package/resources/js/components/base/DBreadcrumb.vue +21 -0
- package/resources/js/components/base/DButton.vue +58 -0
- package/resources/js/components/base/DButtonGroup.vue +21 -0
- package/resources/js/components/base/DButtonToolbar.vue +21 -0
- package/resources/js/components/base/DCard.vue +35 -0
- package/resources/js/components/base/DCarousel.vue +21 -0
- package/resources/js/components/base/DCarouselSlide.vue +14 -0
- package/resources/js/components/base/DCol.vue +14 -0
- package/resources/js/components/base/DCollapse.vue +34 -0
- package/resources/js/components/base/DContainer.vue +14 -0
- package/resources/js/components/base/DDropdown.vue +16 -0
- package/resources/js/components/base/DDropdownDivider.vue +7 -0
- package/resources/js/components/base/DDropdownItem.vue +14 -0
- package/resources/js/components/base/DForm.vue +21 -0
- package/resources/js/components/base/DFormCheckbox.vue +14 -0
- package/resources/js/components/base/DFormGroup.vue +11 -0
- package/resources/js/components/base/DFormInput.vue +7 -0
- package/resources/js/components/base/DFormInvalidFeedback.vue +16 -0
- package/resources/js/components/base/DFormRadio.vue +21 -0
- package/resources/js/components/base/DFormSelect.vue +14 -0
- package/resources/js/components/base/DFormSpinbutton.vue +21 -0
- package/resources/js/components/base/DFormTags.vue +21 -0
- package/resources/js/components/base/DFormText.vue +16 -0
- package/resources/js/components/base/DFormTextarea.vue +7 -0
- package/resources/js/components/base/DImage.vue +21 -0
- package/resources/js/components/base/DInputGroup.vue +21 -0
- package/resources/js/components/base/DLink.vue +21 -0
- package/resources/js/components/base/DListGroup.vue +21 -0
- package/resources/js/components/base/DListGroupItem.vue +14 -0
- package/resources/js/components/base/DModal.vue +11 -0
- package/resources/js/components/base/DNav.vue +14 -0
- package/resources/js/components/base/DNavItem.vue +14 -0
- package/resources/js/components/base/DNavbar.vue +21 -0
- package/resources/js/components/base/DNavbarBrand.vue +14 -0
- package/resources/js/components/base/DNavbarNav.vue +14 -0
- package/resources/js/components/base/DNavbarToggle.vue +14 -0
- package/resources/js/components/base/DOffcanvas.vue +11 -0
- package/resources/js/components/base/DOverlay.vue +21 -0
- package/resources/js/components/base/DPagination.vue +7 -0
- package/resources/js/components/base/DPlaceholder.vue +21 -0
- package/resources/js/components/base/DPopover.vue +21 -0
- package/resources/js/components/base/DProgress.vue +21 -0
- package/resources/js/components/base/DRow.vue +14 -0
- package/resources/js/components/base/DSpinner.vue +7 -0
- package/resources/js/components/base/DTab.vue +14 -0
- package/resources/js/components/base/DTable.vue +62 -0
- package/resources/js/components/base/DTabs.vue +21 -0
- package/resources/js/components/base/DToast.vue +16 -0
- package/resources/js/components/base/DToaster.vue +16 -0
- package/resources/js/components/base/DTooltip.vue +21 -0
- package/resources/js/components/extended/DXBasicForm.vue +177 -0
- package/resources/js/components/extended/DXDashboard.vue +208 -0
- package/resources/js/components/extended/DXDashboardNavbar.vue +112 -0
- package/resources/js/components/extended/DXDashboardSidebar.vue +233 -0
- package/resources/js/components/extended/DXForm.vue +44 -0
- package/resources/js/components/extended/DXTable.vue +1345 -0
- package/resources/js/composables/defineForm.ts +78 -0
- package/resources/js/composables/useForm.ts +272 -0
- package/resources/js/composables/useToast.ts +1 -0
- package/resources/js/index.ts +118 -0
- package/resources/js/types/index.ts +61 -0
- package/resources/js/types/navigation.ts +19 -0
- package/resources/js/utils/api.ts +182 -0
- 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>
|