@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 CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "2.4.0",
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.2.0"
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": "4e247435d6ffc0772296e6d013a78d2d00e31ffe"
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, defineProps } from "vue";
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: 20, right: 20, bottom: 60, left: 60 }
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 11px sans-serif'
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 + 10)
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 = '10px sans-serif'
191
+ ctx.font = '12px sans-serif'
181
192
  ctx.textAlign = 'right'
182
193
 
183
194
  // Truncar label si es muy largo
184
- const maxLabelLength = 15
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
- overflow: hidden;
321
- text-overflow: ellipsis;
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-top: 8px;
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
- // Configurar el tamaño del canvas
91
- const size = Math.min(canvas.parentElement?.clientWidth || 400, 400)
92
- canvas.width = size
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
- const centerX = size / 2
96
- const centerY = size / 2
97
- const radius = (size / 2) * 0.7
110
+ canvas.width = width
111
+ canvas.height = height
98
112
 
99
- // Limpiar el canvas
100
- ctx.clearRect(0, 0, size, size)
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
- // Dibujar cada segmento
103
- let currentAngle = -Math.PI / 2 // Comenzar desde arriba
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
- // Dibujar el segmento
130
+ // Segmento
109
131
  ctx.beginPath()
110
132
  ctx.moveTo(centerX, centerY)
111
- ctx.arc(centerX, centerY, radius, currentAngle, currentAngle + sliceAngle)
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
- currentAngle += sliceAngle
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
- // Dibujar círculo blanco en el centro para efecto "donut" (opcional)
188
+ // Centro blanco para efecto donut
125
189
  ctx.beginPath()
126
- ctx.arc(centerX, centerY, radius * 0.5, 0, 2 * Math.PI)
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(() => data, () => {
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
- margin-bottom: 2px;
207
- padding: 2px;
253
+ padding: 8px 4px;
254
+ width: 100%;
255
+ overflow-x: auto;
208
256
  }
209
257
 
210
258
  .chart-wrapper canvas {
211
- max-width: 200px;
212
- max-height: 180px;
213
- height: auto;
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, defineProps } from "vue";
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 v-if="card?.groupBy?.render === 'table'"
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/identifier/'+ (item as IDashboard)?.identifier">
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/identifier/:identifier',
38
+ path: '/dashboard/view/:identifier',
29
39
  component: DashboardIdentifierPage,
30
40
  meta: {
31
41
  auth: true,