@innertia-solutions/nuxt-theme-spark 0.1.13 → 0.1.14
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/components/Table/DataTable.vue +713 -0
- package/package.json +1 -1
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import {
|
|
3
|
+
ref,
|
|
4
|
+
onMounted,
|
|
5
|
+
watch,
|
|
6
|
+
computed,
|
|
7
|
+
nextTick,
|
|
8
|
+
onBeforeUnmount,
|
|
9
|
+
getCurrentInstance,
|
|
10
|
+
} from "vue";
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
IconArrowsSort,
|
|
14
|
+
IconSortAscendingSmallBig,
|
|
15
|
+
IconSortDescendingSmallBig,
|
|
16
|
+
IconReload,
|
|
17
|
+
IconBolt,
|
|
18
|
+
} from "@tabler/icons-vue";
|
|
19
|
+
|
|
20
|
+
const props = defineProps({
|
|
21
|
+
endpoint: { type: String, required: true },
|
|
22
|
+
params: { type: Object, default: () => ({}) },
|
|
23
|
+
columns: { type: Array, required: true }, // [{ key, label, sortable, class }]
|
|
24
|
+
checkable: { type: Boolean, default: false },
|
|
25
|
+
search: { type: String, default: "" },
|
|
26
|
+
showReloadButton: { type: Boolean, default: true },
|
|
27
|
+
cached: { type: Boolean, default: false },
|
|
28
|
+
name: { type: String, required: true },
|
|
29
|
+
viewMode: { type: String, default: "table" }, // 'table' | 'grid'
|
|
30
|
+
gridClass: { type: String, default: "grid grid-cols-2 lg:grid-cols-3 gap-4" },
|
|
31
|
+
clickRowToOpen: { type: Boolean, default: false },
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const emit = defineEmits(["update:search", "row-click", "loaded"]);
|
|
35
|
+
const instance = getCurrentInstance();
|
|
36
|
+
|
|
37
|
+
const api = useApi();
|
|
38
|
+
const toast = useToast();
|
|
39
|
+
const data = ref([]);
|
|
40
|
+
const meta = ref({});
|
|
41
|
+
const loading = ref(false);
|
|
42
|
+
const page = ref(1);
|
|
43
|
+
const perPage = ref(10);
|
|
44
|
+
const isCustomPerPage = ref(false);
|
|
45
|
+
const selectedRows = ref([]);
|
|
46
|
+
const isDataFromCache = ref(false);
|
|
47
|
+
const lastDataLength = ref(10);
|
|
48
|
+
const lastRowHeight = ref(48);
|
|
49
|
+
const tableBodyRef = ref(null);
|
|
50
|
+
const skeletonRows = computed(() => Array.from({ length: lastDataLength.value }));
|
|
51
|
+
const hasRecords = computed(() => data.value.length > 0);
|
|
52
|
+
const selectedCount = computed(() => selectedRows.value.length);
|
|
53
|
+
const isGridView = computed(() => props.viewMode === "grid");
|
|
54
|
+
|
|
55
|
+
const sortColumns = ref([]);
|
|
56
|
+
let isRestoring = false;
|
|
57
|
+
let searchWatcher = null;
|
|
58
|
+
|
|
59
|
+
const initializeSortColumns = () => {
|
|
60
|
+
if (props.params.sort && typeof props.params.sort === "string") {
|
|
61
|
+
const parts = props.params.sort.split(":");
|
|
62
|
+
if (parts.length === 2) {
|
|
63
|
+
sortColumns.value = [{ column: parts[0], direction: parts[1] }];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// CACHE
|
|
69
|
+
const generateCacheKey = () => {
|
|
70
|
+
if (!props.cached || !props.name) return null;
|
|
71
|
+
return `table_cache_${props.name}`;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const saveToCache = () => {
|
|
75
|
+
if (!props.cached) return;
|
|
76
|
+
const cacheKey = generateCacheKey();
|
|
77
|
+
if (!cacheKey || !data.value?.length) return;
|
|
78
|
+
try {
|
|
79
|
+
sessionStorage.setItem(cacheKey, JSON.stringify({
|
|
80
|
+
data: data.value,
|
|
81
|
+
meta: meta.value,
|
|
82
|
+
page: page.value,
|
|
83
|
+
selectedRows: selectedRows.value,
|
|
84
|
+
sortColumns: sortColumns.value,
|
|
85
|
+
perPage: perPage.value,
|
|
86
|
+
isCustomPerPage: isCustomPerPage.value,
|
|
87
|
+
search: props.search,
|
|
88
|
+
timestamp: Date.now(),
|
|
89
|
+
}));
|
|
90
|
+
} catch (e) {
|
|
91
|
+
console.warn("[DataTable] Error saving cache:", e);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const loadFromCache = () => {
|
|
96
|
+
if (!props.cached) return null;
|
|
97
|
+
const cacheKey = generateCacheKey();
|
|
98
|
+
if (!cacheKey) return null;
|
|
99
|
+
try {
|
|
100
|
+
const cached = sessionStorage.getItem(cacheKey);
|
|
101
|
+
if (!cached) return null;
|
|
102
|
+
const cacheData = JSON.parse(cached);
|
|
103
|
+
if (Date.now() - cacheData.timestamp > 10 * 60 * 1000) {
|
|
104
|
+
sessionStorage.removeItem(cacheKey);
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
if (cacheData.search !== props.search) return null;
|
|
108
|
+
return cacheData;
|
|
109
|
+
} catch (e) {
|
|
110
|
+
console.warn("[DataTable] Error loading cache:", e);
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const clearCache = () => {
|
|
116
|
+
const cacheKey = generateCacheKey();
|
|
117
|
+
if (cacheKey) sessionStorage.removeItem(cacheKey);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const buildRequestParams = () => {
|
|
121
|
+
const { sort, ...otherParams } = props.params;
|
|
122
|
+
return {
|
|
123
|
+
search: props.search,
|
|
124
|
+
page: page.value,
|
|
125
|
+
perPage: perPage.value,
|
|
126
|
+
...otherParams,
|
|
127
|
+
sortColumns: sortColumns.value
|
|
128
|
+
.filter((col) => col.direction !== null)
|
|
129
|
+
.map(({ column, direction }) => ({ column, direction })),
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const fetchData = async () => {
|
|
134
|
+
if (data.value.length > 0) {
|
|
135
|
+
lastDataLength.value = data.value.length;
|
|
136
|
+
if (tableBodyRef.value?.children[0]) {
|
|
137
|
+
const rowHeight = tableBodyRef.value.children[0].getBoundingClientRect().height;
|
|
138
|
+
if (rowHeight > 0) lastRowHeight.value = rowHeight;
|
|
139
|
+
}
|
|
140
|
+
} else if (!props.search && !Object.keys(props.params).length && page.value === 1) {
|
|
141
|
+
lastDataLength.value = 10;
|
|
142
|
+
lastRowHeight.value = 48;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
data.value = [];
|
|
146
|
+
loading.value = true;
|
|
147
|
+
isDataFromCache.value = false;
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const res = await api.post(props.endpoint, buildRequestParams());
|
|
151
|
+
|
|
152
|
+
if (res) {
|
|
153
|
+
data.value = Array.isArray(res.data) ? res.data : (Array.isArray(res) ? res : []);
|
|
154
|
+
|
|
155
|
+
if (res.meta) {
|
|
156
|
+
meta.value = res.meta;
|
|
157
|
+
} else if (res.current_page !== undefined) {
|
|
158
|
+
meta.value = {
|
|
159
|
+
current_page: res.current_page,
|
|
160
|
+
last_page: res.last_page,
|
|
161
|
+
per_page: res.per_page,
|
|
162
|
+
total: res.total,
|
|
163
|
+
from: res.from,
|
|
164
|
+
to: res.to,
|
|
165
|
+
};
|
|
166
|
+
} else {
|
|
167
|
+
meta.value = {
|
|
168
|
+
current_page: 1,
|
|
169
|
+
last_page: 1,
|
|
170
|
+
per_page: data.value.length,
|
|
171
|
+
total: data.value.length,
|
|
172
|
+
from: 1,
|
|
173
|
+
to: data.value.length,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (props.cached) saveToCache();
|
|
178
|
+
emit("loaded", res);
|
|
179
|
+
}
|
|
180
|
+
} catch (e) {
|
|
181
|
+
console.error("[DataTable] Fetch error:", e);
|
|
182
|
+
} finally {
|
|
183
|
+
loading.value = false;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const loadFromCacheOnMount = async () => {
|
|
188
|
+
if (!props.cached) return false;
|
|
189
|
+
const cached = loadFromCache();
|
|
190
|
+
if (!cached) return false;
|
|
191
|
+
|
|
192
|
+
stopWatching();
|
|
193
|
+
stopSearchWatcher();
|
|
194
|
+
|
|
195
|
+
data.value = cached.data;
|
|
196
|
+
meta.value = cached.meta;
|
|
197
|
+
page.value = cached.page;
|
|
198
|
+
perPage.value = cached.perPage || 10;
|
|
199
|
+
isCustomPerPage.value = cached.isCustomPerPage || false;
|
|
200
|
+
selectedRows.value = cached.selectedRows;
|
|
201
|
+
sortColumns.value = cached.sortColumns;
|
|
202
|
+
lastDataLength.value = cached.data.length;
|
|
203
|
+
isDataFromCache.value = true;
|
|
204
|
+
|
|
205
|
+
if (cached.search !== props.search) {
|
|
206
|
+
emit("update:search", cached.search);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
await nextTick();
|
|
210
|
+
startWatching();
|
|
211
|
+
startSearchWatcher();
|
|
212
|
+
return true;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
let searchDebounceTimeout = null;
|
|
216
|
+
|
|
217
|
+
const startSearchWatcher = () => {
|
|
218
|
+
if (searchWatcher) return;
|
|
219
|
+
searchWatcher = watch(
|
|
220
|
+
() => props.search,
|
|
221
|
+
(newSearch, oldSearch) => {
|
|
222
|
+
if (newSearch === oldSearch) return;
|
|
223
|
+
if (searchDebounceTimeout) clearTimeout(searchDebounceTimeout);
|
|
224
|
+
searchDebounceTimeout = setTimeout(() => {
|
|
225
|
+
page.value = 1;
|
|
226
|
+
fetchData();
|
|
227
|
+
}, 500);
|
|
228
|
+
}
|
|
229
|
+
);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const stopSearchWatcher = () => {
|
|
233
|
+
if (searchWatcher) { searchWatcher(); searchWatcher = null; }
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
let pageWatcher = null;
|
|
237
|
+
|
|
238
|
+
const startWatching = () => {
|
|
239
|
+
if (pageWatcher) return;
|
|
240
|
+
pageWatcher = watch([page, sortColumns], () => fetchData(), { deep: true });
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const stopWatching = () => {
|
|
244
|
+
if (pageWatcher) { pageWatcher(); pageWatcher = null; }
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
onMounted(async () => {
|
|
248
|
+
initializeSortColumns();
|
|
249
|
+
startWatching();
|
|
250
|
+
startSearchWatcher();
|
|
251
|
+
try {
|
|
252
|
+
const loadedFromCache = await loadFromCacheOnMount();
|
|
253
|
+
if (!loadedFromCache) await fetchData();
|
|
254
|
+
} catch (e) {
|
|
255
|
+
console.error("[DataTable] Error during mount:", e);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
onBeforeUnmount(() => {
|
|
260
|
+
if (searchDebounceTimeout) clearTimeout(searchDebounceTimeout);
|
|
261
|
+
if (props.cached && data.value.length > 0) saveToCache();
|
|
262
|
+
stopWatching();
|
|
263
|
+
stopSearchWatcher();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
watch(perPage, () => { page.value = 1; fetchData(); });
|
|
267
|
+
|
|
268
|
+
watch(() => props.params, () => { page.value = 1; fetchData(); }, { deep: true });
|
|
269
|
+
|
|
270
|
+
// ROW SELECTION
|
|
271
|
+
const toggleSelectAll = () => {
|
|
272
|
+
const allSelected = isAllVisibleSelected.value;
|
|
273
|
+
selectedRows.value = allSelected ? [] : [...data.value];
|
|
274
|
+
if (props.cached && data.value.length > 0) nextTick(() => saveToCache());
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const toggleRow = (row) => {
|
|
278
|
+
const exists = selectedRows.value.find((r) => r.id === row.id);
|
|
279
|
+
if (exists) {
|
|
280
|
+
selectedRows.value = selectedRows.value.filter((r) => r.id !== row.id);
|
|
281
|
+
} else {
|
|
282
|
+
selectedRows.value.push(row);
|
|
283
|
+
}
|
|
284
|
+
if (props.cached && data.value.length > 0) nextTick(() => saveToCache());
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const isRowSelected = (row) => selectedRows.value.some((r) => r.id === row.id);
|
|
288
|
+
|
|
289
|
+
const isAllVisibleSelected = computed(
|
|
290
|
+
() => data.value.length && data.value.every((row) => isRowSelected(row))
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
const getSelectedRows = () => {
|
|
294
|
+
const allSelected = isAllVisibleSelected.value && selectedRows.value.length === data.value.length;
|
|
295
|
+
return allSelected
|
|
296
|
+
? { meta: { all: true }, rows: [] }
|
|
297
|
+
: { meta: { all: false }, rows: [...selectedRows.value] };
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
// EXPORT
|
|
301
|
+
const exportTable = async (format, exportPagination, exportFilteredRows) => {
|
|
302
|
+
const { download } = useDownload();
|
|
303
|
+
const id = crypto.randomUUID();
|
|
304
|
+
toast.show({
|
|
305
|
+
id,
|
|
306
|
+
type: "process",
|
|
307
|
+
title: "Descargando archivo...",
|
|
308
|
+
progress: 0,
|
|
309
|
+
progressLabel: "Iniciando descarga",
|
|
310
|
+
message: "",
|
|
311
|
+
position: "top-right",
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const exportType = ["csv", "xlsx", "pdf", "json"];
|
|
315
|
+
const params = {
|
|
316
|
+
...buildRequestParams(),
|
|
317
|
+
exportType: exportType.includes(format) ? format : "csv",
|
|
318
|
+
exportPagination,
|
|
319
|
+
exportFilteredRows,
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
const { blob, headers } = await download(props.endpoint, params, {
|
|
324
|
+
method: "POST",
|
|
325
|
+
onProgress: (percent) => {
|
|
326
|
+
toast.update(id, { progress: percent, progressLabel: `Descargando... ${percent}%` });
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
let fileName = "export." + format;
|
|
331
|
+
const contentDisposition = headers["content-disposition"];
|
|
332
|
+
if (contentDisposition) {
|
|
333
|
+
const fileNameMatch = contentDisposition.match(/filename="(.+)"/);
|
|
334
|
+
if (fileNameMatch?.[1]) fileName = fileNameMatch[1];
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const url = window.URL.createObjectURL(blob);
|
|
338
|
+
const link = document.createElement("a");
|
|
339
|
+
link.href = url;
|
|
340
|
+
link.setAttribute("download", fileName);
|
|
341
|
+
document.body.appendChild(link);
|
|
342
|
+
link.click();
|
|
343
|
+
link.parentNode.removeChild(link);
|
|
344
|
+
window.URL.revokeObjectURL(url);
|
|
345
|
+
|
|
346
|
+
toast.update(id, { progress: 100, progressLabel: "¡Descarga completada!", message: "El archivo se descargó correctamente." });
|
|
347
|
+
setTimeout(() => toast.remove(id), 2000);
|
|
348
|
+
} catch (e) {
|
|
349
|
+
toast.update(id, { progressLabel: "Error en la descarga", message: e.message, severity: "danger" });
|
|
350
|
+
setTimeout(() => toast.remove(id), 3000);
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
defineExpose({
|
|
355
|
+
getSelectedRows,
|
|
356
|
+
hasRecords,
|
|
357
|
+
selectedCount,
|
|
358
|
+
loading,
|
|
359
|
+
exportTable,
|
|
360
|
+
reload: () => { clearCache(); fetchData(); },
|
|
361
|
+
clearCache,
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// PAGINATION
|
|
365
|
+
const goToNextPage = () => { if (page.value < meta.value.last_page) page.value++; };
|
|
366
|
+
const goToPreviousPage = () => { if (page.value > 1) page.value--; };
|
|
367
|
+
|
|
368
|
+
const handlePerPageChange = (val) => {
|
|
369
|
+
if (val === "custom") { isCustomPerPage.value = true; return; }
|
|
370
|
+
perPage.value = parseInt(val);
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
const resetPerPage = () => {
|
|
374
|
+
isCustomPerPage.value = false;
|
|
375
|
+
if (![10, 25, 50, 100].includes(perPage.value)) perPage.value = 10;
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
// SORTING
|
|
379
|
+
const toggleSort = (col) => {
|
|
380
|
+
if (!col.sortable) return;
|
|
381
|
+
const existing = sortColumns.value.find((s) => s.column === col.key);
|
|
382
|
+
if (!existing) {
|
|
383
|
+
sortColumns.value.push({ column: col.key, direction: "desc" });
|
|
384
|
+
} else if (existing.direction === "desc") {
|
|
385
|
+
existing.direction = "asc";
|
|
386
|
+
} else {
|
|
387
|
+
sortColumns.value = sortColumns.value.filter((s) => s.column !== col.key);
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const getSortDirection = (colKey) =>
|
|
392
|
+
sortColumns.value.find((s) => s.column === colKey)?.direction ?? null;
|
|
393
|
+
|
|
394
|
+
// ROW CLICK
|
|
395
|
+
const interactiveRowClickSelector = [
|
|
396
|
+
"a", "button", "input", "select", "textarea", "label", "summary",
|
|
397
|
+
"[role='button']", "[role='link']", "[contenteditable='true']",
|
|
398
|
+
"[data-row-click-ignore]", "[data-no-row-click]", ".hs-dropdown", ".dropdown",
|
|
399
|
+
].join(",");
|
|
400
|
+
|
|
401
|
+
const hasRowClickListener = computed(() => !!instance?.vnode?.props?.onRowClick);
|
|
402
|
+
const isRowClickEnabled = computed(() => props.clickRowToOpen || hasRowClickListener.value);
|
|
403
|
+
|
|
404
|
+
const shouldIgnoreRowClick = (event) => {
|
|
405
|
+
const target = event?.target;
|
|
406
|
+
if (!(target instanceof Element)) return false;
|
|
407
|
+
const interactiveTarget = target.closest(interactiveRowClickSelector);
|
|
408
|
+
return !!interactiveTarget && event.currentTarget?.contains(interactiveTarget);
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
const handleRowClick = (row, event) => {
|
|
412
|
+
if (!isRowClickEnabled.value || shouldIgnoreRowClick(event)) return;
|
|
413
|
+
emit("row-click", row, event);
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const handleRowKeydown = (row, event) => {
|
|
417
|
+
if (!isRowClickEnabled.value || shouldIgnoreRowClick(event)) return;
|
|
418
|
+
if (!["Enter", " "].includes(event.key)) return;
|
|
419
|
+
event.preventDefault();
|
|
420
|
+
handleRowClick(row, event);
|
|
421
|
+
};
|
|
422
|
+
</script>
|
|
423
|
+
|
|
424
|
+
<template>
|
|
425
|
+
<div class="relative">
|
|
426
|
+
<!-- Table view -->
|
|
427
|
+
<div v-if="!isGridView" class="overflow-x-auto relative">
|
|
428
|
+
<table class="relative min-w-full divide-y divide-gray-200 dark:divide-slate-700">
|
|
429
|
+
<thead class="relative z-20 bg-white dark:bg-slate-800">
|
|
430
|
+
<tr
|
|
431
|
+
class="divide-x divide-gray-200 dark:border-slate-700 dark:divide-slate-700"
|
|
432
|
+
:class="{ 'border-t border-gray-200': loading || data.length > 0 }"
|
|
433
|
+
>
|
|
434
|
+
<th v-if="checkable" class="text-center w-12">
|
|
435
|
+
<input
|
|
436
|
+
type="checkbox"
|
|
437
|
+
:checked="isAllVisibleSelected"
|
|
438
|
+
@change="toggleSelectAll"
|
|
439
|
+
class="mx-2 shrink-0 border-gray-300 rounded-sm text-blue-900 focus:ring-blue-900 dark:bg-slate-800 dark:border-slate-600"
|
|
440
|
+
/>
|
|
441
|
+
</th>
|
|
442
|
+
<th
|
|
443
|
+
v-for="col in columns"
|
|
444
|
+
:key="col.key"
|
|
445
|
+
scope="col"
|
|
446
|
+
:class="col.class || 'min-w-72'"
|
|
447
|
+
@click="toggleSort(col)"
|
|
448
|
+
>
|
|
449
|
+
<div class="hs-dropdown relative inline-flex w-full cursor-pointer">
|
|
450
|
+
<button class="px-6 py-3 text-start w-full flex items-center gap-x-1 text-[11px] font-bold text-gray-500 dark:text-slate-400 uppercase tracking-wider">
|
|
451
|
+
{{ col.label }}
|
|
452
|
+
<span v-if="col.sortable">
|
|
453
|
+
<IconArrowsSort v-if="getSortDirection(col.key) === null" class="size-4" />
|
|
454
|
+
<IconSortDescendingSmallBig v-if="getSortDirection(col.key) === 'desc'" class="size-5" />
|
|
455
|
+
<IconSortAscendingSmallBig v-if="getSortDirection(col.key) === 'asc'" class="size-5" />
|
|
456
|
+
</span>
|
|
457
|
+
</button>
|
|
458
|
+
</div>
|
|
459
|
+
</th>
|
|
460
|
+
</tr>
|
|
461
|
+
</thead>
|
|
462
|
+
<tbody ref="tableBodyRef" class="divide-y divide-gray-200 dark:divide-slate-700">
|
|
463
|
+
<!-- Loading skeleton -->
|
|
464
|
+
<tr
|
|
465
|
+
v-if="loading"
|
|
466
|
+
v-for="(_, index) in skeletonRows"
|
|
467
|
+
:key="'skeleton-' + index"
|
|
468
|
+
class="animate-pulse divide-x divide-gray-200 dark:divide-slate-700 bg-white dark:bg-slate-800"
|
|
469
|
+
>
|
|
470
|
+
<td v-if="checkable" class="text-center w-12" :style="{ height: lastRowHeight + 'px' }">
|
|
471
|
+
<div class="w-4 h-4 bg-gray-300 dark:bg-slate-600 rounded mx-auto"></div>
|
|
472
|
+
</td>
|
|
473
|
+
<td
|
|
474
|
+
v-for="col in columns"
|
|
475
|
+
:key="col.key"
|
|
476
|
+
class="px-6"
|
|
477
|
+
:class="col.class || ''"
|
|
478
|
+
:style="{ height: lastRowHeight + 'px' }"
|
|
479
|
+
>
|
|
480
|
+
<div class="h-4 w-[50%] rounded bg-gray-200 dark:bg-slate-600"></div>
|
|
481
|
+
</td>
|
|
482
|
+
</tr>
|
|
483
|
+
|
|
484
|
+
<!-- Empty skeleton (no data, no search) -->
|
|
485
|
+
<tr
|
|
486
|
+
v-if="!loading && data.length === 0"
|
|
487
|
+
v-for="(_, index) in skeletonRows"
|
|
488
|
+
:key="'empty-skeleton-' + index"
|
|
489
|
+
class="divide-x divide-gray-200 dark:divide-slate-700 bg-white dark:bg-slate-800"
|
|
490
|
+
>
|
|
491
|
+
<td v-if="checkable" class="text-center w-12" :style="{ height: lastRowHeight + 'px' }">
|
|
492
|
+
<div class="w-4 h-4 bg-gray-200 dark:bg-slate-600 rounded mx-auto"></div>
|
|
493
|
+
</td>
|
|
494
|
+
<td
|
|
495
|
+
v-for="col in columns"
|
|
496
|
+
:key="col.key"
|
|
497
|
+
class="px-6"
|
|
498
|
+
:class="col.class || ''"
|
|
499
|
+
:style="{ height: lastRowHeight + 'px' }"
|
|
500
|
+
>
|
|
501
|
+
<div class="h-4 w-[50%] rounded bg-gray-100 dark:bg-slate-700"></div>
|
|
502
|
+
</td>
|
|
503
|
+
</tr>
|
|
504
|
+
|
|
505
|
+
<!-- Data rows -->
|
|
506
|
+
<tr
|
|
507
|
+
v-else
|
|
508
|
+
v-for="row in data"
|
|
509
|
+
:key="row.id"
|
|
510
|
+
@click="(event) => handleRowClick(row, event)"
|
|
511
|
+
@keydown="(event) => handleRowKeydown(row, event)"
|
|
512
|
+
:tabindex="isRowClickEnabled ? 0 : undefined"
|
|
513
|
+
class="divide-x divide-gray-200 dark:divide-slate-700 bg-white hover:bg-gray-50 dark:bg-slate-800 dark:hover:bg-slate-900"
|
|
514
|
+
:class="{ 'cursor-pointer': isRowClickEnabled }"
|
|
515
|
+
>
|
|
516
|
+
<td v-if="checkable" class="text-center w-12" @click.stop>
|
|
517
|
+
<input
|
|
518
|
+
type="checkbox"
|
|
519
|
+
:checked="isRowSelected(row)"
|
|
520
|
+
@change="() => toggleRow(row)"
|
|
521
|
+
class="rounded border-gray-300 dark:bg-slate-800 dark:border-slate-600"
|
|
522
|
+
/>
|
|
523
|
+
</td>
|
|
524
|
+
<td
|
|
525
|
+
v-for="col in columns"
|
|
526
|
+
:key="col.key"
|
|
527
|
+
class="px-6 py-3 relative group text-sm text-slate-600 dark:text-slate-300"
|
|
528
|
+
:class="col.class || ''"
|
|
529
|
+
>
|
|
530
|
+
<slot :name="col.key" :row="row" :value="row[col.key]">
|
|
531
|
+
{{ row[col.key] }}
|
|
532
|
+
</slot>
|
|
533
|
+
</td>
|
|
534
|
+
</tr>
|
|
535
|
+
</tbody>
|
|
536
|
+
</table>
|
|
537
|
+
|
|
538
|
+
<!-- Empty overlays -->
|
|
539
|
+
<div
|
|
540
|
+
v-if="!loading && data.length === 0 && !search"
|
|
541
|
+
class="absolute inset-0 z-10 pointer-events-none flex items-center justify-center backdrop-blur-sm bg-white/60 dark:bg-slate-800/60 transition-all rounded-xl"
|
|
542
|
+
>
|
|
543
|
+
<slot name="empty">
|
|
544
|
+
<p class="text-slate-400 dark:text-slate-500 text-lg font-medium italic">No hay registros</p>
|
|
545
|
+
</slot>
|
|
546
|
+
</div>
|
|
547
|
+
|
|
548
|
+
<div
|
|
549
|
+
v-if="!loading && data.length === 0 && search"
|
|
550
|
+
class="absolute inset-0 z-10 pointer-events-none flex items-center justify-center backdrop-blur-sm bg-white/60 dark:bg-slate-800/60 transition-all rounded-xl"
|
|
551
|
+
>
|
|
552
|
+
<slot name="empty-search">
|
|
553
|
+
<p class="text-slate-400 dark:text-slate-500 text-lg font-medium italic">No hay registros en la búsqueda</p>
|
|
554
|
+
</slot>
|
|
555
|
+
</div>
|
|
556
|
+
</div>
|
|
557
|
+
|
|
558
|
+
<!-- Grid view -->
|
|
559
|
+
<div v-else class="relative">
|
|
560
|
+
<!-- Grid loading skeleton -->
|
|
561
|
+
<div v-if="loading" :class="gridClass">
|
|
562
|
+
<div v-for="(_, index) in skeletonRows" :key="'grid-skeleton-' + index" class="animate-pulse">
|
|
563
|
+
<slot name="grid-skeleton">
|
|
564
|
+
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-4">
|
|
565
|
+
<div class="space-y-3">
|
|
566
|
+
<div class="h-4 bg-gray-200 dark:bg-slate-600 rounded w-3/4"></div>
|
|
567
|
+
<div class="h-4 bg-gray-200 dark:bg-slate-600 rounded w-1/2"></div>
|
|
568
|
+
<div class="h-6 bg-gray-200 dark:bg-slate-600 rounded w-1/4"></div>
|
|
569
|
+
</div>
|
|
570
|
+
</div>
|
|
571
|
+
</slot>
|
|
572
|
+
</div>
|
|
573
|
+
</div>
|
|
574
|
+
|
|
575
|
+
<!-- Grid data -->
|
|
576
|
+
<div v-else-if="data.length > 0" :class="gridClass">
|
|
577
|
+
<slot
|
|
578
|
+
name="grid-item"
|
|
579
|
+
v-for="row in data"
|
|
580
|
+
:key="row.id"
|
|
581
|
+
:row="row"
|
|
582
|
+
:isSelected="isRowSelected(row)"
|
|
583
|
+
:checkable="checkable"
|
|
584
|
+
:toggleRow="() => toggleRow(row)"
|
|
585
|
+
>
|
|
586
|
+
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-4 hover:shadow-md transition-shadow relative">
|
|
587
|
+
<div v-if="checkable" class="absolute top-2 left-2 z-10">
|
|
588
|
+
<input
|
|
589
|
+
type="checkbox"
|
|
590
|
+
:checked="isRowSelected(row)"
|
|
591
|
+
@change="() => toggleRow(row)"
|
|
592
|
+
class="rounded border-gray-300 dark:bg-slate-800 dark:border-slate-600"
|
|
593
|
+
/>
|
|
594
|
+
</div>
|
|
595
|
+
<div class="space-y-2" :class="{ 'pt-6': checkable }">
|
|
596
|
+
<div v-for="col in columns" :key="col.key" class="flex justify-between">
|
|
597
|
+
<span class="text-sm text-gray-500 dark:text-slate-400">{{ col.label }}:</span>
|
|
598
|
+
<span class="text-sm text-gray-900 dark:text-slate-100">
|
|
599
|
+
<slot :name="col.key" :row="row" :value="row[col.key]">{{ row[col.key] }}</slot>
|
|
600
|
+
</span>
|
|
601
|
+
</div>
|
|
602
|
+
</div>
|
|
603
|
+
</div>
|
|
604
|
+
</slot>
|
|
605
|
+
</div>
|
|
606
|
+
|
|
607
|
+
<!-- Grid empty state -->
|
|
608
|
+
<div v-else class="flex items-center justify-center py-12">
|
|
609
|
+
<slot v-if="!search" name="empty">
|
|
610
|
+
<p class="text-gray-500 dark:text-slate-400 text-lg">No hay registros</p>
|
|
611
|
+
</slot>
|
|
612
|
+
<slot v-else name="empty-search">
|
|
613
|
+
<p class="text-gray-500 dark:text-slate-400 text-lg">No hay registros en la búsqueda</p>
|
|
614
|
+
</slot>
|
|
615
|
+
</div>
|
|
616
|
+
</div>
|
|
617
|
+
|
|
618
|
+
<!-- Pagination & controls -->
|
|
619
|
+
<div class="flex flex-col sm:flex-row items-center justify-between gap-y-4 sm:gap-y-0 mt-4 px-6 pb-6">
|
|
620
|
+
<!-- Left: info & reload -->
|
|
621
|
+
<div class="flex items-center justify-start gap-x-4">
|
|
622
|
+
<div v-if="showReloadButton" class="flex items-center justify-start gap-x-2">
|
|
623
|
+
<IconReload
|
|
624
|
+
v-if="!loading"
|
|
625
|
+
class="size-4 cursor-pointer text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-300 transition-colors"
|
|
626
|
+
@click="() => { clearCache(); isDataFromCache.value = false; fetchData(); }"
|
|
627
|
+
/>
|
|
628
|
+
<div v-if="loading">
|
|
629
|
+
<svg class="animate-spin size-4 text-slate-400 dark:text-slate-600" 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">
|
|
630
|
+
<circle cx="12" cy="12" r="10" opacity=".25" />
|
|
631
|
+
<path d="M22 12a10 10 0 0 1-10 10" />
|
|
632
|
+
</svg>
|
|
633
|
+
</div>
|
|
634
|
+
</div>
|
|
635
|
+
|
|
636
|
+
<p class="text-sm text-gray-800 dark:text-slate-200 font-medium">{{ meta.total }} registros</p>
|
|
637
|
+
|
|
638
|
+
<div v-if="isDataFromCache && cached" class="group relative flex items-center">
|
|
639
|
+
<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 transition-colors hover:bg-emerald-500/20">
|
|
640
|
+
<IconBolt class="size-3.5 fill-current" />
|
|
641
|
+
<span class="text-[10px] font-bold uppercase tracking-wider">Instant</span>
|
|
642
|
+
</div>
|
|
643
|
+
<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 animate-in fade-in zoom-in duration-200">
|
|
644
|
+
<div class="font-bold mb-1 flex items-center gap-x-1.5 text-emerald-400">
|
|
645
|
+
<IconBolt class="size-3" />
|
|
646
|
+
Datos en Caché
|
|
647
|
+
</div>
|
|
648
|
+
Los datos se cargaron instantáneamente desde la memoria local. Actualice la tabla para sincronizar con el servidor.
|
|
649
|
+
<div class="absolute top-full left-4 -mt-1 border-4 border-transparent border-t-slate-900"></div>
|
|
650
|
+
</div>
|
|
651
|
+
</div>
|
|
652
|
+
</div>
|
|
653
|
+
|
|
654
|
+
<!-- Right: per-page & pagination -->
|
|
655
|
+
<div class="flex items-center gap-x-8">
|
|
656
|
+
<div class="flex items-center gap-x-2">
|
|
657
|
+
<label class="text-[10px] font-bold text-gray-400 dark:text-slate-500 uppercase tracking-widest">Filas:</label>
|
|
658
|
+
<select
|
|
659
|
+
v-if="!isCustomPerPage"
|
|
660
|
+
:value="perPage"
|
|
661
|
+
@change="(e) => handlePerPageChange(e.target.value)"
|
|
662
|
+
class="bg-slate-100 dark:bg-slate-800 border-none text-[11px] font-bold text-slate-600 dark:text-slate-300 rounded-lg focus:ring-0 cursor-pointer py-1 pl-2 pr-8"
|
|
663
|
+
>
|
|
664
|
+
<option :value="10">10</option>
|
|
665
|
+
<option :value="25">25</option>
|
|
666
|
+
<option :value="50">50</option>
|
|
667
|
+
<option :value="100">100</option>
|
|
668
|
+
<option value="custom">Otro...</option>
|
|
669
|
+
</select>
|
|
670
|
+
<div v-else class="flex items-center gap-x-1">
|
|
671
|
+
<input
|
|
672
|
+
type="number"
|
|
673
|
+
v-model.number="perPage"
|
|
674
|
+
min="1"
|
|
675
|
+
max="500"
|
|
676
|
+
class="w-14 bg-slate-100 dark:bg-slate-800 border-none text-[11px] font-bold text-slate-600 dark:text-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500/20 py-1 px-2"
|
|
677
|
+
@blur="perPage = perPage || 10"
|
|
678
|
+
/>
|
|
679
|
+
<button @click="resetPerPage" class="text-[10px] text-indigo-500 font-bold hover:underline">Volver</button>
|
|
680
|
+
</div>
|
|
681
|
+
</div>
|
|
682
|
+
|
|
683
|
+
<nav class="flex justify-end items-center gap-x-1" aria-label="Pagination">
|
|
684
|
+
<button
|
|
685
|
+
type="button"
|
|
686
|
+
class="size-8 flex items-center justify-center rounded-lg text-gray-800 hover:bg-gray-100 dark:text-white dark:hover:bg-white/10 disabled:opacity-30"
|
|
687
|
+
:disabled="page <= 1"
|
|
688
|
+
@click="goToPreviousPage"
|
|
689
|
+
>
|
|
690
|
+
<svg class="shrink-0 size-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
691
|
+
<path d="m15 18-6-6 6-6" />
|
|
692
|
+
</svg>
|
|
693
|
+
</button>
|
|
694
|
+
<div class="flex items-center gap-x-1 mx-2">
|
|
695
|
+
<span class="size-8 flex items-center justify-center text-xs font-bold rounded-lg bg-gray-100 dark:bg-slate-700 text-gray-800 dark:text-white">{{ meta.current_page }}</span>
|
|
696
|
+
<span class="text-[10px] font-bold text-gray-400 dark:text-slate-500 uppercase mx-1">de</span>
|
|
697
|
+
<span class="text-[10px] font-bold text-gray-400 dark:text-slate-500">{{ meta.last_page }}</span>
|
|
698
|
+
</div>
|
|
699
|
+
<button
|
|
700
|
+
type="button"
|
|
701
|
+
class="size-8 flex items-center justify-center rounded-lg text-gray-800 hover:bg-gray-100 dark:text-white dark:hover:bg-white/10 disabled:opacity-30"
|
|
702
|
+
:disabled="page >= meta.last_page"
|
|
703
|
+
@click="goToNextPage"
|
|
704
|
+
>
|
|
705
|
+
<svg class="shrink-0 size-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
706
|
+
<path d="m9 18 6-6-6-6" />
|
|
707
|
+
</svg>
|
|
708
|
+
</button>
|
|
709
|
+
</nav>
|
|
710
|
+
</div>
|
|
711
|
+
</div>
|
|
712
|
+
</div>
|
|
713
|
+
</template>
|