@innertia-solutions/innertia-nuxt 0.1.12 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/components/Attention.vue +269 -0
  2. package/components/Chart/index.vue +193 -0
  3. package/components/Container.vue +116 -0
  4. package/components/CopyButton.vue +98 -0
  5. package/components/Files/Card.vue +215 -0
  6. package/components/Files/Preview.vue +675 -0
  7. package/components/Files/Upload.vue +408 -0
  8. package/components/Files/UploadModal.vue +83 -0
  9. package/components/Forms/InputPin.vue +162 -0
  10. package/components/InfoToolbar.vue +54 -0
  11. package/components/Kanban/Standard.vue +588 -0
  12. package/components/Loader.vue +358 -0
  13. package/components/Modal/Base.vue +6 -0
  14. package/components/Modal/Confirm.vue +89 -0
  15. package/components/Modal/DeleteConfirm.vue +14 -31
  16. package/components/Modal/Form.vue +76 -0
  17. package/components/Modal/Host.vue +58 -0
  18. package/components/Modal/Prompt.vue +74 -0
  19. package/components/Modal.vue +119 -34
  20. package/components/Table/Standard.vue +198 -25
  21. package/components/Table/index.vue +169 -369
  22. package/components/Table/views/ViewCards.vue +74 -0
  23. package/components/Table/views/ViewDatabase.vue +236 -0
  24. package/components/Table/views/ViewTable.vue +299 -0
  25. package/components/Toast/Alert.vue +16 -111
  26. package/components/Toast/Container.vue +4 -29
  27. package/components/Toast/Notification.vue +8 -43
  28. package/components/Toast/Process.vue +8 -86
  29. package/components/Toast/Stack.vue +59 -0
  30. package/components/Toast/Standard.vue +115 -0
  31. package/components/Tree/Standard.vue +49 -4
  32. package/components/Workflow/Board.vue +356 -0
  33. package/components/Workflow/Builder.vue +454 -0
  34. package/components/Workflow/Form.vue +526 -0
  35. package/components/Workflow/Viewer.vue +225 -0
  36. package/components/Workflow/edges/TransitionEdge.vue +84 -0
  37. package/components/Workflow/nodes/StepNode.vue +88 -0
  38. package/composables/useClipboard.js +79 -0
  39. package/composables/useConfetti.js +194 -0
  40. package/composables/useFile.js +189 -0
  41. package/composables/useModal.js +71 -0
  42. package/composables/useToast.js +38 -65
  43. package/composables/useWorkflow/form.js +162 -0
  44. package/composables/useWorkflow/index.js +18 -0
  45. package/composables/useWorkflow/view.js +220 -0
  46. package/composables/useWorkflowLayout.js +162 -0
  47. package/css/tokens.css +10 -10
  48. package/nuxt.config.ts +1 -1
  49. package/package.json +13 -8
  50. package/stores/modal.js +48 -0
  51. package/stores/toast.js +156 -117
@@ -0,0 +1,54 @@
1
+ <script setup>
2
+ import { IconBolt, IconReload, IconLoader2 } from '@tabler/icons-vue'
3
+
4
+ /**
5
+ * <InfoToolbar> — Reload + Instant badge en modo compacto.
6
+ *
7
+ * Usado por Table.Standard, Tree.Standard y Kanban.Standard cuando
8
+ * `infoPosition === 'top'` (info se ubica en el toolbar superior en vez del footer).
9
+ *
10
+ * El badge "Instant" se reduce a un cuadrado verde con el ícono de rayo y un tooltip
11
+ * de Preline (`hs-tooltip`) que explica el cache.
12
+ */
13
+ defineProps({
14
+ showReload: { type: Boolean, default: true },
15
+ isFetching: { type: Boolean, default: false },
16
+ showInstant: { type: Boolean, default: false },
17
+ reloadTitle: { type: String, default: 'Recargar' },
18
+ instantTitle: { type: String, default: 'Datos en caché — recargá para sincronizar' },
19
+ })
20
+
21
+ const emit = defineEmits(['reload'])
22
+ </script>
23
+
24
+ <template>
25
+ <div class="inline-flex items-center gap-1">
26
+ <!-- Instant — cuadradito verde con tooltip, SIEMPRE a la izquierda -->
27
+ <div v-if="showInstant" class="hs-tooltip [--placement:bottom] inline-block">
28
+ <div
29
+ class="hs-tooltip-toggle inline-flex items-center justify-center size-7 rounded-control bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 cursor-help"
30
+ >
31
+ <IconBolt class="size-3.5 fill-current" />
32
+ </div>
33
+ <span
34
+ class="hs-tooltip-content hs-tooltip-shown:opacity-100 hs-tooltip-shown:visible opacity-0 transition-opacity inline-block absolute invisible z-50 py-1 px-2 bg-tooltip border border-tooltip-line text-xs font-medium text-tooltip-foreground rounded-popover shadow-2xs whitespace-nowrap"
35
+ role="tooltip"
36
+ >
37
+ {{ instantTitle }}
38
+ </span>
39
+ </div>
40
+
41
+ <!-- Reload -->
42
+ <button
43
+ v-if="showReload"
44
+ type="button"
45
+ :title="reloadTitle"
46
+ :disabled="isFetching"
47
+ class="p-1.5 inline-flex items-center justify-center rounded-control border border-transparent text-muted-foreground hover:border-card-line hover:bg-muted-hover hover:text-foreground transition-colors disabled:opacity-50"
48
+ @click="emit('reload')"
49
+ >
50
+ <IconLoader2 v-if="isFetching" class="size-4 animate-spin" />
51
+ <IconReload v-else class="size-4" />
52
+ </button>
53
+ </div>
54
+ </template>
@@ -0,0 +1,588 @@
1
+ <script setup>
2
+ import {
3
+ IconLoader2, IconSearch, IconBolt, IconReload, IconPlus, IconLayoutColumns,
4
+ IconChevronDown, IconX,
5
+ } from '@tabler/icons-vue'
6
+ import Forms from '../Forms/Input.vue'
7
+
8
+ /**
9
+ * <KanbanStandard> — Kanban board polished, paralelo a Table.Standard / Tree.Standard.
10
+ *
11
+ * Replica el patrón: toolbar (search + columns + reload) + footer (total + cache badge)
12
+ * + preview overlay vía <DataPreview>. Estados como columnas, DnD HTML5, optimistic
13
+ * updates, click en card abre preview.
14
+ *
15
+ * Dos modos de uso:
16
+ *
17
+ * 1) Endpoint mode (fetch interno):
18
+ * <KanbanStandard
19
+ * name="tasks"
20
+ * endpoint="backoffice/tasks"
21
+ * state-key="status"
22
+ * :states="[...]"
23
+ * :move-mutation="(id, state) => api.patch(...)"
24
+ * />
25
+ *
26
+ * 2) Items mode (datos pre-cargados, sin fetch):
27
+ * <KanbanStandard
28
+ * :items="myItems"
29
+ * state-key="current_step"
30
+ * :states="columns"
31
+ * :move-mutation="onMove"
32
+ * :search-keys="['title', 'assignee']"
33
+ * />
34
+ *
35
+ * En items mode: la búsqueda filtra localmente, reload re-aplica el filtro,
36
+ * y moveMutation puede retornar false para indicar transición bloqueada.
37
+ */
38
+
39
+ const slots = useSlots()
40
+
41
+ const props = defineProps({
42
+ // ── Modo endpoint (fetch interno) ─────────────────────────────────────────
43
+ endpoint: { type: String, default: null },
44
+ name: { type: String, default: '' },
45
+ params: { type: Object, default: () => ({}) },
46
+ perPage: { type: Number, default: 100 },
47
+
48
+ // ── Modo items pre-cargados (sin fetch) ───────────────────────────────────
49
+ // Cuando se pasa `items`, el componente NO hace fetch. La búsqueda filtra
50
+ // localmente usando los campos definidos en searchKeys.
51
+ items: { type: Array, default: null },
52
+ searchKeys: { type: [String, Array], default: () => ['name', 'title'] },
53
+
54
+ // ── Común ─────────────────────────────────────────────────────────────────
55
+ stateKey: { type: String, default: 'status' },
56
+ states: { type: Array, required: true },
57
+ moveMutation: { type: Function, default: null },
58
+
59
+ // Toolbar visibility
60
+ showSearch: { type: Boolean, default: true },
61
+ searchPlaceholder:{ type: String, default: 'Buscar...' },
62
+ showColumns: { type: Boolean, default: true },
63
+ showReloadButton: { type: Boolean, default: true },
64
+ showAddButton: { type: Boolean, default: false },
65
+
66
+ // Caché en sessionStorage
67
+ cached: { type: Boolean, default: false },
68
+
69
+ // Modo visual:
70
+ // bordered=true (default) → wrapper con border + rounded-card, columnas con border
71
+ // bordered=false → sin bordes, columnas con bg sutil tipo Trello
72
+ bordered: { type: Boolean, default: true },
73
+
74
+ // Posición de la barra de info (reload + total + instant badge):
75
+ // 'bottom' (default) → footer debajo del board
76
+ // 'top' → footer arriba del board (sobre la tabla)
77
+ // 'none' → sin footer; reload + instant compactos al lado del botón columnas
78
+ infoPosition: { type: String, default: 'bottom' },
79
+
80
+ // Tamaño visible del board y de las columnas internas.
81
+ // 'sm' | 'md' (default) | 'lg' | 'fit' (delegado al padre)
82
+ size: { type: String, default: 'md' },
83
+
84
+ // Preview overlay (mismo patrón que Table/Tree)
85
+ previewHref: { type: [String, Function], default: null },
86
+ previewDeletable: { type: Boolean, default: false },
87
+ autoClosePreview: { type: Boolean, default: true },
88
+ splitRatio: { type: Number, default: 55 },
89
+ })
90
+
91
+ const emit = defineEmits([
92
+ 'card-click', 'move', 'loaded', 'preview-delete', 'add',
93
+ ])
94
+
95
+ const sizeStyle = computed(() => {
96
+ switch (props.size) {
97
+ case 'sm': return { minHeight: '18rem' }
98
+ case 'lg': return { minHeight: '45rem' }
99
+ case 'fit': return {}
100
+ case 'md':
101
+ default: return { minHeight: '30rem' }
102
+ }
103
+ })
104
+
105
+ const columnMaxH = computed(() => {
106
+ switch (props.size) {
107
+ case 'sm': return 'max-h-[40vh]'
108
+ case 'lg': return 'max-h-[75vh]'
109
+ case 'fit': return ''
110
+ case 'md':
111
+ default: return 'max-h-[60vh]'
112
+ }
113
+ })
114
+
115
+ const api = useApi()
116
+
117
+ // ─── Modo ─────────────────────────────────────────────────────────────────────
118
+ // isItemsMode=true → datos pre-cargados vía prop `items`
119
+ // isItemsMode=false → fetch interno vía `endpoint`
120
+ const isItemsMode = computed(() => props.items !== null)
121
+
122
+ // ─── State ───────────────────────────────────────────────────────────────────
123
+ const rows = ref([])
124
+ const meta = ref(null)
125
+ const loading = ref(false)
126
+ const isFetching = ref(false)
127
+ const search = ref('')
128
+ const isDataFromCache = ref(false)
129
+
130
+ // Visibilidad de columnas (estados) — { [stateKey]: boolean }
131
+ const stateVisibility = ref(Object.fromEntries(props.states.map(s => [s.key, true])))
132
+ const visibleStates = computed(() => props.states.filter(s => stateVisibility.value[s.key] !== false))
133
+
134
+ // ─── Items mode: filtro local ────────────────────────────────────────────────
135
+
136
+ const applyLocalFilter = () => {
137
+ const source = props.items ?? []
138
+ const q = search.value.trim().toLowerCase()
139
+ if (!q) {
140
+ rows.value = [...source]
141
+ return
142
+ }
143
+ const keys = Array.isArray(props.searchKeys) ? props.searchKeys : [props.searchKeys]
144
+ rows.value = source.filter(item =>
145
+ keys.some(k => String(item[k] ?? '').toLowerCase().includes(q))
146
+ )
147
+ }
148
+
149
+ // Sincronizar rows cada vez que cambien los items externos
150
+ watch(
151
+ () => props.items,
152
+ () => { if (isItemsMode.value) applyLocalFilter() },
153
+ { deep: true, immediate: true },
154
+ )
155
+
156
+ // ─── Fetch (endpoint mode) ───────────────────────────────────────────────────
157
+ const cacheKey = computed(() => {
158
+ if (!props.cached || !props.name) return null
159
+ return `kanban_${props.name}`
160
+ })
161
+
162
+ const buildBody = () => ({
163
+ ...props.params,
164
+ per_page: props.perPage,
165
+ search: search.value.trim(),
166
+ })
167
+
168
+ const fetchAll = async () => {
169
+ // En items mode no hay fetch — solo re-aplica el filtro local
170
+ if (isItemsMode.value) { applyLocalFilter(); return }
171
+
172
+ loading.value = true
173
+ isFetching.value = true
174
+ isDataFromCache.value = false
175
+ try {
176
+ const res = await api.post(props.endpoint, buildBody())
177
+ rows.value = Array.isArray(res?.data) ? res.data : (Array.isArray(res) ? res : [])
178
+ meta.value = res?.meta ?? null
179
+ saveToCache()
180
+ emit('loaded', res)
181
+ } catch (e) {
182
+ console.error('[KanbanStandard] fetch error:', e)
183
+ rows.value = []
184
+ } finally {
185
+ loading.value = false
186
+ isFetching.value = false
187
+ }
188
+ }
189
+
190
+ const saveToCache = () => {
191
+ if (!cacheKey.value) return
192
+ try {
193
+ sessionStorage.setItem(cacheKey.value, JSON.stringify({
194
+ rows: rows.value,
195
+ meta: meta.value,
196
+ stateVisibility: stateVisibility.value,
197
+ search: search.value,
198
+ timestamp: Date.now(),
199
+ }))
200
+ } catch (_) { /* quota exceeded, ignore */ }
201
+ }
202
+
203
+ const loadFromCache = () => {
204
+ if (!cacheKey.value) return false
205
+ try {
206
+ const raw = sessionStorage.getItem(cacheKey.value)
207
+ if (!raw) return false
208
+ const cached = JSON.parse(raw)
209
+ if (Date.now() - cached.timestamp > 10 * 60 * 1000) {
210
+ sessionStorage.removeItem(cacheKey.value)
211
+ return false
212
+ }
213
+ rows.value = cached.rows ?? []
214
+ meta.value = cached.meta ?? null
215
+ stateVisibility.value = cached.stateVisibility ?? stateVisibility.value
216
+ search.value = cached.search ?? ''
217
+ isDataFromCache.value = true
218
+ return true
219
+ } catch { return false }
220
+ }
221
+
222
+ // Search debounced — en items mode filtra local, en endpoint mode re-fetcha
223
+ let searchTimer = null
224
+ watch(search, () => {
225
+ if (searchTimer) clearTimeout(searchTimer)
226
+ searchTimer = setTimeout(() => {
227
+ if (isItemsMode.value) applyLocalFilter()
228
+ else fetchAll()
229
+ }, 250)
230
+ })
231
+
232
+ // ─── Rows agrupados por estado ──────────────────────────────────────────────
233
+ const columnRows = computed(() => {
234
+ const map = {}
235
+ for (const s of props.states) map[s.key] = []
236
+ for (const row of rows.value) {
237
+ const state = row[props.stateKey]
238
+ if (map[state]) map[state].push(row)
239
+ }
240
+ return map
241
+ })
242
+
243
+ const totalRendered = computed(() => rows.value.length)
244
+
245
+ // ─── DnD ─────────────────────────────────────────────────────────────────────
246
+ const draggedId = ref(null)
247
+ const draggedFrom = ref(null)
248
+ const dragOverState = ref(null)
249
+
250
+ const onDragStart = (row, state) => {
251
+ draggedId.value = row.id
252
+ draggedFrom.value = state
253
+ }
254
+ const onDragOver = (e, state) => { e.preventDefault(); dragOverState.value = state }
255
+ const onDragLeave = () => { dragOverState.value = null }
256
+
257
+ const onDrop = async (targetState) => {
258
+ dragOverState.value = null
259
+ const id = draggedId.value
260
+ const fromState = draggedFrom.value
261
+ draggedId.value = null
262
+ draggedFrom.value = null
263
+ if (!id || fromState === targetState) return
264
+
265
+ // Optimistic update local (solo en endpoint mode).
266
+ // En items mode el optimistic lo gestiona el moveMutation externo
267
+ // (ej. useWorkflowView.onMove), y rows se sincroniza vía el watcher.
268
+ if (!isItemsMode.value) {
269
+ const idx = rows.value.findIndex(r => r.id === id)
270
+ if (idx >= 0) rows.value[idx] = { ...rows.value[idx], [props.stateKey]: targetState }
271
+ }
272
+
273
+ emit('move', { id, from: fromState, to: targetState })
274
+
275
+ if (props.moveMutation) {
276
+ try {
277
+ const ok = await props.moveMutation(id, targetState)
278
+ // Si retorna false = transición bloqueada (ej. workflow sin esa transición)
279
+ if (ok === false && !isItemsMode.value) {
280
+ const ridx = rows.value.findIndex(r => r.id === id)
281
+ if (ridx >= 0) rows.value[ridx] = { ...rows.value[ridx], [props.stateKey]: fromState }
282
+ }
283
+ } catch (e) {
284
+ // Rollback por error
285
+ if (!isItemsMode.value) {
286
+ const ridx = rows.value.findIndex(r => r.id === id)
287
+ if (ridx >= 0) rows.value[ridx] = { ...rows.value[ridx], [props.stateKey]: fromState }
288
+ }
289
+ console.error('[KanbanStandard] move failed, rolled back:', e)
290
+ }
291
+ }
292
+ }
293
+
294
+ // ─── Columns popover ─────────────────────────────────────────────────────────
295
+ const showColumnPanel = ref(false)
296
+ const toggleColumnPanel = () => { showColumnPanel.value = !showColumnPanel.value }
297
+
298
+ // ─── Color map (tokens-aware) ────────────────────────────────────────────────
299
+ const colorMap = {
300
+ slate: { dot: 'bg-slate-400', over: 'ring-2 ring-slate-400' },
301
+ red: { dot: 'bg-red-500', over: 'ring-2 ring-red-400' },
302
+ yellow: { dot: 'bg-yellow-500', over: 'ring-2 ring-yellow-400' },
303
+ amber: { dot: 'bg-amber-500', over: 'ring-2 ring-amber-400' },
304
+ green: { dot: 'bg-emerald-500', over: 'ring-2 ring-emerald-400' },
305
+ blue: { dot: 'bg-blue-500', over: 'ring-2 ring-blue-400' },
306
+ indigo: { dot: 'bg-indigo-500', over: 'ring-2 ring-indigo-400' },
307
+ purple: { dot: 'bg-purple-500', over: 'ring-2 ring-purple-400' },
308
+ pink: { dot: 'bg-pink-500', over: 'ring-2 ring-pink-400' },
309
+ }
310
+ const getColors = (state) => colorMap[state.color ?? 'slate'] ?? colorMap.slate
311
+
312
+ // ─── Preview ─────────────────────────────────────────────────────────────────
313
+ const previewEnabled = computed(() => !!slots.preview)
314
+ const previewRow = ref(null)
315
+ const containerRef = ref(null)
316
+ const dataPreviewRef = ref(null)
317
+ const closePreview = () => { previewRow.value = null }
318
+
319
+ const { collapseDock } = useDockedPreviews()
320
+
321
+ const handleCardClick = (row) => {
322
+ emit('card-click', row)
323
+ if (!previewEnabled.value) return
324
+ if (previewRow.value?.id === row.id) return
325
+ collapseDock()
326
+ previewRow.value = row
327
+ }
328
+
329
+ const onEsc = (e) => {
330
+ if (e.key !== 'Escape') return
331
+ if (previewRow.value) closePreview()
332
+ else collapseDock()
333
+ }
334
+ const onDocMousedown = (e) => {
335
+ if (!props.autoClosePreview || !previewRow.value) return
336
+ const panelEl = dataPreviewRef.value?.panelRef
337
+ if (containerRef.value?.contains(e.target)) return
338
+ if (panelEl?.contains(e.target)) return
339
+ closePreview()
340
+ }
341
+
342
+ // ─── Lifecycle ───────────────────────────────────────────────────────────────
343
+ onMounted(() => {
344
+ window.addEventListener('keydown', onEsc)
345
+ document.addEventListener('mousedown', onDocMousedown)
346
+ if (isItemsMode.value) return // rows ya cargados por el watcher immediate
347
+ if (loadFromCache()) return
348
+ fetchAll()
349
+ })
350
+
351
+ onBeforeUnmount(() => {
352
+ window.removeEventListener('keydown', onEsc)
353
+ document.removeEventListener('mousedown', onDocMousedown)
354
+ })
355
+
356
+ defineExpose({ reload: fetchAll, rows })
357
+ </script>
358
+
359
+ <template>
360
+ <div class="relative" ref="containerRef">
361
+
362
+ <!-- ── Toolbar ─────────────────────────────────────────────────────── -->
363
+ <div class="flex flex-wrap items-center gap-2 mb-2">
364
+ <!-- Search -->
365
+ <div v-if="showSearch" class="flex-1 min-w-48 max-w-xs">
366
+ <Forms v-model="search" type="text" :placeholder="searchPlaceholder" :icon-right="IconSearch" size="sm" />
367
+ </div>
368
+
369
+ <!-- + Nuevo -->
370
+ <button
371
+ v-if="showAddButton"
372
+ type="button"
373
+ @click="emit('add')"
374
+ class="inline-flex items-center gap-1.5 py-1.5 px-3 text-sm font-medium rounded-control bg-primary text-primary-foreground hover:bg-primary-hover transition-colors"
375
+ >
376
+ <IconPlus class="size-3.5" /> Nuevo
377
+ </button>
378
+
379
+ <!-- Reload + Instant compactos (cuando infoPosition === 'none') -->
380
+ <InfoToolbar
381
+ v-if="infoPosition === 'none'"
382
+ class="ml-auto"
383
+ :show-reload="showReloadButton"
384
+ :is-fetching="isFetching"
385
+ :show-instant="isDataFromCache && cached"
386
+ @reload="fetchAll"
387
+ />
388
+
389
+ <!-- Columnas (estados) visibility -->
390
+ <div v-if="showColumns" :class="['relative', infoPosition === 'none' ? '' : 'ml-auto']">
391
+ <button
392
+ type="button"
393
+ @click="toggleColumnPanel"
394
+ title="Columnas"
395
+ :class="[
396
+ 'p-1.5 inline-flex items-center justify-center rounded-control border transition-colors',
397
+ showColumnPanel
398
+ ? 'border-primary/40 bg-primary/10 text-primary'
399
+ : 'border-transparent text-muted-foreground hover:border-card-line hover:bg-muted-hover hover:text-foreground',
400
+ ]"
401
+ >
402
+ <IconLayoutColumns class="size-4" />
403
+ </button>
404
+
405
+ <Transition
406
+ enter-active-class="transition ease-out duration-150"
407
+ enter-from-class="opacity-0 -translate-y-1"
408
+ enter-to-class="opacity-100 translate-y-0"
409
+ leave-active-class="transition ease-in duration-100"
410
+ leave-from-class="opacity-100"
411
+ leave-to-class="opacity-0"
412
+ >
413
+ <div
414
+ v-if="showColumnPanel"
415
+ class="absolute right-0 top-full mt-1 z-30 w-56 bg-dropdown border border-dropdown-line rounded-popover shadow-xl p-2"
416
+ >
417
+ <p class="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest px-2 py-1">Columnas visibles</p>
418
+ <label
419
+ v-for="state in states"
420
+ :key="state.key"
421
+ class="flex items-center gap-2 px-2 py-1.5 rounded-control hover:bg-muted-hover cursor-pointer text-sm"
422
+ >
423
+ <input
424
+ type="checkbox"
425
+ :checked="stateVisibility[state.key] !== false"
426
+ @change="stateVisibility[state.key] = $event.target.checked"
427
+ class="size-4 rounded border-card-line text-primary"
428
+ />
429
+ <span class="size-2 rounded-avatar" :class="getColors(state).dot" />
430
+ <span class="text-foreground">{{ state.label }}</span>
431
+ </label>
432
+ </div>
433
+ </Transition>
434
+ </div>
435
+ </div>
436
+
437
+ <!-- ── Board wrapper (rounded, con scroll horizontal) ─────────────── -->
438
+ <div
439
+ :class="[
440
+ 'flex flex-col',
441
+ bordered ? 'border border-card-line rounded-card overflow-hidden' : '',
442
+ ]"
443
+ >
444
+ <div
445
+ :class="['overflow-x-auto', bordered ? 'p-3' : '']"
446
+ :style="sizeStyle"
447
+ >
448
+ <div v-if="loading && !rows.length" class="flex items-center justify-center py-16 gap-2 text-muted-foreground">
449
+ <IconLoader2 class="size-5 animate-spin" stroke="1.5" />
450
+ <span class="text-sm">Cargando...</span>
451
+ </div>
452
+
453
+ <div v-else class="flex gap-3 items-start">
454
+ <div
455
+ v-for="state in visibleStates"
456
+ :key="state.key"
457
+ :class="[
458
+ 'flex-shrink-0 w-72 flex flex-col overflow-hidden transition-shadow',
459
+ bordered
460
+ ? 'rounded-card border border-card-line bg-card'
461
+ : 'rounded-card bg-muted/50',
462
+ dragOverState === state.key ? getColors(state).over : '',
463
+ ]"
464
+ @dragover="onDragOver($event, state.key)"
465
+ @dragleave="onDragLeave"
466
+ @drop="onDrop(state.key)"
467
+ >
468
+ <!-- Column header -->
469
+ <div
470
+ :class="[
471
+ 'px-3 py-2.5 flex items-center justify-between',
472
+ bordered ? 'bg-card border-b border-card-line' : '',
473
+ ]"
474
+ >
475
+ <div class="flex items-center gap-2 min-w-0">
476
+ <span class="size-2 rounded-avatar shrink-0" :class="getColors(state).dot" />
477
+ <span class="font-medium text-sm text-foreground truncate uppercase tracking-wider text-[11px]">{{ state.label }}</span>
478
+ </div>
479
+ <span
480
+ :class="[
481
+ 'text-[11px] font-semibold rounded-badge px-2 py-0.5',
482
+ bordered ? 'text-muted-foreground bg-muted' : 'text-muted-foreground bg-card/70',
483
+ ]"
484
+ >
485
+ {{ columnRows[state.key]?.length ?? 0 }}
486
+ </span>
487
+ </div>
488
+
489
+ <!-- Cards -->
490
+ <div
491
+ :class="[
492
+ 'flex-1 flex flex-col gap-2 p-2 min-h-32 overflow-y-auto',
493
+ columnMaxH,
494
+ bordered ? 'bg-muted/40' : '',
495
+ ]"
496
+ >
497
+ <div
498
+ v-for="row in columnRows[state.key]"
499
+ :key="row.id"
500
+ draggable="true"
501
+ @dragstart="onDragStart(row, state.key)"
502
+ @click="handleCardClick(row)"
503
+ class="bg-card border border-card-line rounded-card p-3 cursor-grab active:cursor-grabbing hover:border-primary/40 hover:shadow-sm transition-all select-none"
504
+ :class="[
505
+ draggedId === row.id ? 'opacity-40' : '',
506
+ previewRow?.id === row.id ? 'ring-2 ring-primary/30' : '',
507
+ ]"
508
+ >
509
+ <slot name="card" :row="row" :state="state">
510
+ <div class="space-y-1">
511
+ <p class="text-sm font-medium text-foreground truncate">{{ row.name ?? row.title ?? row.id }}</p>
512
+ <p v-if="row.description" class="text-xs text-muted-foreground line-clamp-2">{{ row.description }}</p>
513
+ </div>
514
+ </slot>
515
+ </div>
516
+
517
+ <div
518
+ v-if="!columnRows[state.key]?.length"
519
+ class="flex-1 flex items-center justify-center py-6 text-xs text-muted-foreground-2"
520
+ >
521
+ Vacío
522
+ </div>
523
+ </div>
524
+ </div>
525
+ </div>
526
+ </div>
527
+
528
+ <!-- ── Footer (top o bottom via CSS order, oculto en 'none') ───── -->
529
+ <div
530
+ v-if="infoPosition !== 'none'"
531
+ :class="[
532
+ 'flex items-center justify-between gap-2 px-4 py-3',
533
+ infoPosition === 'top'
534
+ ? (bordered ? 'order-first border-b border-card-line bg-card' : 'order-first pb-4')
535
+ : (bordered ? 'border-t border-card-line bg-card' : 'pt-4'),
536
+ ]"
537
+ >
538
+ <div class="flex items-center gap-x-4 flex-wrap gap-y-2">
539
+ <div v-if="showReloadButton" class="flex items-center gap-x-2">
540
+ <IconReload
541
+ v-if="!isFetching"
542
+ class="size-4 cursor-pointer text-muted-foreground hover:text-muted-foreground-1 transition-colors"
543
+ @click="fetchAll"
544
+ />
545
+ <IconLoader2 v-else class="size-4 text-muted-foreground-2 animate-spin" />
546
+ </div>
547
+
548
+ <p class="text-sm text-foreground font-medium">{{ totalRendered }} {{ totalRendered === 1 ? 'tarjeta' : 'tarjetas' }}</p>
549
+
550
+ <div v-if="isDataFromCache && cached" class="group relative flex items-center">
551
+ <div class="flex items-center gap-x-1.5 py-1 px-2.5 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 rounded-badge cursor-help hover:bg-emerald-500/20 transition-colors">
552
+ <IconBolt class="size-3.5 fill-current" />
553
+ <span class="text-[10px] font-bold uppercase tracking-wider">Instant</span>
554
+ </div>
555
+ </div>
556
+ </div>
557
+ </div>
558
+ </div>
559
+
560
+ <!-- ── Preview compartido (panel overlay + chip flotante + history) ── -->
561
+ <DataPreview
562
+ ref="dataPreviewRef"
563
+ v-model:row="previewRow"
564
+ :enabled="previewEnabled"
565
+ :name="props.name || 'kanban'"
566
+ :cached="cached"
567
+ :preview-href="previewHref"
568
+ :preview-deletable="previewDeletable"
569
+ :split-ratio="splitRatio"
570
+ :container-ref="containerRef"
571
+ :history-endpoint="meta?.has_history && previewRow?.id && meta?.entity_type ? `history/${meta.entity_type}/${previewRow.id}` : null"
572
+ @delete="emit('preview-delete', $event)"
573
+ >
574
+ <template #header="bind">
575
+ <slot name="preview-header" v-bind="bind">
576
+ <div class="font-semibold text-foreground truncate">{{ bind.row.name ?? bind.row.title ?? 'Detalle' }}</div>
577
+ </slot>
578
+ </template>
579
+ <template #actions="bind">
580
+ <slot name="preview-actions" v-bind="bind" />
581
+ </template>
582
+ <template #default="bind">
583
+ <slot name="preview" v-bind="bind" />
584
+ </template>
585
+ </DataPreview>
586
+
587
+ </div>
588
+ </template>