@tekkare/romulus 0.1.99 → 0.1.101
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/AlertCard.vue +278 -0
- package/dist/runtime/components/AlertForm.vue +262 -0
- package/dist/runtime/components/Alerts.vue +222 -0
- package/dist/runtime/components/ExportButton.vue +226 -0
- package/dist/runtime/utils/alerts.d.ts +77 -0
- package/dist/runtime/utils/alerts.js +0 -0
- package/package.json +1 -1
package/dist/module.json
CHANGED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmAlertCard - une alerte posee sur une vue sauvegardee.
|
|
3
|
+
|
|
4
|
+
Une alerte surveille le perimetre d'une vue et rapporte ce qui y apparait,
|
|
5
|
+
y change ou en sort. La carte dit trois choses dans cet ordre : ce qu'elle
|
|
6
|
+
surveille, comment elle previent, et si elle fonctionne.
|
|
7
|
+
|
|
8
|
+
Ce dernier point n'est pas decoratif. Une alerte a des etats ou elle ne
|
|
9
|
+
previendra de rien — reference en cours de capture, vue modifiee sans que
|
|
10
|
+
sa requete suive, dernier passage refuse — et une alerte muette dont
|
|
11
|
+
personne ne sait qu'elle est muette est pire que pas d'alerte du tout. Ces
|
|
12
|
+
etats prennent donc un bandeau en haut de la carte, pas une nuance de
|
|
13
|
+
couleur sur la pastille.
|
|
14
|
+
-->
|
|
15
|
+
<script setup lang="ts">
|
|
16
|
+
import { computed, ref } from 'vue'
|
|
17
|
+
import type { RmAlert, RmAlertMenuItem } from '../utils/alerts'
|
|
18
|
+
|
|
19
|
+
const props = withDefaults(
|
|
20
|
+
defineProps<{
|
|
21
|
+
alert: RmAlert
|
|
22
|
+
menu?: RmAlertMenuItem[]
|
|
23
|
+
lang?: 'fr' | 'en'
|
|
24
|
+
/**
|
|
25
|
+
* La page affichee est celle que l'alerte surveille. Sans elle, une
|
|
26
|
+
* alerte perimee ne peut pas etre rearmee : seule sa page sait
|
|
27
|
+
* reconstruire sa requete Elasticsearch.
|
|
28
|
+
*/
|
|
29
|
+
onWatchedPage?: boolean
|
|
30
|
+
}>(),
|
|
31
|
+
{ menu: () => [], lang: 'fr', onWatchedPage: false },
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
const emit = defineEmits<{
|
|
35
|
+
open: [id: string]
|
|
36
|
+
toggle: [id: string]
|
|
37
|
+
rearm: [id: string]
|
|
38
|
+
menu: [payload: { key: string; id: string }]
|
|
39
|
+
}>()
|
|
40
|
+
|
|
41
|
+
const FR = {
|
|
42
|
+
view: 'Vue',
|
|
43
|
+
trigger: 'Déclencheur',
|
|
44
|
+
watched: 'Champs suivis',
|
|
45
|
+
channel: 'Canaux',
|
|
46
|
+
cadence: 'Cadence',
|
|
47
|
+
tracked: 'éléments suivis',
|
|
48
|
+
unread: 'non lus',
|
|
49
|
+
more: 'Actions',
|
|
50
|
+
pause: 'Mettre en pause',
|
|
51
|
+
resume: 'Réactiver',
|
|
52
|
+
rearm: 'Réarmer depuis cette page',
|
|
53
|
+
shared: "Partagée avec l'espace",
|
|
54
|
+
appeared: 'Nouvel élément',
|
|
55
|
+
changed: 'Changement de valeur',
|
|
56
|
+
disappeared: 'Sortie du périmètre',
|
|
57
|
+
in_app: 'Dans l’app',
|
|
58
|
+
email: 'Email',
|
|
59
|
+
daily: 'Une fois par jour au plus',
|
|
60
|
+
weekly: 'Une fois par semaine au plus',
|
|
61
|
+
// Court a dessein : la pastille tient sur une ligne a cote du titre, et
|
|
62
|
+
// l'explication complete vit dans le bandeau juste en dessous.
|
|
63
|
+
status: {
|
|
64
|
+
seeding: 'En préparation',
|
|
65
|
+
active: 'Active',
|
|
66
|
+
paused: 'En pause',
|
|
67
|
+
stale: 'À réarmer',
|
|
68
|
+
failing: 'En échec',
|
|
69
|
+
},
|
|
70
|
+
notice: {
|
|
71
|
+
seeding:
|
|
72
|
+
"La référence est en cours de capture. Rien ne sera signalé tant qu'elle n'est pas posée.",
|
|
73
|
+
paused: 'En pause : les changements continuent d’être détectés, mais rien ne vous est signalé.',
|
|
74
|
+
stale:
|
|
75
|
+
"La vue a changé sans que la requête de l'alerte suive. Ouvrez la page surveillée pour la réarmer.",
|
|
76
|
+
failing: 'Le dernier passage a été refusé, la référence précédente est conservée.',
|
|
77
|
+
},
|
|
78
|
+
reseeded: 'La vue a changé, l’alerte est repartie d’une nouvelle référence le',
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const EN = {
|
|
82
|
+
view: 'View',
|
|
83
|
+
trigger: 'Trigger',
|
|
84
|
+
watched: 'Watched fields',
|
|
85
|
+
channel: 'Channels',
|
|
86
|
+
cadence: 'Cadence',
|
|
87
|
+
tracked: 'items tracked',
|
|
88
|
+
unread: 'unread',
|
|
89
|
+
more: 'Actions',
|
|
90
|
+
pause: 'Pause',
|
|
91
|
+
resume: 'Resume',
|
|
92
|
+
rearm: 'Re-arm from this page',
|
|
93
|
+
shared: 'Shared with the workspace',
|
|
94
|
+
appeared: 'New item',
|
|
95
|
+
changed: 'Value change',
|
|
96
|
+
disappeared: 'Left the scope',
|
|
97
|
+
in_app: 'In app',
|
|
98
|
+
email: 'Email',
|
|
99
|
+
daily: 'At most once a day',
|
|
100
|
+
weekly: 'At most once a week',
|
|
101
|
+
status: {
|
|
102
|
+
seeding: 'Preparing',
|
|
103
|
+
active: 'Active',
|
|
104
|
+
paused: 'Paused',
|
|
105
|
+
stale: 'Needs re-arming',
|
|
106
|
+
failing: 'Failing',
|
|
107
|
+
},
|
|
108
|
+
notice: {
|
|
109
|
+
seeding: 'The baseline is being captured. Nothing is reported until it is in place.',
|
|
110
|
+
paused: 'Paused: changes are still detected, but nothing is reported to you.',
|
|
111
|
+
stale:
|
|
112
|
+
"The view changed and the alert's query did not follow. Open the watched page to re-arm it.",
|
|
113
|
+
failing: 'The last run was refused; the previous baseline is kept.',
|
|
114
|
+
},
|
|
115
|
+
reseeded: 'The view changed, the alert restarted from a new baseline on',
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const T = computed(() => (props.lang === 'en' ? EN : FR))
|
|
119
|
+
|
|
120
|
+
/** Les etats qui demandent une explication, pas seulement une pastille. */
|
|
121
|
+
const notice = computed(() => {
|
|
122
|
+
const status = props.alert.status
|
|
123
|
+
if (status === 'active') return null
|
|
124
|
+
return {
|
|
125
|
+
tone: status === 'stale' || status === 'failing' ? 'warn' : 'info',
|
|
126
|
+
text: T.value.notice[status],
|
|
127
|
+
detail: status === 'failing' ? props.alert.lastError : null,
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const triggerLabels = computed(() =>
|
|
132
|
+
props.alert.triggers.map((trigger) => T.value[trigger]),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
const channelLabels = computed(() =>
|
|
136
|
+
props.alert.channels.map((channel) => (T.value as any)[channel] ?? channel),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
const cadenceLabel = computed(() => {
|
|
140
|
+
const cadence = props.alert.cadence
|
|
141
|
+
if (!cadence) return ''
|
|
142
|
+
return (T.value as any)[cadence] ?? cadence
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
/** Le pied garde sa bordure meme vide : il ne se dessine que s'il dit quelque chose. */
|
|
146
|
+
const hasMeta = computed(
|
|
147
|
+
() =>
|
|
148
|
+
!!props.alert.owner ||
|
|
149
|
+
!!props.alert.trackedCount ||
|
|
150
|
+
!!props.alert.createdLabel ||
|
|
151
|
+
!!props.alert.lastEventLabel,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
const menuOpen = ref(false)
|
|
155
|
+
|
|
156
|
+
function pick(item: RmAlertMenuItem) {
|
|
157
|
+
menuOpen.value = false
|
|
158
|
+
emit('menu', { key: item.key, id: props.alert.id })
|
|
159
|
+
}
|
|
160
|
+
</script>
|
|
161
|
+
|
|
162
|
+
<template>
|
|
163
|
+
<div class="rm-alert" :class="`rm-alert--${alert.status}`">
|
|
164
|
+
<div class="rm-alert__head">
|
|
165
|
+
<button type="button" class="rm-alert__title" @click="emit('open', alert.id)">
|
|
166
|
+
{{ alert.title }}
|
|
167
|
+
<span v-if="alert.unreadCount" class="rm-alert__unread">
|
|
168
|
+
{{ alert.unreadCount }}
|
|
169
|
+
</span>
|
|
170
|
+
</button>
|
|
171
|
+
|
|
172
|
+
<div class="rm-alert__head-actions">
|
|
173
|
+
<span class="rm-alert__status" :class="`rm-alert__status--${alert.status}`">
|
|
174
|
+
<span class="rm-alert__dot" />
|
|
175
|
+
{{ T.status[alert.status] }}
|
|
176
|
+
</span>
|
|
177
|
+
|
|
178
|
+
<div v-if="menu.length" class="rm-alert__menu-wrap">
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
class="rm-alert__more"
|
|
182
|
+
:title="T.more"
|
|
183
|
+
:aria-label="T.more"
|
|
184
|
+
:aria-expanded="menuOpen"
|
|
185
|
+
@click="menuOpen = !menuOpen"
|
|
186
|
+
>
|
|
187
|
+
<RmIcon name="List" :size="18" />
|
|
188
|
+
</button>
|
|
189
|
+
<template v-if="menuOpen">
|
|
190
|
+
<div class="rm-alert__menu-shade" @click.stop="menuOpen = false" />
|
|
191
|
+
<div class="rm-alert__menu" role="menu">
|
|
192
|
+
<button
|
|
193
|
+
v-for="item in menu"
|
|
194
|
+
:key="item.key"
|
|
195
|
+
type="button"
|
|
196
|
+
class="rm-alert__menu-item"
|
|
197
|
+
:class="{ 'rm-alert__menu-item--danger': item.danger }"
|
|
198
|
+
role="menuitem"
|
|
199
|
+
@click.stop="pick(item)"
|
|
200
|
+
>
|
|
201
|
+
<RmIcon v-if="item.icon" :name="item.icon" :size="16" />
|
|
202
|
+
{{ item.label }}
|
|
203
|
+
</button>
|
|
204
|
+
</div>
|
|
205
|
+
</template>
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
<p v-if="alert.viewTitle" class="rm-alert__view">
|
|
211
|
+
<RmIcon name="BookmarkSimple" :size="13" />
|
|
212
|
+
{{ T.view }} : {{ alert.viewTitle }}
|
|
213
|
+
<span v-if="alert.shared" class="rm-alert__shared">{{ T.shared }}</span>
|
|
214
|
+
</p>
|
|
215
|
+
|
|
216
|
+
<div v-if="notice" class="rm-alert__notice" :class="`rm-alert__notice--${notice.tone}`">
|
|
217
|
+
<RmIcon :name="notice.tone === 'warn' ? 'Warning' : 'Info'" :size="14" />
|
|
218
|
+
<span>
|
|
219
|
+
{{ notice.text }}
|
|
220
|
+
<em v-if="notice.detail" class="rm-alert__notice-detail">{{ notice.detail }}</em>
|
|
221
|
+
</span>
|
|
222
|
+
<RmButton
|
|
223
|
+
v-if="alert.status === 'stale' && onWatchedPage"
|
|
224
|
+
variant="secondary"
|
|
225
|
+
size="sm"
|
|
226
|
+
@click="emit('rearm', alert.id)"
|
|
227
|
+
>
|
|
228
|
+
{{ T.rearm }}
|
|
229
|
+
</RmButton>
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
<dl class="rm-alert__spec">
|
|
233
|
+
<div class="rm-alert__row">
|
|
234
|
+
<dt>{{ T.trigger }}</dt>
|
|
235
|
+
<dd>{{ triggerLabels.join(', ') }}</dd>
|
|
236
|
+
</div>
|
|
237
|
+
<div v-if="alert.watchedLabels?.length" class="rm-alert__row">
|
|
238
|
+
<dt>{{ T.watched }}</dt>
|
|
239
|
+
<dd>{{ alert.watchedLabels.join(', ') }}</dd>
|
|
240
|
+
</div>
|
|
241
|
+
<div class="rm-alert__row">
|
|
242
|
+
<dt>{{ T.channel }}</dt>
|
|
243
|
+
<dd>
|
|
244
|
+
<span v-for="label in channelLabels" :key="label" class="rm-alert__chip">
|
|
245
|
+
{{ label }}
|
|
246
|
+
</span>
|
|
247
|
+
</dd>
|
|
248
|
+
</div>
|
|
249
|
+
<div v-if="cadenceLabel" class="rm-alert__row">
|
|
250
|
+
<dt>{{ T.cadence }}</dt>
|
|
251
|
+
<dd>{{ cadenceLabel }}</dd>
|
|
252
|
+
</div>
|
|
253
|
+
</dl>
|
|
254
|
+
|
|
255
|
+
<p v-if="alert.reseededAt" class="rm-alert__reseed">
|
|
256
|
+
{{ T.reseeded }} {{ alert.reseededAt }}.
|
|
257
|
+
</p>
|
|
258
|
+
|
|
259
|
+
<div v-if="hasMeta" class="rm-alert__meta">
|
|
260
|
+
<span v-if="alert.owner">
|
|
261
|
+
<RmIcon name="User" :size="13" />{{ alert.owner }}
|
|
262
|
+
</span>
|
|
263
|
+
<span v-if="alert.trackedCount">
|
|
264
|
+
<RmIcon name="Target" :size="13" />{{ alert.trackedCount }} {{ T.tracked }}
|
|
265
|
+
</span>
|
|
266
|
+
<span v-if="alert.createdLabel">
|
|
267
|
+
<RmIcon name="Clock" :size="13" />{{ alert.createdLabel }}
|
|
268
|
+
</span>
|
|
269
|
+
<span v-if="alert.lastEventLabel">
|
|
270
|
+
<RmIcon name="Fire" :size="13" />{{ alert.lastEventLabel }}
|
|
271
|
+
</span>
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
</template>
|
|
275
|
+
|
|
276
|
+
<style scoped>
|
|
277
|
+
.rm-alert{background:var(--rm-surface,#fff);border:1px solid var(--rm-border,#e5e7eb);border-radius:10px;display:flex;flex-direction:column;gap:10px;padding:14px 16px}.rm-alert--paused,.rm-alert--seeding{background:var(--rm-surface-muted,#fafafa)}.rm-alert__head{align-items:flex-start;display:flex;gap:10px;justify-content:space-between}.rm-alert__title{all:unset;align-items:center;color:var(--rm-text,#111827);cursor:pointer;display:inline-flex;font-size:15px;font-weight:600;gap:8px;line-height:1.35}.rm-alert__title:hover{color:var(--rm-primary,#4f46e5)}.rm-alert__unread{align-items:center;background:var(--rm-primary,#4f46e5);border-radius:999px;color:#fff;display:inline-flex;font-size:11px;font-weight:700;height:18px;justify-content:center;min-width:18px;padding:0 5px}.rm-alert__head-actions{align-items:center;display:flex;flex-shrink:0;gap:6px}.rm-alert__status{align-items:center;background:var(--rm-surface-muted,#f3f4f6);border-radius:999px;color:var(--rm-text-soft,#6b7280);display:inline-flex;font-size:11px;font-weight:600;gap:5px;letter-spacing:.03em;padding:3px 8px;text-transform:uppercase;white-space:nowrap}.rm-alert__dot{background:currentColor;border-radius:999px;height:6px;width:6px}.rm-alert__status--active{background:var(--rm-success-soft,#ecfdf5);color:var(--rm-success,#047857)}.rm-alert__status--failing,.rm-alert__status--stale{background:var(--rm-warn-soft,#fffbeb);color:var(--rm-warn,#b45309)}.rm-alert__view{align-items:center;color:var(--rm-text-soft,#6b7280);display:flex;flex-wrap:wrap;font-size:12px;gap:5px;margin:0}.rm-alert__shared{border-radius:999px;font-size:11px;padding:1px 6px}.rm-alert__notice,.rm-alert__shared{background:var(--rm-surface-muted,#f3f4f6)}.rm-alert__notice{align-items:flex-start;border-radius:8px;color:var(--rm-text-soft,#4b5563);display:flex;font-size:12.5px;gap:8px;line-height:1.45;padding:9px 11px}.rm-alert__notice--warn{background:var(--rm-warn-soft,#fffbeb);color:var(--rm-warn,#92400e)}.rm-alert__notice-detail{display:block;font-size:11.5px;font-style:normal;margin-top:3px;opacity:.8}.rm-alert__spec{background:var(--rm-surface-muted,#f9fafb);border-radius:8px;display:flex;flex-direction:column;gap:6px;margin:0;padding:10px 12px}.rm-alert__row{align-items:baseline;display:grid;gap:10px;grid-template-columns:110px 1fr}.rm-alert__row dt{color:var(--rm-text-soft,#9ca3af);font-size:11px;font-weight:600;letter-spacing:.03em;text-transform:uppercase}.rm-alert__row dd{color:var(--rm-text,#111827);display:flex;flex-wrap:wrap;font-size:13px;gap:5px;margin:0}.rm-alert__chip{background:var(--rm-surface,#fff);border:1px solid var(--rm-border,#e5e7eb);border-radius:6px;font-size:12px;padding:1px 7px}.rm-alert__reseed{margin:0}.rm-alert__meta,.rm-alert__reseed{color:var(--rm-text-soft,#9ca3af);font-size:11.5px}.rm-alert__meta{border-top:1px solid var(--rm-border-soft,#f3f4f6);display:flex;flex-wrap:wrap;gap:12px;padding-top:9px}.rm-alert__meta span{align-items:center;display:inline-flex;gap:4px}.rm-alert__menu-wrap{position:relative}.rm-alert__more{all:unset;border-radius:6px;color:var(--rm-text-soft,#9ca3af);cursor:pointer;display:inline-flex;padding:3px}.rm-alert__more:hover{background:var(--rm-surface-muted,#f3f4f6);color:var(--rm-text,#111827)}.rm-alert__menu-shade{inset:0;position:fixed;z-index:20}.rm-alert__menu{background:var(--rm-surface,#fff);border:1px solid var(--rm-border,#e5e7eb);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.1);display:flex;flex-direction:column;min-width:190px;padding:4px;position:absolute;right:0;top:calc(100% + 4px);z-index:21}.rm-alert__menu-item{all:unset;align-items:center;border-radius:6px;color:var(--rm-text,#111827);cursor:pointer;display:flex;font-size:13px;gap:8px;padding:7px 9px}.rm-alert__menu-item:hover{background:var(--rm-surface-muted,#f3f4f6)}.rm-alert__menu-item--danger{color:var(--rm-danger,#b91c1c)}
|
|
278
|
+
</style>
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmAlertForm - creer une alerte sur la vue affichee.
|
|
3
|
+
|
|
4
|
+
Le formulaire ne connait ni l'API ni la requete : l'appelant lui donne la
|
|
5
|
+
vue surveillee et le catalogue de champs de sa page, il lui rend un
|
|
6
|
+
descripteur.
|
|
7
|
+
|
|
8
|
+
Trois choix de conception que l'ecran ne montre pas :
|
|
9
|
+
|
|
10
|
+
- les declencheurs sont des cases, pas des boutons radio. Les trois sortent
|
|
11
|
+
du meme passage de diff, donc les rendre exclusifs n'economise rien et
|
|
12
|
+
force a creer deux alertes pour « nouveau produit ET changement de prix » ;
|
|
13
|
+
- les champs suivis sont multiples dans UNE alerte. Une alerte par champ
|
|
14
|
+
voudrait dire autant de requetes et autant de references sur le meme jeu
|
|
15
|
+
de documents, et plusieurs messages le meme matin pour le meme produit ;
|
|
16
|
+
- la cadence appartient au mail. La pastille de l'app encaisse au fil de
|
|
17
|
+
l'eau, elle n'a pas de digest.
|
|
18
|
+
-->
|
|
19
|
+
<script setup lang="ts">
|
|
20
|
+
import { computed, ref, watch } from 'vue'
|
|
21
|
+
import type { RmAlertDraft, RmAlertField, RmAlertTrigger } from '../utils/alerts'
|
|
22
|
+
|
|
23
|
+
const props = withDefaults(
|
|
24
|
+
defineProps<{
|
|
25
|
+
/** Nom de la vue surveillee, affiche et repris comme titre par defaut. */
|
|
26
|
+
viewTitle: string
|
|
27
|
+
/** Champs surveillables de la page. Vide : seule l'apparition compte. */
|
|
28
|
+
fields?: RmAlertField[]
|
|
29
|
+
/** Nombre d'elements que la vue tient a l'ecran, s'il est connu. */
|
|
30
|
+
scopeCount?: number | null
|
|
31
|
+
submitting?: boolean
|
|
32
|
+
lang?: 'fr' | 'en'
|
|
33
|
+
}>(),
|
|
34
|
+
{ fields: () => [], scopeCount: null, submitting: false, lang: 'fr' },
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
const emit = defineEmits<{
|
|
38
|
+
submit: [draft: RmAlertDraft]
|
|
39
|
+
cancel: []
|
|
40
|
+
}>()
|
|
41
|
+
|
|
42
|
+
const FR = {
|
|
43
|
+
name: 'Nom de l’alerte',
|
|
44
|
+
view: 'Vue surveillée',
|
|
45
|
+
viewHint: 'L’alerte se déclenche sur le périmètre de cette vue, tel qu’il est à l’écran.',
|
|
46
|
+
triggers: 'Déclencheurs',
|
|
47
|
+
triggersHint: 'Les trois sortent de la même comparaison, en cocher plusieurs ne coûte rien.',
|
|
48
|
+
appeared: 'Nouvel élément',
|
|
49
|
+
appearedHint: 'Un résultat entre dans la vue',
|
|
50
|
+
changed: 'Changement de valeur',
|
|
51
|
+
changedHint: 'Un champ suivi bouge sur un élément déjà présent',
|
|
52
|
+
disappeared: 'Sortie du périmètre',
|
|
53
|
+
disappearedHint: 'Un élément quitte la vue (retrait, déremboursement)',
|
|
54
|
+
fields: 'Champs suivis',
|
|
55
|
+
fieldsHint: 'Un seul message par élément, quel que soit le nombre de champs cochés.',
|
|
56
|
+
fieldsRequired: 'Cochez au moins un champ pour surveiller les changements.',
|
|
57
|
+
noFields: 'Cette page ne propose aucun champ surveillable pour le moment.',
|
|
58
|
+
channels: 'Canaux',
|
|
59
|
+
inApp: 'Dans l’app (pastille sur la tuile Alertes)',
|
|
60
|
+
email: 'Email',
|
|
61
|
+
cadence: 'Fréquence d’envoi du mail',
|
|
62
|
+
daily: 'Quotidienne (un envoi par jour au plus)',
|
|
63
|
+
weekly: 'Hebdomadaire (un envoi par semaine au plus)',
|
|
64
|
+
cadenceHint:
|
|
65
|
+
'Un plafond, pas un rythme : rien ne part quand rien n’a bougé, et la détection suit celle des données.',
|
|
66
|
+
visibility: 'Partage',
|
|
67
|
+
private: 'Pour moi seul',
|
|
68
|
+
workspace: 'Visible par mon espace de travail',
|
|
69
|
+
submit: 'Créer l’alerte',
|
|
70
|
+
cancel: 'Annuler',
|
|
71
|
+
baseline:
|
|
72
|
+
'À la création, la référence est capturée sans rien signaler : vous ne serez prévenu que de ce qui bouge ensuite.',
|
|
73
|
+
scope: 'éléments dans la vue actuellement',
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const EN = {
|
|
77
|
+
name: 'Alert name',
|
|
78
|
+
view: 'Watched view',
|
|
79
|
+
viewHint: 'The alert fires on this view’s scope, as it stands on screen.',
|
|
80
|
+
triggers: 'Triggers',
|
|
81
|
+
triggersHint: 'All three come from the same comparison; ticking several costs nothing.',
|
|
82
|
+
appeared: 'New item',
|
|
83
|
+
appearedHint: 'A result enters the view',
|
|
84
|
+
changed: 'Value change',
|
|
85
|
+
changedHint: 'A watched field moves on an item already there',
|
|
86
|
+
disappeared: 'Left the scope',
|
|
87
|
+
disappearedHint: 'An item leaves the view (withdrawal, delisting)',
|
|
88
|
+
fields: 'Watched fields',
|
|
89
|
+
fieldsHint: 'One message per item, however many fields are ticked.',
|
|
90
|
+
fieldsRequired: 'Tick at least one field to watch for changes.',
|
|
91
|
+
noFields: 'This page offers no watchable field yet.',
|
|
92
|
+
channels: 'Channels',
|
|
93
|
+
inApp: 'In app (badge on the Alerts tile)',
|
|
94
|
+
email: 'Email',
|
|
95
|
+
cadence: 'Mail frequency',
|
|
96
|
+
daily: 'Daily (at most one a day)',
|
|
97
|
+
weekly: 'Weekly (at most one a week)',
|
|
98
|
+
cadenceHint:
|
|
99
|
+
'A cap, not a rhythm: nothing goes out when nothing moved, and detection follows the data.',
|
|
100
|
+
visibility: 'Sharing',
|
|
101
|
+
private: 'Just me',
|
|
102
|
+
workspace: 'Visible to my workspace',
|
|
103
|
+
submit: 'Create the alert',
|
|
104
|
+
cancel: 'Cancel',
|
|
105
|
+
baseline:
|
|
106
|
+
'On creation the baseline is captured silently: you are only told about what moves afterwards.',
|
|
107
|
+
scope: 'items in the view right now',
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const T = computed(() => (props.lang === 'en' ? EN : FR))
|
|
111
|
+
|
|
112
|
+
const title = ref(props.viewTitle)
|
|
113
|
+
const triggers = ref<RmAlertTrigger[]>(['appeared'])
|
|
114
|
+
const watchedFields = ref<string[]>([])
|
|
115
|
+
const emailOn = ref(false)
|
|
116
|
+
const cadence = ref<'daily' | 'weekly'>('weekly')
|
|
117
|
+
const shared = ref(false)
|
|
118
|
+
|
|
119
|
+
watch(
|
|
120
|
+
() => props.viewTitle,
|
|
121
|
+
(value) => {
|
|
122
|
+
title.value = value
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Deux bascules plutot qu'une generique : dans le template, un `ref` est
|
|
128
|
+
* deballe, donc lui passer la reference elle-meme ne marche pas.
|
|
129
|
+
*/
|
|
130
|
+
function toggleTrigger(key: RmAlertTrigger) {
|
|
131
|
+
triggers.value = triggers.value.includes(key)
|
|
132
|
+
? triggers.value.filter((entry) => entry !== key)
|
|
133
|
+
: [...triggers.value, key]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function toggleField(value: string) {
|
|
137
|
+
watchedFields.value = watchedFields.value.includes(value)
|
|
138
|
+
? watchedFields.value.filter((entry) => entry !== value)
|
|
139
|
+
: [...watchedFields.value, value]
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const watchesChanges = computed(() => triggers.value.includes('changed'))
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Surveiller un changement sans dire de quel champ n'a pas de sens : le
|
|
146
|
+
* moteur comparerait un hachage vide et ne trouverait jamais rien.
|
|
147
|
+
*/
|
|
148
|
+
const missingFields = computed(
|
|
149
|
+
() => watchesChanges.value && watchedFields.value.length === 0 && props.fields.length > 0,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
const valid = computed(
|
|
153
|
+
() => title.value.trim().length > 0 && triggers.value.length > 0 && !missingFields.value,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
function submit() {
|
|
157
|
+
if (!valid.value) return
|
|
158
|
+
emit('submit', {
|
|
159
|
+
title: title.value.trim(),
|
|
160
|
+
triggers: triggers.value,
|
|
161
|
+
// Les champs ne servent qu'au declencheur « changement » : les garder
|
|
162
|
+
// sans lui ferait porter a l'alerte une promesse qu'elle ne tient pas.
|
|
163
|
+
watchedFields: watchesChanges.value ? watchedFields.value : [],
|
|
164
|
+
channels: emailOn.value ? ['in_app', 'email'] : ['in_app'],
|
|
165
|
+
cadence: cadence.value,
|
|
166
|
+
visibility: shared.value ? 'workspace' : 'private',
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
</script>
|
|
170
|
+
|
|
171
|
+
<template>
|
|
172
|
+
<form class="rm-alert-form" @submit.prevent="submit">
|
|
173
|
+
<RmInput v-model="title" :label="T.name" required />
|
|
174
|
+
|
|
175
|
+
<div class="rm-alert-form__view">
|
|
176
|
+
<p class="rm-alert-form__label">{{ T.view }}</p>
|
|
177
|
+
<p class="rm-alert-form__view-name">
|
|
178
|
+
<RmIcon name="BookmarkSimple" :size="14" />
|
|
179
|
+
{{ viewTitle }}
|
|
180
|
+
<span v-if="scopeCount" class="rm-alert-form__count">
|
|
181
|
+
{{ scopeCount }} {{ T.scope }}
|
|
182
|
+
</span>
|
|
183
|
+
</p>
|
|
184
|
+
<p class="rm-alert-form__hint">{{ T.viewHint }}</p>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<fieldset class="rm-alert-form__group">
|
|
188
|
+
<legend class="rm-alert-form__label">{{ T.triggers }}</legend>
|
|
189
|
+
<RmCheckbox
|
|
190
|
+
v-for="entry in [
|
|
191
|
+
{ key: 'appeared', label: T.appeared, hint: T.appearedHint },
|
|
192
|
+
{ key: 'changed', label: T.changed, hint: T.changedHint },
|
|
193
|
+
{ key: 'disappeared', label: T.disappeared, hint: T.disappearedHint },
|
|
194
|
+
]"
|
|
195
|
+
:key="entry.key"
|
|
196
|
+
:model-value="triggers.includes(entry.key as any)"
|
|
197
|
+
:label="entry.label"
|
|
198
|
+
:tooltip="entry.hint"
|
|
199
|
+
@update:model-value="toggleTrigger(entry.key as RmAlertTrigger)"
|
|
200
|
+
/>
|
|
201
|
+
<p class="rm-alert-form__hint">{{ T.triggersHint }}</p>
|
|
202
|
+
</fieldset>
|
|
203
|
+
|
|
204
|
+
<fieldset v-if="watchesChanges" class="rm-alert-form__group">
|
|
205
|
+
<legend class="rm-alert-form__label">{{ T.fields }}</legend>
|
|
206
|
+
<template v-if="fields.length">
|
|
207
|
+
<RmCheckbox
|
|
208
|
+
v-for="field in fields"
|
|
209
|
+
:key="field.value"
|
|
210
|
+
:model-value="watchedFields.includes(field.value)"
|
|
211
|
+
:label="field.label"
|
|
212
|
+
@update:model-value="toggleField(field.value)"
|
|
213
|
+
/>
|
|
214
|
+
<p class="rm-alert-form__hint">{{ T.fieldsHint }}</p>
|
|
215
|
+
<p v-if="missingFields" class="rm-alert-form__error">{{ T.fieldsRequired }}</p>
|
|
216
|
+
</template>
|
|
217
|
+
<p v-else class="rm-alert-form__hint">{{ T.noFields }}</p>
|
|
218
|
+
</fieldset>
|
|
219
|
+
|
|
220
|
+
<fieldset class="rm-alert-form__group">
|
|
221
|
+
<legend class="rm-alert-form__label">{{ T.channels }}</legend>
|
|
222
|
+
<RmCheckbox :model-value="true" :label="T.inApp" disabled />
|
|
223
|
+
<RmCheckbox v-model="emailOn" :label="T.email" />
|
|
224
|
+
|
|
225
|
+
<div v-if="emailOn" class="rm-alert-form__nested">
|
|
226
|
+
<RmDropdownSelect
|
|
227
|
+
:label="T.cadence"
|
|
228
|
+
:options="[T.weekly, T.daily]"
|
|
229
|
+
:model-value="cadence === 'weekly' ? T.weekly : T.daily"
|
|
230
|
+
@update:model-value="cadence = $event === T.daily ? 'daily' : 'weekly'"
|
|
231
|
+
/>
|
|
232
|
+
<p class="rm-alert-form__hint">{{ T.cadenceHint }}</p>
|
|
233
|
+
</div>
|
|
234
|
+
</fieldset>
|
|
235
|
+
|
|
236
|
+
<fieldset class="rm-alert-form__group">
|
|
237
|
+
<legend class="rm-alert-form__label">{{ T.visibility }}</legend>
|
|
238
|
+
<RmCheckbox v-model="shared" :label="T.workspace" />
|
|
239
|
+
</fieldset>
|
|
240
|
+
|
|
241
|
+
<p class="rm-alert-form__baseline">
|
|
242
|
+
<RmIcon name="Info" :size="14" />
|
|
243
|
+
{{ T.baseline }}
|
|
244
|
+
</p>
|
|
245
|
+
|
|
246
|
+
<div class="rm-alert-form__actions">
|
|
247
|
+
<RmButton variant="secondary" @click="emit('cancel')">{{ T.cancel }}</RmButton>
|
|
248
|
+
<RmButton
|
|
249
|
+
variant="primary"
|
|
250
|
+
type="submit"
|
|
251
|
+
:disabled="!valid || submitting"
|
|
252
|
+
:loading="submitting"
|
|
253
|
+
>
|
|
254
|
+
{{ T.submit }}
|
|
255
|
+
</RmButton>
|
|
256
|
+
</div>
|
|
257
|
+
</form>
|
|
258
|
+
</template>
|
|
259
|
+
|
|
260
|
+
<style scoped>
|
|
261
|
+
.rm-alert-form{display:flex;flex-direction:column;gap:18px}.rm-alert-form__label{color:var(--rm-text,#111827);font-size:13px;font-weight:600;margin:0 0 6px;padding:0}.rm-alert-form__group{border:0;display:flex;flex-direction:column;gap:8px;margin:0;padding:0}.rm-alert-form__view-name{align-items:center;color:var(--rm-text,#111827);display:flex;flex-wrap:wrap;font-size:14px;gap:6px;margin:0}.rm-alert-form__count{color:var(--rm-text-soft,#9ca3af);font-size:11.5px}.rm-alert-form__hint{color:var(--rm-text-soft,#9ca3af);font-size:12px;line-height:1.45;margin:0}.rm-alert-form__error{color:var(--rm-danger,#b91c1c);font-size:12px;margin:0}.rm-alert-form__nested{display:flex;flex-direction:column;gap:6px;padding-left:26px}.rm-alert-form__baseline{align-items:flex-start;background:var(--rm-surface-muted,#f9fafb);border-radius:8px;color:var(--rm-text-soft,#4b5563);display:flex;font-size:12.5px;gap:8px;line-height:1.45;margin:0;padding:10px 12px}.rm-alert-form__actions{display:flex;gap:8px;justify-content:flex-end}
|
|
262
|
+
</style>
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmAlerts - le panneau des alertes du rail.
|
|
3
|
+
|
|
4
|
+
Il ne connait ni l'API ni la page : l'appelant lui passe des alertes deja
|
|
5
|
+
mises en forme (cf. `RmAlert`), il lui rend les gestes.
|
|
6
|
+
|
|
7
|
+
Les onglets separent trois questions differentes : ce qui veille, ce qui
|
|
8
|
+
s'est tu, et ce qui a bougé. Le troisieme est le journal, la seule vue qui
|
|
9
|
+
se lit dans le temps plutot que par alerte.
|
|
10
|
+
|
|
11
|
+
Le bouton de creation reste visible meme quand il ne peut rien faire, avec
|
|
12
|
+
la raison sous lui : une alerte se pose sur une vue enregistree, et un
|
|
13
|
+
bouton qui disparait n'apprend pas pourquoi.
|
|
14
|
+
-->
|
|
15
|
+
<script setup lang="ts">
|
|
16
|
+
import { computed } from 'vue'
|
|
17
|
+
import type { RmAlert, RmAlertEvent, RmAlertMenuItem } from '../utils/alerts'
|
|
18
|
+
|
|
19
|
+
const props = withDefaults(
|
|
20
|
+
defineProps<{
|
|
21
|
+
alerts: RmAlert[]
|
|
22
|
+
events?: RmAlertEvent[]
|
|
23
|
+
/** Onglet courant (v-model). */
|
|
24
|
+
tab?: 'active' | 'paused' | 'events'
|
|
25
|
+
menu?: RmAlertMenuItem[]
|
|
26
|
+
loading?: boolean
|
|
27
|
+
/** L'utilisateur peut poser une alerte sur cet ecran. */
|
|
28
|
+
canCreate?: boolean
|
|
29
|
+
/** Pourquoi il ne peut pas, quand il ne peut pas. */
|
|
30
|
+
createHint?: string
|
|
31
|
+
/** La page affichee est celle que les alertes de la liste surveillent. */
|
|
32
|
+
onWatchedPage?: boolean
|
|
33
|
+
lang?: 'fr' | 'en'
|
|
34
|
+
}>(),
|
|
35
|
+
{
|
|
36
|
+
events: () => [],
|
|
37
|
+
tab: 'active',
|
|
38
|
+
menu: () => [],
|
|
39
|
+
loading: false,
|
|
40
|
+
canCreate: false,
|
|
41
|
+
createHint: '',
|
|
42
|
+
onWatchedPage: false,
|
|
43
|
+
lang: 'fr',
|
|
44
|
+
},
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
const emit = defineEmits<{
|
|
48
|
+
'update:tab': [value: 'active' | 'paused' | 'events']
|
|
49
|
+
create: []
|
|
50
|
+
open: [id: string]
|
|
51
|
+
rearm: [id: string]
|
|
52
|
+
menu: [payload: { key: string; id: string }]
|
|
53
|
+
'read-all': []
|
|
54
|
+
}>()
|
|
55
|
+
|
|
56
|
+
const FR = {
|
|
57
|
+
intro:
|
|
58
|
+
'Une alerte surveille le périmètre d’une vue sauvegardée et vous signale ce qui y apparaît, y change ou en sort.',
|
|
59
|
+
create: 'Nouvelle alerte',
|
|
60
|
+
active: 'Actives',
|
|
61
|
+
paused: 'En pause',
|
|
62
|
+
events: 'Activité',
|
|
63
|
+
readAll: 'Tout marquer comme lu',
|
|
64
|
+
emptyActive: 'Aucune alerte active',
|
|
65
|
+
emptyActiveText:
|
|
66
|
+
'Posez une alerte sur une vue pour être prévenu sans avoir à rouvrir la page.',
|
|
67
|
+
emptyPaused: 'Aucune alerte en pause',
|
|
68
|
+
emptyEvents: 'Rien de neuf',
|
|
69
|
+
emptyEventsText:
|
|
70
|
+
'Les changements détectés sur vos périmètres apparaîtront ici, du plus récent au plus ancien.',
|
|
71
|
+
appeared: 'Nouveau',
|
|
72
|
+
changed: 'Modifié',
|
|
73
|
+
disappeared: 'Sorti du périmètre',
|
|
74
|
+
becomes: 'devient',
|
|
75
|
+
empty: '(vide)',
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const EN = {
|
|
79
|
+
intro:
|
|
80
|
+
'An alert watches a saved view’s scope and reports what appears, changes or leaves it.',
|
|
81
|
+
create: 'New alert',
|
|
82
|
+
active: 'Active',
|
|
83
|
+
paused: 'Paused',
|
|
84
|
+
events: 'Activity',
|
|
85
|
+
readAll: 'Mark all as read',
|
|
86
|
+
emptyActive: 'No active alert',
|
|
87
|
+
emptyActiveText: 'Set an alert on a view to hear about it without reopening the page.',
|
|
88
|
+
emptyPaused: 'No paused alert',
|
|
89
|
+
emptyEvents: 'Nothing new',
|
|
90
|
+
emptyEventsText:
|
|
91
|
+
'Changes detected on your scopes will show up here, most recent first.',
|
|
92
|
+
appeared: 'New',
|
|
93
|
+
changed: 'Changed',
|
|
94
|
+
disappeared: 'Left the scope',
|
|
95
|
+
becomes: 'becomes',
|
|
96
|
+
empty: '(empty)',
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const T = computed(() => (props.lang === 'en' ? EN : FR))
|
|
100
|
+
|
|
101
|
+
/** En pause au sens de l'utilisateur : rien ne lui sera signalé. */
|
|
102
|
+
const paused = computed(() => props.alerts.filter((alert) => alert.status === 'paused'))
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Tout le reste veille, y compris ce qui va mal. Une alerte périmée ou en
|
|
106
|
+
* échec appartient à cette liste : la ranger ailleurs la ferait disparaître
|
|
107
|
+
* de l'écran, alors que c'est précisément celle qu'il faut voir.
|
|
108
|
+
*/
|
|
109
|
+
const active = computed(() => props.alerts.filter((alert) => alert.status !== 'paused'))
|
|
110
|
+
|
|
111
|
+
const unreadTotal = computed(() =>
|
|
112
|
+
props.alerts.reduce((total, alert) => total + (alert.unreadCount ?? 0), 0),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
function setTab(value: 'active' | 'paused' | 'events') {
|
|
116
|
+
emit('update:tab', value)
|
|
117
|
+
}
|
|
118
|
+
</script>
|
|
119
|
+
|
|
120
|
+
<template>
|
|
121
|
+
<div class="rm-alerts">
|
|
122
|
+
<p class="rm-alerts__intro">{{ T.intro }}</p>
|
|
123
|
+
|
|
124
|
+
<div class="rm-alerts__create">
|
|
125
|
+
<RmButton variant="primary" size="sm" :disabled="!canCreate" @click="emit('create')">
|
|
126
|
+
<RmIcon name="Plus" :size="15" />
|
|
127
|
+
{{ T.create }}
|
|
128
|
+
</RmButton>
|
|
129
|
+
<p v-if="!canCreate && createHint" class="rm-alerts__hint">{{ createHint }}</p>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<div class="rm-alerts__tabs" role="tablist">
|
|
133
|
+
<button
|
|
134
|
+
v-for="entry in [
|
|
135
|
+
{ key: 'active', label: T.active, count: active.length },
|
|
136
|
+
{ key: 'paused', label: T.paused, count: paused.length },
|
|
137
|
+
{ key: 'events', label: T.events, count: unreadTotal },
|
|
138
|
+
]"
|
|
139
|
+
:key="entry.key"
|
|
140
|
+
type="button"
|
|
141
|
+
role="tab"
|
|
142
|
+
class="rm-alerts__tab"
|
|
143
|
+
:class="{ 'rm-alerts__tab--on': tab === entry.key }"
|
|
144
|
+
:aria-selected="tab === entry.key"
|
|
145
|
+
@click="setTab(entry.key as any)"
|
|
146
|
+
>
|
|
147
|
+
{{ entry.label }}
|
|
148
|
+
<span v-if="entry.count" class="rm-alerts__tab-count">{{ entry.count }}</span>
|
|
149
|
+
</button>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<RmLoadingState v-if="loading" />
|
|
153
|
+
|
|
154
|
+
<template v-else-if="tab === 'events'">
|
|
155
|
+
<div v-if="events.length" class="rm-alerts__events">
|
|
156
|
+
<button
|
|
157
|
+
v-if="unreadTotal"
|
|
158
|
+
type="button"
|
|
159
|
+
class="rm-alerts__read-all"
|
|
160
|
+
@click="emit('read-all')"
|
|
161
|
+
>
|
|
162
|
+
{{ T.readAll }}
|
|
163
|
+
</button>
|
|
164
|
+
|
|
165
|
+
<article
|
|
166
|
+
v-for="event in events"
|
|
167
|
+
:key="event.id"
|
|
168
|
+
class="rm-alerts__event"
|
|
169
|
+
:class="{ 'rm-alerts__event--unread': !event.read }"
|
|
170
|
+
>
|
|
171
|
+
<header class="rm-alerts__event-head">
|
|
172
|
+
<span class="rm-alerts__kind" :class="`rm-alerts__kind--${event.kind}`">
|
|
173
|
+
{{ T[event.kind] }}
|
|
174
|
+
</span>
|
|
175
|
+
<time class="rm-alerts__event-date">{{ event.dateLabel }}</time>
|
|
176
|
+
</header>
|
|
177
|
+
<p class="rm-alerts__event-title">{{ event.title }}</p>
|
|
178
|
+
<p v-if="event.subtitle" class="rm-alerts__event-sub">{{ event.subtitle }}</p>
|
|
179
|
+
<p
|
|
180
|
+
v-for="(change, index) in event.changes ?? []"
|
|
181
|
+
:key="index"
|
|
182
|
+
class="rm-alerts__change"
|
|
183
|
+
>
|
|
184
|
+
{{ change.label || change.field }} :
|
|
185
|
+
<s>{{ change.before || T.empty }}</s>
|
|
186
|
+
{{ T.becomes }}
|
|
187
|
+
<strong>{{ change.after || T.empty }}</strong>
|
|
188
|
+
</p>
|
|
189
|
+
<p v-if="event.alertTitle" class="rm-alerts__event-from">{{ event.alertTitle }}</p>
|
|
190
|
+
</article>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<RmEmptyState v-else :title="T.emptyEvents" :text="T.emptyEventsText" />
|
|
194
|
+
</template>
|
|
195
|
+
|
|
196
|
+
<template v-else>
|
|
197
|
+
<div v-if="(tab === 'paused' ? paused : active).length" class="rm-alerts__list">
|
|
198
|
+
<RmAlertCard
|
|
199
|
+
v-for="alert in tab === 'paused' ? paused : active"
|
|
200
|
+
:key="alert.id"
|
|
201
|
+
:alert="alert"
|
|
202
|
+
:menu="menu"
|
|
203
|
+
:lang="lang"
|
|
204
|
+
:on-watched-page="onWatchedPage"
|
|
205
|
+
@open="emit('open', $event)"
|
|
206
|
+
@rearm="emit('rearm', $event)"
|
|
207
|
+
@menu="emit('menu', $event)"
|
|
208
|
+
/>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
<RmEmptyState
|
|
212
|
+
v-else
|
|
213
|
+
:title="tab === 'paused' ? T.emptyPaused : T.emptyActive"
|
|
214
|
+
:text="tab === 'paused' ? '' : T.emptyActiveText"
|
|
215
|
+
/>
|
|
216
|
+
</template>
|
|
217
|
+
</div>
|
|
218
|
+
</template>
|
|
219
|
+
|
|
220
|
+
<style scoped>
|
|
221
|
+
.rm-alerts{display:flex;flex-direction:column;gap:14px}.rm-alerts__intro{color:var(--rm-text-soft,#6b7280);font-size:13px;line-height:1.5;margin:0}.rm-alerts__create{align-items:flex-start;display:flex;flex-direction:column;gap:6px}.rm-alerts__hint{color:var(--rm-text-soft,#9ca3af);font-size:12px;line-height:1.45;margin:0}.rm-alerts__tabs{border-bottom:1px solid var(--rm-border,#e5e7eb);display:flex;gap:4px}.rm-alerts__tab{all:unset;align-items:center;border-bottom:2px solid transparent;color:var(--rm-text-soft,#6b7280);cursor:pointer;display:inline-flex;font-size:13px;font-weight:500;gap:6px;padding:8px 10px}.rm-alerts__tab--on{border-bottom-color:var(--rm-primary,#4f46e5);color:var(--rm-primary,#4f46e5)}.rm-alerts__tab-count{background:var(--rm-surface-muted,#f3f4f6);border-radius:999px;font-size:11px;font-weight:700;padding:0 6px}.rm-alerts__tab--on .rm-alerts__tab-count{background:var(--rm-primary,#4f46e5);color:#fff}.rm-alerts__events,.rm-alerts__list{display:flex;flex-direction:column;gap:10px}.rm-alerts__read-all{all:unset;align-self:flex-end;color:var(--rm-primary,#4f46e5);cursor:pointer;font-size:12px}.rm-alerts__event{border:1px solid var(--rm-border,#e5e7eb);border-radius:9px;display:flex;flex-direction:column;gap:3px;padding:10px 12px}.rm-alerts__event--unread{border-left:3px solid var(--rm-primary,#4f46e5)}.rm-alerts__event-head{align-items:center;display:flex;gap:8px;justify-content:space-between}.rm-alerts__kind{color:var(--rm-text-soft,#6b7280);font-size:10.5px;font-weight:700;letter-spacing:.04em;text-transform:uppercase}.rm-alerts__kind--appeared{color:var(--rm-success,#047857)}.rm-alerts__kind--changed{color:var(--rm-info,#1d4ed8)}.rm-alerts__kind--disappeared{color:var(--rm-warn,#b45309)}.rm-alerts__event-date{color:var(--rm-text-soft,#9ca3af);font-size:11px}.rm-alerts__event-title{color:var(--rm-text,#111827);font-size:13.5px;line-height:1.4;margin:0}.rm-alerts__event-from,.rm-alerts__event-sub{color:var(--rm-text-soft,#9ca3af);font-size:11.5px;margin:0}.rm-alerts__change{color:var(--rm-text-soft,#4b5563);font-size:12.5px;margin:2px 0 0}.rm-alerts__change s{opacity:.65}
|
|
222
|
+
</style>
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RmExportButton - exporter le contenu d'une carte, en un clic.
|
|
3
|
+
|
|
4
|
+
Le pendant court de RmExportPanel. Le panneau pose quatre questions depuis
|
|
5
|
+
le rail parce qu'il exporte un ecran entier ; ce bouton n'en pose aucune
|
|
6
|
+
parce qu'il exporte une carte, dont le lecteur voit deja ce qu'elle
|
|
7
|
+
contient. Poser un assistant sur un resultat de liste demanderait de choisir
|
|
8
|
+
ce qui n'a qu'une reponse.
|
|
9
|
+
|
|
10
|
+
Il ne genere rien lui-meme : le classeur sort de `useRmExport`, le meme que
|
|
11
|
+
le panneau. Page de garde « OIP Healthcare par Tekkare », en-tetes, plafond
|
|
12
|
+
de 32 767 caracteres par cellule : un export de carte et un export de page
|
|
13
|
+
se presentent donc a l'identique devant un client.
|
|
14
|
+
|
|
15
|
+
Le garde-fou du plafond est garde d'Auriga : au-dela de `limit` lignes, une
|
|
16
|
+
modale dit combien la selection contient et combien partiront, plutot que de
|
|
17
|
+
livrer un fichier tronque en silence. Elle ne parait que si la page passe
|
|
18
|
+
`total`, le seul a connaitre le compte reel : `sheets` ne porte que les
|
|
19
|
+
lignes deja chargees.
|
|
20
|
+
|
|
21
|
+
ORIGINE AURIGA
|
|
22
|
+
--------------
|
|
23
|
+
Remplace `<ArgExportXLSX>` (`argExportXLSX.vue`), et le wrapper applicatif
|
|
24
|
+
`AppExportXLSX` qui lui greffait le suivi Posthog.
|
|
25
|
+
|
|
26
|
+
| Auriga | Romulus | Note |
|
|
27
|
+
|--------------------|--------------|----------------------------------------|
|
|
28
|
+
| `name` | `label` | libelle du bouton, pas le nom du fichier |
|
|
29
|
+
| `export-name` | `file-name` | sans extension ni date, ajoutees ici |
|
|
30
|
+
| `subtitle` | `subtitle` | va sur la page de garde |
|
|
31
|
+
| `tabs: Tab[]` | `sheets: RmExportSheet[]` | meme forme : `{ name, schema, data }`, et `schema` garde `{ column, value }` |
|
|
32
|
+
| `kpi` | `kpi` | inchange |
|
|
33
|
+
| `sources` | `sources` | `{ name, date }` |
|
|
34
|
+
| `limit` | `limit` | inchange |
|
|
35
|
+
| `actualAmount` | `total` | `null` devient absent |
|
|
36
|
+
| `style` | `variant` | les noms de RmButton |
|
|
37
|
+
| `beforeExport` | `@before` | evenement plutot que prop fonction ; attendu s'il rend une promesse |
|
|
38
|
+
| `lang` | `lang` | `es` n'est pas repris, personne ne l'appelait |
|
|
39
|
+
| `posthog-key` / `posthog-host` / `posthog-properties` | - | le suivi ne regarde pas un composant de rendu : la page l'accroche a `@export` |
|
|
40
|
+
| `cut` | - | jamais passe par aucun appelant |
|
|
41
|
+
-->
|
|
42
|
+
<script setup lang="ts">
|
|
43
|
+
import { computed, ref } from 'vue'
|
|
44
|
+
import { useRmExport } from '../composables/useRmExport'
|
|
45
|
+
import type { RmExportKpi, RmExportSheet, RmExportSource } from '../utils/export'
|
|
46
|
+
|
|
47
|
+
const props = withDefaults(
|
|
48
|
+
defineProps<{
|
|
49
|
+
/** Nom du fichier, sans extension : la date et `.xlsx` sont ajoutees ici. */
|
|
50
|
+
fileName?: string
|
|
51
|
+
/** Libelle du bouton. */
|
|
52
|
+
label?: string
|
|
53
|
+
/** Titre du document, sur la page de garde. Absent : le libelle sert. */
|
|
54
|
+
title?: string
|
|
55
|
+
subtitle?: string
|
|
56
|
+
/** Les onglets du classeur. Un seul dans le cas courant. */
|
|
57
|
+
sheets?: RmExportSheet[]
|
|
58
|
+
kpi?: RmExportKpi[]
|
|
59
|
+
sources?: RmExportSource[]
|
|
60
|
+
/** Lignes ecrites au plus, par onglet. */
|
|
61
|
+
limit?: number
|
|
62
|
+
/**
|
|
63
|
+
* Lignes que la selection contient vraiment, quand elle depasse ce qui est
|
|
64
|
+
* charge. Au-dela de `limit`, elle declenche la confirmation. Absente,
|
|
65
|
+
* aucune confirmation : le composant ne peut pas deviner qu'il tronque.
|
|
66
|
+
*/
|
|
67
|
+
total?: number
|
|
68
|
+
variant?: string
|
|
69
|
+
size?: 'sm' | 'md' | 'lg'
|
|
70
|
+
lang?: 'fr' | 'en'
|
|
71
|
+
}>(),
|
|
72
|
+
{
|
|
73
|
+
fileName: 'export-tekkare',
|
|
74
|
+
label: '',
|
|
75
|
+
title: '',
|
|
76
|
+
subtitle: '',
|
|
77
|
+
sheets: () => [],
|
|
78
|
+
kpi: () => [],
|
|
79
|
+
sources: () => [],
|
|
80
|
+
limit: 1000,
|
|
81
|
+
total: undefined,
|
|
82
|
+
variant: 'secondary',
|
|
83
|
+
size: 'sm',
|
|
84
|
+
lang: 'fr',
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
const emit = defineEmits<{
|
|
89
|
+
/**
|
|
90
|
+
* Emis avant de construire le classeur, pour la page qui doit d'abord aller
|
|
91
|
+
* chercher les lignes. Rendre une promesse par `until` la fait attendre.
|
|
92
|
+
*/
|
|
93
|
+
before: [hold: (promise: Promise<unknown>) => void]
|
|
94
|
+
/** Emis apres l'ecriture, avec le nom du fichier et le nombre de lignes. */
|
|
95
|
+
export: [result: { fileName: string, rows: number }]
|
|
96
|
+
/** Emis quand l'ecriture echoue. */
|
|
97
|
+
error: [error: unknown]
|
|
98
|
+
}>()
|
|
99
|
+
|
|
100
|
+
const WORDS = {
|
|
101
|
+
fr: {
|
|
102
|
+
label: 'Exporter',
|
|
103
|
+
limitTitle: 'Limite de téléchargement atteinte',
|
|
104
|
+
cancel: 'Annuler',
|
|
105
|
+
confirm: 'Continuer le téléchargement',
|
|
106
|
+
body: (total: number, limit: number) =>
|
|
107
|
+
`Votre sélection contient ${total} éléments. Pour des raisons d’optimisation, seuls les ${limit} premiers seront téléchargés.`,
|
|
108
|
+
},
|
|
109
|
+
en: {
|
|
110
|
+
label: 'Export',
|
|
111
|
+
limitTitle: 'Download limit reached',
|
|
112
|
+
cancel: 'Cancel',
|
|
113
|
+
confirm: 'Continue the download',
|
|
114
|
+
body: (total: number, limit: number) =>
|
|
115
|
+
`Your selection contains ${total} items. For optimisation reasons, only the first ${limit} will be downloaded.`,
|
|
116
|
+
},
|
|
117
|
+
} as const
|
|
118
|
+
|
|
119
|
+
const t = computed(() => WORDS[props.lang] ?? WORDS.fr)
|
|
120
|
+
const buttonLabel = computed(() => props.label || t.value.label)
|
|
121
|
+
|
|
122
|
+
const busy = ref(false)
|
|
123
|
+
const confirming = ref(false)
|
|
124
|
+
|
|
125
|
+
/** Le fichier porte sa date : deux exports du meme ecran ne s'ecrasent pas. */
|
|
126
|
+
const stampedName = computed(() => {
|
|
127
|
+
const today = new Date().toISOString().slice(0, 10)
|
|
128
|
+
return `${props.fileName}-from_${today}`
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const overLimit = computed(
|
|
132
|
+
() => props.total !== undefined && props.total > props.limit,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
async function start() {
|
|
136
|
+
if (overLimit.value) {
|
|
137
|
+
confirming.value = true
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
await write()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function write() {
|
|
144
|
+
busy.value = true
|
|
145
|
+
try {
|
|
146
|
+
// La page peut avoir a charger ses lignes : elle retient le bouton en
|
|
147
|
+
// passant sa promesse, plutot que le composant ne prenne une prop fonction
|
|
148
|
+
// que la moitie des appelants laissent vide.
|
|
149
|
+
const held: Promise<unknown>[] = []
|
|
150
|
+
emit('before', promise => held.push(promise))
|
|
151
|
+
if (held.length) await Promise.all(held)
|
|
152
|
+
|
|
153
|
+
const { run } = useRmExport()
|
|
154
|
+
const result = await run(
|
|
155
|
+
{
|
|
156
|
+
format: 'xlsx',
|
|
157
|
+
range: 'all',
|
|
158
|
+
sections: [],
|
|
159
|
+
columns: [],
|
|
160
|
+
options: [],
|
|
161
|
+
fileName: stampedName.value,
|
|
162
|
+
layout: '',
|
|
163
|
+
schedule: 'once',
|
|
164
|
+
frequency: '',
|
|
165
|
+
email: false,
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
title: props.title || buttonLabel.value,
|
|
169
|
+
subtitle: props.subtitle,
|
|
170
|
+
kpi: props.kpi,
|
|
171
|
+
sources: props.sources,
|
|
172
|
+
sheets: props.sheets,
|
|
173
|
+
limit: props.limit,
|
|
174
|
+
},
|
|
175
|
+
props.lang,
|
|
176
|
+
)
|
|
177
|
+
emit('export', { fileName: result.fileName, rows: result.rows })
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
emit('error', error)
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
busy.value = false
|
|
184
|
+
confirming.value = false
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
</script>
|
|
188
|
+
|
|
189
|
+
<template>
|
|
190
|
+
<RmButton
|
|
191
|
+
:variant="variant"
|
|
192
|
+
:size="size"
|
|
193
|
+
prefix-icon="FileXls"
|
|
194
|
+
:loading="busy"
|
|
195
|
+
@click="start"
|
|
196
|
+
>
|
|
197
|
+
{{ buttonLabel }}
|
|
198
|
+
</RmButton>
|
|
199
|
+
|
|
200
|
+
<RmModal
|
|
201
|
+
v-model="confirming"
|
|
202
|
+
size="sm"
|
|
203
|
+
mode="free"
|
|
204
|
+
:label="t.limitTitle"
|
|
205
|
+
:close-label="t.cancel"
|
|
206
|
+
>
|
|
207
|
+
<template #header>
|
|
208
|
+
<h2 class="rm-export-button__title">{{ t.limitTitle }}</h2>
|
|
209
|
+
</template>
|
|
210
|
+
|
|
211
|
+
<p class="rm-export-button__body">{{ t.body(total ?? 0, limit) }}</p>
|
|
212
|
+
|
|
213
|
+
<template #footer>
|
|
214
|
+
<RmButton variant="secondary" @click="confirming = false">
|
|
215
|
+
{{ t.cancel }}
|
|
216
|
+
</RmButton>
|
|
217
|
+
<RmButton variant="primary" :loading="busy" @click="write">
|
|
218
|
+
{{ t.confirm }}
|
|
219
|
+
</RmButton>
|
|
220
|
+
</template>
|
|
221
|
+
</RmModal>
|
|
222
|
+
</template>
|
|
223
|
+
|
|
224
|
+
<style scoped>
|
|
225
|
+
.rm-export-button__title{color:var(--text-primary);font-size:var(--font-16);font-weight:700;margin:0}.rm-export-button__body{color:var(--text-secondary);font-size:var(--font-14);line-height:1.6;margin:0}
|
|
226
|
+
</style>
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types des alertes posees sur les vues sauvegardees.
|
|
3
|
+
*
|
|
4
|
+
* Ils vivent ici plutot que dans les composants parce que le paquet n'expose
|
|
5
|
+
* que `utils/*` : une app qui construit ces objets doit pouvoir les typer sans
|
|
6
|
+
* importer un `.vue`. Meme raison que pour `utils/export.ts`.
|
|
7
|
+
*/
|
|
8
|
+
export type RmAlertStatus = 'seeding' | 'active' | 'paused' | 'stale' | 'failing';
|
|
9
|
+
/** Ce qu'une alerte sait detecter. Les trois sortent du meme passage de diff. */
|
|
10
|
+
export type RmAlertTrigger = 'appeared' | 'changed' | 'disappeared';
|
|
11
|
+
export interface RmAlertMenuItem {
|
|
12
|
+
key: string;
|
|
13
|
+
label: string;
|
|
14
|
+
icon?: string;
|
|
15
|
+
danger?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface RmAlert {
|
|
18
|
+
id: string;
|
|
19
|
+
title: string;
|
|
20
|
+
/** Nom de la vue surveillee. */
|
|
21
|
+
viewTitle?: string | null;
|
|
22
|
+
status: RmAlertStatus;
|
|
23
|
+
triggers: RmAlertTrigger[];
|
|
24
|
+
/** Libelles des champs suivis, deja traduits. */
|
|
25
|
+
watchedLabels?: string[];
|
|
26
|
+
channels: string[];
|
|
27
|
+
cadence?: string;
|
|
28
|
+
/** Documents dans la reference : la preuve que la requete tourne. */
|
|
29
|
+
trackedCount?: number;
|
|
30
|
+
/** Evenements pas encore lus. */
|
|
31
|
+
unreadCount?: number;
|
|
32
|
+
/** Message du dernier passage refuse. */
|
|
33
|
+
lastError?: string | null;
|
|
34
|
+
lastRunAt?: string | null;
|
|
35
|
+
/** Date de la derniere remise a zero de la reference, s'il y en a eu une. */
|
|
36
|
+
reseededAt?: string | null;
|
|
37
|
+
owner?: string | null;
|
|
38
|
+
createdLabel?: string | null;
|
|
39
|
+
lastEventLabel?: string | null;
|
|
40
|
+
eventsThisMonth?: number;
|
|
41
|
+
/** Partagee avec l'espace de travail. */
|
|
42
|
+
shared?: boolean;
|
|
43
|
+
/** L'utilisateur courant en est proprietaire. */
|
|
44
|
+
isOwner?: boolean;
|
|
45
|
+
}
|
|
46
|
+
export interface RmAlertChange {
|
|
47
|
+
field: string;
|
|
48
|
+
/** Libelle du champ tel que la page le nomme. */
|
|
49
|
+
label?: string | null;
|
|
50
|
+
before: string;
|
|
51
|
+
after: string;
|
|
52
|
+
}
|
|
53
|
+
export interface RmAlertEvent {
|
|
54
|
+
id: string;
|
|
55
|
+
kind: RmAlertTrigger;
|
|
56
|
+
title: string;
|
|
57
|
+
subtitle?: string | null;
|
|
58
|
+
changes?: RmAlertChange[] | null;
|
|
59
|
+
/** Date deja mise en forme par l'appelant. */
|
|
60
|
+
dateLabel: string;
|
|
61
|
+
alertTitle?: string | null;
|
|
62
|
+
read?: boolean;
|
|
63
|
+
}
|
|
64
|
+
/** Un champ que la page propose de surveiller. */
|
|
65
|
+
export interface RmAlertField {
|
|
66
|
+
value: string;
|
|
67
|
+
label: string;
|
|
68
|
+
}
|
|
69
|
+
/** Ce que le formulaire de creation rend a l'appelant. */
|
|
70
|
+
export interface RmAlertDraft {
|
|
71
|
+
title: string;
|
|
72
|
+
triggers: RmAlertTrigger[];
|
|
73
|
+
watchedFields: string[];
|
|
74
|
+
channels: string[];
|
|
75
|
+
cadence: 'daily' | 'weekly';
|
|
76
|
+
visibility: 'private' | 'workspace';
|
|
77
|
+
}
|
|
File without changes
|