@innertia-solutions/innertia-nuxt 0.1.1
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/.github/workflows/auto-publish.yml +64 -0
- package/.github/workflows/release.yml +59 -0
- package/README.md +60 -0
- package/app.config.ts +70 -0
- package/components/Admin/Base.vue +144 -0
- package/components/Admin/Header.vue +32 -0
- package/components/Admin/Page.vue +65 -0
- package/components/Admin/PageHeader.vue +31 -0
- package/components/App/Button.vue +59 -0
- package/components/App/DevEnvironmentBar.vue +43 -0
- package/components/App/Dropdown.vue +286 -0
- package/components/App/EmptyState.vue +433 -0
- package/components/App/LoadingState.vue +40 -0
- package/components/App/PageLoadingSpinner.vue +118 -0
- package/components/App/PreviewDock.vue +64 -0
- package/components/App/SwitchColorTheme.vue +51 -0
- package/components/App/Tag.vue +193 -0
- package/components/DataTable.vue +713 -0
- package/components/Forms/DatePicker.vue +255 -0
- package/components/Forms/Input.vue +75 -0
- package/components/Forms/Select.vue +100 -0
- package/components/Forms/SelectServer.vue +726 -0
- package/components/Layout/Admin.vue +32 -0
- package/components/Layout/Auth.vue +29 -0
- package/components/Layout/SidebarWithAppColumn.vue +388 -0
- package/components/Layout/TopBar.vue +113 -0
- package/components/MobileBlocker.vue +85 -0
- package/components/MobileLoginPicker.vue +83 -0
- package/components/Modal/Base.vue +29 -0
- package/components/Modal/DeleteConfirm.vue +48 -0
- package/components/Modal.vue +103 -0
- package/components/Nav/Tabs.vue +55 -0
- package/components/PermissionsTree.vue +272 -0
- package/components/Table/Database.vue +183 -0
- package/components/Table/DownloadDropdown.vue +111 -0
- package/components/Table/Enterprise.vue +540 -0
- package/components/Table/FilterDropdown.vue +226 -0
- package/components/Table/Grid.vue +62 -0
- package/components/Table/Kanban.vue +188 -0
- package/components/Table/List.vue +128 -0
- package/components/Table/PreviewTimeline.vue +118 -0
- package/components/Table/Standard.vue +1217 -0
- package/components/Table/index.vue +974 -0
- package/components/TableExportable.vue +172 -0
- package/components/TableFilter.vue +93 -0
- package/components/Toast/Alert.vue +113 -0
- package/components/Toast/Container.vue +34 -0
- package/components/Toast/Notification.vue +45 -0
- package/components/Toast/Process.vue +88 -0
- package/composables/useApi.js +95 -0
- package/composables/useApp.ts +46 -0
- package/composables/useAuth.js +82 -0
- package/composables/useContext.js +44 -0
- package/composables/useDate.js +241 -0
- package/composables/useDevice.js +21 -0
- package/composables/useDockedPreviews.js +56 -0
- package/composables/useDownload.js +87 -0
- package/composables/useEntity.js +82 -0
- package/composables/useForm.js +119 -0
- package/composables/useInnertiaMode.ts +25 -0
- package/composables/useMobileGuard.ts +81 -0
- package/composables/useNotifications.js +22 -0
- package/composables/usePermissions.js +23 -0
- package/composables/useRealtime.js +123 -0
- package/composables/useRequestInterceptors.js +27 -0
- package/composables/useRoles.js +53 -0
- package/composables/useRutFormatter.js +39 -0
- package/composables/useTable.ts +94 -0
- package/composables/useTablePreferences.ts +33 -0
- package/composables/useTenant.js +27 -0
- package/composables/useTimeAgo.js +37 -0
- package/composables/useToast.js +69 -0
- package/composables/useUserRealtime.js +17 -0
- package/composables/useUsers.js +111 -0
- package/css/themes/autumn.css +401 -0
- package/css/themes/bubblegum.css +408 -0
- package/css/themes/cashmere.css +412 -0
- package/css/themes/harvest.css +416 -0
- package/css/themes/moon.css +140 -0
- package/css/themes/ocean.css +273 -0
- package/css/themes/olive.css +413 -0
- package/css/themes/retro.css +431 -0
- package/css/themes/theme.css +725 -0
- package/error.vue +78 -0
- package/middleware/01.detect-subdomain.global.ts +43 -0
- package/middleware/02.validate-tenant.global.ts +67 -0
- package/middleware/03.apps.global.ts +88 -0
- package/middleware/auth.ts +9 -0
- package/middleware/guest.ts +9 -0
- package/nuxt.config.ts +42 -0
- package/package.json +60 -0
- package/pages/tenant-error.vue +50 -0
- package/plugins/api-auth.ts +12 -0
- package/plugins/api-tenant.client.ts +21 -0
- package/plugins/appearance.ts +8 -0
- package/plugins/auth-init.ts +34 -0
- package/plugins/dark-state.client.ts +29 -0
- package/plugins/dockedPreviewsSync.client.js +17 -0
- package/plugins/preline.client.ts +68 -0
- package/plugins/theme.client.ts +7 -0
- package/plugins/vue-query.ts +29 -0
- package/public/init-theme.js +15 -0
- package/spark.css +721 -0
- package/stores/auth.js +130 -0
- package/stores/dockedPreviews.js +34 -0
- package/stores/notifications.js +24 -0
- package/stores/tenant.js +54 -0
- package/stores/toast.js +129 -0
|
@@ -0,0 +1,974 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { useVueTable, getCoreRowModel } from '@tanstack/vue-table'
|
|
3
|
+
import {
|
|
4
|
+
IconSelector,
|
|
5
|
+
IconChevronUp,
|
|
6
|
+
IconChevronDown,
|
|
7
|
+
IconReload,
|
|
8
|
+
IconBolt,
|
|
9
|
+
IconPin,
|
|
10
|
+
} from '@tabler/icons-vue'
|
|
11
|
+
|
|
12
|
+
const props = defineProps({
|
|
13
|
+
endpoint: { type: String, required: true },
|
|
14
|
+
columns: { type: Array, required: true }, // [{ key, label, sortable?, filterable?, class? }]
|
|
15
|
+
params: { type: Object, default: () => ({}) },
|
|
16
|
+
checkable: { type: Boolean, default: false },
|
|
17
|
+
search: { type: String, default: '' },
|
|
18
|
+
name: { type: String, required: true },
|
|
19
|
+
cached: { type: Boolean, default: false },
|
|
20
|
+
showReloadButton: { type: Boolean, default: true },
|
|
21
|
+
viewMode: { type: String, default: 'table' }, // 'table' | 'grid'
|
|
22
|
+
gridClass: { type: String, default: 'grid grid-cols-2 lg:grid-cols-3 gap-4' },
|
|
23
|
+
clickRowToOpen: { type: Boolean, default: false },
|
|
24
|
+
previewRowId: { type: [String, Number], default: null },
|
|
25
|
+
previewMode: { type: Boolean, default: false },
|
|
26
|
+
pinnedColumns: { type: Object, default: null }, // { left?: string[], right?: string[] }
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const emit = defineEmits(['update:search', 'row-click', 'loaded', 'page-change', 'per-page-change'])
|
|
30
|
+
const instance = getCurrentInstance()
|
|
31
|
+
|
|
32
|
+
// ─── API / toast ─────────────────────────────────────────────────────────────
|
|
33
|
+
const api = useApi()
|
|
34
|
+
const toast = useToast()
|
|
35
|
+
|
|
36
|
+
// ─── Local data ───────────────────────────────────────────────────────────────
|
|
37
|
+
const tableData = ref([])
|
|
38
|
+
const rowCount = ref(0)
|
|
39
|
+
const loading = ref(false)
|
|
40
|
+
const isDataFromCache = ref(false)
|
|
41
|
+
const lastDataLength = ref(-1)
|
|
42
|
+
const lastRowHeight = ref(48)
|
|
43
|
+
const tableBodyRef = ref(null)
|
|
44
|
+
const paginationBarRef = ref(null)
|
|
45
|
+
const skeletonRows = computed(() => {
|
|
46
|
+
const count = lastDataLength.value < 0 ? pagination.value.pageSize : lastDataLength.value
|
|
47
|
+
return Array.from({ length: count })
|
|
48
|
+
})
|
|
49
|
+
const isGridView = computed(() => props.viewMode === 'grid')
|
|
50
|
+
|
|
51
|
+
// ─── TanStack state ───────────────────────────────────────────────────────────
|
|
52
|
+
const pagination = ref({ pageIndex: 0, pageSize: 10 })
|
|
53
|
+
const sorting = ref([])
|
|
54
|
+
const columnFilters = ref([])
|
|
55
|
+
const columnVisibility = ref({})
|
|
56
|
+
const columnOrder = ref([])
|
|
57
|
+
const columnSizing = ref({})
|
|
58
|
+
const columnSizingInfo = ref({})
|
|
59
|
+
const rowSelection = ref({})
|
|
60
|
+
const columnPinning = ref({ left: [], right: [] })
|
|
61
|
+
const isCustomPerPage = ref(false)
|
|
62
|
+
|
|
63
|
+
const makeUpdater = (stateRef) => (updater) => {
|
|
64
|
+
stateRef.value = typeof updater === 'function' ? updater(stateRef.value) : updater
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── Column definitions ───────────────────────────────────────────────────────
|
|
68
|
+
const buildColumnDefs = () => {
|
|
69
|
+
const defs = []
|
|
70
|
+
if (props.checkable) {
|
|
71
|
+
defs.push({
|
|
72
|
+
id: 'select',
|
|
73
|
+
header: 'select',
|
|
74
|
+
enableSorting: false,
|
|
75
|
+
enableColumnFilter: false,
|
|
76
|
+
enableResizing: false,
|
|
77
|
+
size: 48,
|
|
78
|
+
minSize: 48,
|
|
79
|
+
maxSize: 48,
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
for (const col of props.columns) {
|
|
83
|
+
defs.push({
|
|
84
|
+
id: col.key,
|
|
85
|
+
accessorKey: col.key,
|
|
86
|
+
header: col.label,
|
|
87
|
+
enableSorting: col.sortable ?? false,
|
|
88
|
+
enableColumnFilter: col.filterable ?? false,
|
|
89
|
+
enableResizing: col.resizable !== false,
|
|
90
|
+
size: col.size ?? 200,
|
|
91
|
+
minSize: 60,
|
|
92
|
+
maxSize: 800,
|
|
93
|
+
meta: { class: col.class ?? '', label: col.label },
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
return defs
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const columnDefs = buildColumnDefs()
|
|
100
|
+
|
|
101
|
+
const hasFilterableColumns = computed(() => props.columns.some(c => c.filterable))
|
|
102
|
+
|
|
103
|
+
// ─── TanStack table instance ──────────────────────────────────────────────────
|
|
104
|
+
const table = useVueTable({
|
|
105
|
+
get data() { return tableData.value },
|
|
106
|
+
get rowCount() { return rowCount.value },
|
|
107
|
+
columns: columnDefs,
|
|
108
|
+
state: {
|
|
109
|
+
get pagination() { return pagination.value },
|
|
110
|
+
get sorting() { return sorting.value },
|
|
111
|
+
get columnFilters() { return columnFilters.value },
|
|
112
|
+
get columnVisibility() { return columnVisibility.value },
|
|
113
|
+
get columnOrder() { return columnOrder.value },
|
|
114
|
+
get columnSizing() { return columnSizing.value },
|
|
115
|
+
get columnSizingInfo() { return columnSizingInfo.value },
|
|
116
|
+
get rowSelection() { return rowSelection.value },
|
|
117
|
+
get columnPinning() { return columnPinning.value },
|
|
118
|
+
},
|
|
119
|
+
onPaginationChange: makeUpdater(pagination),
|
|
120
|
+
onSortingChange: makeUpdater(sorting),
|
|
121
|
+
onColumnFiltersChange: makeUpdater(columnFilters),
|
|
122
|
+
onColumnVisibilityChange: makeUpdater(columnVisibility),
|
|
123
|
+
onColumnOrderChange: makeUpdater(columnOrder),
|
|
124
|
+
onColumnSizingChange: makeUpdater(columnSizing),
|
|
125
|
+
onColumnSizingInfoChange: makeUpdater(columnSizingInfo),
|
|
126
|
+
onRowSelectionChange: makeUpdater(rowSelection),
|
|
127
|
+
onColumnPinningChange: makeUpdater(columnPinning),
|
|
128
|
+
getCoreRowModel: getCoreRowModel(),
|
|
129
|
+
columnResizeMode: 'onChange',
|
|
130
|
+
enableColumnResizing: true,
|
|
131
|
+
enableColumnPinning: true,
|
|
132
|
+
manualPagination: true,
|
|
133
|
+
manualSorting: true,
|
|
134
|
+
manualFiltering: true,
|
|
135
|
+
enableMultiSort: true,
|
|
136
|
+
enableSortingRemoval: true,
|
|
137
|
+
enableRowSelection: true,
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// ─── Column pinning helpers ───────────────────────────────────────────────────
|
|
141
|
+
const pinColumn = (key, position) => {
|
|
142
|
+
table.getColumn(key)?.pin(position)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const getPinnedStyles = (column, isHeader = false) => {
|
|
146
|
+
const pinned = column.getIsPinned()
|
|
147
|
+
if (!pinned) return {}
|
|
148
|
+
const z = isHeader ? 2 : 1
|
|
149
|
+
const w = column.getSize() + 'px'
|
|
150
|
+
// Headers always use the solid card background.
|
|
151
|
+
// Body cells use --row-bg, which is set to solid colors only (normal + hover) via <style scoped>.
|
|
152
|
+
// Selected/preview rows intentionally don't set --row-bg so sticky cells fall back to --card,
|
|
153
|
+
// which prevents semi-transparent tints from bleeding through the sticky cell.
|
|
154
|
+
const bg = isHeader ? 'var(--card, #fff)' : 'var(--row-bg, var(--card, #fff))'
|
|
155
|
+
const base = {
|
|
156
|
+
position: 'sticky',
|
|
157
|
+
zIndex: z,
|
|
158
|
+
background: bg,
|
|
159
|
+
width: w,
|
|
160
|
+
minWidth: w,
|
|
161
|
+
maxWidth: w,
|
|
162
|
+
}
|
|
163
|
+
// inset box-shadow: paints inside the cell so it can't be covered by adjacent cells
|
|
164
|
+
// and always follows the visual sticky position (unlike border or outset box-shadow)
|
|
165
|
+
if (pinned === 'left') return { ...base, left: column.getStart('left') + 'px', boxShadow: 'inset -1px 0 0 0 var(--card-line, #e5e7eb)' }
|
|
166
|
+
if (pinned === 'right') return { ...base, right: column.getAfter('right') + 'px', boxShadow: 'inset 1px 0 0 0 var(--card-line, #e5e7eb)' }
|
|
167
|
+
return {}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
// Initialize pinning from prop if provided
|
|
172
|
+
onMounted(() => {
|
|
173
|
+
if (props.pinnedColumns) {
|
|
174
|
+
columnPinning.value = {
|
|
175
|
+
left: props.pinnedColumns.left ?? [],
|
|
176
|
+
right: props.pinnedColumns.right ?? [],
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
// Keep 'select' always first in the left pinning array.
|
|
182
|
+
// Fires synchronously so the colgroup/headers never render in wrong order.
|
|
183
|
+
watch(() => columnPinning.value.left, (left) => {
|
|
184
|
+
if (props.checkable && left.includes('select') && left[0] !== 'select') {
|
|
185
|
+
columnPinning.value = {
|
|
186
|
+
...columnPinning.value,
|
|
187
|
+
left: ['select', ...left.filter(id => id !== 'select')],
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}, { flush: 'sync' })
|
|
191
|
+
|
|
192
|
+
// ─── Fetch ────────────────────────────────────────────────────────────────────
|
|
193
|
+
const buildRequestParams = () => {
|
|
194
|
+
const { sort, ...otherParams } = props.params
|
|
195
|
+
return {
|
|
196
|
+
search: props.search,
|
|
197
|
+
page: pagination.value.pageIndex + 1,
|
|
198
|
+
perPage: pagination.value.pageSize,
|
|
199
|
+
sortColumns: sorting.value.map(s => ({ column: s.id, direction: s.desc ? 'desc' : 'asc' })),
|
|
200
|
+
columnFilters: Object.fromEntries(columnFilters.value.map(f => [f.id, f.value])),
|
|
201
|
+
...otherParams,
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const fetchData = async () => {
|
|
206
|
+
if (tableData.value.length > 0) {
|
|
207
|
+
lastDataLength.value = tableData.value.length
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
tableData.value = []
|
|
211
|
+
loading.value = true
|
|
212
|
+
isDataFromCache.value = false
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
const res = await api.get(props.endpoint, { params: buildRequestParams() })
|
|
216
|
+
if (!res) return
|
|
217
|
+
|
|
218
|
+
tableData.value = Array.isArray(res.data) ? res.data : (Array.isArray(res) ? res : [])
|
|
219
|
+
const m = res.meta ?? (res.current_page !== undefined ? res : null)
|
|
220
|
+
rowCount.value = m?.total ?? tableData.value.length
|
|
221
|
+
|
|
222
|
+
if (props.cached) saveToCache()
|
|
223
|
+
emit('loaded', res)
|
|
224
|
+
} catch (e) {
|
|
225
|
+
console.error('[FullTable] Fetch error:', e)
|
|
226
|
+
} finally {
|
|
227
|
+
loading.value = false
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ─── Scheduled fetch (deduplicates concurrent state changes) ─────────────────
|
|
232
|
+
let fetchTimeout = null
|
|
233
|
+
const scheduleFetch = (delay = 0) => {
|
|
234
|
+
if (fetchTimeout) clearTimeout(fetchTimeout)
|
|
235
|
+
fetchTimeout = setTimeout(() => fetchData(), delay)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ─── Cache ────────────────────────────────────────────────────────────────────
|
|
239
|
+
const cacheKey = computed(() => {
|
|
240
|
+
if (!props.cached || !props.name) return null
|
|
241
|
+
const base = `full_table_${props.name}`
|
|
242
|
+
if (!Object.keys(props.params).length) return base
|
|
243
|
+
try { return base + '_' + btoa(JSON.stringify(props.params)) } catch { return base }
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
const saveToCache = () => {
|
|
247
|
+
if (!cacheKey.value || !tableData.value.length) return
|
|
248
|
+
try {
|
|
249
|
+
sessionStorage.setItem(cacheKey.value, JSON.stringify({
|
|
250
|
+
data: tableData.value,
|
|
251
|
+
rowCount: rowCount.value,
|
|
252
|
+
pagination: pagination.value,
|
|
253
|
+
sorting: sorting.value,
|
|
254
|
+
columnFilters: columnFilters.value,
|
|
255
|
+
columnVisibility: columnVisibility.value,
|
|
256
|
+
columnOrder: columnOrder.value,
|
|
257
|
+
search: props.search,
|
|
258
|
+
timestamp: Date.now(),
|
|
259
|
+
}))
|
|
260
|
+
} catch (e) {
|
|
261
|
+
console.warn('[FullTable] Cache save error:', e)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const loadFromCache = () => {
|
|
266
|
+
if (!cacheKey.value) return null
|
|
267
|
+
try {
|
|
268
|
+
const raw = sessionStorage.getItem(cacheKey.value)
|
|
269
|
+
if (!raw) return null
|
|
270
|
+
const cached = JSON.parse(raw)
|
|
271
|
+
if (Date.now() - cached.timestamp > 10 * 60 * 1000) {
|
|
272
|
+
sessionStorage.removeItem(cacheKey.value)
|
|
273
|
+
return null
|
|
274
|
+
}
|
|
275
|
+
if (cached.search !== props.search) return null
|
|
276
|
+
return cached
|
|
277
|
+
} catch { return null }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const clearCache = () => {
|
|
281
|
+
if (cacheKey.value) sessionStorage.removeItem(cacheKey.value)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ─── Restore guard (prevents watchers from triggering fetch during restore) ───
|
|
285
|
+
const isRestoring = ref(false)
|
|
286
|
+
|
|
287
|
+
const loadFromCacheOnMount = async () => {
|
|
288
|
+
const cached = loadFromCache()
|
|
289
|
+
if (!cached) return false
|
|
290
|
+
|
|
291
|
+
isRestoring.value = true
|
|
292
|
+
tableData.value = cached.data
|
|
293
|
+
rowCount.value = cached.rowCount
|
|
294
|
+
pagination.value = cached.pagination
|
|
295
|
+
sorting.value = cached.sorting
|
|
296
|
+
columnFilters.value = cached.columnFilters
|
|
297
|
+
columnVisibility.value = cached.columnVisibility
|
|
298
|
+
if (cached.columnOrder?.length) {
|
|
299
|
+
const order = cached.columnOrder.filter(id => id !== 'select')
|
|
300
|
+
if (props.checkable) order.unshift('select')
|
|
301
|
+
columnOrder.value = order
|
|
302
|
+
}
|
|
303
|
+
lastDataLength.value = cached.data.length
|
|
304
|
+
isDataFromCache.value = true
|
|
305
|
+
|
|
306
|
+
if (cached.search !== props.search) emit('update:search', cached.search)
|
|
307
|
+
|
|
308
|
+
await nextTick()
|
|
309
|
+
isRestoring.value = false
|
|
310
|
+
return true
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ─── Watchers ─────────────────────────────────────────────────────────────────
|
|
314
|
+
watch(tableData, (newData) => {
|
|
315
|
+
if (newData.length > 0 && tableBodyRef.value) {
|
|
316
|
+
const firstDataRow = Array.from(tableBodyRef.value.children).find(el => el.dataset.rowType === 'data')
|
|
317
|
+
if (firstDataRow) {
|
|
318
|
+
const h = firstDataRow.getBoundingClientRect().height
|
|
319
|
+
if (h > 0) lastRowHeight.value = h
|
|
320
|
+
lastDataLength.value = newData.length
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}, { flush: 'post' })
|
|
324
|
+
|
|
325
|
+
watch(pagination, () => { if (!isRestoring.value) scheduleFetch(0) }, { deep: true })
|
|
326
|
+
|
|
327
|
+
watch(() => pagination.value.pageIndex, (val, old) => {
|
|
328
|
+
if (!isRestoring.value && val !== old) emit('page-change', val)
|
|
329
|
+
})
|
|
330
|
+
watch(() => pagination.value.pageSize, (val, old) => {
|
|
331
|
+
if (!isRestoring.value && val !== old) emit('per-page-change', val)
|
|
332
|
+
})
|
|
333
|
+
watch(sorting, () => { if (!isRestoring.value) scheduleFetch(0) }, { deep: true })
|
|
334
|
+
watch(columnFilters, () => { if (!isRestoring.value) scheduleFetch(300) }, { deep: true })
|
|
335
|
+
|
|
336
|
+
watch(() => props.search, () => {
|
|
337
|
+
if (isRestoring.value) return
|
|
338
|
+
pagination.value = { ...pagination.value, pageIndex: 0 }
|
|
339
|
+
scheduleFetch(500)
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
watch(() => props.params, () => {
|
|
343
|
+
if (isRestoring.value) return
|
|
344
|
+
pagination.value = { ...pagination.value, pageIndex: 0 }
|
|
345
|
+
scheduleFetch(0)
|
|
346
|
+
}, { deep: true })
|
|
347
|
+
|
|
348
|
+
// ─── Lifecycle ────────────────────────────────────────────────────────────────
|
|
349
|
+
const initColumnOrder = () => {
|
|
350
|
+
const ids = props.checkable ? ['select'] : []
|
|
351
|
+
for (const col of props.columns) ids.push(col.key)
|
|
352
|
+
columnOrder.value = ids
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
onMounted(async () => {
|
|
356
|
+
initColumnOrder()
|
|
357
|
+
try {
|
|
358
|
+
const fromCache = await loadFromCacheOnMount()
|
|
359
|
+
if (!fromCache) await fetchData()
|
|
360
|
+
} catch (e) {
|
|
361
|
+
console.error('[FullTable] Mount error:', e)
|
|
362
|
+
}
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
onBeforeUnmount(() => {
|
|
366
|
+
if (fetchTimeout) clearTimeout(fetchTimeout)
|
|
367
|
+
if (props.cached && tableData.value.length > 0) saveToCache()
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
// ─── Column settings panel ────────────────────────────────────────────────────
|
|
371
|
+
const setColumnOrder = (order) => { columnOrder.value = order }
|
|
372
|
+
|
|
373
|
+
// ─── Header drag reorder ──────────────────────────────────────────────────────
|
|
374
|
+
let draggedHeaderId = null
|
|
375
|
+
const dragOverHeaderId = ref(null)
|
|
376
|
+
const resizeHoverId = ref(null)
|
|
377
|
+
|
|
378
|
+
// ─── Column auto-size on double click ─────────────────────────────────────────
|
|
379
|
+
const _canvas = typeof document !== 'undefined' ? document.createElement('canvas') : null
|
|
380
|
+
const _ctx = _canvas?.getContext('2d')
|
|
381
|
+
|
|
382
|
+
const measureText = (text, font) => {
|
|
383
|
+
if (!_ctx) return 0
|
|
384
|
+
_ctx.font = font
|
|
385
|
+
return _ctx.measureText(String(text ?? '')).width
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const autoSizeColumn = (header) => {
|
|
389
|
+
const colId = header.column.id
|
|
390
|
+
const pad = 32
|
|
391
|
+
|
|
392
|
+
const label = header.column.columnDef.meta?.label ?? header.id
|
|
393
|
+
let max = measureText(label, '500 12px ui-sans-serif,system-ui,sans-serif') + pad + 20
|
|
394
|
+
|
|
395
|
+
if (tableBodyRef.value) {
|
|
396
|
+
tableBodyRef.value.querySelectorAll(`td[data-col-id="${colId}"]`).forEach(td => {
|
|
397
|
+
const w = measureText(td.textContent?.trim(), '14px ui-sans-serif,system-ui,sans-serif') + pad
|
|
398
|
+
if (w > max) max = w
|
|
399
|
+
})
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
table.setColumnSizing(prev => ({ ...prev, [colId]: Math.ceil(max) }))
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const onHeaderDragStart = (colId) => { draggedHeaderId = colId }
|
|
406
|
+
const onHeaderDragOver = (e, colId) => { e.preventDefault(); dragOverHeaderId.value = colId }
|
|
407
|
+
const onHeaderDragLeave = () => { dragOverHeaderId.value = null }
|
|
408
|
+
const onHeaderDrop = (colId) => {
|
|
409
|
+
if (!draggedHeaderId || draggedHeaderId === colId) return
|
|
410
|
+
if (colId === 'select') return
|
|
411
|
+
const order = [...columnOrder.value]
|
|
412
|
+
const from = order.indexOf(draggedHeaderId)
|
|
413
|
+
const to = order.indexOf(colId)
|
|
414
|
+
if (from < 0 || to < 0) return
|
|
415
|
+
order.splice(from, 1)
|
|
416
|
+
order.splice(to, 0, draggedHeaderId)
|
|
417
|
+
// keep 'select' pinned first
|
|
418
|
+
const selIdx = order.indexOf('select')
|
|
419
|
+
if (selIdx > 0) { order.splice(selIdx, 1); order.unshift('select') }
|
|
420
|
+
columnOrder.value = order
|
|
421
|
+
draggedHeaderId = null
|
|
422
|
+
dragOverHeaderId.value = null
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ─── Row selection ────────────────────────────────────────────────────────────
|
|
426
|
+
const getSelectedRows = () => {
|
|
427
|
+
const selected = table.getSelectedRowModel().rows.map(r => r.original)
|
|
428
|
+
return table.getIsAllRowsSelected()
|
|
429
|
+
? { meta: { all: true }, rows: [] }
|
|
430
|
+
: { meta: { all: false }, rows: selected }
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ─── Export ───────────────────────────────────────────────────────────────────
|
|
434
|
+
const exportTable = async (format, exportAllPages, exportFilteredRows, selectedIds = null) => {
|
|
435
|
+
const { download } = useDownload()
|
|
436
|
+
const id = crypto.randomUUID()
|
|
437
|
+
toast.show({
|
|
438
|
+
id, type: 'process', title: 'Descargando archivo...',
|
|
439
|
+
progress: 0, progressLabel: 'Iniciando descarga', message: '', position: 'top-right',
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
const validFormats = ['csv', 'xlsx', 'pdf', 'json']
|
|
443
|
+
const params = {
|
|
444
|
+
...buildRequestParams(),
|
|
445
|
+
exportType: validFormats.includes(format) ? format : 'csv',
|
|
446
|
+
exportAllPages,
|
|
447
|
+
exportFilteredRows,
|
|
448
|
+
...(selectedIds?.length ? { selectedIds } : {}),
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
try {
|
|
452
|
+
const { blob, headers } = await download(props.endpoint, params, {
|
|
453
|
+
method: 'GET',
|
|
454
|
+
onProgress: (p) => toast.update(id, { progress: p, progressLabel: `Descargando... ${p}%` }),
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
let fileName = 'export.' + format
|
|
458
|
+
const cd = headers['content-disposition']
|
|
459
|
+
if (cd) {
|
|
460
|
+
const m = cd.match(/filename="(.+)"/)
|
|
461
|
+
if (m?.[1]) fileName = m[1]
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const url = window.URL.createObjectURL(blob)
|
|
465
|
+
const a = document.createElement('a')
|
|
466
|
+
a.href = url; a.setAttribute('download', fileName)
|
|
467
|
+
document.body.appendChild(a); a.click(); a.remove()
|
|
468
|
+
window.URL.revokeObjectURL(url)
|
|
469
|
+
|
|
470
|
+
toast.update(id, { progress: 100, progressLabel: '¡Descarga completada!', message: 'El archivo se descargó correctamente.' })
|
|
471
|
+
setTimeout(() => toast.remove(id), 2000)
|
|
472
|
+
} catch (e) {
|
|
473
|
+
toast.update(id, { progressLabel: 'Error en la descarga', message: e.message, severity: 'danger' })
|
|
474
|
+
setTimeout(() => toast.remove(id), 3000)
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ─── Per-page ─────────────────────────────────────────────────────────────────
|
|
479
|
+
const handlePerPageChange = (val) => {
|
|
480
|
+
if (val === 'custom') { isCustomPerPage.value = true; return }
|
|
481
|
+
table.setPageSize(parseInt(val))
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const resetPerPage = () => {
|
|
485
|
+
isCustomPerPage.value = false
|
|
486
|
+
if (![10, 25, 50, 100].includes(pagination.value.pageSize)) table.setPageSize(10)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// ─── Row click ────────────────────────────────────────────────────────────────
|
|
490
|
+
const hasRowClickListener = computed(() => !!instance?.vnode?.props?.onRowClick)
|
|
491
|
+
const isRowClickEnabled = computed(() => props.clickRowToOpen || props.previewMode || hasRowClickListener.value)
|
|
492
|
+
|
|
493
|
+
const interactiveSelector = [
|
|
494
|
+
'a', 'button', 'input', 'select', 'textarea', 'label', 'summary',
|
|
495
|
+
"[role='button']", "[role='link']", "[contenteditable='true']",
|
|
496
|
+
'[data-row-click-ignore]', '[data-no-row-click]', '.hs-dropdown', '.dropdown',
|
|
497
|
+
].join(',')
|
|
498
|
+
|
|
499
|
+
const shouldIgnoreRowClick = (e) => {
|
|
500
|
+
const t = e?.target
|
|
501
|
+
if (!(t instanceof Element)) return false
|
|
502
|
+
const el = t.closest(interactiveSelector)
|
|
503
|
+
return !!el && e.currentTarget?.contains(el)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const handleRowClick = (row, e) => {
|
|
507
|
+
if (!isRowClickEnabled.value || shouldIgnoreRowClick(e)) return
|
|
508
|
+
emit('row-click', row.original, e)
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const handleRowKeydown = (row, e) => {
|
|
512
|
+
if (!isRowClickEnabled.value || shouldIgnoreRowClick(e)) return
|
|
513
|
+
if (!['Enter', ' '].includes(e.key)) return
|
|
514
|
+
e.preventDefault()
|
|
515
|
+
emit('row-click', row.original, e)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Compute --row-bg for pinned (sticky) cells.
|
|
519
|
+
// Non-pinned cells get background from Tailwind classes on <tr>.
|
|
520
|
+
// Pinned cells inherit --row-bg which must be a solid opaque color (no transparency → no bleed-through).
|
|
521
|
+
// color-mix() blends the Tailwind tint with the card color to produce an opaque equivalent.
|
|
522
|
+
// For normal/hover rows the <style scoped> CSS rule handles it; selected/preview override via inline style.
|
|
523
|
+
const pinnedRowStyle = (row) => {
|
|
524
|
+
if (props.previewRowId && row.original.id === props.previewRowId) {
|
|
525
|
+
// !bg-indigo-50 → solid indigo-50
|
|
526
|
+
return { '--row-bg': 'color-mix(in srgb, #eef2ff 100%, var(--card, #fff))' }
|
|
527
|
+
}
|
|
528
|
+
if (row.getIsSelected()) {
|
|
529
|
+
// bg-indigo-50/40 → 40% indigo-50 blended with card
|
|
530
|
+
return { '--row-bg': 'color-mix(in srgb, #eef2ff 40%, var(--card, #fff))' }
|
|
531
|
+
}
|
|
532
|
+
return {}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ─── Expose ───────────────────────────────────────────────────────────────────
|
|
536
|
+
const reloadTable = () => {
|
|
537
|
+
clearCache()
|
|
538
|
+
isDataFromCache.value = false
|
|
539
|
+
fetchData()
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
defineExpose({
|
|
543
|
+
getSelectedRows,
|
|
544
|
+
loading,
|
|
545
|
+
exportTable,
|
|
546
|
+
reload: reloadTable,
|
|
547
|
+
clearCache,
|
|
548
|
+
table,
|
|
549
|
+
setColumnOrder,
|
|
550
|
+
isDataFromCache,
|
|
551
|
+
cached: computed(() => props.cached),
|
|
552
|
+
paginationBarRef,
|
|
553
|
+
columnPinning,
|
|
554
|
+
pinColumn,
|
|
555
|
+
})
|
|
556
|
+
</script>
|
|
557
|
+
|
|
558
|
+
<template>
|
|
559
|
+
<div class="relative">
|
|
560
|
+
|
|
561
|
+
<!-- Table view -->
|
|
562
|
+
<div v-if="!isGridView" class="overflow-x-auto relative">
|
|
563
|
+
<table
|
|
564
|
+
class="relative divide-y divide-card-line"
|
|
565
|
+
:style="{ tableLayout: 'fixed', width: table.getTotalSize() + 'px', minWidth: '100%' }"
|
|
566
|
+
>
|
|
567
|
+
<colgroup>
|
|
568
|
+
<!-- Must use pinning order (left|center|right) — same as getHeaderGroups() and row.getVisibleCells() -->
|
|
569
|
+
<col
|
|
570
|
+
v-for="col in [...table.getLeftVisibleLeafColumns(), ...table.getCenterVisibleLeafColumns(), ...table.getRightVisibleLeafColumns()]"
|
|
571
|
+
:key="col.id"
|
|
572
|
+
:style="{ width: col.getSize() + 'px' }"
|
|
573
|
+
>
|
|
574
|
+
</colgroup>
|
|
575
|
+
<thead class="relative z-20 bg-card">
|
|
576
|
+
<template v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
|
577
|
+
<!-- Main header row -->
|
|
578
|
+
<tr class="bg-card">
|
|
579
|
+
<th
|
|
580
|
+
v-for="header in headerGroup.headers"
|
|
581
|
+
:key="header.id"
|
|
582
|
+
scope="col"
|
|
583
|
+
:draggable="header.id !== 'select' && resizeHoverId !== header.id && !header.column.getIsPinned()"
|
|
584
|
+
@dragstart="header.id !== 'select' && resizeHoverId !== header.id && !header.column.getIsPinned() && onHeaderDragStart(header.id)"
|
|
585
|
+
@dragover="header.id !== 'select' && onHeaderDragOver($event, header.id)"
|
|
586
|
+
@dragleave="onHeaderDragLeave"
|
|
587
|
+
@drop="header.id !== 'select' && onHeaderDrop(header.id)"
|
|
588
|
+
class="relative"
|
|
589
|
+
:class="[
|
|
590
|
+
header.id === 'select' ? 'text-center' : '',
|
|
591
|
+
dragOverHeaderId === header.id ? 'bg-indigo-50 dark:bg-indigo-900/20' : '',
|
|
592
|
+
header.column.getCanSort() ? 'cursor-pointer select-none' : '',
|
|
593
|
+
]"
|
|
594
|
+
:style="getPinnedStyles(header.column, true)"
|
|
595
|
+
@click="header.column.getCanSort() && header.column.toggleSorting()"
|
|
596
|
+
>
|
|
597
|
+
<!-- Select all checkbox -->
|
|
598
|
+
<template v-if="header.id === 'select'">
|
|
599
|
+
<input
|
|
600
|
+
type="checkbox"
|
|
601
|
+
:checked="table.getIsAllRowsSelected()"
|
|
602
|
+
:indeterminate="table.getIsSomeRowsSelected()"
|
|
603
|
+
@change="table.getToggleAllRowsSelectedHandler()($event)"
|
|
604
|
+
class="mx-2 shrink-0 border-card-line rounded-sm text-blue-900 focus:ring-0 focus:ring-offset-0 dark:bg-card"
|
|
605
|
+
/>
|
|
606
|
+
</template>
|
|
607
|
+
<!-- Regular column header -->
|
|
608
|
+
<template v-else>
|
|
609
|
+
<div
|
|
610
|
+
class="px-4 py-3 flex items-center gap-x-1 text-xs font-medium w-full overflow-hidden"
|
|
611
|
+
:class="header.column.getIsPinned() ? 'text-foreground' : 'text-muted-foreground'"
|
|
612
|
+
>
|
|
613
|
+
<IconPin v-if="header.column.getIsPinned()" class="size-3 shrink-0 text-indigo-400 dark:text-indigo-500" />
|
|
614
|
+
<span class="truncate">{{ header.column.columnDef.meta?.label ?? header.id }}</span>
|
|
615
|
+
<span v-if="header.column.getCanSort()">
|
|
616
|
+
<IconSelector v-if="!header.column.getIsSorted()" class="size-4 opacity-40" />
|
|
617
|
+
<IconChevronDown v-else-if="header.column.getIsSorted() === 'desc'" class="size-4" />
|
|
618
|
+
<IconChevronUp v-else class="size-4" />
|
|
619
|
+
</span>
|
|
620
|
+
</div>
|
|
621
|
+
<!-- Resize handle -->
|
|
622
|
+
<div
|
|
623
|
+
v-if="header.column.getCanResize()"
|
|
624
|
+
class="absolute right-0 top-0 h-full w-3 cursor-col-resize group/rz flex items-center justify-center select-none touch-none"
|
|
625
|
+
@mouseenter="resizeHoverId = header.id"
|
|
626
|
+
@mouseleave="resizeHoverId = null"
|
|
627
|
+
@mousedown.stop="header.getResizeHandler()?.($event)"
|
|
628
|
+
@touchstart.passive.stop="header.getResizeHandler()?.($event)"
|
|
629
|
+
@dblclick.stop="autoSizeColumn(header)"
|
|
630
|
+
@dragstart.stop.prevent
|
|
631
|
+
@click.stop
|
|
632
|
+
>
|
|
633
|
+
<div
|
|
634
|
+
class="h-4 w-px transition-all"
|
|
635
|
+
:class="header.column.getIsResizing()
|
|
636
|
+
? 'bg-indigo-400 dark:bg-indigo-500 !w-0.5'
|
|
637
|
+
: 'bg-surface-1 group-hover/rz:bg-indigo-300 dark:group-hover/rz:bg-indigo-600 group-hover/rz:w-0.5'"
|
|
638
|
+
/>
|
|
639
|
+
</div>
|
|
640
|
+
</template>
|
|
641
|
+
</th>
|
|
642
|
+
</tr>
|
|
643
|
+
|
|
644
|
+
<!-- Column filter row -->
|
|
645
|
+
<tr
|
|
646
|
+
v-if="hasFilterableColumns"
|
|
647
|
+
class="border-b border-card-line bg-muted/50"
|
|
648
|
+
>
|
|
649
|
+
<th
|
|
650
|
+
v-for="header in headerGroup.headers"
|
|
651
|
+
:key="'f-' + header.id"
|
|
652
|
+
:class="[
|
|
653
|
+
header.id === 'select' ? 'w-12' : 'px-3 py-1.5',
|
|
654
|
+
]"
|
|
655
|
+
:style="getPinnedStyles(header.column, true)"
|
|
656
|
+
>
|
|
657
|
+
<input
|
|
658
|
+
v-if="header.column.getCanFilter()"
|
|
659
|
+
:value="header.column.getFilterValue() ?? ''"
|
|
660
|
+
@input="(e) => header.column.setFilterValue(e.target.value || undefined)"
|
|
661
|
+
:placeholder="`Filtrar ${header.column.columnDef.meta?.label ?? ''}...`"
|
|
662
|
+
class="w-full bg-card border border-card-line rounded-lg text-xs text-muted-foreground-1 px-2.5 py-1 focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-400 dark:focus:border-indigo-500 outline-none transition-all"
|
|
663
|
+
/>
|
|
664
|
+
</th>
|
|
665
|
+
</tr>
|
|
666
|
+
</template>
|
|
667
|
+
</thead>
|
|
668
|
+
|
|
669
|
+
<tbody ref="tableBodyRef" class="divide-y divide-card-line">
|
|
670
|
+
<!-- Loading skeleton rows -->
|
|
671
|
+
<tr
|
|
672
|
+
v-if="loading"
|
|
673
|
+
v-for="(_, i) in skeletonRows"
|
|
674
|
+
:key="'sk-' + i"
|
|
675
|
+
class="animate-pulse bg-card"
|
|
676
|
+
>
|
|
677
|
+
<td
|
|
678
|
+
v-for="header in (table.getHeaderGroups()[0]?.headers ?? [])"
|
|
679
|
+
:key="'skc-' + header.id"
|
|
680
|
+
:class="[
|
|
681
|
+
header.id === 'select' ? 'text-center w-12' : 'px-4 overflow-hidden',
|
|
682
|
+
]"
|
|
683
|
+
:style="{ height: lastRowHeight + 'px', ...getPinnedStyles(header.column) }"
|
|
684
|
+
>
|
|
685
|
+
<div v-if="header.id === 'select'" class="w-4 h-4 bg-surface-1 rounded mx-auto"></div>
|
|
686
|
+
<div v-else class="h-4 w-[50%] rounded bg-surface-1"></div>
|
|
687
|
+
</td>
|
|
688
|
+
</tr>
|
|
689
|
+
|
|
690
|
+
<!-- Loading filler rows: pad to pageSize so table height doesn't change -->
|
|
691
|
+
<tr
|
|
692
|
+
v-if="loading && skeletonRows.length < pagination.pageSize"
|
|
693
|
+
v-for="i in (pagination.pageSize - skeletonRows.length)"
|
|
694
|
+
:key="'lf-' + i"
|
|
695
|
+
class="bg-card"
|
|
696
|
+
>
|
|
697
|
+
<td
|
|
698
|
+
v-for="header in (table.getHeaderGroups()[0]?.headers ?? [])"
|
|
699
|
+
:key="'lfc-' + header.id"
|
|
700
|
+
:style="{ height: lastRowHeight + 'px', ...getPinnedStyles(header.column) }"
|
|
701
|
+
/>
|
|
702
|
+
</tr>
|
|
703
|
+
|
|
704
|
+
<!-- Empty filler rows: maintain table height when no results -->
|
|
705
|
+
<tr
|
|
706
|
+
v-if="!loading && tableData.length === 0"
|
|
707
|
+
v-for="i in pagination.pageSize"
|
|
708
|
+
:key="'esk-' + i"
|
|
709
|
+
class="bg-card"
|
|
710
|
+
>
|
|
711
|
+
<td
|
|
712
|
+
v-for="header in (table.getHeaderGroups()[0]?.headers ?? [])"
|
|
713
|
+
:key="'eskc-' + header.id"
|
|
714
|
+
:style="{ height: lastRowHeight + 'px', ...getPinnedStyles(header.column) }"
|
|
715
|
+
/>
|
|
716
|
+
</tr>
|
|
717
|
+
|
|
718
|
+
<!-- Data rows -->
|
|
719
|
+
<tr
|
|
720
|
+
v-else
|
|
721
|
+
v-for="row in table.getRowModel().rows"
|
|
722
|
+
:key="row.id"
|
|
723
|
+
data-row-type="data"
|
|
724
|
+
@click="(e) => handleRowClick(row, e)"
|
|
725
|
+
@keydown="(e) => handleRowKeydown(row, e)"
|
|
726
|
+
:tabindex="isRowClickEnabled ? 0 : undefined"
|
|
727
|
+
class="bg-card hover:bg-layer-hover transition-colors"
|
|
728
|
+
:class="{
|
|
729
|
+
'cursor-pointer': isRowClickEnabled,
|
|
730
|
+
'bg-indigo-50/40 dark:bg-indigo-900/10 hover:bg-indigo-50/60': row.getIsSelected(),
|
|
731
|
+
'!bg-indigo-50 dark:!bg-indigo-900/20 ring-1 ring-inset ring-indigo-200 dark:ring-indigo-700': previewRowId && row.original.id === previewRowId,
|
|
732
|
+
}"
|
|
733
|
+
:style="pinnedRowStyle(row)"
|
|
734
|
+
>
|
|
735
|
+
<td
|
|
736
|
+
v-for="cell in row.getVisibleCells()"
|
|
737
|
+
:key="cell.id"
|
|
738
|
+
:data-col-id="cell.column.id"
|
|
739
|
+
:class="[
|
|
740
|
+
cell.column.id === 'select'
|
|
741
|
+
? 'text-center w-12 overflow-hidden'
|
|
742
|
+
: 'px-4 py-3 text-sm text-muted-foreground-1 overflow-hidden',
|
|
743
|
+
cell.column.id !== 'select' ? cell.column.columnDef.meta?.class ?? '' : '',
|
|
744
|
+
]"
|
|
745
|
+
:style="getPinnedStyles(cell.column)"
|
|
746
|
+
>
|
|
747
|
+
<!-- Select checkbox -->
|
|
748
|
+
<template v-if="cell.column.id === 'select'">
|
|
749
|
+
<div @click.stop>
|
|
750
|
+
<input
|
|
751
|
+
type="checkbox"
|
|
752
|
+
:checked="row.getIsSelected()"
|
|
753
|
+
:disabled="!row.getCanSelect()"
|
|
754
|
+
@change="row.getToggleSelectedHandler()($event)"
|
|
755
|
+
class="rounded border-card-line focus:ring-0 focus:ring-offset-0 dark:bg-card"
|
|
756
|
+
/>
|
|
757
|
+
</div>
|
|
758
|
+
</template>
|
|
759
|
+
<!-- Data cell with slot -->
|
|
760
|
+
<template v-else>
|
|
761
|
+
<slot :name="cell.column.id" :row="row.original" :value="cell.getValue()">
|
|
762
|
+
{{ cell.getValue() }}
|
|
763
|
+
</slot>
|
|
764
|
+
</template>
|
|
765
|
+
</td>
|
|
766
|
+
</tr>
|
|
767
|
+
|
|
768
|
+
<!-- Filler rows: pad table to full page height when data < perPage -->
|
|
769
|
+
<tr
|
|
770
|
+
v-if="!loading && tableData.length > 0 && tableData.length < pagination.pageSize"
|
|
771
|
+
v-for="i in (pagination.pageSize - tableData.length)"
|
|
772
|
+
:key="'fill-' + i"
|
|
773
|
+
class="bg-card"
|
|
774
|
+
>
|
|
775
|
+
<td
|
|
776
|
+
v-for="header in (table.getHeaderGroups()[0]?.headers ?? [])"
|
|
777
|
+
:key="'fillc-' + header.id"
|
|
778
|
+
:style="{ height: lastRowHeight + 'px', ...getPinnedStyles(header.column) }"
|
|
779
|
+
/>
|
|
780
|
+
</tr>
|
|
781
|
+
</tbody>
|
|
782
|
+
</table>
|
|
783
|
+
|
|
784
|
+
<!-- Empty state overlays -->
|
|
785
|
+
<div
|
|
786
|
+
v-if="!loading && tableData.length === 0 && !search && !columnFilters.length"
|
|
787
|
+
class="absolute inset-0 z-10 pointer-events-none flex items-center justify-center backdrop-blur-sm bg-card/60 rounded-xl"
|
|
788
|
+
>
|
|
789
|
+
<slot name="empty">
|
|
790
|
+
<p class="text-muted-foreground text-lg font-medium italic">No hay registros</p>
|
|
791
|
+
</slot>
|
|
792
|
+
</div>
|
|
793
|
+
|
|
794
|
+
<div
|
|
795
|
+
v-if="!loading && tableData.length === 0 && (search || columnFilters.length)"
|
|
796
|
+
class="absolute inset-0 z-10 pointer-events-none flex items-center justify-center backdrop-blur-sm bg-card/60 rounded-xl"
|
|
797
|
+
>
|
|
798
|
+
<slot name="empty-search">
|
|
799
|
+
<p class="text-muted-foreground text-lg font-medium italic">No hay registros en la búsqueda</p>
|
|
800
|
+
</slot>
|
|
801
|
+
</div>
|
|
802
|
+
</div>
|
|
803
|
+
|
|
804
|
+
<!-- Grid view -->
|
|
805
|
+
<div v-else class="relative">
|
|
806
|
+
<div v-if="loading" :class="gridClass">
|
|
807
|
+
<div v-for="(_, i) in skeletonRows" :key="'gsk-' + i" class="animate-pulse">
|
|
808
|
+
<slot name="grid-skeleton">
|
|
809
|
+
<div class="bg-card rounded-lg border border-card-line p-4">
|
|
810
|
+
<div class="space-y-3">
|
|
811
|
+
<div class="h-4 bg-surface-1 rounded w-3/4"></div>
|
|
812
|
+
<div class="h-4 bg-surface-1 rounded w-1/2"></div>
|
|
813
|
+
<div class="h-6 bg-surface-1 rounded w-1/4"></div>
|
|
814
|
+
</div>
|
|
815
|
+
</div>
|
|
816
|
+
</slot>
|
|
817
|
+
</div>
|
|
818
|
+
</div>
|
|
819
|
+
|
|
820
|
+
<div v-else-if="tableData.length > 0" :class="gridClass">
|
|
821
|
+
<slot
|
|
822
|
+
name="grid-item"
|
|
823
|
+
v-for="row in table.getRowModel().rows"
|
|
824
|
+
:key="row.id"
|
|
825
|
+
:row="row.original"
|
|
826
|
+
:tanstack-row="row"
|
|
827
|
+
:is-selected="row.getIsSelected()"
|
|
828
|
+
:checkable="checkable"
|
|
829
|
+
:toggle-row="() => row.toggleSelected()"
|
|
830
|
+
>
|
|
831
|
+
<div class="bg-card rounded-lg border border-card-line p-4 hover:shadow-md transition-shadow relative"
|
|
832
|
+
:class="{ 'ring-2 ring-indigo-400 dark:ring-indigo-600': row.getIsSelected() }">
|
|
833
|
+
<div v-if="checkable" class="absolute top-2 left-2 z-10">
|
|
834
|
+
<input type="checkbox" :checked="row.getIsSelected()" @change="row.toggleSelected()"
|
|
835
|
+
class="rounded border-card-line dark:bg-card" />
|
|
836
|
+
</div>
|
|
837
|
+
<div class="space-y-2" :class="{ 'pt-6': checkable }">
|
|
838
|
+
<div v-for="cell in row.getVisibleCells().filter(c => c.column.id !== 'select')" :key="cell.id" class="flex justify-between">
|
|
839
|
+
<span class="text-sm text-muted-foreground">{{ cell.column.columnDef.meta?.label ?? cell.column.id }}:</span>
|
|
840
|
+
<span class="text-sm text-foreground">
|
|
841
|
+
<slot :name="cell.column.id" :row="row.original" :value="cell.getValue()">{{ cell.getValue() }}</slot>
|
|
842
|
+
</span>
|
|
843
|
+
</div>
|
|
844
|
+
</div>
|
|
845
|
+
</div>
|
|
846
|
+
</slot>
|
|
847
|
+
</div>
|
|
848
|
+
|
|
849
|
+
<div v-else class="flex items-center justify-center py-12">
|
|
850
|
+
<slot v-if="!search && !columnFilters.length" name="empty">
|
|
851
|
+
<p class="text-muted-foreground text-lg">No hay registros</p>
|
|
852
|
+
</slot>
|
|
853
|
+
<slot v-else name="empty-search">
|
|
854
|
+
<p class="text-muted-foreground text-lg">No hay registros en la búsqueda</p>
|
|
855
|
+
</slot>
|
|
856
|
+
</div>
|
|
857
|
+
</div>
|
|
858
|
+
|
|
859
|
+
<!-- Pagination & controls bar -->
|
|
860
|
+
<div ref="paginationBarRef" class="flex flex-col sm:flex-row items-center justify-between gap-y-4 sm:gap-y-0 px-4 py-3 border-t border-card-line">
|
|
861
|
+
<!-- Left: reload, total, cache, columns button -->
|
|
862
|
+
<div class="flex items-center gap-x-4 flex-wrap gap-y-2">
|
|
863
|
+
<!-- Reload button -->
|
|
864
|
+
<div v-if="showReloadButton" class="flex items-center gap-x-2">
|
|
865
|
+
<IconReload
|
|
866
|
+
v-if="!loading"
|
|
867
|
+
class="size-4 cursor-pointer text-muted-foreground hover:text-muted-foreground-1 transition-colors"
|
|
868
|
+
@click="reloadTable"
|
|
869
|
+
/>
|
|
870
|
+
<div v-else>
|
|
871
|
+
<svg class="animate-spin size-4 text-muted-foreground-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
872
|
+
<circle cx="12" cy="12" r="10" opacity=".25" />
|
|
873
|
+
<path d="M22 12a10 10 0 0 1-10 10" />
|
|
874
|
+
</svg>
|
|
875
|
+
</div>
|
|
876
|
+
</div>
|
|
877
|
+
|
|
878
|
+
<!-- Total records -->
|
|
879
|
+
<p class="text-sm text-foreground font-medium">{{ rowCount }} registros</p>
|
|
880
|
+
|
|
881
|
+
<!-- Cache badge -->
|
|
882
|
+
<div v-if="isDataFromCache && cached" class="group relative flex items-center">
|
|
883
|
+
<div class="flex items-center gap-x-1.5 py-1 px-2.5 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 rounded-lg cursor-help hover:bg-emerald-500/20 transition-colors">
|
|
884
|
+
<IconBolt class="size-3.5 fill-current" />
|
|
885
|
+
<span class="text-[10px] font-bold uppercase tracking-wider">Instant</span>
|
|
886
|
+
</div>
|
|
887
|
+
<div class="absolute bottom-full mb-2 left-0 hidden group-hover:block w-48 p-2.5 bg-slate-900 text-white text-[11px] leading-relaxed rounded-xl shadow-2xl z-50">
|
|
888
|
+
<div class="font-bold mb-1 flex items-center gap-x-1.5 text-emerald-400">
|
|
889
|
+
<IconBolt class="size-3" /> Datos en Caché
|
|
890
|
+
</div>
|
|
891
|
+
Los datos se cargaron instantáneamente desde la memoria local. Actualice para sincronizar con el servidor.
|
|
892
|
+
<div class="absolute top-full left-4 -mt-1 border-4 border-transparent border-t-slate-900"></div>
|
|
893
|
+
</div>
|
|
894
|
+
</div>
|
|
895
|
+
|
|
896
|
+
</div>
|
|
897
|
+
|
|
898
|
+
<!-- Right: per-page + pagination -->
|
|
899
|
+
<div class="flex items-center gap-x-8">
|
|
900
|
+
<!-- Per page selector -->
|
|
901
|
+
<div class="flex items-center gap-x-2">
|
|
902
|
+
<label class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">Filas:</label>
|
|
903
|
+
<select
|
|
904
|
+
v-if="!isCustomPerPage"
|
|
905
|
+
:value="pagination.pageSize"
|
|
906
|
+
@change="(e) => handlePerPageChange(e.target.value)"
|
|
907
|
+
class="bg-surface border-none text-[11px] font-bold text-muted-foreground-1 rounded-lg focus:ring-0 cursor-pointer py-1 pl-2 pr-8"
|
|
908
|
+
>
|
|
909
|
+
<option :value="10">10</option>
|
|
910
|
+
<option :value="25">25</option>
|
|
911
|
+
<option :value="50">50</option>
|
|
912
|
+
<option :value="100">100</option>
|
|
913
|
+
<option value="custom">Otro...</option>
|
|
914
|
+
</select>
|
|
915
|
+
<div v-else class="flex items-center gap-x-1">
|
|
916
|
+
<input
|
|
917
|
+
type="number"
|
|
918
|
+
:value="pagination.pageSize"
|
|
919
|
+
@change="(e) => table.setPageSize(parseInt(e.target.value) || 10)"
|
|
920
|
+
min="1" max="500"
|
|
921
|
+
class="w-14 bg-surface border-none text-[11px] font-bold text-muted-foreground-1 rounded-lg focus:ring-2 focus:ring-indigo-500/20 py-1 px-2"
|
|
922
|
+
/>
|
|
923
|
+
<button @click="resetPerPage" class="text-[10px] text-indigo-500 font-bold hover:underline">Volver</button>
|
|
924
|
+
</div>
|
|
925
|
+
</div>
|
|
926
|
+
|
|
927
|
+
<!-- Pagination nav -->
|
|
928
|
+
<nav class="flex justify-end items-center gap-x-1" aria-label="Pagination">
|
|
929
|
+
<button
|
|
930
|
+
type="button"
|
|
931
|
+
class="size-8 flex items-center justify-center rounded-lg text-foreground hover:bg-muted-hover disabled:opacity-30"
|
|
932
|
+
:disabled="!table.getCanPreviousPage()"
|
|
933
|
+
@click="table.previousPage()"
|
|
934
|
+
>
|
|
935
|
+
<svg class="shrink-0 size-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
936
|
+
<path d="m15 18-6-6 6-6" />
|
|
937
|
+
</svg>
|
|
938
|
+
</button>
|
|
939
|
+
<div class="flex items-center gap-x-1 mx-2">
|
|
940
|
+
<span class="size-8 flex items-center justify-center text-xs font-bold rounded-lg bg-surface text-foreground">
|
|
941
|
+
{{ pagination.pageIndex + 1 }}
|
|
942
|
+
</span>
|
|
943
|
+
<span class="text-[10px] font-bold text-muted-foreground uppercase mx-1">de</span>
|
|
944
|
+
<span class="text-[10px] font-bold text-muted-foreground">{{ table.getPageCount() }}</span>
|
|
945
|
+
</div>
|
|
946
|
+
<button
|
|
947
|
+
type="button"
|
|
948
|
+
class="size-8 flex items-center justify-center rounded-lg text-foreground hover:bg-muted-hover disabled:opacity-30"
|
|
949
|
+
:disabled="!table.getCanNextPage()"
|
|
950
|
+
@click="table.nextPage()"
|
|
951
|
+
>
|
|
952
|
+
<svg class="shrink-0 size-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
953
|
+
<path d="m9 18 6-6-6-6" />
|
|
954
|
+
</svg>
|
|
955
|
+
</button>
|
|
956
|
+
</nav>
|
|
957
|
+
</div>
|
|
958
|
+
</div>
|
|
959
|
+
</div>
|
|
960
|
+
</template>
|
|
961
|
+
|
|
962
|
+
<style scoped>
|
|
963
|
+
/* --row-bg drives the background of sticky (pinned) body cells.
|
|
964
|
+
Only solid, opaque values here — semi-transparent tints for selected/preview
|
|
965
|
+
rows intentionally do NOT override --row-bg, so pinned cells stay opaque
|
|
966
|
+
and text from scrolling content can't bleed through. */
|
|
967
|
+
tbody tr {
|
|
968
|
+
--row-bg: var(--card, #fff);
|
|
969
|
+
}
|
|
970
|
+
tbody tr:hover {
|
|
971
|
+
--row-bg: var(--layer-hover, #f8fafc);
|
|
972
|
+
}
|
|
973
|
+
</style>
|
|
974
|
+
|