@drax/dashboard-vue 3.14.0 → 3.18.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": "3.14.0",
6
+ "version": "3.18.0",
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
9
9
  "module": "./src/index.ts",
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "@drax/crud-front": "^3.11.0",
26
- "@drax/crud-share": "^3.14.0",
26
+ "@drax/crud-share": "^3.17.0",
27
27
  "@drax/dashboard-front": "^3.11.0"
28
28
  },
29
29
  "peerDependencies": {
@@ -46,5 +46,5 @@
46
46
  "vue-tsc": "^3.2.4",
47
47
  "vuetify": "^3.11.8"
48
48
  },
49
- "gitHead": "cec0f824be0bfff0965d7bc7b95241406c53c04d"
49
+ "gitHead": "c7709f3912ba1f8ae6a7e78376e17d50b32cfa7d"
50
50
  }
@@ -314,7 +314,7 @@ const {
314
314
  clearable class="mb-3"></v-select>
315
315
  </v-col>
316
316
  <v-col cols="12" md="6">
317
- <v-select v-model="form.groupBy!.render" :items="['pie', 'bars', 'table', 'gallery']"
317
+ <v-select v-model="form.groupBy!.render" :items="['pie', 'bars', 'lines', 'table', 'gallery']"
318
318
  label="Render visual" variant="outlined" density="compact" hide-details="auto"
319
319
  class="mb-3"></v-select>
320
320
  </v-col>
@@ -21,9 +21,14 @@ const {dashboard} = defineProps({
21
21
  :md="card?.layout?.md || 12"
22
22
  :lg="card?.layout?.lg || 12"
23
23
  >
24
- <v-card :variant="card?.layout?.cardVariant || 'elevated' " hover :height="card?.layout?.height || 300" style="overflow-y: auto">
24
+ <v-card
25
+ class="dashboard-card"
26
+ :variant="card?.layout?.cardVariant || 'elevated' "
27
+ hover
28
+ :height="card?.layout?.height || 300"
29
+ >
25
30
  <v-card-title>{{card?.title}}</v-card-title>
26
- <v-card-text >
31
+ <v-card-text class="dashboard-card__content">
27
32
  <paginate-card v-if="card?.type === 'paginate'" :card="card" />
28
33
  <group-by-card v-else-if="card?.type === 'groupBy'" :card="card" />
29
34
  </v-card-text>
@@ -36,5 +41,14 @@ const {dashboard} = defineProps({
36
41
  </template>
37
42
 
38
43
  <style scoped>
44
+ .dashboard-card {
45
+ display: flex;
46
+ flex-direction: column;
47
+ }
39
48
 
49
+ .dashboard-card__content {
50
+ flex: 1;
51
+ min-height: 0;
52
+ overflow: hidden;
53
+ }
40
54
  </style>
@@ -6,6 +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 GroupByLinesRender from "./renders/GroupByLinesRender.vue";
9
10
  import {ref, onMounted} from "vue";
10
11
 
11
12
 
@@ -38,18 +39,21 @@ watch(() => card, async () => {
38
39
  :data="data"
39
40
  :headers="groupByHeaders"
40
41
  :fields="cardEntityFields"
42
+ :date-format="card?.groupBy?.dateFormat"
41
43
  />
42
44
 
43
45
  <group-by-pie-render v-else-if="card?.groupBy?.render === 'pie'"
44
46
  :data="data"
45
47
  :headers="groupByHeaders"
46
48
  :fields="cardEntityFields"
49
+ :date-format="card?.groupBy?.dateFormat"
47
50
  />
48
51
 
49
52
  <group-by-bars-render v-else-if="card?.groupBy?.render === 'bars'"
50
53
  :data="data"
51
54
  :headers="groupByHeaders"
52
55
  :fields="cardEntityFields"
56
+ :date-format="card?.groupBy?.dateFormat"
53
57
  :show-legend="false"
54
58
  />
55
59
 
@@ -57,9 +61,17 @@ watch(() => card, async () => {
57
61
  :data="data"
58
62
  :headers="groupByHeaders"
59
63
  :fields="cardEntityFields"
64
+ :date-format="card?.groupBy?.dateFormat"
60
65
  :show-legend="false"
61
66
  />
62
67
 
68
+ <group-by-lines-render v-else-if="card?.groupBy?.render === 'lines'"
69
+ :data="data"
70
+ :headers="groupByHeaders"
71
+ :fields="cardEntityFields"
72
+ :date-format="card?.groupBy?.dateFormat"
73
+ />
74
+
63
75
  </template>
64
76
 
65
77
  <style scoped>
@@ -0,0 +1,324 @@
1
+ <script setup lang="ts">
2
+ import type {PropType} from "vue";
3
+ import {computed, ref, onMounted, onUnmounted, 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, 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
+ showLegend: {type: Boolean, default: true},
16
+ })
17
+
18
+ const canvasRef = ref<HTMLCanvasElement | null>(null)
19
+
20
+ const colors = [
21
+ '#2563eb', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
22
+ '#06b6d4', '#ec4899', '#84cc16', '#f97316', '#14b8a6'
23
+ ]
24
+
25
+ const isNumericValue = (value: unknown) => typeof value === 'number' && Number.isFinite(value)
26
+
27
+ const formatFieldValue = (fieldName: string, value: any) => {
28
+ const field = fields?.find((item) => item.name === fieldName)
29
+
30
+ if (value === null || value === undefined) return 'N/A'
31
+
32
+ if (field?.type === 'date') {
33
+ return formatDateByUnit(value, dateFormat)
34
+ }
35
+
36
+ if (['ref', 'object'].includes(field?.type || '') && field?.refDisplay && value) {
37
+ return value[field.refDisplay] || 'N/A'
38
+ }
39
+
40
+ if (typeof value === 'object' && !Array.isArray(value)) {
41
+ return JSON.stringify(value)
42
+ }
43
+
44
+ return String(value)
45
+ }
46
+
47
+ const xField = computed(() => {
48
+ if (!data?.length) return null
49
+
50
+ const sample = data.find((item) => item && typeof item === 'object')
51
+ if (!sample) return null
52
+
53
+ return Object.keys(sample).find((key) => key !== 'count' && !isNumericValue(sample[key])) || null
54
+ })
55
+
56
+ const xFieldConfig = computed(() => {
57
+ if (!xField.value) return null
58
+ return fields?.find((field) => field.name === xField.value) || null
59
+ })
60
+
61
+ const seriesKeys = computed(() => {
62
+ if (!data?.length) return ['count']
63
+
64
+ const sample = data.find((item) => item && typeof item === 'object')
65
+ if (!sample) return ['count']
66
+
67
+ const keys = Object.keys(sample).filter((key) => key !== xField.value && isNumericValue(sample[key]))
68
+ return keys.length > 0 ? keys : ['count']
69
+ })
70
+
71
+ const seriesMeta = computed(() => {
72
+ return seriesKeys.value.map((key, index) => {
73
+ const field = fields?.find((item) => item.name === key)
74
+
75
+ return {
76
+ key,
77
+ label: field?.label || key,
78
+ color: colors[index % colors.length]
79
+ }
80
+ })
81
+ })
82
+
83
+ const buildItemLabel = (item: Record<string, any>) => {
84
+ const labelParts: string[] = []
85
+
86
+ Object.keys(item).forEach((key) => {
87
+ if (seriesKeys.value.includes(key)) return
88
+
89
+ const formattedValue = formatFieldValue(key, item[key])
90
+
91
+ if (formattedValue && formattedValue !== 'N/A') {
92
+ labelParts.push(formattedValue)
93
+ }
94
+ })
95
+
96
+ return labelParts.length > 0 ? labelParts.join(' - ') : 'N/A'
97
+ }
98
+
99
+ const chartData = computed(() => {
100
+ if (!data?.length || !xField.value) return []
101
+
102
+ const normalized = data.map((item, index) => {
103
+ const rawX = item?.[xField.value as string]
104
+ const xLabel = formatFieldValue(xField.value as string, rawX)
105
+ const fullLabel = buildItemLabel(item)
106
+ const xValue = xFieldConfig.value?.type === 'date' && rawX ? new Date(rawX).getTime() : index
107
+
108
+ const seriesValues = Object.fromEntries(
109
+ seriesMeta.value.map((series) => [series.key, Number(item?.[series.key] || 0)])
110
+ )
111
+
112
+ return {
113
+ rawX,
114
+ xLabel,
115
+ fullLabel,
116
+ xValue,
117
+ seriesValues
118
+ }
119
+ })
120
+
121
+ return normalized.sort((left, right) => {
122
+ if (xFieldConfig.value?.type === 'date') {
123
+ return left.xValue - right.xValue
124
+ }
125
+
126
+ return left.xLabel.localeCompare(right.xLabel, undefined, {numeric: true, sensitivity: 'base'})
127
+ })
128
+ })
129
+
130
+ const maxValue = computed(() => {
131
+ if (!chartData.value.length) return 0
132
+
133
+ const values = chartData.value.flatMap((item) => seriesMeta.value.map((series) => item.seriesValues[series.key] || 0))
134
+ return Math.max(...values, 0)
135
+ })
136
+
137
+ const yAxisTicks = computed(() => {
138
+ const steps = 5
139
+ const currentMax = maxValue.value
140
+
141
+ if (currentMax <= 0) {
142
+ return [0, 1, 2, 3, 4, 5]
143
+ }
144
+
145
+ return Array.from({length: steps + 1}, (_, index) => {
146
+ return (currentMax / steps) * index
147
+ })
148
+ })
149
+
150
+ const drawLineChart = () => {
151
+ if (!canvasRef.value || !chartData.value.length || !xField.value) return
152
+
153
+ const canvas = canvasRef.value
154
+ const ctx = canvas.getContext('2d')
155
+ if (!ctx) return
156
+
157
+ const parentWidth = canvas.parentElement?.clientWidth || 640
158
+ const width = parentWidth
159
+ const height = 340
160
+ const padding = {top: 24, right: 24, bottom: 72, left: 56}
161
+
162
+ canvas.width = width
163
+ canvas.height = height
164
+
165
+ const chartWidth = width - padding.left - padding.right
166
+ const chartHeight = height - padding.top - padding.bottom
167
+
168
+ ctx.clearRect(0, 0, width, height)
169
+ ctx.textBaseline = 'middle'
170
+
171
+ yAxisTicks.value.forEach((tick) => {
172
+ const y = padding.top + chartHeight - ((tick / Math.max(maxValue.value, 5)) * chartHeight)
173
+
174
+ ctx.beginPath()
175
+ ctx.moveTo(padding.left, y)
176
+ ctx.lineTo(padding.left + chartWidth, y)
177
+ ctx.strokeStyle = '#e5e7eb'
178
+ ctx.lineWidth = 1
179
+ ctx.stroke()
180
+
181
+ ctx.fillStyle = '#6b7280'
182
+ ctx.font = '11px sans-serif'
183
+ ctx.textAlign = 'right'
184
+ ctx.fillText(Number.isInteger(tick) ? String(tick) : tick.toFixed(1), padding.left - 8, y)
185
+ })
186
+
187
+ ctx.beginPath()
188
+ ctx.moveTo(padding.left, padding.top)
189
+ ctx.lineTo(padding.left, padding.top + chartHeight)
190
+ ctx.lineTo(padding.left + chartWidth, padding.top + chartHeight)
191
+ ctx.strokeStyle = '#9ca3af'
192
+ ctx.lineWidth = 1.25
193
+ ctx.stroke()
194
+
195
+ const getPointX = (index: number) => {
196
+ if (chartData.value.length === 1) return padding.left + (chartWidth / 2)
197
+ return padding.left + ((chartWidth / (chartData.value.length - 1)) * index)
198
+ }
199
+
200
+ const getPointY = (value: number) => {
201
+ const safeMax = Math.max(maxValue.value, 5)
202
+ return padding.top + chartHeight - ((value / safeMax) * chartHeight)
203
+ }
204
+
205
+ seriesMeta.value.forEach((series) => {
206
+ ctx.beginPath()
207
+
208
+ chartData.value.forEach((item, index) => {
209
+ const x = getPointX(index)
210
+ const y = getPointY(item.seriesValues[series.key] || 0)
211
+
212
+ if (index === 0) {
213
+ ctx.moveTo(x, y)
214
+ } else {
215
+ ctx.lineTo(x, y)
216
+ }
217
+ })
218
+
219
+ ctx.strokeStyle = series.color
220
+ ctx.lineWidth = 2.5
221
+ ctx.stroke()
222
+
223
+ chartData.value.forEach((item, index) => {
224
+ const x = getPointX(index)
225
+ const y = getPointY(item.seriesValues[series.key] || 0)
226
+ const valueLabel = String(item.seriesValues[series.key] || 0)
227
+
228
+ ctx.beginPath()
229
+ ctx.arc(x, y, 4, 0, 2 * Math.PI)
230
+ ctx.fillStyle = '#ffffff'
231
+ ctx.fill()
232
+ ctx.lineWidth = 2
233
+ ctx.strokeStyle = series.color
234
+ ctx.stroke()
235
+
236
+ ctx.fillStyle = series.color
237
+ ctx.font = '11px sans-serif'
238
+ ctx.textAlign = 'center'
239
+ ctx.fillText(valueLabel, x, y - 12)
240
+ })
241
+ })
242
+
243
+ chartData.value.forEach((item, index) => {
244
+ const x = getPointX(index)
245
+
246
+ ctx.beginPath()
247
+ ctx.moveTo(x, padding.top + chartHeight)
248
+ ctx.lineTo(x, padding.top + chartHeight + 6)
249
+ ctx.strokeStyle = '#9ca3af'
250
+ ctx.lineWidth = 1
251
+ ctx.stroke()
252
+
253
+ ctx.save()
254
+ ctx.translate(x, padding.top + chartHeight + 18)
255
+ ctx.rotate(-Math.PI / 4)
256
+ ctx.fillStyle = '#4b5563'
257
+ ctx.font = '12px sans-serif'
258
+ ctx.textAlign = 'right'
259
+ const maxLabelLength = 36
260
+ const label = item.fullLabel.length > maxLabelLength
261
+ ? `${item.fullLabel.slice(0, maxLabelLength)}...`
262
+ : item.fullLabel
263
+ ctx.fillText(label, 0, 0)
264
+ ctx.restore()
265
+ })
266
+ }
267
+
268
+ watch(chartData, () => {
269
+ setTimeout(drawLineChart, 100)
270
+ }, {deep: true})
271
+
272
+ onMounted(() => {
273
+ drawLineChart()
274
+ window.addEventListener('resize', drawLineChart)
275
+ })
276
+
277
+ onUnmounted(() => {
278
+ window.removeEventListener('resize', drawLineChart)
279
+ })
280
+ </script>
281
+
282
+ <template>
283
+ <div class="line-chart-container">
284
+ <div v-if="!data || data.length === 0" class="empty-state">
285
+ <v-icon size="64" color="grey-lighten-1">mdi-chart-line</v-icon>
286
+ <p class="text-grey-lighten-1 mt-4">No hay datos para mostrar</p>
287
+ </div>
288
+
289
+ <div v-else-if="!xField" class="empty-state">
290
+ <v-icon size="64" color="grey-lighten-1">mdi-axis-x-arrow</v-icon>
291
+ <p class="text-grey-lighten-1 mt-4">Se necesita al menos un campo categórico o de fecha para el eje X</p>
292
+ </div>
293
+
294
+ <template v-else>
295
+ <div class="chart-wrapper">
296
+ <canvas ref="canvasRef"></canvas>
297
+ </div>
298
+
299
+ </template>
300
+ </div>
301
+ </template>
302
+
303
+ <style scoped>
304
+ .line-chart-container {
305
+ width: 100%;
306
+ min-height: 360px;
307
+ }
308
+
309
+ .chart-wrapper {
310
+ position: relative;
311
+ width: 100%;
312
+ min-height: 340px;
313
+ overflow-x: auto;
314
+ }
315
+
316
+ .empty-state {
317
+ display: flex;
318
+ flex-direction: column;
319
+ align-items: center;
320
+ justify-content: center;
321
+ min-height: 300px;
322
+ text-align: center;
323
+ }
324
+ </style>
@@ -173,13 +173,23 @@ const drawPieChart = () => {
173
173
  ctx.fillStyle = segment.color
174
174
  ctx.fill()
175
175
 
176
+ const textX = lineEndX + (isRightSide ? 6 : -6)
177
+
176
178
  ctx.fillStyle = '#374151'
177
179
  ctx.font = '500 12px sans-serif'
178
180
  ctx.textAlign = isRightSide ? 'left' : 'right'
179
181
  ctx.fillText(
180
182
  segment.shortLabel,
181
- lineEndX + (isRightSide ? 6 : -6),
182
- lineEndY
183
+ textX,
184
+ lineEndY - 7
185
+ )
186
+
187
+ ctx.fillStyle = '#6b7280'
188
+ ctx.font = '11px sans-serif'
189
+ ctx.fillText(
190
+ `${segment.value}`,
191
+ textX,
192
+ lineEndY + 8
183
193
  )
184
194
 
185
195
  currentAngle = endAngle
@@ -29,52 +29,57 @@ const getPercentage = (count: number) => {
29
29
  </script>
30
30
 
31
31
  <template>
32
- <div>
33
- <v-data-table
34
- :headers="headers"
35
- :items="data"
36
- density="compact"
37
- :items-per-page="-1"
38
- hide-default-footer
39
- >
40
- <template v-slot:bottom></template>
41
-
42
- <!-- Slot para personalizar la visualización de cada campo -->
43
- <template
44
- v-for="field in fields"
45
- :key="field.name"
46
- v-slot:[`item.${field.name}`]="{ value }"
32
+ <div class="table-render">
33
+ <div class="table-render__scroll">
34
+ <v-data-table
35
+ :headers="headers"
36
+ :items="data"
37
+ density="compact"
38
+ :items-per-page="-1"
39
+ hide-default-footer
40
+ height="100%"
41
+ :fixed-header="true"
42
+ class="table-render__table"
47
43
  >
48
- <template v-if="['ref','object'].includes(field.type) && field.refDisplay">
49
- {{value ? value[field.refDisplay] : '-' }}
50
- </template>
44
+ <template v-slot:bottom></template>
51
45
 
52
- <template v-else-if="field.type === 'date'">
53
- {{ formatDateByUnit(value, dateFormat) }}
54
- </template>
46
+ <!-- Slot para personalizar la visualización de cada campo -->
47
+ <template
48
+ v-for="field in fields"
49
+ :key="field.name"
50
+ v-slot:[`item.${field.name}`]="{ value }"
51
+ >
52
+ <template v-if="['ref','object'].includes(field.type) && field.refDisplay">
53
+ {{value ? value[field.refDisplay] : '-' }}
54
+ </template>
55
55
 
56
- <template v-else-if="field.type === 'number'">
57
- {{ value.toLocaleString('es-ar') }}
58
- </template>
56
+ <template v-else-if="field.type === 'date'">
57
+ {{ formatDateByUnit(value, dateFormat) }}
58
+ </template>
59
+
60
+ <template v-else-if="field.type === 'number'">
61
+ {{ value.toLocaleString('es-ar') }}
62
+ </template>
63
+
64
+ <template v-else>
65
+ {{ value }}
66
+ </template>
59
67
 
60
- <template v-else>
61
- {{ value }}
62
68
  </template>
63
69
 
64
- </template>
65
-
66
- <!-- Formato especial para el count con porcentaje -->
67
- <template v-slot:item.count="{ item }">
68
- <div class="d-flex align-center justify-end ga-2">
69
- <v-chip color="primary" size="small" variant="flat">
70
- {{ item.count }}
71
- </v-chip>
72
- <span class="text-caption text-grey text-left percentage-text">
73
- ({{ getPercentage(item.count) }}%)
74
- </span>
75
- </div>
76
- </template>
77
- </v-data-table>
70
+ <!-- Formato especial para el count con porcentaje -->
71
+ <template v-slot:item.count="{ item }">
72
+ <div class="d-flex align-center justify-end ga-2">
73
+ <v-chip color="primary" size="small" variant="flat">
74
+ {{ item.count }}
75
+ </v-chip>
76
+ <span class="text-caption text-grey text-left percentage-text">
77
+ ({{ getPercentage(item.count) }}%)
78
+ </span>
79
+ </div>
80
+ </template>
81
+ </v-data-table>
82
+ </div>
78
83
 
79
84
  <!-- Fila de totales -->
80
85
  <v-divider class="my-2"></v-divider>
@@ -89,6 +94,23 @@ const getPercentage = (count: number) => {
89
94
  </template>
90
95
 
91
96
  <style scoped>
97
+ .table-render {
98
+ height: 100%;
99
+ display: flex;
100
+ flex-direction: column;
101
+ min-height: 0;
102
+ }
103
+
104
+ .table-render__scroll {
105
+ flex: 1;
106
+ min-height: 0;
107
+ overflow: hidden;
108
+ }
109
+
110
+ .table-render__table {
111
+ height: 100%;
112
+ }
113
+
92
114
  .percentage-text {
93
115
  display: inline-block;
94
116
  min-width: 45px;