@tekkare/romulus 0.1.243 → 0.1.245

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.
Files changed (42) hide show
  1. package/dist/module.json +1 -1
  2. package/layer/app/app.config.ts +6 -0
  3. package/layer/app/components/OipShell.vue +3 -0
  4. package/layer/app/components/layout/alertBrandSync.vue +143 -0
  5. package/layer/app/components/layout/tool/adminTemplates.vue +261 -0
  6. package/layer/app/components/layout/tool/alerts.vue +969 -0
  7. package/layer/app/components/layout/tool/export.vue +590 -0
  8. package/layer/app/components/layout/tool/views.vue +933 -0
  9. package/layer/app/components/layout/viewEmptyState.vue +278 -0
  10. package/layer/app/components/layout/viewPill.vue +550 -0
  11. package/layer/app/components/panels/OipPanelStatistics.vue +447 -0
  12. package/layer/app/composables/useAdminAccess.ts +86 -0
  13. package/layer/app/composables/useAlertBrandSync.ts +85 -0
  14. package/layer/app/composables/useAlertSource.ts +210 -0
  15. package/layer/app/composables/useAlerts.ts +345 -0
  16. package/layer/app/composables/useExportSource.ts +236 -0
  17. package/layer/app/composables/useHubrEmbedded.ts +13 -0
  18. package/layer/app/composables/usePermalink.ts +108 -0
  19. package/layer/app/composables/useSavedExports.ts +286 -0
  20. package/layer/app/composables/useViewCardActions.ts +500 -0
  21. package/layer/app/composables/useViewTemplates.ts +369 -0
  22. package/layer/app/composables/useViews.ts +574 -0
  23. package/layer/app/oip-defaults/alerts.ts +34 -0
  24. package/layer/app/oip-defaults/api.ts +18 -0
  25. package/layer/app/oip-defaults/export.ts +23 -0
  26. package/layer/app/oip-defaults/ui.ts +21 -0
  27. package/layer/app/oip-defaults/views.ts +59 -0
  28. package/layer/app/types/stats.ts +49 -0
  29. package/layer/app/utils/alertFieldLabels.ts +191 -0
  30. package/layer/app/utils/oipRail.ts +29 -3
  31. package/layer/app/utils/oipScopeStore.ts +112 -0
  32. package/layer/app/utils/oipScopes.ts +376 -0
  33. package/layer/app/utils/viewPages.ts +30 -0
  34. package/layer/app/utils/viewTemplateCriteria.ts +215 -0
  35. package/layer/app/utils/viewTemplates.ts +116 -0
  36. package/layer/nuxt.config.ts +18 -0
  37. package/layer/server/api/oip/notes.get.ts +1 -1
  38. package/layer/server/api/oip/stats/users.get.ts +80 -0
  39. package/layer/server/api/oip/stats.get.ts +88 -0
  40. package/layer/server/utils/oipPosthog.ts +134 -0
  41. package/layer/server/utils/oipWorkspaces.ts +51 -0
  42. package/package.json +3 -2
@@ -0,0 +1,969 @@
1
+ <!--
2
+ Panneau « Alertes » du rail.
3
+
4
+ Une alerte surveille le perimetre d'une vue sauvegardee et signale ce qui y
5
+ apparait, y change ou en sort. Le panneau vit dans le cadre, comme celui des
6
+ vues : il ne connait pas la page, c'est `useAlertSource` qui lui dit ce que
7
+ l'ecran sait surveiller.
8
+
9
+ Une alerte ne peut naitre que depuis la page qu'elle surveille : elle part
10
+ avec la requete Elasticsearch de l'ecran, que l'API ne sait pas
11
+ reconstruire. Le bouton reste donc visible partout, avec la raison sous lui
12
+ quand il ne peut rien faire.
13
+ -->
14
+ <script setup lang="ts">
15
+ import { sameViewPage } from "../../../utils/viewPages";
16
+ import { ALERT_BRAND_DIRECTORY, alertEventSourceLabel, alertProjectPath } from "#oip-app/alerts";
17
+ import { oipSnackbar } from "#oip-app/ui";
18
+ import type {
19
+ RmAlert,
20
+ RmAlertDraft,
21
+ RmAlertEvent,
22
+ RmAlertMenuItem,
23
+ } from "@tekkare/romulus/utils/alerts";
24
+ import type { Alert } from "#oip-app/api";
25
+ import { rmFilterOptions, rmFilterOptionsQuery } from "@tekkare/romulus/utils/filterOptions";
26
+
27
+ const { t, locale } = useI18n();
28
+ const localePath = useLocalePath();
29
+ const alerts = useAlerts();
30
+ const source = useAlertSource();
31
+ const views = useViews();
32
+
33
+ const lang = computed<"fr" | "en">(() => (locale.value === "en" ? "en" : "fr"));
34
+
35
+ /** Le magasin de scopes de la page affichee, comme dans le panneau des vues. */
36
+ const store = () => views.scopes.value();
37
+
38
+ /**
39
+ * Les vues enregistrees de cette page : le choix offert par la modale.
40
+ *
41
+ * La vue active en tete : le formulaire preselectionne la premiere, et
42
+ * l'alerte doit porter par defaut la vue que l'ecran affiche, pas une autre.
43
+ */
44
+ const pageViews = computed(() => {
45
+ const page = views.current.value?.page;
46
+ if (!page) return [];
47
+ const list: { id: string; title: string }[] = store()
48
+ .scopeList.value.filter((scope: any) => sameViewPage(scope.page, page))
49
+ .map((scope: any) => ({ id: String(scope.id), title: String(scope.title) }));
50
+ const activeId = alerts.watchableView.value?.id;
51
+ return [...list.filter((view) => view.id === activeId), ...list.filter((view) => view.id !== activeId)];
52
+ });
53
+
54
+ /**
55
+ * L'ecran porte des filtres qui pourraient devenir une vue.
56
+ *
57
+ * Sans eux, proposer « ecran actuel » creerait une vue vide, donc une alerte
58
+ * sur tout l'index.
59
+ */
60
+ const canSaveScreen = computed(
61
+ () => (views.current.value?.criteria?.().length ?? 0) > 0,
62
+ );
63
+ const tab = ref<"active" | "paused" | "events">("active");
64
+ // Ouverture du formulaire : etat partage (cf. useAlerts), pour que la visite
65
+ // guidee puisse l'ouvrir elle-meme. Il est remis a faux au demontage, sinon
66
+ // rouvrir la tuile rouvrirait la modale laissee ouverte.
67
+ const creating = alerts.creating;
68
+ /** Alerte en cours de modification, quand la modale sert a editer. */
69
+ const editing = ref<Alert | null>(null);
70
+ const submitting = ref(false);
71
+ const events = ref<RmAlertEvent[]>([]);
72
+ /** Compte rendu du dernier passage lance a la main, en mode d'essai. */
73
+ const lastRun = ref<string | null>(null);
74
+
75
+ /**
76
+ * Marques proposees dans la modale, pour une alerte de produit.
77
+ *
78
+ * Elles viennent du repertoire des marques, le meme index que le panneau de
79
+ * selection de produits du portail : une alerte de produit ne depend d'aucune
80
+ * page, elle ne peut donc pas se servir du catalogue de l'ecran.
81
+ */
82
+ const brandOptions = ref<string[]>([]);
83
+ const brandsLoading = ref(false);
84
+ /** Numero de la derniere recherche partie : une reponse plus ancienne est ignoree. */
85
+ let brandSearchSeq = 0;
86
+
87
+ /**
88
+ * Champs interroges par la recherche de marques.
89
+ *
90
+ * Une ligne du repertoire porte a la fois la marque, la molecule, la classe
91
+ * et le laboratoire. Chercher sur les quatre et n'agreger que la marque, c'est
92
+ * ce qui fait les synonymes : « pembrolizumab » sort KEYTRUDA, « L01FF » sort
93
+ * les anti-PD-1, « MSD » sort le portefeuille du laboratoire. La regle vient
94
+ * du panneau de selection du portail, elle n'est pas propre aux alertes.
95
+ */
96
+ const brandSearchFields = computed(() => ALERT_BRAND_DIRECTORY.searchFields(locale.value === "en" ? "en" : "fr"));
97
+
98
+ async function searchBrands(input: string) {
99
+ // La liste du montage et celle de la marque de la page partent en parallele :
100
+ // sans ce compteur, la derniere reponse arrivee ecrasait la derniere demandee.
101
+ const seq = ++brandSearchSeq;
102
+ brandsLoading.value = true;
103
+ try {
104
+ const res: any = await ALERT_BRAND_DIRECTORY.search(
105
+ rmFilterOptionsQuery({
106
+ field: ALERT_BRAND_DIRECTORY.field,
107
+ search: brandSearchFields.value,
108
+ input,
109
+ }),
110
+ );
111
+ if (seq !== brandSearchSeq) return;
112
+ brandOptions.value = rmFilterOptions(res).map((option) => option.value);
113
+ } finally {
114
+ if (seq === brandSearchSeq) brandsLoading.value = false;
115
+ }
116
+ }
117
+
118
+ // Une premiere liste des le montage : un selecteur vide donne l'impression
119
+ // qu'il n'y a rien a choisir.
120
+ onMounted(() => searchBrands(""));
121
+
122
+ /**
123
+ * La marque que la page met en avant, confrontee au repertoire.
124
+ *
125
+ * Une fiche produit nomme sa marque, l'ecran de recherche nomme ce qui a ete
126
+ * cherche : ce sont des candidats, pas des marques. « pembrolizumab » ne
127
+ * designe aucune ligne du repertoire, et cocher un terme absent ferait une
128
+ * alerte qui ne rapporte jamais rien. On ne pre-coche donc que sur egalite
129
+ * exacte ; sinon le selecteur s'ouvre quand meme filtre sur le terme, et
130
+ * « pembrolizumab » y liste KEYTRUDA.
131
+ */
132
+ const brandCandidate = computed(() => source.brands.value[0] ?? "");
133
+ const suggestedBrands = ref<string[]>([]);
134
+
135
+ watch(
136
+ brandCandidate,
137
+ async (term) => {
138
+ suggestedBrands.value = [];
139
+ if (!term) return;
140
+ await searchBrands(term);
141
+ // Le terme a pu changer pendant l'aller-retour : la reponse d'avant
142
+ // cocherait la marque de la page precedente.
143
+ if (brandCandidate.value !== term) return;
144
+ const hit = brandOptions.value.find(
145
+ (option) => option.toLowerCase() === term.toLowerCase(),
146
+ );
147
+ suggestedBrands.value = hit ? [hit] : [];
148
+ },
149
+ { immediate: true },
150
+ );
151
+
152
+ // Relecture a chaque ouverture du panneau : l'etat d'une alerte bouge cote
153
+ // worker, pas cote ecran. S'en tenir au premier chargement de la session
154
+ // laissait une carte annoncer « en préparation » longtemps apres l'echec de
155
+ // son passage.
156
+ onMounted(async () => {
157
+ await alerts.load(true);
158
+ alerts.followSeeding();
159
+ });
160
+
161
+ /** Dates lisibles : les composants ne mettent rien en forme eux-memes. */
162
+ function when(value: string | null | undefined): string | null {
163
+ if (!value) return null;
164
+ return new Date(value).toLocaleDateString(locale.value === "en" ? "en-GB" : "fr-FR", {
165
+ day: "numeric",
166
+ month: "short",
167
+ year: "numeric",
168
+ });
169
+ }
170
+
171
+ function whenExact(value: string): string {
172
+ return new Date(value).toLocaleString(locale.value === "en" ? "en-GB" : "fr-FR", {
173
+ day: "numeric",
174
+ month: "short",
175
+ hour: "2-digit",
176
+ minute: "2-digit",
177
+ });
178
+ }
179
+
180
+ /**
181
+ * Libelles des champs suivis, tels que la page les nomme.
182
+ *
183
+ * La sentinelle `*` ne designe aucun champ du catalogue : elle dit qu'on
184
+ * compare le document entier. L'ecrire telle quelle laissait une etoile nue
185
+ * dans la carte. Une alerte d'une autre page ne trouve rien dans la source de
186
+ * l'ecran : le catalogue commun des champs prend le relais.
187
+ */
188
+ const fieldLabel = (field: string) => {
189
+ if (field === "*") {
190
+ return lang.value === "en" ? "All fields" : "Tous les champs";
191
+ }
192
+ return (
193
+ source.current.value?.fields.find((entry) => entry.value === field)?.label ??
194
+ alertFieldLabel(field, null, t, locale.value).label
195
+ );
196
+ };
197
+
198
+ /**
199
+ * Ou ranger chaque alerte dans la liste.
200
+ *
201
+ * Trois cas, et pas deux : une alerte de marque suit tout le hub, donc elle
202
+ * couvre aussi la page affichee. La ranger dans « autres pages » serait faux,
203
+ * la ranger dans « cette page » ferait croire qu'elle s'y limite.
204
+ */
205
+ const pageAlertIds = computed(
206
+ () => new Set(alerts.forCurrentPage.value.map((alert) => alert.id)),
207
+ );
208
+
209
+ const GROUPS = {
210
+ fr: {
211
+ page: "Cette page",
212
+ hub: "Marques suivies partout",
213
+ project: "Projets suivis",
214
+ other: "Autres pages",
215
+ elsewhere: "Autres applications",
216
+ },
217
+ en: {
218
+ page: "This page",
219
+ hub: "Brands watched everywhere",
220
+ project: "Watched projects",
221
+ other: "Other pages",
222
+ elsewhere: "Other applications",
223
+ },
224
+ };
225
+
226
+ function groupOf(alert: Alert): "page" | "hub" | "project" | "other" | "elsewhere" {
227
+ // Avant tout le reste : une alerte de marque posee ailleurs suit les index
228
+ // de son app, pas ceux de ce hub.
229
+ if (!alerts.fromThisApp(alert)) return "elsewhere";
230
+ if (alert.kind === "product") return "hub";
231
+ if (alert.kind === "project") return "project";
232
+ return pageAlertIds.value.has(alert.id) ? "page" : "other";
233
+ }
234
+
235
+ /** La page affichee d'abord : c'est la reponse a « qu'est-ce qui veille ici ». */
236
+ const GROUP_ORDER = { page: 0, hub: 1, project: 2, other: 3, elsewhere: 4 };
237
+
238
+ /** Les autres apps restent consultables, repliees en bas de liste. */
239
+ const collapsedGroups = computed(() => [GROUPS[lang.value].elsewhere]);
240
+
241
+ /** Les alertes de la page en tete, sans quoi les sections s'entrelaceraient. */
242
+ const sorted = computed(() =>
243
+ [...alerts.alerts.value].sort(
244
+ (a, b) => GROUP_ORDER[groupOf(a)] - GROUP_ORDER[groupOf(b)],
245
+ ),
246
+ );
247
+
248
+ const cards = computed<RmAlert[]>(() =>
249
+ sorted.value.map((alert) => ({
250
+ id: alert.id,
251
+ title: alert.title,
252
+ kind: alert.kind,
253
+ // Une alerte de produit ne surveille pas une vue : la carte annonce ses
254
+ // marques a la place.
255
+ viewTitle:
256
+ alert.kind === "product"
257
+ ? alert.brands.join(", ")
258
+ : alert.kind === "project"
259
+ ? alert.projectTitle
260
+ : alert.pulseTitle,
261
+ status: alert.status,
262
+ triggers: alert.triggers,
263
+ watchedLabels: alert.watchedFields.map(fieldLabel),
264
+ channels: alert.channels,
265
+ cadence: alert.channels.includes("email") ? alert.cadence : undefined,
266
+ trackedCount: alert.trackedCount,
267
+ unreadCount: alert.unreadCount,
268
+ lastError: alert.lastError,
269
+ lastErrorKind: alert.lastErrorKind,
270
+ lastErrorCount: alert.lastErrorCount,
271
+ maxTracked: alert.maxTracked,
272
+ reseededAt: when(alert.reseededAt),
273
+ createdLabel: when(alert.createdAt),
274
+ lastEventLabel: when(alert.lastRunAt),
275
+ shared: alert.visibility === "workspace",
276
+ isOwner: alert.isOwner,
277
+ group: GROUPS[lang.value][groupOf(alert)],
278
+ })),
279
+ );
280
+
281
+ /**
282
+ * Le journal, toutes alertes confondues.
283
+ *
284
+ * L'API rend les evenements par alerte : le panneau les rassemble lui-meme.
285
+ * Avec une poignee d'alertes c'est une poignee d'appels, et ca evite une
286
+ * route de plus tant que la fonctionnalite n'a pas prouve son usage.
287
+ */
288
+ function toEventCard(event: any, alertTitle: string) {
289
+ return {
290
+ id: event.id,
291
+ kind: event.kind,
292
+ title: event.title,
293
+ subtitle: event.subtitle,
294
+ // Le moteur libelle avec les noms de la donnee : le chemin est relu ici,
295
+ // avec les mots de l'app. Les evenements deja en base en profitent, leur
296
+ // libelle etant fige au moment ou ils ont ete rapportes.
297
+ // Les cles techniques ne se montrent pas : un identifiant interne qui
298
+ // bouge n'apprend rien et noie la ligne d'a cote. Le moteur ne les
299
+ // rapporte plus, ce filtre vaut pour ce qui est deja journalise.
300
+ changes: (event.changes ?? [])
301
+ .filter((change: any) => !isAlertNoiseField(change.field))
302
+ .map((change: any) => ({
303
+ ...change,
304
+ ...alertFieldLabel(change.field, change.label, t, locale.value),
305
+ before: alertChangeValue(change.before, t, locale.value),
306
+ after: alertChangeValue(change.after, t, locale.value),
307
+ })),
308
+ dateLabel: whenExact(event.detectedAt),
309
+ // Une alerte de marque traverse tout le hub : sans la provenance, la carte
310
+ // dit qu'il s'est passe quelque chose sans dire ou aller le lire.
311
+ source: alertEventSourceLabel(event.docId, t, lang.value),
312
+ alertTitle,
313
+ read: event.read,
314
+ detectedAt: event.detectedAt,
315
+ };
316
+ }
317
+
318
+ /**
319
+ * Une carte vide ne se montre pas.
320
+ *
321
+ * Un evenement journalise avant le filtre des cles techniques peut n'avoir
322
+ * plus aucune ligne a montrer : « MODIFIE » suivi de rien n'est pas une
323
+ * information. Une apparition ou une sortie de perimetre, elles, se suffisent
324
+ * a leur titre.
325
+ */
326
+ function hasSomethingToSay(card: { kind: string; changes: unknown[] }): boolean {
327
+ return card.kind !== "changed" || card.changes.length > 0;
328
+ }
329
+
330
+ async function loadEvents() {
331
+ // Toutes les alertes, dans l'ordre des cartes : couper la liste brute a dix
332
+ // faisait taire des alertes au hasard de l'ordre de l'API.
333
+ const lists = await Promise.all(
334
+ // Le journal ne parle que de cette app, comme la pastille de la tuile.
335
+ sorted.value.filter(alerts.fromThisApp).map(async (alert) => {
336
+ const rows = await alerts.events(alert.id, 20);
337
+ return rows.map((event) => toEventCard(event, alert.title)).filter(hasSomethingToSay);
338
+ }),
339
+ );
340
+
341
+ events.value = lists
342
+ .flat()
343
+ .sort((a, b) => b.detectedAt.localeCompare(a.detectedAt))
344
+ .slice(0, 60);
345
+ }
346
+
347
+ watch(tab, (value) => {
348
+ if (value === "events") loadEvents();
349
+ });
350
+
351
+ /**
352
+ * Depliement d'une carte : ce que cette alerte, elle, a rapporte.
353
+ *
354
+ * Une seule a la fois. Le journal se lit alerte par alerte, et garder
355
+ * plusieurs listes ouvertes demanderait autant d'appels a chaque relecture
356
+ * pour un panneau deja long.
357
+ */
358
+ const expandedId = ref<string | null>(null);
359
+ const expandedEvents = ref<RmAlertEvent[]>([]);
360
+ const expandedLoading = ref(false);
361
+
362
+ async function onOpen(id: string) {
363
+ if (expandedId.value === id) {
364
+ expandedId.value = null;
365
+ return;
366
+ }
367
+ expandedId.value = id;
368
+ expandedEvents.value = [];
369
+ expandedLoading.value = true;
370
+ try {
371
+ const alert = alerts.alerts.value.find((entry) => entry.id === id);
372
+ const rows = await alerts.events(id, 20);
373
+ // L'alerte a pu etre repliee entre-temps : ecrire dans le vide y
374
+ // rouvrirait un journal que personne n'a demande.
375
+ if (expandedId.value !== id) return;
376
+ expandedEvents.value = rows
377
+ .map((event) => toEventCard(event, alert?.title ?? ""))
378
+ .filter(hasSomethingToSay);
379
+ } finally {
380
+ if (expandedId.value === id) expandedLoading.value = false;
381
+ }
382
+ }
383
+
384
+ const menu = computed<RmAlertMenuItem[]>(() => [
385
+ {
386
+ key: "edit",
387
+ label: lang.value === "en" ? "Edit" : "Modifier",
388
+ icon: "PencilSimple",
389
+ },
390
+ {
391
+ key: "toggle",
392
+ label: lang.value === "en" ? "Pause / resume" : "Mettre en pause ou réactiver",
393
+ icon: "Pause",
394
+ },
395
+ {
396
+ key: "read",
397
+ label: lang.value === "en" ? "Mark as read" : "Marquer comme lu",
398
+ icon: "Check",
399
+ },
400
+ // Mode d'essai seulement : derouler la chaine sans attendre le cron.
401
+ ...(alerts.testCadence.value
402
+ ? [
403
+ {
404
+ key: "run",
405
+ label: lang.value === "en" ? "Run now (test)" : "Lancer maintenant (essai)",
406
+ icon: "Play",
407
+ },
408
+ {
409
+ key: "simulate",
410
+ label:
411
+ lang.value === "en"
412
+ ? "Simulate a change on 2 sources (test)"
413
+ : "Simuler un changement sur 2 sources (essai)",
414
+ icon: "Flask",
415
+ },
416
+ {
417
+ key: "digest",
418
+ label: lang.value === "en" ? "Send my digest (test)" : "M’envoyer le digest (essai)",
419
+ icon: "Envelope",
420
+ },
421
+ ]
422
+ : []),
423
+ {
424
+ key: "delete",
425
+ label: lang.value === "en" ? "Delete" : "Supprimer",
426
+ icon: "Trash",
427
+ danger: true,
428
+ },
429
+ ]);
430
+
431
+ async function onMenu({ key, id }: { key: string; id: string }) {
432
+ const alert = alerts.alerts.value.find((entry) => entry.id === id);
433
+ if (!alert) return;
434
+
435
+ if (key === "edit") {
436
+ // Une alerte de projet se regle sur la page du projet, pas dans le
437
+ // formulaire des vues : elle n'a ni vue ni marques a choisir.
438
+ if (alert.kind === "project" && alert.projectId) {
439
+ await navigateTo(localePath(alertProjectPath(alert.projectId)));
440
+ return;
441
+ }
442
+ editing.value = alert;
443
+ creating.value = true;
444
+ return;
445
+ }
446
+ if (key === "toggle") {
447
+ // Une alerte de projet ne se met pas en pause : elle couperait tous ses
448
+ // abonnes. Chacun arrete son propre suivi, depuis la page du projet.
449
+ if (alert.kind === "project" && alert.projectId) {
450
+ await navigateTo(localePath(alertProjectPath(alert.projectId)));
451
+ return;
452
+ }
453
+ await alerts.update(id, { status: alert.status === "paused" ? "active" : "paused" });
454
+ return;
455
+ }
456
+ if (key === "read") {
457
+ await alerts.markRead({ alertId: id });
458
+ return;
459
+ }
460
+ if (key === "run") {
461
+ const result = await alerts.runNow(id);
462
+ lastRun.value =
463
+ result.status === "refused"
464
+ ? (result.reason ?? "refusé")
465
+ : `${result.status} : ${result.trackedCount} suivis, ${result.events} évènement(s)`;
466
+ if (tab.value === "events") await loadEvents();
467
+ return;
468
+ }
469
+ if (key === "simulate") {
470
+ const result = await alerts.simulate(id, { kind: "changed", count: 1, sources: 2 });
471
+ lastRun.value =
472
+ result.status === "refused"
473
+ ? (result.reason ?? "refusé")
474
+ : `simulation sur ${result.sources.length} source(s) : ${result.events} évènement(s)`;
475
+ // Le journal de la carte est deja ouvert : le relire evite de replier
476
+ // puis rouvrir pour voir ce que la simulation vient de produire.
477
+ if (expandedId.value === id) {
478
+ expandedId.value = null;
479
+ await onOpen(id);
480
+ }
481
+ if (tab.value === "events") await loadEvents();
482
+ return;
483
+ }
484
+ if (key === "digest") {
485
+ const result = await alerts.sendDigestNow();
486
+ lastRun.value = `digest ${result.status} : ${result.eventCount} évènement(s)${
487
+ result.reason ? ` (${result.reason})` : ""
488
+ }`;
489
+ return;
490
+ }
491
+ if (key === "delete") {
492
+ await alerts.remove(id);
493
+ }
494
+ }
495
+
496
+ /**
497
+ * Poser a l'ecran la vue choisie dans la modale.
498
+ *
499
+ * Indispensable : la requete Elasticsearch part de la page, pas des criteres.
500
+ * Creer une alerte sur une vue non appliquee lui ferait porter les filtres de
501
+ * l'ecran sous le nom d'une autre vue.
502
+ */
503
+ function applyView(id: string) {
504
+ const scope = store().scopeList.value.find((entry: any) => entry.id === id);
505
+ if (!scope) return;
506
+ // Copie volontaire, meme raison que dans le panneau des vues : les filtres
507
+ // se rejouent sur un changement de reference de `selectedScope`.
508
+ store().setSelectedScope({ ...scope });
509
+ views.applyPrefs(scope, views.current.value?.prefsScope);
510
+ views.current.value?.onApply?.(scope);
511
+ }
512
+
513
+ /**
514
+ * Attend que les filtres de l'ecran soient ceux de la vue qu'on vient
515
+ * d'appliquer.
516
+ *
517
+ * Les filtres se rejouent par des watchers, pas dans le meme tour : capturer
518
+ * la requete juste apres `applyView` pouvait encore lire l'ecran d'avant.
519
+ * Seuls les filtres comptent, pas le « Personnaliser » : c'est eux qui font
520
+ * la requete Elasticsearch. Rend faux si l'ecran ne rejoint jamais la vue, ce
521
+ * qui arrive quand la vue appartient a un autre onglet du meme chemin (les six
522
+ * onglets des avis HAS partagent leur page) : ses criteres ne s'appliquent a
523
+ * rien ici.
524
+ */
525
+ async function settled(scope: any, attempts = 10): Promise<boolean> {
526
+ const wanted = viewSignature(viewHandFilters(parseViewCriteria(scope)));
527
+ const matches = () => viewSignature(viewHandFilters(views.criteriaOf())) === wanted;
528
+ for (let i = 0; i < attempts; i++) {
529
+ await nextTick();
530
+ if (matches()) return true;
531
+ await new Promise((resolve) => setTimeout(resolve, 50));
532
+ }
533
+ return matches();
534
+ }
535
+
536
+ function notify(text: string) {
537
+ oipSnackbar([
538
+ { attribute: "isVisible", value: true },
539
+ { attribute: "text", value: text },
540
+ { attribute: "loader", value: false },
541
+ { attribute: "hasTimeOut", value: true },
542
+ ]);
543
+ }
544
+
545
+ /**
546
+ * Rearmer : repointer l'alerte sur la requete de SA vue, pas sur l'ecran.
547
+ *
548
+ * Le bouton sort sur toute alerte perimee des que la page sait construire une
549
+ * requete, y compris celles d'une autre page : envoyer la requete d'ici a une
550
+ * alerte de la-bas la ferait surveiller un autre index sous son ancien nom.
551
+ * Et sur la bonne page, l'ecran peut porter d'autres filtres que la vue : on
552
+ * applique la vue d'abord, puis on capture une fois les filtres rejoues.
553
+ */
554
+ async function onRearm(id: string) {
555
+ const alert = alerts.alerts.value.find((entry) => entry.id === id);
556
+ if (!alert) return;
557
+
558
+ const here = alerts.forCurrentPage.value.some((entry) => entry.id === id);
559
+ const scope = here
560
+ ? store().scopeList.value.find((entry: any) => entry.id === alert.pulseId)
561
+ : null;
562
+ if (!scope) {
563
+ notify(
564
+ lang.value === "en"
565
+ ? "Open the page this alert watches to re-arm it."
566
+ : "Ouvrez la page que cette alerte surveille pour la réarmer.",
567
+ );
568
+ return;
569
+ }
570
+
571
+ applyView(scope.id);
572
+ if (!(await settled(scope))) {
573
+ notify(
574
+ lang.value === "en"
575
+ ? `Open the tab of the view “${scope.title}” to re-arm this alert.`
576
+ : `Ouvrez l’onglet de la vue « ${scope.title} » pour réarmer cette alerte.`,
577
+ );
578
+ return;
579
+ }
580
+
581
+ await alerts.rearm(id);
582
+ }
583
+
584
+ async function submit(draft: RmAlertDraft) {
585
+ submitting.value = true;
586
+ try {
587
+ // Modification : la vue ne bouge pas, donc la requete figee non plus.
588
+ // Seuls le titre, les declencheurs, les champs suivis et la remise
589
+ // partent a l'API, qui repose la reference si les champs ont change.
590
+ if (editing.value) {
591
+ // Les marques ne partent que si elles ont bouge : les renvoyer telles
592
+ // quelles remettrait l'alerte a zero pour rien.
593
+ const brandsBefore = [...(editing.value.brands ?? [])].sort().join("|");
594
+ const brandsAfter = [...draft.brands].sort().join("|");
595
+ await alerts.update(editing.value.id, {
596
+ title: draft.title,
597
+ triggers: draft.triggers,
598
+ watchedFields: draft.watchedFields,
599
+ // Les libelles viennent de la page surveillee : depuis une autre page,
600
+ // la source de l'ecran nomme d'autres champs, et un dictionnaire vide
601
+ // effacerait ceux que l'alerte porte deja.
602
+ ...(editingHere.value
603
+ ? {
604
+ fieldLabels: Object.fromEntries(
605
+ (source.current.value?.fields ?? [])
606
+ .filter((field) => draft.watchedFields.includes("*") || draft.watchedFields.includes(field.value))
607
+ .map((field: any) => [field.value, field.label]),
608
+ ),
609
+ }
610
+ : {}),
611
+ ...(editing.value.kind === "product" && brandsAfter !== brandsBefore
612
+ ? { brands: draft.brands }
613
+ : {}),
614
+ channels: draft.channels,
615
+ cadence: draft.cadence,
616
+ });
617
+ creating.value = false;
618
+ editing.value = null;
619
+ await alerts.load(true);
620
+ alerts.followSeeding();
621
+ return;
622
+ }
623
+
624
+ // « Ecran actuel » : la vue n'existe pas encore, on l'enregistre sous le
625
+ // nom de l'alerte avant de s'y accrocher. Une alerte de PRODUIT, elle,
626
+ // ne surveille aucune vue : lui en creer une laissait une vue fantome
627
+ // dans le panneau des vues, nommee comme l'alerte.
628
+ if (!draft.viewId && draft.kind !== "product") {
629
+ const page = views.current.value?.page;
630
+ // Les identifiants d'avant : la creation ne rend pas la vue, elle
631
+ // recharge la liste. Chercher par titre confondait deux vues homonymes.
632
+ const before = new Set(store().scopeList.value.map((scope: any) => scope.id));
633
+ await store().createScope({
634
+ title: draft.title,
635
+ // `criteriaOf` et non `criteria()` : la vue emporte aussi le
636
+ // « Personnaliser », comme celles du panneau Mes vues et de la pastille.
637
+ criteria: views.criteriaOf(),
638
+ currentPage: page,
639
+ });
640
+ const created = store()
641
+ .scopeList.value.filter((scope: any) => sameViewPage(scope.page, page))
642
+ .find((scope: any) => !before.has(scope.id));
643
+ if (!created) return;
644
+ applyView(created.id);
645
+ // Laisser les watchers de filtres de la page rejouer avant la capture.
646
+ await settled(created);
647
+ } else if (draft.kind !== "product" && draft.viewId !== alerts.watchableView.value?.id) {
648
+ // Vue nommee sans avoir ete appliquee (preselection du formulaire, ou
649
+ // vue d'un autre onglet de la meme page) : la requete part de l'ecran,
650
+ // elle doit donc etre celle de cette vue. Meme garde que le rearmement.
651
+ const scope = store().scopeList.value.find((entry: any) => entry.id === draft.viewId);
652
+ if (!scope) {
653
+ notify(
654
+ lang.value === "en"
655
+ ? "This view no longer exists. Pick another one."
656
+ : "Cette vue n’existe plus. Choisissez-en une autre.",
657
+ );
658
+ return;
659
+ }
660
+ applyView(scope.id);
661
+ if (!(await settled(scope))) {
662
+ notify(
663
+ lang.value === "en"
664
+ ? `Open the tab of the view “${scope.title}” to create this alert.`
665
+ : `Ouvrez l’onglet de la vue « ${scope.title} » pour créer cette alerte.`,
666
+ );
667
+ return;
668
+ }
669
+ }
670
+
671
+ const alert = await alerts.create(draft);
672
+ // Rien de cree (vue ou requete manquante) : fermer sans rien dire
673
+ // laissait croire que l'alerte existait.
674
+ if (!alert) {
675
+ notify(
676
+ lang.value === "en"
677
+ ? "The alert could not be created. Save a view on this page first."
678
+ : "L’alerte n’a pas pu être créée. Enregistrez d’abord une vue sur cette page.",
679
+ );
680
+ return;
681
+ }
682
+ creating.value = false;
683
+ } finally {
684
+ submitting.value = false;
685
+ }
686
+ }
687
+
688
+ /**
689
+ * Pourquoi le bouton ne peut rien faire, quand il ne peut rien faire. Deux
690
+ * raisons distinctes, et l'utilisateur ne peut agir que sur la seconde.
691
+ */
692
+ const createHint = computed(() => {
693
+ if (alerts.canCreate.value) return "";
694
+ // Ni requete d'ecran, ni vue : la page n'offre aucune prise. Les autres cas
695
+ // se debloquent en enregistrant une vue, y compris celui d'une page de
696
+ // ventes, ou seule l'alerte de marque est possible.
697
+ if (!source.current.value && !views.current.value) {
698
+ return lang.value === "en"
699
+ ? "This page cannot be watched yet. Open a page that offers saved views."
700
+ : "Cette page ne se surveille pas encore. Ouvrez une page qui propose des vues sauvegardées.";
701
+ }
702
+ return lang.value === "en"
703
+ ? "Save the current view first: an alert is attached to a view, not to a screen."
704
+ : "Enregistrez d’abord la vue affichée : une alerte s’accroche à une vue, pas à un écran.";
705
+ });
706
+
707
+ const modalTitle = computed(() => {
708
+ if (editing.value) return lang.value === "en" ? "Edit alert" : "Modifier l’alerte";
709
+ return lang.value === "en" ? "New alert" : "Nouvelle alerte";
710
+ });
711
+
712
+ /**
713
+ * Les marques que porte la vue affichee, posees d'avance dans le formulaire.
714
+ *
715
+ * Evite de retaper a la main ce que l'ecran affiche deja. La liste part figee
716
+ * dans l'alerte : une vue retouchee plus tard ne l'entraine pas d'elle-meme,
717
+ * c'est le propre d'une alerte de marque, qui ne tient d'aucune page. Le
718
+ * report est propose a l'enregistrement de la vue, cf. useAlertBrandSync.
719
+ */
720
+ const viewBrands = computed(() =>
721
+ viewBrandsFromCriteria(views.current.value?.criteria?.() ?? []),
722
+ );
723
+
724
+ /**
725
+ * L'alerte editee est-elle de cette page ? Sinon l'ecran ne connait ni ses
726
+ * champs ni leurs libelles : le formulaire ne propose rien a cocher.
727
+ */
728
+ const editingHere = computed(
729
+ () => !!editing.value && alerts.forCurrentPage.value.some((entry) => entry.id === editing.value!.id),
730
+ );
731
+
732
+ /**
733
+ * L'alerte editee, dans la forme que le formulaire attend. `kind` et `brands`
734
+ * en font partie : sans eux, une alerte de produit s'editait comme une vue.
735
+ */
736
+ const editingDraft = computed(() =>
737
+ editing.value
738
+ ? {
739
+ title: editing.value.title,
740
+ // Une alerte de projet n'arrive jamais ici : « Modifier » mene a la
741
+ // page du projet (cf. `onMenu`).
742
+ kind: editing.value.kind === "product" ? ("product" as const) : ("view" as const),
743
+ brands: editing.value.brands ?? [],
744
+ triggers: editing.value.triggers,
745
+ watchedFields: editing.value.watchedFields,
746
+ channels: editing.value.channels,
747
+ cadence: editing.value.cadence as "daily" | "weekly" | "monthly",
748
+ }
749
+ : undefined,
750
+ );
751
+
752
+ /**
753
+ * Ce dont le formulaire part.
754
+ *
755
+ * En modification, l'alerte elle-meme. En creation, les marques de la vue,
756
+ * et seulement la ou la page ne livre pas de requete : ailleurs l'alerte de
757
+ * vue reste le choix par defaut, et pre-cocher des marques la ferait
758
+ * basculer en alerte de produit sans que personne l'ait demande.
759
+ */
760
+ const initialDraft = computed(() => {
761
+ if (editing.value) return editingDraft.value;
762
+ // La marque de la page l'emporte : elle est ce que l'ecran montre, et une
763
+ // page qui en nomme une n'a de toute facon pas de vue a surveiller.
764
+ if (suggestedBrands.value.length) {
765
+ const brands = suggestedBrands.value;
766
+ return {
767
+ kind: "product" as const,
768
+ brands,
769
+ // Le formulaire ne nomme l'alerte que sur un changement de marque, et
770
+ // ici les marques arrivent deja posees : sans ce titre, le champ reste
771
+ // vide et le bouton refuse.
772
+ title: `${lang.value === "en" ? "Watch" : "Veille"} ${brands.join(", ")}`,
773
+ };
774
+ }
775
+ if (source.current.value || viewBrands.value.length === 0) return undefined;
776
+ // Meme titre que ci-dessus : sans vue en base, `viewTitle` est vide et le
777
+ // formulaire refuserait un brouillon sans nom.
778
+ return {
779
+ kind: "product" as const,
780
+ brands: viewBrands.value,
781
+ title: `${lang.value === "en" ? "Watch" : "Veille"} ${viewBrands.value.join(", ")}`,
782
+ };
783
+ });
784
+
785
+ /** Fermer remet la modale a zero : sinon la suivante rouvre sur l'ancienne. */
786
+ function close(open = false) {
787
+ creating.value = open;
788
+ if (!open) editing.value = null;
789
+ }
790
+
791
+ /**
792
+ * Le formulaire s'ecarte de sa reference de depart (cf. `update:dirty` de
793
+ * RmAlertForm). Fermer sans le dire perdrait une alerte a moitie composee.
794
+ */
795
+ const formDirty = ref(false);
796
+ const confirmingClose = ref(false);
797
+
798
+ function requestClose() {
799
+ if (formDirty.value) {
800
+ confirmingClose.value = true;
801
+ return;
802
+ }
803
+ close(false);
804
+ }
805
+
806
+ function onModalUpdate(open: boolean) {
807
+ if (open) {
808
+ close(true);
809
+ return;
810
+ }
811
+ requestClose();
812
+ }
813
+
814
+ function cancelConfirmClose() {
815
+ confirmingClose.value = false;
816
+ }
817
+
818
+ function discardClose() {
819
+ confirmingClose.value = false;
820
+ formDirty.value = false;
821
+ close(false);
822
+ }
823
+
824
+ // L'etat d'ouverture est partage (la visite guidee l'ouvre) : il faut donc le
825
+ // refermer avec le panneau, sans quoi la modale ressortirait a la prochaine
826
+ // ouverture de la tuile.
827
+ onBeforeUnmount(() => close(false));
828
+
829
+ /**
830
+ * Les alertes de cette page peuvent etre rearmees ici, parce que l'ecran sait
831
+ * reconstruire leur requete. Ailleurs, le bouton n'apparait pas.
832
+ */
833
+ const onWatchedPage = computed(
834
+ () => !!source.current.value && alerts.forCurrentPage.value.length > 0,
835
+ );
836
+ </script>
837
+
838
+ <template>
839
+ <div>
840
+ <RmAlerts
841
+ v-model:tab="tab"
842
+ :alerts="cards"
843
+ :collapsed-groups="collapsedGroups"
844
+ :events="events"
845
+ :menu="menu"
846
+ :loading="alerts.loading.value"
847
+ :can-create="alerts.canCreate.value"
848
+ :create-hint="createHint"
849
+ :on-watched-page="onWatchedPage"
850
+ :expanded-id="expandedId"
851
+ :expanded-events="expandedEvents"
852
+ :expanded-loading="expandedLoading"
853
+ :lang="lang"
854
+ @open="onOpen"
855
+ @create="editing = null; creating = true"
856
+ @rearm="onRearm"
857
+ @menu="onMenu"
858
+ @read-all="alerts.markRead({}).then(() => alerts.load(true))"
859
+ />
860
+
861
+ <p v-if="lastRun" class="alerts_test_run">
862
+ {{ lang === "en" ? "Test run" : "Passage d’essai" }} : {{ lastRun }}
863
+ </p>
864
+
865
+ <!-- Large plutot que moyenne : le formulaire empile sept sections, et les
866
+ libelles de champ (« Dernier mouvement de prix (date) ») se coupaient
867
+ sur deux lignes a 640px. -->
868
+ <RmModal
869
+ :model-value="creating"
870
+ mode="free"
871
+ size="lg"
872
+ :label="modalTitle"
873
+ @update:model-value="onModalUpdate"
874
+ >
875
+ <template #header>{{ modalTitle }}</template>
876
+
877
+ <RmAlertForm
878
+ v-if="
879
+ source.current.value ||
880
+ editing ||
881
+ pageViews.length ||
882
+ source.brands.value.length ||
883
+ viewBrands.length
884
+ "
885
+ :key="editing?.id ?? 'new'"
886
+ :mode="editing ? 'edit' : 'create'"
887
+ :initial="initialDraft"
888
+ :view-title="
889
+ editing
890
+ ? (editing.kind === 'view' ? editing.pulseTitle ?? '' : '')
891
+ : alerts.watchableView.value?.title ?? ''
892
+ "
893
+ :views="pageViews"
894
+ :can-save-screen="canSaveScreen"
895
+ :fields="editing && !editingHere ? [] : source.current.value?.fields ?? []"
896
+ :submitting="submitting"
897
+ :brand-options="brandOptions"
898
+ :brands-loading="brandsLoading"
899
+ :max-brands="
900
+ alerts.alerts.value.find((entry) => entry.kind === 'product')?.maxBrands ?? 20
901
+ "
902
+ :can-watch-view="!!source.current.value"
903
+ :test-cadence="alerts.testCadence.value"
904
+ :lang="lang"
905
+ @select-view="applyView"
906
+ @search-brands="searchBrands"
907
+ @submit="submit"
908
+ @cancel="requestClose"
909
+ @update:dirty="formDirty = $event"
910
+ />
911
+ </RmModal>
912
+
913
+ <!-- Un brouillon d'alerte non enregistre se perd a la fermeture : la
914
+ modale n'en garde rien entre deux ouvertures. -->
915
+ <RmModal
916
+ v-model="confirmingClose"
917
+ size="sm"
918
+ mode="free"
919
+ :label="lang === 'en' ? 'Close without saving?' : 'Fermer sans enregistrer ?'"
920
+ >
921
+ <template #header>
922
+ {{ lang === "en" ? "Close without saving?" : "Fermer sans enregistrer ?" }}
923
+ </template>
924
+ <p class="alerts_confirm_text">
925
+ {{
926
+ lang === "en"
927
+ ? "This alert isn't saved. It will be lost if you close now."
928
+ : "Cette alerte n’est pas enregistrée. Elle sera perdue si vous fermez maintenant."
929
+ }}
930
+ </p>
931
+ <template #footer>
932
+ <div class="alerts_confirm_actions">
933
+ <RmButton variant="secondary" @click="cancelConfirmClose">
934
+ {{ lang === "en" ? "Cancel" : "Annuler" }}
935
+ </RmButton>
936
+ <RmButton variant="danger" prefix-icon="Trash" @click="discardClose">
937
+ {{ lang === "en" ? "Close without saving" : "Fermer sans enregistrer" }}
938
+ </RmButton>
939
+ </div>
940
+ </template>
941
+ </RmModal>
942
+ </div>
943
+ </template>
944
+
945
+ <style scoped>
946
+ /* Compte rendu du mode d'essai : volontairement sobre, il ne fait pas partie
947
+ du produit et ne sortira jamais en production. */
948
+ .alerts_test_run {
949
+ margin: 10px 0 0;
950
+ padding: 8px 10px;
951
+ border-radius: 8px;
952
+ background: var(--bg-surface-3);
953
+ color: var(--text-secondary);
954
+ font-size: 12px;
955
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
956
+ }
957
+
958
+ .alerts_confirm_text {
959
+ margin: 0;
960
+ font-size: 13px;
961
+ color: var(--text-secondary);
962
+ }
963
+
964
+ .alerts_confirm_actions {
965
+ display: flex;
966
+ justify-content: flex-end;
967
+ gap: 8px;
968
+ }
969
+ </style>