@innertia-solutions/nuxt-theme-spark 0.1.18 → 0.1.19

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.
@@ -0,0 +1,183 @@
1
+ <script setup>
2
+ import { IconSearch, IconLoader2, IconCheck, IconX } from '@tabler/icons-vue'
3
+
4
+ // Dense database view with inline cell editing — click cell → input → blur → mutation
5
+ const props = defineProps({
6
+ endpoint: { type: String, required: true },
7
+ columns: { type: Array, required: true }, // [{ key, label, editable?, type?: 'text'|'number'|'select', options?: [] }]
8
+ name: { type: String, required: true },
9
+ params: { type: Object, default: () => ({}) },
10
+ updateMutation: { type: Function, default: null }, // (id, field, value) => Promise
11
+ cached: { type: Boolean, default: false },
12
+ searchPlaceholder: { type: String, default: 'Buscar...' },
13
+ showSearch: { type: Boolean, default: true },
14
+ })
15
+
16
+ const emit = defineEmits(['row-click', 'cell-save'])
17
+
18
+ const tableRef = ref(null)
19
+
20
+ // ─── Inline editing ───────────────────────────────────────────────────────────
21
+ const editingCell = ref(null) // { rowId, key }
22
+ const editingValue = ref('')
23
+ const savingCell = ref(null)
24
+ const cellError = ref(null)
25
+
26
+ const startEdit = (row, col) => {
27
+ if (!col.editable) return
28
+ editingCell.value = { rowId: row.id, key: col.key }
29
+ editingValue.value = row[col.key] ?? ''
30
+ cellError.value = null
31
+ nextTick(() => {
32
+ const input = document.querySelector(`[data-cell-input="${row.id}-${col.key}"]`)
33
+ input?.focus()
34
+ input?.select()
35
+ })
36
+ }
37
+
38
+ const cancelEdit = () => {
39
+ editingCell.value = null
40
+ editingValue.value = ''
41
+ cellError.value = null
42
+ }
43
+
44
+ const saveEdit = async (row, col) => {
45
+ if (!editingCell.value) return
46
+ const newValue = editingValue.value
47
+ const oldValue = row[col.key]
48
+
49
+ if (newValue === String(oldValue ?? '')) {
50
+ cancelEdit()
51
+ return
52
+ }
53
+
54
+ editingCell.value = null
55
+ savingCell.value = { rowId: row.id, key: col.key }
56
+
57
+ try {
58
+ if (props.updateMutation) {
59
+ await props.updateMutation(row.id, col.key, newValue)
60
+ }
61
+ // Patch local row
62
+ row[col.key] = newValue
63
+ emit('cell-save', { id: row.id, field: col.key, value: newValue, oldValue })
64
+ tableRef.value?.reload()
65
+ } catch (e) {
66
+ cellError.value = { rowId: row.id, key: col.key, message: e.message }
67
+ } finally {
68
+ savingCell.value = null
69
+ }
70
+ }
71
+
72
+ const isEditing = (rowId, key) => editingCell.value?.rowId === rowId && editingCell.value?.key === key
73
+ const isSaving = (rowId, key) => savingCell.value?.rowId === rowId && savingCell.value?.key === key
74
+
75
+ const search = ref('')
76
+ const mergedParams = computed(() => ({ ...props.params }))
77
+
78
+ const reload = () => tableRef.value?.reload()
79
+ defineExpose({ reload })
80
+ </script>
81
+
82
+ <template>
83
+ <div class="flex flex-col gap-3">
84
+ <div v-if="showSearch" class="relative max-w-sm">
85
+ <div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
86
+ <IconSearch class="size-4 text-slate-400" stroke="1.5" />
87
+ </div>
88
+ <input
89
+ v-model="search"
90
+ type="search"
91
+ :placeholder="searchPlaceholder"
92
+ class="block w-full rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white py-1.5 ps-9 pe-4 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500"
93
+ />
94
+ </div>
95
+
96
+ <!-- Dense table wrapper — override Table's default padding with compact styles -->
97
+ <div class="overflow-x-auto border border-slate-200 dark:border-slate-700 rounded-xl">
98
+ <Table
99
+ ref="tableRef"
100
+ :endpoint="endpoint"
101
+ :columns="columns"
102
+ :name="name"
103
+ :params="mergedParams"
104
+ :search="search"
105
+ :cached="cached"
106
+ :show-reload-button="false"
107
+ class="[&_td]:py-1 [&_td]:px-2 [&_th]:py-1.5 [&_th]:px-2 [&_td]:text-xs [&_th]:text-xs"
108
+ @row-click="emit('row-click', $event)"
109
+ >
110
+ <!-- Override each cell slot to support inline editing -->
111
+ <template
112
+ v-for="col in columns"
113
+ :key="col.key"
114
+ #[`cell-${col.key}`]="{ row }"
115
+ >
116
+ <!-- Editing state -->
117
+ <div v-if="isEditing(row.id, col.key)" class="flex items-center gap-1 -mx-1">
118
+ <select
119
+ v-if="col.type === 'select' && col.options"
120
+ v-model="editingValue"
121
+ :data-cell-input="`${row.id}-${col.key}`"
122
+ @blur="saveEdit(row, col)"
123
+ @keydown.enter="saveEdit(row, col)"
124
+ @keydown.escape="cancelEdit"
125
+ class="flex-1 min-w-0 rounded border border-indigo-400 bg-white dark:bg-slate-700 text-slate-900 dark:text-white py-0.5 px-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500"
126
+ >
127
+ <option v-for="opt in col.options" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
128
+ </select>
129
+ <input
130
+ v-else
131
+ v-model="editingValue"
132
+ :type="col.type === 'number' ? 'number' : 'text'"
133
+ :data-cell-input="`${row.id}-${col.key}`"
134
+ @blur="saveEdit(row, col)"
135
+ @keydown.enter="saveEdit(row, col)"
136
+ @keydown.escape="cancelEdit"
137
+ class="flex-1 min-w-0 rounded border border-indigo-400 bg-white dark:bg-slate-700 text-slate-900 dark:text-white py-0.5 px-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500"
138
+ />
139
+ </div>
140
+
141
+ <!-- Saving state -->
142
+ <div v-else-if="isSaving(row.id, col.key)" class="flex items-center gap-1 text-slate-400">
143
+ <IconLoader2 class="size-3 animate-spin shrink-0" stroke="1.5" />
144
+ <span class="truncate">{{ row[col.key] }}</span>
145
+ </div>
146
+
147
+ <!-- Error state -->
148
+ <div
149
+ v-else-if="cellError?.rowId === row.id && cellError?.key === col.key"
150
+ :title="cellError.message"
151
+ class="flex items-center gap-1 text-red-500 cursor-pointer"
152
+ @click="startEdit(row, col)"
153
+ >
154
+ <IconX class="size-3 shrink-0" stroke="2" />
155
+ <span class="truncate text-xs">{{ row[col.key] }}</span>
156
+ </div>
157
+
158
+ <!-- View state -->
159
+ <div
160
+ v-else
161
+ :class="[
162
+ 'truncate',
163
+ col.editable ? 'cursor-text hover:bg-indigo-50 dark:hover:bg-indigo-900/20 rounded px-1 -mx-1 group relative' : ''
164
+ ]"
165
+ @click="col.editable ? startEdit(row, col) : emit('row-click', row)"
166
+ >
167
+ <slot :name="`cell-${col.key}`" :row="row">
168
+ {{ row[col.key] }}
169
+ </slot>
170
+ <span
171
+ v-if="col.editable"
172
+ class="absolute inset-y-0 right-0 flex items-center opacity-0 group-hover:opacity-100 pr-1"
173
+ >
174
+ <svg class="size-3 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
175
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
176
+ </svg>
177
+ </span>
178
+ </div>
179
+ </template>
180
+ </Table>
181
+ </div>
182
+ </div>
183
+ </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-gray-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white 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-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300', 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-slate-200 dark:border-slate-700 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-slate-50/60 dark:bg-slate-900/30 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-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 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-slate-400 dark:text-slate-500 text-xs capitalize">{{ key }}: </span>
172
+ <span class="text-slate-800 dark:text-slate-200 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-slate-300 dark:text-slate-600"
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-gray-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white 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-slate-100 dark:bg-slate-800 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-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 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-slate-400 dark:text-slate-500 text-xs">{{ key }}: </span>
109
+ <span class="text-slate-800 dark:text-slate-200">{{ val }}</span>
110
+ </div>
111
+ </div>
112
+ </slot>
113
+ </div>
114
+
115
+ <div v-if="rows.length === 0" class="text-center py-12 text-slate-400 dark:text-slate-500 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>
@@ -0,0 +1,134 @@
1
+ <script setup>
2
+ import { IconSearch, IconAdjustmentsHorizontal } from '@tabler/icons-vue'
3
+
4
+ // Standard admin table: search + filters + Table (TanStack) + export
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
+ checkable: { type: Boolean, default: false },
11
+ cached: { type: Boolean, default: true },
12
+ showReloadButton: { type: Boolean, default: true },
13
+ clickRowToOpen: { type: Boolean, default: false },
14
+ searchPlaceholder: { type: String, default: 'Buscar...' },
15
+ showSearch: { type: Boolean, default: true },
16
+ showFilters: { type: Boolean, default: true },
17
+ showExport: { type: Boolean, default: true },
18
+ })
19
+
20
+ const emit = defineEmits(['row-click', 'loaded'])
21
+
22
+ const search = ref('')
23
+ const filters = ref({})
24
+ const showFilterPanel = ref(false)
25
+ const tableRef = ref(null)
26
+
27
+ const hasFilterableColumns = computed(() =>
28
+ props.columns.some(c => c.filterType)
29
+ )
30
+
31
+ const activeFilterCount = computed(() =>
32
+ Object.values(filters.value).filter(v => v !== null && v !== undefined && v !== '').length
33
+ )
34
+
35
+ // Merge filters into params
36
+ const mergedParams = computed(() => ({
37
+ ...props.params,
38
+ ...filters.value,
39
+ }))
40
+
41
+ const getSelectedRows = () => tableRef.value?.getSelectedRows()
42
+ const reload = () => tableRef.value?.reload()
43
+ const clearCache = () => tableRef.value?.clearCache()
44
+ const exportTable = (format, allPages, filteredRows) => tableRef.value?.exportTable(format, allPages, filteredRows)
45
+
46
+ defineExpose({ getSelectedRows, reload, clearCache, exportTable, tableRef })
47
+ </script>
48
+
49
+ <template>
50
+ <div class="flex flex-col gap-4">
51
+ <!-- Toolbar -->
52
+ <div class="flex flex-wrap items-center gap-3">
53
+ <!-- Search -->
54
+ <div v-if="showSearch" class="relative flex-1 min-w-48">
55
+ <div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
56
+ <IconSearch class="size-4 text-slate-400" stroke="1.5" />
57
+ </div>
58
+ <input
59
+ v-model="search"
60
+ type="search"
61
+ :placeholder="searchPlaceholder"
62
+ class="block w-full rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white py-2 ps-10 pe-4 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500"
63
+ />
64
+ </div>
65
+
66
+ <!-- Filter toggle -->
67
+ <button
68
+ v-if="showFilters && hasFilterableColumns"
69
+ type="button"
70
+ @click="showFilterPanel = !showFilterPanel"
71
+ :class="[
72
+ 'py-2 px-3 inline-flex items-center gap-2 text-sm font-medium rounded-lg border transition-colors',
73
+ showFilterPanel || activeFilterCount > 0
74
+ ? 'border-indigo-500 bg-indigo-50 text-indigo-700 dark:bg-indigo-900/20 dark:border-indigo-500 dark:text-indigo-300'
75
+ : 'border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700'
76
+ ]"
77
+ >
78
+ <IconAdjustmentsHorizontal class="size-4" stroke="1.5" />
79
+ Filtros
80
+ <span
81
+ v-if="activeFilterCount > 0"
82
+ class="inline-flex items-center justify-center size-5 rounded-full bg-indigo-600 text-white text-xs font-bold"
83
+ >{{ activeFilterCount }}</span>
84
+ </button>
85
+
86
+ <slot name="actions" />
87
+
88
+ <!-- Export -->
89
+ <TableExportable
90
+ v-if="showExport"
91
+ :table-ref="tableRef"
92
+ :name="name"
93
+ :columns="columns"
94
+ />
95
+ </div>
96
+
97
+ <!-- Filter panel -->
98
+ <Transition
99
+ enter-active-class="transition ease-out duration-150"
100
+ enter-from-class="opacity-0 -translate-y-2"
101
+ enter-to-class="opacity-100 translate-y-0"
102
+ leave-active-class="transition ease-in duration-100"
103
+ leave-from-class="opacity-100 translate-y-0"
104
+ leave-to-class="opacity-0 -translate-y-2"
105
+ >
106
+ <div
107
+ v-if="showFilterPanel && hasFilterableColumns"
108
+ class="p-4 bg-slate-50 dark:bg-slate-800/50 rounded-xl border border-slate-200 dark:border-slate-700"
109
+ >
110
+ <TableFilter v-model="filters" :columns="columns" />
111
+ </div>
112
+ </Transition>
113
+
114
+ <!-- Table -->
115
+ <Table
116
+ ref="tableRef"
117
+ :endpoint="endpoint"
118
+ :columns="columns"
119
+ :name="name"
120
+ :params="mergedParams"
121
+ :search="search"
122
+ :checkable="checkable"
123
+ :cached="cached"
124
+ :show-reload-button="showReloadButton"
125
+ :click-row-to-open="clickRowToOpen"
126
+ @row-click="emit('row-click', $event)"
127
+ @loaded="emit('loaded', $event)"
128
+ >
129
+ <template v-for="(_, name) in $slots" #[name]="slotProps">
130
+ <slot :name="name" v-bind="slotProps ?? {}" />
131
+ </template>
132
+ </Table>
133
+ </div>
134
+ </template>
@@ -0,0 +1,205 @@
1
+ <script setup>
2
+ import {
3
+ IconFileTypeXls,
4
+ IconCodeDots,
5
+ IconFileTypePdf,
6
+ IconFileTypeCsv,
7
+ IconDownload,
8
+ } from '@tabler/icons-vue'
9
+
10
+ // Modal with format selector, pre-filled filename, columns checkboxes
11
+ const props = defineProps({
12
+ tableRef: { type: Object, default: null },
13
+ name: { type: String, default: 'export' },
14
+ columns: { type: Array, default: () => [] }, // [{ key, label }]
15
+ })
16
+
17
+ const isOpen = ref(false)
18
+ const format = ref('xlsx')
19
+ const filename = ref(props.name)
20
+ const selectedColumns = ref([])
21
+
22
+ watch(() => props.columns, (cols) => {
23
+ selectedColumns.value = cols.map(c => c.key)
24
+ }, { immediate: true })
25
+
26
+ watch(() => props.name, (v) => { filename.value = v })
27
+
28
+ const formats = [
29
+ { value: 'xlsx', label: 'Excel', icon: 'xlsx' },
30
+ { value: 'csv', label: 'CSV', icon: 'csv' },
31
+ { value: 'pdf', label: 'PDF', icon: 'pdf' },
32
+ { value: 'json', label: 'JSON', icon: 'json' },
33
+ ]
34
+
35
+ const toggleColumn = (key) => {
36
+ const idx = selectedColumns.value.indexOf(key)
37
+ if (idx >= 0) selectedColumns.value.splice(idx, 1)
38
+ else selectedColumns.value.push(key)
39
+ }
40
+
41
+ const toggleAll = () => {
42
+ if (selectedColumns.value.length === props.columns.length)
43
+ selectedColumns.value = []
44
+ else
45
+ selectedColumns.value = props.columns.map(c => c.key)
46
+ }
47
+
48
+ const allSelected = computed(() => selectedColumns.value.length === props.columns.length)
49
+ const indeterminate = computed(() => selectedColumns.value.length > 0 && !allSelected.value)
50
+
51
+ const doExport = () => {
52
+ if (props.tableRef) {
53
+ props.tableRef.exportTable(format.value, true, true)
54
+ }
55
+ isOpen.value = false
56
+ }
57
+
58
+ const open = () => { isOpen.value = true }
59
+
60
+ defineExpose({ open })
61
+ </script>
62
+
63
+ <template>
64
+ <div>
65
+ <button
66
+ type="button"
67
+ @click="isOpen = true"
68
+ class="py-1.5 sm:py-2 px-2.5 inline-flex items-center gap-x-1.5 text-sm font-medium rounded-lg border border-slate-200 bg-white text-slate-800 shadow-2xs hover:bg-slate-50 disabled:opacity-50 disabled:pointer-events-none focus:outline-hidden dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700"
69
+ >
70
+ <IconDownload class="shrink-0 size-4" stroke="1.5" />
71
+ Exportar
72
+ </button>
73
+
74
+ <!-- Modal -->
75
+ <Teleport to="body">
76
+ <Transition
77
+ enter-active-class="transition ease-out duration-200"
78
+ enter-from-class="opacity-0"
79
+ enter-to-class="opacity-100"
80
+ leave-active-class="transition ease-in duration-150"
81
+ leave-from-class="opacity-100"
82
+ leave-to-class="opacity-0"
83
+ >
84
+ <div
85
+ v-if="isOpen"
86
+ class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm"
87
+ @click.self="isOpen = false"
88
+ >
89
+ <Transition
90
+ enter-active-class="transition ease-out duration-200"
91
+ enter-from-class="opacity-0 scale-95"
92
+ enter-to-class="opacity-100 scale-100"
93
+ >
94
+ <div
95
+ v-if="isOpen"
96
+ class="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl w-full max-w-md border border-slate-200 dark:border-slate-700"
97
+ >
98
+ <div class="flex items-center justify-between px-6 py-4 border-b border-slate-100 dark:border-slate-700">
99
+ <h3 class="font-semibold text-slate-800 dark:text-slate-100">Exportar tabla</h3>
100
+ <button
101
+ type="button"
102
+ @click="isOpen = false"
103
+ class="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
104
+ >
105
+ <svg class="size-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
106
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
107
+ </svg>
108
+ </button>
109
+ </div>
110
+
111
+ <div class="px-6 py-5 space-y-5">
112
+ <!-- Format selector -->
113
+ <div>
114
+ <p class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-2">Formato</p>
115
+ <div class="grid grid-cols-4 gap-2">
116
+ <button
117
+ v-for="f in formats"
118
+ :key="f.value"
119
+ type="button"
120
+ @click="format = f.value"
121
+ :class="[
122
+ 'flex flex-col items-center gap-1.5 p-3 rounded-xl border text-sm font-medium transition-colors',
123
+ format === f.value
124
+ ? 'border-indigo-500 bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:border-indigo-500 dark:text-indigo-300'
125
+ : 'border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700'
126
+ ]"
127
+ >
128
+ <IconFileTypeXls v-if="f.value === 'xlsx'" class="size-5" stroke="1.5" />
129
+ <IconFileTypeCsv v-else-if="f.value === 'csv'" class="size-5" stroke="1.5" />
130
+ <IconFileTypePdf v-else-if="f.value === 'pdf'" class="size-5" stroke="1.5" />
131
+ <IconCodeDots v-else class="size-5" stroke="1.5" />
132
+ {{ f.label }}
133
+ </button>
134
+ </div>
135
+ </div>
136
+
137
+ <!-- Filename -->
138
+ <div>
139
+ <label class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider block mb-2">
140
+ Nombre de archivo
141
+ </label>
142
+ <div class="flex items-center gap-2">
143
+ <input
144
+ v-model="filename"
145
+ type="text"
146
+ class="flex-1 rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 text-slate-900 dark:text-white py-2 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500"
147
+ />
148
+ <span class="text-sm text-slate-400">.{{ format }}</span>
149
+ </div>
150
+ </div>
151
+
152
+ <!-- Columns -->
153
+ <div v-if="columns.length > 0">
154
+ <div class="flex items-center justify-between mb-2">
155
+ <p class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">Columnas</p>
156
+ <button
157
+ type="button"
158
+ @click="toggleAll"
159
+ class="text-xs text-indigo-600 dark:text-indigo-400 hover:underline"
160
+ >
161
+ {{ allSelected ? 'Deseleccionar todas' : 'Seleccionar todas' }}
162
+ </button>
163
+ </div>
164
+ <div class="grid grid-cols-2 gap-1.5 max-h-40 overflow-y-auto pr-1">
165
+ <label
166
+ v-for="col in columns"
167
+ :key="col.key"
168
+ class="flex items-center gap-2 py-1.5 px-2 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700 cursor-pointer"
169
+ >
170
+ <input
171
+ type="checkbox"
172
+ :checked="selectedColumns.includes(col.key)"
173
+ @change="toggleColumn(col.key)"
174
+ class="rounded border-gray-300 dark:bg-slate-700 dark:border-slate-600 text-indigo-600"
175
+ />
176
+ <span class="text-sm text-slate-700 dark:text-slate-200 truncate">{{ col.label }}</span>
177
+ </label>
178
+ </div>
179
+ </div>
180
+ </div>
181
+
182
+ <div class="flex justify-end gap-2 px-6 py-4 border-t border-slate-100 dark:border-slate-700">
183
+ <button
184
+ type="button"
185
+ @click="isOpen = false"
186
+ class="py-2 px-4 text-sm font-medium rounded-lg border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
187
+ >
188
+ Cancelar
189
+ </button>
190
+ <button
191
+ type="button"
192
+ @click="doExport"
193
+ class="py-2 px-4 text-sm font-medium rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 transition-colors inline-flex items-center gap-2"
194
+ >
195
+ <IconDownload class="size-4" stroke="1.5" />
196
+ Exportar
197
+ </button>
198
+ </div>
199
+ </div>
200
+ </Transition>
201
+ </div>
202
+ </Transition>
203
+ </Teleport>
204
+ </div>
205
+ </template>
@@ -0,0 +1,100 @@
1
+ <script setup>
2
+ // Reusable filter panel — reads filterType from column definitions
3
+ // Column definition: { key, label, filterType: 'text' | 'select' | 'daterange', filterOptions?: [{value, label}] }
4
+
5
+ const props = defineProps({
6
+ modelValue: { type: Object, default: () => ({}) }, // { [key]: value }
7
+ columns: { type: Array, required: true },
8
+ })
9
+
10
+ const emit = defineEmits(['update:modelValue'])
11
+
12
+ const filterableColumns = computed(() =>
13
+ props.columns.filter(c => c.filterType)
14
+ )
15
+
16
+ const localFilters = ref({ ...props.modelValue })
17
+
18
+ watch(() => props.modelValue, (v) => {
19
+ localFilters.value = { ...v }
20
+ }, { deep: true })
21
+
22
+ const updateFilter = (key, value) => {
23
+ localFilters.value[key] = value
24
+ emit('update:modelValue', { ...localFilters.value })
25
+ }
26
+
27
+ const clearAll = () => {
28
+ localFilters.value = {}
29
+ emit('update:modelValue', {})
30
+ }
31
+
32
+ const activeCount = computed(() =>
33
+ Object.values(localFilters.value).filter(v => v !== null && v !== undefined && v !== '').length
34
+ )
35
+ </script>
36
+
37
+ <template>
38
+ <div class="flex flex-wrap items-end gap-3">
39
+ <template v-for="col in filterableColumns" :key="col.key">
40
+ <!-- text filter -->
41
+ <div v-if="col.filterType === 'text'" class="flex flex-col gap-1 min-w-40">
42
+ <label class="text-xs text-slate-500 dark:text-slate-400">{{ col.label }}</label>
43
+ <input
44
+ type="text"
45
+ :value="localFilters[col.key] ?? ''"
46
+ @input="updateFilter(col.key, $event.target.value)"
47
+ :placeholder="`Filtrar ${col.label.toLowerCase()}...`"
48
+ class="rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white py-2 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500"
49
+ />
50
+ </div>
51
+
52
+ <!-- select filter -->
53
+ <div v-else-if="col.filterType === 'select'" class="flex flex-col gap-1 min-w-40">
54
+ <label class="text-xs text-slate-500 dark:text-slate-400">{{ col.label }}</label>
55
+ <select
56
+ :value="localFilters[col.key] ?? ''"
57
+ @change="updateFilter(col.key, $event.target.value || null)"
58
+ class="rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white py-2 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500"
59
+ >
60
+ <option value="">Todos</option>
61
+ <option
62
+ v-for="opt in col.filterOptions ?? []"
63
+ :key="opt.value"
64
+ :value="opt.value"
65
+ >{{ opt.label }}</option>
66
+ </select>
67
+ </div>
68
+
69
+ <!-- daterange filter -->
70
+ <div v-else-if="col.filterType === 'daterange'" class="flex flex-col gap-1">
71
+ <label class="text-xs text-slate-500 dark:text-slate-400">{{ col.label }}</label>
72
+ <div class="flex items-center gap-1.5">
73
+ <input
74
+ type="date"
75
+ :value="localFilters[col.key]?.from ?? ''"
76
+ @change="updateFilter(col.key, { ...localFilters[col.key], from: $event.target.value || null })"
77
+ class="rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white py-2 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500"
78
+ />
79
+ <span class="text-slate-400 text-sm">—</span>
80
+ <input
81
+ type="date"
82
+ :value="localFilters[col.key]?.to ?? ''"
83
+ @change="updateFilter(col.key, { ...localFilters[col.key], to: $event.target.value || null })"
84
+ class="rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white py-2 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500"
85
+ />
86
+ </div>
87
+ </div>
88
+ </template>
89
+
90
+ <button
91
+ v-if="activeCount > 0"
92
+ type="button"
93
+ @click="clearAll"
94
+ class="py-2 px-3 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors flex items-center gap-1.5 self-end"
95
+ >
96
+ <svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
97
+ Limpiar ({{ activeCount }})
98
+ </button>
99
+ </div>
100
+ </template>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@innertia-solutions/nuxt-theme-spark",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
4
4
  "description": "Innertia Solutions — Spark theme: backoffice, landing and mobile components and layouts",
5
5
  "keywords": [
6
6
  "nuxt",