@tekkare/romulus 0.1.210 → 0.1.212

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.
@@ -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) {
@@ -77,7 +77,7 @@ const emit = defineEmits<{
77
77
 
78
78
  const WORDS = {
79
79
  fr: {
80
- key: 'Vue :',
80
+ key: 'Changer de vue :',
81
81
  none: 'Aucune vue',
82
82
  favorites: 'Favoris',
83
83
  actions: 'Actions',
@@ -88,7 +88,7 @@ const WORDS = {
88
88
  aria: 'Vue affichée',
89
89
  },
90
90
  en: {
91
- key: 'View:',
91
+ key: 'Change view:',
92
92
  none: 'No view',
93
93
  favorites: 'Favorites',
94
94
  actions: 'Actions',
@@ -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
- names.push(sheet.name.replace(/[[\]:*?/\\]/g, " ").slice(0, 31));
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: {