@drax/dashboard-vue 0.37.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 +66 -0
- package/src/combobox/DashboardCombobox.vue +41 -0
- package/src/components/DashboardView/DashboardView.vue +41 -0
- package/src/components/GroupByCard/GroupByCard.vue +57 -0
- package/src/components/GroupByCard/renders/GroupByBarsRender.vue +361 -0
- package/src/components/GroupByCard/renders/GroupByGalleryRender.vue +181 -0
- package/src/components/GroupByCard/renders/GroupByPieRender.vue +298 -0
- package/src/components/GroupByCard/renders/GroupByTableRender.vue +91 -0
- package/src/components/PaginateCard/PaginateCard.vue +35 -0
- package/src/components/PaginateCard/renders/PaginateTableRender.vue +65 -0
- package/src/composables/UseDashboardCard.ts +86 -0
- package/src/cruds/DashboardCrud.ts +230 -0
- package/src/i18n/Dashboard-i18n.ts +55 -0
- package/src/index.ts +29 -0
- package/src/pages/DashboardIdentifierPage.vue +45 -0
- package/src/pages/DashboardViewPage.vue +25 -0
- package/src/pages/crud/DashboardCrudPage.vue +19 -0
- package/src/routes/DashboardCrudRoute.ts +38 -0
- package/src/stores/UseDashboardStore.ts +25 -0
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@drax/dashboard-vue",
|
|
3
|
+
"publishConfig": {
|
|
4
|
+
"access": "public"
|
|
5
|
+
},
|
|
6
|
+
"version": "0.37.0",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./src/index.ts",
|
|
9
|
+
"module": "./src/index.ts",
|
|
10
|
+
"types": "./src/index.ts",
|
|
11
|
+
"files": [
|
|
12
|
+
"src"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"dev": "vite",
|
|
16
|
+
"build": "run-p type-check \"build-only {@}\" --",
|
|
17
|
+
"preview": "vite preview",
|
|
18
|
+
"test:unit": "vitest",
|
|
19
|
+
"test:e2e": "start-server-and-test preview http://localhost:4173 'cypress run --e2e'",
|
|
20
|
+
"test:e2e:dev": "start-server-and-test 'vite dev --port 4173' http://localhost:4173 'cypress open --e2e'",
|
|
21
|
+
"build-only": "vite build",
|
|
22
|
+
"type-check": "vue-tsc --build --force",
|
|
23
|
+
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
|
24
|
+
"format": "prettier --write src/"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@drax/crud-front": "^0.37.0",
|
|
28
|
+
"@drax/crud-share": "^0.37.0"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"pinia": "^2.2.2",
|
|
32
|
+
"vue": "^3.5.7",
|
|
33
|
+
"vue-i18n": "^9.14.0",
|
|
34
|
+
"vuetify": "^3.7.2"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@rushstack/eslint-patch": "^1.8.0",
|
|
38
|
+
"@tsconfig/node20": "^20.1.4",
|
|
39
|
+
"@types/jsdom": "^21.1.7",
|
|
40
|
+
"@types/node": "^20.12.5",
|
|
41
|
+
"@vitejs/plugin-vue": "^5.0.4",
|
|
42
|
+
"@vue/eslint-config-prettier": "^9.0.0",
|
|
43
|
+
"@vue/eslint-config-typescript": "^13.0.0",
|
|
44
|
+
"@vue/test-utils": "^2.4.5",
|
|
45
|
+
"@vue/tsconfig": "^0.5.1",
|
|
46
|
+
"cypress": "^13.7.2",
|
|
47
|
+
"eslint": "^8.57.0",
|
|
48
|
+
"eslint-plugin-cypress": "^2.15.1",
|
|
49
|
+
"eslint-plugin-vue": "^9.23.0",
|
|
50
|
+
"jsdom": "^24.0.0",
|
|
51
|
+
"npm-run-all2": "^6.1.2",
|
|
52
|
+
"pinia": "^2.1.7",
|
|
53
|
+
"pinia-plugin-persistedstate": "^3.2.1",
|
|
54
|
+
"prettier": "^3.2.5",
|
|
55
|
+
"start-server-and-test": "^2.0.3",
|
|
56
|
+
"typescript": "5.6.3",
|
|
57
|
+
"vite": "^5.4.3",
|
|
58
|
+
"vite-plugin-css-injected-by-js": "^3.5.1",
|
|
59
|
+
"vite-plugin-dts": "^3.9.1",
|
|
60
|
+
"vitest": "^1.4.0",
|
|
61
|
+
"vue": "^3.5.3",
|
|
62
|
+
"vue-tsc": "^2.0.11",
|
|
63
|
+
"vuetify": "^3.7.1"
|
|
64
|
+
},
|
|
65
|
+
"gitHead": "0bca48d4b686fd9536a78d84f5befe6801238000"
|
|
66
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {ref, onMounted} from "vue"
|
|
3
|
+
import {DashboardProvider} from "@drax/dashboard-front";
|
|
4
|
+
import type {IDashboard} from "@drax/dashboard-share";
|
|
5
|
+
|
|
6
|
+
const valueModel = defineModel({default: null})
|
|
7
|
+
|
|
8
|
+
const loading = ref(false)
|
|
9
|
+
const items = ref<IDashboard[]>([])
|
|
10
|
+
|
|
11
|
+
const fetchDashboards = async () => {
|
|
12
|
+
try {
|
|
13
|
+
loading.value = true
|
|
14
|
+
items.value = await DashboardProvider.instance.find({})
|
|
15
|
+
} catch (error) {
|
|
16
|
+
console.error('Error fetching dashboards:', error)
|
|
17
|
+
}finally {
|
|
18
|
+
loading.value = false
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
onMounted(() => {
|
|
23
|
+
fetchDashboards()
|
|
24
|
+
})
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<template>
|
|
28
|
+
<v-select
|
|
29
|
+
:loading="loading"
|
|
30
|
+
label="Dashboard"
|
|
31
|
+
v-model="valueModel"
|
|
32
|
+
:items="items"
|
|
33
|
+
item-text="title"
|
|
34
|
+
item-value="_id"
|
|
35
|
+
return-object
|
|
36
|
+
></v-select>
|
|
37
|
+
</template>
|
|
38
|
+
|
|
39
|
+
<style scoped>
|
|
40
|
+
|
|
41
|
+
</style>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type {PropType} from "vue";
|
|
3
|
+
import type {IDashboard} from "@drax/dashboard-share";
|
|
4
|
+
import GroupByCard from "../GroupByCard/GroupByCard.vue";
|
|
5
|
+
import PaginateCard from "../PaginateCard/PaginateCard.vue";
|
|
6
|
+
|
|
7
|
+
const {dashboard} = defineProps({
|
|
8
|
+
dashboard: {type: Object as PropType<IDashboard>, required: true},
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<template>
|
|
14
|
+
<v-card v-if="dashboard" class="mt-3" >
|
|
15
|
+
<v-card-title>{{dashboard.title}}</v-card-title>
|
|
16
|
+
<v-card-subtitle>{{dashboard.identifier}}</v-card-subtitle>
|
|
17
|
+
<v-card-text>
|
|
18
|
+
<v-row>
|
|
19
|
+
<v-col v-for="(card,i) in dashboard.cards" :key="i"
|
|
20
|
+
:cols="card?.layout?.cols || 12"
|
|
21
|
+
:sm="card?.layout?.sm || 12"
|
|
22
|
+
:md="card?.layout?.md || 12"
|
|
23
|
+
:lg="card?.layout?.lg || 12"
|
|
24
|
+
>
|
|
25
|
+
<v-card :variant="card?.layout?.cardVariant || 'outlined' " :height="card?.layout?.height || 300" style="overflow-y: auto">
|
|
26
|
+
<v-card-title>{{card?.title}}</v-card-title>
|
|
27
|
+
<v-card-text >
|
|
28
|
+
<paginate-card v-if="card?.type === 'paginate'" :card="card" />
|
|
29
|
+
<group-by-card v-else-if="card?.type === 'groupBy'" :card="card" />
|
|
30
|
+
</v-card-text>
|
|
31
|
+
</v-card>
|
|
32
|
+
</v-col>
|
|
33
|
+
</v-row>
|
|
34
|
+
</v-card-text>
|
|
35
|
+
|
|
36
|
+
</v-card>
|
|
37
|
+
</template>
|
|
38
|
+
|
|
39
|
+
<style scoped>
|
|
40
|
+
|
|
41
|
+
</style>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type {PropType} from "vue";
|
|
3
|
+
import type {IDashboardCard} from "@drax/dashboard-share";
|
|
4
|
+
import {useDashboardCard} from "../../composables/UseDashboardCard";
|
|
5
|
+
import GroupByTableRender from "./renders/GroupByTableRender.vue";
|
|
6
|
+
import GroupByPieRender from "./renders/GroupByPieRender.vue";
|
|
7
|
+
import GroupByBarsRender from "./renders/GroupByBarsRender.vue";
|
|
8
|
+
import GroupByGalleryRender from "./renders/GroupByGalleryRender.vue";
|
|
9
|
+
import {ref, onMounted, defineProps } from "vue";
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
const {card} = defineProps({
|
|
13
|
+
card: {type: Object as PropType<IDashboardCard>, required: true},
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const {fetchGroupByData, groupByHeaders, cardEntityFields} = useDashboardCard(card)
|
|
17
|
+
|
|
18
|
+
const data = ref<any[]>()
|
|
19
|
+
|
|
20
|
+
onMounted(async ()=> {
|
|
21
|
+
data.value = await fetchGroupByData()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<template>
|
|
27
|
+
<group-by-table-render v-if="card?.groupBy?.render === 'table'"
|
|
28
|
+
:data="data"
|
|
29
|
+
:headers="groupByHeaders"
|
|
30
|
+
:fields="cardEntityFields"
|
|
31
|
+
/>
|
|
32
|
+
|
|
33
|
+
<group-by-pie-render v-else-if="card?.groupBy?.render === 'pie'"
|
|
34
|
+
:data="data"
|
|
35
|
+
:headers="groupByHeaders"
|
|
36
|
+
:fields="cardEntityFields"
|
|
37
|
+
/>
|
|
38
|
+
|
|
39
|
+
<group-by-bars-render v-else-if="card?.groupBy?.render === 'bars'"
|
|
40
|
+
:data="data"
|
|
41
|
+
:headers="groupByHeaders"
|
|
42
|
+
:fields="cardEntityFields"
|
|
43
|
+
:show-legend="false"
|
|
44
|
+
/>
|
|
45
|
+
|
|
46
|
+
<group-by-gallery-render v-else-if="card?.groupBy?.render === 'gallery'"
|
|
47
|
+
:data="data"
|
|
48
|
+
:headers="groupByHeaders"
|
|
49
|
+
:fields="cardEntityFields"
|
|
50
|
+
:show-legend="false"
|
|
51
|
+
/>
|
|
52
|
+
|
|
53
|
+
</template>
|
|
54
|
+
|
|
55
|
+
<style scoped>
|
|
56
|
+
|
|
57
|
+
</style>
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type {PropType} from "vue";
|
|
3
|
+
import {computed, ref, onMounted, watch} 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, headers, fields, dateFormat, showLegend} = 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
|
+
showLegend: {type: Boolean, default: true},
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
|
19
|
+
|
|
20
|
+
// Paleta de colores para el gráfico
|
|
21
|
+
const colors = [
|
|
22
|
+
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF',
|
|
23
|
+
'#FF9F40', '#FF6384', '#C9CBCF', '#4BC0C0', '#FF9F40',
|
|
24
|
+
'#36A2EB', '#FFCE56', '#9966FF', '#FF6384', '#4BC0C0'
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
// Calcular el total de todos los counts
|
|
28
|
+
const totalCount = computed(() => {
|
|
29
|
+
if (!data || data.length === 0) return 0
|
|
30
|
+
return data.reduce((sum, item) => sum + (item.count || 0), 0)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
// Calcular el valor máximo para escalar las barras
|
|
34
|
+
const maxValue = computed(() => {
|
|
35
|
+
if (!data || data.length === 0) return 0
|
|
36
|
+
return Math.max(...data.map(item => item.count || 0))
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// Preparar datos para el gráfico
|
|
40
|
+
const chartData = computed(() => {
|
|
41
|
+
if (!data || data.length === 0) return []
|
|
42
|
+
|
|
43
|
+
return data.map((item, index) => {
|
|
44
|
+
const percentage = totalCount.value > 0 ? (item.count / totalCount.value) * 100 : 0
|
|
45
|
+
|
|
46
|
+
// Obtener el label combinando todos los campos excepto count
|
|
47
|
+
const labelParts: string[] = []
|
|
48
|
+
|
|
49
|
+
// Iterar sobre las claves del item excepto count
|
|
50
|
+
Object.keys(item).forEach(key => {
|
|
51
|
+
if (key === 'count') return
|
|
52
|
+
|
|
53
|
+
// Buscar el campo correspondiente en fields
|
|
54
|
+
const field = fields?.find(f => f.name === key)
|
|
55
|
+
const value = item[key]
|
|
56
|
+
|
|
57
|
+
if (!field || value === null || value === undefined) return
|
|
58
|
+
|
|
59
|
+
let formattedValue = ''
|
|
60
|
+
|
|
61
|
+
if (field.type === 'ref' && field.refDisplay && value) {
|
|
62
|
+
formattedValue = value[field.refDisplay]
|
|
63
|
+
} else if (field.type === 'date' && value) {
|
|
64
|
+
formattedValue = formatDateByUnit(value, dateFormat)
|
|
65
|
+
} else if (field.type === 'enum' && value) {
|
|
66
|
+
formattedValue = value.toString()
|
|
67
|
+
} else if (typeof value === 'object' && !Array.isArray(value)) {
|
|
68
|
+
formattedValue = JSON.stringify(value)
|
|
69
|
+
} else {
|
|
70
|
+
formattedValue = value?.toString() || ''
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (formattedValue) {
|
|
74
|
+
labelParts.push(formattedValue)
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const label = labelParts.length > 0 ? labelParts.join(' - ') : 'N/A'
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
label,
|
|
82
|
+
value: item.count || 0,
|
|
83
|
+
percentage,
|
|
84
|
+
color: colors[index % colors.length]
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// Dibujar el gráfico de barras
|
|
90
|
+
const drawBarChart = () => {
|
|
91
|
+
if (!canvasRef.value || chartData.value.length === 0) return
|
|
92
|
+
|
|
93
|
+
const canvas = canvasRef.value
|
|
94
|
+
const ctx = canvas.getContext('2d')
|
|
95
|
+
if (!ctx) return
|
|
96
|
+
|
|
97
|
+
// Configurar el tamaño del canvas
|
|
98
|
+
const containerWidth = canvas.parentElement?.clientWidth || 400
|
|
99
|
+
const containerHeight = 300
|
|
100
|
+
canvas.width = containerWidth
|
|
101
|
+
canvas.height = containerHeight
|
|
102
|
+
|
|
103
|
+
const padding = { top: 20, right: 20, bottom: 60, left: 60 }
|
|
104
|
+
const chartWidth = containerWidth - padding.left - padding.right
|
|
105
|
+
const chartHeight = containerHeight - padding.top - padding.bottom
|
|
106
|
+
|
|
107
|
+
// Limpiar el canvas
|
|
108
|
+
ctx.clearRect(0, 0, containerWidth, containerHeight)
|
|
109
|
+
|
|
110
|
+
// Calcular el ancho de cada barra
|
|
111
|
+
const barWidth = chartWidth / chartData.value.length
|
|
112
|
+
const barPadding = barWidth * 0.2
|
|
113
|
+
|
|
114
|
+
// Dibujar el eje Y (valores)
|
|
115
|
+
ctx.strokeStyle = '#e0e0e0'
|
|
116
|
+
ctx.lineWidth = 1
|
|
117
|
+
ctx.beginPath()
|
|
118
|
+
ctx.moveTo(padding.left, padding.top)
|
|
119
|
+
ctx.lineTo(padding.left, padding.top + chartHeight)
|
|
120
|
+
ctx.stroke()
|
|
121
|
+
|
|
122
|
+
// Dibujar el eje X (categorías)
|
|
123
|
+
ctx.beginPath()
|
|
124
|
+
ctx.moveTo(padding.left, padding.top + chartHeight)
|
|
125
|
+
ctx.lineTo(padding.left + chartWidth, padding.top + chartHeight)
|
|
126
|
+
ctx.stroke()
|
|
127
|
+
|
|
128
|
+
// Dibujar líneas de referencia horizontales
|
|
129
|
+
const numGridLines = 5
|
|
130
|
+
ctx.strokeStyle = '#f5f5f5'
|
|
131
|
+
ctx.lineWidth = 1
|
|
132
|
+
ctx.font = '10px sans-serif'
|
|
133
|
+
ctx.fillStyle = '#666'
|
|
134
|
+
ctx.textAlign = 'right'
|
|
135
|
+
|
|
136
|
+
for (let i = 0; i <= numGridLines; i++) {
|
|
137
|
+
const y = padding.top + (chartHeight / numGridLines) * i
|
|
138
|
+
const value = Math.round(maxValue.value * (1 - i / numGridLines))
|
|
139
|
+
|
|
140
|
+
// Línea de referencia
|
|
141
|
+
ctx.beginPath()
|
|
142
|
+
ctx.moveTo(padding.left, y)
|
|
143
|
+
ctx.lineTo(padding.left + chartWidth, y)
|
|
144
|
+
ctx.stroke()
|
|
145
|
+
|
|
146
|
+
// Etiqueta del valor
|
|
147
|
+
ctx.fillText(value.toString(), padding.left - 10, y + 4)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Dibujar las barras
|
|
151
|
+
chartData.value.forEach((segment, index) => {
|
|
152
|
+
const x = padding.left + (barWidth * index) + barPadding / 2
|
|
153
|
+
const barHeight = (segment.value / maxValue.value) * chartHeight
|
|
154
|
+
const y = padding.top + chartHeight - barHeight
|
|
155
|
+
|
|
156
|
+
// Dibujar la barra
|
|
157
|
+
ctx.fillStyle = segment.color
|
|
158
|
+
ctx.fillRect(x, y, barWidth - barPadding, barHeight)
|
|
159
|
+
|
|
160
|
+
// Dibujar borde de la barra
|
|
161
|
+
ctx.strokeStyle = '#ffffff'
|
|
162
|
+
ctx.lineWidth = 2
|
|
163
|
+
ctx.strokeRect(x, y, barWidth - barPadding, barHeight)
|
|
164
|
+
|
|
165
|
+
// Dibujar el valor encima de la barra
|
|
166
|
+
ctx.fillStyle = '#333'
|
|
167
|
+
ctx.font = 'bold 11px sans-serif'
|
|
168
|
+
ctx.textAlign = 'center'
|
|
169
|
+
ctx.fillText(
|
|
170
|
+
segment.value.toString(),
|
|
171
|
+
x + (barWidth - barPadding) / 2,
|
|
172
|
+
y - 5
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
// Dibujar etiqueta del eje X (rotada si es necesario)
|
|
176
|
+
ctx.save()
|
|
177
|
+
ctx.translate(x + (barWidth - barPadding) / 2, padding.top + chartHeight + 10)
|
|
178
|
+
ctx.rotate(-Math.PI / 4)
|
|
179
|
+
ctx.fillStyle = '#666'
|
|
180
|
+
ctx.font = '10px sans-serif'
|
|
181
|
+
ctx.textAlign = 'right'
|
|
182
|
+
|
|
183
|
+
// Truncar label si es muy largo
|
|
184
|
+
const maxLabelLength = 15
|
|
185
|
+
const label = segment.label.length > maxLabelLength
|
|
186
|
+
? segment.label.substring(0, maxLabelLength) + '...'
|
|
187
|
+
: segment.label
|
|
188
|
+
|
|
189
|
+
ctx.fillText(label, 0, 0)
|
|
190
|
+
ctx.restore()
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Redibujar cuando cambien los datos
|
|
195
|
+
watch(() => data, () => {
|
|
196
|
+
setTimeout(drawBarChart, 100)
|
|
197
|
+
}, { deep: true })
|
|
198
|
+
|
|
199
|
+
onMounted(() => {
|
|
200
|
+
drawBarChart()
|
|
201
|
+
|
|
202
|
+
// Redibujar al cambiar el tamaño de la ventana
|
|
203
|
+
window.addEventListener('resize', drawBarChart)
|
|
204
|
+
})
|
|
205
|
+
</script>
|
|
206
|
+
|
|
207
|
+
<template>
|
|
208
|
+
<div class="bar-chart-container">
|
|
209
|
+
<div v-if="!data || data.length === 0" class="empty-state">
|
|
210
|
+
<v-icon size="64" color="grey-lighten-1">mdi-chart-bar</v-icon>
|
|
211
|
+
<p class="text-grey-lighten-1 mt-4">No hay datos para mostrar</p>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
<template v-else>
|
|
215
|
+
<div class="chart-wrapper">
|
|
216
|
+
<canvas ref="canvasRef"></canvas>
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
<div v-if="showLegend" class="legend-container">
|
|
220
|
+
<div
|
|
221
|
+
v-for="(segment, index) in chartData"
|
|
222
|
+
:key="index"
|
|
223
|
+
class="legend-item"
|
|
224
|
+
>
|
|
225
|
+
<div class="legend-color" :style="{ backgroundColor: segment.color }"></div>
|
|
226
|
+
<div class="legend-content">
|
|
227
|
+
<div class="legend-label">{{ segment.label }}</div>
|
|
228
|
+
<div class="legend-stats">
|
|
229
|
+
<v-chip color="primary" size="x-small" variant="flat">
|
|
230
|
+
{{ segment.value }}
|
|
231
|
+
</v-chip>
|
|
232
|
+
<span class="legend-percentage">{{ segment.percentage.toFixed(1) }}%</span>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
</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
|
+
</template>
|
|
248
|
+
</div>
|
|
249
|
+
</template>
|
|
250
|
+
|
|
251
|
+
<style scoped>
|
|
252
|
+
.bar-chart-container {
|
|
253
|
+
padding: 2px;
|
|
254
|
+
margin-top: 6px;
|
|
255
|
+
width: 100%;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.empty-state {
|
|
259
|
+
display: flex;
|
|
260
|
+
flex-direction: column;
|
|
261
|
+
align-items: center;
|
|
262
|
+
justify-content: center;
|
|
263
|
+
padding: 4px 2px;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
.chart-wrapper {
|
|
267
|
+
display: flex;
|
|
268
|
+
justify-content: center;
|
|
269
|
+
align-items: center;
|
|
270
|
+
margin-bottom: 2px;
|
|
271
|
+
padding: 2px;
|
|
272
|
+
width: 100%;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.chart-wrapper canvas {
|
|
276
|
+
height: 100%;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.legend-container {
|
|
280
|
+
display: flex;
|
|
281
|
+
flex-direction: column;
|
|
282
|
+
gap: 6px;
|
|
283
|
+
max-height: 120px;
|
|
284
|
+
overflow-y: auto;
|
|
285
|
+
padding: 2px;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
.legend-item {
|
|
289
|
+
display: flex;
|
|
290
|
+
align-items: center;
|
|
291
|
+
gap: 8px;
|
|
292
|
+
padding: 2px 4px;
|
|
293
|
+
border-radius: 6px;
|
|
294
|
+
transition: background-color 0.2s;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
.legend-item:hover {
|
|
298
|
+
background-color: rgba(0, 0, 0, 0.04);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
.legend-color {
|
|
302
|
+
width: 12px;
|
|
303
|
+
height: 12px;
|
|
304
|
+
border-radius: 3px;
|
|
305
|
+
flex-shrink: 0;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.legend-content {
|
|
309
|
+
flex: 1;
|
|
310
|
+
display: flex;
|
|
311
|
+
justify-content: space-between;
|
|
312
|
+
align-items: center;
|
|
313
|
+
gap: 6px;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
.legend-label {
|
|
317
|
+
font-size: 13px;
|
|
318
|
+
font-weight: 500;
|
|
319
|
+
flex: 1;
|
|
320
|
+
overflow: hidden;
|
|
321
|
+
text-overflow: ellipsis;
|
|
322
|
+
white-space: nowrap;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.legend-stats {
|
|
326
|
+
display: flex;
|
|
327
|
+
align-items: center;
|
|
328
|
+
gap: 6px;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
.legend-percentage {
|
|
332
|
+
font-size: 11px;
|
|
333
|
+
color: rgba(0, 0, 0, 0.6);
|
|
334
|
+
font-weight: 500;
|
|
335
|
+
min-width: 40px;
|
|
336
|
+
text-align: right;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
.total-container {
|
|
340
|
+
margin-top: 8px;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/* Scrollbar personalizado para la leyenda */
|
|
344
|
+
.legend-container::-webkit-scrollbar {
|
|
345
|
+
width: 4px;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
.legend-container::-webkit-scrollbar-track {
|
|
349
|
+
background: rgba(0, 0, 0, 0.05);
|
|
350
|
+
border-radius: 2px;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
.legend-container::-webkit-scrollbar-thumb {
|
|
354
|
+
background: rgba(0, 0, 0, 0.2);
|
|
355
|
+
border-radius: 2px;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
.legend-container::-webkit-scrollbar-thumb:hover {
|
|
359
|
+
background: rgba(0, 0, 0, 0.3);
|
|
360
|
+
}
|
|
361
|
+
</style>
|