@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 CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=3.16.0"
6
6
  },
7
- "version": "0.1.208",
7
+ "version": "0.1.210",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "0.8.4",
10
10
  "unbuild": "2.0.0"
@@ -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>