@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,226 @@
1
+ <script setup>
2
+ const props = defineProps({
3
+ modelValue: {
4
+ type: [String, Number],
5
+ default: null,
6
+ },
7
+ options: {
8
+ type: Array,
9
+ default: () => [],
10
+ },
11
+ placeholder: {
12
+ type: String,
13
+ default: "Seleccionar...",
14
+ },
15
+ optionLabelKey: {
16
+ type: String,
17
+ default: "label",
18
+ },
19
+ optionValueKey: {
20
+ type: String,
21
+ default: "value",
22
+ },
23
+ clearable: {
24
+ type: Boolean,
25
+ default: true,
26
+ },
27
+ disabled: {
28
+ type: Boolean,
29
+ default: false,
30
+ },
31
+ menuClass: {
32
+ type: String,
33
+ default: "",
34
+ },
35
+ });
36
+
37
+ const emit = defineEmits(["update:modelValue", "change"]);
38
+
39
+ const isOpen = ref(false);
40
+ const dropdownRef = ref(null);
41
+
42
+ const getOptionLabel = (option) => option?.[props.optionLabelKey] ?? option?.label ?? "";
43
+ const getOptionValue = (option) => option?.[props.optionValueKey] ?? option?.value ?? null;
44
+
45
+ const hasSelection = computed(() => props.modelValue !== null && props.modelValue !== undefined && props.modelValue !== "");
46
+
47
+ const selectedOption = computed(() =>
48
+ props.options.find((option) => getOptionValue(option) === props.modelValue),
49
+ );
50
+
51
+ const displayText = computed(() => {
52
+ if (!selectedOption.value) return props.placeholder;
53
+ return getOptionLabel(selectedOption.value) || props.placeholder;
54
+ });
55
+
56
+ const selectClasses = computed(() => {
57
+ const base =
58
+ "relative w-full rounded-lg border bg-card transition-colors cursor-pointer text-foreground py-2 px-3 text-sm focus:outline-none focus:ring-0 focus:border-gray-400";
59
+ const validation = "border-card-line";
60
+ const disabled = props.disabled ? "opacity-50 cursor-not-allowed" : "";
61
+ return `${base} ${validation} ${disabled}`;
62
+ });
63
+
64
+ const closeDropdown = () => {
65
+ isOpen.value = false;
66
+ };
67
+
68
+ const toggleDropdown = () => {
69
+ if (props.disabled) return;
70
+ isOpen.value = !isOpen.value;
71
+ };
72
+
73
+ const selectOption = (option) => {
74
+ const selectedValue = getOptionValue(option);
75
+ const nextValue = props.modelValue === selectedValue ? null : selectedValue;
76
+ emit("update:modelValue", nextValue);
77
+ emit("change", nextValue);
78
+ closeDropdown();
79
+ };
80
+
81
+ const clearSelection = () => {
82
+ emit("update:modelValue", null);
83
+ emit("change", null);
84
+ closeDropdown();
85
+ };
86
+
87
+ const isOptionSelected = (option) => getOptionValue(option) === props.modelValue;
88
+
89
+ const handleClickOutside = (event) => {
90
+ if (dropdownRef.value && !dropdownRef.value.contains(event.target)) {
91
+ closeDropdown();
92
+ }
93
+ };
94
+
95
+ onMounted(() => document.addEventListener("mousedown", handleClickOutside));
96
+ onUnmounted(() => document.removeEventListener("mousedown", handleClickOutside));
97
+ </script>
98
+
99
+ <template>
100
+ <div ref="dropdownRef" class="relative w-full">
101
+ <button
102
+ type="button"
103
+ :class="selectClasses"
104
+ :disabled="disabled"
105
+ :aria-expanded="isOpen"
106
+ :aria-haspopup="true"
107
+ @click="toggleDropdown"
108
+ >
109
+ <div class="flex items-center justify-between w-full">
110
+ <span
111
+ class="truncate flex-1 text-left pr-10"
112
+ :class="{
113
+ 'text-muted-foreground': !hasSelection,
114
+ 'text-slate-900 dark:text-white': hasSelection,
115
+ }"
116
+ >
117
+ <slot name="display" :selected-option="selectedOption" :display-text="displayText">
118
+ {{ displayText }}
119
+ </slot>
120
+ </span>
121
+
122
+ <span
123
+ v-if="clearable && hasSelection && !disabled"
124
+ class="absolute end-8 top-1/2 -translate-y-1/2 hover:bg-muted-hover rounded-full p-1 transition-colors focus:outline-none focus:ring-1 focus:ring-slate-400"
125
+ role="button"
126
+ tabindex="0"
127
+ @click.stop="clearSelection"
128
+ @keydown.enter.prevent="clearSelection"
129
+ @keydown.space.prevent="clearSelection"
130
+ >
131
+ <svg class="size-3.5 text-slate-400" fill="currentColor" viewBox="0 0 20 20">
132
+ <path
133
+ fill-rule="evenodd"
134
+ d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
135
+ clip-rule="evenodd"
136
+ />
137
+ </svg>
138
+ </span>
139
+
140
+ <div class="absolute top-1/2 end-3 -translate-y-1/2">
141
+ <svg
142
+ class="shrink-0 size-3.5 text-muted-foreground transition-transform"
143
+ :class="{ 'rotate-180': isOpen }"
144
+ xmlns="http://www.w3.org/2000/svg"
145
+ width="24"
146
+ height="24"
147
+ viewBox="0 0 24 24"
148
+ fill="none"
149
+ stroke="currentColor"
150
+ stroke-width="2"
151
+ stroke-linecap="round"
152
+ stroke-linejoin="round"
153
+ >
154
+ <path d="m7 15 5 5 5-5" />
155
+ <path d="m7 9 5-5 5 5" />
156
+ </svg>
157
+ </div>
158
+ </div>
159
+ </button>
160
+
161
+ <Transition
162
+ enter-active-class="transition ease-out duration-100"
163
+ enter-from-class="transform opacity-0 scale-95"
164
+ enter-to-class="transform opacity-100 scale-100"
165
+ leave-active-class="transition ease-in duration-75"
166
+ leave-from-class="transform opacity-100 scale-100"
167
+ leave-to-class="transform opacity-0 scale-95"
168
+ >
169
+ <div
170
+ v-show="isOpen"
171
+ :class="[
172
+ 'absolute z-50 w-full mt-1 bg-dropdown border border-dropdown-line rounded-xl shadow-xl max-h-60 overflow-auto',
173
+ menuClass,
174
+ ]"
175
+ >
176
+ <div v-if="options.length">
177
+ <button
178
+ v-for="option in options"
179
+ :key="getOptionValue(option)"
180
+ type="button"
181
+ :class="[
182
+ 'w-full px-3 py-2 text-left hover:bg-muted-hover flex items-center text-sm',
183
+ isOptionSelected(option) ? 'bg-muted' : '',
184
+ ]"
185
+ @click="selectOption(option)"
186
+ >
187
+ <slot name="option" :option="option" :selected="isOptionSelected(option)">
188
+ <div class="flex items-center flex-1 min-w-0">
189
+ <span
190
+ v-if="option.dot"
191
+ class="relative flex-shrink-0 size-2.5 flex items-center justify-center mr-2.5"
192
+ >
193
+ <span
194
+ v-if="option.pulse"
195
+ :class="['animate-ping absolute inline-flex h-full w-full rounded-full opacity-75', option.dot]"
196
+ />
197
+ <span :class="['relative inline-flex rounded-full size-2.5', option.dot]" />
198
+ </span>
199
+ <span class="font-bold text-foreground truncate">
200
+ {{ getOptionLabel(option) }}
201
+ </span>
202
+ </div>
203
+ </slot>
204
+
205
+ <svg
206
+ v-if="isOptionSelected(option)"
207
+ class="w-4 h-4 text-slate-500 ml-2 shrink-0"
208
+ fill="currentColor"
209
+ viewBox="0 0 20 20"
210
+ >
211
+ <path
212
+ fill-rule="evenodd"
213
+ d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
214
+ clip-rule="evenodd"
215
+ />
216
+ </svg>
217
+ </button>
218
+ </div>
219
+
220
+ <div v-else class="px-3 py-4 text-center text-muted-foreground text-sm">
221
+ Sin opciones
222
+ </div>
223
+ </div>
224
+ </Transition>
225
+ </div>
226
+ </template>
@@ -0,0 +1,62 @@
1
+ <script setup>
2
+ import { IconSearch } from '@tabler/icons-vue'
3
+
4
+ // Grid / card layout view — wraps DataTable with viewMode="grid"
5
+ const props = defineProps({
6
+ endpoint: { type: String, required: true },
7
+ columns: { type: Array, required: true },
8
+ name: { type: String, required: true },
9
+ params: { type: Object, default: () => ({}) },
10
+ cached: { type: Boolean, default: true },
11
+ searchPlaceholder: { type: String, default: 'Buscar...' },
12
+ showSearch: { type: Boolean, default: true },
13
+ gridClass: { type: String, default: 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4' },
14
+ clickRowToOpen: { type: Boolean, default: false },
15
+ })
16
+
17
+ const emit = defineEmits(['row-click', 'loaded'])
18
+
19
+ const search = ref('')
20
+ const tableRef = ref(null)
21
+
22
+ const reload = () => tableRef.value?.reload()
23
+ const clearCache = () => tableRef.value?.clearCache()
24
+
25
+ defineExpose({ reload, clearCache })
26
+ </script>
27
+
28
+ <template>
29
+ <div class="flex flex-col gap-4">
30
+ <div v-if="showSearch" class="relative">
31
+ <div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
32
+ <IconSearch class="size-4 text-slate-400" stroke="1.5" />
33
+ </div>
34
+ <input
35
+ v-model="search"
36
+ type="search"
37
+ :placeholder="searchPlaceholder"
38
+ class="block w-full rounded-lg border border-card-line bg-card text-foreground py-2 ps-10 pe-4 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500"
39
+ />
40
+ </div>
41
+
42
+ <DataTable
43
+ ref="tableRef"
44
+ :endpoint="endpoint"
45
+ :columns="columns"
46
+ :name="name"
47
+ :params="params"
48
+ :search="search"
49
+ :cached="cached"
50
+ :click-row-to-open="clickRowToOpen"
51
+ view-mode="grid"
52
+ :grid-class="gridClass"
53
+ @row-click="emit('row-click', $event)"
54
+ @loaded="emit('loaded', $event)"
55
+ >
56
+ <!-- Card slot: pass through for custom card rendering -->
57
+ <template v-if="$slots.card" #card="slotProps">
58
+ <slot name="card" v-bind="slotProps" />
59
+ </template>
60
+ </DataTable>
61
+ </div>
62
+ </template>
@@ -0,0 +1,188 @@
1
+ <script setup>
2
+ import { IconLoader2, IconPlus } from '@tabler/icons-vue'
3
+ import { useQueryClient } from '@tanstack/vue-query'
4
+
5
+ // Kanban board: states as columns, HTML5 DnD, optimistic updates
6
+ // Usage: <TableKanban endpoint="..." :states="[{key:'todo',label:'Pendiente',color:'slate'}]" state-key="status" ... />
7
+
8
+ const props = defineProps({
9
+ endpoint: { type: String, required: true }, // POST endpoint returning paginated list
10
+ name: { type: String, required: true }, // used as queryKey base
11
+ params: { type: Object, default: () => ({}) },
12
+ stateKey: { type: String, default: 'status' }, // field in row that holds the state key
13
+ states: { // column definitions
14
+ type: Array,
15
+ required: true,
16
+ // [{ key: 'todo', label: 'Pendiente', color: 'slate' }]
17
+ // color = tailwind color name: slate|red|yellow|green|blue|indigo|purple|pink
18
+ },
19
+ moveMutation: { type: Function, default: null }, // (id, newState) => Promise — called on drop
20
+ perPage: { type: Number, default: 50 },
21
+ })
22
+
23
+ const emit = defineEmits(['move', 'card-click'])
24
+
25
+ const api = useApi()
26
+ const queryClient = useQueryClient()
27
+
28
+ // ─── Fetch all rows ───────────────────────────────────────────────────────────
29
+ const loading = ref(false)
30
+ const rows = ref([])
31
+
32
+ const fetchAll = async () => {
33
+ loading.value = true
34
+ try {
35
+ const res = await api.post(props.endpoint, { perPage: props.perPage, ...props.params })
36
+ rows.value = Array.isArray(res?.data) ? res.data : (Array.isArray(res) ? res : [])
37
+ } catch (e) {
38
+ console.error('[Kanban] fetch error', e)
39
+ } finally {
40
+ loading.value = false
41
+ }
42
+ }
43
+
44
+ onMounted(fetchAll)
45
+
46
+ watch(() => props.params, fetchAll, { deep: true })
47
+
48
+ // ─── Rows grouped by state ────────────────────────────────────────────────────
49
+ const columnRows = computed(() => {
50
+ const map = {}
51
+ for (const s of props.states) map[s.key] = []
52
+ for (const row of rows.value) {
53
+ const state = row[props.stateKey]
54
+ if (map[state]) map[state].push(row)
55
+ }
56
+ return map
57
+ })
58
+
59
+ // ─── DnD ─────────────────────────────────────────────────────────────────────
60
+ const draggedId = ref(null)
61
+ const draggedFromState = ref(null)
62
+ const dragOverState = ref(null)
63
+
64
+ const onDragStart = (row, state) => {
65
+ draggedId.value = row.id
66
+ draggedFromState.value = state
67
+ }
68
+
69
+ const onDragOver = (e, state) => {
70
+ e.preventDefault()
71
+ dragOverState.value = state
72
+ }
73
+
74
+ const onDragLeave = () => { dragOverState.value = null }
75
+
76
+ const onDrop = async (targetState) => {
77
+ dragOverState.value = null
78
+ if (!draggedId.value || draggedFromState.value === targetState) {
79
+ draggedId.value = null
80
+ draggedFromState.value = null
81
+ return
82
+ }
83
+
84
+ const id = draggedId.value
85
+ const fromState = draggedFromState.value
86
+ draggedId.value = null
87
+ draggedFromState.value = null
88
+
89
+ // Optimistic update
90
+ const idx = rows.value.findIndex(r => r.id === id)
91
+ if (idx >= 0) rows.value[idx] = { ...rows.value[idx], [props.stateKey]: targetState }
92
+
93
+ emit('move', { id, from: fromState, to: targetState })
94
+
95
+ if (props.moveMutation) {
96
+ try {
97
+ await props.moveMutation(id, targetState)
98
+ } catch (e) {
99
+ // Rollback
100
+ const ridx = rows.value.findIndex(r => r.id === id)
101
+ if (ridx >= 0) rows.value[ridx] = { ...rows.value[ridx], [props.stateKey]: fromState }
102
+ console.error('[Kanban] move failed, rolled back', e)
103
+ }
104
+ }
105
+ }
106
+
107
+ // ─── Color map ────────────────────────────────────────────────────────────────
108
+ const colorMap = {
109
+ slate: { header: 'bg-surface text-foreground', over: 'ring-2 ring-slate-400' },
110
+ red: { header: 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300', over: 'ring-2 ring-red-400' },
111
+ yellow: { header: 'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-300', over: 'ring-2 ring-yellow-400' },
112
+ green: { header: 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300', over: 'ring-2 ring-green-400' },
113
+ blue: { header: 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300', over: 'ring-2 ring-blue-400' },
114
+ indigo: { header: 'bg-indigo-50 dark:bg-indigo-900/20 text-indigo-700 dark:text-indigo-300', over: 'ring-2 ring-indigo-400' },
115
+ purple: { header: 'bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300', over: 'ring-2 ring-purple-400' },
116
+ pink: { header: 'bg-pink-50 dark:bg-pink-900/20 text-pink-700 dark:text-pink-300', over: 'ring-2 ring-pink-400' },
117
+ }
118
+
119
+ const getColors = (state) => colorMap[state.color ?? 'slate'] ?? colorMap.slate
120
+
121
+ const reload = () => fetchAll()
122
+ defineExpose({ reload, rows })
123
+ </script>
124
+
125
+ <template>
126
+ <div>
127
+ <!-- Loading -->
128
+ <div v-if="loading" class="flex items-center justify-center py-16 gap-2 text-slate-400">
129
+ <IconLoader2 class="size-5 animate-spin" stroke="1.5" />
130
+ <span class="text-sm">Cargando...</span>
131
+ </div>
132
+
133
+ <!-- Board -->
134
+ <div v-else class="flex gap-4 overflow-x-auto pb-4">
135
+ <div
136
+ v-for="state in states"
137
+ :key="state.key"
138
+ class="flex-shrink-0 w-72 flex flex-col rounded-xl border border-card-line overflow-hidden transition-shadow"
139
+ :class="dragOverState === state.key ? getColors(state).over : ''"
140
+ @dragover="onDragOver($event, state.key)"
141
+ @dragleave="onDragLeave"
142
+ @drop="onDrop(state.key)"
143
+ >
144
+ <!-- Column header -->
145
+ <div :class="['px-4 py-3 flex items-center justify-between', getColors(state).header]">
146
+ <span class="font-semibold text-sm">{{ state.label }}</span>
147
+ <span class="text-xs font-bold bg-white/60 dark:bg-black/20 rounded-full px-2 py-0.5">
148
+ {{ columnRows[state.key]?.length ?? 0 }}
149
+ </span>
150
+ </div>
151
+
152
+ <!-- Cards -->
153
+ <div class="flex-1 flex flex-col gap-2 p-3 bg-muted min-h-24">
154
+ <div
155
+ v-for="row in columnRows[state.key]"
156
+ :key="row.id"
157
+ draggable="true"
158
+ @dragstart="onDragStart(row, state.key)"
159
+ class="bg-card border border-card-line rounded-lg p-3 cursor-grab active:cursor-grabbing hover:shadow-md transition-shadow select-none"
160
+ :class="draggedId === row.id ? 'opacity-40' : ''"
161
+ @click="emit('card-click', row)"
162
+ >
163
+ <slot name="card" :row="row" :state="state">
164
+ <!-- Default card -->
165
+ <div class="space-y-1">
166
+ <div
167
+ v-for="(val, key) in Object.fromEntries(Object.entries(row).filter(([k]) => k !== props.stateKey).slice(0, 3))"
168
+ :key="key"
169
+ class="text-sm"
170
+ >
171
+ <span class="text-muted-foreground text-xs capitalize">{{ key }}: </span>
172
+ <span class="text-foreground font-medium">{{ val }}</span>
173
+ </div>
174
+ </div>
175
+ </slot>
176
+ </div>
177
+
178
+ <div
179
+ v-if="!columnRows[state.key]?.length"
180
+ class="flex-1 flex items-center justify-center py-6 text-sm text-muted-foreground-2"
181
+ >
182
+ Sin elementos
183
+ </div>
184
+ </div>
185
+ </div>
186
+ </div>
187
+ </div>
188
+ </template>
@@ -0,0 +1,128 @@
1
+ <script setup>
2
+ import { IconSearch, IconLoader2 } from '@tabler/icons-vue'
3
+ import { useInfiniteQuery } from '@tanstack/vue-query'
4
+
5
+ // Infinite scroll list — fetches next page when sentinel enters viewport
6
+ const props = defineProps({
7
+ endpoint: { type: String, required: true },
8
+ name: { type: String, required: true },
9
+ params: { type: Object, default: () => ({}) },
10
+ searchPlaceholder: { type: String, default: 'Buscar...' },
11
+ showSearch: { type: Boolean, default: true },
12
+ perPage: { type: Number, default: 20 },
13
+ })
14
+
15
+ const emit = defineEmits(['row-click'])
16
+
17
+ const api = useApi()
18
+ const search = ref('')
19
+ const sentinel = ref(null)
20
+
21
+ let searchTimeout = null
22
+ const debouncedSearch = ref('')
23
+ watch(search, (v) => {
24
+ if (searchTimeout) clearTimeout(searchTimeout)
25
+ searchTimeout = setTimeout(() => { debouncedSearch.value = v }, 400)
26
+ })
27
+
28
+ const queryKey = computed(() => [props.name, 'infinite', debouncedSearch.value, props.params])
29
+
30
+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, refetch } = useInfiniteQuery({
31
+ queryKey,
32
+ queryFn: ({ pageParam = 1 }) =>
33
+ api.post(props.endpoint, {
34
+ search: debouncedSearch.value,
35
+ page: pageParam,
36
+ perPage: props.perPage,
37
+ ...props.params,
38
+ }),
39
+ getNextPageParam: (lastPage) => {
40
+ const meta = lastPage?.meta ?? lastPage
41
+ if (!meta?.current_page || !meta?.last_page) return undefined
42
+ return meta.current_page < meta.last_page ? meta.current_page + 1 : undefined
43
+ },
44
+ initialPageParam: 1,
45
+ })
46
+
47
+ const rows = computed(() => data.value?.pages.flatMap(p => p?.data ?? (Array.isArray(p) ? p : [])) ?? [])
48
+
49
+ // Intersection observer for auto-load
50
+ let observer = null
51
+ onMounted(() => {
52
+ observer = new IntersectionObserver((entries) => {
53
+ if (entries[0].isIntersecting && hasNextPage.value && !isFetchingNextPage.value) {
54
+ fetchNextPage()
55
+ }
56
+ }, { rootMargin: '200px' })
57
+ if (sentinel.value) observer.observe(sentinel.value)
58
+ })
59
+ onBeforeUnmount(() => observer?.disconnect())
60
+
61
+ watch(sentinel, (el) => {
62
+ if (el && observer) observer.observe(el)
63
+ })
64
+
65
+ const reload = () => refetch()
66
+ defineExpose({ reload, rows })
67
+ </script>
68
+
69
+ <template>
70
+ <div class="flex flex-col gap-4">
71
+ <div v-if="showSearch" class="relative">
72
+ <div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
73
+ <IconSearch class="size-4 text-slate-400" stroke="1.5" />
74
+ </div>
75
+ <input
76
+ v-model="search"
77
+ type="search"
78
+ :placeholder="searchPlaceholder"
79
+ class="block w-full rounded-lg border border-card-line bg-card text-foreground py-2 ps-10 pe-4 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500"
80
+ />
81
+ </div>
82
+
83
+ <!-- Loading skeleton -->
84
+ <div v-if="isLoading" class="space-y-2">
85
+ <div
86
+ v-for="i in 8"
87
+ :key="i"
88
+ class="h-16 bg-surface rounded-xl animate-pulse"
89
+ />
90
+ </div>
91
+
92
+ <!-- Rows -->
93
+ <div v-else class="space-y-2">
94
+ <div
95
+ v-for="row in rows"
96
+ :key="row.id ?? JSON.stringify(row)"
97
+ class="bg-card border border-card-line rounded-xl px-4 py-3 hover:border-indigo-300 dark:hover:border-indigo-600 transition-colors cursor-pointer"
98
+ @click="emit('row-click', row)"
99
+ >
100
+ <slot name="item" :row="row">
101
+ <!-- Default: show first few keys -->
102
+ <div class="flex flex-wrap gap-x-6 gap-y-1">
103
+ <div
104
+ v-for="(val, key) in Object.fromEntries(Object.entries(row).slice(0, 4))"
105
+ :key="key"
106
+ class="text-sm"
107
+ >
108
+ <span class="text-muted-foreground text-xs">{{ key }}: </span>
109
+ <span class="text-foreground">{{ val }}</span>
110
+ </div>
111
+ </div>
112
+ </slot>
113
+ </div>
114
+
115
+ <div v-if="rows.length === 0" class="text-center py-12 text-muted-foreground text-sm">
116
+ Sin resultados
117
+ </div>
118
+ </div>
119
+
120
+ <!-- Sentinel + loader -->
121
+ <div ref="sentinel" class="flex justify-center py-4">
122
+ <div v-if="isFetchingNextPage" class="flex items-center gap-2 text-sm text-slate-400">
123
+ <IconLoader2 class="size-4 animate-spin" stroke="1.5" />
124
+ Cargando más...
125
+ </div>
126
+ </div>
127
+ </div>
128
+ </template>