@drax/dashboard-vue 2.4.0 → 2.7.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 +3 -3
- package/src/components/DashboardConfig/DashboardCardEditor.vue +175 -0
- package/src/components/DashboardConfig/DashboardConfig.vue +241 -0
- package/src/components/GroupByCard/GroupByCard.vue +1 -1
- package/src/components/GroupByCard/renders/GroupByBarsRender.vue +34 -21
- 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/pages/DashboardConfigPage.vue +63 -0
- package/src/pages/crud/DashboardCrudPage.vue +3 -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.7.0",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "./src/index.ts",
|
|
9
9
|
"module": "./src/index.ts",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@drax/crud-front": "^2.0.0",
|
|
26
26
|
"@drax/crud-share": "^2.4.0",
|
|
27
|
-
"@drax/dashboard-front": "^2.
|
|
27
|
+
"@drax/dashboard-front": "^2.6.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": "6914eb5bfd532fb5e510b95c7d17b5b2ada8b07d"
|
|
50
50
|
}
|
|
@@ -0,0 +1,175 @@
|
|
|
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
|
+
|
|
6
|
+
const props = defineProps({
|
|
7
|
+
modelValue: { type: Object as PropType<IDashboardCard>, required: true }
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const emit = defineEmits(['update:modelValue', 'save', 'cancel']);
|
|
11
|
+
|
|
12
|
+
// Create a local reactive copy
|
|
13
|
+
const form = ref<IDashboardCard>(JSON.parse(JSON.stringify(props.modelValue)));
|
|
14
|
+
|
|
15
|
+
watch(() => props.modelValue, (newVal) => {
|
|
16
|
+
form.value = JSON.parse(JSON.stringify(newVal));
|
|
17
|
+
}, { deep: true });
|
|
18
|
+
|
|
19
|
+
// Ensure nested objects exist to avoid v-model errors
|
|
20
|
+
const ensureStructure = () => {
|
|
21
|
+
if (!form.value.layout) form.value.layout = { cols: 12, sm: 12, md: 12, lg: 12, height: 450, cardVariant: 'outlined' };
|
|
22
|
+
if (!form.value.groupBy) form.value.groupBy = { fields: [], dateFormat: 'day', render: 'pie' };
|
|
23
|
+
if (!form.value.paginate) form.value.paginate = { columns: [], orderBy: '', order: '' };
|
|
24
|
+
};
|
|
25
|
+
ensureStructure();
|
|
26
|
+
|
|
27
|
+
const save = () => {
|
|
28
|
+
emit('update:modelValue', form.value);
|
|
29
|
+
emit('save');
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const cancel = () => {
|
|
33
|
+
emit('cancel');
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const entities = computed(() => {
|
|
37
|
+
const dashboardStore = useEntityStore();
|
|
38
|
+
return dashboardStore.entities.map((entity: any) => entity.name)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const columns = computed(() => {
|
|
42
|
+
const dashboardStore = useEntityStore();
|
|
43
|
+
const entity = dashboardStore.entities.find((entity: any) => entity.name === form.value.entity)
|
|
44
|
+
return entity ? entity.headers.map((h:any) => ({title: h.title, value: h.key}) ) : []
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
function onEntityChange(){
|
|
48
|
+
console.log('entity change',form.value)
|
|
49
|
+
if(form.value){
|
|
50
|
+
if(form.value.paginate){
|
|
51
|
+
form.value.paginate.columns = []
|
|
52
|
+
}
|
|
53
|
+
if(form.value.groupBy){
|
|
54
|
+
form.value.groupBy.fields = []
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
</script>
|
|
60
|
+
|
|
61
|
+
<template>
|
|
62
|
+
<v-card class="d-flex flex-column dashboard-card-editor" height="100%" color="surface" variant="flat">
|
|
63
|
+
<v-card-title class="d-flex align-center bg-primary text-white py-2">
|
|
64
|
+
<v-icon icon="mdi-pencil-box-outline" class="mr-2"></v-icon>
|
|
65
|
+
Configuración de Tarjeta
|
|
66
|
+
<v-spacer></v-spacer>
|
|
67
|
+
<v-btn icon="mdi-close" variant="text" size="small" @click="cancel"></v-btn>
|
|
68
|
+
</v-card-title>
|
|
69
|
+
|
|
70
|
+
<v-card-text class="pt-4 flex-grow-1 overflow-y-auto">
|
|
71
|
+
<v-form @submit.prevent>
|
|
72
|
+
<v-row dense>
|
|
73
|
+
<!-- Base Config -->
|
|
74
|
+
<v-col cols="12" md="12">
|
|
75
|
+
<v-text-field v-model="form.title" label="Título de la tarjeta" variant="outlined" density="compact" hide-details="auto" class="mb-3"></v-text-field>
|
|
76
|
+
</v-col>
|
|
77
|
+
<v-col cols="12" md="6" lg="4">
|
|
78
|
+
<v-select :items="entities"
|
|
79
|
+
v-model="form.entity"
|
|
80
|
+
label="Entidad (ej. User, Country)"
|
|
81
|
+
variant="outlined"
|
|
82
|
+
density="compact"
|
|
83
|
+
hide-details="auto"
|
|
84
|
+
class="mb-3"
|
|
85
|
+
clearable
|
|
86
|
+
@update:modelValue="onEntityChange"
|
|
87
|
+
></v-select>
|
|
88
|
+
</v-col>
|
|
89
|
+
<v-col cols="12" md="6" lg="4">
|
|
90
|
+
<v-select v-model="form.type" :items="['groupBy' , 'paginate']" label="Tipo de tarjeta" variant="outlined" density="compact" hide-details="auto" class="mb-3"></v-select>
|
|
91
|
+
</v-col>
|
|
92
|
+
|
|
93
|
+
<v-col cols="12" md="6" lg="4">
|
|
94
|
+
<v-text-field v-if="form.layout" v-model="form.layout!.height" label="Altura" variant="outlined" density="compact" hide-details="auto" class="mb-3"></v-text-field>
|
|
95
|
+
</v-col>
|
|
96
|
+
|
|
97
|
+
<v-divider class="my-2 w-100" v-if="form.type"></v-divider>
|
|
98
|
+
|
|
99
|
+
<!-- Type specific config -->
|
|
100
|
+
<template v-if="form.type === 'paginate'">
|
|
101
|
+
<v-col cols="12">
|
|
102
|
+
<div class="text-subtitle-2 mb-2 text-primary d-flex align-center"><v-icon icon="mdi-table" size="small" class="mr-1"></v-icon> Paginate</div>
|
|
103
|
+
</v-col>
|
|
104
|
+
<v-col cols="12">
|
|
105
|
+
<v-select item-title="title"
|
|
106
|
+
item-value="value"
|
|
107
|
+
:items="columns"
|
|
108
|
+
v-model="form.paginate!.columns"
|
|
109
|
+
label="Columnas (presiona enter)"
|
|
110
|
+
multiple chips variant="outlined"
|
|
111
|
+
density="compact"
|
|
112
|
+
hide-details="auto"
|
|
113
|
+
class="mb-3">
|
|
114
|
+
|
|
115
|
+
</v-select>
|
|
116
|
+
</v-col>
|
|
117
|
+
<v-col cols="12" md="6">
|
|
118
|
+
<v-select
|
|
119
|
+
item-title="title"
|
|
120
|
+
item-value="value"
|
|
121
|
+
:items="columns"
|
|
122
|
+
v-model="form.paginate!.orderBy"
|
|
123
|
+
label="Ordenar por campo"
|
|
124
|
+
variant="outlined"
|
|
125
|
+
density="compact"
|
|
126
|
+
hide-details="auto"
|
|
127
|
+
class="mb-3"
|
|
128
|
+
clearable
|
|
129
|
+
></v-select>
|
|
130
|
+
</v-col>
|
|
131
|
+
<v-col cols="12" md="6">
|
|
132
|
+
<v-select v-model="form.paginate!.order" clearable :items="['asc', 'desc']" label="Dirección de orden" variant="outlined" density="compact" hide-details="auto" class="mb-3"></v-select>
|
|
133
|
+
</v-col>
|
|
134
|
+
</template>
|
|
135
|
+
|
|
136
|
+
<template v-else-if="form.type === 'groupBy'">
|
|
137
|
+
<v-col cols="12">
|
|
138
|
+
<div class="text-subtitle-2 mb-2 text-primary d-flex align-center"><v-icon icon="mdi-chart-pie" size="small" class="mr-1"></v-icon> Group By</div>
|
|
139
|
+
</v-col>
|
|
140
|
+
<v-col cols="12">
|
|
141
|
+
<v-select
|
|
142
|
+
item-title="title"
|
|
143
|
+
item-value="value"
|
|
144
|
+
:items="columns"
|
|
145
|
+
v-model="form.groupBy!.fields"
|
|
146
|
+
label="Campos de agrupación"
|
|
147
|
+
multiple chips variant="outlined"
|
|
148
|
+
density="compact" hide-details="auto"
|
|
149
|
+
class="mb-3"></v-select>
|
|
150
|
+
</v-col>
|
|
151
|
+
<v-col cols="12" md="6">
|
|
152
|
+
<v-select v-model="form.groupBy!.dateFormat" :items="['year', 'month', 'day', 'hour', 'minute', 'second']" label="Formato de Fecha (opcional)" variant="outlined" density="compact" hide-details="auto" clearable class="mb-3"></v-select>
|
|
153
|
+
</v-col>
|
|
154
|
+
<v-col cols="12" md="6">
|
|
155
|
+
<v-select v-model="form.groupBy!.render" :items="['pie', 'bars', 'table', 'gallery']" label="Render visual" variant="outlined" density="compact" hide-details="auto" class="mb-3"></v-select>
|
|
156
|
+
</v-col>
|
|
157
|
+
</template>
|
|
158
|
+
</v-row>
|
|
159
|
+
</v-form>
|
|
160
|
+
</v-card-text>
|
|
161
|
+
|
|
162
|
+
<v-card-actions class="bg-grey-lighten-4 py-3">
|
|
163
|
+
<v-spacer></v-spacer>
|
|
164
|
+
<v-btn color="grey-darken-1" variant="text" @click="cancel">Cancelar</v-btn>
|
|
165
|
+
<v-btn color="primary" variant="flat" @click="save" class="px-6">Guardar Cambios</v-btn>
|
|
166
|
+
</v-card-actions>
|
|
167
|
+
</v-card>
|
|
168
|
+
</template>
|
|
169
|
+
|
|
170
|
+
<style scoped>
|
|
171
|
+
.dashboard-card-editor {
|
|
172
|
+
border: 1px solid rgba(0, 0, 0, 0.12);
|
|
173
|
+
box-shadow: 0 4px 20px rgba(0,0,0,0.1) !important;
|
|
174
|
+
}
|
|
175
|
+
</style>
|
|
@@ -0,0 +1,241 @@
|
|
|
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
|
+
|
|
8
|
+
const valueModel = defineModel<IDashboardBase>({required: true})
|
|
9
|
+
|
|
10
|
+
const editingCardIndex = ref<number | null>(null);
|
|
11
|
+
|
|
12
|
+
// Drag and drop state
|
|
13
|
+
const dragCardIndex = ref<number | null>(null);
|
|
14
|
+
const dropTargetIndex = ref<number | null>(null);
|
|
15
|
+
|
|
16
|
+
const onDragStart = (e: DragEvent, index: number) => {
|
|
17
|
+
dragCardIndex.value = index;
|
|
18
|
+
if (e.dataTransfer) {
|
|
19
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const onDragEnter = (e: DragEvent, index: number) => {
|
|
24
|
+
e.preventDefault();
|
|
25
|
+
if (dragCardIndex.value !== index) {
|
|
26
|
+
dropTargetIndex.value = index;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const onDragOver = (e: DragEvent) => {
|
|
31
|
+
e.preventDefault();
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const onDrop = (e: DragEvent, index: number) => {
|
|
35
|
+
e.preventDefault();
|
|
36
|
+
dropTargetIndex.value = null;
|
|
37
|
+
if (dragCardIndex.value !== null && dragCardIndex.value !== index) {
|
|
38
|
+
const cards = valueModel.value.cards || [];
|
|
39
|
+
const movedItem = cards.splice(dragCardIndex.value, 1)[0] as IDashboardCard;
|
|
40
|
+
cards.splice(index, 0, movedItem);
|
|
41
|
+
emit("dashboardUpdated")
|
|
42
|
+
}
|
|
43
|
+
dragCardIndex.value = null;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const onDragEnd = () => {
|
|
47
|
+
dragCardIndex.value = null;
|
|
48
|
+
dropTargetIndex.value = null;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Actions
|
|
52
|
+
const editCard = (index: number) => {
|
|
53
|
+
editingCardIndex.value = index;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const deleteCard = (index: number) => {
|
|
57
|
+
if (confirm('¿Estás seguro de eliminar esta tarjeta?')) {
|
|
58
|
+
valueModel.value.cards?.splice(index, 1);
|
|
59
|
+
if (editingCardIndex.value === index) {
|
|
60
|
+
editingCardIndex.value = null;
|
|
61
|
+
emit("dashboardUpdated")
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const contractCard = (index: number) => {
|
|
67
|
+
const card = valueModel.value.cards?.[index];
|
|
68
|
+
if (card && card.layout) {
|
|
69
|
+
card.layout.md = Math.max(3, (card.layout.md || 12) - 1);
|
|
70
|
+
card.layout.lg = Math.max(3, (card.layout.lg || 12) - 1);
|
|
71
|
+
emit("dashboardUpdated")
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const expandCard = (index: number) => {
|
|
76
|
+
const card = valueModel.value.cards?.[index];
|
|
77
|
+
if (card && card.layout) {
|
|
78
|
+
card.layout.md = Math.min(12, (card.layout.md || 12) + 1);
|
|
79
|
+
card.layout.lg = Math.min(12, (card.layout.lg || 12) + 1);
|
|
80
|
+
emit("dashboardUpdated")
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const addNewCard = () => {
|
|
85
|
+
if (!valueModel.value.cards) {
|
|
86
|
+
valueModel.value.cards = [];
|
|
87
|
+
}
|
|
88
|
+
valueModel.value.cards.push({
|
|
89
|
+
title: 'Nueva Tarjeta',
|
|
90
|
+
entity: '',
|
|
91
|
+
type: 'groupBy',
|
|
92
|
+
layout: {cols: 12, sm: 12, md: 6, lg: 6, height: 450, cardVariant: 'outlined'}
|
|
93
|
+
});
|
|
94
|
+
editingCardIndex.value = valueModel.value.cards.length - 1;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const onSaveCard = () => {
|
|
98
|
+
emit("dashboardUpdated")
|
|
99
|
+
editingCardIndex.value = null;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const onCancelCard = () => {
|
|
103
|
+
editingCardIndex.value = null;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const emit = defineEmits(["dashboardUpdated"])
|
|
107
|
+
|
|
108
|
+
</script>
|
|
109
|
+
|
|
110
|
+
<template>
|
|
111
|
+
<v-card v-if="valueModel" class="mt-3 valueModel-config-wrapper" variant="flat" color="transparent">
|
|
112
|
+
<v-card-title class="px-0 d-flex align-center">
|
|
113
|
+
<span class="text-h5 font-weight-bold">{{ valueModel.title || 'Configuración de Dashboard' }}</span>
|
|
114
|
+
<v-spacer></v-spacer>
|
|
115
|
+
<v-btn color="primary" prepend-icon="mdi-plus" @click="addNewCard" elevation="2">Añadir Tarjeta</v-btn>
|
|
116
|
+
</v-card-title>
|
|
117
|
+
|
|
118
|
+
<v-card-text class="px-0">
|
|
119
|
+
<div v-if="!valueModel.cards?.length"
|
|
120
|
+
class="text-center pa-10 text-grey border-dashed rounded-lg bg-surface mt-4">
|
|
121
|
+
<v-icon icon="mdi-view-valueModel-variant-outline" size="64" color="grey-lighten-1" class="mb-4"></v-icon>
|
|
122
|
+
<h3 class="text-h6">Dashboard Vacío</h3>
|
|
123
|
+
<p class="mb-4">No hay tarjetas configuradas todavía.</p>
|
|
124
|
+
<v-btn color="primary" variant="tonal" prepend-icon="mdi-plus" @click="addNewCard">Añadir primera tarjeta
|
|
125
|
+
</v-btn>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<v-row v-else class="mt-2">
|
|
129
|
+
<v-col v-for="(card, i) in valueModel.cards" :key="i"
|
|
130
|
+
:cols="card?.layout?.cols || 12"
|
|
131
|
+
:sm="card?.layout?.sm || 12"
|
|
132
|
+
:md="card?.layout?.md || 12"
|
|
133
|
+
:lg="card?.layout?.lg || 12"
|
|
134
|
+
class="transition-swing drop-zone"
|
|
135
|
+
:class="{ 'drop-target': dropTargetIndex === i }"
|
|
136
|
+
@dragenter="onDragEnter($event, i)"
|
|
137
|
+
@dragover="onDragOver"
|
|
138
|
+
@drop="onDrop($event, i)"
|
|
139
|
+
>
|
|
140
|
+
<!-- Vista Edición -->
|
|
141
|
+
<template v-if="editingCardIndex === i">
|
|
142
|
+
<dashboard-card-editor
|
|
143
|
+
v-if="valueModel.cards[i]"
|
|
144
|
+
v-model="valueModel.cards[i]"
|
|
145
|
+
@save="onSaveCard"
|
|
146
|
+
@cancel="onCancelCard()"
|
|
147
|
+
/>
|
|
148
|
+
</template>
|
|
149
|
+
|
|
150
|
+
<!-- Vista Card -->
|
|
151
|
+
<v-card v-else
|
|
152
|
+
:variant="card?.layout?.cardVariant || 'outlined'"
|
|
153
|
+
:height="card?.layout?.height || 300"
|
|
154
|
+
class="hover-card d-flex flex-column"
|
|
155
|
+
draggable="true"
|
|
156
|
+
@dragstart="onDragStart($event, i)"
|
|
157
|
+
@dragend="onDragEnd"
|
|
158
|
+
>
|
|
159
|
+
<!-- Toolbar oculta en hover -->
|
|
160
|
+
<div class="card-toolbar d-flex align-center px-2 py-1 bg-grey-lighten-4">
|
|
161
|
+
<v-icon icon="mdi-drag" class="cursor-move text-grey" title="Arrastrar para mover"></v-icon>
|
|
162
|
+
<span class="text-caption text-grey ml-2">{{ card?.layout?.md || 12 }} cols</span>
|
|
163
|
+
<v-spacer></v-spacer>
|
|
164
|
+
<v-btn icon="mdi-arrow-collapse-horizontal" variant="text" size="x-small" density="comfortable"
|
|
165
|
+
color="grey-darken-1" title="Contraer" @click="contractCard(i)"
|
|
166
|
+
:disabled="(card?.layout?.md || 12) <= 3"></v-btn>
|
|
167
|
+
<v-btn icon="mdi-arrow-expand-horizontal" variant="text" size="x-small" density="comfortable"
|
|
168
|
+
color="grey-darken-1" title="Expandir" @click="expandCard(i)"
|
|
169
|
+
:disabled="(card?.layout?.md || 12) >= 12"></v-btn>
|
|
170
|
+
<v-divider vertical class="mx-1"></v-divider>
|
|
171
|
+
<v-btn icon="mdi-pencil" variant="text" size="x-small" density="comfortable" color="primary"
|
|
172
|
+
title="Editar" @click="editCard(i)"></v-btn>
|
|
173
|
+
<v-btn icon="mdi-delete" variant="text" size="x-small" density="comfortable" color="error"
|
|
174
|
+
title="Eliminar" @click="deleteCard(i)"></v-btn>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
<v-card-title class="text-subtitle-1 font-weight-bold pb-1">{{ card?.title }}</v-card-title>
|
|
178
|
+
<v-card-text class="flex-grow-1 overflow-y-auto pt-0 relative">
|
|
179
|
+
<paginate-card v-if="card?.type === 'paginate'" :card="card"/>
|
|
180
|
+
<group-by-card v-else-if="card?.type === 'groupBy'" :card="card"/>
|
|
181
|
+
</v-card-text>
|
|
182
|
+
</v-card>
|
|
183
|
+
</v-col>
|
|
184
|
+
</v-row>
|
|
185
|
+
</v-card-text>
|
|
186
|
+
</v-card>
|
|
187
|
+
</template>
|
|
188
|
+
|
|
189
|
+
<style scoped>
|
|
190
|
+
.hover-card {
|
|
191
|
+
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
|
192
|
+
border: 1px solid rgba(0, 0, 0, 0.12);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.hover-card:hover {
|
|
196
|
+
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;
|
|
197
|
+
transform: translateY(-2px);
|
|
198
|
+
border-color: rgba(0, 0, 0, 0.0);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.card-toolbar {
|
|
202
|
+
opacity: 0;
|
|
203
|
+
transition: opacity 0.2s ease-in-out;
|
|
204
|
+
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.hover-card:hover .card-toolbar {
|
|
208
|
+
opacity: 1;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.cursor-move {
|
|
212
|
+
cursor: grab;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.cursor-move:active {
|
|
216
|
+
cursor: grabbing;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.border-dashed {
|
|
220
|
+
border: 2px dashed rgba(0, 0, 0, 0.12);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.drop-zone {
|
|
224
|
+
transition: padding 0.2s ease-in-out;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.drop-target {
|
|
228
|
+
padding-left: 20px;
|
|
229
|
+
position: relative;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.drop-target::before {
|
|
233
|
+
content: '';
|
|
234
|
+
position: absolute;
|
|
235
|
+
left: 4px;
|
|
236
|
+
top: 12px;
|
|
237
|
+
bottom: 12px;
|
|
238
|
+
width: 4px;
|
|
239
|
+
border-radius: 4px;
|
|
240
|
+
}
|
|
241
|
+
</style>
|
|
@@ -6,7 +6,7 @@ import GroupByTableRender from "./renders/GroupByTableRender.vue";
|
|
|
6
6
|
import GroupByPieRender from "./renders/GroupByPieRender.vue";
|
|
7
7
|
import GroupByBarsRender from "./renders/GroupByBarsRender.vue";
|
|
8
8
|
import GroupByGalleryRender from "./renders/GroupByGalleryRender.vue";
|
|
9
|
-
import {ref, onMounted
|
|
9
|
+
import {ref, onMounted} from "vue";
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
const {card} = defineProps({
|
|
@@ -100,7 +100,7 @@ const drawBarChart = () => {
|
|
|
100
100
|
canvas.width = containerWidth
|
|
101
101
|
canvas.height = containerHeight
|
|
102
102
|
|
|
103
|
-
const padding = { top:
|
|
103
|
+
const padding = { top: 40, right: 20, bottom: 80, left: 60 }
|
|
104
104
|
const chartWidth = containerWidth - padding.left - padding.right
|
|
105
105
|
const chartHeight = containerHeight - padding.top - padding.bottom
|
|
106
106
|
|
|
@@ -162,26 +162,37 @@ const drawBarChart = () => {
|
|
|
162
162
|
ctx.lineWidth = 2
|
|
163
163
|
ctx.strokeRect(x, y, barWidth - barPadding, barHeight)
|
|
164
164
|
|
|
165
|
-
// Dibujar el valor encima de la barra
|
|
165
|
+
// Dibujar el valor y porcentaje encima de la barra
|
|
166
166
|
ctx.fillStyle = '#333'
|
|
167
|
-
ctx.font = 'bold
|
|
167
|
+
ctx.font = 'bold 12px sans-serif'
|
|
168
168
|
ctx.textAlign = 'center'
|
|
169
|
+
|
|
170
|
+
// Valor
|
|
169
171
|
ctx.fillText(
|
|
170
172
|
segment.value.toString(),
|
|
171
173
|
x + (barWidth - barPadding) / 2,
|
|
174
|
+
y - 18
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
// Porcentaje
|
|
178
|
+
ctx.font = '11px sans-serif'
|
|
179
|
+
ctx.fillStyle = '#666'
|
|
180
|
+
ctx.fillText(
|
|
181
|
+
`(${segment.percentage.toFixed(1)}%)`,
|
|
182
|
+
x + (barWidth - barPadding) / 2,
|
|
172
183
|
y - 5
|
|
173
184
|
)
|
|
174
185
|
|
|
175
186
|
// Dibujar etiqueta del eje X (rotada si es necesario)
|
|
176
187
|
ctx.save()
|
|
177
|
-
ctx.translate(x + (barWidth - barPadding) / 2, padding.top + chartHeight +
|
|
188
|
+
ctx.translate(x + (barWidth - barPadding) / 2, padding.top + chartHeight + 12)
|
|
178
189
|
ctx.rotate(-Math.PI / 4)
|
|
179
190
|
ctx.fillStyle = '#666'
|
|
180
|
-
ctx.font = '
|
|
191
|
+
ctx.font = '12px sans-serif'
|
|
181
192
|
ctx.textAlign = 'right'
|
|
182
193
|
|
|
183
194
|
// Truncar label si es muy largo
|
|
184
|
-
const maxLabelLength =
|
|
195
|
+
const maxLabelLength = 20
|
|
185
196
|
const label = segment.label.length > maxLabelLength
|
|
186
197
|
? segment.label.substring(0, maxLabelLength) + '...'
|
|
187
198
|
: segment.label
|
|
@@ -212,6 +223,16 @@ onMounted(() => {
|
|
|
212
223
|
</div>
|
|
213
224
|
|
|
214
225
|
<template v-else>
|
|
226
|
+
<div v-if="showLegend" class="total-container-top">
|
|
227
|
+
<div class="d-flex align-center justify-space-between">
|
|
228
|
+
<span class="text-h6 font-weight-bold">Total</span>
|
|
229
|
+
<v-chip color="primary" size="large" variant="flat">
|
|
230
|
+
{{ totalCount }}
|
|
231
|
+
</v-chip>
|
|
232
|
+
</div>
|
|
233
|
+
<v-divider class="my-2"></v-divider>
|
|
234
|
+
</div>
|
|
235
|
+
|
|
215
236
|
<div class="chart-wrapper">
|
|
216
237
|
<canvas ref="canvasRef"></canvas>
|
|
217
238
|
</div>
|
|
@@ -234,16 +255,6 @@ onMounted(() => {
|
|
|
234
255
|
</div>
|
|
235
256
|
</div>
|
|
236
257
|
</div>
|
|
237
|
-
|
|
238
|
-
<div v-if="showLegend" class="total-container">
|
|
239
|
-
<v-divider class="my-1"></v-divider>
|
|
240
|
-
<div class="d-flex align-center justify-space-between">
|
|
241
|
-
<span class="text-subtitle-1 font-weight-medium ml-2">Total</span>
|
|
242
|
-
<v-chip color="primary" variant="flat">
|
|
243
|
-
{{ totalCount }}
|
|
244
|
-
</v-chip>
|
|
245
|
-
</div>
|
|
246
|
-
</div>
|
|
247
258
|
</template>
|
|
248
259
|
</div>
|
|
249
260
|
</template>
|
|
@@ -317,9 +328,8 @@ onMounted(() => {
|
|
|
317
328
|
font-size: 13px;
|
|
318
329
|
font-weight: 500;
|
|
319
330
|
flex: 1;
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
white-space: nowrap;
|
|
331
|
+
word-break: break-word;
|
|
332
|
+
line-height: 1.3;
|
|
323
333
|
}
|
|
324
334
|
|
|
325
335
|
.legend-stats {
|
|
@@ -336,8 +346,11 @@ onMounted(() => {
|
|
|
336
346
|
text-align: right;
|
|
337
347
|
}
|
|
338
348
|
|
|
339
|
-
.total-container {
|
|
340
|
-
margin-
|
|
349
|
+
.total-container-top {
|
|
350
|
+
margin-bottom: 12px;
|
|
351
|
+
padding: 8px 12px;
|
|
352
|
+
background-color: rgba(0, 0, 0, 0.02);
|
|
353
|
+
border-radius: 8px;
|
|
341
354
|
}
|
|
342
355
|
|
|
343
356
|
/* Scrollbar personalizado para la leyenda */
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type {PropType} from "vue";
|
|
3
|
+
import {computed} from "vue";
|
|
4
|
+
import {useDateFormat} from "@drax/common-vue"
|
|
5
|
+
import type {IDraxDateFormatUnit} from "@drax/common-share";
|
|
6
|
+
import type {IEntityCrudField} from "@drax/crud-share";
|
|
7
|
+
|
|
8
|
+
const {formatDateByUnit} = useDateFormat()
|
|
9
|
+
|
|
10
|
+
const {data, fields, dateFormat} = defineProps({
|
|
11
|
+
data: {type: Array as PropType<any[]>, required: false},
|
|
12
|
+
headers: {type: Array as PropType<any[]>, required: false},
|
|
13
|
+
fields: {type: Array as PropType<IEntityCrudField[]>, required: false},
|
|
14
|
+
dateFormat: {type: String as PropType<IDraxDateFormatUnit>, required: false, default:'day'},
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
// Paleta de colores para las tarjetas
|
|
18
|
+
const colors = [
|
|
19
|
+
'purple', 'indigo', 'teal', 'orange', 'pink',
|
|
20
|
+
'cyan', 'lime', 'amber', 'deep-purple', 'light-blue',
|
|
21
|
+
'deep-orange', 'blue-grey', 'brown', 'red', 'green'
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
// Calcular el total de todos los counts
|
|
25
|
+
const totalCount = computed(() => {
|
|
26
|
+
if (!data || data.length === 0) return 0
|
|
27
|
+
return data.reduce((sum, item) => sum + (item.count || 0), 0)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
// Preparar datos para las tarjetas
|
|
31
|
+
const cardData = computed(() => {
|
|
32
|
+
if (!data || data.length === 0) return []
|
|
33
|
+
|
|
34
|
+
return data.map((item, index) => {
|
|
35
|
+
const percentage = totalCount.value > 0 ? (item.count / totalCount.value) * 100 : 0
|
|
36
|
+
|
|
37
|
+
// Obtener el label combinando todos los campos excepto count
|
|
38
|
+
const labelParts: string[] = []
|
|
39
|
+
|
|
40
|
+
// Iterar sobre las claves del item excepto count
|
|
41
|
+
Object.keys(item).forEach(key => {
|
|
42
|
+
if (key === 'count') return
|
|
43
|
+
|
|
44
|
+
// Buscar el campo correspondiente en fields
|
|
45
|
+
const field = fields?.find(f => f.name === key)
|
|
46
|
+
const value = item[key]
|
|
47
|
+
|
|
48
|
+
if (!field || value === null || value === undefined) return
|
|
49
|
+
|
|
50
|
+
let formattedValue = ''
|
|
51
|
+
|
|
52
|
+
if (['ref','object'].includes(field.type) && field.refDisplay && value) {
|
|
53
|
+
formattedValue = value[field.refDisplay]
|
|
54
|
+
} else if (field.type === 'date' && value) {
|
|
55
|
+
formattedValue = formatDateByUnit(value, dateFormat)
|
|
56
|
+
} else if (field.type === 'enum' && value) {
|
|
57
|
+
formattedValue = value.toString()
|
|
58
|
+
} else if (typeof value === 'object' && !Array.isArray(value)) {
|
|
59
|
+
formattedValue = JSON.stringify(value)
|
|
60
|
+
} else {
|
|
61
|
+
formattedValue = value?.toString() || ''
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (formattedValue) {
|
|
65
|
+
labelParts.push(formattedValue)
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const label = labelParts.length > 0 ? labelParts.join(' - ') : 'N/A'
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
label,
|
|
73
|
+
value: item.count || 0,
|
|
74
|
+
percentage,
|
|
75
|
+
color: colors[index % colors.length]
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
</script>
|
|
80
|
+
|
|
81
|
+
<template>
|
|
82
|
+
<div class="gallery-container">
|
|
83
|
+
<div v-if="!data || data.length === 0" class="empty-state">
|
|
84
|
+
<v-icon size="64" color="grey-lighten-1">mdi-view-grid</v-icon>
|
|
85
|
+
<p class="text-grey-lighten-1 mt-4">No hay datos para mostrar</p>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<template v-else>
|
|
89
|
+
<v-row dense class="ma-0">
|
|
90
|
+
<v-col
|
|
91
|
+
v-for="(card, index) in cardData"
|
|
92
|
+
:key="index"
|
|
93
|
+
cols="6"
|
|
94
|
+
sm="4"
|
|
95
|
+
md="3"
|
|
96
|
+
lg="2"
|
|
97
|
+
class="pa-1"
|
|
98
|
+
>
|
|
99
|
+
<v-card
|
|
100
|
+
:color="card.color"
|
|
101
|
+
variant="tonal"
|
|
102
|
+
class="gallery-card"
|
|
103
|
+
hover
|
|
104
|
+
>
|
|
105
|
+
<v-card-text class="pa-2">
|
|
106
|
+
<div class="d-flex flex-column align-center text-center">
|
|
107
|
+
<div class="card-value text-h5 font-weight-bold mb-1">
|
|
108
|
+
{{ card.value }}
|
|
109
|
+
</div>
|
|
110
|
+
<div class="card-label text-caption text-truncate" :title="card.label">
|
|
111
|
+
{{ card.label }}
|
|
112
|
+
</div>
|
|
113
|
+
<v-chip
|
|
114
|
+
:color="card.color"
|
|
115
|
+
size="x-small"
|
|
116
|
+
variant="flat"
|
|
117
|
+
class="mt-1"
|
|
118
|
+
>
|
|
119
|
+
{{ card.percentage.toFixed(1) }}%
|
|
120
|
+
</v-chip>
|
|
121
|
+
</div>
|
|
122
|
+
</v-card-text>
|
|
123
|
+
</v-card>
|
|
124
|
+
</v-col>
|
|
125
|
+
</v-row>
|
|
126
|
+
|
|
127
|
+
<v-divider class="my-2"></v-divider>
|
|
128
|
+
|
|
129
|
+
<div class="total-section pa-2">
|
|
130
|
+
<v-card variant="flat" color="grey-lighten-4">
|
|
131
|
+
<v-card-text class="pa-2 d-flex align-center justify-space-between">
|
|
132
|
+
<span class="text-subtitle-2 font-weight-bold">Total</span>
|
|
133
|
+
<v-chip color="primary" size="small" variant="flat">
|
|
134
|
+
{{ totalCount }}
|
|
135
|
+
</v-chip>
|
|
136
|
+
</v-card-text>
|
|
137
|
+
</v-card>
|
|
138
|
+
</div>
|
|
139
|
+
</template>
|
|
140
|
+
</div>
|
|
141
|
+
</template>
|
|
142
|
+
|
|
143
|
+
<style scoped>
|
|
144
|
+
.gallery-container {
|
|
145
|
+
width: 100%;
|
|
146
|
+
padding: 4px;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.empty-state {
|
|
150
|
+
display: flex;
|
|
151
|
+
flex-direction: column;
|
|
152
|
+
align-items: center;
|
|
153
|
+
justify-content: center;
|
|
154
|
+
padding: 32px 16px;
|
|
155
|
+
min-height: 200px;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.gallery-card {
|
|
159
|
+
height: 100%;
|
|
160
|
+
transition: transform 0.2s, box-shadow 0.2s;
|
|
161
|
+
cursor: pointer;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.gallery-card:hover {
|
|
165
|
+
transform: translateY(-2px);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.card-value {
|
|
169
|
+
line-height: 1.2;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.card-label {
|
|
173
|
+
width: 100%;
|
|
174
|
+
line-height: 1.2;
|
|
175
|
+
max-width: 100%;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.total-section {
|
|
179
|
+
padding: 0 4px;
|
|
180
|
+
}
|
|
181
|
+
</style>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type {PropType} from "vue";
|
|
3
|
-
import {computed, ref, onMounted, watch} from "vue";
|
|
3
|
+
import {computed, ref, onMounted, onUnmounted, watch} from "vue";
|
|
4
4
|
import {useDateFormat} from "@drax/common-vue"
|
|
5
5
|
import type {IDraxDateFormatUnit} from "@drax/common-share";
|
|
6
6
|
import type {IEntityCrudField} from "@drax/crud-share";
|
|
@@ -23,6 +23,21 @@ const colors = [
|
|
|
23
23
|
'#36A2EB', '#FFCE56', '#9966FF', '#FF6384', '#4BC0C0'
|
|
24
24
|
]
|
|
25
25
|
|
|
26
|
+
const truncateLabel = (label: string, maxLength = 18) => {
|
|
27
|
+
if (!label) return 'N/A'
|
|
28
|
+
return label.length > maxLength ? `${label.slice(0, maxLength)}…` : label
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const getContrastColor = (hexColor: string) => {
|
|
32
|
+
const hex = hexColor.replace('#', '')
|
|
33
|
+
const r = Number.parseInt(hex.substring(0, 2), 16)
|
|
34
|
+
const g = Number.parseInt(hex.substring(2, 4), 16)
|
|
35
|
+
const b = Number.parseInt(hex.substring(4, 6), 16)
|
|
36
|
+
const luminance = (0.299 * r) + (0.587 * g) + (0.114 * b)
|
|
37
|
+
|
|
38
|
+
return luminance > 186 ? '#1f2937' : '#ffffff'
|
|
39
|
+
}
|
|
40
|
+
|
|
26
41
|
// Calcular el total de todos los counts
|
|
27
42
|
const totalCount = computed(() => {
|
|
28
43
|
if (!data || data.length === 0) return 0
|
|
@@ -72,6 +87,7 @@ const chartData = computed(() => {
|
|
|
72
87
|
|
|
73
88
|
return {
|
|
74
89
|
label,
|
|
90
|
+
shortLabel: truncateLabel(label),
|
|
75
91
|
value: item.count || 0,
|
|
76
92
|
percentage,
|
|
77
93
|
color: colors[index % colors.length]
|
|
@@ -87,58 +103,118 @@ const drawPieChart = () => {
|
|
|
87
103
|
const ctx = canvas.getContext('2d')
|
|
88
104
|
if (!ctx) return
|
|
89
105
|
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
canvas.height = size
|
|
106
|
+
const parentWidth = canvas.parentElement?.clientWidth || 420
|
|
107
|
+
const width = Math.min(parentWidth, 520)
|
|
108
|
+
const height = 300
|
|
94
109
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const radius = (size / 2) * 0.7
|
|
110
|
+
canvas.width = width
|
|
111
|
+
canvas.height = height
|
|
98
112
|
|
|
99
|
-
|
|
100
|
-
|
|
113
|
+
const centerX = width / 2
|
|
114
|
+
const centerY = height / 2
|
|
115
|
+
const radius = Math.min(width * 0.24, height * 0.34)
|
|
116
|
+
const labelRadius = radius + 16
|
|
117
|
+
const labelOffset = 22
|
|
118
|
+
const donutRadius = radius * 0.45
|
|
101
119
|
|
|
102
|
-
|
|
103
|
-
|
|
120
|
+
ctx.clearRect(0, 0, width, height)
|
|
121
|
+
ctx.textBaseline = 'middle'
|
|
122
|
+
|
|
123
|
+
let currentAngle = -Math.PI / 2
|
|
104
124
|
|
|
105
125
|
chartData.value.forEach((segment) => {
|
|
106
126
|
const sliceAngle = (segment.percentage / 100) * 2 * Math.PI
|
|
127
|
+
const endAngle = currentAngle + sliceAngle
|
|
128
|
+
const midAngle = currentAngle + (sliceAngle / 2)
|
|
107
129
|
|
|
108
|
-
//
|
|
130
|
+
// Segmento
|
|
109
131
|
ctx.beginPath()
|
|
110
132
|
ctx.moveTo(centerX, centerY)
|
|
111
|
-
ctx.arc(centerX, centerY, radius, currentAngle,
|
|
133
|
+
ctx.arc(centerX, centerY, radius, currentAngle, endAngle)
|
|
112
134
|
ctx.closePath()
|
|
113
135
|
ctx.fillStyle = segment.color
|
|
114
136
|
ctx.fill()
|
|
115
137
|
|
|
116
|
-
// Dibujar borde blanco
|
|
117
138
|
ctx.strokeStyle = '#ffffff'
|
|
118
139
|
ctx.lineWidth = 2
|
|
119
140
|
ctx.stroke()
|
|
120
141
|
|
|
121
|
-
|
|
142
|
+
// Porcentaje dentro del segmento
|
|
143
|
+
if (segment.percentage >= 4) {
|
|
144
|
+
const textRadius = radius * 0.68
|
|
145
|
+
const textX = centerX + Math.cos(midAngle) * textRadius
|
|
146
|
+
const textY = centerY + Math.sin(midAngle) * textRadius
|
|
147
|
+
|
|
148
|
+
ctx.fillStyle = getContrastColor(segment.color)
|
|
149
|
+
ctx.font = 'bold 12px sans-serif'
|
|
150
|
+
ctx.textAlign = 'center'
|
|
151
|
+
ctx.fillText(`${segment.percentage.toFixed(1)}%`, textX, textY)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Label al costado con línea guía
|
|
155
|
+
const lineStartX = centerX + Math.cos(midAngle) * radius
|
|
156
|
+
const lineStartY = centerY + Math.sin(midAngle) * radius
|
|
157
|
+
const lineMidX = centerX + Math.cos(midAngle) * labelRadius
|
|
158
|
+
const lineMidY = centerY + Math.sin(midAngle) * labelRadius
|
|
159
|
+
const isRightSide = Math.cos(midAngle) >= 0
|
|
160
|
+
const lineEndX = lineMidX + (isRightSide ? labelOffset : -labelOffset)
|
|
161
|
+
const lineEndY = lineMidY
|
|
162
|
+
|
|
163
|
+
ctx.beginPath()
|
|
164
|
+
ctx.moveTo(lineStartX, lineStartY)
|
|
165
|
+
ctx.lineTo(lineMidX, lineMidY)
|
|
166
|
+
ctx.lineTo(lineEndX, lineEndY)
|
|
167
|
+
ctx.strokeStyle = segment.color
|
|
168
|
+
ctx.lineWidth = 1.5
|
|
169
|
+
ctx.stroke()
|
|
170
|
+
|
|
171
|
+
ctx.beginPath()
|
|
172
|
+
ctx.arc(lineEndX, lineEndY, 2.5, 0, 2 * Math.PI)
|
|
173
|
+
ctx.fillStyle = segment.color
|
|
174
|
+
ctx.fill()
|
|
175
|
+
|
|
176
|
+
ctx.fillStyle = '#374151'
|
|
177
|
+
ctx.font = '500 12px sans-serif'
|
|
178
|
+
ctx.textAlign = isRightSide ? 'left' : 'right'
|
|
179
|
+
ctx.fillText(
|
|
180
|
+
segment.shortLabel,
|
|
181
|
+
lineEndX + (isRightSide ? 6 : -6),
|
|
182
|
+
lineEndY
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
currentAngle = endAngle
|
|
122
186
|
})
|
|
123
187
|
|
|
124
|
-
//
|
|
188
|
+
// Centro blanco para efecto donut
|
|
125
189
|
ctx.beginPath()
|
|
126
|
-
ctx.arc(centerX, centerY,
|
|
190
|
+
ctx.arc(centerX, centerY, donutRadius, 0, 2 * Math.PI)
|
|
127
191
|
ctx.fillStyle = '#ffffff'
|
|
128
192
|
ctx.fill()
|
|
193
|
+
|
|
194
|
+
// Total al centro
|
|
195
|
+
ctx.fillStyle = '#6b7280'
|
|
196
|
+
ctx.font = '500 11px sans-serif'
|
|
197
|
+
ctx.textAlign = 'center'
|
|
198
|
+
ctx.fillText('Total', centerX, centerY - 8)
|
|
199
|
+
|
|
200
|
+
ctx.fillStyle = '#111827'
|
|
201
|
+
ctx.font = 'bold 16px sans-serif'
|
|
202
|
+
ctx.fillText(String(totalCount.value), centerX, centerY + 10)
|
|
129
203
|
}
|
|
130
204
|
|
|
131
205
|
// Redibujar cuando cambien los datos
|
|
132
|
-
watch(
|
|
206
|
+
watch(chartData, () => {
|
|
133
207
|
setTimeout(drawPieChart, 100)
|
|
134
208
|
}, { deep: true })
|
|
135
209
|
|
|
136
210
|
onMounted(() => {
|
|
137
211
|
drawPieChart()
|
|
138
|
-
|
|
139
|
-
// Redibujar al cambiar el tamaño de la ventana
|
|
140
212
|
window.addEventListener('resize', drawPieChart)
|
|
141
213
|
})
|
|
214
|
+
|
|
215
|
+
onUnmounted(() => {
|
|
216
|
+
window.removeEventListener('resize', drawPieChart)
|
|
217
|
+
})
|
|
142
218
|
</script>
|
|
143
219
|
|
|
144
220
|
<template>
|
|
@@ -152,35 +228,6 @@ onMounted(() => {
|
|
|
152
228
|
<div class="chart-wrapper">
|
|
153
229
|
<canvas ref="canvasRef"></canvas>
|
|
154
230
|
</div>
|
|
155
|
-
|
|
156
|
-
<div class="legend-container">
|
|
157
|
-
<div
|
|
158
|
-
v-for="(segment, index) in chartData"
|
|
159
|
-
:key="index"
|
|
160
|
-
class="legend-item"
|
|
161
|
-
>
|
|
162
|
-
<div class="legend-color" :style="{ backgroundColor: segment.color }"></div>
|
|
163
|
-
<div class="legend-content">
|
|
164
|
-
<div class="legend-label">{{ segment.label }}</div>
|
|
165
|
-
<div class="legend-stats">
|
|
166
|
-
<v-chip color="primary" size="x-small" variant="flat">
|
|
167
|
-
{{ segment.value }}
|
|
168
|
-
</v-chip>
|
|
169
|
-
<span class="legend-percentage">{{ segment.percentage.toFixed(1) }}%</span>
|
|
170
|
-
</div>
|
|
171
|
-
</div>
|
|
172
|
-
</div>
|
|
173
|
-
</div>
|
|
174
|
-
|
|
175
|
-
<div class="total-container">
|
|
176
|
-
<v-divider class="my-1"></v-divider>
|
|
177
|
-
<div class="d-flex align-center justify-space-between">
|
|
178
|
-
<span class="text-subtitle-1 font-weight-medium ml-2">Total</span>
|
|
179
|
-
<v-chip color="primary" variant="flat">
|
|
180
|
-
{{ totalCount }}
|
|
181
|
-
</v-chip>
|
|
182
|
-
</div>
|
|
183
|
-
</div>
|
|
184
231
|
</template>
|
|
185
232
|
</div>
|
|
186
233
|
</template>
|
|
@@ -203,96 +250,15 @@ onMounted(() => {
|
|
|
203
250
|
display: flex;
|
|
204
251
|
justify-content: center;
|
|
205
252
|
align-items: center;
|
|
206
|
-
|
|
207
|
-
|
|
253
|
+
padding: 8px 4px;
|
|
254
|
+
width: 100%;
|
|
255
|
+
overflow-x: auto;
|
|
208
256
|
}
|
|
209
257
|
|
|
210
258
|
.chart-wrapper canvas {
|
|
211
|
-
|
|
212
|
-
max-
|
|
213
|
-
height:
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
.legend-container {
|
|
217
|
-
display: flex;
|
|
218
|
-
flex-direction: column;
|
|
219
|
-
gap: 6px;
|
|
220
|
-
max-height: 200px;
|
|
221
|
-
overflow-y: auto;
|
|
222
|
-
padding: 2px;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
.legend-item {
|
|
226
|
-
display: flex;
|
|
227
|
-
align-items: center;
|
|
228
|
-
gap: 8px;
|
|
229
|
-
padding: 2px 4px;
|
|
230
|
-
border-radius: 6px;
|
|
231
|
-
transition: background-color 0.2s;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
.legend-item:hover {
|
|
235
|
-
background-color: rgba(0, 0, 0, 0.04);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
.legend-color {
|
|
239
|
-
width: 12px;
|
|
240
|
-
height: 12px;
|
|
241
|
-
border-radius: 3px;
|
|
242
|
-
flex-shrink: 0;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
.legend-content {
|
|
246
|
-
flex: 1;
|
|
247
|
-
display: flex;
|
|
248
|
-
justify-content: space-between;
|
|
249
|
-
align-items: center;
|
|
250
|
-
gap: 6px;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
.legend-label {
|
|
254
|
-
font-size: 13px;
|
|
255
|
-
font-weight: 500;
|
|
256
|
-
flex: 1;
|
|
257
|
-
overflow: hidden;
|
|
258
|
-
text-overflow: ellipsis;
|
|
259
|
-
white-space: nowrap;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
.legend-stats {
|
|
263
|
-
display: flex;
|
|
264
|
-
align-items: center;
|
|
265
|
-
gap: 6px;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
.legend-percentage {
|
|
269
|
-
font-size: 11px;
|
|
270
|
-
color: rgba(0, 0, 0, 0.6);
|
|
271
|
-
font-weight: 500;
|
|
272
|
-
min-width: 40px;
|
|
273
|
-
text-align: right;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
.total-container {
|
|
277
|
-
margin-top: 8px;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/* Scrollbar personalizado para la leyenda */
|
|
281
|
-
.legend-container::-webkit-scrollbar {
|
|
282
|
-
width: 4px;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
.legend-container::-webkit-scrollbar-track {
|
|
286
|
-
background: rgba(0, 0, 0, 0.05);
|
|
287
|
-
border-radius: 2px;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
.legend-container::-webkit-scrollbar-thumb {
|
|
291
|
-
background: rgba(0, 0, 0, 0.2);
|
|
292
|
-
border-radius: 2px;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
.legend-container::-webkit-scrollbar-thumb:hover {
|
|
296
|
-
background: rgba(0, 0, 0, 0.3);
|
|
259
|
+
width: 100%;
|
|
260
|
+
max-width: 520px;
|
|
261
|
+
height: 300px;
|
|
262
|
+
display: block;
|
|
297
263
|
}
|
|
298
264
|
</style>
|
|
@@ -3,7 +3,7 @@ import type {PropType} from "vue";
|
|
|
3
3
|
import type {IDashboardCard} from "@drax/dashboard-share";
|
|
4
4
|
import {useDashboardCard} from "../../composables/UseDashboardCard";
|
|
5
5
|
import PaginateTableRender from "./renders/PaginateTableRender.vue";
|
|
6
|
-
import {ref, onMounted
|
|
6
|
+
import {ref, onMounted } from "vue";
|
|
7
7
|
import type {IDraxPaginateResult} from "@drax/crud-share";
|
|
8
8
|
|
|
9
9
|
|
|
@@ -22,12 +22,11 @@ onMounted(async ()=> {
|
|
|
22
22
|
</script>
|
|
23
23
|
|
|
24
24
|
<template>
|
|
25
|
-
<paginate-table-render
|
|
25
|
+
<paginate-table-render
|
|
26
26
|
:data="data"
|
|
27
27
|
:fields="cardEntityFields"
|
|
28
28
|
:headers="paginateHeaders"
|
|
29
29
|
/>
|
|
30
|
-
|
|
31
30
|
</template>
|
|
32
31
|
|
|
33
32
|
<style scoped>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type {IDashboard} from "@drax/dashboard-share";
|
|
3
|
+
import {onMounted, ref} from "vue";
|
|
4
|
+
import DashboardConfig from "../components/DashboardConfig/DashboardConfig.vue";
|
|
5
|
+
import {useRoute} from "vue-router";
|
|
6
|
+
import {DashboardProvider} from "@drax/dashboard-front";
|
|
7
|
+
|
|
8
|
+
const route = useRoute()
|
|
9
|
+
|
|
10
|
+
const identifier = route.params.identifier
|
|
11
|
+
|
|
12
|
+
const dashboardSelected = ref<IDashboard>()
|
|
13
|
+
|
|
14
|
+
const loading = ref(false)
|
|
15
|
+
|
|
16
|
+
const findDashboard = async () => {
|
|
17
|
+
try {
|
|
18
|
+
loading.value = true
|
|
19
|
+
const filters = [{field: 'identifier', operator: 'eq', value: identifier}]
|
|
20
|
+
dashboardSelected.value = await DashboardProvider.instance.findOne({filters})
|
|
21
|
+
} catch (error) {
|
|
22
|
+
console.error('Error fetching dashboards:', error)
|
|
23
|
+
} finally {
|
|
24
|
+
loading.value = false
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const updateDashboard = async () => {
|
|
29
|
+
try {
|
|
30
|
+
if(dashboardSelected?.value){
|
|
31
|
+
loading.value = true
|
|
32
|
+
dashboardSelected.value = await DashboardProvider.instance.update(dashboardSelected.value._id, dashboardSelected?.value)
|
|
33
|
+
console.log("dashboard updated", dashboardSelected.value)
|
|
34
|
+
}
|
|
35
|
+
} catch (error) {
|
|
36
|
+
console.error('Error fetching dashboards:', error)
|
|
37
|
+
} finally {
|
|
38
|
+
loading.value = false
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
onMounted(() => {
|
|
43
|
+
findDashboard()
|
|
44
|
+
})
|
|
45
|
+
</script>
|
|
46
|
+
|
|
47
|
+
<template>
|
|
48
|
+
<v-container fluid>
|
|
49
|
+
<v-btn size="small" prepend-icon="mdi-view-dashboard" :href="'/dashboard/view/'+identifier">ver</v-btn>
|
|
50
|
+
|
|
51
|
+
<v-skeleton-loader :loading="loading"/>
|
|
52
|
+
<dashboard-config v-if="dashboardSelected"
|
|
53
|
+
v-model="dashboardSelected"
|
|
54
|
+
@dashboardUpdated="updateDashboard"
|
|
55
|
+
></dashboard-config>
|
|
56
|
+
|
|
57
|
+
</v-container>
|
|
58
|
+
|
|
59
|
+
</template>
|
|
60
|
+
|
|
61
|
+
<style scoped>
|
|
62
|
+
|
|
63
|
+
</style>
|
|
@@ -8,7 +8,9 @@ import type {IDashboard} from "@drax/dashboard-share";
|
|
|
8
8
|
<template>
|
|
9
9
|
<crud :entity="DashboardCrud.instance">
|
|
10
10
|
<template v-slot:item.actions="{ item }">
|
|
11
|
-
<v-btn size="small" icon="mdi-view-dashboard" :href="'/dashboard/
|
|
11
|
+
<v-btn class="mx-1" variant="text" color="purple" size="small" icon="mdi-view-dashboard" :href="'/dashboard/view/'+ (item as IDashboard)?.identifier">
|
|
12
|
+
</v-btn>
|
|
13
|
+
<v-btn class="mx-1" variant="text" color="indigo" size="small" icon="mdi-cogs" :href="'/dashboard/config/'+ (item as IDashboard)?.identifier">
|
|
12
14
|
</v-btn>
|
|
13
15
|
</template>
|
|
14
16
|
</crud>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
|
|
2
2
|
import DashboardCrudPage from "../pages/crud/DashboardCrudPage.vue";
|
|
3
3
|
import DashboardViewPage from "../pages/DashboardViewPage.vue";
|
|
4
|
+
import DashboardConfigPage from "../pages/DashboardConfigPage.vue";
|
|
4
5
|
import DashboardIdentifierPage from "../pages/DashboardIdentifierPage.vue";
|
|
5
6
|
|
|
6
7
|
|
|
@@ -23,9 +24,18 @@ const DashboardCrudRoute = [
|
|
|
23
24
|
permission: 'dashboard:manage',
|
|
24
25
|
}
|
|
25
26
|
},
|
|
27
|
+
{
|
|
28
|
+
name: 'DashboardConfigPage',
|
|
29
|
+
path: '/dashboard/config/:identifier',
|
|
30
|
+
component: DashboardConfigPage,
|
|
31
|
+
meta: {
|
|
32
|
+
auth: true,
|
|
33
|
+
permission: 'dashboard:manage',
|
|
34
|
+
}
|
|
35
|
+
},
|
|
26
36
|
{
|
|
27
37
|
name: 'DashboardIdentifierPage',
|
|
28
|
-
path: '/dashboard/
|
|
38
|
+
path: '/dashboard/view/:identifier',
|
|
29
39
|
component: DashboardIdentifierPage,
|
|
30
40
|
meta: {
|
|
31
41
|
auth: true,
|