@tekkare/romulus 0.1.210 → 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/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
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmDashboardWidgetEditor - ajouter ou regler un widget d'un tableau de bord.
|
|
3
|
+
|
|
4
|
+
Un tiroir plutot qu'une modale : le catalogue, les reglages et un apercu a
|
|
5
|
+
taille utile tiennent cote a cote, chacun defilant de son cote.
|
|
6
|
+
|
|
7
|
+
A gauche le catalogue de l'app, groupe par source : ce qu'on peut afficher.
|
|
8
|
+
A droite les reglages de l'entree choisie : visualisation, parametres,
|
|
9
|
+
marques, colonnes et titre. Un widget existant s'ouvre sur son entree et
|
|
10
|
+
ses reglages ; changer d'entree repart de ses valeurs par defaut, les
|
|
11
|
+
parametres d'une source ne valant pas pour une autre.
|
|
12
|
+
|
|
13
|
+
Les marques suivent le contexte par defaut (`scopeLabel` dit lequel : le
|
|
14
|
+
projet, la vue...). « Choisir » fige une liste parmi ces marques : le
|
|
15
|
+
widget ne bouge plus quand le contexte en gagne d'autres.
|
|
16
|
+
-->
|
|
17
|
+
<script setup lang="ts">
|
|
18
|
+
import { computed, ref, watch } from 'vue'
|
|
19
|
+
import {
|
|
20
|
+
RM_VIZ_ICONS,
|
|
21
|
+
RM_VIZ_LABELS,
|
|
22
|
+
rmShownColumns,
|
|
23
|
+
rmWidgetKind,
|
|
24
|
+
rmWidgetParams,
|
|
25
|
+
type RmDashboardLang,
|
|
26
|
+
type RmDashboardViz,
|
|
27
|
+
type RmDashboardWidget,
|
|
28
|
+
type RmWidgetColumn,
|
|
29
|
+
type RmWidgetContext,
|
|
30
|
+
type RmWidgetData,
|
|
31
|
+
type RmWidgetKind,
|
|
32
|
+
type RmWidgetParam,
|
|
33
|
+
type RmWidgetSource,
|
|
34
|
+
} from '../utils/dashboard'
|
|
35
|
+
|
|
36
|
+
const props = withDefaults(defineProps<{
|
|
37
|
+
modelValue: boolean
|
|
38
|
+
/** Le widget a regler ; absent, on en ajoute un. */
|
|
39
|
+
widget: RmDashboardWidget | null
|
|
40
|
+
catalog: RmWidgetKind<any>[]
|
|
41
|
+
context: RmWidgetContext
|
|
42
|
+
/** La source d'une entree, pour l'apercu. */
|
|
43
|
+
sourceOf: (kind: RmWidgetKind<any>) => RmWidgetSource
|
|
44
|
+
lang?: RmDashboardLang
|
|
45
|
+
/** Ce que suivent les marques par defaut : « Celles du projet », « Celles de la vue ». */
|
|
46
|
+
scopeLabel?: string
|
|
47
|
+
/** Ce que dit l'editeur quand le contexte n'a aucune marque. */
|
|
48
|
+
noBrandsText?: string
|
|
49
|
+
}>(), {
|
|
50
|
+
lang: 'fr',
|
|
51
|
+
scopeLabel: undefined,
|
|
52
|
+
noBrandsText: undefined,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const emit = defineEmits<{
|
|
56
|
+
'update:modelValue': [value: boolean]
|
|
57
|
+
/** Le widget regle, sans sa place : la grille la lui donne. */
|
|
58
|
+
'save': [value: Pick<RmDashboardWidget, 'kind' | 'viz' | 'title' | 'params'>]
|
|
59
|
+
}>()
|
|
60
|
+
|
|
61
|
+
defineSlots<{
|
|
62
|
+
cards(props: { kind: string, items: unknown[], editing: boolean }): unknown
|
|
63
|
+
}>()
|
|
64
|
+
|
|
65
|
+
const en = computed(() => props.lang === 'en')
|
|
66
|
+
|
|
67
|
+
const T = computed(() => en.value
|
|
68
|
+
? {
|
|
69
|
+
add: 'Add a widget',
|
|
70
|
+
edit: 'Edit the widget',
|
|
71
|
+
catalog: 'Widget catalogue',
|
|
72
|
+
title: 'Title',
|
|
73
|
+
display: 'Display',
|
|
74
|
+
brands: 'Brands',
|
|
75
|
+
scope: 'All brands',
|
|
76
|
+
pick: 'Pick',
|
|
77
|
+
columns: 'Columns',
|
|
78
|
+
order: 'Shown columns, in order',
|
|
79
|
+
left: (c: string) => `Move ${c} left`,
|
|
80
|
+
right: (c: string) => `Move ${c} right`,
|
|
81
|
+
hide: (c: string) => `Hide ${c}`,
|
|
82
|
+
addColumn: 'Add:',
|
|
83
|
+
orderHint: 'Shown columns are displayed and exported in this order.',
|
|
84
|
+
reset: 'Back to the default columns',
|
|
85
|
+
preview: 'Preview',
|
|
86
|
+
cancel: 'Cancel',
|
|
87
|
+
save: 'Save',
|
|
88
|
+
addToBoard: 'Add to the dashboard',
|
|
89
|
+
noBrands: 'No brand to read yet.',
|
|
90
|
+
follows: (list: string) => `Follows ${list}.`,
|
|
91
|
+
fixed: 'A fixed list: brands added later will not appear.',
|
|
92
|
+
}
|
|
93
|
+
: {
|
|
94
|
+
add: 'Ajouter un widget',
|
|
95
|
+
edit: 'Régler le widget',
|
|
96
|
+
catalog: 'Catalogue de widgets',
|
|
97
|
+
title: 'Titre',
|
|
98
|
+
display: 'Affichage',
|
|
99
|
+
brands: 'Marques',
|
|
100
|
+
scope: 'Toutes les marques',
|
|
101
|
+
pick: 'Choisir',
|
|
102
|
+
columns: 'Colonnes',
|
|
103
|
+
order: 'Colonnes affichées, dans l\'ordre',
|
|
104
|
+
left: (c: string) => `Avancer ${c}`,
|
|
105
|
+
right: (c: string) => `Reculer ${c}`,
|
|
106
|
+
hide: (c: string) => `Masquer ${c}`,
|
|
107
|
+
addColumn: 'Ajouter :',
|
|
108
|
+
orderHint: 'Les colonnes affichées sont montrées et exportées dans cet ordre.',
|
|
109
|
+
reset: 'Revenir aux colonnes par défaut',
|
|
110
|
+
preview: 'Aperçu',
|
|
111
|
+
cancel: 'Annuler',
|
|
112
|
+
save: 'Enregistrer',
|
|
113
|
+
addToBoard: 'Ajouter au tableau de bord',
|
|
114
|
+
noBrands: 'Aucune marque à lire pour l\'instant.',
|
|
115
|
+
follows: (list: string) => `Suit ${list}.`,
|
|
116
|
+
fixed: 'Une liste figée : les marques ajoutées plus tard n\'y apparaîtront pas.',
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const heading = computed(() => (props.widget ? T.value.edit : T.value.add))
|
|
120
|
+
|
|
121
|
+
const groups = computed(() => {
|
|
122
|
+
const out: { label: string, entries: RmWidgetKind<any>[] }[] = []
|
|
123
|
+
for (const entry of props.catalog) {
|
|
124
|
+
const label = entry.group[props.lang]
|
|
125
|
+
let group = out.find(g => g.label === label)
|
|
126
|
+
if (!group) out.push((group = { label, entries: [] }))
|
|
127
|
+
group.entries.push(entry)
|
|
128
|
+
}
|
|
129
|
+
return out
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
const kind = ref('')
|
|
133
|
+
const title = ref('')
|
|
134
|
+
const viz = ref<RmDashboardViz>('line')
|
|
135
|
+
const values = ref<Record<string, string>>({})
|
|
136
|
+
const brandMode = ref<'project' | 'pick'>('project')
|
|
137
|
+
const picked = ref<string[]>([])
|
|
138
|
+
/** Colonnes cochees ; `null` : celles de l'entree par defaut. */
|
|
139
|
+
const columns = ref<string[] | null>(null)
|
|
140
|
+
|
|
141
|
+
/** Les colonnes que la liste propose, lues dans l'apercu : elles dependent du regroupement choisi. */
|
|
142
|
+
const available = ref<RmWidgetColumn[]>([])
|
|
143
|
+
function onPreviewData(value: { data: RmWidgetData } | null) {
|
|
144
|
+
available.value = value?.data.type === 'rows' ? value.data.columns : []
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Les colonnes affichees, dans l'ordre choisi (meme regle que le widget). */
|
|
148
|
+
const shown = computed(() => rmShownColumns(available.value, columns.value).map(c => c.key))
|
|
149
|
+
|
|
150
|
+
const hidden = computed(() => available.value.filter(c => !shown.value.includes(c.key)))
|
|
151
|
+
|
|
152
|
+
function labelOf(key: string) {
|
|
153
|
+
return available.value.find(c => c.key === key)?.label ?? key
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Une colonne ajoutee va en fin de liste ; on la place ensuite. */
|
|
157
|
+
function toggleColumn(key: string) {
|
|
158
|
+
const current = shown.value
|
|
159
|
+
if (current.includes(key)) {
|
|
160
|
+
if (current.length === 1) return
|
|
161
|
+
columns.value = current.filter(k => k !== key)
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
columns.value = [...current, key]
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function moveColumn(key: string, step: number) {
|
|
169
|
+
const list = [...shown.value]
|
|
170
|
+
const from = list.indexOf(key)
|
|
171
|
+
const to = from + step
|
|
172
|
+
if (from < 0 || to < 0 || to >= list.length) return
|
|
173
|
+
list.splice(to, 0, ...list.splice(from, 1))
|
|
174
|
+
columns.value = list
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const dragged = ref('')
|
|
178
|
+
const target = ref(-1)
|
|
179
|
+
|
|
180
|
+
function onDragStart(event: DragEvent, key: string) {
|
|
181
|
+
dragged.value = key
|
|
182
|
+
event.dataTransfer?.setData('text/plain', key)
|
|
183
|
+
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function onDrop(index: number) {
|
|
187
|
+
const key = dragged.value
|
|
188
|
+
dragged.value = ''
|
|
189
|
+
target.value = -1
|
|
190
|
+
if (!key) return
|
|
191
|
+
const list = shown.value.filter(k => k !== key)
|
|
192
|
+
list.splice(index, 0, key)
|
|
193
|
+
columns.value = list
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const entry = computed(() => rmWidgetKind(props.catalog, kind.value))
|
|
197
|
+
/** Les reglages a peu de choix se lisent en bascule ; au-dela de trois, en liste (le rail passerait sur deux lignes). */
|
|
198
|
+
const toggles = computed(() => (entry.value?.params ?? []).filter(p => p.options.length <= 3))
|
|
199
|
+
const lists = computed(() => (entry.value?.params ?? []).filter(p => p.options.length > 3))
|
|
200
|
+
|
|
201
|
+
const vizOptions = computed(() =>
|
|
202
|
+
(entry.value?.viz ?? []).map(value => ({ value, label: RM_VIZ_LABELS[value][props.lang] })),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
function pick(next: string, from?: RmDashboardWidget | null) {
|
|
206
|
+
const chosen = rmWidgetKind(props.catalog, next)
|
|
207
|
+
if (!chosen) return
|
|
208
|
+
kind.value = next
|
|
209
|
+
const params = from?.kind === next ? from.params : {}
|
|
210
|
+
values.value = rmWidgetParams(chosen, params)
|
|
211
|
+
viz.value = from?.kind === next && chosen.viz.includes(from.viz) ? from.viz : chosen.viz[0]!
|
|
212
|
+
const brands = params.brands
|
|
213
|
+
brandMode.value = Array.isArray(brands) ? 'pick' : 'project'
|
|
214
|
+
picked.value = Array.isArray(brands) ? (brands as string[]) : []
|
|
215
|
+
columns.value = Array.isArray(params.columns) ? (params.columns as string[]) : null
|
|
216
|
+
available.value = []
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
watch(
|
|
220
|
+
() => props.modelValue,
|
|
221
|
+
(open) => {
|
|
222
|
+
if (!open) return
|
|
223
|
+
title.value = props.widget?.title ?? ''
|
|
224
|
+
pick(props.widget?.kind ?? props.catalog[0]!.kind, props.widget)
|
|
225
|
+
},
|
|
226
|
+
{ immediate: true },
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
function optionLabel(param: RmWidgetParam, value: string | undefined) {
|
|
230
|
+
return param.options.find(o => o.value === value)?.label[props.lang]
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function optionValue(param: RmWidgetParam, label: string) {
|
|
234
|
+
return param.options.find(o => o.label[props.lang] === label)?.value ?? param.options[0]!.value
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const brandHint = computed(() => {
|
|
238
|
+
if (!props.context.brands.length) return props.noBrandsText ?? T.value.noBrands
|
|
239
|
+
if (brandMode.value === 'project') return T.value.follows(props.context.brands.join(', '))
|
|
240
|
+
return T.value.fixed
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
/** Le widget tel qu'il sera enregistre, pour l'apercu. */
|
|
244
|
+
const draft = computed<RmDashboardWidget | null>(() => {
|
|
245
|
+
if (!entry.value || !canSave.value) return null
|
|
246
|
+
return { id: 'preview', ...settings(), layout: { x: 0, y: 0, ...entry.value.size } }
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
function settings(): Pick<RmDashboardWidget, 'kind' | 'viz' | 'title' | 'params'> {
|
|
250
|
+
const params: Record<string, unknown> = { ...values.value }
|
|
251
|
+
if (entry.value?.usesBrands && brandMode.value === 'pick') params.brands = [...picked.value]
|
|
252
|
+
if (columns.value) params.columns = [...columns.value]
|
|
253
|
+
return { kind: kind.value, viz: viz.value, title: title.value.trim() || undefined, params }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const canSave = computed(() => !!entry.value && (brandMode.value === 'project' || picked.value.length > 0))
|
|
257
|
+
|
|
258
|
+
function save() {
|
|
259
|
+
if (!entry.value || !canSave.value) return
|
|
260
|
+
emit('save', settings())
|
|
261
|
+
emit('update:modelValue', false)
|
|
262
|
+
}
|
|
263
|
+
</script>
|
|
264
|
+
|
|
265
|
+
<template>
|
|
266
|
+
<RmDrawer
|
|
267
|
+
:model-value="modelValue"
|
|
268
|
+
size="min(1200px, 94vw)"
|
|
269
|
+
:label="heading"
|
|
270
|
+
@update:model-value="emit('update:modelValue', $event)"
|
|
271
|
+
>
|
|
272
|
+
<template #header>
|
|
273
|
+
<h2 class="rm-dash-editor__title">{{ heading }}</h2>
|
|
274
|
+
</template>
|
|
275
|
+
|
|
276
|
+
<div class="rm-dash-editor">
|
|
277
|
+
<nav class="rm-dash-editor__catalog" :aria-label="T.catalog">
|
|
278
|
+
<section v-for="group in groups" :key="group.label" class="rm-dash-editor__group">
|
|
279
|
+
<p class="rm-dash-editor__group-label">{{ group.label }}</p>
|
|
280
|
+
<button
|
|
281
|
+
v-for="item in group.entries"
|
|
282
|
+
:key="item.kind"
|
|
283
|
+
type="button"
|
|
284
|
+
class="rm-dash-editor__entry"
|
|
285
|
+
:class="{ 'is-active': item.kind === kind }"
|
|
286
|
+
:aria-pressed="item.kind === kind"
|
|
287
|
+
@click="pick(item.kind)"
|
|
288
|
+
>
|
|
289
|
+
<span class="rm-dash-editor__entry-icon" aria-hidden="true">
|
|
290
|
+
<RmIcon :name="RM_VIZ_ICONS[item.viz[0]!]" :size="18" />
|
|
291
|
+
</span>
|
|
292
|
+
<span class="rm-dash-editor__entry-body">
|
|
293
|
+
<span class="rm-dash-editor__entry-title">{{ item.title[lang] }}</span>
|
|
294
|
+
<span class="rm-dash-editor__entry-text">{{ item.description[lang] }}</span>
|
|
295
|
+
</span>
|
|
296
|
+
</button>
|
|
297
|
+
</section>
|
|
298
|
+
</nav>
|
|
299
|
+
|
|
300
|
+
<div v-if="entry" class="rm-dash-editor__main">
|
|
301
|
+
<div class="rm-dash-editor__settings">
|
|
302
|
+
<RmInput
|
|
303
|
+
v-model="title"
|
|
304
|
+
class="rm-dash-editor__wide"
|
|
305
|
+
:label="T.title"
|
|
306
|
+
:placeholder="entry.title[lang]"
|
|
307
|
+
/>
|
|
308
|
+
|
|
309
|
+
<!-- Deux rangees, une par nature de controle : les bascules (choix
|
|
310
|
+
courts, tous visibles) sur une ligne, les listes deroulantes sur
|
|
311
|
+
la suivante. Alternees sur une grille, elles se decalaient d'une
|
|
312
|
+
colonne a l'autre. -->
|
|
313
|
+
<div class="rm-dash-editor__row rm-dash-editor__wide">
|
|
314
|
+
<div class="rm-dash-editor__field">
|
|
315
|
+
<span class="rm-dash-editor__label">{{ T.display }}</span>
|
|
316
|
+
<RmSegmented v-model="viz" size="sm" :options="vizOptions" :aria-label="T.display" />
|
|
317
|
+
</div>
|
|
318
|
+
|
|
319
|
+
<div v-for="param in toggles" :key="param.key" class="rm-dash-editor__field">
|
|
320
|
+
<span class="rm-dash-editor__label">{{ param.label[lang] }}</span>
|
|
321
|
+
<RmSegmented
|
|
322
|
+
v-model="values[param.key]"
|
|
323
|
+
size="sm"
|
|
324
|
+
:options="param.options.map(o => ({ value: o.value, label: o.label[lang] }))"
|
|
325
|
+
:aria-label="param.label[lang]"
|
|
326
|
+
/>
|
|
327
|
+
</div>
|
|
328
|
+
|
|
329
|
+
<div v-if="entry.usesBrands" class="rm-dash-editor__field">
|
|
330
|
+
<span class="rm-dash-editor__label">{{ T.brands }}</span>
|
|
331
|
+
<RmSegmented
|
|
332
|
+
v-model="brandMode"
|
|
333
|
+
size="sm"
|
|
334
|
+
:options="[
|
|
335
|
+
{ value: 'project', label: scopeLabel ?? T.scope },
|
|
336
|
+
{ value: 'pick', label: T.pick, disabled: !context.brands.length },
|
|
337
|
+
]"
|
|
338
|
+
:aria-label="T.brands"
|
|
339
|
+
/>
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
|
|
343
|
+
<!-- Les listes portent leur propre libelle (RmDropdownSelect) : pas de second titre au-dessus. -->
|
|
344
|
+
<div v-if="lists.length || (entry.usesBrands && brandMode === 'pick')" class="rm-dash-editor__row rm-dash-editor__wide">
|
|
345
|
+
<RmDropdownSelect
|
|
346
|
+
v-for="param in lists"
|
|
347
|
+
:key="param.key"
|
|
348
|
+
:model-value="optionLabel(param, values[param.key])"
|
|
349
|
+
:label="param.label[lang]"
|
|
350
|
+
:options="param.options.map(o => o.label[lang])"
|
|
351
|
+
:lang="lang"
|
|
352
|
+
@change="(label: string) => (values[param.key] = optionValue(param, label))"
|
|
353
|
+
/>
|
|
354
|
+
<RmMultipleSelect
|
|
355
|
+
v-if="entry.usesBrands && brandMode === 'pick'"
|
|
356
|
+
v-model="picked"
|
|
357
|
+
:label="T.brands"
|
|
358
|
+
:options="context.brands"
|
|
359
|
+
search
|
|
360
|
+
:lang="lang"
|
|
361
|
+
/>
|
|
362
|
+
</div>
|
|
363
|
+
|
|
364
|
+
<p v-if="entry.usesBrands" class="rm-dash-editor__hint rm-dash-editor__wide">{{ brandHint }}</p>
|
|
365
|
+
|
|
366
|
+
<!-- Les cartes sont celles des pages de l'app : elles gardent leurs champs. -->
|
|
367
|
+
<div v-if="viz === 'table' && available.length" class="rm-dash-editor__field rm-dash-editor__wide">
|
|
368
|
+
<span class="rm-dash-editor__label">{{ T.columns }}</span>
|
|
369
|
+
<!-- Les colonnes affichees, dans leur ordre : on les glisse, ou on
|
|
370
|
+
les deplace aux fleches (le glisser n'existe pas au clavier). -->
|
|
371
|
+
<ol class="rm-dash-editor__order" :aria-label="T.order">
|
|
372
|
+
<li
|
|
373
|
+
v-for="(key, index) in shown"
|
|
374
|
+
:key="key"
|
|
375
|
+
class="rm-dash-editor__chip"
|
|
376
|
+
:class="{ 'is-dragged': dragged === key, 'is-target': target === index && dragged !== key }"
|
|
377
|
+
draggable="true"
|
|
378
|
+
@dragstart="onDragStart($event, key)"
|
|
379
|
+
@dragover.prevent="target = index"
|
|
380
|
+
@dragleave="target === index && (target = -1)"
|
|
381
|
+
@drop.prevent="onDrop(index)"
|
|
382
|
+
@dragend="dragged = ''; target = -1"
|
|
383
|
+
>
|
|
384
|
+
<RmIcon name="DotsSixVertical" :size="14" class="rm-dash-editor__chip-grip" aria-hidden="true" />
|
|
385
|
+
<span class="rm-dash-editor__chip-label">{{ labelOf(key) }}</span>
|
|
386
|
+
<button
|
|
387
|
+
type="button"
|
|
388
|
+
class="rm-dash-editor__chip-btn"
|
|
389
|
+
:disabled="index === 0"
|
|
390
|
+
:aria-label="T.left(labelOf(key))"
|
|
391
|
+
@click="moveColumn(key, -1)"
|
|
392
|
+
>
|
|
393
|
+
<RmIcon name="CaretLeft" :size="12" />
|
|
394
|
+
</button>
|
|
395
|
+
<button
|
|
396
|
+
type="button"
|
|
397
|
+
class="rm-dash-editor__chip-btn"
|
|
398
|
+
:disabled="index === shown.length - 1"
|
|
399
|
+
:aria-label="T.right(labelOf(key))"
|
|
400
|
+
@click="moveColumn(key, 1)"
|
|
401
|
+
>
|
|
402
|
+
<RmIcon name="CaretRight" :size="12" />
|
|
403
|
+
</button>
|
|
404
|
+
<button
|
|
405
|
+
type="button"
|
|
406
|
+
class="rm-dash-editor__chip-btn"
|
|
407
|
+
:disabled="shown.length === 1"
|
|
408
|
+
:aria-label="T.hide(labelOf(key))"
|
|
409
|
+
@click="toggleColumn(key)"
|
|
410
|
+
>
|
|
411
|
+
<RmIcon name="X" :size="12" />
|
|
412
|
+
</button>
|
|
413
|
+
</li>
|
|
414
|
+
</ol>
|
|
415
|
+
<div v-if="hidden.length" class="rm-dash-editor__columns">
|
|
416
|
+
<span class="rm-dash-editor__hint">{{ T.addColumn }}</span>
|
|
417
|
+
<RmPill
|
|
418
|
+
v-for="column in hidden"
|
|
419
|
+
:key="column.key"
|
|
420
|
+
:label="`+ ${column.label}`"
|
|
421
|
+
type="button"
|
|
422
|
+
@click="toggleColumn(column.key)"
|
|
423
|
+
/>
|
|
424
|
+
</div>
|
|
425
|
+
<p class="rm-dash-editor__hint">
|
|
426
|
+
{{ T.orderHint }}
|
|
427
|
+
<button v-if="columns" type="button" class="rm-dash-editor__link" @click="columns = null">
|
|
428
|
+
{{ T.reset }}
|
|
429
|
+
</button>
|
|
430
|
+
</p>
|
|
431
|
+
</div>
|
|
432
|
+
</div>
|
|
433
|
+
|
|
434
|
+
<div class="rm-dash-editor__preview">
|
|
435
|
+
<span class="rm-dash-editor__label">{{ T.preview }}</span>
|
|
436
|
+
<div class="rm-dash-editor__preview-frame">
|
|
437
|
+
<RmDashboardWidget
|
|
438
|
+
v-if="draft"
|
|
439
|
+
:key="draft.kind"
|
|
440
|
+
:widget="draft"
|
|
441
|
+
:kind="entry"
|
|
442
|
+
:context="context"
|
|
443
|
+
:source="sourceOf(entry)"
|
|
444
|
+
:editing="false"
|
|
445
|
+
:chart-height="320"
|
|
446
|
+
:lang="lang"
|
|
447
|
+
:no-brands-text="noBrandsText"
|
|
448
|
+
@data="onPreviewData"
|
|
449
|
+
>
|
|
450
|
+
<template #cards="slotProps">
|
|
451
|
+
<slot name="cards" v-bind="slotProps" />
|
|
452
|
+
</template>
|
|
453
|
+
</RmDashboardWidget>
|
|
454
|
+
</div>
|
|
455
|
+
</div>
|
|
456
|
+
</div>
|
|
457
|
+
</div>
|
|
458
|
+
|
|
459
|
+
<template #footer>
|
|
460
|
+
<div class="rm-dash-editor__footer">
|
|
461
|
+
<RmButton variant="tertiary" @click="emit('update:modelValue', false)">
|
|
462
|
+
{{ T.cancel }}
|
|
463
|
+
</RmButton>
|
|
464
|
+
<RmButton variant="primary" :disabled="!canSave" @click="save">
|
|
465
|
+
{{ widget ? T.save : T.addToBoard }}
|
|
466
|
+
</RmButton>
|
|
467
|
+
</div>
|
|
468
|
+
</template>
|
|
469
|
+
</RmDrawer>
|
|
470
|
+
</template>
|
|
471
|
+
|
|
472
|
+
<style scoped>
|
|
473
|
+
.rm-dash-editor__title{font-family:var(--font-serif);font-size:var(--font-22);font-weight:var(--weight-normal);margin:0}.rm-dash-editor{display:grid;grid-template-columns:320px minmax(0,1fr);height:calc(100% + 48px);margin:-24px}@media (max-width:860px){.rm-dash-editor{grid-template-columns:1fr;height:auto}}.rm-dash-editor__catalog{background:var(--bg-surface-2);border-right:1px solid var(--border-default);display:flex;flex-direction:column;gap:20px;overflow:auto;padding:20px 16px}.rm-dash-editor__group{display:flex;flex-direction:column;gap:8px}.rm-dash-editor__group-label{color:var(--text-muted);font-size:var(--font-11);font-weight:var(--weight-semibold);letter-spacing:.06em;margin:0 4px;text-transform:uppercase}.rm-dash-editor__entry{align-items:flex-start;background:var(--bg-surface);border:1px solid var(--border-soft);border-radius:var(--radius-md);color:var(--text-primary);cursor:pointer;display:flex;gap:12px;padding:12px;text-align:left;transition:border-color var(--duration-fast) var(--ease-out),box-shadow var(--duration-fast) var(--ease-out);width:100%}.rm-dash-editor__entry:hover{border-color:var(--border-default);box-shadow:var(--shadow-xs)}.rm-dash-editor__entry:focus-visible{box-shadow:var(--shadow-focus);outline:none}.rm-dash-editor__entry.is-active{border-color:var(--color-primary);box-shadow:0 0 0 1px var(--color-primary)}.rm-dash-editor__entry-icon{align-items:center;background:color-mix(in srgb,var(--color-primary) 10%,var(--bg-surface));border-radius:var(--radius-md);color:var(--color-primary);display:inline-flex;flex:none;height:32px;justify-content:center;width:32px}.rm-dash-editor__entry-body{display:flex;flex-direction:column;gap:2px;min-width:0}.rm-dash-editor__entry-title{font-size:var(--font-13);font-weight:var(--weight-semibold);line-height:18px}.rm-dash-editor__entry-text{color:var(--text-secondary);font-size:var(--font-12);line-height:16px}.rm-dash-editor__main{display:flex;flex-direction:column;gap:28px;min-width:0;overflow:auto;padding:24px 32px}.rm-dash-editor__settings{display:grid;gap:16px 24px;grid-template-columns:repeat(2,minmax(0,1fr))}.rm-dash-editor__wide{grid-column:1/-1}.rm-dash-editor__field{align-items:flex-start;display:flex;flex-direction:column;gap:6px;min-width:0}.rm-dash-editor__label{color:var(--text-secondary);font-size:var(--font-12);line-height:14px}.rm-dash-editor__row{align-items:flex-end;display:flex;flex-wrap:wrap;gap:16px 28px}.rm-dash-editor__columns{align-items:center;display:flex;flex-wrap:wrap;gap:6px}.rm-dash-editor__order{display:flex;flex-wrap:wrap;gap:6px;list-style:none;margin:0;padding:0}.rm-dash-editor__chip{align-items:center;background:color-mix(in srgb,var(--color-primary) 8%,var(--bg-surface));border:1px solid var(--color-primary);border-radius:999px;color:var(--text-primary);cursor:grab;display:inline-flex;font-size:var(--font-12);gap:2px;padding:3px 4px 3px 6px;-webkit-user-select:none;-moz-user-select:none;user-select:none}.rm-dash-editor__chip.is-dragged{opacity:.4}.rm-dash-editor__chip.is-target{box-shadow:-4px 0 0 0 var(--color-primary)}.rm-dash-editor__chip-grip{color:var(--text-muted)}.rm-dash-editor__chip-label{margin:0 4px 0 2px;white-space:nowrap}.rm-dash-editor__chip-btn{align-items:center;background:none;border-radius:999px;color:var(--text-secondary);cursor:pointer;display:inline-flex;height:20px;justify-content:center;padding:0;width:20px}.rm-dash-editor__chip-btn:hover:not(:disabled){background:var(--bg-surface-2);color:var(--text-primary)}.rm-dash-editor__chip-btn:disabled{cursor:default;opacity:.3}.rm-dash-editor__chip-btn:focus-visible{box-shadow:var(--shadow-focus);outline:none}.rm-dash-editor__link{background:none;color:var(--color-primary);cursor:pointer;font-size:inherit;margin-left:4px;padding:0;text-decoration:underline}.rm-dash-editor__hint{color:var(--text-muted);font-size:var(--font-12);margin:0}.rm-dash-editor__footer{display:flex;gap:8px;justify-content:flex-end}.rm-dash-editor__preview{display:flex;flex-direction:column;gap:6px}.rm-dash-editor__preview-frame{background:var(--bg-surface-2);border:1px dashed var(--border-default);border-radius:var(--radius-lg);height:480px;padding:16px;position:relative}
|
|
474
|
+
</style>
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<script setup lang="ts">
|
|
6
6
|
import { computed, ref } from 'vue'
|
|
7
7
|
import { useChartTheme } from '../composables/useChartTheme'
|
|
8
|
-
import { rmLineOption, type RmSeriesInput, RM_CHART_HEIGHTS, RM_LINE_LABEL_MAX_SERIES } from '../utils/chart'
|
|
8
|
+
import { rmLineOption, type RmLineLabelFormatter, type RmSeriesInput, RM_CHART_HEIGHTS, RM_LINE_LABEL_MAX_SERIES } from '../utils/chart'
|
|
9
9
|
import { rmCompact, rmNumber } from '../utils/format'
|
|
10
10
|
|
|
11
11
|
const props = withDefaults(defineProps<{
|
|
@@ -58,6 +58,16 @@ const props = withDefaults(defineProps<{
|
|
|
58
58
|
* partout, y compris au survol.
|
|
59
59
|
*/
|
|
60
60
|
valueFormatter?: (value: number) => string
|
|
61
|
+
/**
|
|
62
|
+
* Etiquette de chaque point, quand la valeur seule ne suffit pas : elle
|
|
63
|
+
* recoit aussi le rang du point et celui de sa serie, de quoi ecrire une
|
|
64
|
+
* evolution par rapport au point precedent ou une part du total. Ne touche
|
|
65
|
+
* ni l'axe ni l'infobulle, qui gardent `valueFormatter`.
|
|
66
|
+
*
|
|
67
|
+
* ORIGINE AURIGA : reprend l'etiquette de `arg-chart` en `with-percentages`
|
|
68
|
+
* (« valeur (+x %) ») et les `dataLabels.formatter` passes par `:options`.
|
|
69
|
+
*/
|
|
70
|
+
labelFormatter?: RmLineLabelFormatter
|
|
61
71
|
height?: keyof typeof RM_CHART_HEIGHTS | number
|
|
62
72
|
description?: string
|
|
63
73
|
}>(), {
|
|
@@ -71,6 +81,7 @@ const props = withDefaults(defineProps<{
|
|
|
71
81
|
startAtZero: false,
|
|
72
82
|
numberFormat: undefined,
|
|
73
83
|
valueFormatter: undefined,
|
|
84
|
+
labelFormatter: undefined,
|
|
74
85
|
height: 'standard',
|
|
75
86
|
description: undefined,
|
|
76
87
|
})
|
|
@@ -108,6 +119,7 @@ const option = computed(() => {
|
|
|
108
119
|
legendPosition: props.legendPosition,
|
|
109
120
|
startAtZero: props.startAtZero,
|
|
110
121
|
valueFormatter: formatter.value,
|
|
122
|
+
labelFormatter: props.labelFormatter,
|
|
111
123
|
tooltipFormatter: props.valueFormatter,
|
|
112
124
|
}) as Record<string, any>
|
|
113
125
|
if (built.legend && Object.keys(legendSelected.value).length) {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useRmDashboard - l'etat d'un tableau de bord a widgets : chargement,
|
|
3
|
+
* tableau de depart, sauvegarde temporisee.
|
|
4
|
+
*
|
|
5
|
+
* Le paquet ne sait pas ou le tableau vit : l'app passe `load` et `save`
|
|
6
|
+
* (deux appels d'API, le tableau part en entier), et `defaults`, le tableau
|
|
7
|
+
* de depart d'un ecran jamais enregistre. Chaque geste (deplacer, regler,
|
|
8
|
+
* retirer) l'ecrit apres un court delai : une rafale de fleches au clavier
|
|
9
|
+
* part en une requete, pas en dix.
|
|
10
|
+
*
|
|
11
|
+
* Jamais enregistre (`null`), le tableau est celui de depart, recalcule par
|
|
12
|
+
* `defaults` a chaque fois que ce qu'il lit change (les marques d'un projet,
|
|
13
|
+
* par exemple) : un ecran cree vide puis rempli montre ses donnees sans que
|
|
14
|
+
* personne ait eu a les ajouter. Il ne s'ecrit qu'au premier geste.
|
|
15
|
+
*/
|
|
16
|
+
import { type MaybeRef } from 'vue';
|
|
17
|
+
import type { RmDashboardModel, RmDashboardWidget, RmWidgetKind } from '../utils/dashboard.js';
|
|
18
|
+
export interface RmDashboardOptions {
|
|
19
|
+
/** Le catalogue de l'app : un widget d'un `kind` absent est ignore a l'affichage, pas perdu. */
|
|
20
|
+
catalog: MaybeRef<RmWidgetKind<any>[]>;
|
|
21
|
+
load: () => Promise<RmDashboardModel | null | undefined>;
|
|
22
|
+
save: (model: RmDashboardModel) => Promise<unknown>;
|
|
23
|
+
/**
|
|
24
|
+
* Le tableau de depart. Lu dans un `computed` : il se recalcule quand ce
|
|
25
|
+
* qu'il lit change, et seulement alors, pour que les identifiants des
|
|
26
|
+
* widgets restent stables entre deux rendus.
|
|
27
|
+
*/
|
|
28
|
+
defaults: () => RmDashboardModel;
|
|
29
|
+
/** Delai avant l'ecriture d'un geste, en millisecondes. */
|
|
30
|
+
saveDelay?: number;
|
|
31
|
+
}
|
|
32
|
+
export declare function useRmDashboard(options: RmDashboardOptions): {
|
|
33
|
+
dashboard: import("vue").ComputedRef<RmDashboardModel>;
|
|
34
|
+
widgets: import("vue").ComputedRef<RmDashboardWidget[]>;
|
|
35
|
+
loading: import("vue").Ref<boolean, boolean>;
|
|
36
|
+
failed: import("vue").Ref<boolean, boolean>;
|
|
37
|
+
saving: import("vue").Ref<boolean, boolean>;
|
|
38
|
+
saveFailed: import("vue").Ref<boolean, boolean>;
|
|
39
|
+
load: () => Promise<void>;
|
|
40
|
+
update: (next: RmDashboardWidget[]) => void;
|
|
41
|
+
retrySave: () => Promise<void>;
|
|
42
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { computed, onBeforeUnmount, ref, unref } from "vue";
|
|
2
|
+
export function useRmDashboard(options) {
|
|
3
|
+
const stored = ref(null);
|
|
4
|
+
const fallback = computed(() => options.defaults());
|
|
5
|
+
const loading = ref(true);
|
|
6
|
+
const failed = ref(false);
|
|
7
|
+
const saving = ref(false);
|
|
8
|
+
const saveFailed = ref(false);
|
|
9
|
+
const dashboard = computed(() => stored.value ?? fallback.value);
|
|
10
|
+
const widgets = computed(() => {
|
|
11
|
+
const catalog = unref(options.catalog);
|
|
12
|
+
return dashboard.value.widgets.filter((w) => catalog.some((e) => e.kind === w.kind));
|
|
13
|
+
});
|
|
14
|
+
async function load() {
|
|
15
|
+
loading.value = true;
|
|
16
|
+
failed.value = false;
|
|
17
|
+
try {
|
|
18
|
+
stored.value = await options.load() ?? null;
|
|
19
|
+
} catch {
|
|
20
|
+
failed.value = true;
|
|
21
|
+
} finally {
|
|
22
|
+
loading.value = false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
let timer;
|
|
26
|
+
async function flush() {
|
|
27
|
+
timer = void 0;
|
|
28
|
+
if (!stored.value) return;
|
|
29
|
+
saving.value = true;
|
|
30
|
+
try {
|
|
31
|
+
await options.save(stored.value);
|
|
32
|
+
saveFailed.value = false;
|
|
33
|
+
} catch {
|
|
34
|
+
saveFailed.value = true;
|
|
35
|
+
} finally {
|
|
36
|
+
saving.value = false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function update(next) {
|
|
40
|
+
const unknown = dashboard.value.widgets.filter((w) => !widgets.value.includes(w));
|
|
41
|
+
stored.value = { version: 1, widgets: [...next, ...unknown] };
|
|
42
|
+
if (timer) clearTimeout(timer);
|
|
43
|
+
timer = setTimeout(flush, options.saveDelay ?? 600);
|
|
44
|
+
}
|
|
45
|
+
onBeforeUnmount(() => {
|
|
46
|
+
if (timer) {
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
flush();
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
return { dashboard, widgets, loading, failed, saving, saveFailed, load, update, retrySave: flush };
|
|
52
|
+
}
|
|
@@ -295,7 +295,13 @@ export function useRmExport() {
|
|
|
295
295
|
rowCount += rows.length;
|
|
296
296
|
data.push(rows);
|
|
297
297
|
schema.push(xlsxColumnsOf(sheet, config.columns));
|
|
298
|
-
|
|
298
|
+
const base = sheet.name.replace(/[[\]:*?/\\]/g, " ").slice(0, 31);
|
|
299
|
+
let name = base;
|
|
300
|
+
for (let n = 2; names.some((taken) => taken.toLowerCase() === name.toLowerCase()); n++) {
|
|
301
|
+
const suffix = ` (${n})`;
|
|
302
|
+
name = base.slice(0, 31 - suffix.length) + suffix;
|
|
303
|
+
}
|
|
304
|
+
names.push(name);
|
|
299
305
|
});
|
|
300
306
|
await writeXlsxFile(data, { schema, sheets: names, fileName, stickyRowsCount: 1 });
|
|
301
307
|
return { fileName, rows: rowCount, bytes: 0 };
|
|
@@ -200,6 +200,13 @@ export interface RmSeriesInput {
|
|
|
200
200
|
* (« 1 240 (18 %) »), et c'est l'index qui la retrouve.
|
|
201
201
|
*/
|
|
202
202
|
export type RmBarLabelFormatter = (value: number, index: number) => string;
|
|
203
|
+
/**
|
|
204
|
+
* Etiquette d'un point de courbe, qui connait sa position : rang du point
|
|
205
|
+
* dans la serie et rang de la serie. C'est ce qu'il faut pour ecrire une
|
|
206
|
+
* evolution (« 1 200 (+4,3 %) », par rapport au point precedent) ou une part
|
|
207
|
+
* du total de la periode, la ou `valueFormatter` ne voit que la valeur.
|
|
208
|
+
*/
|
|
209
|
+
export type RmLineLabelFormatter = (value: number, index: number, seriesIndex: number) => string;
|
|
203
210
|
export declare function rmBarVerticalOption(categories: (string | number)[], series: RmSeriesInput[], theme: RmChartTheme, palette: string[], decal?: boolean, formatter?: RmBarLabelFormatter, valueFormatter?: (value: number) => string): {
|
|
204
211
|
tooltip: {
|
|
205
212
|
valueFormatter: (value: unknown) => string;
|
|
@@ -507,6 +514,8 @@ export declare function rmLineOption(categories: (string | number)[], series: Rm
|
|
|
507
514
|
startAtZero?: boolean;
|
|
508
515
|
/** Formate les valeurs, dans les etiquettes comme dans l'infobulle. */
|
|
509
516
|
valueFormatter?: (value: number) => string;
|
|
517
|
+
/** Etiquette de chaque point, a la place de `valueFormatter`. */
|
|
518
|
+
labelFormatter?: RmLineLabelFormatter;
|
|
510
519
|
/**
|
|
511
520
|
* Formate l'infobulle a la place de `rmNumber`. Une app qui impose sa
|
|
512
521
|
* propre regle d'arrondi la veut aussi au survol, pas seulement sur l'axe.
|
|
@@ -737,6 +746,7 @@ export declare function rmLineOption(categories: (string | number)[], series: Rm
|
|
|
737
746
|
borderRadius: number;
|
|
738
747
|
formatter: (p: {
|
|
739
748
|
value: number | null;
|
|
749
|
+
dataIndex: number;
|
|
740
750
|
}) => string;
|
|
741
751
|
} | undefined;
|
|
742
752
|
lineStyle: {
|
|
@@ -229,6 +229,7 @@ export function rmLineOption(categories, series, theme, palette, options = {}) {
|
|
|
229
229
|
legendPosition = "bottom",
|
|
230
230
|
startAtZero = false,
|
|
231
231
|
valueFormatter,
|
|
232
|
+
labelFormatter,
|
|
232
233
|
tooltipFormatter
|
|
233
234
|
} = options;
|
|
234
235
|
const labelled = valueLabels ?? series.length <= RM_LINE_LABEL_MAX_SERIES;
|
|
@@ -310,7 +311,7 @@ export function rmLineOption(categories, series, theme, palette, options = {}) {
|
|
|
310
311
|
backgroundColor: color,
|
|
311
312
|
padding: [3, 5],
|
|
312
313
|
borderRadius: 2,
|
|
313
|
-
formatter: (p) => p.value === null || p.value === void 0 ? "" : format(p.value)
|
|
314
|
+
formatter: (p) => p.value === null || p.value === void 0 ? "" : labelFormatter ? labelFormatter(p.value, p.dataIndex, i) : format(p.value)
|
|
314
315
|
} : void 0,
|
|
315
316
|
lineStyle: { color, width: 2.5 },
|
|
316
317
|
itemStyle: { color },
|