@drax/dashboard-vue 2.6.0 → 2.8.0

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/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "2.6.0",
6
+ "version": "2.8.0",
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
9
9
  "module": "./src/index.ts",
@@ -23,8 +23,8 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "@drax/crud-front": "^2.0.0",
26
- "@drax/crud-share": "^2.4.0",
27
- "@drax/dashboard-front": "^2.6.0"
26
+ "@drax/crud-share": "^2.8.0",
27
+ "@drax/dashboard-front": "^2.8.0"
28
28
  },
29
29
  "peerDependencies": {
30
30
  "pinia": "^3.0.4",
@@ -46,5 +46,5 @@
46
46
  "vue-tsc": "^3.2.4",
47
47
  "vuetify": "^3.11.8"
48
48
  },
49
- "gitHead": "57830b50c2c9081b9d7e236d441ed9b2a3aca0ba"
49
+ "gitHead": "3adeb31ee60eb83c92137dc28162f9226cab06c1"
50
50
  }
@@ -0,0 +1,411 @@
1
+ <script setup lang="ts">
2
+ import {ref, watch, computed, type PropType} from 'vue';
3
+ import type {IDashboardCard} from "@drax/dashboard-share";
4
+ import {useEntityStore} from "@drax/crud-vue";
5
+ import type {
6
+ IDraxFieldFilter,
7
+ IEntityCrud,
8
+ IEntityCrudFieldTypes,
9
+ IEntityCrudFilter,
10
+ IEntityCrudFilterOperators
11
+ } from "@drax/crud-share";
12
+ import {useI18n} from "vue-i18n"
13
+ import CrudFormField from "@drax/crud-vue/src/components/CrudFormField.vue";
14
+ import {useFilterIcon} from "@drax/crud-vue";
15
+
16
+ const props = defineProps({
17
+ modelValue: {type: Object as PropType<IDashboardCard>, required: true}
18
+ });
19
+ const {filterIcon} = useFilterIcon()
20
+ const {t, te} = useI18n()
21
+
22
+ const emit = defineEmits(['update:modelValue', 'save', 'cancel']);
23
+
24
+ // Create a local reactive copy
25
+ const form = ref<IDashboardCard>(JSON.parse(JSON.stringify(props.modelValue)));
26
+
27
+ const entityStore = useEntityStore();
28
+
29
+ const entitySelected = computed<IEntityCrud>(() => {
30
+ return entityStore.entities.find((entity: any) => entity.name === form.value.entity)
31
+ })
32
+
33
+ const entityField = computed(() => {
34
+ return (fieldName: string) => {
35
+ if(!entitySelected.value){
36
+ return null
37
+ }
38
+ return entitySelected.value.fields.find(f => f.name === fieldName)
39
+ }
40
+ })
41
+
42
+
43
+ const entities = computed(() => {
44
+ return entityStore.entities.map((entity: any) => entity.name)
45
+ })
46
+
47
+ const columns = computed(() => {
48
+ return entitySelected.value ? entitySelected.value.headers.map((h: any) => ({title: h.title, value: h.key})) : []
49
+ })
50
+
51
+
52
+ const filters = ref<IEntityCrudFilter[]>(prepareFilters(form.value.filters));
53
+
54
+ function prepareFilters(filters: IDraxFieldFilter[] = []): IEntityCrudFilter[] {
55
+
56
+ return filters.map((filter: IDraxFieldFilter) => {
57
+ let theField = entityField.value(filter.field)
58
+
59
+ let value = filter.value
60
+ if(value === 'true'){
61
+ value = true
62
+ }
63
+ if(value === 'false'){
64
+ value = false
65
+ }
66
+
67
+ return {
68
+ name: filter.field,
69
+ operator: filter.operator as IEntityCrudFilterOperators,
70
+ value: value,
71
+ type: theField?.type || 'string',
72
+ label: theField?.label || '',
73
+ default: '',
74
+ ref: theField?.ref || '',
75
+ refDisplay: theField?.refDisplay || '',
76
+ enum: theField?.enum || []
77
+ }
78
+
79
+ })
80
+ }
81
+
82
+ watch(() => props.modelValue, (newVal) => {
83
+ form.value = JSON.parse(JSON.stringify(newVal));
84
+ ensureStructure();
85
+ }, {deep: true});
86
+
87
+ // Ensure nested objects exist to avoid v-model errors
88
+ const ensureStructure = () => {
89
+ if (!form.value.layout) form.value.layout = {cols: 12, sm: 12, md: 12, lg: 12, height: 450, cardVariant: 'elevated'};
90
+ if (!form.value.groupBy) form.value.groupBy = {fields: [], dateFormat: 'day', render: 'pie'};
91
+ if (!form.value.paginate) form.value.paginate = {columns: [], orderBy: '', order: ''};
92
+ if (!form.value.filters) form.value.filters = [];
93
+ };
94
+ ensureStructure();
95
+
96
+ const save = () => {
97
+ const payload = form.value
98
+ payload.filters = filters.value
99
+ .filter((filter: IEntityCrudFilter) => filter.name && filter.operator)
100
+ .map((filter: IEntityCrudFilter) => ({
101
+ field: filter.name,
102
+ operator: filter.operator as string,
103
+ value: filter.value == null ? '' : String(filter.value)
104
+ }))
105
+ emit('update:modelValue', payload);
106
+ emit('save');
107
+ };
108
+
109
+ const cancel = () => {
110
+ emit('cancel');
111
+ };
112
+
113
+
114
+ function onEntityChange() {
115
+ if (form.value) {
116
+ if (form.value.paginate) {
117
+ form.value.paginate.columns = []
118
+ }
119
+ if (form.value.groupBy) {
120
+ form.value.groupBy.fields = []
121
+ }
122
+ }
123
+ }
124
+
125
+ const fieldI18n = computed(() => {
126
+ return (field: IEntityCrudFilter) => {
127
+ return te(entitySelected.value?.name?.toLowerCase() + ".field." + field.name) ? t(entitySelected.value?.name?.toLowerCase() + ".field." + field.name) : field.label
128
+ }
129
+ })
130
+
131
+ const selectableFields = computed(() => {
132
+ return entitySelected.value ?
133
+ entitySelected.value.fields
134
+ .filter((f: any) => !['fullFile', 'object', 'array.object'].includes(f.type))
135
+ .map((f: any) => ({title: fieldI18n.value(f), value: f.name}))
136
+ : []
137
+ })
138
+
139
+ const dynamicFilter = computed(() => {
140
+ return (index: number) => {
141
+ return filters.value[index]
142
+ }
143
+ })
144
+
145
+ const operations = [
146
+ {title: t('operation.equals'), value: 'eq'},
147
+ {title: t('operation.notEquals'), value: 'ne'},
148
+ {title: t('operation.contains'), value: 'like'},
149
+ {title: t('operation.greaterThan'), value: 'gt'},
150
+ {title: t('operation.lessThan'), value: 'lt'},
151
+ {title: t('operation.greaterThanOrEqual'), value: 'gte'},
152
+ {title: t('operation.lessThanOrEqual'), value: 'lte'},
153
+ ]
154
+
155
+ function removeFilter(index: number) {
156
+ if (filters.value) {
157
+ filters.value.splice(index, 1)
158
+ }
159
+ }
160
+
161
+ function addFilter() {
162
+ const filter: IEntityCrudFilter = {
163
+ default: undefined,
164
+ label: "",
165
+ name: '',
166
+ operator: 'eq',
167
+ type: 'string',
168
+ permission: '',
169
+ value: ''
170
+ }
171
+ if (filters.value) {
172
+ filters.value.push(filter)
173
+ }
174
+ }
175
+
176
+ function normalizeFieldType(type: string): IEntityCrudFieldTypes {
177
+ if (type === 'array.ref') return 'ref';
178
+ if (type === 'array.string') return 'string';
179
+ if (type === 'longString') return 'string';
180
+ if (type === 'array.number') return 'number';
181
+ if (type === 'array.enum') return 'enum';
182
+ return type as IEntityCrudFieldTypes;
183
+ }
184
+
185
+ function onUpdateField(index: number, val: string) {
186
+ const field = entitySelected.value.fields.find((e: any) => e.name === val)
187
+ let filter = dynamicFilter.value(index)
188
+ if (!filter) {
189
+ return
190
+ }
191
+ filter.value = null
192
+ if (!field) return
193
+
194
+ if (field.ref) {
195
+ filter.ref = field.ref
196
+ }
197
+ if (field.refDisplay) {
198
+ filter.refDisplay = field.refDisplay
199
+ }
200
+ if (field.enum) {
201
+ filter.enum = field.enum
202
+ }
203
+ if (field.type) {
204
+ filter.type = normalizeFieldType(field.type)
205
+ if(field.type === 'boolean'){
206
+ filter.value = false
207
+ }
208
+ }
209
+ }
210
+
211
+
212
+ </script>
213
+
214
+ <template>
215
+ <v-card class="d-flex flex-column dashboard-card-editor" height="100%" color="surface" variant="flat">
216
+ <v-card-title class="d-flex align-center bg-primary text-white py-2">
217
+ <v-icon icon="mdi-pencil-box-outline" class="mr-2"></v-icon>
218
+ Configuración de Tarjeta
219
+ <v-spacer></v-spacer>
220
+ <v-btn icon="mdi-close" variant="text" size="small" @click="cancel"></v-btn>
221
+ </v-card-title>
222
+
223
+ <v-card-text class="pt-4 flex-grow-1 overflow-y-auto">
224
+ <v-form @submit.prevent>
225
+ <v-row v-if="form" dense>
226
+ <!-- Base Config -->
227
+ <v-col cols="12" md="12">
228
+ <v-text-field v-model="form.title" label="Título de la tarjeta" variant="outlined" density="compact"
229
+ hide-details="auto" class="mb-3"></v-text-field>
230
+ </v-col>
231
+ <v-col cols="12" md="6" lg="6">
232
+ <v-select :items="entities"
233
+ v-model="form.entity"
234
+ label="Entidad (ej. User, Country)"
235
+ variant="outlined"
236
+ density="compact"
237
+ hide-details="auto"
238
+ class="mb-3"
239
+ clearable
240
+ @update:modelValue="onEntityChange"
241
+ ></v-select>
242
+ </v-col>
243
+ <v-col cols="12" md="6" lg="6">
244
+ <v-select v-model="form.type" :items="['groupBy' , 'paginate']" label="Tipo de tarjeta" variant="outlined"
245
+ density="compact" hide-details="auto" class="mb-3"></v-select>
246
+ </v-col>
247
+
248
+ <v-col cols="12" md="6" lg="6">
249
+ <v-select v-model="form.layout!.cardVariant" :items="['elevated', 'outlined', 'flat','text','tonal','plain']" label="Tipo de tarjeta" variant="outlined"
250
+ density="compact" hide-details="auto" class="mb-3"></v-select>
251
+ </v-col>
252
+
253
+
254
+ <v-col cols="12" md="6" lg="6">
255
+ <v-text-field v-if="form.layout" v-model="form.layout!.height" label="Altura" variant="outlined"
256
+ density="compact" hide-details="auto" class="mb-3"></v-text-field>
257
+ </v-col>
258
+
259
+
260
+ <v-divider class="my-2 w-100"></v-divider>
261
+ <!-- Filters HERE -->
262
+ <v-row dense class="mt-4">
263
+ <v-col v-for="(filter,index) in filters"
264
+ :key="filter.name"
265
+ cols="12"
266
+ >
267
+ <v-row>
268
+ <v-col cols="12" sm="4">
269
+ <v-select
270
+ :items="selectableFields"
271
+ v-model="dynamicFilter(index)!.name"
272
+ :label="t('crud.field')"
273
+ density="compact"
274
+ variant="outlined"
275
+ hide-details
276
+ @update:modelValue="(v:string) => onUpdateField(index, v)"
277
+ />
278
+ </v-col>
279
+ <v-col cols="12" sm="3">
280
+ <v-select
281
+ :items="operations"
282
+ v-model="dynamicFilter(index)!.operator"
283
+ :label="t('crud.operator')"
284
+ density="compact"
285
+ variant="outlined"
286
+ hide-details
287
+ />
288
+ </v-col>
289
+ <v-col cols="12" sm="4">
290
+ <crud-form-field
291
+ v-if="entitySelected"
292
+ :field="filter"
293
+ :entity="entitySelected"
294
+ v-model="dynamicFilter(index)!.value"
295
+ :clearable="true"
296
+ density="compact"
297
+ variant="outlined"
298
+ :prepend-inner-icon="filterIcon(filter)"
299
+ hide-details
300
+ />
301
+ </v-col>
302
+ <v-col cols="12" sm="1">
303
+ <v-btn @click="removeFilter(index)"
304
+ icon="mdi-delete"
305
+ class="mr-1"
306
+ variant="text"
307
+ color="red"
308
+ >
309
+ </v-btn>
310
+ </v-col>
311
+ </v-row>
312
+
313
+
314
+ </v-col>
315
+
316
+ <v-col cols="12">
317
+ <v-btn small variant="outlined" color="primary" @click="addFilter">+ {{ t('action.addFilter') }}</v-btn>
318
+ </v-col>
319
+
320
+ </v-row>
321
+
322
+ <v-divider class="my-2 w-100" v-if="form.type"></v-divider>
323
+
324
+ <!-- Type specific config -->
325
+ <template v-if="form.type === 'paginate'">
326
+ <v-col cols="12">
327
+ <div class="text-subtitle-2 mb-2 text-primary d-flex align-center">
328
+ <v-icon icon="mdi-table" size="small" class="mr-1"></v-icon>
329
+ Paginate
330
+ </div>
331
+ </v-col>
332
+ <v-col cols="12">
333
+ <v-select item-title="title"
334
+ item-value="value"
335
+ :items="columns"
336
+ v-model="form.paginate!.columns"
337
+ label="Columnas (presiona enter)"
338
+ multiple chips variant="outlined"
339
+ density="compact"
340
+ hide-details="auto"
341
+ class="mb-3">
342
+
343
+ </v-select>
344
+ </v-col>
345
+ <v-col cols="12" md="6">
346
+ <v-select
347
+ item-title="title"
348
+ item-value="value"
349
+ :items="columns"
350
+ v-model="form.paginate!.orderBy"
351
+ label="Ordenar por campo"
352
+ variant="outlined"
353
+ density="compact"
354
+ hide-details="auto"
355
+ class="mb-3"
356
+ clearable
357
+ ></v-select>
358
+ </v-col>
359
+ <v-col cols="12" md="6">
360
+ <v-select v-model="form.paginate!.order" clearable :items="['asc', 'desc']" label="Dirección de orden"
361
+ variant="outlined" density="compact" hide-details="auto" class="mb-3"></v-select>
362
+ </v-col>
363
+ </template>
364
+
365
+ <template v-else-if="form.type === 'groupBy'">
366
+ <v-col cols="12">
367
+ <div class="text-subtitle-2 mb-2 text-primary d-flex align-center">
368
+ <v-icon icon="mdi-chart-pie" size="small" class="mr-1"></v-icon>
369
+ Group By
370
+ </div>
371
+ </v-col>
372
+ <v-col cols="12">
373
+ <v-select
374
+ item-title="title"
375
+ item-value="value"
376
+ :items="columns"
377
+ v-model="form.groupBy!.fields"
378
+ label="Campos de agrupación"
379
+ multiple chips variant="outlined"
380
+ density="compact" hide-details="auto"
381
+ class="mb-3"></v-select>
382
+ </v-col>
383
+ <v-col cols="12" md="6">
384
+ <v-select v-model="form.groupBy!.dateFormat" :items="['year', 'month', 'day', 'hour', 'minute', 'second']"
385
+ label="Formato de Fecha (opcional)" variant="outlined" density="compact" hide-details="auto"
386
+ clearable class="mb-3"></v-select>
387
+ </v-col>
388
+ <v-col cols="12" md="6">
389
+ <v-select v-model="form.groupBy!.render" :items="['pie', 'bars', 'table', 'gallery']"
390
+ label="Render visual" variant="outlined" density="compact" hide-details="auto"
391
+ class="mb-3"></v-select>
392
+ </v-col>
393
+ </template>
394
+ </v-row>
395
+ </v-form>
396
+ </v-card-text>
397
+
398
+ <v-card-actions class="bg-grey-lighten-4 py-3">
399
+ <v-spacer></v-spacer>
400
+ <v-btn color="grey-darken-1" variant="text" @click="cancel">Cancelar</v-btn>
401
+ <v-btn color="primary" variant="flat" @click="save" class="px-6">Guardar Cambios</v-btn>
402
+ </v-card-actions>
403
+ </v-card>
404
+ </template>
405
+
406
+ <style scoped>
407
+ .dashboard-card-editor {
408
+ border: 1px solid rgba(0, 0, 0, 0.12);
409
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1) !important;
410
+ }
411
+ </style>
@@ -0,0 +1,253 @@
1
+ <script setup lang="ts">
2
+ import {ref} from "vue";
3
+ import type {IDashboardBase, IDashboardCard} from "@drax/dashboard-share";
4
+ import GroupByCard from "../GroupByCard/GroupByCard.vue";
5
+ import PaginateCard from "../PaginateCard/PaginateCard.vue";
6
+ import DashboardCardEditor from "./DashboardCardEditor.vue";
7
+ import {debounce} from "@drax/common-front"
8
+
9
+ const valueModel = defineModel<IDashboardBase>({required: true})
10
+
11
+ const editingCardIndex = ref<number | null>(null);
12
+
13
+ // Drag and drop state
14
+ const dragCardIndex = ref<number | null>(null);
15
+ const dropTargetIndex = ref<number | null>(null);
16
+
17
+ const onDragStart = (e: DragEvent, index: number) => {
18
+ dragCardIndex.value = index;
19
+ if (e.dataTransfer) {
20
+ e.dataTransfer.effectAllowed = 'move';
21
+ }
22
+ };
23
+
24
+ const onDragEnter = (e: DragEvent, index: number) => {
25
+ e.preventDefault();
26
+ if (dragCardIndex.value !== index) {
27
+ dropTargetIndex.value = index;
28
+ }
29
+ };
30
+
31
+ const onDragOver = (e: DragEvent) => {
32
+ e.preventDefault();
33
+ };
34
+
35
+ const onDrop = (e: DragEvent, index: number) => {
36
+ e.preventDefault();
37
+ dropTargetIndex.value = null;
38
+ if (dragCardIndex.value !== null && dragCardIndex.value !== index) {
39
+ const cards = valueModel.value.cards || [];
40
+ const movedItem = cards.splice(dragCardIndex.value, 1)[0] as IDashboardCard;
41
+ cards.splice(index, 0, movedItem);
42
+ emit("dashboardUpdated")
43
+ }
44
+ dragCardIndex.value = null;
45
+ };
46
+
47
+ const onDragEnd = () => {
48
+ dragCardIndex.value = null;
49
+ dropTargetIndex.value = null;
50
+ };
51
+
52
+ // Actions
53
+ const editCard = (index: number) => {
54
+ editingCardIndex.value = index;
55
+ };
56
+
57
+ const deleteCard = (index: number) => {
58
+ if (confirm('¿Estás seguro de eliminar esta tarjeta?')) {
59
+ valueModel.value.cards?.splice(index, 1);
60
+ if (editingCardIndex.value === index) {
61
+ editingCardIndex.value = null;
62
+ emit("dashboardUpdated")
63
+ }
64
+ }
65
+ };
66
+
67
+ const contractCard = (index: number) => {
68
+ const card = valueModel.value.cards?.[index];
69
+ if (card && card.layout) {
70
+ card.layout.md = Math.max(3, (card.layout.md || 12) - 1);
71
+ card.layout.lg = Math.max(3, (card.layout.lg || 12) - 1);
72
+ emit("dashboardUpdated")
73
+ }
74
+ };
75
+
76
+ const expandCard = (index: number) => {
77
+ const card = valueModel.value.cards?.[index];
78
+ if (card && card.layout) {
79
+ card.layout.md = Math.min(12, (card.layout.md || 12) + 1);
80
+ card.layout.lg = Math.min(12, (card.layout.lg || 12) + 1);
81
+ emit("dashboardUpdated")
82
+ }
83
+ };
84
+
85
+ const addNewCard = () => {
86
+ if (!valueModel.value.cards) {
87
+ valueModel.value.cards = [];
88
+ }
89
+ valueModel.value.cards.push({
90
+ title: 'Nueva Tarjeta',
91
+ entity: '',
92
+ filters: [],
93
+ type: 'groupBy',
94
+ layout: {cols: 12, sm: 12, md: 6, lg: 6, height: 450, cardVariant: 'elevated'}
95
+ });
96
+ editingCardIndex.value = valueModel.value.cards.length - 1;
97
+ };
98
+
99
+
100
+
101
+ const onSaveCard = () => {
102
+ emit("dashboardUpdated")
103
+ editingCardIndex.value = null;
104
+ };
105
+
106
+ const debouncedSave = debounce(onSaveCard, 500)
107
+
108
+ const onCancelCard = () => {
109
+ editingCardIndex.value = null;
110
+ };
111
+
112
+ const emit = defineEmits(["dashboardUpdated"])
113
+
114
+ </script>
115
+
116
+ <template>
117
+ <v-card v-if="valueModel" class="mt-3 valueModel-config-wrapper" variant="flat" >
118
+ <v-card-title class="px-0 d-flex align-center">
119
+ <v-text-field
120
+ variant="solo-filled"
121
+ class="font-weight-semibold"
122
+ v-model="valueModel.title"
123
+ @update:modelValue="debouncedSave"
124
+ ></v-text-field>
125
+ <v-spacer></v-spacer>
126
+ <slot name="buttons"></slot>
127
+ <v-btn color="primary" prepend-icon="mdi-plus" @click="addNewCard" elevation="2">Añadir Tarjeta</v-btn>
128
+ </v-card-title>
129
+
130
+ <v-card-text class="px-0">
131
+ <div v-if="!valueModel.cards?.length"
132
+ class="text-center pa-10 text-grey border-dashed rounded-lg bg-surface mt-4">
133
+ <v-icon icon="mdi-view-valueModel-variant-outline" size="64" color="grey-lighten-1" class="mb-4"></v-icon>
134
+ <h3 class="text-h6">Dashboard Vacío</h3>
135
+ <p class="mb-4">No hay tarjetas configuradas todavía.</p>
136
+ <v-btn color="primary" variant="tonal" prepend-icon="mdi-plus" @click="addNewCard">Añadir primera tarjeta
137
+ </v-btn>
138
+ </div>
139
+
140
+ <v-row v-else class="mt-2">
141
+ <v-col v-for="(card, i) in valueModel.cards" :key="i"
142
+ :cols="card?.layout?.cols || 12"
143
+ :sm="card?.layout?.sm || 12"
144
+ :md="card?.layout?.md || 12"
145
+ :lg="card?.layout?.lg || 12"
146
+ class="transition-swing drop-zone"
147
+ :class="{ 'drop-target': dropTargetIndex === i }"
148
+ @dragenter="onDragEnter($event, i)"
149
+ @dragover="onDragOver"
150
+ @drop="onDrop($event, i)"
151
+ >
152
+ <!-- Vista Edición -->
153
+ <template v-if="editingCardIndex === i">
154
+ <dashboard-card-editor
155
+ v-if="valueModel.cards[i]"
156
+ v-model="valueModel.cards[i]"
157
+ @save="onSaveCard"
158
+ @cancel="onCancelCard()"
159
+ />
160
+ </template>
161
+
162
+ <!-- Vista Card -->
163
+ <v-card v-else
164
+ :variant="card?.layout?.cardVariant || 'outlined'"
165
+ :height="card?.layout?.height || 300"
166
+ class="hover-card d-flex flex-column"
167
+ draggable="true"
168
+ @dragstart="onDragStart($event, i)"
169
+ @dragend="onDragEnd"
170
+ >
171
+ <!-- Toolbar oculta en hover -->
172
+ <div class="card-toolbar d-flex align-center px-2 py-1 bg-grey-lighten-4">
173
+ <v-icon icon="mdi-drag" class="cursor-move text-grey" title="Arrastrar para mover"></v-icon>
174
+ <span class="text-caption text-grey ml-2">{{ card?.layout?.md || 12 }} cols</span>
175
+ <v-spacer></v-spacer>
176
+ <v-btn icon="mdi-arrow-collapse-horizontal" variant="text" size="x-small" density="comfortable"
177
+ color="grey-darken-1" title="Contraer" @click="contractCard(i)"
178
+ :disabled="(card?.layout?.md || 12) <= 3"></v-btn>
179
+ <v-btn icon="mdi-arrow-expand-horizontal" variant="text" size="x-small" density="comfortable"
180
+ color="grey-darken-1" title="Expandir" @click="expandCard(i)"
181
+ :disabled="(card?.layout?.md || 12) >= 12"></v-btn>
182
+ <v-divider vertical class="mx-1"></v-divider>
183
+ <v-btn icon="mdi-pencil" variant="text" size="x-small" density="comfortable" color="primary"
184
+ title="Editar" @click="editCard(i)"></v-btn>
185
+ <v-btn icon="mdi-delete" variant="text" size="x-small" density="comfortable" color="error"
186
+ title="Eliminar" @click="deleteCard(i)"></v-btn>
187
+ </div>
188
+
189
+ <v-card-title class="text-subtitle-1 font-weight-bold pb-1">{{ card?.title }}</v-card-title>
190
+ <v-card-text class="flex-grow-1 overflow-y-auto pt-0 relative">
191
+ <paginate-card v-if="card?.type === 'paginate'" :card="card"/>
192
+ <group-by-card v-else-if="card?.type === 'groupBy'" :card="card"/>
193
+ </v-card-text>
194
+ </v-card>
195
+ </v-col>
196
+ </v-row>
197
+ </v-card-text>
198
+ </v-card>
199
+ </template>
200
+
201
+ <style scoped>
202
+ .hover-card {
203
+ transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
204
+ border: 1px solid rgba(0, 0, 0, 0.12);
205
+ }
206
+
207
+ .hover-card:hover {
208
+ box-shadow: 0 8px 17px 2px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0, 0, 0, 0.12), 0 5px 5px -3px rgba(0, 0, 0, 0.2) !important;
209
+ transform: translateY(-2px);
210
+ border-color: rgba(0, 0, 0, 0.0);
211
+ }
212
+
213
+ .card-toolbar {
214
+ opacity: 0;
215
+ transition: opacity 0.2s ease-in-out;
216
+ border-bottom: 1px solid rgba(0, 0, 0, 0.05);
217
+ }
218
+
219
+ .hover-card:hover .card-toolbar {
220
+ opacity: 1;
221
+ }
222
+
223
+ .cursor-move {
224
+ cursor: grab;
225
+ }
226
+
227
+ .cursor-move:active {
228
+ cursor: grabbing;
229
+ }
230
+
231
+ .border-dashed {
232
+ border: 2px dashed rgba(0, 0, 0, 0.12);
233
+ }
234
+
235
+ .drop-zone {
236
+ transition: padding 0.2s ease-in-out;
237
+ }
238
+
239
+ .drop-target {
240
+ padding-left: 20px;
241
+ position: relative;
242
+ }
243
+
244
+ .drop-target::before {
245
+ content: '';
246
+ position: absolute;
247
+ left: 4px;
248
+ top: 12px;
249
+ bottom: 12px;
250
+ width: 4px;
251
+ border-radius: 4px;
252
+ }
253
+ </style>