@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
|
@@ -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>
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
Slots : #CardTitle (libelle de la puce si `customTitle`), #Rail (sur la
|
|
15
15
|
barre, a cote de la puce : le reglage qu'on change tout le temps et qui ne
|
|
16
16
|
merite pas d'ouvrir le panneau ; il passe sur sa propre rangee quand la page
|
|
17
|
-
fournit aussi #Search), #Search, #Filters.
|
|
17
|
+
fournit aussi #Search), #Search, #Filters. Sans #Filters, ni puce ni
|
|
18
|
+
panneau : une page dont le seul reglage tient dans #Rail n'a rien a ouvrir.
|
|
18
19
|
Le contenu projete vient encore, dans les apps, des champs Auriga
|
|
19
20
|
(arg-advanced-search-bar) : les regles `:deep` en bas les remettent a la
|
|
20
21
|
hauteur et au rayon de la rangee. Elles tomberont avec ces champs.
|
|
@@ -173,7 +174,7 @@ onBeforeUnmount(() => {
|
|
|
173
174
|
en offrait le double. -->
|
|
174
175
|
<div class="rm-fbar__band">
|
|
175
176
|
<div class="rm-fbar__row">
|
|
176
|
-
<button type="button" class="rm-fbar__toggle" :class="{ 'is-active': open }" @click="toggle('filters')">
|
|
177
|
+
<button v-if="$slots.Filters" type="button" class="rm-fbar__toggle" :class="{ 'is-active': open }" @click="toggle('filters')">
|
|
177
178
|
<RmIcon name="Funnel" :size="16" />
|
|
178
179
|
<span v-if="customTitle"><slot name="CardTitle" /></span>
|
|
179
180
|
<span v-else>{{ t.title }}</span>
|
|
@@ -253,7 +254,7 @@ onBeforeUnmount(() => {
|
|
|
253
254
|
</div>
|
|
254
255
|
</div>
|
|
255
256
|
|
|
256
|
-
<div v-show="open" class="rm-fbar__panel">
|
|
257
|
+
<div v-if="$slots.Filters" v-show="open" class="rm-fbar__panel">
|
|
257
258
|
<!-- Le panneau se presente comme une section : son titre, et les
|
|
258
259
|
commandes juste a cote, pres des filtres qu'elles vident. -->
|
|
259
260
|
<div class="rm-fbar__panel-head">
|
|
@@ -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 };
|