@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.
- package/components/Table/Database.vue +183 -0
- package/components/Table/Grid.vue +62 -0
- package/components/Table/Kanban.vue +188 -0
- package/components/Table/List.vue +128 -0
- package/components/Table/Standard.vue +134 -0
- package/components/TableExportable.vue +205 -0
- package/components/TableFilter.vue +100 -0
- package/package.json +1 -1
|
@@ -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>
|