@tekkare/romulus 0.1.243 → 0.1.244

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 (33) hide show
  1. package/dist/module.json +1 -1
  2. package/layer/app/components/layout/alertBrandSync.vue +143 -0
  3. package/layer/app/components/layout/tool/adminTemplates.vue +261 -0
  4. package/layer/app/components/layout/tool/alerts.vue +969 -0
  5. package/layer/app/components/layout/tool/export.vue +590 -0
  6. package/layer/app/components/layout/tool/views.vue +933 -0
  7. package/layer/app/components/layout/viewEmptyState.vue +278 -0
  8. package/layer/app/components/layout/viewPill.vue +550 -0
  9. package/layer/app/composables/useAdminAccess.ts +86 -0
  10. package/layer/app/composables/useAlertBrandSync.ts +85 -0
  11. package/layer/app/composables/useAlertSource.ts +210 -0
  12. package/layer/app/composables/useAlerts.ts +345 -0
  13. package/layer/app/composables/useExportSource.ts +236 -0
  14. package/layer/app/composables/useHubrEmbedded.ts +13 -0
  15. package/layer/app/composables/usePermalink.ts +108 -0
  16. package/layer/app/composables/useSavedExports.ts +286 -0
  17. package/layer/app/composables/useViewCardActions.ts +500 -0
  18. package/layer/app/composables/useViewTemplates.ts +369 -0
  19. package/layer/app/composables/useViews.ts +574 -0
  20. package/layer/app/oip-defaults/alerts.ts +34 -0
  21. package/layer/app/oip-defaults/api.ts +18 -0
  22. package/layer/app/oip-defaults/export.ts +23 -0
  23. package/layer/app/oip-defaults/ui.ts +21 -0
  24. package/layer/app/oip-defaults/views.ts +59 -0
  25. package/layer/app/utils/alertFieldLabels.ts +191 -0
  26. package/layer/app/utils/oipScopeStore.ts +112 -0
  27. package/layer/app/utils/oipScopes.ts +376 -0
  28. package/layer/app/utils/viewPages.ts +30 -0
  29. package/layer/app/utils/viewTemplateCriteria.ts +215 -0
  30. package/layer/app/utils/viewTemplates.ts +116 -0
  31. package/layer/nuxt.config.ts +9 -0
  32. package/layer/server/api/oip/notes.get.ts +1 -1
  33. package/package.json +3 -2
@@ -0,0 +1,574 @@
1
+ /**
2
+ * useViews - ce qu'une page donne a sauvegarder comme vue.
3
+ *
4
+ * Les vues (les « scopes » cote API : rien ne change en base, cf.
5
+ * `useScopes`) sont propres a une page : ses filtres, sa cle de stockage. Le
6
+ * panneau, lui, vit dans le cadre et ne voit pas la page. Une page declare
7
+ * donc ici de quoi lire ses filtres courants, et le cadre fait sortir la tuile
8
+ * « Mes vues » uniquement pour elle.
9
+ *
10
+ * Meme forme que `useExportSource` : un `shallowRef` de module, pose au
11
+ * montage, retire au demontage, donc rien a nettoyer a la main et rien qui
12
+ * traverse le rendu serveur.
13
+ *
14
+ * Chaque app du hub garde son propre magasin de scopes (`useScopes`,
15
+ * `useHeartScopes`...) : ce sont des caches distincts de la meme API, et leurs
16
+ * watchers de filtres sont deja branches dessus. Le panneau ne peut donc pas
17
+ * choisir a leur place — c'est l'appelant qui passe son handle, et la page qui
18
+ * declare l'emporte avec elle. `useHeartViews` n'est que ce branchement.
19
+ */
20
+ import {
21
+ DEEP_LINK_SCOPE_KEYS,
22
+ FILTER_LABELS as APP_FILTER_LABELS,
23
+ HAND_FILTER_EXCLUDED,
24
+ IGNORED_FILTERS,
25
+ useCardPrefs,
26
+ useDefaultScopes,
27
+ type CardPrefScope,
28
+ } from "#oip-app/views";
29
+ // Import explicite : l'auto-import de Nuxt ne pose rien pour un identifiant
30
+ // qui n'est pas appele — ici une valeur par defaut de parametre — et le
31
+ // module partait sur un `useScopes is not defined` a la premiere ouverture du
32
+ // panneau.
33
+
34
+ import { sameViewPage } from "../utils/viewPages";
35
+
36
+ /**
37
+ * Nom du pseudo-filtre qui porte le « Personnaliser » dans les criteres d'une
38
+ * vue. Ce n'est pas un filtre : rien ne part vers Elasticsearch, personne ne le
39
+ * compte dans la pastille de la barre. Il voyage dans `criteria` parce que
40
+ * c'est le seul champ libre du pulse, et il est nomme pour etre reconnu et mis
41
+ * de cote par les lecteurs de criteres.
42
+ */
43
+ export const VIEW_PREFS_FILTER = "__cardPrefs";
44
+
45
+ export interface ViewsSource {
46
+ /**
47
+ * Cle de la page cote API (`pulse.page`). Les vues sont filtrees dessus,
48
+ * exactement comme le faisait le selecteur de scope.
49
+ */
50
+ page: string;
51
+ /** Filtres poses a l'ecran, dans la forme attendue par `criteria`. */
52
+ criteria: () => any[];
53
+ /**
54
+ * Appele apres qu'une vue a ete appliquee, pour que la page relance sa
55
+ * requete si ses propres watchers ne le font pas.
56
+ */
57
+ onApply?: (scope: any | null) => void;
58
+ /**
59
+ * Famille de page du panneau « Personnaliser », quand la page en a un. Sans
60
+ * elle, la vue ne porte que les filtres.
61
+ */
62
+ prefsScope?: CardPrefScope;
63
+ /**
64
+ * Prefixe i18n des libelles de filtres, sans le point final. Les pages heart
65
+ * rangent les leurs sous `heart.filters.*`, le portail sous `filters.*`.
66
+ */
67
+ filterNamespace?: string;
68
+ /**
69
+ * Action propre a la page pour retoucher sa vue : sur les pages du portail,
70
+ * ouvrir le panneau de selection de produits. Elle apparait dans le menu de
71
+ * la pastille de vue, sous « Actions ».
72
+ */
73
+ onEdit?: () => void;
74
+ /** Libelle de cette action. */
75
+ editLabel?: string;
76
+ /**
77
+ * Tout ce que la page doit defaire sur un « Tout réinitialiser » : ses
78
+ * filtres remis a leur defaut, et le perimetre de produits vide quand elle
79
+ * en porte un.
80
+ *
81
+ * Elle le declare parce qu'elle seule le sait : « Effacer les filtres »
82
+ * vit deja dans chaque page, et le perimetre ne se range pas au
83
+ * meme endroit d'une famille de pages a l'autre. Ce qui est commun (la vue
84
+ * posee, l'affichage) est defait par `resetAll`, pas ici.
85
+ */
86
+ onReset?: () => void;
87
+ }
88
+
89
+ /** Le handle de scopes d'une app : la forme rendue par `useScopes`. */
90
+ export type ViewsScopes = () => any;
91
+
92
+ type Registration = {
93
+ build: () => ViewsSource | null;
94
+ /** Le magasin de scopes de l'app qui a declare cette page. */
95
+ scopes: ViewsScopes;
96
+ };
97
+
98
+ const registry = shallowRef<Registration | null>(null);
99
+
100
+ /**
101
+ * Pages dont la vue par defaut a deja ete posee depuis le chargement de
102
+ * l'application.
103
+ *
104
+ * La vue par defaut est une aide au demarrage, pas un etat a reimposer : elle
105
+ * s'applique a l'arrivee sur la page, puis plus jamais tant que l'onglet n'est
106
+ * pas recharge. Sans cela, toute navigation interne qui repasse par la page —
107
+ * ouvrir la fiche d'un produit puis revenir — remontait le composant, ne
108
+ * trouvait plus de vue posee pour cette page (la fiche a pu en poser une autre,
109
+ * ou l'utilisateur avoir tout efface) et rejouait la vue par defaut par-dessus
110
+ * la recherche en cours.
111
+ *
112
+ * Etat de module, donc vide a chaque rechargement complet : c'est exactement la
113
+ * duree de vie voulue. Rien ici cote serveur, `declare` ne l'ecrit que dans
114
+ * `onMounted`.
115
+ */
116
+ const defaultApplied = new Set<string>();
117
+
118
+ /** Filtres qui decrivent la pagination, pas l'analyse : hors de la vue. */
119
+
120
+ /** Les criteres d'une vue, que l'API les rende en objet ou en chaine. */
121
+ /**
122
+ * Une vue posee sans etre enregistree : un lien partage, un lien profond
123
+ * `?brand=`, un export rejoue, un perimetre compose a la main. Elle n'a pas
124
+ * d'identifiant, donc rien a renommer ni a ecrire dedans, mais c'est bien elle
125
+ * qui est a l'ecran. Le marqueur historique etait le titre « Custom » : deux
126
+ * chemins l'ecrivaient, un troisieme l'oubliait, et un ecran filtre pouvait se
127
+ * dire sans vue. L'absence d'identifiant, elle, ne se falsifie pas.
128
+ */
129
+ export const isTemporaryView = (scope: any): boolean => !!scope && !scope.id;
130
+
131
+ /**
132
+ * Deux chemins qui designent la meme page, ou deux pages qui partagent leurs
133
+ * vues (cf. `viewPages`). `trailingSlash` est actif, une vue posee par un lien
134
+ * profond ou par l'ancienne page de selection porte le chemin sans barre
135
+ * finale, celui de la page l'a : les comparer tels quels faisait dire « aucune
136
+ * vue » a un ecran filtre.
137
+ */
138
+ const samePage = (a: any, b: any): boolean => sameViewPage(a, b);
139
+
140
+ export function parseViewCriteria(scope: any): any[] {
141
+ const raw = scope?.criteria;
142
+ if (Array.isArray(raw)) return raw;
143
+ if (typeof raw !== "string") return [];
144
+ try {
145
+ return JSON.parse(raw) ?? [];
146
+ } catch {
147
+ return [];
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Valeurs reellement posees sur un filtre. Le magasin garde des filtres vides
153
+ * (une periode sans borne vaut `[null, null]`) : les compter ferait croire a un
154
+ * ecart entre l'ecran et la vue, alors que l'API ne les garde pas.
155
+ */
156
+ export function viewFilledValues(item: any): string[] {
157
+ if (!Array.isArray(item?.values)) return [];
158
+ return item.values
159
+ .map((value: any) =>
160
+ value === null || value === undefined
161
+ ? ""
162
+ : typeof value === "object"
163
+ ? String(value.value ?? "")
164
+ : String(value),
165
+ )
166
+ .filter((label: string) => label !== "");
167
+ }
168
+
169
+ /**
170
+ * Filtres poses par les composants dont le nom technique n'a pas d'entree dans
171
+ * les traductions, ou dont le nom lisible ne vient pas d'un filtre du tout.
172
+ */
173
+ // Calcule au premier usage et non au chargement : le contrat #oip-app/views
174
+ // importe le composable de vues par defaut, qui revient ici, et la table de
175
+ // l'app n'est pas encore initialisee quand ce module s'evalue.
176
+ let filterLabelsCache: Record<string, { fr: string; en: string }> | null = null;
177
+ const filterLabels = () =>
178
+ (filterLabelsCache ??= {
179
+ ...APP_FILTER_LABELS,
180
+ [VIEW_PREFS_FILTER]: { fr: "Affichage", en: "Display" },
181
+ });
182
+
183
+ /**
184
+ * Libelle lisible d'un filtre : son nom connu, sa traduction dans le namespace
185
+ * de la page, ou son nom brut. Le panneau du rail et la modale de sauvegarde
186
+ * lisent le meme, sinon la meme vue se decrirait de deux facons.
187
+ */
188
+ export function useViewFilterLabels(namespace?: () => string | undefined) {
189
+ const { locale, t, te } = useI18n();
190
+
191
+ return function label(name: string): string {
192
+ return resolveFilterLabel(name, namespace?.(), { locale: locale.value, t, te });
193
+ };
194
+ }
195
+
196
+ /** Le corps de `useViewFilterLabels`, sans le `useI18n` qui exige un setup. */
197
+ function resolveFilterLabel(
198
+ name: string,
199
+ namespace: string | undefined,
200
+ i18n: { locale: string; t: (key: string) => string; te: (key: string) => boolean },
201
+ ): string {
202
+ const known = filterLabels()[name];
203
+ if (known) return i18n.locale === "en" ? known.en : known.fr;
204
+ const key = `${namespace ?? "filters"}.${name}`;
205
+ return i18n.te(key) ? i18n.t(key) : name;
206
+ }
207
+
208
+ /**
209
+ * Filtres poses a la main, parmi les criteres d'un ecran.
210
+ *
211
+ * Le tri, la pagination et l'onglet courant n'en sont pas : ils portent une
212
+ * valeur par defaut sur toute page, et les compter dirait « filtres non
213
+ * enregistres » sur un ecran que personne n'a touche (« Avis HAS » s'ouvrait
214
+ * avec le point orange, sans un seul filtre pose : son onglet s'ecrit dans le
215
+ * magasin sous le nom `Type`, cf. heart/elastic/searchTypeFilter). Les pages
216
+ * ne s'accordent pas la-dessus, certaines les ecartent deja de leur
217
+ * `criteria()`, d'autres non, donc on les ecarte ici, une fois. C'est la meme
218
+ * liste de reglages d'affichage que `countSelectedFilters` du magasin de
219
+ * filtres, qui compte la pastille « Filtres » de la barre.
220
+ *
221
+ * Ils restent dans la vue enregistree : une vue rejoue l'onglet et le tri
222
+ * qu'elle a captures. Ce sont les compter comme des filtres poses qui est
223
+ * faux, pas les capturer.
224
+ *
225
+ * Le « Personnaliser » n'y figure pas non plus : il ne passe jamais par
226
+ * `criteria()`, il est ajoute a part par `prefsCriterion`.
227
+ */
228
+
229
+ export function viewHandFilters(criteria: any[]): any[] {
230
+ return (Array.isArray(criteria) ? criteria : []).filter(
231
+ (item: any) =>
232
+ !HAND_FILTER_EXCLUDED.includes(item?.filter) &&
233
+ item?.filter !== VIEW_PREFS_FILTER &&
234
+ viewFilledValues(item).length > 0,
235
+ );
236
+ }
237
+
238
+ /** Signature d'un jeu de criteres, pour comparer l'ecran a la vue active. */
239
+ export function viewSignature(criteria: any[]): string {
240
+ return (Array.isArray(criteria) ? criteria : [])
241
+ .filter(
242
+ (item: any) => !IGNORED_FILTERS.includes(item?.filter) && viewFilledValues(item).length > 0,
243
+ )
244
+ .map((item: any) => `${item.filter}=${viewFilledValues(item).sort().join("|")}`)
245
+ .sort()
246
+ .join("&");
247
+ }
248
+
249
+ /**
250
+ * Etat du « Personnaliser » sous forme de critere : la liste des options
251
+ * affichees. Rien a enregistrer quand tout est masque, c'est l'etat par
252
+ * defaut.
253
+ */
254
+ function prefsCriterion(page: string, scope?: CardPrefScope) {
255
+ if (!scope) return null;
256
+ const on = useCardPrefs().on(scope);
257
+ const keys = Object.keys(on).filter((key) => on[key]);
258
+ return keys.length > 0
259
+ ? { filter: VIEW_PREFS_FILTER, page, values: keys }
260
+ : null;
261
+ }
262
+
263
+ /**
264
+ * Remet le « Personnaliser » dans l'etat que porte la vue. Une vue sans
265
+ * critere d'affichage remet tout a masque : c'est ce qu'elle decrit, et sans
266
+ * cela l'etat du poste survivrait a l'application de la vue.
267
+ */
268
+ function applyPrefs(scope: any, prefsScope?: CardPrefScope) {
269
+ if (!prefsScope) return;
270
+
271
+ let criteria = scope?.criteria;
272
+ if (typeof criteria === "string") {
273
+ try {
274
+ criteria = JSON.parse(criteria);
275
+ } catch {
276
+ return;
277
+ }
278
+ }
279
+ if (!Array.isArray(criteria)) return;
280
+
281
+ const entry = criteria.find((item: any) => item?.filter === VIEW_PREFS_FILTER);
282
+ const keys: string[] = Array.isArray(entry?.values) ? entry.values : [];
283
+
284
+ const prefs = useCardPrefs();
285
+ prefs.reset(prefsScope);
286
+ keys.forEach((key) => prefs.toggle(prefsScope, key));
287
+ }
288
+
289
+ /**
290
+ * @param scopes magasin de scopes de l'app appelante. Le portail garde le
291
+ * defaut ; les apps prefixees passent le leur.
292
+ */
293
+ /**
294
+ * Les filtres de la page declaree, dans la forme du recapitulatif d'export.
295
+ *
296
+ * Une vue et un export decrivent le meme ecran : ce qui est enregistrable est
297
+ * exactement ce qu'un rapport doit citer. Les lire au meme endroit evite qu'un
298
+ * filtre ajoute a la page apparaisse dans les vues mais pas dans le PDF.
299
+ *
300
+ * L'etat du « Personnaliser » est ecarte : ce n'est pas un filtre, il ne
301
+ * change pas ce que la page interroge.
302
+ */
303
+ export function viewsAsExportFilters(): { label: string; value: string }[] {
304
+ const source = registry.value?.build() ?? null;
305
+ if (!source) return [];
306
+
307
+ // Meme libelle que les cartes de vues, sinon le rapport citait le nom
308
+ // technique du filtre. Par `$i18n` et non `useI18n` : les pages appellent
309
+ // ceci dans le getter de leur source d'export, relu au moment d'exporter,
310
+ // hors de tout `setup`. Sans app Nuxt sous la main, le nom brut reste.
311
+ const i18n = (tryUseNuxtApp() as any)?.$i18n;
312
+ const label = (name: string) =>
313
+ i18n
314
+ ? resolveFilterLabel(name, source.filterNamespace, {
315
+ locale: String(i18n.locale?.value ?? i18n.locale ?? "fr"),
316
+ t: (key) => String(i18n.t(key)),
317
+ te: (key) => i18n.te(key),
318
+ })
319
+ : name;
320
+
321
+ return (source.criteria() ?? [])
322
+ .filter((item: any) => item?.filter !== VIEW_PREFS_FILTER)
323
+ .map((item: any) => ({
324
+ label: label(String(item?.filter ?? "")),
325
+ // Meme lecture que les cartes de vues : un filtre pose mais vide ne se
326
+ // cite pas dans un rapport.
327
+ value: viewFilledValues(item).join(", "),
328
+ }))
329
+ .filter((filter) => filter.label !== "" && filter.value !== "");
330
+ }
331
+
332
+ export function useViews(scopes: ViewsScopes = useDefaultScopes) {
333
+ /**
334
+ * Declare la page courante. A appeler dans le `setup` d'un composant de la
335
+ * page.
336
+ *
337
+ * La liste des vues est chargee ici, pas a l'ouverture du panneau : le
338
+ * bandeau nomme la vue active, et la vue par defaut doit s'appliquer des
339
+ * l'arrivee sur la page. Attendre l'ouverture du panneau reviendrait a
340
+ * demander a l'utilisateur d'aller chercher ce qu'il a justement demande
341
+ * d'automatiser.
342
+ */
343
+ function declare(build: () => ViewsSource | null) {
344
+ // Lu dans le `setup` : dans un hook de cycle de vie, l'injection du
345
+ // routeur n'a plus d'instance a interroger.
346
+ const route = useRoute();
347
+
348
+ onMounted(async () => {
349
+ registry.value = { build, scopes };
350
+
351
+ const store = scopes();
352
+ if (store.scopeList.value.length === 0) await store.getScopes();
353
+ // Une autre page a pu se declarer pendant l'attente : la suite poserait
354
+ // sa vue par defaut, et `defaultApplied`, pour la mauvaise page.
355
+ if (registry.value?.build !== build) return;
356
+
357
+ const source = build();
358
+ if (!source) return;
359
+
360
+ // Un lien partage porte les filtres de l'expediteur (cf. usePermalink) :
361
+ // ils gagnent sur la vue par defaut du destinataire, sinon le lien
362
+ // ouvrirait un autre ecran que celui qui a ete partage. La vue posee n'a
363
+ // pas d'identifiant, la pastille la dit temporaire.
364
+ const shared = await permalinkCriteria(route.query);
365
+ if (registry.value?.build !== build) return;
366
+ if (shared.length > 0) {
367
+ // Pas de titre : la vue n'en a pas recu, et la pastille la nomme
368
+ // « Vue temporaire » plutot que d'inventer un nom que personne n'a
369
+ // choisi. Pas d'identifiant non plus : rien n'est enregistre tant que
370
+ // le destinataire ne le demande pas.
371
+ const scope = {
372
+ id: null,
373
+ title: "",
374
+ page: source.page,
375
+ criteria: JSON.stringify(shared),
376
+ };
377
+ store.setSelectedScope(scope);
378
+ applyPrefs(scope, source.prefsScope);
379
+ source.onApply?.(scope);
380
+ return;
381
+ }
382
+
383
+ // Une seule pose de la vue par defaut par page et par chargement : la
384
+ // suite ne s'execute qu'a la premiere arrivee sur la page. Un lien
385
+ // partage, lui, reste au-dessus : il porte une demande explicite dans
386
+ // l'URL, a chaque fois.
387
+ if (defaultApplied.has(source.page)) return;
388
+ defaultApplied.add(source.page);
389
+
390
+ // Une selection venue d'une autre page ne compte pas : elle bloquait la
391
+ // vue par defaut de la page d'arrivee. Celle de cette page-ci gagne, y
392
+ // compris sans identifiant : un deep link `?brand=` pose une vue
393
+ // temporaire avant le montage (cf. le middleware scope-from-query), et
394
+ // appliquer la vue par defaut par-dessus remplacait la selection
395
+ // demandee par celle du destinataire.
396
+ const selected = store.selectedScope.value;
397
+ if (selected && samePage(selected.page, source.page)) return;
398
+
399
+ // Meme raison que le lien partage ci-dessus : Hub-R n'a pas de
400
+ // middleware equivalent a scope-from-query pour poser une vue
401
+ // temporaire avant le montage — un `?brand=`/`?molecule=`/`?atc4=`/
402
+ // `?company=` d'arrivee (cf. useHeartScopeHandoff, le lien Prix/Avis
403
+ // HAS depuis Medicaments France) est donc une demande explicite qui
404
+ // doit gagner sur la vue par defaut plutot que d'etre effacee des
405
+ // qu'elle s'applique.
406
+ const q = route.query as Record<string, any>;
407
+ const hasScopeDeepLink = DEEP_LINK_SCOPE_KEYS.some((key) => {
408
+ const value = q[key];
409
+ return value != null && String(value).trim().length >= 2;
410
+ });
411
+ if (hasScopeDeepLink) return;
412
+
413
+ const wanted = store.scopeList.value.find(
414
+ (scope: any) => scope.isDefault === true && samePage(scope.page, source.page),
415
+ );
416
+
417
+ // Copie volontaire : les filtres se reappliquent sur un changement de
418
+ // reference de `selectedScope` (cf. les watchers des composants de
419
+ // filtre).
420
+ if (wanted) {
421
+ store.setSelectedScope({ ...wanted });
422
+ applyPrefs(wanted, source.prefsScope);
423
+ source.onApply?.(wanted);
424
+ }
425
+ });
426
+
427
+ onBeforeUnmount(() => {
428
+ if (registry.value?.build === build) registry.value = null;
429
+ });
430
+ }
431
+
432
+ return {
433
+ declare,
434
+ /**
435
+ * Criteres a enregistrer pour la page courante : ses filtres, plus l'etat
436
+ * du « Personnaliser ». Le panneau « Mes vues » et les modales de
437
+ * sauvegarde des pages passent par ici, donc les deux chemins ecrivent la
438
+ * meme chose.
439
+ */
440
+ criteriaOf(): any[] {
441
+ const source = registry.value?.build() ?? null;
442
+ if (!source) return [];
443
+ const criteria = source.criteria() ?? [];
444
+ const prefs = prefsCriterion(source.page, source.prefsScope);
445
+ return prefs ? [...criteria, prefs] : criteria;
446
+ },
447
+ /** Rejoue le « Personnaliser » porte par une vue. */
448
+ applyPrefs,
449
+ /**
450
+ * Pose des criteres a l'ecran sans rien enregistrer : un template Tekkare
451
+ * qu'on demarre, comme un lien partage. La vue n'a pas d'identifiant, donc
452
+ * la pastille la dit temporaire et « Sauvegarder la vue actuelle » est ce
453
+ * qui l'inscrit dans les vues de l'utilisateur.
454
+ *
455
+ * Les criteres arrivent construits par l'appelant : leurs valeurs
456
+ * dependent de la langue de l'ecran (cf. `viewTemplates`), et c'est un
457
+ * composant qui la connait.
458
+ */
459
+ applyTemporary(criteria: any[], title = "", template: string | null = null) {
460
+ const source = registry.value?.build() ?? null;
461
+ if (!source) return;
462
+ const store = (registry.value?.scopes ?? scopes)();
463
+ const scope = {
464
+ id: null,
465
+ title,
466
+ // D'ou vient la vue, quand c'est un template : la pastille le nomme
467
+ // plutot que de dire « Vue temporaire ».
468
+ template,
469
+ page: source.page,
470
+ // Chaque critere porte sa page, comme ceux que les composants de
471
+ // filtre ecrivent : les lecteurs de criteres s'y attendent.
472
+ criteria: JSON.stringify(
473
+ criteria.map((item: any) => ({ page: source.page, ...item })),
474
+ ),
475
+ };
476
+ store.setSelectedScope(scope);
477
+ // Pas de `applyPrefs` ici : un template ne decrit que des filtres, et
478
+ // rejouer un « Personnaliser » vide remettrait tout l'affichage a
479
+ // masque, donc viderait l'ecran qu'on vient de demander.
480
+ source.onApply?.(scope);
481
+ },
482
+ /**
483
+ * Remet la page a l'etat vierge : plus aucune vue, plus de perimetre, les
484
+ * filtres et l'affichage a leur defaut.
485
+ *
486
+ * « Effacer les filtres » ne touche qu'aux filtres de l'ecran : la vue
487
+ * posee lui survit, et avec elle la selection de produits sur laquelle
488
+ * tout se lit. Ce geste-ci va jusqu'au bout, d'ou les deux
489
+ * commandes cote a cote dans le panneau de filtres.
490
+ */
491
+ resetAll() {
492
+ const source = registry.value?.build() ?? null;
493
+ if (!source) return;
494
+
495
+ // La vue d'abord : elle repose les filtres de la page par ses watchers,
496
+ // et la defaire avant que la page remette ses defauts evite qu'elle les
497
+ // recouvre.
498
+ const store = (registry.value?.scopes ?? scopes)();
499
+ store.clearScope();
500
+
501
+ // Le « Personnaliser » fait partie de ce qu'une vue emporte : le laisser
502
+ // en place laisserait des colonnes ouvertes sur un ecran remis a zero.
503
+ if (source.prefsScope) useCardPrefs().reset(source.prefsScope);
504
+
505
+ source.onReset?.();
506
+ },
507
+ /** La source courante, relue a chaque acces : elle suit les filtres. */
508
+ current: computed(() => registry.value?.build() ?? null),
509
+ /** Vrai quand la page sait sauvegarder une vue. */
510
+ has: computed(() => (registry.value?.build() ?? null) !== null),
511
+ /**
512
+ * Le magasin de scopes de la page declaree : c'est celui-la que le panneau
513
+ * doit lire et ecrire, pas celui de l'app qui l'a instancie.
514
+ */
515
+ scopes: computed<ViewsScopes>(() => registry.value?.scopes ?? scopes),
516
+ /**
517
+ * La vue active DE CETTE PAGE. Une vue = une page.
518
+ *
519
+ * Le magasin ne garde qu'une selection pour toute l'app : en changeant de
520
+ * page elle reste en place, et la vue de la page precedente continuait de
521
+ * se dire active sur la nouvelle. La pastille en portait le nom, le bouton
522
+ * « Sauvegarder » y ecrivait les filtres de la nouvelle page, et la vue par
523
+ * defaut de la page d'arrivee ne s'appliquait pas puisqu'une selection
524
+ * occupait deja la place — une vue a moitie appliquee, a moitie
525
+ * enregistree. Tout ce qui parle de la vue courante lit donc ceci, jamais
526
+ * `selectedScope` directement.
527
+ */
528
+ active: computed(() => {
529
+ const source = registry.value?.build() ?? null;
530
+ if (!source) return null;
531
+ const store = (registry.value?.scopes ?? scopes)();
532
+ const selected = store.selectedScope.value;
533
+ if (!selected || !samePage(selected.page, source.page)) return null;
534
+ // Sans identifiant, la vue n'est pas enregistree — un lien partage, un
535
+ // export rejoue — mais c'est bien elle qui est a l'ecran. L'exiger
536
+ // faisait dire « aucune vue » a un ecran filtre : le destinataire d'un
537
+ // lien voyait les filtres appliques et la pastille les nier. Ce qui
538
+ // demande une vue enregistree (renommer, mettre par defaut, ecrire
539
+ // dedans) lit `active.id`, pas `active`.
540
+ return selected;
541
+ }),
542
+ /**
543
+ * Ce qui est a l'ecran n'est enregistre dans aucune vue.
544
+ *
545
+ * Deux cas. Avec une vue active, ses filtres ont bouge depuis qu'elle a ete
546
+ * appliquee : il y a quelque chose a rattraper. Sans vue active, les
547
+ * filtres poses ne sont enregistres nulle part : c'est le meme avertissement
548
+ * — l'ecran se perd en quittant la page — et il ne sortait pas, la pastille
549
+ * restait muette sur « Aucune vue » quelle que soit la pile de filtres.
550
+ *
551
+ * Une vue venue d'une autre page ne compte pas comme active : il n'y a rien
552
+ * a rattraper de ce cote.
553
+ */
554
+ dirty: computed(() => {
555
+ const source = registry.value?.build() ?? null;
556
+ if (!source) return false;
557
+ const store = (registry.value?.scopes ?? scopes)();
558
+ const selected = store.selectedScope.value;
559
+ const criteria = source.criteria() ?? [];
560
+
561
+ // Meme regle que `active` : une vue d'une autre page n'est pas active
562
+ // ici, il n'y a rien a rattraper, mais les filtres poses ne sont
563
+ // enregistres nulle part.
564
+ if (!selected?.id || !samePage(selected.page, source.page)) {
565
+ return viewHandFilters(criteria).length > 0;
566
+ }
567
+ const active = selected;
568
+
569
+ const prefs = prefsCriterion(source.page, source.prefsScope);
570
+ const shown = prefs ? [...criteria, prefs] : criteria;
571
+ return viewSignature(shown) !== viewSignature(parseViewCriteria(active));
572
+ }),
573
+ };
574
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Contrat `#oip-app/alerts` : le metier des alertes propre a l'app. Valeurs
3
+ * neutres par defaut (pas d'alerte de produit, libelles bruts).
4
+ */
5
+
6
+ /** Famille de document d'une alerte (types de ressources de l'app). */
7
+ export type AlertResourceType = string;
8
+
9
+ /**
10
+ * Repertoire des marques des alertes de produit : champ agrege, champs
11
+ * cherches, recherche. Sans repertoire, la liste des marques reste vide.
12
+ */
13
+ export const ALERT_BRAND_DIRECTORY = {
14
+ field: "brand.keyword",
15
+ searchFields: (_lang: "fr" | "en"): string[] => ["brand"],
16
+ search: async (_body: unknown): Promise<any> => ({}),
17
+ };
18
+
19
+ /** La source d'un evenement, nommee pour le journal. */
20
+ export function alertEventSourceLabel(
21
+ _docId: string | null | undefined,
22
+ _t: (key: string) => string,
23
+ _lang: "fr" | "en",
24
+ ): string | null {
25
+ return null;
26
+ }
27
+
28
+ /** La page d'un projet. */
29
+ export const alertProjectPath = (id: string) => `/projects/${id}`;
30
+
31
+ /** Libelles des champs surveilles : premier niveau, imbriques, entrees. */
32
+ export const ALERT_FIELD_TOP_LEVEL: Record<string, string> = {};
33
+ export const ALERT_FIELD_NESTED: Record<string, string> = {};
34
+ export const ALERT_ENTRY_MARKERS: Record<string, string> = {};
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Contrat `#oip-app/api` : le client de la Virtual Lab API de l'app.
3
+ *
4
+ * Les vues, les alertes, les exports enregistres et les modeles de vues du
5
+ * layer passent par lui (pulses, alerts, viewTemplates, admin, profile). Une
6
+ * app branchee sur ces panneaux pointe l'alias `#oip-app` vers son propre
7
+ * dossier et y exporte `useOipApi` (en general son useElysiaApi).
8
+ */
9
+ export function useOipApi(): any {
10
+ throw new Error(
11
+ "[oip] useOipApi n'est pas fourni : pointer l'alias #oip-app vers le dossier oip/ de l'app (cf. layer/app/oip-defaults).",
12
+ );
13
+ }
14
+
15
+ /** Types de la Virtual Lab API manipules par le layer (alertes). */
16
+ export type Alert = any;
17
+ export type AlertEvent = any;
18
+ export type AlertTrigger = any;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Contrat `#oip-app/export` : ce que le panneau Export du layer demande a
3
+ * l'app. Valeurs neutres par defaut.
4
+ */
5
+
6
+ /**
7
+ * Les sources Directus d'une page, pour le classeur et le rapport :
8
+ * `load(uuids)` les charge, `exportSources(uuids)` les rend au format
9
+ * RmExportSource.
10
+ */
11
+ export function useExportDirectusSources() {
12
+ return {
13
+ load: async (_uuids: string[]) => {},
14
+ exportSources: (_uuids: string[], _locale?: string): any[] => [],
15
+ };
16
+ }
17
+
18
+ /** Proprietes d'un evenement de telechargement (contexte de vue, espace...). */
19
+ export function useExportTracking() {
20
+ return {
21
+ getExportPosthogProps: (name: string, extra: Record<string, unknown> = {}) => ({ name, ...extra }),
22
+ };
23
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Contrat `#oip-app/ui` : le bandeau de confirmation et le suivi d'usage de
3
+ * l'app.
4
+ *
5
+ * `oipSnackbar` recoit la configuration du bandeau OIP historique
6
+ * (`[{ attribute, value }]` : isVisible, text, loader, prefixIcon,
7
+ * hasTimeOut...). Par defaut, le texte part par le hook `oip:notify`.
8
+ * `oipTrack` envoie un evenement d'usage (PostHog...) ; par defaut, rien.
9
+ */
10
+ export type OipSnackbarConfig = { attribute: string; value: unknown }[];
11
+
12
+ export function oipSnackbar(config: OipSnackbarConfig) {
13
+ const text = config.find((c) => c.attribute === "text")?.value;
14
+ const visible = config.find((c) => c.attribute === "isVisible")?.value;
15
+ if (!visible || typeof text !== "string") return;
16
+ const loading = !!config.find((c) => c.attribute === "loader")?.value;
17
+ if (loading) return;
18
+ useNuxtApp().callHook("oip:notify" as any, { text, kind: "success" });
19
+ }
20
+
21
+ export function oipTrack(_event: string, _props?: Record<string, unknown>) {}