@tekkare/romulus 0.1.242 → 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 (45) hide show
  1. package/dist/module.json +1 -1
  2. package/layer/app/components/AppUpdateToast.vue +157 -0
  3. package/layer/app/components/OipShell.vue +2 -0
  4. package/layer/app/components/auth/SplitLayout.vue +261 -0
  5. package/layer/app/components/layout/alertBrandSync.vue +143 -0
  6. package/layer/app/components/layout/firstVisitVideo.vue +165 -0
  7. package/layer/app/components/layout/tool/adminTemplates.vue +261 -0
  8. package/layer/app/components/layout/tool/alerts.vue +969 -0
  9. package/layer/app/components/layout/tool/export.vue +590 -0
  10. package/layer/app/components/layout/tool/views.vue +933 -0
  11. package/layer/app/components/layout/viewEmptyState.vue +278 -0
  12. package/layer/app/components/layout/viewPill.vue +550 -0
  13. package/layer/app/components/panels/OipPanelProjects.vue +94 -0
  14. package/layer/app/composables/useAdminAccess.ts +86 -0
  15. package/layer/app/composables/useAlertBrandSync.ts +85 -0
  16. package/layer/app/composables/useAlertSource.ts +210 -0
  17. package/layer/app/composables/useAlerts.ts +345 -0
  18. package/layer/app/composables/useExportSource.ts +236 -0
  19. package/layer/app/composables/useHubrEmbedded.ts +13 -0
  20. package/layer/app/composables/usePermalink.ts +108 -0
  21. package/layer/app/composables/useSavedExports.ts +286 -0
  22. package/layer/app/composables/useViewCardActions.ts +500 -0
  23. package/layer/app/composables/useViewEditing.ts +11 -0
  24. package/layer/app/composables/useViewTemplates.ts +369 -0
  25. package/layer/app/composables/useViews.ts +574 -0
  26. package/layer/app/oip-defaults/alerts.ts +34 -0
  27. package/layer/app/oip-defaults/api.ts +18 -0
  28. package/layer/app/oip-defaults/export.ts +23 -0
  29. package/layer/app/oip-defaults/ui.ts +21 -0
  30. package/layer/app/oip-defaults/views.ts +59 -0
  31. package/layer/app/utils/alertFieldLabels.ts +191 -0
  32. package/layer/app/utils/countries.ts +30 -0
  33. package/layer/app/utils/exportPage.ts +418 -0
  34. package/layer/app/utils/headerTabs.ts +15 -0
  35. package/layer/app/utils/hybridRows.ts +22 -0
  36. package/layer/app/utils/oipScopeStore.ts +112 -0
  37. package/layer/app/utils/oipScopes.ts +376 -0
  38. package/layer/app/utils/postLoginRedirect.ts +58 -0
  39. package/layer/app/utils/valueTable.ts +126 -0
  40. package/layer/app/utils/viewPages.ts +30 -0
  41. package/layer/app/utils/viewTemplateCriteria.ts +215 -0
  42. package/layer/app/utils/viewTemplates.ts +116 -0
  43. package/layer/nuxt.config.ts +9 -0
  44. package/layer/server/api/oip/notes.get.ts +1 -1
  45. package/package.json +4 -2
@@ -0,0 +1,108 @@
1
+ /**
2
+ * usePermalink - l'URL d'un ecran filtre, et sa relecture a l'arrivee.
3
+ *
4
+ * Le format « Permalink » du panneau d'export copiait l'URL de la page. Or les
5
+ * filtres d'un ecran du hub ne sont pas dans l'URL : ils vivent dans le magasin
6
+ * de scopes (cf. `useViews`). Le lien partage ouvrait donc la page nue, avec la
7
+ * vue par defaut du destinataire, et pas l'ecran que l'expediteur avait sous
8
+ * les yeux.
9
+ *
10
+ * Un permalink porte donc les criteres de la page, dans la forme exacte d'une
11
+ * vue : le lien est une vue temporaire transportee par l'URL. A l'arrivee, ils
12
+ * sont reposes par le meme chemin qu'une vue appliquee, et la vue par defaut de
13
+ * la page leur cede la place (cf. `useViews().declare`).
14
+ *
15
+ * Les criteres voyagent compresses, en base64url : le routeur aplatit les
16
+ * tableaux imbriques d'une requete, et un JSON pose tel quel dans l'URL en
17
+ * ressortirait deforme. La compression n'est pas une coquetterie — une
18
+ * selection de cinquante produits fait 3 300 caracteres en clair, au-dela de ce
19
+ * qu'un client mail transmet sans couper, contre 450 compressee. Elle est
20
+ * portee par `CompressionStream`, native, donc rien a installer.
21
+ */
22
+
23
+ /** Parametre de requete qui porte les criteres d'un lien partage. */
24
+ export const PERMALINK_PARAM = "vue";
25
+
26
+ /**
27
+ * Premier caractere de la charge : il dit comment lire la suite. Un navigateur
28
+ * sans `CompressionStream` ecrit en clair, et le lien reste lisible par les
29
+ * autres.
30
+ */
31
+ const DEFLATED = "1";
32
+ const PLAIN = "0";
33
+
34
+ function toBase64Url(bytes: Uint8Array): string {
35
+ let binary = "";
36
+ bytes.forEach((byte) => {
37
+ binary += String.fromCharCode(byte);
38
+ });
39
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
40
+ }
41
+
42
+ function fromBase64Url(raw: string): Uint8Array {
43
+ const binary = atob(raw.replace(/-/g, "+").replace(/_/g, "/"));
44
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
45
+ }
46
+
47
+ /** Passe des octets dans un flux de (de)compression et rend le resultat. */
48
+ async function through(bytes: Uint8Array, stream: TransformStream): Promise<Uint8Array> {
49
+ const blob = new Blob([bytes as BlobPart]);
50
+ const piped = blob.stream().pipeThrough(stream);
51
+ return new Uint8Array(await new Response(piped).arrayBuffer());
52
+ }
53
+
54
+ /**
55
+ * La charge a poser dans l'URL pour ces criteres. Compressee quand le
56
+ * navigateur sait le faire, en clair sinon.
57
+ */
58
+ async function encode(criteria: any[]): Promise<string> {
59
+ const bytes = new TextEncoder().encode(JSON.stringify(criteria));
60
+ if (typeof CompressionStream === "undefined") return PLAIN + toBase64Url(bytes);
61
+ // « deflate-raw » et pas « deflate » : l'en-tete zlib ne sert a rien ici, et
62
+ // ce sont six caracteres de base64 en moins sur chaque lien.
63
+ return DEFLATED + toBase64Url(await through(bytes, new CompressionStream("deflate-raw")));
64
+ }
65
+
66
+ /**
67
+ * Les criteres portes par une URL, dans la forme de ceux d'une vue. Vide quand
68
+ * le lien n'en porte pas — ou quand il a ete tronque par un client mail : la
69
+ * page s'ouvre alors sur ses propres filtres plutot que sur une erreur.
70
+ */
71
+ export async function permalinkCriteria(query: Record<string, any>): Promise<any[]> {
72
+ const raw = query?.[PERMALINK_PARAM];
73
+ if (typeof raw !== "string" || raw.length < 2) return [];
74
+
75
+ try {
76
+ const bytes = fromBase64Url(raw.slice(1));
77
+ const json =
78
+ raw[0] === DEFLATED
79
+ ? new TextDecoder().decode(await through(bytes, new DecompressionStream("deflate-raw")))
80
+ : new TextDecoder().decode(bytes);
81
+ const parsed = JSON.parse(json);
82
+ return Array.isArray(parsed) ? parsed : [];
83
+ } catch {
84
+ return [];
85
+ }
86
+ }
87
+
88
+ export function usePermalink() {
89
+ const views = useViews();
90
+
91
+ /**
92
+ * L'URL a partager pour l'ecran courant. Sans filtre pose, c'est l'URL de la
93
+ * page : un lien vide de criteres n'a rien a transporter.
94
+ */
95
+ async function build(): Promise<string> {
96
+ const url = new URL(window.location.href);
97
+ url.searchParams.delete(PERMALINK_PARAM);
98
+
99
+ // Meme lecture que la sauvegarde d'une vue et que le recapitulatif d'un
100
+ // rapport : un filtre ajoute a une page suit sans retouche ici.
101
+ const criteria = views.criteriaOf().filter((item: any) => viewFilledValues(item).length > 0);
102
+ if (criteria.length > 0) url.searchParams.set(PERMALINK_PARAM, await encode(criteria));
103
+
104
+ return url.toString();
105
+ }
106
+
107
+ return { build };
108
+ }
@@ -0,0 +1,286 @@
1
+ /**
2
+ * useSavedExports - les exports deja tires, conserves dans le compte.
3
+ *
4
+ * Un export n'est pas un fichier stocke : les documents partent du navigateur
5
+ * et personne ne les garde. Ce qui est conserve, c'est la *recette* : la
6
+ * configuration du wizard, plus les filtres poses a l'ecran au moment du
7
+ * tirage. Rejouer un export repose donc ces filtres et regenere le document
8
+ * avec la donnee du jour.
9
+ *
10
+ * Le magasin est celui des vues : les pulses de `elysiaApi.pulses`, le seul
11
+ * endroit ou le hub sait deja ranger quelque chose par utilisateur, par app et
12
+ * par page. Deux marques les distinguent d'une vue :
13
+ *
14
+ * - `page` porte le prefixe `export:`. Les listes de vues filtrent sur
15
+ * l'egalite avec la page courante (cf. `layout/tool/views.vue` et la
16
+ * pastille), donc un export prefixe n'y parait jamais et la vue par defaut
17
+ * d'une page ne peut pas tomber sur lui.
18
+ * - la configuration voyage dans un critere nomme `__export`, comme l'etat du
19
+ * « Personnaliser » voyage dans `__cardPrefs` : `criteria` est le seul champ
20
+ * libre du pulse.
21
+ *
22
+ * Les criteres qui restent sont donc lisibles tels quels par les composants de
23
+ * filtre, et rejouer un export emprunte exactement le chemin d'une vue
24
+ * appliquee.
25
+ */
26
+ import type { RmExportConfig } from "@tekkare/romulus/utils/export";
27
+ import { useOipApi } from "#oip-app/api";
28
+ import { normalizePage } from "../utils/viewTemplates";
29
+
30
+ /** Prefixe de `pulse.page` qui distingue un export d'une vue. */
31
+ const EXPORT_PAGE_PREFIX = "export:";
32
+
33
+ /** Critere qui porte la configuration du wizard. Ce n'est pas un filtre. */
34
+ export const EXPORT_CONFIG_FILTER = "__export";
35
+
36
+ /**
37
+ * Nombre d'exports gardes. Au-dela, les plus anciens partent : la liste est
38
+ * un historique consultable, pas une archive, et une page en tire plusieurs
39
+ * par session.
40
+ */
41
+ const KEPT = 20;
42
+
43
+ export interface SavedExport {
44
+ id: string;
45
+ /** Nom du fichier tel qu'il est sorti. */
46
+ title: string;
47
+ /** Ecran d'origine, sans le prefixe. */
48
+ page: string;
49
+ /** Titre de cet ecran, garde avec l'entree. */
50
+ pageLabel: string;
51
+ /** Dernier tirage : un rejeu remplace l'entree, il n'en cree pas une autre. */
52
+ at: string;
53
+ format: string;
54
+ rows?: number;
55
+ size?: string;
56
+ config: RmExportConfig;
57
+ /** Filtres du moment du tirage, dans la forme des criteres d'une vue. */
58
+ criteria: any[];
59
+ }
60
+
61
+ /**
62
+ * Liste partagee entre les montages du panneau : la tuile monte et demonte le
63
+ * composant a chaque ouverture, un etat local repartirait vide. `useState`
64
+ * plutot qu'un `ref` de module : la liste porte de la donnee d'utilisateur, et
65
+ * un ref de module est partage par toutes les requetes d'un meme serveur.
66
+ */
67
+ const useList = () => useState<SavedExport[]>("oip-saved-exports", () => []);
68
+
69
+ function parseCriteria(pulse: any): any[] {
70
+ const raw = pulse?.criteria;
71
+ if (Array.isArray(raw)) return raw;
72
+ if (typeof raw !== "string") return [];
73
+ try {
74
+ return JSON.parse(raw) ?? [];
75
+ } catch {
76
+ return [];
77
+ }
78
+ }
79
+
80
+ /** Un pulse prefixe devient une entree, ou rien s'il ne porte pas de config. */
81
+ function toSavedExport(pulse: any): SavedExport | null {
82
+ const criteria = parseCriteria(pulse);
83
+ const entry = criteria.find((item: any) => item?.filter === EXPORT_CONFIG_FILTER);
84
+ const raw = Array.isArray(entry?.values) ? entry.values[0] : null;
85
+ if (typeof raw !== "string") return null;
86
+
87
+ let payload: any;
88
+ try {
89
+ payload = JSON.parse(raw);
90
+ } catch {
91
+ return null;
92
+ }
93
+ if (!payload?.config?.format) return null;
94
+
95
+ return {
96
+ id: String(pulse.id),
97
+ title: String(pulse.title ?? ""),
98
+ page: String(pulse.page ?? "").slice(EXPORT_PAGE_PREFIX.length),
99
+ pageLabel: String(pulse.description ?? ""),
100
+ // La date du dernier tirage voyage dans la config : un rejeu ecrit le
101
+ // meme pulse, et `createdAt` resterait celle du premier. Les entrees
102
+ // d'avant ce champ retombent dessus.
103
+ at: String(payload.at ?? pulse.createdAt ?? pulse.updatedAt ?? ""),
104
+ format: String(payload.config.format),
105
+ rows: typeof payload.rows === "number" ? payload.rows : undefined,
106
+ size: typeof payload.size === "string" ? payload.size : undefined,
107
+ config: payload.config as RmExportConfig,
108
+ criteria: criteria.filter((item: any) => item?.filter !== EXPORT_CONFIG_FILTER),
109
+ };
110
+ }
111
+
112
+ export function useSavedExports() {
113
+ const list = useList();
114
+ const elysiaApi = useOipApi();
115
+ const views = useViews();
116
+ const route = useRoute();
117
+
118
+ /**
119
+ * Page de rangement : celle que la page a declaree, sinon la route. Sans le
120
+ * prefixe de langue : les pages declarent `route.path`, et « Mes exports »
121
+ * se vidait au passage de `/fr/` a `/en/`.
122
+ */
123
+ const pageKey = () => normalizePage(views.current.value?.page ?? route.path);
124
+
125
+ /** Les exports du compte pour cette app, les plus recents d'abord. */
126
+ async function load() {
127
+ try {
128
+ // Meme plafond que les listes de vues : les deux lisent le meme magasin,
129
+ // et un compte charge tronquerait sinon son propre historique.
130
+ const response = await elysiaApi.pulses.list({
131
+ domain: window.location.host,
132
+ limit: 500,
133
+ });
134
+ list.value = response.data
135
+ .filter((pulse: any) => String(pulse.page ?? "").startsWith(EXPORT_PAGE_PREFIX))
136
+ .map(toSavedExport)
137
+ .filter((entry): entry is SavedExport => entry !== null)
138
+ .sort((a, b) => b.at.localeCompare(a.at));
139
+ } catch (error) {
140
+ // Compte sans acces aux pulses, ou API muette : l'onglet reste vide
141
+ // plutot que de faire echouer l'export lui-meme.
142
+ console.error("[useSavedExports] chargement impossible", error);
143
+ list.value = [];
144
+ }
145
+ }
146
+
147
+ interface ExportEntry {
148
+ title: string;
149
+ /** Titre de l'ecran d'origine, garde avec l'entree. */
150
+ pageLabel: string;
151
+ config: RmExportConfig;
152
+ rows?: number;
153
+ size?: string;
154
+ }
155
+
156
+ /** Les filtres de l'ecran, suivis de la config du tirage. */
157
+ function criteriaFor(entry: ExportEntry, page: string) {
158
+ return [
159
+ // Les filtres de l'ecran, lus la ou les vues les lisent : un filtre
160
+ // ajoute a une page suit sans retouche ici.
161
+ ...views.criteriaOf(),
162
+ {
163
+ filter: EXPORT_CONFIG_FILTER,
164
+ page,
165
+ values: [
166
+ JSON.stringify({
167
+ config: entry.config,
168
+ rows: entry.rows,
169
+ size: entry.size,
170
+ at: new Date().toISOString(),
171
+ }),
172
+ ],
173
+ },
174
+ ];
175
+ }
176
+
177
+ /**
178
+ * Garde l'export qui vient de partir. Appele apres coup : un echec
179
+ * d'enregistrement ne doit pas priver l'utilisateur de son fichier, il est
180
+ * deja telecharge.
181
+ *
182
+ * `replaces` nomme l'entree rejouee : un rejeu refait le meme document a
183
+ * partir de la meme recette, il met a jour son entree (date, lignes, taille)
184
+ * au lieu d'empiler un doublon a chaque clic.
185
+ */
186
+ async function save(entry: ExportEntry, replaces?: string) {
187
+ const page = pageKey();
188
+ try {
189
+ if (replaces) {
190
+ await elysiaApi.pulses.update(replaces, {
191
+ title: entry.title,
192
+ criteria: criteriaFor(entry, page),
193
+ });
194
+ } else {
195
+ await elysiaApi.pulses.create({
196
+ title: entry.title,
197
+ description: entry.pageLabel,
198
+ url: window.location.href,
199
+ page: `${EXPORT_PAGE_PREFIX}${page}`,
200
+ criteria: criteriaFor(entry, page),
201
+ });
202
+ }
203
+ await load();
204
+ await trim();
205
+ } catch (error) {
206
+ console.error("[useSavedExports] enregistrement impossible", error);
207
+ }
208
+ }
209
+
210
+ /** Retire les entrees au-dela du plafond, les plus anciennes d'abord. */
211
+ async function trim() {
212
+ const surplus = list.value.slice(KEPT);
213
+ if (surplus.length === 0) return;
214
+ await Promise.all(
215
+ surplus.map((entry) =>
216
+ elysiaApi.pulses
217
+ .delete(entry.id)
218
+ .catch((error) => console.error("[useSavedExports] purge impossible", error)),
219
+ ),
220
+ );
221
+ list.value = list.value.slice(0, KEPT);
222
+ }
223
+
224
+ async function remove(id: string) {
225
+ const kept = list.value.filter((entry) => entry.id !== id);
226
+ try {
227
+ await elysiaApi.pulses.delete(id);
228
+ list.value = kept;
229
+ } catch (error) {
230
+ console.error("[useSavedExports] suppression impossible", error);
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Repose les filtres que porte un export.
236
+ *
237
+ * Meme chemin qu'une vue appliquee : les composants de filtre de heart
238
+ * ecoutent la reference de `selectedScope`, drugsstats et le portail passent
239
+ * par `onApply`. La vue posee n'a ni identifiant ni titre, comme celle d'un
240
+ * lien partage : l'ecran ne correspond plus a une vue enregistree, la
241
+ * pastille le dit (« Vue temporaire ») plutot que de porter le nom de
242
+ * l'export.
243
+ *
244
+ * Un export tire sans filtre repose un ecran vierge : garder les filtres du
245
+ * moment aurait donne un autre perimetre que celui de l'export.
246
+ *
247
+ * Rend ce que `onApply` rend : il est type `void`, mais une page qui
248
+ * renverrait sa promesse de rechargement serait attendue par le rejeu.
249
+ */
250
+ async function applyFilters(entry: SavedExport) {
251
+ const source = views.current.value;
252
+ if (!source) return;
253
+
254
+ if (entry.criteria.length === 0) {
255
+ views.resetAll();
256
+ return;
257
+ }
258
+
259
+ const scope = {
260
+ id: null,
261
+ title: "",
262
+ page: source.page,
263
+ criteria: JSON.stringify(entry.criteria),
264
+ };
265
+ views.scopes.value().setSelectedScope(scope);
266
+ views.applyPrefs(scope, source.prefsScope);
267
+ await source.onApply?.(scope);
268
+ }
269
+
270
+ return {
271
+ /**
272
+ * Les exports de l'ecran courant, et eux seuls : un export nomme les
273
+ * filtres et les colonnes d'une page, il ne se lit pas depuis une autre.
274
+ * La liste complete reste chargee derriere : le plafond se compte par
275
+ * compte et non par page, et `find` cherche par identifiant.
276
+ */
277
+ list: computed(() => list.value.filter((entry) => entry.page === pageKey())),
278
+ /** Vrai quand l'export a ete tire de l'ecran courant : il peut se rejouer. */
279
+ isHere: (entry: SavedExport) => entry.page === pageKey(),
280
+ find: (id: string) => list.value.find((entry) => entry.id === id) ?? null,
281
+ load,
282
+ save,
283
+ remove,
284
+ applyFilters,
285
+ };
286
+ }