@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 +4 -4
- package/src/components/DashboardConfig/DashboardCardEditor.vue +411 -0
- package/src/components/DashboardConfig/DashboardConfig.vue +253 -0
- package/src/components/DashboardView/DashboardView.vue +3 -3
- package/src/components/GroupByCard/GroupByCard.vue +1 -1
- package/src/components/GroupByCard/renders/GroupByBarsRender.vue +34 -21
- package/src/components/GroupByCard/renders/GroupByGalleryRender.vue +28 -13
- package/src/components/GroupByCard/renders/GroupByGalleryRenderbkp.vue +181 -0
- package/src/components/GroupByCard/renders/GroupByPieRender.vue +104 -138
- package/src/components/PaginateCard/PaginateCard.vue +2 -3
- package/src/cruds/DashboardCrud.ts +90 -71
- package/src/pages/DashboardConfigPage.vue +67 -0
- package/src/pages/DashboardIdentifierPage.vue +2 -3
- package/src/pages/crud/DashboardCrudPage.vue +11 -1
- package/src/routes/DashboardCrudRoute.ts +11 -1
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "2.
|
|
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.
|
|
27
|
-
"@drax/dashboard-front": "^2.
|
|
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": "
|
|
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>
|