@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.
- package/components/Attention.vue +269 -0
- package/components/Chart/index.vue +193 -0
- package/components/Container.vue +116 -0
- package/components/CopyButton.vue +98 -0
- package/components/Files/Card.vue +215 -0
- package/components/Files/Preview.vue +675 -0
- package/components/Files/Upload.vue +408 -0
- package/components/Files/UploadModal.vue +83 -0
- package/components/Forms/InputPin.vue +162 -0
- package/components/InfoToolbar.vue +54 -0
- package/components/Kanban/Standard.vue +588 -0
- package/components/Loader.vue +358 -0
- package/components/Modal/Base.vue +6 -0
- package/components/Modal/Confirm.vue +89 -0
- package/components/Modal/DeleteConfirm.vue +14 -31
- package/components/Modal/Form.vue +76 -0
- package/components/Modal/Host.vue +58 -0
- package/components/Modal/Prompt.vue +74 -0
- package/components/Modal.vue +119 -34
- package/components/Table/Standard.vue +198 -25
- package/components/Table/index.vue +169 -369
- package/components/Table/views/ViewCards.vue +74 -0
- package/components/Table/views/ViewDatabase.vue +236 -0
- package/components/Table/views/ViewTable.vue +299 -0
- package/components/Toast/Alert.vue +16 -111
- package/components/Toast/Container.vue +4 -29
- package/components/Toast/Notification.vue +8 -43
- package/components/Toast/Process.vue +8 -86
- package/components/Toast/Stack.vue +59 -0
- package/components/Toast/Standard.vue +115 -0
- package/components/Tree/Standard.vue +49 -4
- package/components/Workflow/Board.vue +356 -0
- package/components/Workflow/Builder.vue +454 -0
- package/components/Workflow/Form.vue +526 -0
- package/components/Workflow/Viewer.vue +225 -0
- package/components/Workflow/edges/TransitionEdge.vue +84 -0
- package/components/Workflow/nodes/StepNode.vue +88 -0
- package/composables/useClipboard.js +79 -0
- package/composables/useConfetti.js +194 -0
- package/composables/useFile.js +189 -0
- package/composables/useModal.js +71 -0
- package/composables/useToast.js +38 -65
- package/composables/useWorkflow/form.js +162 -0
- package/composables/useWorkflow/index.js +18 -0
- package/composables/useWorkflow/view.js +220 -0
- package/composables/useWorkflowLayout.js +162 -0
- package/css/tokens.css +10 -10
- package/nuxt.config.ts +1 -1
- package/package.json +13 -8
- package/stores/modal.js +48 -0
- 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>
|