@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,933 @@
1
+ <!--
2
+ Panneau « Mes vues » du rail.
3
+
4
+ Une vue est un jeu de filtres mis de cote pour etre rejoue. C'est ce que
5
+ l'app appelait « scope » : le mot change a l'ecran, l'API ne change pas (les
6
+ pulses), donc les vues deja enregistrees restent en place.
7
+
8
+ Le panneau ne fait que traduire : les pulses de la page courante deviennent
9
+ des `RmSavedView`, les actions du panneau retombent sur les appels existants
10
+ (creer, mettre a jour, supprimer, appliquer). Le favori et la vue par defaut
11
+ sont les marques du LECTEUR (`starred`, `isDefault`), ecrites par l'API sur
12
+ le couple (vue, lecteur) : elles valent donc aussi sur une vue qu'on a recue.
13
+
14
+ Une vue peut venir de quelqu'un d'autre. Elle est alors en lecture seule :
15
+ on l'active, on la marque, on la duplique, mais on ne la renomme pas, on ne
16
+ l'ecrase pas et on ne la supprime pas. C'est `isOwner` qui le dit, jamais la
17
+ comparaison des identifiants d'utilisateur.
18
+
19
+ Le panneau est celui de tout le hub : il ne connait pas l'app affichee. Le
20
+ magasin de scopes lui vient de la page qui a declare sa vue (`useViews`),
21
+ parce que chaque app tient le sien.
22
+ -->
23
+ <script setup lang="ts">
24
+ import { VIEW_BRAND_FILTERS } from "#oip-app/views";
25
+ import { sameViewPage } from "../../../utils/viewPages";
26
+
27
+ const { locale } = useI18n();
28
+ const route = useRoute();
29
+ const views = useViews();
30
+ const brandSync = useAlertBrandSync();
31
+ const alerts = useAlerts();
32
+
33
+ const source = computed(() => views.current.value);
34
+ const filterLabel = useViewFilterLabels(() => source.value?.filterNamespace);
35
+
36
+ /** Page cote API : les vues sont rangees par page, comme avant. */
37
+ const page = computed(() => source.value?.page ?? route.path);
38
+
39
+ /**
40
+ * Le magasin de scopes de la page affichee. Un appel par acces : ces
41
+ * composables creent leurs refs a chaque fois, donc rien ne se garde ici.
42
+ */
43
+ const store = () => views.scopes.value();
44
+
45
+ /**
46
+ * Le magasin cree ses refs a chaque appel : son `scopeLoading` ne dit rien de
47
+ * ce que ce panneau a lance. On tient donc l'etat de notre propre chargement.
48
+ */
49
+ const loading = ref(false);
50
+
51
+ // Les alertes des vues, pour le compte affiche sur chaque carte. La liste est
52
+ // deja chargee par le layout (pastille de la tuile) : pas de relecture forcee.
53
+ onMounted(() => alerts.load());
54
+
55
+ onMounted(async () => {
56
+ if (store().scopeList.value.length > 0) return;
57
+ loading.value = true;
58
+ try {
59
+ await store().getScopes();
60
+ } finally {
61
+ loading.value = false;
62
+ }
63
+ });
64
+
65
+ /** Les pulses de cette page, et de celles qui partagent ses vues (cf. viewPages). */
66
+ const scopes = computed(() =>
67
+ store().scopeList.value.filter((scope: any) => sameViewPage(scope.page, page.value)),
68
+ );
69
+
70
+ // La vue active de cette page, pas la selection de l'app : une vue = une page
71
+ // (cf. `active` dans useViews).
72
+ const selected = computed(() => views.active.value);
73
+
74
+ // Lecture des criteres et signature : les memes que celles de la pastille de
75
+ // vue, cf. useViews. Deux definitions donneraient deux verdicts sur « la vue
76
+ // est-elle a jour ».
77
+ const parseCriteria = parseViewCriteria;
78
+
79
+ /**
80
+ * Valeurs reellement posees sur un filtre.
81
+ *
82
+ * Le magasin garde des filtres vides : une periode sans borne vaut
83
+ * `[null, null]`, ce qui a bien deux entrees mais ne filtre rien. Les compter
84
+ * ferait afficher un critere vide sur la carte, et surtout ferait croire a un
85
+ * ecart entre l'ecran et la vue (l'API, elle, ne garde pas ces entrees).
86
+ */
87
+ /**
88
+ * Libelles des options d'affichage, tous ecrans confondus.
89
+ *
90
+ * Une vue garde les preferences de carte sous leur cle (`oncoIcd10`,
91
+ * `genes`...). La carte de vue montre le libelle. Les cles ne se recoupent pas
92
+ * d'un ecran a l'autre, une table unique suffit.
93
+ */
94
+ const prefLabels = computed<Record<string, string>>(() =>
95
+ Object.fromEntries(
96
+ Object.values(CARD_PREF_GROUPS)
97
+ .flat()
98
+ .flatMap((group) => group.options)
99
+ .map((option) => [
100
+ option.key,
101
+ locale.value === "fr" ? option.fr : option.en,
102
+ ]),
103
+ ),
104
+ );
105
+
106
+ function filledValues(item: any): string[] {
107
+ if (item?.filter === VIEW_PREFS_FILTER && Array.isArray(item.values)) {
108
+ return item.values.map((key: string) => prefLabels.value[key] ?? key);
109
+ }
110
+ return viewFilledValues(item);
111
+ }
112
+
113
+ /** Criteres retenus : pagination exclue, filtres vides exclus. */
114
+ function usefulCriteria(scope: any) {
115
+ return parseCriteria(scope).filter((item: any) => filledValues(item).length > 0);
116
+ }
117
+
118
+ /** Les criteres qui portent des produits (vues du portail) : cites en premier. */
119
+ const PRODUCT_FILTERS = VIEW_BRAND_FILTERS;
120
+
121
+ /**
122
+ * Le bloc « contenu » d'une carte : ses criteres, valeurs comprises, les
123
+ * produits d'abord. Meme lecture que les templates de l'onglet voisin : un
124
+ * nombre de produits ne disait pas lesquels.
125
+ */
126
+ function cardConfig(criteria: any[]) {
127
+ const filters = [
128
+ ...criteria.filter((item: any) => PRODUCT_FILTERS.includes(item?.filter)),
129
+ ...criteria.filter((item: any) => !PRODUCT_FILTERS.includes(item?.filter)),
130
+ ];
131
+ if (!filters.length) return [];
132
+ return [
133
+ {
134
+ icon: "Funnel",
135
+ key: `${locale.value === "en" ? "Filters" : "Filtres"} (${
136
+ filters.filter((item: any) => item.filter !== VIEW_PREFS_FILTER).length
137
+ })`,
138
+ pills: filters.map((item: any) => ({
139
+ k: filterLabel(item.filter),
140
+ v: filledValues(item).join(", "),
141
+ })),
142
+ },
143
+ ];
144
+ }
145
+
146
+ const dateFormat = computed(
147
+ () => new Intl.DateTimeFormat(locale.value === "en" ? "en-GB" : "fr-FR", { dateStyle: "medium" }),
148
+ );
149
+
150
+ function metaOf(scope: any) {
151
+ const meta = [
152
+ {
153
+ icon: "User",
154
+ label: scope.isOwner === false
155
+ ? (scope.ownerName ?? (locale.value === "en" ? "Shared" : "Partagée"))
156
+ : locale.value === "en"
157
+ ? "You"
158
+ : "Vous",
159
+ },
160
+ ];
161
+ // Une vue deja surveillee le dit sur sa carte : sans ce repere, le
162
+ // raccourci « Créer une alerte » proposait un doublon a l'aveugle.
163
+ const watched = alerts.alerts.value.filter(
164
+ (alert) => alert.kind === "view" && alert.pulseId === scope.id,
165
+ ).length;
166
+ if (watched) {
167
+ meta.push({
168
+ icon: "Bell",
169
+ label:
170
+ locale.value === "en"
171
+ ? `${watched} alert${watched > 1 ? "s" : ""}`
172
+ : `${watched} alerte${watched > 1 ? "s" : ""}`,
173
+ });
174
+ }
175
+ if (scope.createdAt) {
176
+ const date = new Date(scope.createdAt);
177
+ if (!Number.isNaN(date.getTime())) {
178
+ meta.push({
179
+ icon: "Clock",
180
+ label:
181
+ (locale.value === "en" ? "Created on " : "Créée le ") + dateFormat.value.format(date),
182
+ });
183
+ }
184
+ }
185
+ return meta;
186
+ }
187
+
188
+ /*
189
+ * Menu des cartes, partage compris : commun avec l'etat vide des pages.
190
+ *
191
+ * Appele avant `cardsOf` qui lit `menuOf` : un `watch` sur la liste des vues
192
+ * recues evalue sa source des le `setup`, et le bundle jetait « Cannot access
193
+ * ... before initialization » chez le destinataire d'un partage quand le menu
194
+ * etait declare plus bas. `apply` est une declaration de fonction, hissee.
195
+ */
196
+ const {
197
+ menuOf,
198
+ isShared,
199
+ onAction: onCardAction,
200
+ rename,
201
+ star,
202
+ sharing,
203
+ shareTarget,
204
+ shareMembers,
205
+ isSuperAdmin,
206
+ workspaces,
207
+ templateNote,
208
+ onShareSearch,
209
+ confirmShare,
210
+ } = useViewCardActions(apply);
211
+
212
+ function cardsOf(items: any[]) {
213
+ return items.map((scope: any) => {
214
+ const criteria = usefulCriteria(scope);
215
+ const mine = scope.isOwner !== false;
216
+ return {
217
+ id: scope.id,
218
+ title: scope.title,
219
+ description: scope.description,
220
+ active: selected.value?.id === scope.id,
221
+ starred: scope.starred === true,
222
+ isDefault: scope.isDefault === true,
223
+ // Le bandeau de la carte dit d'ou vient la vue : recue de quelqu'un, ou
224
+ // a moi mais ouverte a d'autres.
225
+ sharedBy: mine ? undefined : (scope.ownerName ?? undefined),
226
+ shared: mine && isShared(scope),
227
+ // Un template reste prive au sens du partage : sans sa propre marque,
228
+ // sa carte serait celle d'une vue que personne ne voit.
229
+ isTemplate: mine && scope.isTemplate === true,
230
+ templateTargets: scope.targetWorkspaces?.length ?? 0,
231
+ // Un menu par carte : le lecteur d'une vue recue ne peut ni la renommer,
232
+ // ni la partager, ni la supprimer.
233
+ menu: menuOf(scope),
234
+ renamable: mine,
235
+ updatedAt: scope.createdAt,
236
+ config: cardConfig(criteria),
237
+ meta: metaOf(scope),
238
+ };
239
+ });
240
+ }
241
+
242
+ /**
243
+ * Mes vues de cette page. Les templates que j'ai publies n'y sont pas : ils se
244
+ * gerent dans l'onglet « Admin templates » du groupe, et melanges ici ils
245
+ * noyaient les vues de travail de l'administrateur.
246
+ */
247
+ const list = computed(() =>
248
+ cardsOf(
249
+ scopes.value.filter((scope: any) => scope.isOwner !== false && scope.isTemplate !== true),
250
+ ),
251
+ );
252
+
253
+ /** Les vues que d'autres m'ont ouvertes, sur cette page. */
254
+ const sharedList = computed(() =>
255
+ cardsOf(scopes.value.filter((scope: any) => scope.isOwner === false)),
256
+ );
257
+
258
+ // ------------------------------------------------------- templates Tekkare
259
+
260
+ /**
261
+ * Trois onglets : mes vues, celles qu'on m'a partagees, les templates fournis.
262
+ * L'onglet des vues recues sort meme vide : c'est la seule place ou l'on va
263
+ * voir si quelqu'un a partage quelque chose, et le cacher quand il n'y a rien
264
+ * laissait croire que le partage n'existait pas.
265
+ */
266
+ const tab = ref("mine");
267
+
268
+ // Mise en carte et demarrage vivent dans `useViewTemplates` : l'etat vide des
269
+ // pages a selection de produits propose les memes templates, de la meme facon.
270
+ const { cards: templateList, start, save: saveTemplate } = useViewTemplates(() => page.value);
271
+
272
+ const tabs = computed(() => [
273
+ {
274
+ key: "mine",
275
+ label: locale.value === "en" ? "My views" : "Mes vues",
276
+ icon: "BookmarkSimple",
277
+ count: list.value.length,
278
+ },
279
+ {
280
+ key: "shared",
281
+ label: locale.value === "en" ? "Team" : "Équipe",
282
+ icon: "UsersThree",
283
+ count: sharedList.value.length,
284
+ },
285
+ {
286
+ key: "templates",
287
+ label: locale.value === "en" ? "Tekkare templates" : "Templates Tekkare",
288
+ icon: "MagicWand",
289
+ count: templateList.value.length,
290
+ },
291
+ ]);
292
+
293
+ const onTemplates = computed(() => tab.value === "templates");
294
+ const onShared = computed(() => tab.value === "shared");
295
+
296
+ const signature = viewSignature;
297
+
298
+ // Filtres a l'ecran, plus l'etat du « Personnaliser » : les deux partent dans
299
+ // la vue, et les deux comptent pour dire si la vue active est depassee.
300
+ const current = computed(() => views.criteriaOf());
301
+
302
+ /**
303
+ * La vue active ne correspond plus a l'ecran : l'utilisateur a touche aux
304
+ * filtres depuis qu'il l'a appliquee. On le lui dit plutot que de laisser
305
+ * croire que la vue porte ce qu'il voit.
306
+ *
307
+ * Une vue appartient a une page : si la vue active vient d'ailleurs (elle a
308
+ * ete appliquee sur un autre ecran, et son application a pu faire naviguer
309
+ * jusqu'ici), il n'y a rien a rattraper. Proposer de l'ecraser avec les
310
+ * filtres d'une autre page detruirait la vue de l'utilisateur.
311
+ */
312
+ const dirtyTitle = computed(() => {
313
+ const scope = selected.value;
314
+ if (!scope?.id || !sameViewPage(scope.page, page.value)) return "";
315
+ // Une vue recue ne s'ecrase pas : la banniere n'offrirait qu'un bouton
316
+ // interdit. Ce qu'on peut faire de cet ecran, c'est l'enregistrer comme
317
+ // vue a soi, ce que le bouton du panneau propose deja.
318
+ if (scope.isOwner === false) return "";
319
+ if (signature(current.value) === signature(parseCriteria(scope))) return "";
320
+ return scope.title;
321
+ });
322
+
323
+ // ---------------------------------------------------------------- sauvegarde
324
+
325
+ /** Formulaire de sauvegarde, ouvert par le bouton ou par « comme nouvelle ». */
326
+ const saving = ref(false);
327
+ const draftTitle = ref("");
328
+ const draftDescription = ref("");
329
+ const titleMissing = ref(false);
330
+
331
+ function openSave(prefill = "") {
332
+ draftTitle.value = prefill;
333
+ draftDescription.value = "";
334
+ titleMissing.value = false;
335
+ saving.value = true;
336
+ }
337
+
338
+ async function confirmSave() {
339
+ const title = draftTitle.value.trim();
340
+ if (!title) {
341
+ titleMissing.value = true;
342
+ return;
343
+ }
344
+ saving.value = false;
345
+ // Les identifiants d'avant : la creation ne rend pas la vue, elle recharge
346
+ // la liste. La chercher par titre appliquait une homonyme plus ancienne.
347
+ const before = new Set(store().scopeList.value.map((scope: any) => scope.id));
348
+ await store().createScope({
349
+ title,
350
+ description: draftDescription.value.trim(),
351
+ criteria: current.value,
352
+ currentPage: page.value,
353
+ });
354
+ // La vue tout juste creee devient la vue active : c'est ce qui est a l'ecran.
355
+ const created = store()
356
+ .scopeList.value.filter((scope: any) => sameViewPage(scope.page, page.value))
357
+ .find((scope: any) => !before.has(scope.id));
358
+ if (created) apply(created.id);
359
+ }
360
+
361
+ // ------------------------------------------------------------------- actions
362
+
363
+ function scopeOf(id: string) {
364
+ return scopes.value.find((scope: any) => scope.id === id) ?? null;
365
+ }
366
+
367
+ function apply(id: string) {
368
+ const scope = scopeOf(id);
369
+ if (!scope) return;
370
+ // Copie volontaire : les filtres se reappliquent sur un changement de
371
+ // reference de `selectedScope`. Rejouer le meme objet ne reveillerait aucun
372
+ // watcher, donc « Annuler » ne remettrait rien en place.
373
+ store().setSelectedScope({ ...scope });
374
+ views.applyPrefs(scope, source.value?.prefsScope);
375
+ source.value?.onApply?.(scope);
376
+ }
377
+
378
+
379
+ /**
380
+ * « Nouvelle vue » : meme geste que celui de la pastille. L'ecran repart
381
+ * vierge (cf. `resetAll`), puis la page ouvre son tiroir de selection quand
382
+ * elle en a un, ou la vue se compose.
383
+ */
384
+ function startNew() {
385
+ const page = source.value;
386
+ views.resetAll();
387
+ page?.onEdit?.();
388
+ }
389
+
390
+ function onAction(key: string, id: string) {
391
+ if (key === "save-template") void saveTemplate(id);
392
+ else onCardAction(key, id);
393
+ }
394
+
395
+ /** Banniere : remettre l'ecran dans l'etat de la vue active. */
396
+ function discard() {
397
+ if (selected.value?.id) apply(selected.value.id);
398
+ }
399
+
400
+ /** Banniere : ecrire l'ecran courant dans la vue active. */
401
+ async function save() {
402
+ const scope = selected.value;
403
+ if (!scope?.id) return;
404
+ await store().updateScope({
405
+ id: scope.id,
406
+ title: scope.title,
407
+ description: scope.description,
408
+ criteria: current.value,
409
+ currentPage: scope.page,
410
+ });
411
+ apply(scope.id);
412
+ // Une alerte de marque posee sur cette vue ne suit pas ses produits : elle
413
+ // porte sa propre liste. On propose le report plutot que de le faire.
414
+ brandSync.ask(scope.id, viewBrandsFromCriteria(current.value));
415
+ }
416
+
417
+ // ------------------------------------------------------- mode modification
418
+
419
+ /*
420
+ * Ouvert par « Modifier ma vue » de la pastille. Le panneau prend alors toute
421
+ * la place : nom, description, contenu. Le contenu se change par l'action de
422
+ * la page quand elle en a une (le tiroir de produits du portail, qui ecrit
423
+ * lui-meme dans la vue), sinon par les filtres de la page, repris ici.
424
+ */
425
+ const editing = useViewEditing();
426
+ const editTitle = ref("");
427
+ const editDescription = ref("");
428
+ const editTitleMissing = ref(false);
429
+ const editBusy = ref(false);
430
+ /** Ecrire aussi les filtres affiches, quand ils different de la vue. */
431
+ const editTakeScreen = ref(true);
432
+
433
+ /** La vue modifiee, lue dans la liste : elle suit le tiroir de produits. */
434
+ const editScope = computed(() => (editing.value?.id ? scopeOf(editing.value.id) : null));
435
+ const editCreating = computed(() => editing.value !== null && !editScope.value);
436
+
437
+ /** L'ecran ne correspond plus a la vue modifiee. */
438
+ const editScreenDiffers = computed(
439
+ () =>
440
+ !!editScope.value &&
441
+ selected.value?.id === editScope.value.id &&
442
+ signature(current.value) !== signature(parseCriteria(editScope.value)),
443
+ );
444
+
445
+ /** Ce que la vue emportera a l'enregistrement. */
446
+ const editCriteria = computed(() =>
447
+ !editScope.value || (editScreenDiffers.value && editTakeScreen.value)
448
+ ? current.value
449
+ : parseCriteria(editScope.value),
450
+ );
451
+ const editContent = computed(() => cardConfig(usefulCriteria({ criteria: editCriteria.value }))[0]?.pills ?? []);
452
+
453
+ // Rejoue aussi quand la vue arrive dans la liste : le panneau charge les vues
454
+ // a son montage, apres que la pastille a demande le mode.
455
+ watch(
456
+ () => [editing.value, editScope.value?.id] as const,
457
+ ([value], previous) => {
458
+ if (!value || (previous?.[0] === value && previous?.[1])) return;
459
+ const scope = value.id ? scopeOf(value.id) : null;
460
+ // Une vue recue se reprend sous son nom : la copie est a soi.
461
+ editTitle.value = scope?.title ?? (selected.value?.id ? selected.value.title : "");
462
+ editDescription.value = scope?.description ?? selected.value?.description ?? "";
463
+ editTitleMissing.value = false;
464
+ editTakeScreen.value = true;
465
+ },
466
+ { immediate: true },
467
+ );
468
+
469
+ // Fermer le rail quitte le mode : rouvert plus tard, le panneau montre ses listes.
470
+ onBeforeUnmount(() => {
471
+ editing.value = null;
472
+ });
473
+
474
+ async function confirmEdit() {
475
+ const title = editTitle.value.trim();
476
+ if (!title) {
477
+ editTitleMissing.value = true;
478
+ return;
479
+ }
480
+ const scope = editScope.value;
481
+ const criteria = editCriteria.value;
482
+ const description = editDescription.value.trim();
483
+ editBusy.value = true;
484
+ try {
485
+ if (scope) {
486
+ const contentChanged = signature(criteria) !== signature(parseCriteria(scope));
487
+ await store().updateScope({
488
+ id: scope.id,
489
+ title,
490
+ description,
491
+ criteria,
492
+ currentPage: scope.page,
493
+ refreshAlerts: contentChanged,
494
+ });
495
+ // La vue active porte une copie : la rejouer met le nouveau nom sur la
496
+ // pastille (et remet l'ecran sur la vue si on n'a pas repris ses filtres).
497
+ if (selected.value?.id === scope.id) apply(scope.id);
498
+ if (contentChanged) brandSync.ask(scope.id, viewBrandsFromCriteria(criteria));
499
+ } else {
500
+ const before = new Set(store().scopeList.value.map((item: any) => item.id));
501
+ await store().createScope({ title, description, criteria, currentPage: page.value });
502
+ const created = store()
503
+ .scopeList.value.filter((item: any) => sameViewPage(item.page, page.value))
504
+ .find((item: any) => !before.has(item.id));
505
+ if (created) apply(created.id);
506
+ }
507
+ editing.value = null;
508
+ } finally {
509
+ editBusy.value = false;
510
+ }
511
+ }
512
+
513
+ </script>
514
+
515
+ <template>
516
+ <div class="full_width">
517
+ <form v-if="editing" class="vedit" @submit.prevent="confirmEdit">
518
+ <button type="button" class="vedit_back" @click="editing = null">
519
+ <RmIcon name="ArrowLeft" :size="14" />
520
+ {{ locale === "en" ? "All views" : "Toutes les vues" }}
521
+ </button>
522
+ <p class="vedit_title">
523
+ <RmIcon name="PencilSimple" :size="18" class="vedit_title_icon" />
524
+ {{
525
+ editCreating
526
+ ? locale === "en" ? "Save the view" : "Sauvegarder la vue"
527
+ : locale === "en" ? "Edit the view" : "Modifier la vue"
528
+ }}
529
+ </p>
530
+ <p v-if="editCreating" class="vedit_hint">
531
+ {{
532
+ locale === "en"
533
+ ? "This view is not saved yet. Name it to find it again."
534
+ : "Cette vue n'est pas encore enregistrée. Nommez-la pour la retrouver."
535
+ }}
536
+ </p>
537
+
538
+ <label class="vsave_field">
539
+ <span class="vsave_label">{{ locale === "en" ? "View name" : "Nom de la vue" }}</span>
540
+ <input
541
+ v-model="editTitle"
542
+ class="vsave_input"
543
+ :class="{ vsave_input_error: editTitleMissing }"
544
+ type="text"
545
+ maxlength="60"
546
+ :placeholder="locale === 'en' ? 'e.g. Oncology monitoring' : 'ex. Veille oncologie'"
547
+ autofocus
548
+ >
549
+ <span v-if="editTitleMissing" class="vsave_error">
550
+ {{ locale === "en" ? "A name is required." : "Un nom est nécessaire." }}
551
+ </span>
552
+ </label>
553
+ <label class="vsave_field">
554
+ <span class="vsave_label">
555
+ {{ locale === "en" ? "Description (optional)" : "Description (optionnelle)" }}
556
+ </span>
557
+ <textarea v-model="editDescription" class="vsave_input" rows="3" maxlength="240" />
558
+ </label>
559
+
560
+ <div class="vsave_field">
561
+ <!-- Le geste qui change le contenu se pose sur son titre : il dit de quoi
562
+ il parle sans descendre sous la liste des filtres. -->
563
+ <div class="vedit_content_head">
564
+ <span class="vsave_label">{{ locale === "en" ? "Content" : "Contenu de la vue" }}</span>
565
+ <RmButton
566
+ v-if="source?.onEdit"
567
+ variant="primary"
568
+ size="sm"
569
+ prefix-icon="Package"
570
+ @click="source.onEdit()"
571
+ >
572
+ {{ source.editLabel ?? (locale === "en" ? "Edit the view content" : "Modifier le contenu de la vue") }}
573
+ </RmButton>
574
+ </div>
575
+ <div class="vedit_content">
576
+ <span v-for="pill in editContent" :key="pill.k" class="vedit_chip" :title="pill.v">
577
+ <span class="vedit_chip_k">{{ pill.k }}</span>
578
+ <span class="vedit_chip_v">{{ pill.v }}</span>
579
+ </span>
580
+ <span v-if="editContent.length === 0" class="vedit_empty">
581
+ {{ locale === "en" ? "No filter set" : "Aucun filtre posé" }}
582
+ </span>
583
+ </div>
584
+ <label v-if="editScreenDiffers" class="vedit_check">
585
+ <input v-model="editTakeScreen" type="checkbox">
586
+ <span>
587
+ {{
588
+ locale === "en"
589
+ ? "Save the filters shown on the page"
590
+ : "Enregistrer les filtres affichés sur la page"
591
+ }}
592
+ <span class="vedit_check_hint">
593
+ {{
594
+ locale === "en"
595
+ ? "(otherwise the page returns to the saved view)"
596
+ : "(sinon la page revient à la vue enregistrée)"
597
+ }}
598
+ </span>
599
+ </span>
600
+ </label>
601
+ <p v-if="!source?.onEdit && !editScreenDiffers" class="vedit_hint">
602
+ {{
603
+ locale === "en"
604
+ ? "To change the content, adjust the page filters: they can then be saved here."
605
+ : "Pour changer le contenu, modifiez les filtres de la page : vous pourrez ensuite les enregistrer ici."
606
+ }}
607
+ </p>
608
+ </div>
609
+
610
+ <div class="vsave_actions vedit_actions">
611
+ <RmButton variant="primary" size="sm" type="submit" prefix-icon="BookmarkSimple" :loading="editBusy">
612
+ {{ locale === "en" ? "Save" : "Enregistrer" }}
613
+ </RmButton>
614
+ <RmButton variant="secondary" size="sm" :disabled="editBusy" @click="editing = null">
615
+ {{ locale === "en" ? "Cancel" : "Annuler" }}
616
+ </RmButton>
617
+ </div>
618
+ </form>
619
+
620
+ <RmSavedViews
621
+ v-else
622
+ v-model:tab="tab"
623
+ class="full_width"
624
+ :views="onTemplates ? templateList : onShared ? sharedList : list"
625
+ :tabs="tabs"
626
+ :description="
627
+ onTemplates
628
+ ? locale === 'en'
629
+ ? 'Ready-made views built by Tekkare. Start an analysis, then adjust it and save it in your own views.'
630
+ : `Vues prêtes à l'emploi créées par Tekkare. Démarrez une analyse, vous pouvez ensuite la personnaliser et l'enregistrer dans vos vues.`
631
+ : locale === 'en'
632
+ ? 'A view captures the filters of an analysis. Save your setup to find it again instantly.'
633
+ : `Une vue capture les filtres d'une analyse. Sauvegardez votre configuration pour la retrouver instantanément.`
634
+ "
635
+ :save-label="
636
+ saving || onTemplates || onShared
637
+ ? ''
638
+ : locale === 'en'
639
+ ? 'Save the current view'
640
+ : 'Sauvegarder la vue actuelle'
641
+ "
642
+ :save-disabled="current.length === 0"
643
+ :reset-label="
644
+ saving || onTemplates || onShared ? '' : locale === 'en' ? 'New view' : 'Nouvelle vue'
645
+ "
646
+ :menu="[]"
647
+ :renamable="!onTemplates"
648
+ :starrable="!onTemplates"
649
+ :loading="onTemplates ? false : loading"
650
+ :hide-tip="onTemplates"
651
+ :dirty-title="onTemplates || onShared ? '' : dirtyTitle"
652
+ :empty-title="
653
+ onTemplates
654
+ ? locale === 'en'
655
+ ? 'No template yet'
656
+ : 'Aucun template pour cette page'
657
+ : onShared
658
+ ? locale === 'en'
659
+ ? 'No view shared with you'
660
+ : 'Aucune vue partagée avec vous'
661
+ : ''
662
+ "
663
+ :empty-text="
664
+ onTemplates
665
+ ? locale === 'en'
666
+ ? 'Tekkare has not published a ready-made view for this analysis yet.'
667
+ : `Tekkare n'a pas encore publié de vue prête à l'emploi pour cette analyse.`
668
+ : onShared
669
+ ? locale === 'en'
670
+ ? 'Views your colleagues share with you show up here.'
671
+ : 'Les vues que vos collègues vous partagent apparaissent ici.'
672
+ : ''
673
+ "
674
+ :empty-action-label="
675
+ onTemplates || onShared || current.length === 0
676
+ ? ''
677
+ : locale === 'en'
678
+ ? 'Save your first view'
679
+ : 'Sauvegarder votre première vue'
680
+ "
681
+ :lang="locale === 'en' ? 'en' : 'fr'"
682
+ @save-current="openSave()"
683
+ @reset="startNew"
684
+ @activate="onTemplates ? start($event) : apply($event)"
685
+ @star="star"
686
+ @rename="rename"
687
+ @action="onAction"
688
+ @discard="discard"
689
+ @save="save"
690
+ @save-as="openSave(selected?.title ?? '')"
691
+ >
692
+ <template #before-list>
693
+ <!-- Le formulaire prend la place du bouton, dans le panneau : nommer une
694
+ vue ne merite pas une fenetre par-dessus la page qu'on est en train
695
+ de regarder. -->
696
+ <form v-if="saving && !onTemplates" class="vsave" @submit.prevent="confirmSave">
697
+ <label class="vsave_field">
698
+ <span class="vsave_label">{{ locale === "en" ? "View name" : "Nom de la vue" }}</span>
699
+ <input
700
+ v-model="draftTitle"
701
+ class="vsave_input"
702
+ :class="{ vsave_input_error: titleMissing }"
703
+ type="text"
704
+ :placeholder="
705
+ locale === 'en' ? 'e.g. Oncology monitoring' : 'ex. Veille oncologie'
706
+ "
707
+ autofocus
708
+ >
709
+ </label>
710
+ <label class="vsave_field">
711
+ <span class="vsave_label">
712
+ {{ locale === "en" ? "Description (optional)" : "Description (optionnelle)" }}
713
+ </span>
714
+ <textarea v-model="draftDescription" class="vsave_input" rows="2" />
715
+ </label>
716
+ <p v-if="titleMissing" class="vsave_error">
717
+ {{ locale === "en" ? "A name is required." : "Un nom est nécessaire." }}
718
+ </p>
719
+ <div class="vsave_actions">
720
+ <RmButton variant="secondary" size="sm" @click="saving = false">
721
+ {{ locale === "en" ? "Cancel" : "Annuler" }}
722
+ </RmButton>
723
+ <RmButton variant="primary" size="sm" type="submit" prefix-icon="BookmarkSimple">
724
+ {{ locale === "en" ? "Save" : "Sauvegarder" }}
725
+ </RmButton>
726
+ </div>
727
+ </form>
728
+ </template>
729
+ </RmSavedViews>
730
+
731
+ <RmShareDialog
732
+ v-model="sharing"
733
+ :title="shareTarget?.title ?? ''"
734
+ :members="shareMembers"
735
+ :visibility="shareTarget?.visibility ?? 'private'"
736
+ :shared-with="shareTarget?.sharedWith ?? []"
737
+ :can-template="isSuperAdmin"
738
+ :is-template="shareTarget?.isTemplate ?? false"
739
+ :target-workspaces="shareTarget?.targetWorkspaces ?? []"
740
+ :workspaces="workspaces"
741
+ :template-note="templateNote"
742
+ :lang="locale === 'en' ? 'en' : 'fr'"
743
+ @search="onShareSearch"
744
+ @save="confirmShare"
745
+ />
746
+ </div>
747
+ </template>
748
+
749
+ <style scoped>
750
+ .full_width {
751
+ align-self: stretch;
752
+ width: 100%;
753
+ }
754
+
755
+ .vsave {
756
+ display: flex;
757
+ flex-direction: column;
758
+ gap: 10px;
759
+ padding: 14px 16px;
760
+ border: 1px solid var(--border-default);
761
+ border-radius: var(--radius-lg);
762
+ background: var(--bg-surface-2);
763
+ }
764
+
765
+ .vsave_field {
766
+ display: flex;
767
+ flex-direction: column;
768
+ gap: 4px;
769
+ }
770
+
771
+ .vsave_label {
772
+ color: var(--text-muted);
773
+ font-size: var(--font-11);
774
+ font-weight: var(--weight-semibold);
775
+ letter-spacing: 0.04em;
776
+ text-transform: uppercase;
777
+ }
778
+
779
+ .vsave_input {
780
+ padding: 8px 10px;
781
+ border: 1px solid var(--border-default);
782
+ border-radius: var(--radius-md);
783
+ background: var(--bg-surface);
784
+ color: var(--text-primary);
785
+ font-family: var(--font-sans);
786
+ font-size: var(--font-13);
787
+ outline: none;
788
+ resize: vertical;
789
+ }
790
+
791
+ .vsave_input:focus {
792
+ border-color: var(--color-secondary);
793
+ box-shadow: 0 0 0 3px rgba(var(--brand-rgb), 0.1);
794
+ }
795
+
796
+ .vsave_input_error,
797
+ .vsave_input_error:focus {
798
+ border-color: var(--color-danger);
799
+ }
800
+
801
+ .vsave_error {
802
+ margin: 0;
803
+ color: var(--color-danger);
804
+ font-size: var(--font-12);
805
+ }
806
+
807
+ .vsave_actions {
808
+ display: flex;
809
+ justify-content: flex-end;
810
+ gap: 8px;
811
+ }
812
+
813
+ .vedit {
814
+ display: flex;
815
+ flex-direction: column;
816
+ gap: 16px;
817
+ }
818
+
819
+ .vedit_back {
820
+ align-self: flex-start;
821
+ display: inline-flex;
822
+ align-items: center;
823
+ gap: 6px;
824
+ padding: 0;
825
+ border: 0;
826
+ background: none;
827
+ color: var(--text-muted);
828
+ font-family: var(--font-sans);
829
+ font-size: var(--font-12);
830
+ cursor: pointer;
831
+ }
832
+
833
+ .vedit_back:hover {
834
+ color: var(--color-secondary);
835
+ }
836
+
837
+ .vedit_title {
838
+ display: flex;
839
+ align-items: center;
840
+ gap: 8px;
841
+ margin: 0;
842
+ color: var(--text-primary);
843
+ font-size: var(--font-16);
844
+ font-weight: var(--weight-semibold);
845
+ }
846
+
847
+ .vedit_title_icon {
848
+ color: var(--color-secondary);
849
+ }
850
+
851
+ .vedit_hint {
852
+ margin: 0;
853
+ color: var(--text-muted);
854
+ font-size: var(--font-12);
855
+ }
856
+
857
+ .vedit_content {
858
+ display: flex;
859
+ flex-wrap: wrap;
860
+ gap: 6px;
861
+ padding: 10px 12px;
862
+ border: 1px solid var(--border-soft);
863
+ border-radius: var(--radius-md);
864
+ background: var(--bg-surface-2);
865
+ }
866
+
867
+ .vedit_chip {
868
+ display: inline-flex;
869
+ gap: 6px;
870
+ max-width: 100%;
871
+ padding: 3px 8px;
872
+ border: 1px solid var(--border-default);
873
+ border-radius: var(--radius-pill);
874
+ background: var(--bg-surface);
875
+ font-size: var(--font-12);
876
+ }
877
+
878
+ .vedit_chip_k {
879
+ flex-shrink: 0;
880
+ color: var(--text-muted);
881
+ }
882
+
883
+ .vedit_chip_v {
884
+ overflow: hidden;
885
+ color: var(--text-primary);
886
+ font-weight: var(--weight-semibold);
887
+ text-overflow: ellipsis;
888
+ white-space: nowrap;
889
+ }
890
+
891
+ .vedit_empty {
892
+ color: var(--text-muted);
893
+ font-size: var(--font-12);
894
+ font-style: italic;
895
+ }
896
+
897
+ .vedit_check {
898
+ display: flex;
899
+ align-items: flex-start;
900
+ gap: 8px;
901
+ color: var(--text-secondary);
902
+ font-size: var(--font-13);
903
+ cursor: pointer;
904
+ }
905
+
906
+ .vedit_check input {
907
+ margin-top: 2px;
908
+ accent-color: var(--color-secondary);
909
+ }
910
+
911
+ .vedit_check_hint {
912
+ color: var(--text-muted);
913
+ }
914
+
915
+ .vedit_content_head {
916
+ display: flex;
917
+ align-items: center;
918
+ gap: 12px;
919
+ }
920
+
921
+ /* Le formulaire se lit de haut en bas, a gauche : ses actions aussi, bien
922
+ detachees du contenu. Collees au bas du panneau quand il defile : elles ne
923
+ sortent jamais du champ. */
924
+ .vedit_actions {
925
+ position: sticky;
926
+ bottom: 0;
927
+ z-index: 1;
928
+ justify-content: flex-start;
929
+ margin-top: 24px;
930
+ padding: 12px 0;
931
+ background: var(--bg-surface);
932
+ }
933
+ </style>