@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,369 @@
1
+ /**
2
+ * useViewTemplates - les templates Tekkare d'une page, prets a afficher.
3
+ *
4
+ * Deux ecrans les proposent : l'onglet « Templates Tekkare » du panneau du
5
+ * rail, et l'etat vide des pages a selection de produits (c'est justement la
6
+ * qu'un panier pret a l'emploi sert le plus). Les deux les mettent en forme et
7
+ * les demarrent de la meme facon, donc la mise en carte et le demarrage vivent
8
+ * ici, pas en double.
9
+ *
10
+ * Ils viennent de deux endroits, et rien en aval ne fait la difference :
11
+ * - le catalogue en dur de `~/utils/viewTemplates`, socle offert a tout le
12
+ * monde ;
13
+ * - ceux qu'un superadmin a publies depuis l'app pour des espaces clients
14
+ * choisis (`GET /view-templates`).
15
+ *
16
+ * Les seconds sont convertis dans la forme des premiers des leur arrivee :
17
+ * mise en carte, demarrage et lien profond restent ecrits une fois.
18
+ */
19
+ import {
20
+ normalizePage,
21
+ viewTemplateById,
22
+ viewTemplatesFor,
23
+ VIEW_TEMPLATE_QUERY_KEYS,
24
+ VIEW_TEMPLATE_QUERY_PARAM,
25
+ VIEW_TEMPLATE_QUERY_RESET,
26
+ } from "../utils/viewTemplates";
27
+ import type { ViewTemplate, ViewTemplateCriterion } from "../utils/viewTemplates";
28
+ import { decodeCriteria } from "../utils/viewTemplateCriteria";
29
+ import type { StoredCriterion } from "../utils/viewTemplateCriteria";
30
+ import { useOipApi } from "#oip-app/api";
31
+ import { PUBLISHED_TEMPLATE_NAMESPACE, resolveTemplateBrands } from "#oip-app/views";
32
+
33
+ /** Icone des templates publies depuis l'app : ils n'en portent pas. */
34
+ const PUBLISHED_ICON = "Sparkle";
35
+
36
+ /**
37
+ * Criteres qui decrivent un panier par sa CLASSE, et non par les produits
38
+ * qu'elle resout aujourd'hui.
39
+ *
40
+ * Une vue du portail porte les deux : la liste de marques dépliée et la
41
+ * classe qui l'a produite (cf. `useDrugsViews`). Pour une vue enregistree,
42
+ * c'est la liste qui fait foi — son proprietaire veut retrouver la meme
43
+ * analyse. Pour un template, c'est l'inverse : une classe se reexpanse a
44
+ * chaque ouverture, une liste figee ignore la prochaine AMM.
45
+ */
46
+ const SCOPE_CLASS_FILTERS = ["atc5", "atc4", "company"];
47
+
48
+ /**
49
+ * Les templates publies pour cet espace, charges une fois par session.
50
+ *
51
+ * Tout l'espace d'un coup, et le tri par page se fait ici : une vue est
52
+ * enregistree sous `route.path`, donc sous `/en/...` pour qui lit en anglais.
53
+ * Demander la page a l'API reviendrait a ne rien rendre a la moitie des
54
+ * lecteurs. `normalizePage` retire ce prefixe des deux cotes, exactement
55
+ * comme pour le catalogue en dur.
56
+ *
57
+ * Etat partage plutot que `useAsyncData` : deux ecrans lisent la meme liste
58
+ * (le panneau du rail et l'etat vide), et le panneau suit la page declaree,
59
+ * qui change en naviguant sans qu'un composant soit remonte.
60
+ */
61
+ function publishedStore() {
62
+ return {
63
+ list: useState<ViewTemplate[] | null>("viewTemplates:published", () => null),
64
+ pending: useState<boolean>("viewTemplates:pending", () => false),
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Un template publie, dans la forme du catalogue en dur.
70
+ *
71
+ * Le titre et la description sont ranges dans une seule langue, celle de qui
72
+ * a publie : les deux faces pointent donc sur la meme chaine. Ce sont les
73
+ * CRITERES qui se traduisent, quand leur cle est connue (cf.
74
+ * `viewTemplateCriteria`).
75
+ *
76
+ * `deepLink` n'est pas range non plus, il se deduit : un template dont tous
77
+ * les criteres sont des cles de perimetre produit doit passer par l'URL, pour
78
+ * que le middleware `03.scope-from-query` les expanse.
79
+ */
80
+ export function toTemplate(row: {
81
+ id: string;
82
+ title: string;
83
+ description?: string | null;
84
+ page: string;
85
+ criteria: any;
86
+ forWorkspace?: boolean;
87
+ }): ViewTemplate {
88
+ const stored: StoredCriterion[] = Array.isArray(row.criteria) ? row.criteria : [];
89
+ const text = { fr: row.title, en: row.title };
90
+
91
+ return {
92
+ id: `published-${row.id}`,
93
+ page: normalizePage(row.page),
94
+ icon: PUBLISHED_ICON,
95
+ tone: "primary",
96
+ title: text,
97
+ description: { fr: row.description ?? "", en: row.description ?? "" },
98
+ deepLink:
99
+ stored.length > 0 &&
100
+ stored.every((item) => VIEW_TEMPLATE_QUERY_KEYS[item.filter] !== undefined),
101
+ forYou: row.forWorkspace === true,
102
+ criteria: (ctx) =>
103
+ decodeCriteria(stored, {
104
+ lang: ctx.lang,
105
+ t: ctx.t,
106
+ te: ctx.te,
107
+ namespace: PUBLISHED_TEMPLATE_NAMESPACE,
108
+ }) as ViewTemplateCriterion[],
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Valeurs d'un critere de template. La periode est gardee en chaine par le
114
+ * selecteur de dates (c'est ce qui la garde relative), la ou les autres
115
+ * filtres tiennent un tableau : les deux se lisent ici.
116
+ *
117
+ * Les bornes vides sont retirees : une periode sans borne vaut `[null, null]`,
118
+ * que `String` rendait « null, null » sur la carte du template et repassait
119
+ * telle quelle dans l'URL au demarrage (`period=null,null`).
120
+ */
121
+ export function viewTemplateValues(item: ViewTemplateCriterion): string[] {
122
+ const raw = Array.isArray(item?.values) ? item.values : item?.values ? [item.values] : [];
123
+ return raw
124
+ .map((value) => (value === null || value === undefined ? "" : String(value)))
125
+ .filter((value) => value !== "" && value !== "null" && value !== "undefined");
126
+ }
127
+
128
+ /**
129
+ * @param page chemin de la page dont on veut les templates. Relu a chaque
130
+ * acces : le panneau du rail suit la page declaree, qui change en naviguant.
131
+ */
132
+ export function useViewTemplates(page: () => string) {
133
+ const { locale, t, te } = useI18n();
134
+ const route = useRoute();
135
+ const views = useViews();
136
+ const elysiaApi = useOipApi();
137
+ const filterLabel = useViewFilterLabels(() => views.current.value?.filterNamespace);
138
+ const published = publishedStore();
139
+
140
+ const lang = computed<"fr" | "en">(() => (locale.value === "en" ? "en" : "fr"));
141
+
142
+ /**
143
+ * Les criteres se construisent avec la langue de l'ecran : valeurs de
144
+ * filtres traduites, et sur la page Prix le nom du filtre lui-meme (cf.
145
+ * `viewTemplates`).
146
+ */
147
+ const context = computed(() => ({
148
+ lang: lang.value,
149
+ t,
150
+ te: (key: string) => te(key),
151
+ }));
152
+
153
+ /**
154
+ * Charge les templates publies. Rien au rendu serveur : la route demande la
155
+ * session, et le panneau ne s'ouvre que cote client.
156
+ */
157
+ async function loadPublished() {
158
+ if (!import.meta.client) return;
159
+ if (published.list.value || published.pending.value) return;
160
+
161
+ published.pending.value = true;
162
+ try {
163
+ const res = await elysiaApi.viewTemplates.list();
164
+ published.list.value = (res?.data ?? []).map(toTemplate);
165
+ } catch (err) {
166
+ console.error("[useViewTemplates] Templates publies indisponibles:", err);
167
+ published.list.value = [];
168
+ } finally {
169
+ published.pending.value = false;
170
+ }
171
+ }
172
+
173
+ onMounted(loadPublished);
174
+
175
+ /**
176
+ * Les publies d'abord : ce sont eux qui visent cet espace, le catalogue en
177
+ * dur est le socle offert a tout le monde. Parmi eux, ceux crees pour cet
178
+ * espace nommement passent en tete.
179
+ */
180
+ const templates = computed<ViewTemplate[]>(() => {
181
+ const wanted = normalizePage(page());
182
+ const mine = (published.list.value ?? []).filter((item) => item.page === wanted);
183
+ return [
184
+ ...mine.filter((item) => item.forYou),
185
+ ...mine.filter((item) => !item.forYou),
186
+ ...viewTemplatesFor(page()),
187
+ ];
188
+ });
189
+
190
+ /**
191
+ * Ce que la pastille d'un critere affiche. Un critere sans valeur utilisable
192
+ * se dit, plutot que de se taire : un template peut porter une periode sans
193
+ * borne, et une pastille « Periode » vide laisserait croire a un bug.
194
+ */
195
+ function pillValue(item: ViewTemplateCriterion) {
196
+ const values = viewTemplateValues(item);
197
+ if (values.length) return values.join(", ");
198
+ if (item.filter === "Period") return lang.value === "en" ? "No period" : "Aucune période";
199
+ return lang.value === "en" ? "None" : "Aucune valeur";
200
+ }
201
+
202
+ /**
203
+ * Un template en carte, dans la forme de `RmSavedViewCard`. Expose : l'onglet
204
+ * « Admin templates » met en carte des templates qui ne sont pas offerts a
205
+ * l'espace du lecteur, donc absents de `templates`.
206
+ */
207
+ function cardOf(template: ViewTemplate) {
208
+ const criteria = template.criteria(context.value);
209
+ return {
210
+ id: template.id,
211
+ title: template.title[lang.value],
212
+ description: template.description[lang.value],
213
+ icon: template.icon,
214
+ tone: template.tone ?? "primary",
215
+ actionLabel: lang.value === "en" ? "Start" : "Démarrer",
216
+ forYou: template.forYou === true,
217
+ // Garder le template sans le demarrer : il fallait l'ouvrir, puis le
218
+ // nommer et l'enregistrer comme n'importe quelle selection, et rien ne
219
+ // disait que c'etait possible.
220
+ menu: [
221
+ {
222
+ key: "save-template",
223
+ label: lang.value === "en" ? "Add to my views" : "Ajouter à mes vues",
224
+ icon: "BookmarkSimple",
225
+ },
226
+ ],
227
+ config: [
228
+ {
229
+ icon: "Funnel",
230
+ key: `${lang.value === "en" ? "Filters" : "Filtres"} (${criteria.length})`,
231
+ pills: criteria.map((item) => ({
232
+ k: filterLabel(item.filter),
233
+ v: pillValue(item),
234
+ })),
235
+ },
236
+ ],
237
+ };
238
+ }
239
+
240
+ /** Les templates de la page en cartes. */
241
+ const cards = computed(() => templates.value.map(cardOf));
242
+
243
+ /**
244
+ * Demarre un template : ses filtres passent a l'ecran, rien n'est enregistre
245
+ * (la vue est temporaire, cf. `useViews.applyTemporary`).
246
+ *
247
+ * Un panier de produits du portail passe, lui, par l'URL : c'est le
248
+ * middleware `03.scope-from-query` qui expanse un code ATC en liste de
249
+ * marques et remplit les listes par source, ce que la page ne sait pas faire
250
+ * elle-meme.
251
+ */
252
+ function start(id: string | ViewTemplate) {
253
+ // Un template se passe aussi tel quel : ceux de l'onglet « Admin
254
+ // templates » ne sont pas forcement offerts a l'espace du lecteur.
255
+ const template =
256
+ typeof id === "string" ? templates.value.find((item) => item.id === id) : id;
257
+ if (!template) return;
258
+
259
+ const criteria = template.criteria(context.value);
260
+
261
+ if (template.deepLink) {
262
+ // La classe l'emporte sur la liste qu'elle avait resolue : les deux
263
+ // partiraient en `should` dans la requete du perimetre, donc en union,
264
+ // et la liste figee ramenerait les produits que la classe a depuis
265
+ // perdus. Un template dit une classe, pas un etat du referentiel.
266
+ const byClass = criteria.some((item) => SCOPE_CLASS_FILTERS.includes(item.filter));
267
+ const posed = byClass
268
+ ? criteria.filter((item) => item.filter !== "brands")
269
+ : criteria;
270
+
271
+ const query: Record<string, any> = { ...route.query };
272
+ VIEW_TEMPLATE_QUERY_RESET.forEach((key) => delete query[key]);
273
+ posed.forEach((item) => {
274
+ const key = VIEW_TEMPLATE_QUERY_KEYS[item.filter];
275
+ const values = viewTemplateValues(item);
276
+ // Un critere sans valeur ne pose rien : `?period=` serait lu comme une
277
+ // borne vide par la page, pas comme une absence de filtre.
278
+ if (key && values.length) query[key] = values.join(",");
279
+ });
280
+ // Le template se nomme dans l'URL : la vue temporaire que le middleware
281
+ // pose en le lisant porte son identifiant, et la pastille le nomme.
282
+ query[VIEW_TEMPLATE_QUERY_PARAM] = template.id;
283
+ // La vue nommee active cede la place avant la navigation : le middleware
284
+ // garde une vue enregistree de la page (`keepsNamedView`), et le
285
+ // template partait sous le nom de la vue de l'utilisateur, banniere
286
+ // « ecraser » comprise. Meme forme que la vue qu'il posera lui-meme.
287
+ views.scopes.value().setSelectedScope({
288
+ id: null,
289
+ title: "",
290
+ template: template.id,
291
+ page: route.path,
292
+ criteria: [],
293
+ });
294
+ navigateTo({ path: route.path, query });
295
+ return;
296
+ }
297
+
298
+ views.applyTemporary(criteria, template.title[lang.value], template.id);
299
+ }
300
+
301
+ /**
302
+ * Le titre d'un template par identifiant, sur toutes les pages : une vue
303
+ * demarree depuis un template sur le marche mensuel garde son nom sur le
304
+ * KPI, alors que l'identifiant porte la page de depart.
305
+ */
306
+ function titleOf(id: unknown): string | null {
307
+ const wanted = String(id ?? "");
308
+ const template =
309
+ templates.value.find((item) => item.id === wanted) ??
310
+ (published.list.value ?? []).find((item) => item.id === wanted) ??
311
+ viewTemplateById(wanted);
312
+ return template ? template.title[lang.value] : null;
313
+ }
314
+
315
+ /**
316
+ * Enregistre un template dans les vues du lecteur, sans l'ouvrir. Il garde
317
+ * son titre et sa description ; la vue est rangee sous la page courante,
318
+ * comme une vue composee ici.
319
+ *
320
+ * Un panier du portail est une classe : la vue enregistree porte la liste de
321
+ * marques qu'elle resout aujourd'hui (c'est ce qu'une vue du portail sait
322
+ * appliquer, cf. useDrugsViews) ET la classe, pour dire comment elle a ete
323
+ * composee. Meme resolution que le middleware du lien profond.
324
+ */
325
+ async function save(id: string) {
326
+ const template = templates.value.find((item) => item.id === id);
327
+ if (!template) return;
328
+
329
+ const page = route.path;
330
+ const criteria = template.criteria(context.value);
331
+ let stored: any[] = criteria.map((item) => ({
332
+ filter: item.filter,
333
+ page,
334
+ values: Array.isArray(item.values) ? item.values : [item.values],
335
+ }));
336
+
337
+ if (template.deepLink) {
338
+ const of = (filter: string) =>
339
+ stored.filter((item) => item.filter === filter).flatMap((item) => item.values as string[]);
340
+ // Un panier est une classe : la vue porte la liste de marques qu'elle
341
+ // resout aujourd'hui. La resolution est propre a l'app (son repertoire
342
+ // de marques), cf. contrat #oip-app/views.
343
+ const brands = await resolveTemplateBrands(
344
+ {
345
+ // `brand` (singulier) : exception ponctuelle d'un panier, a fondre
346
+ // dans la meme liste resolue que `brands`.
347
+ brands: [...of("brands"), ...of("brand")],
348
+ atc5: of("atc5"),
349
+ atc4: of("atc4"),
350
+ companies: of("company"),
351
+ },
352
+ `viewTemplateSave:${id}`,
353
+ );
354
+ stored = [
355
+ { filter: "brands", page, values: brands },
356
+ ...stored.filter((item) => item.filter !== "brands"),
357
+ ];
358
+ }
359
+
360
+ await views.scopes.value().createScope({
361
+ title: template.title[lang.value],
362
+ description: template.description[lang.value],
363
+ criteria: stored,
364
+ currentPage: page,
365
+ });
366
+ }
367
+
368
+ return { lang, templates, cards, cardOf, start, save, titleOf };
369
+ }