@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,278 @@
1
+ <!--
2
+ Etat vide d'une page d'analyse : aucune vue n'est active, donc aucun produit
3
+ n'est selectionne et il n'y a rien a interroger.
4
+
5
+ Le message seul renvoyait l'utilisateur vers le tiroir de selection, meme
6
+ quand il avait deja des vues gardees pour cette page : il devait les aller
7
+ chercher dans la tuile « Mes vues » du rail alors que l'ecran lui demandait
8
+ d'en composer une. Les vues applicables ici sont donc listees sous le
9
+ message, activables d'un clic (`RmSavedViewCard`, les memes cartes que le
10
+ panneau du rail).
11
+
12
+ Les templates Tekkare de la page viennent ensuite : un panier pret a
13
+ l'emploi depanne quand l'utilisateur n'a rien garde, mais ses propres vues
14
+ passent devant, comme dans le panneau du rail ou l'onglet « Mes vues » est le
15
+ premier. Leur mise en carte et leur demarrage viennent de
16
+ `useViewTemplates`.
17
+
18
+ Le composant lit la page declaree par `useViews` : c'est elle qui porte le
19
+ magasin de scopes de l'app affichee, pas ce composant.
20
+ -->
21
+ <script setup lang="ts">
22
+ import { VIEW_BRAND_FILTERS } from "#oip-app/views";
23
+ import { sameViewPage } from "../../utils/viewPages";
24
+
25
+ const { locale } = useI18n();
26
+ const route = useRoute();
27
+ const views = useViews();
28
+
29
+ const emit = defineEmits<{ create: [] }>();
30
+
31
+ const fr = computed(() => locale.value !== "en");
32
+ const source = computed(() => views.current.value);
33
+
34
+ /** Page cote API : les vues y sont rangees, comme dans le panneau du rail. */
35
+ const page = computed(() => (source.value?.page ?? route.path).replace(/\/$/, ""));
36
+
37
+ /**
38
+ * Le magasin de scopes de la page affichee. Un appel par acces : ces
39
+ * composables creent leurs refs a chaque fois, donc rien ne se garde ici.
40
+ */
41
+ const store = () => views.scopes.value();
42
+
43
+ /**
44
+ * Chemins acceptes. Les vues des delivrances hospitalieres enregistrees par
45
+ * l'ancienne page de selection portent son chemin (`/doutpatient/drug/scope`)
46
+ * et non celui de l'analyse : sans les deux, elles disparaitraient de la liste
47
+ * (cf. le tiroir de selection, meme lecture).
48
+ */
49
+ const pages = computed(() => [page.value, `${page.value}/scope`]);
50
+ /** La vue vaut ici : meme page, meme famille (cf. viewPages), ou ancienne page de selection. */
51
+ const forThisPage = (scope: any) =>
52
+ sameViewPage(scope.page, page.value) ||
53
+ pages.value.includes(String(scope.page).replace(/\/$/, ""));
54
+
55
+ const { cards: templates, start, save: saveTemplate } = useViewTemplates(() => page.value);
56
+ const filterLabel = useViewFilterLabels(() => source.value?.filterNamespace);
57
+
58
+ /**
59
+ * Menu des cartes (renommer, alerte, export, partager...) : le meme que dans
60
+ * le panneau « Mes vues », sans devoir aller l'y chercher.
61
+ */
62
+ const {
63
+ menuOf,
64
+ onAction,
65
+ rename,
66
+ sharing,
67
+ shareTarget,
68
+ shareMembers,
69
+ isSuperAdmin,
70
+ workspaces,
71
+ templateNote,
72
+ onShareSearch,
73
+ confirmShare,
74
+ } = useViewCardActions(apply);
75
+
76
+ function onTemplateAction(key: string, id: string) {
77
+ if (key === "save-template") void saveTemplate(id);
78
+ }
79
+
80
+ /** Les criteres qui portent des produits : cites en premier. */
81
+ const PRODUCT_FILTERS = VIEW_BRAND_FILTERS;
82
+
83
+ const dateFormat = computed(
84
+ () => new Intl.DateTimeFormat(locale.value === "en" ? "en-GB" : "fr-FR", { dateStyle: "medium" }),
85
+ );
86
+
87
+ /**
88
+ * Ce que porte la vue, valeurs comprises : les marques une a une, puis la
89
+ * classe ou le laboratoire qui l'ont composee. Meme lecture que les templates
90
+ * juste en dessous ; un nombre de produits ne disait pas lesquels.
91
+ */
92
+ function shownCriteria(scope: any): any[] {
93
+ const criteria = parseViewCriteria(scope).filter(
94
+ (item: any) => item?.filter !== VIEW_PREFS_FILTER && viewFilledValues(item).length > 0,
95
+ );
96
+ return [
97
+ ...criteria.filter((item: any) => PRODUCT_FILTERS.includes(item?.filter)),
98
+ ...criteria.filter((item: any) => !PRODUCT_FILTERS.includes(item?.filter)),
99
+ ];
100
+ }
101
+
102
+ const savedViews = computed(() =>
103
+ store()
104
+ .scopeList.value.filter(forThisPage)
105
+ .map((scope: any) => {
106
+ const criteria = shownCriteria(scope);
107
+ const created = scope.createdAt ? new Date(scope.createdAt) : null;
108
+ return {
109
+ id: scope.id,
110
+ title: scope.title,
111
+ description: scope.description,
112
+ // Meme pastille pour toutes les vues gardees : elles n'ont pas de
113
+ // sujet declare, contrairement aux templates dont l'icone dit la
114
+ // classe traitee. Le signet reprend celle de l'etat vide au dessus, et
115
+ // le ton de marque les distingue des templates, chacun dans sa
116
+ // couleur.
117
+ icon: "BookmarkSimple",
118
+ tone: "primary",
119
+ isDefault: scope.isDefault === true,
120
+ menu: menuOf(scope),
121
+ renamable: scope.isOwner !== false,
122
+ starred: scope.starred === true,
123
+ // Les vues qu'on nous a partagees s'activent ici comme les autres : la
124
+ // carte dit seulement d'ou elles viennent, pour qu'on ne cherche pas
125
+ // qui a pose ces filtres.
126
+ sharedBy: scope.isOwner === false ? (scope.ownerName ?? undefined) : undefined,
127
+ config: criteria.length
128
+ ? [
129
+ {
130
+ icon: "Funnel",
131
+ key: `${fr.value ? "Filtres" : "Filters"} (${criteria.length})`,
132
+ pills: criteria.map((item: any) => ({
133
+ k: filterLabel(item.filter),
134
+ v: viewFilledValues(item).join(", "),
135
+ })),
136
+ },
137
+ ]
138
+ : [],
139
+ meta:
140
+ created && !Number.isNaN(created.getTime())
141
+ ? [
142
+ {
143
+ icon: "Clock",
144
+ label: (fr.value ? "Créée le " : "Created on ") + dateFormat.value.format(created),
145
+ },
146
+ ]
147
+ : [],
148
+ };
149
+ }),
150
+ );
151
+
152
+ /**
153
+ * Le « Ou » ne se dit que s'il y a bien une premiere liste au dessus : sans
154
+ * vue gardee, les templates ouvrent l'ecran et l'alternative n'existe pas.
155
+ */
156
+ const templatesLabel = computed(() => {
157
+ if (savedViews.value.length)
158
+ return fr.value ? "Ou démarrer avec une vue prête à l'emploi" : "Or start from a ready-made view";
159
+ return fr.value ? "Démarrer avec une vue prête à l'emploi" : "Start from a ready-made view";
160
+ });
161
+
162
+ /**
163
+ * Active une vue : meme chemin que le panneau du rail (copie volontaire, les
164
+ * watchers de filtres suivent la reference de `selectedScope`).
165
+ *
166
+ * Le chemin de la vue est ramene a celui de la page : une vue venue de
167
+ * l'ancienne page de selection ne se dirait pas active ici, et la pastille
168
+ * resterait muette sur la vue qu'on vient d'appliquer.
169
+ */
170
+ function apply(id: string) {
171
+ const scope = store().scopeList.value.find((item: any) => item.id === id);
172
+ if (!scope) return;
173
+ const applied = { ...scope, page: page.value };
174
+ store().setSelectedScope(applied);
175
+ views.applyPrefs(applied, source.value?.prefsScope);
176
+ source.value?.onApply?.(applied);
177
+ }
178
+ </script>
179
+
180
+ <template>
181
+ <div class="vempty">
182
+ <RmEmptyState
183
+ icon="BookmarkSimple"
184
+ tone="primary"
185
+ framed
186
+ :title="fr ? 'Aucune vue active' : 'No active view'"
187
+ :text="fr
188
+ ? 'Une vue définit les produits de l’analyse (marque, molécule, classe ATC4 ou fabricant). Créez-en une pour commencer, vous l’enregistrerez si elle vous sert.'
189
+ : 'A view defines the products the analysis covers (brand, molecule, ATC4 class or company). Create one to start; save it if it proves useful.'"
190
+ :action-label="fr ? 'Créer une vue' : 'Create a view'"
191
+ action-icon="Plus"
192
+ @action="emit('create')"
193
+ />
194
+
195
+ <section v-if="savedViews.length" class="vempty_saved">
196
+ <p class="vempty_label">
197
+ {{ fr ? "Reprendre une vue enregistrée" : "Resume a saved view" }}
198
+ </p>
199
+ <div class="vempty_grid">
200
+ <RmSavedViewCard
201
+ v-for="view in savedViews"
202
+ :key="view.id"
203
+ :view="view"
204
+ :lang="fr ? 'fr' : 'en'"
205
+ @activate="apply"
206
+ @action="onAction"
207
+ @rename="rename"
208
+ />
209
+ </div>
210
+ </section>
211
+
212
+ <section v-if="templates.length" class="vempty_saved">
213
+ <p class="vempty_label">
214
+ {{ templatesLabel }}
215
+ </p>
216
+ <div class="vempty_grid">
217
+ <RmSavedViewCard
218
+ v-for="template in templates"
219
+ :key="template.id"
220
+ :view="template"
221
+ :lang="fr ? 'fr' : 'en'"
222
+ @activate="start"
223
+ @action="onTemplateAction"
224
+ />
225
+ </div>
226
+ </section>
227
+
228
+ <RmShareDialog
229
+ v-model="sharing"
230
+ :title="shareTarget?.title ?? ''"
231
+ :members="shareMembers"
232
+ :visibility="shareTarget?.visibility ?? 'private'"
233
+ :shared-with="shareTarget?.sharedWith ?? []"
234
+ :can-template="isSuperAdmin"
235
+ :is-template="shareTarget?.isTemplate ?? false"
236
+ :target-workspaces="shareTarget?.targetWorkspaces ?? []"
237
+ :workspaces="workspaces"
238
+ :template-note="templateNote"
239
+ :lang="fr ? 'fr' : 'en'"
240
+ @search="onShareSearch"
241
+ @save="confirmShare"
242
+ />
243
+ </div>
244
+ </template>
245
+
246
+ <style scoped>
247
+ .vempty {
248
+ display: flex;
249
+ flex-direction: column;
250
+ gap: 24px;
251
+ width: 100%;
252
+ }
253
+
254
+ .vempty_saved {
255
+ display: flex;
256
+ flex-direction: column;
257
+ gap: 12px;
258
+ }
259
+
260
+ /* Mosaique : une carte de vue ne porte qu'un nom, ses criteres et une date,
261
+ une pleine largeur par vue faisait defiler l'ecran pour rien. A 280px, un
262
+ titre long partageait sa ligne avec « Demarrer » sur trois ou quatre
263
+ lignes. */
264
+ .vempty_grid {
265
+ display: grid;
266
+ grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
267
+ gap: 12px;
268
+ }
269
+
270
+ .vempty_label {
271
+ margin: 0;
272
+ color: var(--text-muted);
273
+ font-size: var(--font-11);
274
+ font-weight: var(--weight-semibold);
275
+ letter-spacing: 0.04em;
276
+ text-transform: uppercase;
277
+ }
278
+ </style>