@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,86 @@
1
+ import { useOipApi } from "#oip-app/api";
2
+ /**
3
+ * useAdminAccess - a-t-on le droit de publier un template, et pour qui.
4
+ *
5
+ * Deux choses distinctes, souvent confondues :
6
+ * - `isPlatformAdmin`, porte par `/me/summary`, vaut pour toute adresse
7
+ * @tekkare.com : il ouvre les produits, pas l'administration ;
8
+ * - `isSuperAdmin`, rendu par `/superadmin/me`, est attribue a la main. C'est
9
+ * lui qui garde la console d'OIP 7, donc aussi `/superadmin/workspaces`.
10
+ *
11
+ * Publier un template met une vue devant des espaces clients : c'est le
12
+ * second portillon, le meme que la console. Rien n'est demande au chargement
13
+ * de la page : `ensureLoaded()` n'est appele qu'a l'ouverture du dialogue de
14
+ * partage, sans quoi tout le monde paierait un aller-retour pour une option
15
+ * que presque personne ne voit.
16
+ *
17
+ * Meme forme que le composable homonyme d'OIP 7 : un `useState` partage, une
18
+ * seule requete par session.
19
+ */
20
+ export interface AdminWorkspace {
21
+ workspaceId: string;
22
+ name: string;
23
+ tier: string;
24
+ }
25
+
26
+ export const useAdminAccess = () => {
27
+ const isSuperAdmin = useState<boolean>("admin:isSuperAdmin", () => false);
28
+ const loaded = useState<boolean>("admin:loaded", () => false);
29
+ const workspaces = useState<AdminWorkspace[]>("admin:workspaces", () => []);
30
+ const workspacesLoaded = useState<boolean>("admin:workspacesLoaded", () => false);
31
+ const elysiaApi = useOipApi();
32
+ const { status } = useAuth();
33
+
34
+ async function ensureLoaded(): Promise<boolean> {
35
+ if (loaded.value) return isSuperAdmin.value;
36
+ if (status.value !== "authenticated") return false;
37
+ try {
38
+ const me = await elysiaApi.admin.me();
39
+ isSuperAdmin.value = !!me?.isSuperAdmin;
40
+ } catch {
41
+ isSuperAdmin.value = false;
42
+ } finally {
43
+ loaded.value = true;
44
+ }
45
+ return isSuperAdmin.value;
46
+ }
47
+
48
+ /**
49
+ * Tous les espaces, pour le selecteur de cibles d'un template.
50
+ *
51
+ * La route est paginee et rend bien plus que des noms (licences, membres,
52
+ * comptes) : on la deroule une fois, on ne garde que le nom, et on ecarte
53
+ * les espaces personnels — offrir un template a l'espace prive de
54
+ * quelqu'un n'a pas de sens. La recherche se fait ensuite dans le
55
+ * selecteur, sur la liste en memoire, comme pour les membres.
56
+ */
57
+ async function ensureWorkspaces(): Promise<AdminWorkspace[]> {
58
+ if (workspacesLoaded.value) return workspaces.value;
59
+ if (!(await ensureLoaded())) return [];
60
+
61
+ try {
62
+ const first = await elysiaApi.admin.workspaces({ page: 1, limit: 100 });
63
+ const pages = first?.pagination?.totalPages ?? 1;
64
+ const rest = await Promise.all(
65
+ Array.from({ length: Math.max(0, pages - 1) }, (_, i) =>
66
+ elysiaApi.admin.workspaces({ page: i + 2, limit: 100 }),
67
+ ),
68
+ );
69
+
70
+ workspaces.value = [first, ...rest]
71
+ .flatMap((res) => res?.workspaces ?? [])
72
+ .filter((ws) => !ws.isPersonal)
73
+ .map((ws) => ({ workspaceId: ws.workspaceId, name: ws.name, tier: ws.tier }))
74
+ .sort((a, b) => a.name.localeCompare(b.name));
75
+ } catch (err) {
76
+ console.error("[useAdminAccess] Impossible de lister les espaces:", err);
77
+ workspaces.value = [];
78
+ } finally {
79
+ workspacesLoaded.value = true;
80
+ }
81
+
82
+ return workspaces.value;
83
+ }
84
+
85
+ return { isSuperAdmin, workspaces, ensureLoaded, ensureWorkspaces };
86
+ };
@@ -0,0 +1,85 @@
1
+ /**
2
+ * useAlertBrandSync - proposer a une alerte de marque de suivre sa vue.
3
+ *
4
+ * Une alerte de vue est repointee toute seule quand la vue bouge : l'API
5
+ * compare les criteres et la page lui remet sa requete (cf.
6
+ * `alertRefreshPayload`). Une alerte de MARQUE, elle, ne peut pas l'etre :
7
+ * elle ne lit ni les filtres de la vue ni son index, elle porte sa propre
8
+ * liste de marques, figee a la creation.
9
+ *
10
+ * Sur les pages de ventes c'est le seul genre d'alerte possible, et la vue y
11
+ * est justement une selection de produits : elargir la vue sans toucher a
12
+ * l'alerte laisse une veille qui ne couvre plus ce que l'ecran montre, sans
13
+ * que rien ne le dise.
14
+ *
15
+ * D'ou cette demande, posee au moment ou la vue est enregistree. Demander, et
16
+ * non suivre en silence : une alerte de marque qui suivrait sa vue d'
17
+ * elle-meme deviendrait une alerte de vue deguisee, alors que les deux ne se
18
+ * remettent pas a jour de la meme facon et ne survivent pas aux memes
19
+ * changements.
20
+ */
21
+ import type { Alert } from "#oip-app/api";
22
+
23
+ export interface AlertBrandSyncRequest {
24
+ alert: Alert;
25
+ /** Marques que porte desormais la vue. */
26
+ brands: string[];
27
+ /** Celles que l'alerte suit encore. */
28
+ current: string[];
29
+ }
30
+
31
+ const pending = shallowRef<AlertBrandSyncRequest | null>(null);
32
+ const syncing = ref(false);
33
+
34
+ export function useAlertBrandSync() {
35
+ const alerts = useAlerts();
36
+
37
+ /**
38
+ * Regarde si la vue enregistree porte une alerte de marque a decaler.
39
+ *
40
+ * Rien n'est demande quand les listes sont deja identiques, ni quand la vue
41
+ * n'a plus de marques du tout : une vue videe ne veut pas dire « ne
42
+ * surveille plus rien », et l'API refuserait une alerte sans marque.
43
+ *
44
+ * Une seule alerte a la fois : deux demandes empilees feraient deux modales
45
+ * l'une derriere l'autre, et le cas ne s'est jamais presente.
46
+ */
47
+ function ask(pulseId: string, brands: string[]) {
48
+ if (brands.length === 0) return;
49
+
50
+ const alert = alerts.alerts.value.find(
51
+ (entry) => entry.kind === "product" && entry.pulseId === pulseId,
52
+ );
53
+ if (!alert) return;
54
+
55
+ const current = alert.brands ?? [];
56
+ const same =
57
+ current.length === brands.length &&
58
+ [...current].sort().join("|") === [...brands].sort().join("|");
59
+ if (same) return;
60
+
61
+ pending.value = { alert, brands, current };
62
+ }
63
+
64
+ function dismiss() {
65
+ pending.value = null;
66
+ }
67
+
68
+ /** Reporte les marques de la vue sur l'alerte. L'API la reseme. */
69
+ async function confirm() {
70
+ const request = pending.value;
71
+ if (!request) return;
72
+ syncing.value = true;
73
+ try {
74
+ await alerts.update(request.alert.id, { brands: request.brands });
75
+ // L'alerte repart en `seeding` cote worker : la carte doit le dire.
76
+ await alerts.load(true);
77
+ alerts.followSeeding();
78
+ pending.value = null;
79
+ } finally {
80
+ syncing.value = false;
81
+ }
82
+ }
83
+
84
+ return { pending: computed(() => pending.value), syncing, ask, dismiss, confirm };
85
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * useAlertSource - ce qu'une page donne a surveiller.
3
+ *
4
+ * Une alerte rejoue la requete d'une vue sauvegardee et rapporte ce qui y
5
+ * apparait, y change ou en sort. L'API ne sait pas construire cette requete :
6
+ * la DSL Elasticsearch est assemblee dans le navigateur, clause par clause,
7
+ * par les composants de filtre de la page (`elasticQueryStore`). La page la
8
+ * remet donc telle quelle au moment ou l'alerte est creee, et l'API la garde
9
+ * figee.
10
+ *
11
+ * Consequence assumee : une vue modifiee doit repointer ses alertes. Quand
12
+ * l'edition part de la page, le client renvoie la nouvelle requete avec les
13
+ * criteres et l'API repart d'une nouvelle reference. Par tout autre chemin,
14
+ * l'alerte passe en `stale` et attend d'etre rearmee ici.
15
+ *
16
+ * Meme forme que `useExportSource` et `useViews` : un `shallowRef` de module,
17
+ * pose au montage, retire au demontage, donc rien a nettoyer a la main et rien
18
+ * qui traverse le rendu serveur (les descripteurs portent des fonctions).
19
+ */
20
+ import type { AlertResourceType as HubrResourceType } from "#oip-app/alerts";
21
+ import { VIEW_BRAND_FILTERS } from "#oip-app/views";
22
+
23
+ /** Un champ que la page propose de surveiller. */
24
+ export interface AlertField {
25
+ /** Chemin du champ dans le `_source`, points admis. */
26
+ value: string;
27
+ /** Libelle affiche, deja traduit. */
28
+ label: string;
29
+ }
30
+
31
+ export interface AlertSource {
32
+ /** Index Elasticsearch interroge par la page. */
33
+ index: string;
34
+ /** Famille du document, cf. `useHubrResourceKinds`. */
35
+ resourceType: HubrResourceType;
36
+ /**
37
+ * Requete de l'ecran, relue au moment ou l'alerte est creee.
38
+ *
39
+ * La clause `query` seule : ni tri, ni agregation, ni pagination. Le tri ne
40
+ * decrit rien pour une alerte (le diff porte sur un ensemble, pas sur un
41
+ * classement) et les agregations couteraient cher pour rien.
42
+ */
43
+ query: () => any;
44
+ /**
45
+ * Champs relus pour nommer un element dans le journal : titre, puis
46
+ * sous-titre. Ils sont aussi gardes dans la reference, seul endroit ou le
47
+ * nom d'un element sorti de la vue existe encore.
48
+ */
49
+ labelFields: [string] | [string, string];
50
+ /** Champs surveillables, tels que la page les propose a l'ecran. */
51
+ fields: AlertField[];
52
+ /** Titre suggere a la creation. Defaut : le nom de la vue. */
53
+ defaultTitle?: string;
54
+ }
55
+
56
+ const registry = shallowRef<(() => AlertSource | null) | null>(null);
57
+
58
+ /**
59
+ * Marques que la page affichee met en avant, quand elle en met une.
60
+ *
61
+ * Deuxieme registre plutot qu'un champ de `AlertSource` : une fiche produit et
62
+ * l'ecran de recherche ne livrent aucune requete surveillable (l'une est un
63
+ * document, l'autre un agregat de dix index), ils n'ont donc pas de source du
64
+ * tout. Ils ont pourtant la seule chose dont une alerte de MARQUE a besoin :
65
+ * le nom du produit qu'on regarde.
66
+ *
67
+ * Ce sont des candidats, pas des marques : le panneau les confronte au
68
+ * repertoire avant de cocher quoi que ce soit, parce qu'un terme de recherche
69
+ * peut etre une molecule (« pembrolizumab ») ou n'exister nulle part.
70
+ */
71
+ const brandRegistry = shallowRef<(() => string[]) | null>(null);
72
+
73
+ export function useAlertSource() {
74
+ /**
75
+ * Declare la source de la page. A appeler dans le `setup`, une seule fois :
76
+ * le descripteur est relu a chaque acces, donc il peut lire des refs.
77
+ */
78
+ const declare = (build: () => AlertSource | null) => {
79
+ // Page du tiroir de projet : les alertes restent celles de l'ecran dessous.
80
+ if (useHubrEmbedded()) return;
81
+ // Le moteur d'alertes interroge Elasticsearch lui-meme, par NOM : l'id
82
+ // Directus du catalogue est traduit ici, une fois pour tous les appels.
83
+ const { nameOf } = useOipEsIndices();
84
+ const named = () => {
85
+ const source = build();
86
+ return source ? { ...source, index: nameOf(source.index) } : null;
87
+ };
88
+ registry.value = named;
89
+ onScopeDispose(() => {
90
+ if (registry.value === named) registry.value = null;
91
+ });
92
+ };
93
+
94
+ /** La source de la page affichee, ou `null` si elle n'en declare pas. */
95
+ const current = computed(() => registry.value?.() ?? null);
96
+
97
+ /**
98
+ * Declare les marques de la page. Meme forme que `declare`, et independant :
99
+ * une page peut n'avoir que ca.
100
+ */
101
+ const declareBrands = (build: () => string[]) => {
102
+ if (useHubrEmbedded()) return;
103
+ brandRegistry.value = build;
104
+ onScopeDispose(() => {
105
+ if (brandRegistry.value === build) brandRegistry.value = null;
106
+ });
107
+ };
108
+
109
+ /** Les marques candidates de la page affichee, vides si elle n'en propose pas. */
110
+ const brands = computed(() =>
111
+ (brandRegistry.value?.() ?? []).map((brand) => String(brand).trim()).filter(Boolean),
112
+ );
113
+
114
+ return { declare, current, declareBrands, brands };
115
+ }
116
+
117
+ /**
118
+ * Noms sous lesquels une vue range sa selection de produits.
119
+ *
120
+ * Le portail ecrit `brands` (cf. useDrugsViews), les delivrances
121
+ * hospitalieres `brandSelection` : c'est le nom qu'elles donnent en base
122
+ * depuis l'origine, garde tel quel pour ne pas perdre les vues deja
123
+ * enregistrees.
124
+ */
125
+
126
+ /**
127
+ * Les marques que porte une vue, s'il y en a.
128
+ *
129
+ * Sur les pages de ventes, une vue EST une selection de produits : faute de
130
+ * requete de documents a livrer, « surveiller cette vue » ne peut vouloir
131
+ * dire que « surveiller ces marques ». C'est ce qui pre-remplit le formulaire
132
+ * d'alerte, et ce qu'on propose de reporter sur une alerte existante quand la
133
+ * vue change.
134
+ */
135
+ export function viewBrandsFromCriteria(criteria: any[]): string[] {
136
+ const entry = (Array.isArray(criteria) ? criteria : []).find((item: any) =>
137
+ VIEW_BRAND_FILTERS.includes(item?.filter),
138
+ );
139
+ return Array.isArray(entry?.values) ? entry.values.map(String) : [];
140
+ }
141
+
142
+ /** Deux chemins qui designent la meme page, `trailingSlash` etant actif. */
143
+ const samePage = (a: unknown, b: unknown): boolean =>
144
+ String(a ?? "").replace(/\/$/, "") === String(b ?? "").replace(/\/$/, "");
145
+
146
+ /**
147
+ * Ce qu'une mise a jour de vue doit emporter pour que ses alertes survivent.
148
+ *
149
+ * L'API repointe toute seule les alertes d'une vue dont les criteres ont
150
+ * bouge, mais elle a besoin de la requete : sa DSL est assemblee dans le
151
+ * navigateur, elle ne sait pas la reconstruire. Sans elle, les alertes
152
+ * passent en `stale` et attendent un rearmement a la main depuis la page.
153
+ *
154
+ * Rien n'est envoye si la vue mise a jour n'est pas celle de la page
155
+ * affichee : la requete de l'ecran decrirait alors une autre page que la vue
156
+ * qu'on enregistre.
157
+ */
158
+ export function alertRefreshPayload(
159
+ page: unknown,
160
+ ): { alertQuery?: any; alertIndex?: string } {
161
+ const declared = registry.value?.() ?? null;
162
+ if (!declared) return {};
163
+ if (!samePage(page, useViews().current.value?.page)) return {};
164
+ return { alertQuery: declared.query(), alertIndex: declared.index };
165
+ }
166
+
167
+ /**
168
+ * Ce que la snackbar ajoute quand des alertes ont suivi, ou pas.
169
+ *
170
+ * Sans ce mot, l'API rendait deja ces deux nombres et personne ne les lisait :
171
+ * une vue elargie endormait ses alertes en silence, et la panne ne se
172
+ * decouvrait qu'en ouvrant le panneau, parfois des jours plus tard.
173
+ */
174
+ export function alertRefreshNotice(result: unknown): string {
175
+ const refreshed = Number((result as any)?.alertsRefreshed ?? 0);
176
+ const stale = Number((result as any)?.alertsStale ?? 0);
177
+ // Appelee depuis les magasins, hors setup : la langue vient de l'app, pas de useI18n.
178
+ const fr = String(tryUseNuxtApp()?.$i18n?.locale?.value ?? "fr") !== "en";
179
+ const plural = (n: number) => (n > 1 ? "s" : "");
180
+ if (stale > 0) {
181
+ return fr
182
+ ? ` (${stale} alerte${plural(stale)} à réarmer)`
183
+ : ` (${stale} alert${plural(stale)} to re-arm)`;
184
+ }
185
+ if (refreshed > 0) {
186
+ return fr
187
+ ? ` (${refreshed} alerte${plural(refreshed)} mise${plural(refreshed)} à jour)`
188
+ : ` (${refreshed} alert${plural(refreshed)} updated)`;
189
+ }
190
+ return "";
191
+ }
192
+
193
+ /**
194
+ * Requete d'alerte a partir des filtres d'une page heart.
195
+ *
196
+ * Les pages du hub tiennent leurs clauses dans `elasticQueryStore` sous la
197
+ * forme `{ filter, page, values, keyName }`, et `useHeartElastic` les assemble
198
+ * en `bool.must` au moment d'interroger le cluster. On reprend exactement le
199
+ * meme assemblage : une alerte qui ne poserait pas la meme question que
200
+ * l'ecran ne vaut rien.
201
+ */
202
+ export function alertQueryFromFilters(filters: any[]): any {
203
+ return {
204
+ bool: {
205
+ must: (filters ?? [])
206
+ .map((entry: any) => entry?.values)
207
+ .filter((clause: unknown) => clause !== undefined && clause !== null),
208
+ },
209
+ };
210
+ }