@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.
- package/.github/workflows/auto-publish.yml +64 -0
- package/.github/workflows/release.yml +59 -0
- package/README.md +60 -0
- package/app.config.ts +70 -0
- package/components/Admin/Base.vue +144 -0
- package/components/Admin/Header.vue +32 -0
- package/components/Admin/Page.vue +65 -0
- package/components/Admin/PageHeader.vue +31 -0
- package/components/App/Button.vue +59 -0
- package/components/App/DevEnvironmentBar.vue +43 -0
- package/components/App/Dropdown.vue +286 -0
- package/components/App/EmptyState.vue +433 -0
- package/components/App/LoadingState.vue +40 -0
- package/components/App/PageLoadingSpinner.vue +118 -0
- package/components/App/PreviewDock.vue +64 -0
- package/components/App/SwitchColorTheme.vue +51 -0
- package/components/App/Tag.vue +193 -0
- package/components/DataTable.vue +713 -0
- package/components/Forms/DatePicker.vue +255 -0
- package/components/Forms/Input.vue +75 -0
- package/components/Forms/Select.vue +100 -0
- package/components/Forms/SelectServer.vue +726 -0
- package/components/Layout/Admin.vue +32 -0
- package/components/Layout/Auth.vue +29 -0
- package/components/Layout/SidebarWithAppColumn.vue +388 -0
- package/components/Layout/TopBar.vue +113 -0
- package/components/MobileBlocker.vue +85 -0
- package/components/MobileLoginPicker.vue +83 -0
- package/components/Modal/Base.vue +29 -0
- package/components/Modal/DeleteConfirm.vue +48 -0
- package/components/Modal.vue +103 -0
- package/components/Nav/Tabs.vue +55 -0
- package/components/PermissionsTree.vue +272 -0
- package/components/Table/Database.vue +183 -0
- package/components/Table/DownloadDropdown.vue +111 -0
- package/components/Table/Enterprise.vue +540 -0
- package/components/Table/FilterDropdown.vue +226 -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/PreviewTimeline.vue +118 -0
- package/components/Table/Standard.vue +1217 -0
- package/components/Table/index.vue +974 -0
- package/components/TableExportable.vue +172 -0
- package/components/TableFilter.vue +93 -0
- package/components/Toast/Alert.vue +113 -0
- package/components/Toast/Container.vue +34 -0
- package/components/Toast/Notification.vue +45 -0
- package/components/Toast/Process.vue +88 -0
- package/composables/useApi.js +95 -0
- package/composables/useApp.ts +46 -0
- package/composables/useAuth.js +82 -0
- package/composables/useContext.js +44 -0
- package/composables/useDate.js +241 -0
- package/composables/useDevice.js +21 -0
- package/composables/useDockedPreviews.js +56 -0
- package/composables/useDownload.js +87 -0
- package/composables/useEntity.js +82 -0
- package/composables/useForm.js +119 -0
- package/composables/useInnertiaMode.ts +25 -0
- package/composables/useMobileGuard.ts +81 -0
- package/composables/useNotifications.js +22 -0
- package/composables/usePermissions.js +23 -0
- package/composables/useRealtime.js +123 -0
- package/composables/useRequestInterceptors.js +27 -0
- package/composables/useRoles.js +53 -0
- package/composables/useRutFormatter.js +39 -0
- package/composables/useTable.ts +94 -0
- package/composables/useTablePreferences.ts +33 -0
- package/composables/useTenant.js +27 -0
- package/composables/useTimeAgo.js +37 -0
- package/composables/useToast.js +69 -0
- package/composables/useUserRealtime.js +17 -0
- package/composables/useUsers.js +111 -0
- package/css/themes/autumn.css +401 -0
- package/css/themes/bubblegum.css +408 -0
- package/css/themes/cashmere.css +412 -0
- package/css/themes/harvest.css +416 -0
- package/css/themes/moon.css +140 -0
- package/css/themes/ocean.css +273 -0
- package/css/themes/olive.css +413 -0
- package/css/themes/retro.css +431 -0
- package/css/themes/theme.css +725 -0
- package/error.vue +78 -0
- package/middleware/01.detect-subdomain.global.ts +43 -0
- package/middleware/02.validate-tenant.global.ts +67 -0
- package/middleware/03.apps.global.ts +88 -0
- package/middleware/auth.ts +9 -0
- package/middleware/guest.ts +9 -0
- package/nuxt.config.ts +42 -0
- package/package.json +60 -0
- package/pages/tenant-error.vue +50 -0
- package/plugins/api-auth.ts +12 -0
- package/plugins/api-tenant.client.ts +21 -0
- package/plugins/appearance.ts +8 -0
- package/plugins/auth-init.ts +34 -0
- package/plugins/dark-state.client.ts +29 -0
- package/plugins/dockedPreviewsSync.client.js +17 -0
- package/plugins/preline.client.ts +68 -0
- package/plugins/theme.client.ts +7 -0
- package/plugins/vue-query.ts +29 -0
- package/public/init-theme.js +15 -0
- package/spark.css +721 -0
- package/stores/auth.js +130 -0
- package/stores/dockedPreviews.js +34 -0
- package/stores/notifications.js +24 -0
- package/stores/tenant.js +54 -0
- package/stores/toast.js +129 -0
|
@@ -0,0 +1,1217 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { IconSearch, IconLayoutColumns, IconGripVertical, IconMinus, IconMaximize, IconX, IconPlus, IconChevronLeft, IconCheck, IconChevronDown, IconExternalLink, IconTrash } from '@tabler/icons-vue'
|
|
3
|
+
import Table from './index.vue'
|
|
4
|
+
|
|
5
|
+
const props = defineProps({
|
|
6
|
+
table: { type: Object, default: null },
|
|
7
|
+
endpoint: { type: String, default: '' },
|
|
8
|
+
columns: { type: Array, required: true },
|
|
9
|
+
name: { type: String, default: '' },
|
|
10
|
+
params: { type: Object, default: () => ({}) },
|
|
11
|
+
checkable: { type: Boolean, default: false },
|
|
12
|
+
cached: { type: Boolean, default: false },
|
|
13
|
+
showReloadButton: { type: Boolean, default: true },
|
|
14
|
+
clickRowToOpen: { type: Boolean, default: false },
|
|
15
|
+
searchPlaceholder: { type: String, default: 'Buscar...' },
|
|
16
|
+
showSearch: { type: Boolean, default: true },
|
|
17
|
+
showFilters: { type: Boolean, default: true },
|
|
18
|
+
showExport: { type: Boolean, default: true },
|
|
19
|
+
filters: { type: Array, default: () => [] },
|
|
20
|
+
splitRatio: { type: Number, default: 60 },
|
|
21
|
+
autoClosePreview: { type: Boolean, default: true },
|
|
22
|
+
previewHref: { type: [String, Function], default: null }, // url fija o (row) => url
|
|
23
|
+
previewDeletable: { type: Boolean, default: false },
|
|
24
|
+
defaultPinnedColumns: { type: Object, default: null }, // { left?: string[], right?: string[] }
|
|
25
|
+
persistPreferences: { type: Boolean, default: true }, // persist column prefs in backend
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const resolvedEndpoint = computed(() => props.table?.endpoint ?? props.endpoint)
|
|
29
|
+
const resolvedName = computed(() => props.table?.name ?? props.name)
|
|
30
|
+
|
|
31
|
+
// ─── Table preferences (column pinning, visibility, order) ───────────────────
|
|
32
|
+
const tablePrefName = computed(() => resolvedName.value || 'default')
|
|
33
|
+
const { preferences: tablePrefs, load: loadPrefs, save: savePrefs } = useTablePreferences(tablePrefName.value)
|
|
34
|
+
|
|
35
|
+
// Resolved initial pinned columns: merge defaultPinnedColumns with saved preferences
|
|
36
|
+
const resolvedPinnedColumns = computed(() => {
|
|
37
|
+
const saved = tablePrefs.value.pinning
|
|
38
|
+
if (saved) return saved
|
|
39
|
+
return props.defaultPinnedColumns ?? null
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const emit = defineEmits(['row-click', 'loaded', 'preview-delete'])
|
|
43
|
+
const slots = useSlots()
|
|
44
|
+
const forwardedSlots = computed(() => {
|
|
45
|
+
const excluded = new Set(['toolbar', 'preview'])
|
|
46
|
+
return Object.fromEntries(Object.entries(slots).filter(([k]) => !excluded.has(k)))
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const search = ref('')
|
|
50
|
+
const activeFilters = ref({})
|
|
51
|
+
const tableRef = ref(null)
|
|
52
|
+
const prefsLoaded = ref(false)
|
|
53
|
+
|
|
54
|
+
// ─── Filter config ─────────────────────────────────────────────────────────────
|
|
55
|
+
const filtersConfig = computed(() =>
|
|
56
|
+
props.filters?.length ? props.filters : props.columns.filter(c => c.filterType)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
const hasFilterableColumns = computed(() => filtersConfig.value.length > 0)
|
|
60
|
+
|
|
61
|
+
// ─── Notion-style filter ───────────────────────────────────────────────────────
|
|
62
|
+
const showFilterPanel = ref(false)
|
|
63
|
+
const filterMenuStep = ref('columns') // 'columns' | 'value'
|
|
64
|
+
const pendingCol = ref(null)
|
|
65
|
+
const pendingValue = ref(null) // string for text/select, { singleDate, from, to } for daterange
|
|
66
|
+
const pendingDateOp = ref('before') // 'before' | 'after' | 'between'
|
|
67
|
+
const filterMenuRef = ref(null)
|
|
68
|
+
const filterAddBtnRef = ref(null)
|
|
69
|
+
const filterMenuStyle = ref({})
|
|
70
|
+
|
|
71
|
+
const pendingOperator = ref('contains')
|
|
72
|
+
const pendingValueInputRef = ref(null)
|
|
73
|
+
|
|
74
|
+
const textOps = [
|
|
75
|
+
{ value: 'contains', label: 'contiene' },
|
|
76
|
+
{ value: 'starts_with', label: 'empieza con' },
|
|
77
|
+
{ value: 'equals', label: 'es igual a' },
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
const selectOps = [
|
|
81
|
+
{ value: 'is', label: 'es' },
|
|
82
|
+
{ value: 'is_not', label: 'no es' },
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
const dateOps = [
|
|
86
|
+
{ value: 'before', label: 'antes de' },
|
|
87
|
+
{ value: 'after', label: 'después de' },
|
|
88
|
+
{ value: 'between', label: 'entre' },
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
const activeFilterList = computed(() =>
|
|
92
|
+
filtersConfig.value
|
|
93
|
+
.filter(col => {
|
|
94
|
+
const v = activeFilters.value[col.key]
|
|
95
|
+
if (!v) return false
|
|
96
|
+
if (col.filterType === 'daterange') return v?.from || v?.to
|
|
97
|
+
return v?.value !== null && v?.value !== undefined && v?.value !== ''
|
|
98
|
+
})
|
|
99
|
+
.map(col => {
|
|
100
|
+
const v = activeFilters.value[col.key]
|
|
101
|
+
let displayOp = '', displayVal = ''
|
|
102
|
+
if (col.filterType === 'daterange') {
|
|
103
|
+
if (v.from && v.to) { displayOp = 'entre'; displayVal = `${v.from} y ${v.to}` }
|
|
104
|
+
else if (v.from) { displayOp = 'después de'; displayVal = v.from }
|
|
105
|
+
else { displayOp = 'antes de'; displayVal = v.to }
|
|
106
|
+
} else if (col.filterType === 'select') {
|
|
107
|
+
const op = selectOps.find(o => o.value === v.operator) ?? selectOps[0]
|
|
108
|
+
displayOp = op.label
|
|
109
|
+
displayVal = col.filterOptions?.find(o => o.value === v.value)?.label ?? v.value
|
|
110
|
+
} else {
|
|
111
|
+
const op = textOps.find(o => o.value === v.operator) ?? textOps[0]
|
|
112
|
+
displayOp = op.label
|
|
113
|
+
displayVal = v.value
|
|
114
|
+
}
|
|
115
|
+
return { key: col.key, label: col.label, displayOp, displayVal, col }
|
|
116
|
+
})
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
const activeFilterCount = computed(() => activeFilterList.value.length)
|
|
120
|
+
|
|
121
|
+
// Columns NOT yet filtered — what appears in the picker (already-active columns are hidden)
|
|
122
|
+
const availableFilterColumns = computed(() =>
|
|
123
|
+
filtersConfig.value.filter(col => {
|
|
124
|
+
const v = activeFilters.value[col.key]
|
|
125
|
+
if (!v) return true
|
|
126
|
+
if (col.filterType === 'daterange') return !v.from && !v.to
|
|
127
|
+
return !v.value
|
|
128
|
+
})
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
// Convert activeFilters to enriched [{field, operator, value}] for the backend DataTable
|
|
132
|
+
const enrichedFilters = computed(() => {
|
|
133
|
+
const result = []
|
|
134
|
+
for (const col of filtersConfig.value) {
|
|
135
|
+
const v = activeFilters.value[col.key]
|
|
136
|
+
if (!v) continue
|
|
137
|
+
if (col.filterType === 'daterange') {
|
|
138
|
+
if (!v.from && !v.to) continue
|
|
139
|
+
if (v.from) result.push({ field: col.key, operator: 'after', value: v.from })
|
|
140
|
+
if (v.to) result.push({ field: col.key, operator: 'before', value: v.to })
|
|
141
|
+
} else {
|
|
142
|
+
if (!v.value) continue
|
|
143
|
+
result.push({ field: col.key, operator: v.operator ?? 'contains', value: v.value })
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return result
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
const mergedParams = computed(() => ({
|
|
150
|
+
...props.params,
|
|
151
|
+
...(enrichedFilters.value.length ? { filters: enrichedFilters.value } : {}),
|
|
152
|
+
}))
|
|
153
|
+
|
|
154
|
+
const removeFilter = (key) => {
|
|
155
|
+
const u = { ...activeFilters.value }; delete u[key]; activeFilters.value = u
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const updateFilterMenuPosition = () => {
|
|
159
|
+
const rect = filterAddBtnRef.value?.getBoundingClientRect()
|
|
160
|
+
if (rect) filterMenuStyle.value = { top: rect.bottom + 4 + 'px', left: rect.left + 'px' }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const openFilterMenu = async () => {
|
|
164
|
+
filterMenuStep.value = 'columns'
|
|
165
|
+
pendingCol.value = null
|
|
166
|
+
showFilterPanel.value = true
|
|
167
|
+
await nextTick()
|
|
168
|
+
updateFilterMenuPosition()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const toggleFilterMenu = async () => {
|
|
172
|
+
if (showFilterPanel.value) { closeFilterMenu() } else { await openFilterMenu() }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const closeFilterMenu = () => {
|
|
176
|
+
showFilterPanel.value = false
|
|
177
|
+
filterMenuStep.value = 'columns'
|
|
178
|
+
pendingCol.value = null
|
|
179
|
+
pendingValue.value = null
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const selectFilterColumn = (col) => {
|
|
183
|
+
pendingCol.value = col
|
|
184
|
+
const existing = activeFilters.value[col.key]
|
|
185
|
+
if (col.filterType === 'daterange') {
|
|
186
|
+
if (existing?.from && existing?.to) { pendingDateOp.value = 'between'; pendingValue.value = { from: existing.from, to: existing.to, singleDate: '' } }
|
|
187
|
+
else if (existing?.from) { pendingDateOp.value = 'after'; pendingValue.value = { singleDate: existing.from, from: '', to: '' } }
|
|
188
|
+
else if (existing?.to) { pendingDateOp.value = 'before'; pendingValue.value = { singleDate: existing.to, from: '', to: '' } }
|
|
189
|
+
else { pendingDateOp.value = 'before'; pendingValue.value = { singleDate: '', from: '', to: '' } }
|
|
190
|
+
} else {
|
|
191
|
+
pendingOperator.value = existing?.operator ?? (col.filterType === 'select' ? 'is' : 'contains')
|
|
192
|
+
pendingValue.value = existing?.value ?? ''
|
|
193
|
+
}
|
|
194
|
+
filterMenuStep.value = 'value'
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const applyPendingFilter = () => {
|
|
198
|
+
if (!pendingCol.value) return
|
|
199
|
+
const col = pendingCol.value
|
|
200
|
+
let v
|
|
201
|
+
if (col.filterType === 'daterange') {
|
|
202
|
+
if (pendingDateOp.value === 'between') v = { from: pendingValue.value.from, to: pendingValue.value.to }
|
|
203
|
+
else if (pendingDateOp.value === 'after') v = { from: pendingValue.value.singleDate }
|
|
204
|
+
else v = { to: pendingValue.value.singleDate }
|
|
205
|
+
} else {
|
|
206
|
+
v = { value: pendingValue.value, operator: pendingOperator.value }
|
|
207
|
+
}
|
|
208
|
+
activeFilters.value = { ...activeFilters.value, [col.key]: v }
|
|
209
|
+
closeFilterMenu()
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const openEditFilter = async (col) => {
|
|
213
|
+
selectFilterColumn(col)
|
|
214
|
+
showFilterPanel.value = true
|
|
215
|
+
await nextTick()
|
|
216
|
+
const rect = filterAddBtnRef.value?.getBoundingClientRect()
|
|
217
|
+
if (rect) filterMenuStyle.value = { top: rect.bottom + 4 + 'px', left: rect.left + 'px' }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const onFilterMenuOutsideClick = (e) => {
|
|
221
|
+
if (filterMenuRef.value && !filterMenuRef.value.contains(e.target) &&
|
|
222
|
+
filterAddBtnRef.value && !filterAddBtnRef.value.contains(e.target)) {
|
|
223
|
+
closeFilterMenu()
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
watch(showFilterPanel, v => {
|
|
227
|
+
if (v) {
|
|
228
|
+
document.addEventListener('mousedown', onFilterMenuOutsideClick)
|
|
229
|
+
window.addEventListener('scroll', updateFilterMenuPosition, true)
|
|
230
|
+
window.addEventListener('resize', updateFilterMenuPosition)
|
|
231
|
+
} else {
|
|
232
|
+
document.removeEventListener('mousedown', onFilterMenuOutsideClick)
|
|
233
|
+
window.removeEventListener('scroll', updateFilterMenuPosition, true)
|
|
234
|
+
window.removeEventListener('resize', updateFilterMenuPosition)
|
|
235
|
+
}
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
watch(filterMenuStep, async (step) => {
|
|
239
|
+
if (step === 'value' && pendingCol.value?.filterType === 'text') {
|
|
240
|
+
await nextTick()
|
|
241
|
+
pendingValueInputRef.value?.focus()
|
|
242
|
+
}
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
// ─── Preview href helper ───────────────────────────────────────────────────────
|
|
246
|
+
const resolvedPreviewHref = computed(() => {
|
|
247
|
+
if (!props.previewHref || !previewRow.value) return null
|
|
248
|
+
return typeof props.previewHref === 'function'
|
|
249
|
+
? props.previewHref(previewRow.value)
|
|
250
|
+
: props.previewHref
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
// ─── Preview panel ─────────────────────────────────────────────────────────────
|
|
254
|
+
const previewRow = ref(null)
|
|
255
|
+
const currentRatio = ref(props.splitRatio)
|
|
256
|
+
const containerRef = ref(null)
|
|
257
|
+
const previewEnabled = ref(false)
|
|
258
|
+
const paginationHeight = ref(0)
|
|
259
|
+
|
|
260
|
+
const previewCacheKey = computed(() => `table-preview-${resolvedName.value}`)
|
|
261
|
+
|
|
262
|
+
const previewFromCache = ref(false)
|
|
263
|
+
const previewPanelRef = ref(null)
|
|
264
|
+
const closePreview = () => { previewRow.value = null }
|
|
265
|
+
|
|
266
|
+
const previewTab = ref('datos')
|
|
267
|
+
const tableMeta = ref(null)
|
|
268
|
+
|
|
269
|
+
const hasHistory = computed(() => !!tableMeta.value?.has_history)
|
|
270
|
+
const resolvedHistoryEndpoint = computed(() => {
|
|
271
|
+
if (!hasHistory.value || !previewRow.value?.id || !tableMeta.value?.entity_type) return null
|
|
272
|
+
return `history/${tableMeta.value.entity_type}/${previewRow.value.id}`
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
watch(previewRow, async (row) => {
|
|
276
|
+
previewTab.value = 'datos'
|
|
277
|
+
if (row) {
|
|
278
|
+
await nextTick()
|
|
279
|
+
if (typeof window !== 'undefined') window.HSStaticMethods?.autoInit?.(['Tooltip'])
|
|
280
|
+
}
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
const handleRowClick = (row) => {
|
|
284
|
+
if (previewEnabled.value) {
|
|
285
|
+
if (previewRow.value?.id === row.id) return // ya está abierto, no pestañear
|
|
286
|
+
collapseDock()
|
|
287
|
+
previewRow.value = row
|
|
288
|
+
} else {
|
|
289
|
+
emit('row-click', row)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Persist preview row in session cache when table cache is enabled
|
|
294
|
+
watch(previewRow, (row) => {
|
|
295
|
+
if (!props.cached) return
|
|
296
|
+
if (row) sessionStorage.setItem(previewCacheKey.value, JSON.stringify(row))
|
|
297
|
+
else sessionStorage.removeItem(previewCacheKey.value)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
// When data reloads, update previewRow with fresh data — close silently if deleted
|
|
301
|
+
const handleLoaded = (res) => {
|
|
302
|
+
emit('loaded', res)
|
|
303
|
+
if (res?.meta) tableMeta.value = res.meta
|
|
304
|
+
if (previewRow.value && Array.isArray(res?.data)) {
|
|
305
|
+
const fresh = res.data.find(r => r.id === previewRow.value.id)
|
|
306
|
+
if (fresh) previewRow.value = fresh
|
|
307
|
+
else closePreview()
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Track pagination bar height so the overlay never covers it
|
|
312
|
+
let paginationObserver = null
|
|
313
|
+
watch(() => tableRef.value?.paginationBarRef, (el) => {
|
|
314
|
+
paginationObserver?.disconnect()
|
|
315
|
+
paginationObserver = null
|
|
316
|
+
if (!el) return
|
|
317
|
+
paginationHeight.value = el.offsetHeight
|
|
318
|
+
paginationObserver = new ResizeObserver(() => {
|
|
319
|
+
paginationHeight.value = el.offsetHeight
|
|
320
|
+
})
|
|
321
|
+
paginationObserver.observe(el)
|
|
322
|
+
}, { flush: 'post' })
|
|
323
|
+
|
|
324
|
+
const startResize = (e) => {
|
|
325
|
+
e.preventDefault()
|
|
326
|
+
const onMove = (ev) => {
|
|
327
|
+
if (!containerRef.value) return
|
|
328
|
+
const rect = containerRef.value.getBoundingClientRect()
|
|
329
|
+
const ratio = ((ev.clientX - rect.left) / rect.width) * 100
|
|
330
|
+
currentRatio.value = Math.min(80, Math.max(25, ratio))
|
|
331
|
+
}
|
|
332
|
+
const onUp = () => {
|
|
333
|
+
window.removeEventListener('mousemove', onMove)
|
|
334
|
+
window.removeEventListener('mouseup', onUp)
|
|
335
|
+
}
|
|
336
|
+
window.addEventListener('mousemove', onMove)
|
|
337
|
+
window.addEventListener('mouseup', onUp)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const onEsc = (e) => { if (e.key === 'Escape') { if (previewRow.value) closePreview(); else collapseDock() } }
|
|
341
|
+
|
|
342
|
+
// ─── Auto-close preview on outside click ──────────────────────────────────────
|
|
343
|
+
const onDocMousedown = (e) => {
|
|
344
|
+
if (props.autoClosePreview && previewRow.value && previewPanelRef.value && !previewPanelRef.value.contains(e.target)) {
|
|
345
|
+
closePreview()
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ─── Dock (minimizar preview) ──────────────────────────────────────────────────
|
|
350
|
+
const {
|
|
351
|
+
docked,
|
|
352
|
+
dock, undock: undockItem, isActive,
|
|
353
|
+
activeDockId, activeDockRect,
|
|
354
|
+
expandDock, collapseDock,
|
|
355
|
+
} = useDockedPreviews()
|
|
356
|
+
const route = useRoute()
|
|
357
|
+
|
|
358
|
+
function minimizePreview() {
|
|
359
|
+
if (!previewRow.value) return
|
|
360
|
+
const label = previewRow.value.name ?? previewRow.value.title ?? previewRow.value.email ?? String(previewRow.value.id)
|
|
361
|
+
const subtitle = previewRow.value.email ?? previewRow.value.description ?? null
|
|
362
|
+
dock({
|
|
363
|
+
id: `${resolvedName.value}-${previewRow.value.id}`,
|
|
364
|
+
label,
|
|
365
|
+
subtitle,
|
|
366
|
+
row: { ...previewRow.value },
|
|
367
|
+
tableName: resolvedName.value,
|
|
368
|
+
route: route.path,
|
|
369
|
+
})
|
|
370
|
+
closePreview()
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Item que debe mostrarse como mini-preview flotante (pertenece a esta tabla)
|
|
374
|
+
const floatingItem = computed(() =>
|
|
375
|
+
activeDockId.value
|
|
376
|
+
? docked.value.find(d => d.id === activeDockId.value && d.tableName === resolvedName.value) ?? null
|
|
377
|
+
: null
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
// Posición del panel flotante: centrado sobre el tab que lo abrió
|
|
381
|
+
const floatingPanelStyle = computed(() => {
|
|
382
|
+
const rect = activeDockRect.value
|
|
383
|
+
const panelW = 384
|
|
384
|
+
const bottom = 52
|
|
385
|
+
if (!rect || typeof window === 'undefined') return { bottom: bottom + 'px', right: '16px' }
|
|
386
|
+
const tabCenter = rect.left + rect.width / 2
|
|
387
|
+
let right = window.innerWidth - tabCenter - panelW / 2
|
|
388
|
+
right = Math.max(8, Math.min(right, window.innerWidth - panelW - 8))
|
|
389
|
+
return { bottom: bottom + 'px', right: right + 'px' }
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
function expandToFull(item) {
|
|
393
|
+
previewRow.value = item.row
|
|
394
|
+
undockItem(item.id)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Escuchar evento de restauración (fallback cuando la tabla no estaba montada)
|
|
398
|
+
onMounted(() => {
|
|
399
|
+
useNuxtApp().hooks.hook('preview:restore', (item) => {
|
|
400
|
+
if (item.tableName === resolvedName.value) previewRow.value = item.row
|
|
401
|
+
})
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
onMounted(async () => {
|
|
405
|
+
previewEnabled.value = !!slots.preview
|
|
406
|
+
window.addEventListener('keydown', onEsc)
|
|
407
|
+
document.addEventListener('mousedown', onDocMousedown)
|
|
408
|
+
// Restore preview from session cache — mark as from-cache to skip enter animation
|
|
409
|
+
if (props.cached && previewEnabled.value) {
|
|
410
|
+
try {
|
|
411
|
+
const raw = sessionStorage.getItem(previewCacheKey.value)
|
|
412
|
+
if (raw) {
|
|
413
|
+
previewFromCache.value = true
|
|
414
|
+
previewRow.value = JSON.parse(raw)
|
|
415
|
+
await nextTick()
|
|
416
|
+
previewFromCache.value = false
|
|
417
|
+
}
|
|
418
|
+
} catch {}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Load column preferences from backend
|
|
422
|
+
if (props.persistPreferences && resolvedName.value) {
|
|
423
|
+
await loadPrefs()
|
|
424
|
+
// Apply saved visibility
|
|
425
|
+
if (tablePrefs.value.visibility && tableRef.value?.table) {
|
|
426
|
+
tableRef.value.table.setColumnVisibility(tablePrefs.value.visibility)
|
|
427
|
+
}
|
|
428
|
+
// Apply saved column order
|
|
429
|
+
if (tablePrefs.value.order?.length && tableRef.value?.setColumnOrder) {
|
|
430
|
+
tableRef.value.setColumnOrder(tablePrefs.value.order)
|
|
431
|
+
}
|
|
432
|
+
// Apply saved pinning
|
|
433
|
+
if (tablePrefs.value.pinning && tableRef.value?.table) {
|
|
434
|
+
tableRef.value.table.setColumnPinning(tablePrefs.value.pinning)
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
prefsLoaded.value = true
|
|
438
|
+
})
|
|
439
|
+
onBeforeUnmount(() => {
|
|
440
|
+
window.removeEventListener('keydown', onEsc)
|
|
441
|
+
document.removeEventListener('mousedown', onDocMousedown)
|
|
442
|
+
paginationObserver?.disconnect()
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
// ─── Column panel ─────────────────────────────────────────────────────────────
|
|
446
|
+
const showColumnPanel = ref(false)
|
|
447
|
+
const columnPanelRef = ref(null)
|
|
448
|
+
const columnButtonRef = ref(null)
|
|
449
|
+
const columnPanelStyle = ref({})
|
|
450
|
+
|
|
451
|
+
const orderedColumns = computed(() => {
|
|
452
|
+
if (!tableRef.value) return props.columns.filter(c => c.label)
|
|
453
|
+
const ids = tableRef.value.table.getAllLeafColumns().map(c => c.id).filter(id => id !== 'select')
|
|
454
|
+
return ids.map(id => props.columns.find(c => c.key === id)).filter(c => c?.label)
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
let draggedKey = null
|
|
458
|
+
let draggedFromSection = null // 'left' | 'center' | 'right'
|
|
459
|
+
const dragOverKey = ref(null)
|
|
460
|
+
const dragOverSection = ref(null)
|
|
461
|
+
|
|
462
|
+
// ─── Columns grouped by pinning section ───────────────────────────────────────
|
|
463
|
+
const columnsBySection = computed(() => {
|
|
464
|
+
// reactive dependency on pinning state
|
|
465
|
+
const _pin = tableRef.value?.columnPinning?.value
|
|
466
|
+
const cols = orderedColumns.value
|
|
467
|
+
if (!tableRef.value?.table) return { left: [], center: cols, right: [] }
|
|
468
|
+
|
|
469
|
+
const left = [], center = [], right = []
|
|
470
|
+
for (const col of cols) {
|
|
471
|
+
const pinned = tableRef.value.table.getColumn(col.key)?.getIsPinned()
|
|
472
|
+
if (pinned === 'left') left.push(col)
|
|
473
|
+
else if (pinned === 'right') right.push(col)
|
|
474
|
+
else center.push(col)
|
|
475
|
+
}
|
|
476
|
+
return { left, center, right }
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
const resetColDrag = () => {
|
|
480
|
+
draggedKey = null
|
|
481
|
+
draggedFromSection = null
|
|
482
|
+
dragOverKey.value = null
|
|
483
|
+
dragOverSection.value = null
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const onDragStart = (key, section) => { draggedKey = key; draggedFromSection = section }
|
|
487
|
+
const onDragLeave = () => { dragOverKey.value = null }
|
|
488
|
+
|
|
489
|
+
// Auto-pin anchor columns when any column enters/leaves a pinned section:
|
|
490
|
+
// Left section → checkbox (select) is always pinned left
|
|
491
|
+
// Right section → actions column is always pinned right
|
|
492
|
+
// Called AFTER pinColumn(draggedKey) so columnsBySection reflects the new state.
|
|
493
|
+
const enforceAnchorPins = (targetSection) => {
|
|
494
|
+
const t = tableRef.value
|
|
495
|
+
if (!t) return
|
|
496
|
+
const from = draggedFromSection // still valid before resetColDrag()
|
|
497
|
+
|
|
498
|
+
// ─── Left anchor: select checkbox ────────────────────────────────────────────
|
|
499
|
+
// Order (select always first) is enforced by a watch in Table/index.vue
|
|
500
|
+
if (props.checkable && t.table?.getColumn('select')) {
|
|
501
|
+
if (targetSection === 'left') {
|
|
502
|
+
t.pinColumn('select', 'left')
|
|
503
|
+
} else if (from === 'left' && columnsBySection.value.left.length === 0) {
|
|
504
|
+
t.pinColumn('select', false)
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ─── Right anchor: actions ────────────────────────────────────────────────────
|
|
509
|
+
// 'actions' has label:'' so it's excluded from orderedColumns/columnsBySection.
|
|
510
|
+
// We only auto-pin it if it exists in the columns definition.
|
|
511
|
+
const hasActions = props.columns.some(c => c.key === 'actions')
|
|
512
|
+
if (hasActions) {
|
|
513
|
+
if (targetSection === 'right') {
|
|
514
|
+
// Something was pinned right → force-pin actions too
|
|
515
|
+
t.pinColumn('actions', 'right')
|
|
516
|
+
} else if (from === 'right') {
|
|
517
|
+
// Something left the right section → unpin actions if no more right columns
|
|
518
|
+
if (columnsBySection.value.right.length === 0) t.pinColumn('actions', false)
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Drop on a specific column row (handles both reorder + section change)
|
|
524
|
+
const onDrop = (targetKey, targetSection) => {
|
|
525
|
+
if (!draggedKey) { resetColDrag(); return }
|
|
526
|
+
|
|
527
|
+
if (draggedFromSection !== targetSection) {
|
|
528
|
+
// Change pinning
|
|
529
|
+
const pinVal = targetSection === 'left' ? 'left' : targetSection === 'right' ? 'right' : false
|
|
530
|
+
tableRef.value?.pinColumn(draggedKey, pinVal)
|
|
531
|
+
enforceAnchorPins(targetSection)
|
|
532
|
+
persistCurrentPrefs()
|
|
533
|
+
} else if (draggedKey !== targetKey) {
|
|
534
|
+
// Reorder within section
|
|
535
|
+
const ids = tableRef.value?.table.getAllLeafColumns().map(c => c.id) ?? []
|
|
536
|
+
const from = ids.indexOf(draggedKey)
|
|
537
|
+
const to = ids.indexOf(targetKey)
|
|
538
|
+
if (from >= 0 && to >= 0) {
|
|
539
|
+
ids.splice(from, 1)
|
|
540
|
+
ids.splice(to, 0, draggedKey)
|
|
541
|
+
const selIdx = ids.indexOf('select')
|
|
542
|
+
if (selIdx > 0) { ids.splice(selIdx, 1); ids.unshift('select') }
|
|
543
|
+
tableRef.value?.setColumnOrder(ids)
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
resetColDrag()
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Drop on the section zone itself (empty area) — only changes pinning
|
|
550
|
+
const onDropSection = (targetSection) => {
|
|
551
|
+
if (!draggedKey || draggedFromSection === targetSection) { resetColDrag(); return }
|
|
552
|
+
const pinVal = targetSection === 'left' ? 'left' : targetSection === 'right' ? 'right' : false
|
|
553
|
+
tableRef.value?.pinColumn(draggedKey, pinVal)
|
|
554
|
+
enforceAnchorPins(targetSection)
|
|
555
|
+
persistCurrentPrefs()
|
|
556
|
+
resetColDrag()
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const onColumnPanelOutsideClick = (e) => {
|
|
560
|
+
if (
|
|
561
|
+
columnPanelRef.value && !columnPanelRef.value.contains(e.target) &&
|
|
562
|
+
columnButtonRef.value && !columnButtonRef.value.contains(e.target)
|
|
563
|
+
) {
|
|
564
|
+
showColumnPanel.value = false
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const updateColumnPanelPosition = () => {
|
|
569
|
+
const rect = columnButtonRef.value?.getBoundingClientRect()
|
|
570
|
+
if (rect) columnPanelStyle.value = { top: rect.bottom + 6 + 'px', right: window.innerWidth - rect.right + 'px' }
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
watch(showColumnPanel, async (v) => {
|
|
574
|
+
if (v) {
|
|
575
|
+
await nextTick()
|
|
576
|
+
updateColumnPanelPosition()
|
|
577
|
+
document.addEventListener('mousedown', onColumnPanelOutsideClick)
|
|
578
|
+
window.addEventListener('scroll', updateColumnPanelPosition, true)
|
|
579
|
+
window.addEventListener('resize', updateColumnPanelPosition)
|
|
580
|
+
} else {
|
|
581
|
+
document.removeEventListener('mousedown', onColumnPanelOutsideClick)
|
|
582
|
+
window.removeEventListener('scroll', updateColumnPanelPosition, true)
|
|
583
|
+
window.removeEventListener('resize', updateColumnPanelPosition)
|
|
584
|
+
}
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
// ─── Persist column preferences when they change ─────────────────────────────
|
|
588
|
+
const persistCurrentPrefs = () => {
|
|
589
|
+
if (!props.persistPreferences || !resolvedName.value || !prefsLoaded.value || !tableRef.value) return
|
|
590
|
+
const tanTable = tableRef.value.table
|
|
591
|
+
if (!tanTable) return
|
|
592
|
+
|
|
593
|
+
const visibility = Object.fromEntries(
|
|
594
|
+
tanTable.getAllLeafColumns()
|
|
595
|
+
.filter(c => c.id !== 'select')
|
|
596
|
+
.map(c => [c.id, c.getIsVisible()])
|
|
597
|
+
)
|
|
598
|
+
const order = tanTable.getAllLeafColumns().map(c => c.id).filter(id => id !== 'select')
|
|
599
|
+
const rawPinning = tableRef.value.columnPinning?.value ?? tanTable.getState().columnPinning
|
|
600
|
+
const pinning = rawPinning
|
|
601
|
+
? { left: rawPinning.left ?? [], right: rawPinning.right ?? [] }
|
|
602
|
+
: { left: [], right: [] }
|
|
603
|
+
|
|
604
|
+
savePrefs({ visibility, order, pinning })
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Watch column pinning changes via tableRef
|
|
608
|
+
watch(
|
|
609
|
+
() => tableRef.value?.columnPinning?.value,
|
|
610
|
+
() => { if (prefsLoaded.value) persistCurrentPrefs() },
|
|
611
|
+
{ deep: true }
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
// Watch column visibility changes
|
|
615
|
+
watch(
|
|
616
|
+
() => tableRef.value?.table?.getState()?.columnVisibility,
|
|
617
|
+
() => { if (prefsLoaded.value) persistCurrentPrefs() },
|
|
618
|
+
{ deep: true }
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
// Watch column order changes
|
|
622
|
+
watch(
|
|
623
|
+
() => tableRef.value?.table?.getState()?.columnOrder,
|
|
624
|
+
() => { if (prefsLoaded.value) persistCurrentPrefs() },
|
|
625
|
+
{ deep: true }
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
// ─── Expose ───────────────────────────────────────────────────────────────────
|
|
629
|
+
const getSelectedRows = () => tableRef.value?.getSelectedRows()
|
|
630
|
+
const reload = () => tableRef.value?.reload()
|
|
631
|
+
const clearCache = () => tableRef.value?.clearCache()
|
|
632
|
+
const exportTable = (format, allPages, filteredRows) => tableRef.value?.exportTable(format, allPages, filteredRows)
|
|
633
|
+
const pinColumn = (key, position) => tableRef.value?.pinColumn(key, position)
|
|
634
|
+
|
|
635
|
+
defineExpose({ getSelectedRows, reload, clearCache, exportTable, tableRef, closePreview, pinColumn })
|
|
636
|
+
</script>
|
|
637
|
+
|
|
638
|
+
<template>
|
|
639
|
+
<div class="relative" ref="containerRef">
|
|
640
|
+
|
|
641
|
+
<!-- Toolbar row (no card) -->
|
|
642
|
+
<div class="flex flex-wrap items-center gap-2 mb-2">
|
|
643
|
+
|
|
644
|
+
<!-- Search -->
|
|
645
|
+
<div v-if="showSearch" class="flex-1 min-w-48 max-w-xs">
|
|
646
|
+
<Forms.Input v-model="search" type="search" :placeholder="searchPlaceholder" :icon-left="IconSearch" size="sm" />
|
|
647
|
+
</div>
|
|
648
|
+
|
|
649
|
+
<!-- + Filtros button -->
|
|
650
|
+
<div v-if="showFilters && hasFilterableColumns" ref="filterAddBtnRef" class="relative">
|
|
651
|
+
<button
|
|
652
|
+
type="button"
|
|
653
|
+
@click="toggleFilterMenu"
|
|
654
|
+
:class="[
|
|
655
|
+
'inline-flex items-center gap-1.5 py-1.5 px-3 text-sm font-medium rounded-lg border transition-colors',
|
|
656
|
+
activeFilterList.length
|
|
657
|
+
? 'border-primary/40 bg-primary/10 text-primary'
|
|
658
|
+
: 'border-card-line bg-card text-muted-foreground-1 hover:bg-muted-hover'
|
|
659
|
+
]"
|
|
660
|
+
>
|
|
661
|
+
<IconPlus class="size-3.5" />
|
|
662
|
+
Filtros{{ activeFilterList.length ? ` (${activeFilterList.length})` : '' }}
|
|
663
|
+
</button>
|
|
664
|
+
</div>
|
|
665
|
+
|
|
666
|
+
<!-- Slot for custom toolbar buttons -->
|
|
667
|
+
<slot name="toolbar" />
|
|
668
|
+
|
|
669
|
+
<!-- Secondary actions: pushed to the right, icon-only style -->
|
|
670
|
+
<div class="ml-auto flex items-center gap-1">
|
|
671
|
+
<button
|
|
672
|
+
ref="columnButtonRef"
|
|
673
|
+
type="button"
|
|
674
|
+
@click="showColumnPanel = !showColumnPanel"
|
|
675
|
+
:title="'Columnas'"
|
|
676
|
+
:class="[
|
|
677
|
+
'p-1.5 inline-flex items-center justify-center rounded-lg border transition-colors',
|
|
678
|
+
showColumnPanel
|
|
679
|
+
? 'border-primary/40 bg-primary/10 text-primary'
|
|
680
|
+
: 'border-transparent text-muted-foreground hover:border-card-line hover:bg-muted-hover hover:text-foreground'
|
|
681
|
+
]"
|
|
682
|
+
>
|
|
683
|
+
<IconLayoutColumns class="size-4" />
|
|
684
|
+
</button>
|
|
685
|
+
|
|
686
|
+
<TableExportable v-if="showExport" :table-ref="tableRef" :name="resolvedName" :columns="columns" />
|
|
687
|
+
</div>
|
|
688
|
+
</div>
|
|
689
|
+
|
|
690
|
+
<!-- Filter chips row (shown when filters active) -->
|
|
691
|
+
<div v-if="activeFilterList.length" class="flex flex-wrap items-center gap-1.5 mb-2">
|
|
692
|
+
<div
|
|
693
|
+
v-for="chip in activeFilterList"
|
|
694
|
+
:key="chip.key"
|
|
695
|
+
class="inline-flex items-center text-xs rounded-lg border border-card-line bg-card overflow-hidden"
|
|
696
|
+
>
|
|
697
|
+
<span class="px-2.5 py-1 text-foreground font-medium border-r border-card-line bg-surface">{{ chip.label }}</span>
|
|
698
|
+
<span class="px-2 py-1 text-muted-foreground">{{ chip.displayOp }}</span>
|
|
699
|
+
<button
|
|
700
|
+
type="button"
|
|
701
|
+
@click.stop="openEditFilter(chip.col)"
|
|
702
|
+
class="inline-flex items-center gap-1 px-2 py-1 text-primary font-medium hover:bg-primary/10 transition-colors border-x border-card-line"
|
|
703
|
+
>
|
|
704
|
+
{{ chip.displayVal }}
|
|
705
|
+
<IconChevronDown class="size-3 opacity-60" />
|
|
706
|
+
</button>
|
|
707
|
+
<button
|
|
708
|
+
type="button"
|
|
709
|
+
@click.stop="removeFilter(chip.key)"
|
|
710
|
+
class="px-1.5 py-1 text-muted-foreground hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
|
711
|
+
>
|
|
712
|
+
<IconX class="size-3" />
|
|
713
|
+
</button>
|
|
714
|
+
</div>
|
|
715
|
+
</div>
|
|
716
|
+
|
|
717
|
+
<!-- Tabla -->
|
|
718
|
+
<div class="overflow-hidden border-t border-b border-card-line">
|
|
719
|
+
<Table
|
|
720
|
+
ref="tableRef"
|
|
721
|
+
:endpoint="resolvedEndpoint"
|
|
722
|
+
:columns="columns"
|
|
723
|
+
:name="resolvedName"
|
|
724
|
+
:params="mergedParams"
|
|
725
|
+
:search="search"
|
|
726
|
+
:checkable="checkable"
|
|
727
|
+
:cached="cached"
|
|
728
|
+
:show-reload-button="showReloadButton"
|
|
729
|
+
:click-row-to-open="clickRowToOpen"
|
|
730
|
+
:preview-row-id="previewRow?.id ?? null"
|
|
731
|
+
:preview-mode="!!previewEnabled"
|
|
732
|
+
:pinned-columns="resolvedPinnedColumns"
|
|
733
|
+
@row-click="handleRowClick"
|
|
734
|
+
@loaded="handleLoaded"
|
|
735
|
+
@page-change="closePreview"
|
|
736
|
+
@per-page-change="closePreview"
|
|
737
|
+
>
|
|
738
|
+
<template v-for="(_, name) in forwardedSlots" #[name]="slotProps">
|
|
739
|
+
<slot :name="name" v-bind="slotProps ?? {}" />
|
|
740
|
+
</template>
|
|
741
|
+
</Table>
|
|
742
|
+
</div>
|
|
743
|
+
|
|
744
|
+
<!-- Preview panel — overlay deslizante desde la derecha (cubre todo el componente) -->
|
|
745
|
+
<Transition
|
|
746
|
+
:enter-active-class="previewFromCache ? '' : 'transition ease-out duration-200'"
|
|
747
|
+
:enter-from-class="previewFromCache ? '' : 'opacity-0 translate-x-6'"
|
|
748
|
+
:enter-to-class="previewFromCache ? '' : 'opacity-100 translate-x-0'"
|
|
749
|
+
leave-active-class="transition ease-in duration-150"
|
|
750
|
+
leave-from-class="opacity-100 translate-x-0"
|
|
751
|
+
leave-to-class="opacity-0 translate-x-6"
|
|
752
|
+
>
|
|
753
|
+
<div
|
|
754
|
+
v-if="previewRow && previewEnabled"
|
|
755
|
+
ref="previewPanelRef"
|
|
756
|
+
class="absolute top-0 right-0 bottom-0 z-30 flex flex-col bg-card border border-card-line rounded-2xl shadow-xl overflow-hidden"
|
|
757
|
+
:style="{ width: (100 - currentRatio) + '%' }"
|
|
758
|
+
>
|
|
759
|
+
<!-- Resize handle — thin pill on left edge -->
|
|
760
|
+
<div
|
|
761
|
+
class="absolute left-1 top-1/2 -translate-y-1/2 h-12 w-1 cursor-col-resize rounded-full bg-border hover:bg-primary/50 transition-colors z-10"
|
|
762
|
+
@mousedown="startResize"
|
|
763
|
+
/>
|
|
764
|
+
|
|
765
|
+
<!-- Barra de acciones del preview -->
|
|
766
|
+
<div class="shrink-0 flex items-center gap-3 px-5 py-4 border-b border-card-line">
|
|
767
|
+
<!-- Título (reemplazable via slot) -->
|
|
768
|
+
<div class="flex-1 min-w-0">
|
|
769
|
+
<slot name="preview-header" :row="previewRow" :close="closePreview" />
|
|
770
|
+
</div>
|
|
771
|
+
|
|
772
|
+
<!-- Botones de acción — todos icon-only, mismo tamaño y estilo -->
|
|
773
|
+
<div class="flex items-center gap-0.5 shrink-0">
|
|
774
|
+
|
|
775
|
+
<!-- Acciones extra configurables desde el padre -->
|
|
776
|
+
<slot name="preview-actions" :row="previewRow" :close="closePreview" />
|
|
777
|
+
|
|
778
|
+
<!-- Abrir (configurable vía :preview-href) -->
|
|
779
|
+
<div v-if="resolvedPreviewHref" class="hs-tooltip [--placement:top] inline-block">
|
|
780
|
+
<NuxtLink
|
|
781
|
+
:to="resolvedPreviewHref"
|
|
782
|
+
class="hs-tooltip-toggle inline-flex items-center justify-center size-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors"
|
|
783
|
+
>
|
|
784
|
+
<IconExternalLink class="size-3.5" />
|
|
785
|
+
</NuxtLink>
|
|
786
|
+
<span class="hs-tooltip-content hs-tooltip-shown:opacity-100 hs-tooltip-shown:visible opacity-0 transition-opacity inline-block absolute invisible z-10 py-1 px-2 bg-tooltip border border-tooltip-line text-xs font-medium text-tooltip-foreground rounded-md shadow-2xs" role="tooltip">Abrir</span>
|
|
787
|
+
</div>
|
|
788
|
+
|
|
789
|
+
<!-- Eliminar (configurable vía :preview-deletable) -->
|
|
790
|
+
<div v-if="previewDeletable" class="hs-tooltip [--placement:top] inline-block">
|
|
791
|
+
<button
|
|
792
|
+
type="button"
|
|
793
|
+
class="hs-tooltip-toggle inline-flex items-center justify-center size-7 rounded-md text-muted-foreground hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
|
794
|
+
@click.stop="emit('preview-delete', previewRow)"
|
|
795
|
+
>
|
|
796
|
+
<IconTrash class="size-3.5" />
|
|
797
|
+
</button>
|
|
798
|
+
<span class="hs-tooltip-content hs-tooltip-shown:opacity-100 hs-tooltip-shown:visible opacity-0 transition-opacity inline-block absolute invisible z-10 py-1 px-2 bg-tooltip border border-tooltip-line text-xs font-medium text-tooltip-foreground rounded-md shadow-2xs" role="tooltip">Eliminar</span>
|
|
799
|
+
</div>
|
|
800
|
+
|
|
801
|
+
<!-- Minimizar (siempre) -->
|
|
802
|
+
<div class="hs-tooltip [--placement:top] inline-block">
|
|
803
|
+
<button
|
|
804
|
+
type="button"
|
|
805
|
+
class="hs-tooltip-toggle inline-flex items-center justify-center size-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors"
|
|
806
|
+
@click.stop="minimizePreview"
|
|
807
|
+
>
|
|
808
|
+
<IconMinus class="size-3.5" />
|
|
809
|
+
</button>
|
|
810
|
+
<span class="hs-tooltip-content hs-tooltip-shown:opacity-100 hs-tooltip-shown:visible opacity-0 transition-opacity inline-block absolute invisible z-10 py-1 px-2 bg-tooltip border border-tooltip-line text-xs font-medium text-tooltip-foreground rounded-md shadow-2xs" role="tooltip">Minimizar</span>
|
|
811
|
+
</div>
|
|
812
|
+
|
|
813
|
+
<!-- Cerrar (siempre) -->
|
|
814
|
+
<div class="hs-tooltip [--placement:top] inline-block">
|
|
815
|
+
<button
|
|
816
|
+
type="button"
|
|
817
|
+
class="hs-tooltip-toggle inline-flex items-center justify-center size-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors"
|
|
818
|
+
@click.stop="closePreview"
|
|
819
|
+
>
|
|
820
|
+
<IconX class="size-3.5" />
|
|
821
|
+
</button>
|
|
822
|
+
<span class="hs-tooltip-content hs-tooltip-shown:opacity-100 hs-tooltip-shown:visible opacity-0 transition-opacity inline-block absolute invisible z-10 py-1 px-2 bg-tooltip border border-tooltip-line text-xs font-medium text-tooltip-foreground rounded-md shadow-2xs" role="tooltip">Cerrar</span>
|
|
823
|
+
</div>
|
|
824
|
+
|
|
825
|
+
</div>
|
|
826
|
+
</div>
|
|
827
|
+
|
|
828
|
+
<!-- Scrollable content -->
|
|
829
|
+
<div class="flex-1 overflow-y-auto min-h-0">
|
|
830
|
+
<slot v-if="previewTab === 'datos'" name="preview" :row="previewRow" :close="closePreview" />
|
|
831
|
+
<Table.PreviewTimeline
|
|
832
|
+
v-else-if="previewTab === 'bitacora' && resolvedHistoryEndpoint"
|
|
833
|
+
:endpoint="resolvedHistoryEndpoint"
|
|
834
|
+
/>
|
|
835
|
+
</div>
|
|
836
|
+
|
|
837
|
+
<!-- Tabs — bottom -->
|
|
838
|
+
<div v-if="hasHistory" class="shrink-0 flex border-t border-card-line">
|
|
839
|
+
<button
|
|
840
|
+
type="button"
|
|
841
|
+
@click="previewTab = 'datos'"
|
|
842
|
+
:class="[
|
|
843
|
+
'flex-1 py-2.5 text-xs font-semibold transition-colors border-r border-card-line border-t-2 -mt-px',
|
|
844
|
+
previewTab === 'datos'
|
|
845
|
+
? 'border-t-card text-foreground'
|
|
846
|
+
: 'border-t-transparent text-muted-foreground hover:text-foreground hover:bg-muted-hover'
|
|
847
|
+
]"
|
|
848
|
+
>
|
|
849
|
+
Datos
|
|
850
|
+
</button>
|
|
851
|
+
<button
|
|
852
|
+
type="button"
|
|
853
|
+
@click="resolvedHistoryEndpoint && (previewTab = 'bitacora')"
|
|
854
|
+
:disabled="!resolvedHistoryEndpoint"
|
|
855
|
+
:class="[
|
|
856
|
+
'flex-1 py-2.5 text-xs font-semibold transition-colors border-t-2 -mt-px',
|
|
857
|
+
!resolvedHistoryEndpoint
|
|
858
|
+
? 'border-t-transparent text-muted-foreground/40 cursor-not-allowed'
|
|
859
|
+
: previewTab === 'bitacora'
|
|
860
|
+
? 'border-t-card text-foreground'
|
|
861
|
+
: 'border-t-transparent text-muted-foreground hover:text-foreground hover:bg-muted-hover'
|
|
862
|
+
]"
|
|
863
|
+
>
|
|
864
|
+
Bitácora
|
|
865
|
+
</button>
|
|
866
|
+
</div>
|
|
867
|
+
|
|
868
|
+
</div>
|
|
869
|
+
</Transition>
|
|
870
|
+
|
|
871
|
+
<!-- ── Floating mini-preview (dock expand, estilo Gmail) ── -->
|
|
872
|
+
<Teleport to="body">
|
|
873
|
+
<Transition
|
|
874
|
+
enter-active-class="transition ease-out duration-200"
|
|
875
|
+
enter-from-class="opacity-0 translate-y-4"
|
|
876
|
+
enter-to-class="opacity-100 translate-y-0"
|
|
877
|
+
leave-active-class="transition ease-in duration-150"
|
|
878
|
+
leave-from-class="opacity-100 translate-y-0"
|
|
879
|
+
leave-to-class="opacity-0 translate-y-4"
|
|
880
|
+
>
|
|
881
|
+
<div
|
|
882
|
+
v-if="floatingItem"
|
|
883
|
+
class="fixed z-[60] w-96 flex flex-col bg-card border border-card-line rounded-t-xl shadow-2xl overflow-hidden"
|
|
884
|
+
:style="{ ...floatingPanelStyle, maxHeight: 'min(480px, calc(100vh - 60px))' }"
|
|
885
|
+
>
|
|
886
|
+
<div class="flex items-center gap-2 px-3 py-2.5 border-b border-card-line shrink-0 bg-surface select-none">
|
|
887
|
+
<span class="size-6 rounded-full bg-primary flex items-center justify-center text-[10px] font-bold text-primary-foreground shrink-0">
|
|
888
|
+
{{ (floatingItem.label?.[0] ?? '?').toUpperCase() }}
|
|
889
|
+
</span>
|
|
890
|
+
<div class="flex-1 min-w-0">
|
|
891
|
+
<p class="text-sm font-semibold text-foreground truncate leading-tight">{{ floatingItem.label }}</p>
|
|
892
|
+
<p v-if="floatingItem.subtitle" class="text-xs text-muted-foreground truncate">{{ floatingItem.subtitle }}</p>
|
|
893
|
+
</div>
|
|
894
|
+
<button type="button" title="Expandir" class="inline-flex items-center justify-center size-6 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors" @click.stop="expandToFull(floatingItem)">
|
|
895
|
+
<IconMaximize class="size-3.5" />
|
|
896
|
+
</button>
|
|
897
|
+
<button type="button" title="Minimizar" class="inline-flex items-center justify-center size-6 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors" @click.stop="collapseDock()">
|
|
898
|
+
<IconMinus class="size-3.5" />
|
|
899
|
+
</button>
|
|
900
|
+
<button type="button" title="Cerrar" class="inline-flex items-center justify-center size-6 rounded-md text-muted-foreground hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors" @click.stop="undockItem(floatingItem.id)">
|
|
901
|
+
<IconX class="size-3.5" />
|
|
902
|
+
</button>
|
|
903
|
+
</div>
|
|
904
|
+
<div class="flex-1 overflow-y-auto min-h-0">
|
|
905
|
+
<slot name="preview" :row="floatingItem.row" :close="() => undockItem(floatingItem.id)" />
|
|
906
|
+
</div>
|
|
907
|
+
</div>
|
|
908
|
+
</Transition>
|
|
909
|
+
</Teleport>
|
|
910
|
+
|
|
911
|
+
<!-- Filter menu — teleported to body -->
|
|
912
|
+
<Teleport to="body">
|
|
913
|
+
<Transition
|
|
914
|
+
enter-active-class="transition ease-out duration-150"
|
|
915
|
+
enter-from-class="opacity-0 translate-y-1 scale-95"
|
|
916
|
+
enter-to-class="opacity-100 translate-y-0 scale-100"
|
|
917
|
+
leave-active-class="transition ease-in duration-100"
|
|
918
|
+
leave-from-class="opacity-100 translate-y-0 scale-100"
|
|
919
|
+
leave-to-class="opacity-0 translate-y-1 scale-95"
|
|
920
|
+
>
|
|
921
|
+
<div
|
|
922
|
+
v-if="showFilterPanel"
|
|
923
|
+
ref="filterMenuRef"
|
|
924
|
+
class="fixed z-[60] bg-dropdown border border-dropdown-line rounded-xl shadow-2xl overflow-hidden"
|
|
925
|
+
:style="filterMenuStyle"
|
|
926
|
+
>
|
|
927
|
+
|
|
928
|
+
<!-- Step 1: column picker -->
|
|
929
|
+
<template v-if="filterMenuStep === 'columns'">
|
|
930
|
+
<p class="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest px-3 pt-3 pb-1.5">
|
|
931
|
+
Filtrar por
|
|
932
|
+
</p>
|
|
933
|
+
<div class="pb-2 min-w-48">
|
|
934
|
+
<template v-if="availableFilterColumns.length">
|
|
935
|
+
<button
|
|
936
|
+
v-for="col in availableFilterColumns"
|
|
937
|
+
:key="col.key"
|
|
938
|
+
type="button"
|
|
939
|
+
@click.stop="selectFilterColumn(col)"
|
|
940
|
+
class="w-full flex items-center justify-between gap-3 px-3 py-2 text-sm text-foreground hover:bg-muted-hover transition-colors text-left group"
|
|
941
|
+
>
|
|
942
|
+
<span>{{ col.label }}</span>
|
|
943
|
+
<span class="text-[11px] text-muted-foreground-2 group-hover:text-muted-foreground transition-colors capitalize">
|
|
944
|
+
{{ col.filterType === 'daterange' ? 'fecha' : col.filterType === 'select' ? 'opción' : 'texto' }}
|
|
945
|
+
</span>
|
|
946
|
+
</button>
|
|
947
|
+
</template>
|
|
948
|
+
<p v-else class="px-3 py-3 text-xs text-muted-foreground italic">
|
|
949
|
+
Todos los filtros están configurados
|
|
950
|
+
</p>
|
|
951
|
+
</div>
|
|
952
|
+
</template>
|
|
953
|
+
|
|
954
|
+
<!-- Step 2: value config -->
|
|
955
|
+
<template v-else-if="filterMenuStep === 'value' && pendingCol">
|
|
956
|
+
|
|
957
|
+
<!-- Header: back + field name + operator selector -->
|
|
958
|
+
<div class="flex items-center gap-1.5 px-2 py-2 border-b border-dropdown-line">
|
|
959
|
+
<button
|
|
960
|
+
type="button"
|
|
961
|
+
@click.stop="filterMenuStep = 'columns'"
|
|
962
|
+
class="inline-flex items-center justify-center size-6 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors shrink-0"
|
|
963
|
+
>
|
|
964
|
+
<IconChevronLeft class="size-4" />
|
|
965
|
+
</button>
|
|
966
|
+
<span class="text-sm font-medium text-foreground">{{ pendingCol.label }}</span>
|
|
967
|
+
|
|
968
|
+
<!-- Operator: native select for text (3 options) -->
|
|
969
|
+
<select
|
|
970
|
+
v-if="pendingCol.filterType === 'text'"
|
|
971
|
+
v-model="pendingOperator"
|
|
972
|
+
class="ml-auto text-xs bg-dropdown border border-dropdown-line rounded-md px-2 py-1 text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/50 cursor-pointer"
|
|
973
|
+
>
|
|
974
|
+
<option v-for="op in textOps" :key="op.value" :value="op.value">{{ op.label }}</option>
|
|
975
|
+
</select>
|
|
976
|
+
|
|
977
|
+
<!-- Operator: segmented toggle for select (es / no es) -->
|
|
978
|
+
<div v-else-if="pendingCol.filterType === 'select'" class="ml-auto flex rounded-md border border-dropdown-line overflow-hidden text-xs">
|
|
979
|
+
<button
|
|
980
|
+
v-for="op in selectOps"
|
|
981
|
+
:key="op.value"
|
|
982
|
+
type="button"
|
|
983
|
+
@click.stop="pendingOperator = op.value"
|
|
984
|
+
:class="[
|
|
985
|
+
'px-2.5 py-1 transition-colors',
|
|
986
|
+
pendingOperator === op.value
|
|
987
|
+
? 'bg-primary/10 text-primary'
|
|
988
|
+
: 'text-muted-foreground hover:bg-muted-hover'
|
|
989
|
+
]"
|
|
990
|
+
>
|
|
991
|
+
{{ op.label }}
|
|
992
|
+
</button>
|
|
993
|
+
</div>
|
|
994
|
+
|
|
995
|
+
<!-- Operator: inline select for daterange -->
|
|
996
|
+
<select
|
|
997
|
+
v-else-if="pendingCol.filterType === 'daterange'"
|
|
998
|
+
v-model="pendingDateOp"
|
|
999
|
+
class="ml-auto text-xs bg-dropdown border border-dropdown-line rounded-md px-2 py-1 text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/50 cursor-pointer"
|
|
1000
|
+
>
|
|
1001
|
+
<option v-for="op in dateOps" :key="op.value" :value="op.value">{{ op.label }}</option>
|
|
1002
|
+
</select>
|
|
1003
|
+
</div>
|
|
1004
|
+
|
|
1005
|
+
<div class="p-3 min-w-56 space-y-2">
|
|
1006
|
+
|
|
1007
|
+
<!-- ── TEXT ── -->
|
|
1008
|
+
<input
|
|
1009
|
+
v-if="pendingCol.filterType === 'text'"
|
|
1010
|
+
ref="pendingValueInputRef"
|
|
1011
|
+
v-model="pendingValue"
|
|
1012
|
+
type="text"
|
|
1013
|
+
@keydown.enter.stop="applyPendingFilter"
|
|
1014
|
+
@keydown.escape.stop="closeFilterMenu"
|
|
1015
|
+
placeholder="Valor..."
|
|
1016
|
+
class="w-full rounded-lg border border-dropdown-line bg-card text-foreground py-1.5 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary/60 focus:border-primary"
|
|
1017
|
+
/>
|
|
1018
|
+
|
|
1019
|
+
<!-- ── SELECT ── -->
|
|
1020
|
+
<div v-else-if="pendingCol.filterType === 'select'" class="space-y-0.5 max-h-52 overflow-y-auto -mx-1">
|
|
1021
|
+
<button
|
|
1022
|
+
v-for="opt in pendingCol.filterOptions"
|
|
1023
|
+
:key="opt.value"
|
|
1024
|
+
type="button"
|
|
1025
|
+
@click.stop="pendingValue = opt.value; applyPendingFilter()"
|
|
1026
|
+
:class="[
|
|
1027
|
+
'w-full flex items-center gap-2 px-2.5 py-2 text-sm rounded-lg transition-colors text-left',
|
|
1028
|
+
pendingValue === opt.value
|
|
1029
|
+
? 'bg-primary/10 text-primary'
|
|
1030
|
+
: 'hover:bg-muted-hover text-foreground'
|
|
1031
|
+
]"
|
|
1032
|
+
>
|
|
1033
|
+
<span class="flex-1">{{ opt.label }}</span>
|
|
1034
|
+
<IconCheck v-if="pendingValue === opt.value" class="size-3.5 shrink-0 text-primary/70" />
|
|
1035
|
+
</button>
|
|
1036
|
+
</div>
|
|
1037
|
+
|
|
1038
|
+
<!-- ── DATERANGE ── -->
|
|
1039
|
+
<template v-else-if="pendingCol.filterType === 'daterange'">
|
|
1040
|
+
<template v-if="pendingDateOp === 'between'">
|
|
1041
|
+
<input type="date" v-model="pendingValue.from" class="w-full rounded-lg border border-dropdown-line bg-card text-foreground py-1.5 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary/60" />
|
|
1042
|
+
<input type="date" v-model="pendingValue.to" class="w-full rounded-lg border border-dropdown-line bg-card text-foreground py-1.5 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary/60" />
|
|
1043
|
+
</template>
|
|
1044
|
+
<input v-else type="date" v-model="pendingValue.singleDate" class="w-full rounded-lg border border-dropdown-line bg-card text-foreground py-1.5 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary/60" />
|
|
1045
|
+
</template>
|
|
1046
|
+
|
|
1047
|
+
<!-- Apply (not for select — auto-applies on click) -->
|
|
1048
|
+
<button
|
|
1049
|
+
v-if="pendingCol.filterType !== 'select'"
|
|
1050
|
+
type="button"
|
|
1051
|
+
@click.stop="applyPendingFilter"
|
|
1052
|
+
class="w-full py-1.5 text-sm font-medium rounded-lg bg-primary text-white hover:bg-primary/90 active:bg-primary/80 transition-colors"
|
|
1053
|
+
>
|
|
1054
|
+
Aplicar
|
|
1055
|
+
</button>
|
|
1056
|
+
|
|
1057
|
+
</div>
|
|
1058
|
+
</template>
|
|
1059
|
+
|
|
1060
|
+
</div>
|
|
1061
|
+
</Transition>
|
|
1062
|
+
</Teleport>
|
|
1063
|
+
|
|
1064
|
+
<!-- Column panel — teleported to body to escape overflow-hidden -->
|
|
1065
|
+
<Teleport to="body">
|
|
1066
|
+
<Transition
|
|
1067
|
+
enter-active-class="transition ease-out duration-150"
|
|
1068
|
+
enter-from-class="opacity-0 translate-y-1 scale-95"
|
|
1069
|
+
enter-to-class="opacity-100 translate-y-0 scale-100"
|
|
1070
|
+
leave-active-class="transition ease-in duration-100"
|
|
1071
|
+
leave-from-class="opacity-100 translate-y-0 scale-100"
|
|
1072
|
+
leave-to-class="opacity-0 translate-y-1 scale-95"
|
|
1073
|
+
>
|
|
1074
|
+
<div
|
|
1075
|
+
v-if="showColumnPanel"
|
|
1076
|
+
ref="columnPanelRef"
|
|
1077
|
+
class="fixed z-50 bg-dropdown border border-dropdown-line rounded-xl shadow-2xl min-w-64 max-h-[480px] overflow-y-auto"
|
|
1078
|
+
:style="columnPanelStyle"
|
|
1079
|
+
>
|
|
1080
|
+
|
|
1081
|
+
<!-- ── Sección: Fija a la izquierda ── -->
|
|
1082
|
+
<div class="p-2 pb-1">
|
|
1083
|
+
<p class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest px-1 pb-1.5 flex items-center gap-1.5">
|
|
1084
|
+
<span class="size-1.5 rounded-full bg-indigo-400 inline-block"></span>
|
|
1085
|
+
Fija a la izquierda
|
|
1086
|
+
</p>
|
|
1087
|
+
<div
|
|
1088
|
+
class="rounded-lg min-h-[34px] transition-colors"
|
|
1089
|
+
:class="dragOverSection === 'left' && draggedKey && !columnsBySection.left.find(c => c.key === draggedKey)
|
|
1090
|
+
? 'bg-indigo-50 dark:bg-indigo-900/20 ring-1 ring-indigo-300 dark:ring-indigo-700'
|
|
1091
|
+
: columnsBySection.left.length === 0 ? 'border border-dashed border-card-line' : ''"
|
|
1092
|
+
@dragover.prevent="dragOverSection = 'left'"
|
|
1093
|
+
@dragleave="dragOverSection = null"
|
|
1094
|
+
@drop.stop="onDropSection('left')"
|
|
1095
|
+
>
|
|
1096
|
+
<p v-if="columnsBySection.left.length === 0" class="flex items-center justify-center h-[34px] text-xs text-muted-foreground-2 italic select-none">
|
|
1097
|
+
Arrastra columnas aquí
|
|
1098
|
+
</p>
|
|
1099
|
+
<div
|
|
1100
|
+
v-for="col in columnsBySection.left"
|
|
1101
|
+
:key="col.key"
|
|
1102
|
+
draggable="true"
|
|
1103
|
+
@dragstart="onDragStart(col.key, 'left')"
|
|
1104
|
+
@dragover.prevent="dragOverSection = 'left'; dragOverKey = col.key"
|
|
1105
|
+
@dragleave="dragOverKey = null"
|
|
1106
|
+
@drop.stop="onDrop(col.key, 'left')"
|
|
1107
|
+
class="flex items-center gap-2 py-1.5 px-2 rounded-lg select-none cursor-grab transition-colors"
|
|
1108
|
+
:class="dragOverKey === col.key ? 'bg-indigo-50 dark:bg-indigo-900/20' : 'hover:bg-muted-hover'"
|
|
1109
|
+
>
|
|
1110
|
+
<IconGripVertical class="size-4 text-muted-foreground-2 shrink-0" />
|
|
1111
|
+
<input
|
|
1112
|
+
type="checkbox"
|
|
1113
|
+
:checked="tableRef?.table.getColumn(col.key)?.getIsVisible() ?? true"
|
|
1114
|
+
@change="tableRef?.table.getColumn(col.key)?.toggleVisibility(); persistCurrentPrefs()"
|
|
1115
|
+
@click.stop
|
|
1116
|
+
class="rounded border-card-line bg-surface shrink-0 cursor-pointer"
|
|
1117
|
+
/>
|
|
1118
|
+
<span class="text-sm text-foreground truncate flex-1">{{ col.label }}</span>
|
|
1119
|
+
<span class="size-1.5 rounded-full bg-indigo-400 shrink-0 opacity-60" />
|
|
1120
|
+
</div>
|
|
1121
|
+
</div>
|
|
1122
|
+
</div>
|
|
1123
|
+
|
|
1124
|
+
<div class="mx-3 border-t border-dropdown-line" />
|
|
1125
|
+
|
|
1126
|
+
<!-- ── Sección: Columnas libres ── -->
|
|
1127
|
+
<div class="p-2 py-1">
|
|
1128
|
+
<p class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest px-1 pb-1.5 flex items-center gap-1.5">
|
|
1129
|
+
<span class="size-1.5 rounded-full bg-muted-foreground-2 inline-block"></span>
|
|
1130
|
+
Columnas
|
|
1131
|
+
</p>
|
|
1132
|
+
<div
|
|
1133
|
+
class="rounded-lg min-h-[34px] transition-colors"
|
|
1134
|
+
:class="dragOverSection === 'center' && draggedKey && !columnsBySection.center.find(c => c.key === draggedKey)
|
|
1135
|
+
? 'bg-muted/60 ring-1 ring-border'
|
|
1136
|
+
: ''"
|
|
1137
|
+
@dragover.prevent="dragOverSection = 'center'"
|
|
1138
|
+
@dragleave="dragOverSection = null"
|
|
1139
|
+
@drop.stop="onDropSection('center')"
|
|
1140
|
+
>
|
|
1141
|
+
<p v-if="columnsBySection.center.length === 0" class="flex items-center justify-center h-[34px] text-xs text-muted-foreground-2 italic select-none">
|
|
1142
|
+
Sin columnas libres
|
|
1143
|
+
</p>
|
|
1144
|
+
<div
|
|
1145
|
+
v-for="col in columnsBySection.center"
|
|
1146
|
+
:key="col.key"
|
|
1147
|
+
draggable="true"
|
|
1148
|
+
@dragstart="onDragStart(col.key, 'center')"
|
|
1149
|
+
@dragover.prevent="dragOverSection = 'center'; dragOverKey = col.key"
|
|
1150
|
+
@dragleave="dragOverKey = null"
|
|
1151
|
+
@drop.stop="onDrop(col.key, 'center')"
|
|
1152
|
+
class="flex items-center gap-2 py-1.5 px-2 rounded-lg select-none cursor-grab transition-colors"
|
|
1153
|
+
:class="dragOverKey === col.key ? 'bg-blue-50 dark:bg-blue-900/20 ring-1 ring-blue-200 dark:ring-blue-700' : 'hover:bg-muted-hover'"
|
|
1154
|
+
>
|
|
1155
|
+
<IconGripVertical class="size-4 text-muted-foreground-2 shrink-0" />
|
|
1156
|
+
<input
|
|
1157
|
+
type="checkbox"
|
|
1158
|
+
:checked="tableRef?.table.getColumn(col.key)?.getIsVisible() ?? true"
|
|
1159
|
+
@change="tableRef?.table.getColumn(col.key)?.toggleVisibility(); persistCurrentPrefs()"
|
|
1160
|
+
@click.stop
|
|
1161
|
+
class="rounded border-card-line bg-surface shrink-0 cursor-pointer"
|
|
1162
|
+
/>
|
|
1163
|
+
<span class="text-sm text-foreground truncate flex-1">{{ col.label }}</span>
|
|
1164
|
+
</div>
|
|
1165
|
+
</div>
|
|
1166
|
+
</div>
|
|
1167
|
+
|
|
1168
|
+
<div class="mx-3 border-t border-dropdown-line" />
|
|
1169
|
+
|
|
1170
|
+
<!-- ── Sección: Fija a la derecha ── -->
|
|
1171
|
+
<div class="p-2 pt-1">
|
|
1172
|
+
<p class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest px-1 pb-1.5 flex items-center gap-1.5">
|
|
1173
|
+
<span class="size-1.5 rounded-full bg-amber-400 inline-block"></span>
|
|
1174
|
+
Fija a la derecha
|
|
1175
|
+
</p>
|
|
1176
|
+
<div
|
|
1177
|
+
class="rounded-lg min-h-[34px] transition-colors"
|
|
1178
|
+
:class="dragOverSection === 'right' && draggedKey && !columnsBySection.right.find(c => c.key === draggedKey)
|
|
1179
|
+
? 'bg-amber-50 dark:bg-amber-900/20 ring-1 ring-amber-300 dark:ring-amber-700'
|
|
1180
|
+
: columnsBySection.right.length === 0 ? 'border border-dashed border-card-line' : ''"
|
|
1181
|
+
@dragover.prevent="dragOverSection = 'right'"
|
|
1182
|
+
@dragleave="dragOverSection = null"
|
|
1183
|
+
@drop.stop="onDropSection('right')"
|
|
1184
|
+
>
|
|
1185
|
+
<p v-if="columnsBySection.right.length === 0" class="flex items-center justify-center h-[34px] text-xs text-muted-foreground-2 italic select-none">
|
|
1186
|
+
Arrastra columnas aquí
|
|
1187
|
+
</p>
|
|
1188
|
+
<div
|
|
1189
|
+
v-for="col in columnsBySection.right"
|
|
1190
|
+
:key="col.key"
|
|
1191
|
+
draggable="true"
|
|
1192
|
+
@dragstart="onDragStart(col.key, 'right')"
|
|
1193
|
+
@dragover.prevent="dragOverSection = 'right'; dragOverKey = col.key"
|
|
1194
|
+
@dragleave="dragOverKey = null"
|
|
1195
|
+
@drop.stop="onDrop(col.key, 'right')"
|
|
1196
|
+
class="flex items-center gap-2 py-1.5 px-2 rounded-lg select-none cursor-grab transition-colors"
|
|
1197
|
+
:class="dragOverKey === col.key ? 'bg-amber-50 dark:bg-amber-900/20' : 'hover:bg-muted-hover'"
|
|
1198
|
+
>
|
|
1199
|
+
<IconGripVertical class="size-4 text-muted-foreground-2 shrink-0" />
|
|
1200
|
+
<input
|
|
1201
|
+
type="checkbox"
|
|
1202
|
+
:checked="tableRef?.table.getColumn(col.key)?.getIsVisible() ?? true"
|
|
1203
|
+
@change="tableRef?.table.getColumn(col.key)?.toggleVisibility(); persistCurrentPrefs()"
|
|
1204
|
+
@click.stop
|
|
1205
|
+
class="rounded border-card-line bg-surface shrink-0 cursor-pointer"
|
|
1206
|
+
/>
|
|
1207
|
+
<span class="text-sm text-foreground truncate flex-1">{{ col.label }}</span>
|
|
1208
|
+
<span class="size-1.5 rounded-full bg-amber-400 shrink-0 opacity-60" />
|
|
1209
|
+
</div>
|
|
1210
|
+
</div>
|
|
1211
|
+
</div>
|
|
1212
|
+
|
|
1213
|
+
</div>
|
|
1214
|
+
</Transition>
|
|
1215
|
+
</Teleport>
|
|
1216
|
+
</div>
|
|
1217
|
+
</template>
|