@tekkare/romulus 0.1.209 → 0.1.211
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/dist/module.json +1 -1
- package/dist/runtime/components/Dashboard.vue +290 -0
- package/dist/runtime/components/DashboardWidget.vue +381 -0
- package/dist/runtime/components/DashboardWidgetEditor.vue +474 -0
- package/dist/runtime/components/FilterBar.vue +4 -3
- package/dist/runtime/components/LineChart.vue +13 -1
- package/dist/runtime/composables/useRmDashboard.d.ts +42 -0
- package/dist/runtime/composables/useRmDashboard.js +52 -0
- package/dist/runtime/composables/useRmExport.js +7 -1
- package/dist/runtime/utils/chart.d.ts +10 -0
- package/dist/runtime/utils/chart.js +2 -1
- package/dist/runtime/utils/dashboard.d.ts +232 -0
- package/dist/runtime/utils/dashboard.js +167 -0
- package/package.json +5 -3
package/dist/module.json
CHANGED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmDashboard - un tableau de bord a widgets, a la maniere de PostHog : les
|
|
3
|
+
lecteurs ajoutent des widgets tires du catalogue de l'app, les reglent, les
|
|
4
|
+
deplacent et les redimensionnent.
|
|
5
|
+
|
|
6
|
+
Le composant ne porte que l'ecran : la barre d'etat et ses boutons, les
|
|
7
|
+
etats chargement, erreur, vide, la grille (RmDashboardGrid) et le tiroir
|
|
8
|
+
d'edition. L'etat (chargement, sauvegarde) vient de useRmDashboard, que
|
|
9
|
+
l'app branche sur son API et dont elle passe ici les champs. La disposition
|
|
10
|
+
ne bouge qu'en mode edition : en lecture, un glisser sur un graphe ne doit
|
|
11
|
+
pas deplacer la carte.
|
|
12
|
+
|
|
13
|
+
Ce que le paquet ne sait pas :
|
|
14
|
+
- la source de chaque entree (`sourceOf`), que l'app lit ou elle veut
|
|
15
|
+
(Directus dans les apps OIP) ;
|
|
16
|
+
- les cartes de la lecture « Cartes » (slot `#cards`), qui sont celles des
|
|
17
|
+
pages de l'app.
|
|
18
|
+
|
|
19
|
+
L'export de l'ecran part de la donnee de chaque widget charge : la page le
|
|
20
|
+
declare a son cadre avec `exportSheets()`, expose ici.
|
|
21
|
+
-->
|
|
22
|
+
<script setup lang="ts">
|
|
23
|
+
import { computed, reactive, ref } from 'vue'
|
|
24
|
+
import { rmGridBottom } from '../utils/dashboardGrid'
|
|
25
|
+
import {
|
|
26
|
+
RM_WIDGET_MIN,
|
|
27
|
+
rmDashboardExportSheets,
|
|
28
|
+
rmNewWidgetId,
|
|
29
|
+
rmWidgetKind,
|
|
30
|
+
type RmDashboardLang,
|
|
31
|
+
type RmDashboardWidget,
|
|
32
|
+
type RmWidgetContext,
|
|
33
|
+
type RmWidgetData,
|
|
34
|
+
type RmWidgetKind,
|
|
35
|
+
type RmWidgetSource,
|
|
36
|
+
} from '../utils/dashboard'
|
|
37
|
+
|
|
38
|
+
const props = withDefaults(defineProps<{
|
|
39
|
+
/** Les widgets affiches (useRmDashboard, `widgets`). */
|
|
40
|
+
widgets: RmDashboardWidget[]
|
|
41
|
+
catalog: RmWidgetKind<any>[]
|
|
42
|
+
/** Ce que l'ecran donne a ses widgets : ses marques, et ce que l'app y ajoute. */
|
|
43
|
+
context: RmWidgetContext
|
|
44
|
+
/** La source d'une entree du catalogue, citee en pied de carte et reprise par l'export. */
|
|
45
|
+
sourceOf: (kind: RmWidgetKind<any>) => RmWidgetSource
|
|
46
|
+
loading?: boolean
|
|
47
|
+
failed?: boolean
|
|
48
|
+
saving?: boolean
|
|
49
|
+
saveFailed?: boolean
|
|
50
|
+
lang?: RmDashboardLang
|
|
51
|
+
/** Hauteur d'une ligne de grille et ecart entre tuiles, en pixels. */
|
|
52
|
+
rowHeight?: number
|
|
53
|
+
gap?: number
|
|
54
|
+
/** Le corps vide d'un tableau sans widget. */
|
|
55
|
+
emptyTitle?: string
|
|
56
|
+
emptyText?: string
|
|
57
|
+
/** Ce que suivent les marques par defaut dans l'editeur : « Celles du projet ». */
|
|
58
|
+
scopeLabel?: string
|
|
59
|
+
/** Ce que disent les widgets et l'editeur quand le contexte n'a aucune marque. */
|
|
60
|
+
noBrandsText?: string
|
|
61
|
+
}>(), {
|
|
62
|
+
loading: false,
|
|
63
|
+
failed: false,
|
|
64
|
+
saving: false,
|
|
65
|
+
saveFailed: false,
|
|
66
|
+
lang: 'fr',
|
|
67
|
+
rowHeight: 80,
|
|
68
|
+
gap: 16,
|
|
69
|
+
emptyTitle: undefined,
|
|
70
|
+
emptyText: undefined,
|
|
71
|
+
scopeLabel: undefined,
|
|
72
|
+
noBrandsText: undefined,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const emit = defineEmits<{
|
|
76
|
+
/** Les widgets apres un geste (useRmDashboard, `update`). */
|
|
77
|
+
'update:widgets': [widgets: RmDashboardWidget[]]
|
|
78
|
+
/** Recharger apres un echec de chargement. */
|
|
79
|
+
'load': []
|
|
80
|
+
/** Reessayer une sauvegarde echouee. */
|
|
81
|
+
'retrySave': []
|
|
82
|
+
}>()
|
|
83
|
+
|
|
84
|
+
defineSlots<{
|
|
85
|
+
cards(props: { kind: string, items: unknown[], editing: boolean }): unknown
|
|
86
|
+
}>()
|
|
87
|
+
|
|
88
|
+
const T = computed(() => props.lang === 'en'
|
|
89
|
+
? {
|
|
90
|
+
notSaved: 'Not saved.',
|
|
91
|
+
retry: 'Retry',
|
|
92
|
+
saving: 'Saving…',
|
|
93
|
+
editingHint: 'Drag a card to move it, its corner to resize it.',
|
|
94
|
+
add: 'Add a widget',
|
|
95
|
+
done: 'Done',
|
|
96
|
+
editLayout: 'Edit layout',
|
|
97
|
+
loadFailed: 'The dashboard could not be loaded.',
|
|
98
|
+
emptyTitle: 'An empty dashboard',
|
|
99
|
+
emptyText: 'Add charts, tables or key figures from the catalogue.',
|
|
100
|
+
}
|
|
101
|
+
: {
|
|
102
|
+
notSaved: 'Non enregistré.',
|
|
103
|
+
retry: 'Réessayer',
|
|
104
|
+
saving: 'Enregistrement…',
|
|
105
|
+
editingHint: 'Faites glisser une carte pour la déplacer, son coin pour la redimensionner.',
|
|
106
|
+
add: 'Ajouter un widget',
|
|
107
|
+
done: 'Terminer',
|
|
108
|
+
editLayout: 'Modifier la disposition',
|
|
109
|
+
loadFailed: 'Le tableau de bord n\'a pas pu être chargé.',
|
|
110
|
+
emptyTitle: 'Un tableau de bord vide',
|
|
111
|
+
emptyText: 'Ajoutez des graphiques, des tableaux ou des chiffres clés tirés du catalogue.',
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
/** Tete (titre, contexte) et pied (source) de la carte, autour du graphe. */
|
|
115
|
+
const CARD_CHROME = 150
|
|
116
|
+
|
|
117
|
+
const editing = ref(false)
|
|
118
|
+
|
|
119
|
+
const byId = computed(() => Object.fromEntries(props.widgets.map(w => [w.id, w])) as Record<string, RmDashboardWidget>)
|
|
120
|
+
const kindOf = (widget: RmDashboardWidget) => rmWidgetKind(props.catalog, widget.kind)!
|
|
121
|
+
|
|
122
|
+
const layout = computed(() => props.widgets.map((w) => {
|
|
123
|
+
const min = kindOf(w).min ?? RM_WIDGET_MIN
|
|
124
|
+
return { id: w.id, ...w.layout, minW: min.w, minH: min.h }
|
|
125
|
+
}))
|
|
126
|
+
|
|
127
|
+
function labelOf(id: string) {
|
|
128
|
+
const widget = byId.value[id]
|
|
129
|
+
if (!widget) return id
|
|
130
|
+
return widget.title || kindOf(widget).title[props.lang]
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function chartHeight(rows: number) {
|
|
134
|
+
return Math.max(140, rows * (props.rowHeight + props.gap) - props.gap - CARD_CHROME)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function move(next: { id: string, x: number, y: number, w: number, h: number }[]) {
|
|
138
|
+
const places = new Map(next.map(item => [item.id, item]))
|
|
139
|
+
emit('update:widgets', props.widgets.map((w) => {
|
|
140
|
+
const place = places.get(w.id)
|
|
141
|
+
return place ? { ...w, layout: { x: place.x, y: place.y, w: place.w, h: place.h } } : w
|
|
142
|
+
}))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// --- Ajout et reglages ----------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
const editorOpen = ref(false)
|
|
148
|
+
const editedId = ref('')
|
|
149
|
+
|
|
150
|
+
function openAdd() {
|
|
151
|
+
editedId.value = ''
|
|
152
|
+
editorOpen.value = true
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function openEdit(id: string) {
|
|
156
|
+
editedId.value = id
|
|
157
|
+
editorOpen.value = true
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function saveWidget(value: Pick<RmDashboardWidget, 'kind' | 'viz' | 'title' | 'params'>) {
|
|
161
|
+
if (editedId.value) {
|
|
162
|
+
emit('update:widgets', props.widgets.map(w => (w.id === editedId.value ? { ...w, ...value } : w)))
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
// Un widget ajoute se pose sous les autres, a gauche : la ou on le cherche.
|
|
166
|
+
const size = rmWidgetKind(props.catalog, value.kind)!.size
|
|
167
|
+
const y = rmGridBottom(layout.value)
|
|
168
|
+
emit('update:widgets', [...props.widgets, { id: rmNewWidgetId(), ...value, layout: { x: 0, y, ...size } }])
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function duplicate(id: string) {
|
|
172
|
+
const source = byId.value[id]
|
|
173
|
+
if (!source) return
|
|
174
|
+
const y = rmGridBottom(layout.value)
|
|
175
|
+
emit('update:widgets', [...props.widgets, { ...source, id: rmNewWidgetId(), layout: { ...source.layout, x: 0, y } }])
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function remove(id: string) {
|
|
179
|
+
emit('update:widgets', props.widgets.filter(w => w.id !== id))
|
|
180
|
+
delete loaded[id]
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// --- Export -----------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
/** La donnee de chaque widget charge, telle que le widget l'a recue. */
|
|
186
|
+
const loaded = reactive<Record<string, { title: string, data: RmWidgetData } | null>>({})
|
|
187
|
+
|
|
188
|
+
defineExpose({
|
|
189
|
+
/** Un onglet de classeur par widget charge, dans l'ordre de lecture de la grille. */
|
|
190
|
+
exportSheets: () => rmDashboardExportSheets(props.widgets, loaded, props.lang),
|
|
191
|
+
})
|
|
192
|
+
</script>
|
|
193
|
+
|
|
194
|
+
<template>
|
|
195
|
+
<div class="rm-dashboard">
|
|
196
|
+
<div class="rm-dashboard__bar">
|
|
197
|
+
<p class="rm-dashboard__status" aria-live="polite">
|
|
198
|
+
<template v-if="saveFailed">
|
|
199
|
+
<RmIcon name="WarningCircle" :size="14" />
|
|
200
|
+
{{ T.notSaved }}
|
|
201
|
+
<button type="button" class="rm-dashboard__link" @click="emit('retrySave')">{{ T.retry }}</button>
|
|
202
|
+
</template>
|
|
203
|
+
<template v-else-if="saving">{{ T.saving }}</template>
|
|
204
|
+
<template v-else-if="editing">{{ T.editingHint }}</template>
|
|
205
|
+
</p>
|
|
206
|
+
<div class="rm-dashboard__actions">
|
|
207
|
+
<RmButton variant="tertiary" prefix-icon="Plus" :disabled="loading || failed" @click="openAdd">
|
|
208
|
+
{{ T.add }}
|
|
209
|
+
</RmButton>
|
|
210
|
+
<RmButton
|
|
211
|
+
:variant="editing ? 'primary' : 'secondary'"
|
|
212
|
+
:prefix-icon="editing ? 'Check' : 'Layout'"
|
|
213
|
+
:disabled="loading || failed || !widgets.length"
|
|
214
|
+
@click="editing = !editing"
|
|
215
|
+
>
|
|
216
|
+
{{ editing ? T.done : T.editLayout }}
|
|
217
|
+
</RmButton>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
<RmLoadingState v-if="loading" />
|
|
222
|
+
<RmErrorState
|
|
223
|
+
v-else-if="failed"
|
|
224
|
+
:text="T.loadFailed"
|
|
225
|
+
:action-label="T.retry"
|
|
226
|
+
@action="emit('load')"
|
|
227
|
+
/>
|
|
228
|
+
<RmEmptyState
|
|
229
|
+
v-else-if="!widgets.length"
|
|
230
|
+
icon="SquaresFour"
|
|
231
|
+
:title="emptyTitle ?? T.emptyTitle"
|
|
232
|
+
:text="emptyText ?? T.emptyText"
|
|
233
|
+
:action-label="T.add"
|
|
234
|
+
action-icon="Plus"
|
|
235
|
+
@action="openAdd"
|
|
236
|
+
/>
|
|
237
|
+
<RmDashboardGrid
|
|
238
|
+
v-else
|
|
239
|
+
:layout="layout"
|
|
240
|
+
:editable="editing"
|
|
241
|
+
:row-height="rowHeight"
|
|
242
|
+
:gap="gap"
|
|
243
|
+
:item-label="labelOf"
|
|
244
|
+
:lang="lang"
|
|
245
|
+
@update:layout="move"
|
|
246
|
+
>
|
|
247
|
+
<template #item="{ item }">
|
|
248
|
+
<RmDashboardWidget
|
|
249
|
+
v-if="byId[item.id]"
|
|
250
|
+
:widget="byId[item.id]!"
|
|
251
|
+
:kind="kindOf(byId[item.id]!)"
|
|
252
|
+
:context="context"
|
|
253
|
+
:source="sourceOf(kindOf(byId[item.id]!))"
|
|
254
|
+
:editing="editing"
|
|
255
|
+
:chart-height="chartHeight(item.h)"
|
|
256
|
+
:lang="lang"
|
|
257
|
+
:no-brands-text="noBrandsText"
|
|
258
|
+
@edit="openEdit(item.id)"
|
|
259
|
+
@duplicate="duplicate(item.id)"
|
|
260
|
+
@remove="remove(item.id)"
|
|
261
|
+
@data="loaded[item.id] = $event"
|
|
262
|
+
>
|
|
263
|
+
<template #cards="slotProps">
|
|
264
|
+
<slot name="cards" v-bind="slotProps" />
|
|
265
|
+
</template>
|
|
266
|
+
</RmDashboardWidget>
|
|
267
|
+
</template>
|
|
268
|
+
</RmDashboardGrid>
|
|
269
|
+
|
|
270
|
+
<RmDashboardWidgetEditor
|
|
271
|
+
v-model="editorOpen"
|
|
272
|
+
:widget="editedId ? byId[editedId] ?? null : null"
|
|
273
|
+
:catalog="catalog"
|
|
274
|
+
:context="context"
|
|
275
|
+
:source-of="sourceOf"
|
|
276
|
+
:lang="lang"
|
|
277
|
+
:scope-label="scopeLabel"
|
|
278
|
+
:no-brands-text="noBrandsText"
|
|
279
|
+
@save="saveWidget"
|
|
280
|
+
>
|
|
281
|
+
<template #cards="slotProps">
|
|
282
|
+
<slot name="cards" v-bind="slotProps" />
|
|
283
|
+
</template>
|
|
284
|
+
</RmDashboardWidgetEditor>
|
|
285
|
+
</div>
|
|
286
|
+
</template>
|
|
287
|
+
|
|
288
|
+
<style scoped>
|
|
289
|
+
.rm-dashboard{display:flex;flex-direction:column;gap:16px}.rm-dashboard__bar{align-items:center;display:flex;flex-wrap:wrap;gap:12px;justify-content:space-between}.rm-dashboard__status{align-items:center;color:var(--text-secondary);display:inline-flex;font-size:.8125rem;gap:4px;margin:0;min-height:1.25rem}.rm-dashboard__actions{display:flex;gap:8px;margin-left:auto}.rm-dashboard__link{background:none;color:var(--color-primary);cursor:pointer;padding:0;text-decoration:underline}
|
|
290
|
+
</style>
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmDashboardWidget - un widget d'un tableau de bord : une entree du
|
|
3
|
+
catalogue de l'app (`RmWidgetKind`), reglee et affichee dans une carte.
|
|
4
|
+
|
|
5
|
+
La carte est toujours RmChartCard, quelle que soit la visualisation : c'est
|
|
6
|
+
elle qui porte la source et sa date, le menu d'export et les etats
|
|
7
|
+
chargement, vide, erreur. Le widget va chercher sa donnee par le `fetch` de
|
|
8
|
+
son entree et la rend sous la visualisation choisie ; le tableau se deduit
|
|
9
|
+
de n'importe quelle forme (utils/dashboard, rmWidgetSheet).
|
|
10
|
+
|
|
11
|
+
L'export part de la donnee, pas du rendu : le classeur recoit les nombres
|
|
12
|
+
bruts et toutes les periodes, meme quand le graphe n'en montre qu'une
|
|
13
|
+
partie. Les sources passent dans le `payload` : sans elles, le menu
|
|
14
|
+
citerait celles de la page entiere.
|
|
15
|
+
|
|
16
|
+
La lecture en cartes est la seule que le paquet ne sait pas rendre : les
|
|
17
|
+
cartes sont celles des pages de l'app. Elle les fournit par le slot
|
|
18
|
+
`#cards`, nourri du `kind` et des `items` que sa donnee declare.
|
|
19
|
+
|
|
20
|
+
En edition, le menu d'export laisse sa place aux reglages du widget, et la
|
|
21
|
+
carte entiere sert de poignee a la grille (`data-rm-dash-drag`).
|
|
22
|
+
-->
|
|
23
|
+
<script setup lang="ts">
|
|
24
|
+
import { computed, ref, watch } from 'vue'
|
|
25
|
+
import type { RmExportPayload } from '../utils/export'
|
|
26
|
+
import { rmCompact, rmNumber } from '../utils/format'
|
|
27
|
+
import {
|
|
28
|
+
rmWidgetBrands,
|
|
29
|
+
rmWidgetEmpty,
|
|
30
|
+
rmWidgetParams,
|
|
31
|
+
rmWidgetSheet,
|
|
32
|
+
rmWidgetTitle,
|
|
33
|
+
rmWidgetViz,
|
|
34
|
+
type RmDashboardLang,
|
|
35
|
+
type RmDashboardWidget,
|
|
36
|
+
type RmWidgetContext,
|
|
37
|
+
type RmWidgetData,
|
|
38
|
+
type RmWidgetKind,
|
|
39
|
+
type RmWidgetSource,
|
|
40
|
+
} from '../utils/dashboard'
|
|
41
|
+
|
|
42
|
+
const props = withDefaults(defineProps<{
|
|
43
|
+
widget: RmDashboardWidget
|
|
44
|
+
/** L'entree du catalogue que le widget nomme. */
|
|
45
|
+
kind: RmWidgetKind<any>
|
|
46
|
+
/** Ce que l'ecran donne a ses widgets : ses marques, et ce que l'app y ajoute. */
|
|
47
|
+
context: RmWidgetContext
|
|
48
|
+
/** La source citee en pied de carte, et reprise par l'export. */
|
|
49
|
+
source: RmWidgetSource
|
|
50
|
+
editing?: boolean
|
|
51
|
+
/** Hauteur offerte au graphe, deduite de la tuile. */
|
|
52
|
+
chartHeight?: number
|
|
53
|
+
lang?: RmDashboardLang
|
|
54
|
+
/** Ce que dit le corps vide quand le widget lit les marques et qu'il n'y en a aucune. */
|
|
55
|
+
noBrandsText?: string
|
|
56
|
+
}>(), {
|
|
57
|
+
editing: false,
|
|
58
|
+
chartHeight: 240,
|
|
59
|
+
lang: 'fr',
|
|
60
|
+
noBrandsText: undefined,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const emit = defineEmits<{
|
|
64
|
+
edit: []
|
|
65
|
+
duplicate: []
|
|
66
|
+
remove: []
|
|
67
|
+
/** La donnee chargee, pour l'export de la page entiere. */
|
|
68
|
+
data: [value: { title: string, data: RmWidgetData } | null]
|
|
69
|
+
}>()
|
|
70
|
+
|
|
71
|
+
defineSlots<{
|
|
72
|
+
cards(props: { kind: string, items: unknown[], editing: boolean }): unknown
|
|
73
|
+
}>()
|
|
74
|
+
|
|
75
|
+
const T = computed(() => props.lang === 'en'
|
|
76
|
+
? {
|
|
77
|
+
retry: 'Retry',
|
|
78
|
+
noData: 'No data',
|
|
79
|
+
nothing: 'This source has nothing for this selection.',
|
|
80
|
+
noBrands: 'No brand selected.',
|
|
81
|
+
vsLastYear: 'vs last year',
|
|
82
|
+
noPrevious: 'No figure a year earlier',
|
|
83
|
+
edit: 'Edit',
|
|
84
|
+
duplicate: 'Duplicate',
|
|
85
|
+
remove: 'Remove',
|
|
86
|
+
}
|
|
87
|
+
: {
|
|
88
|
+
retry: 'Réessayer',
|
|
89
|
+
noData: 'Aucune donnée',
|
|
90
|
+
nothing: 'Cette source n\'a rien pour cette sélection.',
|
|
91
|
+
noBrands: 'Aucune marque sélectionnée.',
|
|
92
|
+
vsLastYear: 'vs N-1',
|
|
93
|
+
noPrevious: 'Pas de chiffre un an plus tôt',
|
|
94
|
+
edit: 'Régler',
|
|
95
|
+
duplicate: 'Dupliquer',
|
|
96
|
+
remove: 'Retirer',
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const viz = computed(() => rmWidgetViz(props.widget, props.kind))
|
|
100
|
+
const params = computed(() => rmWidgetParams(props.kind, props.widget.params))
|
|
101
|
+
const brands = computed(() => rmWidgetBrands(props.widget, props.context.brands))
|
|
102
|
+
const title = computed(() => rmWidgetTitle(props.widget, props.kind, props.lang))
|
|
103
|
+
|
|
104
|
+
/** Ce que le widget lit, dit sous le titre : reglages, periode, marques. */
|
|
105
|
+
const context = computed(() => {
|
|
106
|
+
const bits: string[] = []
|
|
107
|
+
for (const param of props.kind.params) {
|
|
108
|
+
const option = param.options.find(o => o.value === params.value[param.key])
|
|
109
|
+
if (option) bits.push(option.label[props.lang])
|
|
110
|
+
}
|
|
111
|
+
if (data.value?.type === 'kpi' && data.value.period) bits.push(data.value.period)
|
|
112
|
+
if (props.kind.usesBrands) {
|
|
113
|
+
const list = brands.value
|
|
114
|
+
bits.push(list.length > 4 ? `${list.slice(0, 4).join(', ')} +${list.length - 4}` : list.join(', '))
|
|
115
|
+
}
|
|
116
|
+
return bits.join(', ')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
// --- Donnee --------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
const data = ref<RmWidgetData | null>(null)
|
|
122
|
+
const loading = ref(true)
|
|
123
|
+
const failed = ref(false)
|
|
124
|
+
let runId = 0
|
|
125
|
+
|
|
126
|
+
async function run() {
|
|
127
|
+
const id = ++runId
|
|
128
|
+
if (props.kind.usesBrands && !brands.value.length) {
|
|
129
|
+
data.value = null
|
|
130
|
+
loading.value = false
|
|
131
|
+
failed.value = false
|
|
132
|
+
emit('data', null)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
loading.value = true
|
|
136
|
+
failed.value = false
|
|
137
|
+
try {
|
|
138
|
+
const result = await props.kind.fetch(params.value, { ...props.context, brands: brands.value, lang: props.lang })
|
|
139
|
+
if (id !== runId) return
|
|
140
|
+
data.value = result
|
|
141
|
+
emit('data', { title: title.value, data: result })
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
if (id !== runId) return
|
|
145
|
+
failed.value = true
|
|
146
|
+
data.value = null
|
|
147
|
+
emit('data', null)
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
if (id === runId) loading.value = false
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
watch(
|
|
155
|
+
() => [props.widget.kind, JSON.stringify(params.value), brands.value.join('|'), props.lang, JSON.stringify(props.context)],
|
|
156
|
+
run,
|
|
157
|
+
{ immediate: true },
|
|
158
|
+
)
|
|
159
|
+
watch(title, () => data.value && emit('data', { title: title.value, data: data.value }))
|
|
160
|
+
|
|
161
|
+
const emptyText = computed(() => {
|
|
162
|
+
if (props.kind.usesBrands && !brands.value.length) return props.noBrandsText ?? T.value.noBrands
|
|
163
|
+
return data.value?.note ?? T.value.nothing
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
const state = computed(() => {
|
|
167
|
+
if (loading.value) return 'loading'
|
|
168
|
+
if (failed.value) return 'error'
|
|
169
|
+
return rmWidgetEmpty(data.value) ? 'empty' : 'ready'
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
// --- Visualisations ------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
const series = computed(() => (data.value?.type === 'series' ? data.value : null))
|
|
175
|
+
|
|
176
|
+
/** Des series en barres telles quelles ; des parts en barres horizontales, une barre par part. */
|
|
177
|
+
const bars = computed(() => {
|
|
178
|
+
const d = data.value
|
|
179
|
+
if (d?.type === 'series') return { categories: d.categories, series: d.series, horizontal: false }
|
|
180
|
+
if (d?.type === 'parts') {
|
|
181
|
+
return {
|
|
182
|
+
categories: d.parts.map(p => p.name),
|
|
183
|
+
series: [{ name: title.value, data: d.parts.map(p => p.value) }],
|
|
184
|
+
horizontal: true,
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return null
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
const figure = (value: number) => rmCompact(Math.round(value))
|
|
191
|
+
|
|
192
|
+
const kpis = computed(() => {
|
|
193
|
+
const d = data.value
|
|
194
|
+
if (d?.type === 'kpi') {
|
|
195
|
+
return d.items.map(item => ({
|
|
196
|
+
label: item.label,
|
|
197
|
+
value: figure(item.value),
|
|
198
|
+
trend: item.trend,
|
|
199
|
+
trendLabel: item.trend === undefined ? undefined : T.value.vsLastYear,
|
|
200
|
+
note: item.previous === null ? T.value.noPrevious : undefined,
|
|
201
|
+
}))
|
|
202
|
+
}
|
|
203
|
+
if (d?.type === 'parts') {
|
|
204
|
+
return d.parts.map(p => ({ label: p.name, value: figure(p.value), trend: undefined, trendLabel: undefined, note: undefined }))
|
|
205
|
+
}
|
|
206
|
+
return []
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
/** Le tableau en nombres bruts : le meme que l'export. */
|
|
210
|
+
const sheet = computed(() => (data.value ? rmWidgetSheet(data.value, props.lang, props.widget.params.columns) : null))
|
|
211
|
+
|
|
212
|
+
/** Le tableau affiche : les memes lignes, les nombres et les dates mis au format. */
|
|
213
|
+
const table = computed(() => {
|
|
214
|
+
const s = sheet.value
|
|
215
|
+
if (!s) return null
|
|
216
|
+
const numeric = new Set(s.columns.filter(c => c.numeric).map(c => c.key))
|
|
217
|
+
const dates = new Set(s.columns.filter(c => c.date).map(c => c.key))
|
|
218
|
+
const locale = props.lang === 'en' ? 'en-GB' : 'fr-FR'
|
|
219
|
+
return {
|
|
220
|
+
columns: s.columns.map(c => (c.long ? { ...c, minWidth: '360px' } : c)),
|
|
221
|
+
longColumns: s.columns.filter(c => c.long).map(c => c.key),
|
|
222
|
+
rows: s.rows.map(row =>
|
|
223
|
+
Object.fromEntries(
|
|
224
|
+
Object.entries(row).map(([key, value]) => [
|
|
225
|
+
key,
|
|
226
|
+
numeric.has(key)
|
|
227
|
+
? typeof value === 'number' ? rmNumber(Math.round(value * 100) / 100) : ''
|
|
228
|
+
: dates.has(key) && typeof value === 'string'
|
|
229
|
+
? new Date(value).toLocaleDateString(locale)
|
|
230
|
+
: value,
|
|
231
|
+
]),
|
|
232
|
+
),
|
|
233
|
+
),
|
|
234
|
+
}
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
const cards = computed(() => (data.value?.type === 'rows' ? (data.value.cards ?? null) : null))
|
|
238
|
+
|
|
239
|
+
/** L'onglet du classeur : les nombres bruts, pas le texte du tableau. */
|
|
240
|
+
const payload = (): Partial<RmExportPayload> | null => {
|
|
241
|
+
const s = sheet.value
|
|
242
|
+
if (!s) return null
|
|
243
|
+
return {
|
|
244
|
+
title: title.value,
|
|
245
|
+
subtitle: context.value,
|
|
246
|
+
sources: props.source.export,
|
|
247
|
+
sheets: [
|
|
248
|
+
{
|
|
249
|
+
name: title.value.slice(0, 31),
|
|
250
|
+
schema: s.columns.map(c => ({ column: c.label, value: (row: Record<string, unknown>) => row[c.key] ?? null })),
|
|
251
|
+
data: s.rows,
|
|
252
|
+
},
|
|
253
|
+
],
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
</script>
|
|
257
|
+
|
|
258
|
+
<template>
|
|
259
|
+
<RmChartCard
|
|
260
|
+
class="rm-dash-widget"
|
|
261
|
+
:title="title"
|
|
262
|
+
:context="context"
|
|
263
|
+
:source="source.name"
|
|
264
|
+
:verified="source.verified"
|
|
265
|
+
:lang="lang"
|
|
266
|
+
:extra="state === 'ready' ? data?.note : undefined"
|
|
267
|
+
:actions="!editing"
|
|
268
|
+
:payload="payload"
|
|
269
|
+
:state="state"
|
|
270
|
+
:retry-label="T.retry"
|
|
271
|
+
data-rm-dash-drag
|
|
272
|
+
@retry="run"
|
|
273
|
+
>
|
|
274
|
+
<!-- Le corps vide par defaut parle de filtres : ici il n'y en a pas, la
|
|
275
|
+
source n'a simplement rien pour ces marques. On le dit. -->
|
|
276
|
+
<template #empty>
|
|
277
|
+
<RmEmptyState icon="MagnifyingGlassMinus" :title="T.noData" :text="emptyText" />
|
|
278
|
+
</template>
|
|
279
|
+
<RmLineChart
|
|
280
|
+
v-if="viz === 'line' && series"
|
|
281
|
+
:categories="series.categories"
|
|
282
|
+
:series="series.series"
|
|
283
|
+
:height="chartHeight"
|
|
284
|
+
number-format="compact"
|
|
285
|
+
:description="title"
|
|
286
|
+
/>
|
|
287
|
+
<RmBarChart
|
|
288
|
+
v-else-if="viz === 'bar' && bars"
|
|
289
|
+
:categories="bars.categories"
|
|
290
|
+
:series="bars.series"
|
|
291
|
+
:horizontal="bars.horizontal"
|
|
292
|
+
:height="chartHeight"
|
|
293
|
+
:value-formatter="rmCompact"
|
|
294
|
+
:description="title"
|
|
295
|
+
/>
|
|
296
|
+
<RmDonutChart
|
|
297
|
+
v-else-if="viz === 'donut' && data?.type === 'parts'"
|
|
298
|
+
:data="data.parts"
|
|
299
|
+
:height="chartHeight"
|
|
300
|
+
:description="title"
|
|
301
|
+
/>
|
|
302
|
+
<div v-else-if="viz === 'kpi'" class="rm-dash-widget__kpis">
|
|
303
|
+
<RmKpi
|
|
304
|
+
v-for="kpi in kpis"
|
|
305
|
+
:key="kpi.label"
|
|
306
|
+
:label="kpi.label"
|
|
307
|
+
:value="kpi.value"
|
|
308
|
+
:unit="data?.unit"
|
|
309
|
+
:trend="kpi.trend"
|
|
310
|
+
:trend-label="kpi.trendLabel"
|
|
311
|
+
:note="kpi.note"
|
|
312
|
+
/>
|
|
313
|
+
</div>
|
|
314
|
+
<!-- Les cartes de l'app, condensees, qui defilent dans la tuile. En
|
|
315
|
+
edition, elles ne repondent plus au clic : deplacer la tuile ne doit
|
|
316
|
+
pas ouvrir une fiche. -->
|
|
317
|
+
<div
|
|
318
|
+
v-else-if="viz === 'cards' && cards"
|
|
319
|
+
class="rm-dash-widget__cards"
|
|
320
|
+
:class="{ 'is-editing': editing }"
|
|
321
|
+
:style="{ maxHeight: `${chartHeight}px` }"
|
|
322
|
+
>
|
|
323
|
+
<slot name="cards" :kind="cards.kind" :items="cards.items" :editing="editing" />
|
|
324
|
+
</div>
|
|
325
|
+
<RmTable
|
|
326
|
+
v-else-if="table"
|
|
327
|
+
:columns="table.columns"
|
|
328
|
+
:rows="table.rows"
|
|
329
|
+
:max-height="`${chartHeight}px`"
|
|
330
|
+
sticky-header
|
|
331
|
+
:search="false"
|
|
332
|
+
>
|
|
333
|
+
<!-- Une indication peut faire quarante lignes : trois, puis « Lire plus ».
|
|
334
|
+
Seulement sur les colonnes longues : RmClampText laisse couper les
|
|
335
|
+
mots, et pose dans une colonne courte (marque, type) il laissait le
|
|
336
|
+
tableau l'ecraser. -->
|
|
337
|
+
<template v-for="column in table.longColumns" :key="column" #[column]="{ value }">
|
|
338
|
+
<RmClampText v-if="value !== null && value !== undefined && value !== ''" :text="String(value)" :lines="3" :lang="lang" />
|
|
339
|
+
<template v-else>-</template>
|
|
340
|
+
</template>
|
|
341
|
+
</RmTable>
|
|
342
|
+
</RmChartCard>
|
|
343
|
+
<!-- Les reglages se posent par-dessus la tete de la carte, a la place du
|
|
344
|
+
menu d'export que l'edition masque. -->
|
|
345
|
+
<div v-if="editing" class="rm-dash-widget__tools" data-rm-dash-nodrag>
|
|
346
|
+
<RmButton
|
|
347
|
+
variant="tertiary"
|
|
348
|
+
size="sm"
|
|
349
|
+
icon-only
|
|
350
|
+
ghost
|
|
351
|
+
prefix-icon="SlidersHorizontal"
|
|
352
|
+
:aria-label="`${T.edit} ${title}`"
|
|
353
|
+
:title="T.edit"
|
|
354
|
+
@click="emit('edit')"
|
|
355
|
+
/>
|
|
356
|
+
<RmButton
|
|
357
|
+
variant="tertiary"
|
|
358
|
+
size="sm"
|
|
359
|
+
icon-only
|
|
360
|
+
ghost
|
|
361
|
+
prefix-icon="Copy"
|
|
362
|
+
:aria-label="`${T.duplicate} ${title}`"
|
|
363
|
+
:title="T.duplicate"
|
|
364
|
+
@click="emit('duplicate')"
|
|
365
|
+
/>
|
|
366
|
+
<RmButton
|
|
367
|
+
variant="text-danger"
|
|
368
|
+
size="sm"
|
|
369
|
+
icon-only
|
|
370
|
+
ghost
|
|
371
|
+
prefix-icon="Trash"
|
|
372
|
+
:aria-label="`${T.remove} ${title}`"
|
|
373
|
+
:title="T.remove"
|
|
374
|
+
@click="emit('remove')"
|
|
375
|
+
/>
|
|
376
|
+
</div>
|
|
377
|
+
</template>
|
|
378
|
+
|
|
379
|
+
<style scoped>
|
|
380
|
+
.rm-dash-widget{display:flex;flex-direction:column;height:100%;overflow:hidden}.rm-dash-widget :deep(.chart-card-body){flex:1;min-height:0;overflow:auto}.rm-dash-widget__kpis{display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(150px,1fr))}.rm-dash-widget :deep(.rm-table__td){vertical-align:top}.rm-dash-widget__cards{display:flex;flex-direction:column;gap:8px;overflow:auto;padding-right:4px}.rm-dash-widget__cards>:deep(*){flex-shrink:0}.rm-dash-widget__cards.is-editing{pointer-events:none}.rm-dash-widget__tools{background:var(--bg-surface);border-radius:var(--radius-md);display:flex;gap:4px;padding:2px;position:absolute;right:12px;top:12px;z-index:2}
|
|
381
|
+
</style>
|