@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.
Files changed (108) hide show
  1. package/.github/workflows/auto-publish.yml +64 -0
  2. package/.github/workflows/release.yml +59 -0
  3. package/README.md +60 -0
  4. package/app.config.ts +70 -0
  5. package/components/Admin/Base.vue +144 -0
  6. package/components/Admin/Header.vue +32 -0
  7. package/components/Admin/Page.vue +65 -0
  8. package/components/Admin/PageHeader.vue +31 -0
  9. package/components/App/Button.vue +59 -0
  10. package/components/App/DevEnvironmentBar.vue +43 -0
  11. package/components/App/Dropdown.vue +286 -0
  12. package/components/App/EmptyState.vue +433 -0
  13. package/components/App/LoadingState.vue +40 -0
  14. package/components/App/PageLoadingSpinner.vue +118 -0
  15. package/components/App/PreviewDock.vue +64 -0
  16. package/components/App/SwitchColorTheme.vue +51 -0
  17. package/components/App/Tag.vue +193 -0
  18. package/components/DataTable.vue +713 -0
  19. package/components/Forms/DatePicker.vue +255 -0
  20. package/components/Forms/Input.vue +75 -0
  21. package/components/Forms/Select.vue +100 -0
  22. package/components/Forms/SelectServer.vue +726 -0
  23. package/components/Layout/Admin.vue +32 -0
  24. package/components/Layout/Auth.vue +29 -0
  25. package/components/Layout/SidebarWithAppColumn.vue +388 -0
  26. package/components/Layout/TopBar.vue +113 -0
  27. package/components/MobileBlocker.vue +85 -0
  28. package/components/MobileLoginPicker.vue +83 -0
  29. package/components/Modal/Base.vue +29 -0
  30. package/components/Modal/DeleteConfirm.vue +48 -0
  31. package/components/Modal.vue +103 -0
  32. package/components/Nav/Tabs.vue +55 -0
  33. package/components/PermissionsTree.vue +272 -0
  34. package/components/Table/Database.vue +183 -0
  35. package/components/Table/DownloadDropdown.vue +111 -0
  36. package/components/Table/Enterprise.vue +540 -0
  37. package/components/Table/FilterDropdown.vue +226 -0
  38. package/components/Table/Grid.vue +62 -0
  39. package/components/Table/Kanban.vue +188 -0
  40. package/components/Table/List.vue +128 -0
  41. package/components/Table/PreviewTimeline.vue +118 -0
  42. package/components/Table/Standard.vue +1217 -0
  43. package/components/Table/index.vue +974 -0
  44. package/components/TableExportable.vue +172 -0
  45. package/components/TableFilter.vue +93 -0
  46. package/components/Toast/Alert.vue +113 -0
  47. package/components/Toast/Container.vue +34 -0
  48. package/components/Toast/Notification.vue +45 -0
  49. package/components/Toast/Process.vue +88 -0
  50. package/composables/useApi.js +95 -0
  51. package/composables/useApp.ts +46 -0
  52. package/composables/useAuth.js +82 -0
  53. package/composables/useContext.js +44 -0
  54. package/composables/useDate.js +241 -0
  55. package/composables/useDevice.js +21 -0
  56. package/composables/useDockedPreviews.js +56 -0
  57. package/composables/useDownload.js +87 -0
  58. package/composables/useEntity.js +82 -0
  59. package/composables/useForm.js +119 -0
  60. package/composables/useInnertiaMode.ts +25 -0
  61. package/composables/useMobileGuard.ts +81 -0
  62. package/composables/useNotifications.js +22 -0
  63. package/composables/usePermissions.js +23 -0
  64. package/composables/useRealtime.js +123 -0
  65. package/composables/useRequestInterceptors.js +27 -0
  66. package/composables/useRoles.js +53 -0
  67. package/composables/useRutFormatter.js +39 -0
  68. package/composables/useTable.ts +94 -0
  69. package/composables/useTablePreferences.ts +33 -0
  70. package/composables/useTenant.js +27 -0
  71. package/composables/useTimeAgo.js +37 -0
  72. package/composables/useToast.js +69 -0
  73. package/composables/useUserRealtime.js +17 -0
  74. package/composables/useUsers.js +111 -0
  75. package/css/themes/autumn.css +401 -0
  76. package/css/themes/bubblegum.css +408 -0
  77. package/css/themes/cashmere.css +412 -0
  78. package/css/themes/harvest.css +416 -0
  79. package/css/themes/moon.css +140 -0
  80. package/css/themes/ocean.css +273 -0
  81. package/css/themes/olive.css +413 -0
  82. package/css/themes/retro.css +431 -0
  83. package/css/themes/theme.css +725 -0
  84. package/error.vue +78 -0
  85. package/middleware/01.detect-subdomain.global.ts +43 -0
  86. package/middleware/02.validate-tenant.global.ts +67 -0
  87. package/middleware/03.apps.global.ts +88 -0
  88. package/middleware/auth.ts +9 -0
  89. package/middleware/guest.ts +9 -0
  90. package/nuxt.config.ts +42 -0
  91. package/package.json +60 -0
  92. package/pages/tenant-error.vue +50 -0
  93. package/plugins/api-auth.ts +12 -0
  94. package/plugins/api-tenant.client.ts +21 -0
  95. package/plugins/appearance.ts +8 -0
  96. package/plugins/auth-init.ts +34 -0
  97. package/plugins/dark-state.client.ts +29 -0
  98. package/plugins/dockedPreviewsSync.client.js +17 -0
  99. package/plugins/preline.client.ts +68 -0
  100. package/plugins/theme.client.ts +7 -0
  101. package/plugins/vue-query.ts +29 -0
  102. package/public/init-theme.js +15 -0
  103. package/spark.css +721 -0
  104. package/stores/auth.js +130 -0
  105. package/stores/dockedPreviews.js +34 -0
  106. package/stores/notifications.js +24 -0
  107. package/stores/tenant.js +54 -0
  108. package/stores/toast.js +129 -0
@@ -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-card-line">
429
+ <thead class="relative z-20 bg-card">
430
+ <tr
431
+ class="divide-x divide-card-line"
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-card border-card-line"
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-muted-foreground 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-card-line">
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-card-line bg-card"
469
+ >
470
+ <td v-if="checkable" class="text-center w-12" :style="{ height: lastRowHeight + 'px' }">
471
+ <div class="w-4 h-4 bg-surface-1 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-surface-1"></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-card-line bg-card"
490
+ >
491
+ <td v-if="checkable" class="text-center w-12" :style="{ height: lastRowHeight + 'px' }">
492
+ <div class="w-4 h-4 bg-surface-1 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-surface"></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-card-line bg-card hover:bg-muted"
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-card-line dark:bg-card"
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-muted-foreground-1"
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-card/60 transition-all rounded-xl"
542
+ >
543
+ <slot name="empty">
544
+ <p class="text-muted-foreground 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-card/60 transition-all rounded-xl"
551
+ >
552
+ <slot name="empty-search">
553
+ <p class="text-muted-foreground 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-card rounded-lg border border-card-line p-4">
565
+ <div class="space-y-3">
566
+ <div class="h-4 bg-surface-1 rounded w-3/4"></div>
567
+ <div class="h-4 bg-surface-1 rounded w-1/2"></div>
568
+ <div class="h-6 bg-surface-1 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-card rounded-lg border border-card-line 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-card-line dark:bg-card"
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-muted-foreground">{{ col.label }}:</span>
598
+ <span class="text-sm text-foreground">
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-muted-foreground text-lg">No hay registros</p>
611
+ </slot>
612
+ <slot v-else name="empty-search">
613
+ <p class="text-muted-foreground 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-muted-foreground hover:text-muted-foreground-1 transition-colors"
626
+ @click="() => { clearCache(); isDataFromCache.value = false; fetchData(); }"
627
+ />
628
+ <div v-if="loading">
629
+ <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">
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-foreground 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-muted-foreground uppercase tracking-widest">Filas:</label>
658
+ <select
659
+ v-if="!isCustomPerPage"
660
+ :value="perPage"
661
+ @change="(e) => handlePerPageChange(e.target.value)"
662
+ 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"
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-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"
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-foreground hover:bg-muted-hover 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-surface text-foreground">{{ meta.current_page }}</span>
696
+ <span class="text-[10px] font-bold text-muted-foreground uppercase mx-1">de</span>
697
+ <span class="text-[10px] font-bold text-muted-foreground">{{ meta.last_page }}</span>
698
+ </div>
699
+ <button
700
+ type="button"
701
+ class="size-8 flex items-center justify-center rounded-lg text-foreground hover:bg-muted-hover 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>