@adminforth/dashboard 1.0.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/.woodpecker/buildRelease.sh +13 -0
- package/.woodpecker/buildSlackNotify.sh +46 -0
- package/.woodpecker/release.yml +57 -0
- package/README.md +59 -0
- package/custom/api/dashboardApi.ts +213 -0
- package/custom/composables/useElementSize.ts +41 -0
- package/custom/model/dashboard.types.ts +73 -0
- package/custom/package.json +9 -0
- package/custom/pnpm-lock.yaml +24 -0
- package/custom/queries/useDashboardConfig.ts +51 -0
- package/custom/queries/useWidgetData.ts +51 -0
- package/custom/runtime/DashboardGroup.vue +185 -0
- package/custom/runtime/DashboardPage.vue +122 -0
- package/custom/runtime/DashboardRuntime.vue +435 -0
- package/custom/runtime/WidgetRenderer.vue +60 -0
- package/custom/runtime/WidgetShell.vue +152 -0
- package/custom/skills/adminforth-dashboard/SKILL.md +125 -0
- package/custom/widgets/chart/ChartWidget.vue +188 -0
- package/custom/widgets/chart/bar/BarChart.vue +167 -0
- package/custom/widgets/chart/chart.types.ts +34 -0
- package/custom/widgets/chart/chart.utils.ts +54 -0
- package/custom/widgets/chart/funnel/FunnelChart.vue +197 -0
- package/custom/widgets/chart/histogram/HistogramChart.vue +21 -0
- package/custom/widgets/chart/line/LineChart.vue +175 -0
- package/custom/widgets/chart/pie/PieChart.vue +161 -0
- package/custom/widgets/chart/stacked-bar/StackedBarChart.vue +256 -0
- package/custom/widgets/gauge-card/GaugeCardWidget.vue +107 -0
- package/custom/widgets/kpi-card/KpiCardWidget.vue +73 -0
- package/custom/widgets/pivot-table/PivotTableWidget.vue +122 -0
- package/custom/widgets/registry.ts +51 -0
- package/custom/widgets/table/TableWidget.vue +110 -0
- package/dist/custom/api/dashboardApi.d.ts +32 -0
- package/dist/custom/api/dashboardApi.js +179 -0
- package/dist/custom/api/dashboardApi.ts +213 -0
- package/dist/custom/composables/useElementSize.d.ts +8 -0
- package/dist/custom/composables/useElementSize.js +30 -0
- package/dist/custom/composables/useElementSize.ts +41 -0
- package/dist/custom/model/dashboard.types.d.ts +45 -0
- package/dist/custom/model/dashboard.types.js +14 -0
- package/dist/custom/model/dashboard.types.ts +73 -0
- package/dist/custom/package.json +9 -0
- package/dist/custom/pnpm-lock.yaml +24 -0
- package/dist/custom/queries/useDashboardConfig.d.ts +112 -0
- package/dist/custom/queries/useDashboardConfig.js +57 -0
- package/dist/custom/queries/useDashboardConfig.ts +51 -0
- package/dist/custom/queries/useWidgetData.d.ts +90 -0
- package/dist/custom/queries/useWidgetData.js +57 -0
- package/dist/custom/queries/useWidgetData.ts +51 -0
- package/dist/custom/runtime/DashboardGroup.vue +185 -0
- package/dist/custom/runtime/DashboardPage.vue +122 -0
- package/dist/custom/runtime/DashboardRuntime.vue +435 -0
- package/dist/custom/runtime/WidgetRenderer.vue +60 -0
- package/dist/custom/runtime/WidgetShell.vue +152 -0
- package/dist/custom/skills/adminforth-dashboard/SKILL.md +125 -0
- package/dist/custom/widgets/chart/ChartWidget.vue +188 -0
- package/dist/custom/widgets/chart/bar/BarChart.vue +167 -0
- package/dist/custom/widgets/chart/chart.types.d.ts +25 -0
- package/dist/custom/widgets/chart/chart.types.js +2 -0
- package/dist/custom/widgets/chart/chart.types.ts +34 -0
- package/dist/custom/widgets/chart/chart.utils.d.ts +5 -0
- package/dist/custom/widgets/chart/chart.utils.js +52 -0
- package/dist/custom/widgets/chart/chart.utils.ts +54 -0
- package/dist/custom/widgets/chart/funnel/FunnelChart.vue +197 -0
- package/dist/custom/widgets/chart/histogram/HistogramChart.vue +21 -0
- package/dist/custom/widgets/chart/line/LineChart.vue +175 -0
- package/dist/custom/widgets/chart/pie/PieChart.vue +161 -0
- package/dist/custom/widgets/chart/stacked-bar/StackedBarChart.vue +256 -0
- package/dist/custom/widgets/gauge-card/GaugeCardWidget.vue +107 -0
- package/dist/custom/widgets/kpi-card/KpiCardWidget.vue +73 -0
- package/dist/custom/widgets/pivot-table/PivotTableWidget.vue +122 -0
- package/dist/custom/widgets/registry.d.ts +11 -0
- package/dist/custom/widgets/registry.js +47 -0
- package/dist/custom/widgets/registry.ts +51 -0
- package/dist/custom/widgets/table/TableWidget.vue +110 -0
- package/dist/endpoint/dashboard.d.ts +7 -0
- package/dist/endpoint/dashboard.js +29 -0
- package/dist/endpoint/groups.d.ts +30 -0
- package/dist/endpoint/groups.js +131 -0
- package/dist/endpoint/widgets.d.ts +15 -0
- package/dist/endpoint/widgets.js +182 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +124 -0
- package/dist/schema/api.d.ts +1205 -0
- package/dist/schema/api.js +84 -0
- package/dist/schema/widget.d.ts +514 -0
- package/dist/schema/widget.js +133 -0
- package/dist/services/dashboardConfigService.d.ts +35 -0
- package/dist/services/dashboardConfigService.js +79 -0
- package/dist/services/widgetConfigValidator.d.ts +8 -0
- package/dist/services/widgetConfigValidator.js +65 -0
- package/dist/services/widgetDataService.d.ts +20 -0
- package/dist/services/widgetDataService.js +32 -0
- package/dist/types.d.ts +8 -0
- package/dist/types.js +1 -0
- package/endpoint/dashboard.ts +32 -0
- package/endpoint/groups.ts +213 -0
- package/endpoint/widgets.ts +255 -0
- package/index.ts +141 -0
- package/package.json +64 -0
- package/schema/api.ts +99 -0
- package/schema/widget.ts +159 -0
- package/services/dashboardConfigService.ts +136 -0
- package/services/widgetConfigValidator.ts +93 -0
- package/services/widgetDataService.ts +57 -0
- package/shims-vue.d.ts +5 -0
- package/tsconfig.json +18 -0
- package/types.ts +8 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { useElementSize } from '../../../composables/useElementSize.js'
|
|
4
|
+
import type { ChartWidgetSeriesConfig } from '../chart.types.js'
|
|
5
|
+
import { CHART_COLORS, formatChartAxisLabel, formatChartLabel, formatChartValue, toFiniteNumber } from '../chart.utils.js'
|
|
6
|
+
|
|
7
|
+
const props = withDefaults(defineProps<{
|
|
8
|
+
rows: Record<string, unknown>[]
|
|
9
|
+
xField: string
|
|
10
|
+
series: ChartWidgetSeriesConfig[]
|
|
11
|
+
colors?: string[]
|
|
12
|
+
height?: number
|
|
13
|
+
}>(), {
|
|
14
|
+
height: 280,
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const { el: rootEl, width: rootWidth } = useElementSize<HTMLDivElement>()
|
|
18
|
+
const { el: svgEl, width: svgWidth, height: svgHeight } = useElementSize<HTMLDivElement>()
|
|
19
|
+
|
|
20
|
+
const padding = {
|
|
21
|
+
top: 24,
|
|
22
|
+
right: 6,
|
|
23
|
+
bottom: 34,
|
|
24
|
+
left: 38,
|
|
25
|
+
}
|
|
26
|
+
const barGap = 10
|
|
27
|
+
const normalizedSeries = computed(() => props.series.map((series, index) => ({
|
|
28
|
+
...series,
|
|
29
|
+
color: series.color || props.colors?.[index] || CHART_COLORS[index % CHART_COLORS.length],
|
|
30
|
+
})))
|
|
31
|
+
const showLegend = computed(() => normalizedSeries.value.length > 0)
|
|
32
|
+
const isCompact = computed(() => rootWidth.value > 0 && rootWidth.value < 420)
|
|
33
|
+
const chartWidth = computed(() => Math.max(svgWidth.value, 1))
|
|
34
|
+
const chartHeight = computed(() => {
|
|
35
|
+
if (svgHeight.value > 0) {
|
|
36
|
+
return Math.max(svgHeight.value, 1)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return Math.max(props.height - (showLegend.value ? 28 : 0), 96)
|
|
40
|
+
})
|
|
41
|
+
const innerWidth = computed(() => Math.max(chartWidth.value - padding.left - padding.right, 1))
|
|
42
|
+
const innerHeight = computed(() => Math.max(chartHeight.value - padding.top - padding.bottom, 1))
|
|
43
|
+
const groupedRows = computed(() => {
|
|
44
|
+
const grouped = new Map<string, Record<string, unknown>>()
|
|
45
|
+
|
|
46
|
+
for (const row of props.rows) {
|
|
47
|
+
const label = formatChartLabel(row[props.xField])
|
|
48
|
+
const item = grouped.get(label) ?? { [props.xField]: label }
|
|
49
|
+
|
|
50
|
+
for (const series of normalizedSeries.value) {
|
|
51
|
+
item[series.name] = toFiniteNumber(item[series.name])
|
|
52
|
+
+ getSeriesContribution(row[series.field], series.name)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
grouped.set(label, item)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return Array.from(grouped.values())
|
|
59
|
+
})
|
|
60
|
+
const barWidth = computed(() => {
|
|
61
|
+
const count = Math.max(groupedRows.value.length, 1)
|
|
62
|
+
return Math.max(Math.min((innerWidth.value - barGap * (count - 1)) / count, 80), 4)
|
|
63
|
+
})
|
|
64
|
+
const totalChartWidth = computed(() => {
|
|
65
|
+
const count = Math.max(groupedRows.value.length, 1)
|
|
66
|
+
return count * barWidth.value + (count - 1) * barGap
|
|
67
|
+
})
|
|
68
|
+
const chartStartX = computed(() => padding.left + Math.max((innerWidth.value - totalChartWidth.value) / 2, 0))
|
|
69
|
+
const totals = computed(() => groupedRows.value.map((row) => normalizedSeries.value.reduce(
|
|
70
|
+
(sum, series) => sum + toFiniteNumber(row[series.name]),
|
|
71
|
+
0,
|
|
72
|
+
)))
|
|
73
|
+
const maxTotal = computed(() => Math.max(...totals.value, 1))
|
|
74
|
+
|
|
75
|
+
const bars = computed(() => groupedRows.value.map((row, rowIndex) => {
|
|
76
|
+
let y = padding.top + innerHeight.value
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
label: String(row[props.xField]),
|
|
80
|
+
axisLabel: formatChartAxisLabel(row[props.xField]),
|
|
81
|
+
x: chartStartX.value + rowIndex * (barWidth.value + barGap),
|
|
82
|
+
total: totals.value[rowIndex],
|
|
83
|
+
segments: normalizedSeries.value.map((series) => {
|
|
84
|
+
const value = toFiniteNumber(row[series.name])
|
|
85
|
+
const height = (value / maxTotal.value) * innerHeight.value
|
|
86
|
+
y -= height
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
id: `${rowIndex}-${series.name}`,
|
|
90
|
+
name: series.name,
|
|
91
|
+
value,
|
|
92
|
+
color: series.color,
|
|
93
|
+
y,
|
|
94
|
+
height,
|
|
95
|
+
}
|
|
96
|
+
}),
|
|
97
|
+
}
|
|
98
|
+
}))
|
|
99
|
+
|
|
100
|
+
const visibleLabelIndexes = computed(() => {
|
|
101
|
+
const count = bars.value.length
|
|
102
|
+
const approxLabelWidth = 52
|
|
103
|
+
const maxLabels = Math.max(2, Math.floor(innerWidth.value / approxLabelWidth))
|
|
104
|
+
|
|
105
|
+
if (count <= maxLabels || barWidth.value >= 44) {
|
|
106
|
+
return new Set(bars.value.map((_, index) => index))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const indexes = new Set<number>()
|
|
110
|
+
const step = (count - 1) / (maxLabels - 1)
|
|
111
|
+
|
|
112
|
+
for (let index = 0; index < maxLabels; index += 1) {
|
|
113
|
+
indexes.add(Math.round(index * step))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return indexes
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const yTicks = computed(() => [0, 0.5, 1].map((ratio) => ({
|
|
120
|
+
value: maxTotal.value * (1 - ratio),
|
|
121
|
+
y: padding.top + innerHeight.value * ratio,
|
|
122
|
+
})))
|
|
123
|
+
|
|
124
|
+
function getSeriesContribution(value: unknown, seriesName: string) {
|
|
125
|
+
if (typeof value === 'boolean') {
|
|
126
|
+
return value === getBooleanSeriesValue(seriesName) ? 1 : 0
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (typeof value === 'string' && ['true', 'false'].includes(value.toLowerCase())) {
|
|
130
|
+
return (value.toLowerCase() === 'true') === getBooleanSeriesValue(seriesName) ? 1 : 0
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return toFiniteNumber(value)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function getBooleanSeriesValue(seriesName: string) {
|
|
137
|
+
const normalizedName = seriesName.toLowerCase()
|
|
138
|
+
|
|
139
|
+
if (normalizedName.includes('unlisted') || normalizedName.includes('false')) {
|
|
140
|
+
return false
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return true
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function getBarTooltip(bar: { label: string, total: number, segments: Array<{ name: string, value: number }> }) {
|
|
147
|
+
const percentFormatter = new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 })
|
|
148
|
+
|
|
149
|
+
const segmentLines = bar.segments.map((segment) => {
|
|
150
|
+
const share = bar.total > 0
|
|
151
|
+
? (segment.value / bar.total) * 100
|
|
152
|
+
: 0
|
|
153
|
+
|
|
154
|
+
return `${segment.name}: ${formatChartValue(segment.value)} (${percentFormatter.format(share)}%)`
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
return [
|
|
158
|
+
`${bar.label}`,
|
|
159
|
+
`Total: ${formatChartValue(bar.total)}`,
|
|
160
|
+
...segmentLines,
|
|
161
|
+
].join('\n')
|
|
162
|
+
}
|
|
163
|
+
</script>
|
|
164
|
+
|
|
165
|
+
<template>
|
|
166
|
+
<div
|
|
167
|
+
ref="rootEl"
|
|
168
|
+
class="grid h-full min-h-0 w-full grid-rows-[auto_minmax(0,1fr)] gap-3 overflow-hidden"
|
|
169
|
+
>
|
|
170
|
+
<div
|
|
171
|
+
v-if="showLegend"
|
|
172
|
+
class="flex flex-wrap items-center gap-3 text-xs text-lightListTableText dark:text-darkListTableText"
|
|
173
|
+
:class="isCompact ? 'justify-start' : 'justify-end'"
|
|
174
|
+
>
|
|
175
|
+
<div
|
|
176
|
+
v-for="series in normalizedSeries"
|
|
177
|
+
:key="series.name"
|
|
178
|
+
class="flex items-center gap-1.5"
|
|
179
|
+
>
|
|
180
|
+
<span
|
|
181
|
+
class="h-2.5 w-2.5 rounded-full"
|
|
182
|
+
:style="{ backgroundColor: series.color }"
|
|
183
|
+
/>
|
|
184
|
+
<span>{{ series.name }}</span>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
<div
|
|
189
|
+
ref="svgEl"
|
|
190
|
+
class="min-h-0 overflow-hidden"
|
|
191
|
+
>
|
|
192
|
+
<svg
|
|
193
|
+
v-if="chartWidth > 0 && chartHeight > 0"
|
|
194
|
+
class="block h-full w-full"
|
|
195
|
+
:viewBox="`0 0 ${chartWidth} ${chartHeight}`"
|
|
196
|
+
role="img"
|
|
197
|
+
:aria-label="xField"
|
|
198
|
+
>
|
|
199
|
+
<g class="text-lightListTableText dark:text-darkListTableText">
|
|
200
|
+
<line
|
|
201
|
+
v-for="tick in yTicks"
|
|
202
|
+
:key="tick.y"
|
|
203
|
+
:x1="padding.left"
|
|
204
|
+
:x2="chartWidth - padding.right"
|
|
205
|
+
:y1="tick.y"
|
|
206
|
+
:y2="tick.y"
|
|
207
|
+
stroke="currentColor"
|
|
208
|
+
stroke-opacity="0.14"
|
|
209
|
+
/>
|
|
210
|
+
<text
|
|
211
|
+
v-for="tick in yTicks"
|
|
212
|
+
:key="`label-${tick.y}`"
|
|
213
|
+
:x="padding.left - 8"
|
|
214
|
+
:y="tick.y + 4"
|
|
215
|
+
fill="currentColor"
|
|
216
|
+
font-size="11"
|
|
217
|
+
text-anchor="end"
|
|
218
|
+
>
|
|
219
|
+
{{ formatChartValue(tick.value) }}
|
|
220
|
+
</text>
|
|
221
|
+
</g>
|
|
222
|
+
|
|
223
|
+
<g
|
|
224
|
+
v-for="(bar, barIndex) in bars"
|
|
225
|
+
:key="bar.label"
|
|
226
|
+
>
|
|
227
|
+
<rect
|
|
228
|
+
v-for="segment in bar.segments"
|
|
229
|
+
:key="segment.id"
|
|
230
|
+
v-show="segment.height > 0"
|
|
231
|
+
:x="bar.x"
|
|
232
|
+
:y="segment.y"
|
|
233
|
+
:width="barWidth"
|
|
234
|
+
:height="segment.height"
|
|
235
|
+
:fill="segment.color"
|
|
236
|
+
rx="3"
|
|
237
|
+
>
|
|
238
|
+
<title>{{ getBarTooltip(bar) }}</title>
|
|
239
|
+
</rect>
|
|
240
|
+
|
|
241
|
+
<text
|
|
242
|
+
v-if="visibleLabelIndexes.has(barIndex)"
|
|
243
|
+
:x="bar.x + barWidth / 2"
|
|
244
|
+
:y="padding.top + innerHeight + 24"
|
|
245
|
+
fill="currentColor"
|
|
246
|
+
font-size="11"
|
|
247
|
+
text-anchor="middle"
|
|
248
|
+
class="text-lightListTableText dark:text-darkListTableText"
|
|
249
|
+
>
|
|
250
|
+
{{ bar.axisLabel }}
|
|
251
|
+
</text>
|
|
252
|
+
</g>
|
|
253
|
+
</svg>
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
</template>
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, watch } from 'vue'
|
|
3
|
+
import { useWidgetData } from '../../queries/useWidgetData.js'
|
|
4
|
+
import type { DashboardWidgetConfig, DashboardWidgetTableData } from '../../model/dashboard.types.js'
|
|
5
|
+
import { CHART_COLORS, formatChartValue, toFiniteNumber } from '../chart/chart.utils.js'
|
|
6
|
+
|
|
7
|
+
const props = defineProps<{
|
|
8
|
+
dashboardSlug: string
|
|
9
|
+
widget: DashboardWidgetConfig
|
|
10
|
+
}>()
|
|
11
|
+
|
|
12
|
+
const dashboardSlugRef = computed(() => props.dashboardSlug)
|
|
13
|
+
const widgetIdRef = computed(() => props.widget.id)
|
|
14
|
+
const {
|
|
15
|
+
data,
|
|
16
|
+
isLoading,
|
|
17
|
+
error,
|
|
18
|
+
refetch,
|
|
19
|
+
} = useWidgetData(dashboardSlugRef, widgetIdRef)
|
|
20
|
+
|
|
21
|
+
watch(
|
|
22
|
+
() => props.widget,
|
|
23
|
+
() => {
|
|
24
|
+
void refetch()
|
|
25
|
+
},
|
|
26
|
+
{ deep: true },
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
const gaugeConfig = computed(() => props.widget.gauge_card as {
|
|
30
|
+
value_field?: string
|
|
31
|
+
min?: number
|
|
32
|
+
max?: number
|
|
33
|
+
suffix?: string
|
|
34
|
+
color?: string
|
|
35
|
+
} | undefined)
|
|
36
|
+
const widgetData = computed(() => data.value?.data as DashboardWidgetTableData | null)
|
|
37
|
+
const columns = computed(() => widgetData.value?.columns ?? [])
|
|
38
|
+
const firstRow = computed(() => widgetData.value?.rows[0] ?? {})
|
|
39
|
+
const valueField = computed(() => gaugeConfig.value?.value_field || columns.value[0])
|
|
40
|
+
const minValue = computed(() => gaugeConfig.value?.min ?? 0)
|
|
41
|
+
const maxValue = computed(() => gaugeConfig.value?.max ?? 100)
|
|
42
|
+
const value = computed(() => toFiniteNumber(firstRow.value[valueField.value]))
|
|
43
|
+
const progress = computed(() => {
|
|
44
|
+
const range = maxValue.value - minValue.value
|
|
45
|
+
return range > 0 ? Math.min(Math.max((value.value - minValue.value) / range, 0), 1) : 0
|
|
46
|
+
})
|
|
47
|
+
const radius = 72
|
|
48
|
+
const circumference = Math.PI * radius
|
|
49
|
+
const strokeDashoffset = computed(() => circumference * (1 - progress.value))
|
|
50
|
+
const gaugeColor = computed(() => gaugeConfig.value?.color || CHART_COLORS[0])
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<template>
|
|
54
|
+
<div class="mt-3 rounded-lg border border-lightListBorder bg-lightTableBackground p-4 dark:border-darkListBorder dark:bg-darkTableBackground">
|
|
55
|
+
<div
|
|
56
|
+
v-if="isLoading"
|
|
57
|
+
class="text-sm text-lightListTableText dark:text-darkListTableText"
|
|
58
|
+
>
|
|
59
|
+
Loading...
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<div
|
|
63
|
+
v-else-if="error"
|
|
64
|
+
class="text-sm text-lightInputErrorColor"
|
|
65
|
+
>
|
|
66
|
+
Failed to load gauge data
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<div
|
|
70
|
+
v-else
|
|
71
|
+
class="flex flex-col items-center gap-2"
|
|
72
|
+
>
|
|
73
|
+
<svg
|
|
74
|
+
width="180"
|
|
75
|
+
height="104"
|
|
76
|
+
viewBox="0 0 180 104"
|
|
77
|
+
role="img"
|
|
78
|
+
:aria-label="valueField"
|
|
79
|
+
>
|
|
80
|
+
<path
|
|
81
|
+
d="M18 90a72 72 0 0 1 144 0"
|
|
82
|
+
class="text-lightListBorder dark:text-darkListBorder"
|
|
83
|
+
fill="none"
|
|
84
|
+
stroke="currentColor"
|
|
85
|
+
stroke-linecap="round"
|
|
86
|
+
stroke-width="18"
|
|
87
|
+
/>
|
|
88
|
+
<path
|
|
89
|
+
d="M18 90a72 72 0 0 1 144 0"
|
|
90
|
+
fill="none"
|
|
91
|
+
:stroke="gaugeColor"
|
|
92
|
+
stroke-linecap="round"
|
|
93
|
+
stroke-width="18"
|
|
94
|
+
:stroke-dasharray="circumference"
|
|
95
|
+
:stroke-dashoffset="strokeDashoffset"
|
|
96
|
+
/>
|
|
97
|
+
</svg>
|
|
98
|
+
|
|
99
|
+
<div class="text-3xl font-bold text-lightNavbarText dark:text-darkNavbarText">
|
|
100
|
+
{{ formatChartValue(value) }}{{ gaugeConfig?.suffix ?? '' }}
|
|
101
|
+
</div>
|
|
102
|
+
<div class="text-sm text-lightListTableText dark:text-darkListTableText">
|
|
103
|
+
{{ formatChartValue(minValue) }} - {{ formatChartValue(maxValue) }}
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
</template>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, watch } from 'vue'
|
|
3
|
+
import { useWidgetData } from '../../queries/useWidgetData.js'
|
|
4
|
+
import type { DashboardWidgetConfig, DashboardWidgetTableData } from '../../model/dashboard.types.js'
|
|
5
|
+
import { formatChartValue, toFiniteNumber } from '../chart/chart.utils.js'
|
|
6
|
+
|
|
7
|
+
const props = defineProps<{
|
|
8
|
+
dashboardSlug: string
|
|
9
|
+
widget: DashboardWidgetConfig
|
|
10
|
+
}>()
|
|
11
|
+
|
|
12
|
+
const dashboardSlugRef = computed(() => props.dashboardSlug)
|
|
13
|
+
const widgetIdRef = computed(() => props.widget.id)
|
|
14
|
+
const {
|
|
15
|
+
data,
|
|
16
|
+
isLoading,
|
|
17
|
+
error,
|
|
18
|
+
refetch,
|
|
19
|
+
} = useWidgetData(dashboardSlugRef, widgetIdRef)
|
|
20
|
+
|
|
21
|
+
watch(
|
|
22
|
+
() => props.widget,
|
|
23
|
+
() => {
|
|
24
|
+
void refetch()
|
|
25
|
+
},
|
|
26
|
+
{ deep: true },
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
const kpiConfig = computed(() => props.widget.kpi_card as {
|
|
30
|
+
value_field?: string
|
|
31
|
+
label_field?: string
|
|
32
|
+
prefix?: string
|
|
33
|
+
suffix?: string
|
|
34
|
+
} | undefined)
|
|
35
|
+
const widgetData = computed(() => data.value?.data as DashboardWidgetTableData | null)
|
|
36
|
+
const columns = computed(() => widgetData.value?.columns ?? [])
|
|
37
|
+
const firstRow = computed(() => widgetData.value?.rows[0] ?? {})
|
|
38
|
+
const valueField = computed(() => kpiConfig.value?.value_field || columns.value[0])
|
|
39
|
+
const labelField = computed(() => kpiConfig.value?.label_field)
|
|
40
|
+
const value = computed(() => toFiniteNumber(firstRow.value[valueField.value]))
|
|
41
|
+
const label = computed(() => labelField.value ? String(firstRow.value[labelField.value]) : props.widget.label)
|
|
42
|
+
const formattedValue = computed(() => `${kpiConfig.value?.prefix ?? ''}${formatChartValue(value.value)}${kpiConfig.value?.suffix ?? ''}`)
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<template>
|
|
46
|
+
<div class="mt-3 rounded-lg border border-lightListBorder bg-lightTableBackground p-4 dark:border-darkListBorder dark:bg-darkTableBackground">
|
|
47
|
+
<div
|
|
48
|
+
v-if="isLoading"
|
|
49
|
+
class="text-sm text-lightListTableText dark:text-darkListTableText"
|
|
50
|
+
>
|
|
51
|
+
Loading...
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<div
|
|
55
|
+
v-else-if="error"
|
|
56
|
+
class="text-sm text-lightInputErrorColor"
|
|
57
|
+
>
|
|
58
|
+
Failed to load KPI data
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<div
|
|
62
|
+
v-else
|
|
63
|
+
class="grid gap-1"
|
|
64
|
+
>
|
|
65
|
+
<div class="text-3xl font-bold text-lightNavbarText dark:text-darkNavbarText">
|
|
66
|
+
{{ formattedValue }}
|
|
67
|
+
</div>
|
|
68
|
+
<div class="text-sm text-lightListTableText dark:text-darkListTableText">
|
|
69
|
+
{{ label }}
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
</template>
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, watch } from 'vue'
|
|
3
|
+
import { useWidgetData } from '../../queries/useWidgetData.js'
|
|
4
|
+
import type { DashboardWidgetConfig, DashboardWidgetTableData } from '../../model/dashboard.types.js'
|
|
5
|
+
import { formatChartLabel, formatChartValue, toFiniteNumber } from '../chart/chart.utils.js'
|
|
6
|
+
|
|
7
|
+
const props = defineProps<{
|
|
8
|
+
dashboardSlug: string
|
|
9
|
+
widget: DashboardWidgetConfig
|
|
10
|
+
}>()
|
|
11
|
+
|
|
12
|
+
const dashboardSlugRef = computed(() => props.dashboardSlug)
|
|
13
|
+
const widgetIdRef = computed(() => props.widget.id)
|
|
14
|
+
const {
|
|
15
|
+
data,
|
|
16
|
+
isLoading,
|
|
17
|
+
error,
|
|
18
|
+
refetch,
|
|
19
|
+
} = useWidgetData(dashboardSlugRef, widgetIdRef)
|
|
20
|
+
|
|
21
|
+
watch(
|
|
22
|
+
() => props.widget,
|
|
23
|
+
() => {
|
|
24
|
+
void refetch()
|
|
25
|
+
},
|
|
26
|
+
{ deep: true },
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
const pivotConfig = computed(() => props.widget.pivot_table as {
|
|
30
|
+
row_field?: string
|
|
31
|
+
column_field?: string
|
|
32
|
+
value_field?: string
|
|
33
|
+
aggregation?: 'count' | 'sum'
|
|
34
|
+
} | undefined)
|
|
35
|
+
const widgetData = computed(() => data.value?.data as DashboardWidgetTableData | null)
|
|
36
|
+
const rows = computed(() => widgetData.value?.rows ?? [])
|
|
37
|
+
const columns = computed(() => widgetData.value?.columns ?? [])
|
|
38
|
+
const rowField = computed(() => pivotConfig.value?.row_field || columns.value[0])
|
|
39
|
+
const columnField = computed(() => pivotConfig.value?.column_field || columns.value[1])
|
|
40
|
+
const valueField = computed(() => pivotConfig.value?.value_field || columns.value[2])
|
|
41
|
+
const aggregation = computed(() => pivotConfig.value?.aggregation || (valueField.value ? 'sum' : 'count'))
|
|
42
|
+
const pivotColumnLabels = computed(() => Array.from(new Set(rows.value.map((row) => formatChartLabel(row[columnField.value])))))
|
|
43
|
+
const pivotRows = computed(() => {
|
|
44
|
+
const rowMap = new Map<string, Record<string, number | string>>()
|
|
45
|
+
|
|
46
|
+
for (const row of rows.value) {
|
|
47
|
+
const rowLabel = formatChartLabel(row[rowField.value])
|
|
48
|
+
const columnLabel = formatChartLabel(row[columnField.value])
|
|
49
|
+
const item = rowMap.get(rowLabel) ?? { label: rowLabel }
|
|
50
|
+
const currentValue = typeof item[columnLabel] === 'number' ? item[columnLabel] : 0
|
|
51
|
+
item[columnLabel] = currentValue + (aggregation.value === 'count' ? 1 : toFiniteNumber(row[valueField.value]))
|
|
52
|
+
rowMap.set(rowLabel, item)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return Array.from(rowMap.values())
|
|
56
|
+
})
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<template>
|
|
60
|
+
<div class="mt-3 flex h-full min-h-0 flex-col overflow-hidden rounded-lg border border-lightListBorder bg-lightTableBackground dark:border-darkListBorder dark:bg-darkTableBackground">
|
|
61
|
+
<div
|
|
62
|
+
v-if="isLoading"
|
|
63
|
+
class="p-4 text-sm text-lightListTableText dark:text-darkListTableText"
|
|
64
|
+
>
|
|
65
|
+
Loading...
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<div
|
|
69
|
+
v-else-if="error"
|
|
70
|
+
class="p-4 text-sm text-lightInputErrorColor"
|
|
71
|
+
>
|
|
72
|
+
Failed to load pivot data
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<div
|
|
76
|
+
v-else-if="!pivotRows.length"
|
|
77
|
+
class="p-4 text-sm text-lightListTableText dark:text-darkListTableText"
|
|
78
|
+
>
|
|
79
|
+
No data available
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<div
|
|
83
|
+
v-else
|
|
84
|
+
class="min-h-0 flex-1 overflow-auto"
|
|
85
|
+
>
|
|
86
|
+
<table class="min-w-max w-full border-collapse text-left text-sm">
|
|
87
|
+
<thead class="bg-lightTableHeadingBackground text-xs uppercase text-lightTableHeadingText dark:bg-darkTableHeadingBackground dark:text-darkTableHeadingText">
|
|
88
|
+
<tr>
|
|
89
|
+
<th class="px-3 py-2 font-semibold">
|
|
90
|
+
{{ rowField }}
|
|
91
|
+
</th>
|
|
92
|
+
<th
|
|
93
|
+
v-for="column in pivotColumnLabels"
|
|
94
|
+
:key="column"
|
|
95
|
+
class="px-3 py-2 text-right font-semibold"
|
|
96
|
+
>
|
|
97
|
+
{{ column }}
|
|
98
|
+
</th>
|
|
99
|
+
</tr>
|
|
100
|
+
</thead>
|
|
101
|
+
<tbody>
|
|
102
|
+
<tr
|
|
103
|
+
v-for="row in pivotRows"
|
|
104
|
+
:key="String(row.label)"
|
|
105
|
+
class="border-t border-lightListBorder odd:bg-lightTableOddBackground even:bg-lightTableEvenBackground dark:border-darkListBorder odd:dark:bg-darkTableOddBackground even:dark:bg-darkTableEvenBackground"
|
|
106
|
+
>
|
|
107
|
+
<td class="px-3 py-2 font-medium text-lightNavbarText dark:text-darkNavbarText">
|
|
108
|
+
{{ row.label }}
|
|
109
|
+
</td>
|
|
110
|
+
<td
|
|
111
|
+
v-for="column in pivotColumnLabels"
|
|
112
|
+
:key="column"
|
|
113
|
+
class="px-3 py-2 text-right text-lightListTableText dark:text-darkListTableText"
|
|
114
|
+
>
|
|
115
|
+
{{ formatChartValue(typeof row[column] === 'number' ? row[column] : 0) }}
|
|
116
|
+
</td>
|
|
117
|
+
</tr>
|
|
118
|
+
</tbody>
|
|
119
|
+
</table>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
</template>
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Component } from 'vue'
|
|
2
|
+
import type { DashboardWidgetTarget } from '../model/dashboard.types.js'
|
|
3
|
+
import ChartWidget from './chart/ChartWidget.vue'
|
|
4
|
+
import GaugeCardWidget from './gauge-card/GaugeCardWidget.vue'
|
|
5
|
+
import KpiCardWidget from './kpi-card/KpiCardWidget.vue'
|
|
6
|
+
import PivotTableWidget from './pivot-table/PivotTableWidget.vue'
|
|
7
|
+
import TableWidget from './table/TableWidget.vue'
|
|
8
|
+
|
|
9
|
+
export type DashboardWidgetType = DashboardWidgetTarget
|
|
10
|
+
|
|
11
|
+
export type DashboardWidgetRegistration = {
|
|
12
|
+
type: DashboardWidgetType
|
|
13
|
+
label: string
|
|
14
|
+
component?: Component
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const widgetRegistry: DashboardWidgetRegistration[] = [
|
|
18
|
+
{
|
|
19
|
+
type: 'table',
|
|
20
|
+
label: 'Table',
|
|
21
|
+
component: TableWidget,
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
type: 'chart',
|
|
25
|
+
label: 'Chart',
|
|
26
|
+
component: ChartWidget,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
type: 'kpi_card',
|
|
30
|
+
label: 'KPI Card',
|
|
31
|
+
component: KpiCardWidget,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
type: 'pivot_table',
|
|
35
|
+
label: 'Pivot Table',
|
|
36
|
+
component: PivotTableWidget,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
type: 'gauge_card',
|
|
40
|
+
label: 'Gauge Card',
|
|
41
|
+
component: GaugeCardWidget,
|
|
42
|
+
},
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
export function getWidgetRegistration(type: DashboardWidgetType) {
|
|
46
|
+
return widgetRegistry.find((widget) => widget.type === type)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function getWidgetLabel(type: DashboardWidgetType) {
|
|
50
|
+
return getWidgetRegistration(type)?.label || type.replaceAll('_', ' ')
|
|
51
|
+
}
|