@tekkare/romulus 0.1.38 → 0.1.40
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/assets/styles/components.css +1 -1
- package/dist/runtime/assets/styles/shell.css +1 -1
- package/dist/runtime/components/Button.vue +13 -4
- package/dist/runtime/components/ChartCard.vue +74 -16
- package/dist/runtime/components/ExportPanel.vue +5 -2
- package/dist/runtime/components/FilterDropdown.vue +56 -5
- package/dist/runtime/components/HybridTable.vue +111 -46
- package/dist/runtime/components/Kpi.vue +3 -0
- package/dist/runtime/components/NumberInput.vue +93 -0
- package/dist/runtime/components/PageHeader.vue +39 -5
- package/dist/runtime/components/RailPanel.vue +14 -0
- package/dist/runtime/components/SidebarLeft.vue +38 -7
- package/dist/runtime/components/SidebarRight.vue +11 -2
- package/dist/runtime/components/SimpleBar.vue +48 -57
- package/dist/runtime/components/Table.vue +1 -1
- package/dist/runtime/composables/useRmExport.js +218 -74
- package/dist/runtime/utils/chart.d.ts +28 -9
- package/dist/runtime/utils/chart.js +20 -9
- package/dist/runtime/utils/export.d.ts +48 -3
- package/dist/runtime/utils/export.js +9 -4
- package/package.json +1 -1
|
@@ -1,71 +1,136 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Tableau a barres : un classement ou chaque ligne porte sa part sous forme de
|
|
3
|
+
barre, derriere son libelle, et sa valeur chiffree a droite.
|
|
4
|
+
|
|
5
|
+
Remplace `arg-hybrid-chart` d'Auriga, avec le meme contrat de lecture : la
|
|
6
|
+
barre la plus longue vaut la plus grande part, la valeur garde son pourcentage
|
|
7
|
+
entre parentheses, et un bouton deplie le reste du classement quand `limit`
|
|
8
|
+
coupe la liste.
|
|
9
|
+
|
|
10
|
+
La barre n'est pas un graphe : c'est un tableau, lisible au lecteur d'ecran
|
|
11
|
+
ligne a ligne, ou la barre n'est qu'un fond. C'est ce qui la distingue de
|
|
12
|
+
RmBarChart, qui peint un SVG.
|
|
13
|
+
-->
|
|
1
14
|
<script setup lang="ts">
|
|
2
15
|
import { computed, ref } from 'vue'
|
|
3
|
-
|
|
4
|
-
* HybridTable — a table with inline horizontal bars behind values.
|
|
5
|
-
* Each row shows a label, a value, and a bar proportional to the max value.
|
|
6
|
-
*/
|
|
16
|
+
import { rmNumber } from '../utils/format'
|
|
7
17
|
|
|
8
|
-
export interface
|
|
18
|
+
export interface RmHybridRow {
|
|
9
19
|
label: string
|
|
10
20
|
value: number
|
|
21
|
+
/** Part en pourcent, deja calculee (0-100). Affichee entre parentheses. */
|
|
22
|
+
percentage?: number | string
|
|
23
|
+
/** Colonnes de fin de ligne, alignees a droite apres la valeur. */
|
|
24
|
+
extra?: (string | number)[]
|
|
11
25
|
}
|
|
12
26
|
|
|
13
27
|
const props = withDefaults(defineProps<{
|
|
14
|
-
rows:
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
28
|
+
rows: RmHybridRow[]
|
|
29
|
+
/**
|
|
30
|
+
* En-tetes de colonnes : le libelle, la valeur, puis un titre par colonne
|
|
31
|
+
* `extra`. Vide : le tableau n'a pas de tete.
|
|
32
|
+
*/
|
|
33
|
+
columns?: string[]
|
|
34
|
+
/**
|
|
35
|
+
* Couleur de la barre. Un chiffre de 1 a 6 prend la couleur categorielle du
|
|
36
|
+
* theme (`--chart-cat-N`), qui suit le mode nuit et les modes daltoniens ;
|
|
37
|
+
* une chaine passe telle quelle, pour une couleur imposee par la page.
|
|
38
|
+
*/
|
|
39
|
+
accent?: number | string
|
|
40
|
+
/** Nombre de lignes visibles au repos. 0 : tout le classement. */
|
|
41
|
+
limit?: number
|
|
42
|
+
/** Langue du bouton de depliage. Le reste du texte vient de la page. */
|
|
43
|
+
lang?: 'fr' | 'en'
|
|
44
|
+
/** Formatage de la valeur. Par defaut le format francais (rmNumber). */
|
|
45
|
+
valueFormatter?: (value: number) => string
|
|
19
46
|
}>(), {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
47
|
+
columns: () => [],
|
|
48
|
+
accent: 1,
|
|
49
|
+
limit: 0,
|
|
50
|
+
lang: 'fr',
|
|
51
|
+
valueFormatter: rmNumber,
|
|
24
52
|
})
|
|
25
53
|
|
|
54
|
+
const emit = defineEmits<{ showState: [value: boolean] }>()
|
|
55
|
+
|
|
26
56
|
const showAll = ref(false)
|
|
27
57
|
|
|
28
|
-
const
|
|
58
|
+
const barColor = computed(() =>
|
|
59
|
+
typeof props.accent === 'number' ? `var(--chart-cat-${props.accent})` : props.accent,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
const visibleRows = computed(() =>
|
|
63
|
+
props.limit && !showAll.value ? props.rows.slice(0, props.limit) : props.rows,
|
|
64
|
+
)
|
|
29
65
|
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
66
|
+
const hasMore = computed(() => Boolean(props.limit) && props.rows.length > props.limit)
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* La barre se lit en relatif : la plus grande part occupe toute la largeur,
|
|
70
|
+
* les autres se mesurent contre elle. Une barre proportionnelle a la part
|
|
71
|
+
* absolue serait illisible des que le premier du classement pese 3 %.
|
|
72
|
+
*/
|
|
73
|
+
const reference = computed(() => {
|
|
74
|
+
const shares = props.rows.map(share)
|
|
75
|
+
return Math.max(...shares, 0) || 1
|
|
33
76
|
})
|
|
34
77
|
|
|
35
|
-
|
|
78
|
+
function share(row: RmHybridRow) {
|
|
79
|
+
if (row.percentage !== undefined && row.percentage !== null && row.percentage !== '') {
|
|
80
|
+
return Number(row.percentage)
|
|
81
|
+
}
|
|
82
|
+
// Sans part fournie, la valeur se rapporte au total affiche.
|
|
83
|
+
const total = props.rows.reduce((sum, r) => sum + r.value, 0)
|
|
84
|
+
return total ? (row.value * 100) / total : 0
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function barWidth(row: RmHybridRow) {
|
|
88
|
+
return `${Math.min((share(row) * 100) / reference.value, 100)}%`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function toggle() {
|
|
92
|
+
showAll.value = !showAll.value
|
|
93
|
+
emit('showState', showAll.value)
|
|
94
|
+
}
|
|
36
95
|
</script>
|
|
37
96
|
|
|
38
97
|
<template>
|
|
39
|
-
<div class="hybrid
|
|
40
|
-
<
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
>
|
|
48
|
-
|
|
49
|
-
<
|
|
50
|
-
<
|
|
51
|
-
class="
|
|
52
|
-
:
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
98
|
+
<div class="rm-hybrid">
|
|
99
|
+
<table class="rm-hybrid__table">
|
|
100
|
+
<thead v-if="columns.length" class="rm-hybrid__head">
|
|
101
|
+
<tr>
|
|
102
|
+
<th v-for="(column, index) in columns" :key="index" class="rm-hybrid__th">
|
|
103
|
+
{{ column }}
|
|
104
|
+
</th>
|
|
105
|
+
</tr>
|
|
106
|
+
</thead>
|
|
107
|
+
<tbody>
|
|
108
|
+
<tr v-for="(row, index) in visibleRows" :key="index" class="rm-hybrid__row">
|
|
109
|
+
<th scope="row" class="rm-hybrid__label_cell">
|
|
110
|
+
<span class="rm-hybrid__bar" :style="{ background: barColor, width: barWidth(row) }" />
|
|
111
|
+
<span class="rm-hybrid__label" :title="row.label">{{ row.label }}</span>
|
|
112
|
+
</th>
|
|
113
|
+
<td class="rm-hybrid__value">
|
|
114
|
+
{{ valueFormatter(row.value) }}
|
|
115
|
+
<span v-if="row.percentage !== undefined && row.percentage !== null" class="rm-hybrid__share">
|
|
116
|
+
({{ row.percentage }} %)
|
|
117
|
+
</span>
|
|
118
|
+
</td>
|
|
119
|
+
<td v-for="(cell, cellIndex) in row.extra ?? []" :key="cellIndex" class="rm-hybrid__value">
|
|
120
|
+
{{ cell }}
|
|
121
|
+
</td>
|
|
122
|
+
</tr>
|
|
123
|
+
</tbody>
|
|
124
|
+
</table>
|
|
58
125
|
|
|
59
|
-
<
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
>
|
|
64
|
-
{{ showAll ? 'Show less' : `Show all (${rows.length})` }}
|
|
65
|
-
</button>
|
|
126
|
+
<div v-if="hasMore" class="rm-hybrid__footer">
|
|
127
|
+
<RmButton variant="outline" size="sm" :prefix-icon="showAll ? 'CornersIn' : 'CornersOut'" @click="toggle">
|
|
128
|
+
{{ showAll ? (lang === 'fr' ? 'Voir moins' : 'Show less') : (lang === 'fr' ? 'Voir plus' : 'Show more') }}
|
|
129
|
+
</RmButton>
|
|
130
|
+
</div>
|
|
66
131
|
</div>
|
|
67
132
|
</template>
|
|
68
133
|
|
|
69
134
|
<style scoped>
|
|
70
|
-
.hybrid
|
|
135
|
+
.rm-hybrid{font-family:var(--font-sans);width:100%}.rm-hybrid__table{border-collapse:collapse;table-layout:auto;width:100%}.rm-hybrid__th{color:var(--text-muted);font-size:var(--font-12);font-weight:var(--weight-normal);line-height:14px;padding:4px 10px;text-align:right;white-space:nowrap}.rm-hybrid__th:first-child{padding-left:0;text-align:left}.rm-hybrid__th:last-child{padding-right:0}.rm-hybrid__row{height:34px}.rm-hybrid__label_cell{font-weight:var(--weight-normal);padding:0;position:relative;text-align:left;width:100%}.rm-hybrid__bar{border-radius:var(--radius-sm);bottom:2px;left:0;opacity:.14;position:absolute;top:2px}.rm-hybrid__label{display:block;overflow:hidden;padding:6px 10px 6px 8px;position:relative;text-overflow:ellipsis}.rm-hybrid__label,.rm-hybrid__value{color:var(--text-primary);font-size:var(--font-13);white-space:nowrap}.rm-hybrid__value{font-variant-numeric:tabular-nums;font-weight:var(--weight-medium);padding:6px 10px;text-align:right}.rm-hybrid__value:last-child{padding-right:0}.rm-hybrid__share{color:var(--text-muted);font-weight:var(--weight-normal);margin-left:2px}.rm-hybrid__footer{display:flex;justify-content:center;margin-top:16px}
|
|
71
136
|
</style>
|
|
@@ -59,5 +59,8 @@ const trendText = computed(() =>
|
|
|
59
59
|
<span v-if="trendLabel">{{ trendLabel }}</span>
|
|
60
60
|
</p>
|
|
61
61
|
<p v-if="note" class="kpi-foot">{{ note }}</p>
|
|
62
|
+
<!-- Complement de tuile : une repartition, une mini-courbe. Le contenu
|
|
63
|
+
projete reste sous le pied, pour ne pas repousser la valeur. -->
|
|
64
|
+
<div v-if="$slots.default" class="kpi-extra"><slot /></div>
|
|
62
65
|
</div>
|
|
63
66
|
</template>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Champ numerique borne, pour les filtres qui prennent un nombre plutot qu'une
|
|
3
|
+
liste : un "Top N", un seuil, un nombre de mois.
|
|
4
|
+
|
|
5
|
+
Remplace `arg-number-input` d'Auriga. Meme hauteur et meme rayon que la puce
|
|
6
|
+
de filtre (RmFilterDropdown) : les deux se rangent sur la meme rangee.
|
|
7
|
+
|
|
8
|
+
La valeur est toujours ramenee dans les bornes, y compris a la frappe : un
|
|
9
|
+
filtre qui laisse saisir 5000 quand la donnee s'arrete a 100 renvoie une page
|
|
10
|
+
vide sans rien dire.
|
|
11
|
+
-->
|
|
12
|
+
<script setup lang="ts">
|
|
13
|
+
import { computed } from 'vue'
|
|
14
|
+
|
|
15
|
+
const props = withDefaults(defineProps<{
|
|
16
|
+
/** Libelle du champ, au-dessus. Vide : aucun libelle. */
|
|
17
|
+
label?: string
|
|
18
|
+
/** Borne basse. */
|
|
19
|
+
min?: number
|
|
20
|
+
/** Borne haute. */
|
|
21
|
+
max?: number
|
|
22
|
+
/** Pas des boutons moins et plus. */
|
|
23
|
+
step?: number
|
|
24
|
+
}>(), {
|
|
25
|
+
label: undefined,
|
|
26
|
+
min: 0,
|
|
27
|
+
max: Number.MAX_SAFE_INTEGER,
|
|
28
|
+
step: 1,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const model = defineModel<number>({ default: 0 })
|
|
32
|
+
|
|
33
|
+
const atMin = computed(() => model.value <= props.min)
|
|
34
|
+
const atMax = computed(() => model.value >= props.max)
|
|
35
|
+
|
|
36
|
+
function clamp(value: number) {
|
|
37
|
+
if (Number.isNaN(value)) return props.min
|
|
38
|
+
return Math.min(Math.max(value, props.min), props.max)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function nudge(delta: number) {
|
|
42
|
+
model.value = clamp(model.value + delta)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function onInput(event: Event) {
|
|
46
|
+
const input = event.target as HTMLInputElement
|
|
47
|
+
const next = clamp(Number(input.value))
|
|
48
|
+
model.value = next
|
|
49
|
+
// Le champ affiche ce qui est retenu : sans ca, une saisie hors bornes reste
|
|
50
|
+
// a l'ecran alors que la page filtre sur autre chose.
|
|
51
|
+
if (String(next) !== input.value) input.value = String(next)
|
|
52
|
+
}
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
<template>
|
|
56
|
+
<div class="rm-number">
|
|
57
|
+
<label v-if="label" class="rm-number__label">{{ label }}</label>
|
|
58
|
+
<div class="rm-number__box">
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
class="rm-number__step"
|
|
62
|
+
:disabled="atMin"
|
|
63
|
+
:aria-label="`Diminuer${label ? ' ' + label : ''}`"
|
|
64
|
+
@click="nudge(-step)"
|
|
65
|
+
>
|
|
66
|
+
<RmIcon name="Minus" :size="14" />
|
|
67
|
+
</button>
|
|
68
|
+
<input
|
|
69
|
+
class="rm-number__input"
|
|
70
|
+
type="number"
|
|
71
|
+
:value="model"
|
|
72
|
+
:min="min"
|
|
73
|
+
:max="max"
|
|
74
|
+
:step="step"
|
|
75
|
+
:aria-label="label"
|
|
76
|
+
@input="onInput"
|
|
77
|
+
/>
|
|
78
|
+
<button
|
|
79
|
+
type="button"
|
|
80
|
+
class="rm-number__step"
|
|
81
|
+
:disabled="atMax"
|
|
82
|
+
:aria-label="`Augmenter${label ? ' ' + label : ''}`"
|
|
83
|
+
@click="nudge(step)"
|
|
84
|
+
>
|
|
85
|
+
<RmIcon name="Plus" :size="14" />
|
|
86
|
+
</button>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
</template>
|
|
90
|
+
|
|
91
|
+
<style scoped>
|
|
92
|
+
.rm-number{display:flex;flex-direction:column;font-family:var(--font-sans);gap:6px}.rm-number__label{color:var(--text-muted);font-size:var(--font-12);font-weight:var(--weight-medium)}.rm-number__box{align-items:center;background:var(--bg-surface);border:1px solid var(--border-default);border-radius:6px;box-sizing:border-box;display:flex;height:32px;overflow:hidden;width:193px}.rm-number__step{align-items:center;background:none;border:none;color:var(--text-secondary);cursor:pointer;display:flex;flex:none;height:100%;justify-content:center;transition:background var(--duration-fast) var(--ease-in-out);width:32px}.rm-number__step:hover:not(:disabled){background:var(--bg-hover)}.rm-number__step:disabled{color:var(--text-muted);cursor:not-allowed;opacity:.5}.rm-number__input{-moz-appearance:textfield;-webkit-appearance:textfield;appearance:textfield;background:none;border:none;color:var(--text-primary);flex:1;font-family:inherit;font-size:var(--font-14);font-weight:var(--weight-medium);height:100%;min-width:0;outline:none;text-align:center}.rm-number__input::-webkit-inner-spin-button,.rm-number__input::-webkit-outer-spin-button{-webkit-appearance:none;appearance:none;margin:0}
|
|
93
|
+
</style>
|
|
@@ -5,22 +5,40 @@
|
|
|
5
5
|
se colle en haut de la colonne de contenu quand elle defile ; la barre de
|
|
6
6
|
filtres (RmFilterBar) se cale sous lui en suivant son id `rm_page_header`.
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
Il porte aussi les onglets de la page, sous le titre : c'etait la derniere
|
|
9
|
+
raison de garder `arg-header` dans les apps. Le dessin est celui des onglets
|
|
10
|
+
du bandeau de cadre (RmAppHeader) : libelle souligne quand il est actif.
|
|
11
|
+
|
|
12
|
+
Slots : #title (a la place de la prop), #actions (a droite du titre),
|
|
13
|
+
defaut (sous la rangee du titre, avant les onglets).
|
|
9
14
|
-->
|
|
10
15
|
<script setup lang="ts">
|
|
11
16
|
import { useRouter } from 'vue-router'
|
|
17
|
+
import type { RmHeaderTab } from './AppHeader.vue'
|
|
12
18
|
|
|
13
19
|
withDefaults(
|
|
14
20
|
defineProps<{
|
|
15
21
|
title?: string | null
|
|
22
|
+
/**
|
|
23
|
+
* Sur-titre, au-dessus du titre : le domaine dont la page traite
|
|
24
|
+
* ("Retail & Hospital Drugs"). Le titre seul ne dit pas toujours de quel
|
|
25
|
+
* jeu de donnees il parle.
|
|
26
|
+
*/
|
|
27
|
+
eyebrow?: string | null
|
|
16
28
|
/** Fleche de retour a gauche du titre. */
|
|
17
29
|
haveReturn?: boolean
|
|
18
30
|
/** Destination du retour ; sans valeur, l'historique. */
|
|
19
31
|
backTo?: string | null
|
|
32
|
+
/** Onglets de la page. La page dit lequel est actif et ecoute `tab`. */
|
|
33
|
+
tabs?: RmHeaderTab[]
|
|
34
|
+
/** Nom du groupe d'onglets pour les lecteurs d'ecran. */
|
|
35
|
+
tabsLabel?: string
|
|
20
36
|
}>(),
|
|
21
|
-
{ title: null, haveReturn: false, backTo: null },
|
|
37
|
+
{ title: null, eyebrow: null, haveReturn: false, backTo: null, tabs: () => [], tabsLabel: undefined },
|
|
22
38
|
)
|
|
23
39
|
|
|
40
|
+
const emit = defineEmits<{ tab: [key: string] }>()
|
|
41
|
+
|
|
24
42
|
const router = useRouter()
|
|
25
43
|
</script>
|
|
26
44
|
|
|
@@ -36,15 +54,31 @@ const router = useRouter()
|
|
|
36
54
|
</button>
|
|
37
55
|
</template>
|
|
38
56
|
|
|
39
|
-
<
|
|
40
|
-
|
|
57
|
+
<div class="rm-page-header__heading">
|
|
58
|
+
<p v-if="eyebrow" class="rm-page-header__eyebrow">{{ eyebrow }}</p>
|
|
59
|
+
<h1 v-if="title" class="rm-page-header__title">{{ title }}</h1>
|
|
60
|
+
<div v-else class="rm-page-header__title"><slot name="title" /></div>
|
|
61
|
+
</div>
|
|
41
62
|
|
|
42
63
|
<div v-if="$slots.actions" class="rm-page-header__actions"><slot name="actions" /></div>
|
|
43
64
|
</div>
|
|
44
65
|
<slot />
|
|
66
|
+
|
|
67
|
+
<div v-if="tabs.length" class="rm-page-header__tabs" role="tablist" :aria-label="tabsLabel">
|
|
68
|
+
<button
|
|
69
|
+
v-for="tab in tabs"
|
|
70
|
+
:key="tab.key"
|
|
71
|
+
class="rm-page-header__tab"
|
|
72
|
+
:class="{ 'rm-page-header__tab--active': tab.active }"
|
|
73
|
+
type="button"
|
|
74
|
+
role="tab"
|
|
75
|
+
:aria-selected="tab.active ? 'true' : 'false'"
|
|
76
|
+
@click="emit('tab', tab.key)"
|
|
77
|
+
>{{ tab.label }}</button>
|
|
78
|
+
</div>
|
|
45
79
|
</header>
|
|
46
80
|
</template>
|
|
47
81
|
|
|
48
82
|
<style scoped>
|
|
49
|
-
.rm-page-header{backdrop-filter:blur(12px);background:rgba(245,245,249,.8);border-bottom:1px solid var(--border-default);box-sizing:border-box;color:var(--text-primary);display:flex;flex-direction:column;font-family:var(--font-sans);gap:12px;padding:16px 32px 12px;position:sticky;top:0;width:100%;z-index:95}.rm-page-header__row{align-items:center;align-self:stretch;display:flex;gap:16px}.rm-page-header__back{background:transparent;border:none;color:inherit;cursor:pointer;display:inline-flex;padding:0}.rm-page-header__title{flex-grow:2;font-size:23px;font-weight:900;line-height:normal;margin:0;min-width:0}.rm-page-header__title :deep(h1),.rm-page-header__title :deep(h2),.rm-page-header__title :deep(p){font-size:inherit;font-weight:inherit;line-height:inherit;margin:0}.rm-page-header__actions{align-items:center;display:flex;flex:0 0 auto;gap:16px}
|
|
83
|
+
.rm-page-header{backdrop-filter:blur(12px);background:rgba(245,245,249,.8);border-bottom:1px solid var(--border-default);box-sizing:border-box;color:var(--text-primary);display:flex;flex-direction:column;font-family:var(--font-sans);gap:12px;padding:16px 32px 12px;position:sticky;top:0;width:100%;z-index:95}.rm-page-header__row{align-items:center;align-self:stretch;display:flex;gap:16px}.rm-page-header__back{background:transparent;border:none;color:inherit;cursor:pointer;display:inline-flex;padding:0}.rm-page-header__heading{flex-grow:2;min-width:0}.rm-page-header__eyebrow{color:var(--text-muted);font-size:var(--font-12);font-weight:var(--weight-medium);margin:0 0 2px}.rm-page-header__title{flex-grow:2;font-size:23px;font-weight:900;line-height:normal;margin:0;min-width:0}.rm-page-header__title :deep(h1),.rm-page-header__title :deep(h2),.rm-page-header__title :deep(p){font-size:inherit;font-weight:inherit;line-height:inherit;margin:0}.rm-page-header__actions{align-items:center;display:flex;flex:0 0 auto;gap:16px}.rm-page-header__tabs{display:flex;gap:8px;margin-bottom:-13px;overflow-x:auto}.rm-page-header__tab{background:none;border:none;border-bottom:3px solid transparent;border-radius:8px 8px 0 0;color:var(--text-muted);cursor:pointer;font-family:inherit;font-size:14px;font-weight:500;line-height:1.6;padding:8px 16px 10px;transition:background-color .3s,color .15s;white-space:nowrap}.rm-page-header__tab:hover{color:var(--color-primary)}.rm-page-header__tab--active{background:color-mix(in srgb,var(--color-primary) 6%,transparent);border-bottom-color:var(--color-primary);color:var(--color-primary);font-weight:600}
|
|
50
84
|
</style>
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
|
|
12
12
|
L'en-tete reprend la rangee de 56px du bandeau pour que le titre du panneau
|
|
13
13
|
s'aligne sur le fil d'Ariane, a la ligne pres.
|
|
14
|
+
|
|
15
|
+
Un voile flou et grise couvre le reste du cadre pendant l'ouverture, barre
|
|
16
|
+
laterale comprise : le panneau est ce qu'on lit, pas ce qu'on lisait. Un
|
|
17
|
+
clic sur le voile referme le panneau, comme un tiroir.
|
|
14
18
|
-->
|
|
15
19
|
<script setup lang="ts">
|
|
16
20
|
withDefaults(defineProps<{
|
|
@@ -30,6 +34,16 @@ const emit = defineEmits<{ close: [] }>()
|
|
|
30
34
|
</script>
|
|
31
35
|
|
|
32
36
|
<template>
|
|
37
|
+
<!-- Voile : le panneau ouvert prend la main, le reste du cadre passe en
|
|
38
|
+
flou grise. Un element a part plutot qu'un `filter` sur le contenu,
|
|
39
|
+
qui creerait un bloc conteneur et decrocherait les bandeaux collants
|
|
40
|
+
des pages. Il prend les clics et referme le panneau : c'est le geste
|
|
41
|
+
attendu de tout ce qui voile la page. Le clavier, lui, garde le bouton
|
|
42
|
+
de fermeture de l'en-tete, seul element expose. -->
|
|
43
|
+
<Transition name="rm-panel-veil">
|
|
44
|
+
<div v-if="open" class="rm-panel__veil" aria-hidden="true" @click="emit('close')" />
|
|
45
|
+
</Transition>
|
|
46
|
+
|
|
33
47
|
<Transition name="rm-panel">
|
|
34
48
|
<aside v-if="open" class="rm-panel" :style="{ '--rm-panel-w': width }">
|
|
35
49
|
<div class="rm-panel__header">
|
|
@@ -77,15 +77,46 @@ const collapsibles = computed(() =>
|
|
|
77
77
|
props.sections.filter(s => s.label && s.collapsible !== false),
|
|
78
78
|
)
|
|
79
79
|
|
|
80
|
-
/**
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
/** Chemin comparable : sans query ni slash final. */
|
|
81
|
+
const trim = (path: string) => (path.split('?')[0] || '').replace(/\/+$/, '') || '/'
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Section ouverte : celle qui porte la page courante, sinon la premiere.
|
|
85
|
+
*
|
|
86
|
+
* L'entree marquee `active` decide d'abord. C'est l'app qui la calcule, et
|
|
87
|
+
* elle en sait plus que nous : son `to` passe par `localePath`, donc prefixe
|
|
88
|
+
* de la locale et parfois suivi d'un slash, quand `activePath` est brut. Une
|
|
89
|
+
* egalite stricte entre les deux ne tombait jamais juste et toutes les pages
|
|
90
|
+
* repliaient la premiere section.
|
|
91
|
+
*
|
|
92
|
+
* A defaut de drapeau, le chemin sert de recours : egalite d'abord, puis
|
|
93
|
+
* prefixe, pour qu'une sous-page (« /prices/jo ») ouvre la section de son
|
|
94
|
+
* entree.
|
|
95
|
+
*/
|
|
96
|
+
const sectionOf = (path?: string) => {
|
|
97
|
+
const byActive = collapsibles.value.find(s => s.items.some(i => i.active))
|
|
98
|
+
if (byActive) return byActive.key
|
|
99
|
+
|
|
100
|
+
const here = path ? trim(path) : null
|
|
101
|
+
if (here) {
|
|
102
|
+
const byPath = collapsibles.value.find(s =>
|
|
103
|
+
s.items.some((i) => {
|
|
104
|
+
if (!i.to) return false
|
|
105
|
+
const to = trim(i.to)
|
|
106
|
+
return here === to || here.startsWith(`${to}/`)
|
|
107
|
+
}),
|
|
108
|
+
)
|
|
109
|
+
if (byPath) return byPath.key
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return collapsibles.value[0]?.key ?? null
|
|
113
|
+
}
|
|
85
114
|
|
|
86
115
|
const openSection = ref<string | null>(sectionOf(props.activePath))
|
|
87
|
-
|
|
88
|
-
|
|
116
|
+
// La cible se deduit des sections autant que du chemin : les deux arrivent
|
|
117
|
+
// ensemble a la navigation, et les droits d'acces peuvent recomposer la liste
|
|
118
|
+
// en cours de route.
|
|
119
|
+
watch(() => sectionOf(props.activePath), (key) => {
|
|
89
120
|
if (key && key !== openSection.value) openSection.value = key
|
|
90
121
|
})
|
|
91
122
|
|
|
@@ -20,6 +20,12 @@ export interface RmRailTile {
|
|
|
20
20
|
label: string
|
|
21
21
|
/** Icone Phosphor, nom en kebab-case sans le prefixe `ph-`. */
|
|
22
22
|
icon: string
|
|
23
|
+
/**
|
|
24
|
+
* Pastille de comptage posee sur la tuile (nombre de sources de la page,
|
|
25
|
+
* par exemple). Rien n'est dessine a zero ou sans valeur : une pastille
|
|
26
|
+
* vide dirait qu'il y a quelque chose a voir.
|
|
27
|
+
*/
|
|
28
|
+
count?: number
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
export interface RmRailLocale {
|
|
@@ -87,11 +93,14 @@ const second = computed(() =>
|
|
|
87
93
|
class="rm-rail__item"
|
|
88
94
|
:class="{ 'rm-rail__item--active': active === tile.key }"
|
|
89
95
|
type="button"
|
|
90
|
-
:title="tile.label"
|
|
96
|
+
:title="tile.count ? `${tile.label} (${tile.count})` : tile.label"
|
|
91
97
|
:aria-pressed="active === tile.key"
|
|
92
98
|
@click="emit('select', tile.key)"
|
|
93
99
|
>
|
|
94
|
-
<span class="rm-rail__tile"
|
|
100
|
+
<span class="rm-rail__tile">
|
|
101
|
+
<i :class="`ph ph-${tile.icon}`" />
|
|
102
|
+
<span v-if="tile.count" class="rm-rail__badge">{{ tile.count > 99 ? '99+' : tile.count }}</span>
|
|
103
|
+
</span>
|
|
95
104
|
<span class="rm-rail__caption">{{ tile.label }}</span>
|
|
96
105
|
</button>
|
|
97
106
|
|
|
@@ -1,86 +1,77 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Barre de repartition : une barre empilee, puis la legende ligne a ligne avec
|
|
3
|
+
la valeur et la part de chaque poste.
|
|
4
|
+
|
|
5
|
+
Remplace `arg-simple-chart` d'Auriga. Pas de moteur de graphe : quelques
|
|
6
|
+
divs, ce qui la rend utilisable a l'interieur d'une tuile KPI ou d'une carte
|
|
7
|
+
etroite, la ou un ECharts serait disproportionne.
|
|
8
|
+
-->
|
|
1
9
|
<script setup lang="ts">
|
|
2
10
|
import { computed } from 'vue'
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
* Pure CSS, no dependencies. Great for small inline visualizations.
|
|
6
|
-
*/
|
|
11
|
+
import { useChartTheme } from '../composables/useChartTheme'
|
|
12
|
+
import { rmNumber } from '../utils/format'
|
|
7
13
|
|
|
8
|
-
export interface
|
|
14
|
+
export interface RmSimpleBarItem {
|
|
9
15
|
label: string
|
|
10
16
|
value: number
|
|
17
|
+
/** Couleur imposee. Sans elle, la couleur categorielle du rang. */
|
|
11
18
|
color?: string
|
|
12
19
|
}
|
|
13
20
|
|
|
14
21
|
const props = withDefaults(defineProps<{
|
|
15
|
-
items:
|
|
22
|
+
items: RmSimpleBarItem[]
|
|
16
23
|
title?: string
|
|
17
|
-
|
|
18
|
-
showPercent?: boolean
|
|
24
|
+
/** Hauteur de la barre empilee, en pixels. */
|
|
19
25
|
height?: number
|
|
26
|
+
/** Masque la legende : la barre seule, quand la place manque. */
|
|
27
|
+
hideLegend?: boolean
|
|
28
|
+
/** Formatage des valeurs de la legende. */
|
|
29
|
+
valueFormatter?: (value: number) => string
|
|
20
30
|
}>(), {
|
|
21
31
|
title: undefined,
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
32
|
+
height: 8,
|
|
33
|
+
hideLegend: false,
|
|
34
|
+
valueFormatter: rmNumber,
|
|
25
35
|
})
|
|
26
36
|
|
|
27
|
-
const {
|
|
37
|
+
const { palette } = useChartTheme()
|
|
38
|
+
|
|
39
|
+
const total = computed(() => props.items.reduce((sum, item) => sum + item.value, 0))
|
|
40
|
+
|
|
41
|
+
function colorOf(item: RmSimpleBarItem, index: number) {
|
|
42
|
+
return item.color ?? palette.value.cat[index % palette.value.cat.length]
|
|
43
|
+
}
|
|
28
44
|
|
|
29
|
-
|
|
30
|
-
|
|
45
|
+
function shareOf(item: RmSimpleBarItem) {
|
|
46
|
+
return total.value ? (item.value * 100) / total.value : 0
|
|
47
|
+
}
|
|
31
48
|
</script>
|
|
32
49
|
|
|
33
50
|
<template>
|
|
34
|
-
<div class="simple-bar">
|
|
35
|
-
<
|
|
51
|
+
<div class="rm-simple-bar">
|
|
52
|
+
<p v-if="title" class="rm-simple-bar__title">{{ title }}</p>
|
|
36
53
|
|
|
37
|
-
|
|
38
|
-
<div v-if="showPercent" class="simple-bar__stacked" :style="{ height: `${height}px` }">
|
|
54
|
+
<div class="rm-simple-bar__track" :style="{ height: `${height}px` }">
|
|
39
55
|
<div
|
|
40
|
-
v-for="(item,
|
|
41
|
-
:key="
|
|
42
|
-
class="simple-bar__segment"
|
|
43
|
-
:style="{
|
|
44
|
-
|
|
45
|
-
backgroundColor: item.color ?? PALETTE[idx % PALETTE.length],
|
|
46
|
-
}"
|
|
56
|
+
v-for="(item, index) in items"
|
|
57
|
+
:key="index"
|
|
58
|
+
class="rm-simple-bar__segment"
|
|
59
|
+
:style="{ width: `${shareOf(item)}%`, background: colorOf(item, index) }"
|
|
60
|
+
:title="`${item.label} : ${valueFormatter(item.value)}`"
|
|
47
61
|
/>
|
|
48
62
|
</div>
|
|
49
63
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
class="simple-
|
|
56
|
-
>
|
|
57
|
-
|
|
58
|
-
<span
|
|
59
|
-
class="simple-bar__dot"
|
|
60
|
-
:style="{ backgroundColor: item.color ?? PALETTE[idx % PALETTE.length] }"
|
|
61
|
-
/>
|
|
62
|
-
<span class="simple-bar__label">{{ item.label }}</span>
|
|
63
|
-
<span v-if="showValues" class="simple-bar__value">
|
|
64
|
-
{{ item.value.toLocaleString() }}
|
|
65
|
-
<span v-if="showPercent" class="simple-bar__pct">
|
|
66
|
-
({{ total > 0 ? ((item.value / total) * 100).toFixed(1) : 0 }}%)
|
|
67
|
-
</span>
|
|
68
|
-
</span>
|
|
69
|
-
</div>
|
|
70
|
-
<div v-if="!showPercent" class="simple-bar__track" :style="{ height: `${height}px` }">
|
|
71
|
-
<div
|
|
72
|
-
class="simple-bar__fill"
|
|
73
|
-
:style="{
|
|
74
|
-
width: `${(item.value / maxValue) * 100}%`,
|
|
75
|
-
backgroundColor: item.color ?? PALETTE[idx % PALETTE.length],
|
|
76
|
-
}"
|
|
77
|
-
/>
|
|
78
|
-
</div>
|
|
79
|
-
</div>
|
|
80
|
-
</div>
|
|
64
|
+
<ul v-if="!hideLegend" class="rm-simple-bar__legend">
|
|
65
|
+
<li v-for="(item, index) in items" :key="index" class="rm-simple-bar__row">
|
|
66
|
+
<span class="rm-simple-bar__dot" :style="{ background: colorOf(item, index) }" />
|
|
67
|
+
<span class="rm-simple-bar__label">{{ item.label }}</span>
|
|
68
|
+
<span class="rm-simple-bar__value">{{ valueFormatter(item.value) }}</span>
|
|
69
|
+
<span class="rm-simple-bar__share">({{ shareOf(item).toFixed(1).replace('.', ',') }} %)</span>
|
|
70
|
+
</li>
|
|
71
|
+
</ul>
|
|
81
72
|
</div>
|
|
82
73
|
</template>
|
|
83
74
|
|
|
84
75
|
<style scoped>
|
|
85
|
-
.simple-bar{font-family:var(--
|
|
76
|
+
.rm-simple-bar{font-family:var(--font-sans)}.rm-simple-bar__title{color:var(--text-muted);font-size:var(--font-12);font-weight:var(--weight-medium);margin:0 0 8px}.rm-simple-bar__track{background:var(--bg-surface-2);border-radius:999px;display:flex;overflow:hidden}.rm-simple-bar__segment{height:100%}.rm-simple-bar__legend{list-style:none;margin:10px 0 0;padding:0}.rm-simple-bar__row{align-items:center;display:flex;font-size:var(--font-12);gap:6px;padding:3px 0}.rm-simple-bar__dot{border-radius:2px;flex:none;height:8px;width:8px}.rm-simple-bar__label{color:var(--text-secondary);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rm-simple-bar__value{color:var(--text-primary);font-variant-numeric:tabular-nums;font-weight:var(--weight-medium)}.rm-simple-bar__share{color:var(--text-muted)}
|
|
86
77
|
</style>
|
|
@@ -148,7 +148,7 @@ const sortedRows = computed(() => {
|
|
|
148
148
|
:class="col.align && `rm-table__td--${col.align}`"
|
|
149
149
|
>
|
|
150
150
|
<slot :name="col.key" :value="getCellValue(row, col.key)" :row="row" :index="idx">
|
|
151
|
-
{{ getCellValue(row, col.key) ?? '
|
|
151
|
+
{{ getCellValue(row, col.key) ?? '-' }}
|
|
152
152
|
</slot>
|
|
153
153
|
</td>
|
|
154
154
|
</tr>
|