@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,550 @@
1
+ <!--
2
+ Pastille de vue du cadre, posee dans la barre de filtres de la page.
3
+
4
+ Elle ne sort que si la page a declare sa vue (cf. useViews) : ailleurs, il n'y
5
+ aurait rien a nommer. Elle vit dans le cadre et non dans les pages parce que
6
+ toutes les pages la portent : la barre de filtres reserve un emplacement, on
7
+ y teleporte la pastille.
8
+
9
+ Elle donne les gestes courants sans ouvrir le rail : rattraper des
10
+ modifications, basculer sur une autre vue de la page, et « Modifier ma vue »,
11
+ qui ouvre le panneau « Mes vues » en mode modification, et « Nouvelle vue »,
12
+ qui repart d'un ecran vierge. Le panneau garde la
13
+ gestion complete, ce qui evite deux ecrans qui font la meme chose.
14
+ -->
15
+ <template>
16
+ <Teleport v-if="mounted && has" to="#rm_fbar_views" defer>
17
+ <RmViewPill
18
+ :view-name="viewName"
19
+ :views="views"
20
+ :views-label="viewsLabel"
21
+ :dirty="dirty"
22
+ :temporary="!namedActive?.id"
23
+ :actions="actions"
24
+ :lang="locale === 'en' ? 'en' : 'fr'"
25
+ @select="apply"
26
+ @save="saveCurrent"
27
+ @action="run"
28
+ />
29
+ </Teleport>
30
+
31
+ <!-- Nommer une vue est une decision : elle s'arrete sur une modale, avec le
32
+ recapitulatif de ce qu'on enregistre. Le panneau du rail garde son
33
+ formulaire en place, pour la gestion. -->
34
+ <RmModal
35
+ v-model="saving"
36
+ size="md"
37
+ mode="free"
38
+ labelled-by="oip_save_view_title"
39
+ :close-label="fr ? 'Fermer' : 'Close'"
40
+ >
41
+ <template #header>
42
+ <p id="oip_save_view_title" class="sv_title">
43
+ <RmIcon name="BookmarkSimple" :size="20" class="sv_title_icon" />
44
+ {{ fr ? "Sauvegarder la vue" : "Save the view" }}
45
+ </p>
46
+ </template>
47
+
48
+ <form class="sv_form" @submit.prevent="confirmSave">
49
+ <label class="sv_field">
50
+ <span class="sv_label">{{ fr ? "Nom de la vue" : "View name" }}</span>
51
+ <input
52
+ ref="nameInput"
53
+ v-model="draftTitle"
54
+ class="sv_input"
55
+ :class="{ sv_input_error: titleMissing }"
56
+ type="text"
57
+ maxlength="60"
58
+ :placeholder="fr ? 'Ex. Suivi mensuel oncologie' : 'e.g. Monthly oncology monitoring'"
59
+ >
60
+ <span v-if="titleMissing" class="sv_error">
61
+ {{ fr ? "Un nom est nécessaire." : "A name is required." }}
62
+ </span>
63
+ <span v-else class="sv_hint">
64
+ {{
65
+ fr
66
+ ? "Choisissez un nom descriptif pour la retrouver facilement."
67
+ : "Pick a descriptive name so you can find it again."
68
+ }}
69
+ </span>
70
+ </label>
71
+
72
+ <label class="sv_field">
73
+ <span class="sv_label">
74
+ {{ fr ? "Description" : "Description" }}
75
+ <span class="sv_optional">{{ fr ? "(optionnel)" : "(optional)" }}</span>
76
+ </span>
77
+ <textarea
78
+ v-model="draftDescription"
79
+ class="sv_input"
80
+ rows="3"
81
+ maxlength="240"
82
+ :placeholder="fr ? 'Pourquoi cette vue ? Quels filtres ?' : 'Why this view? Which filters?'"
83
+ />
84
+ </label>
85
+
86
+ <!-- Ce que la vue emporte, nomme : sans ce rappel, on enregistre a
87
+ l'aveugle et on decouvre le contenu en la rejouant. -->
88
+ <div class="sv_recap">
89
+ <span class="sv_recap_label">{{ fr ? "Configuration capturée :" : "Captured setup:" }}</span>
90
+ <span v-for="chip in recap" :key="chip.k" class="sv_chip">
91
+ <span class="sv_chip_k">{{ chip.k }}</span>
92
+ <span class="sv_chip_v">{{ chip.v }}</span>
93
+ </span>
94
+ <span v-if="recap.length === 0" class="sv_recap_empty">
95
+ {{ fr ? "aucun filtre posé" : "no filter set" }}
96
+ </span>
97
+ </div>
98
+
99
+ <label class="sv_check">
100
+ <input v-model="draftDefault" type="checkbox">
101
+ <span>
102
+ <RmIcon name="PushPin" weight="fill" :size="14" class="sv_check_icon" />
103
+ {{ fr ? "Définir comme vue par défaut" : "Set as default view" }}
104
+ <span class="sv_check_hint">
105
+ {{ fr ? "(s'activera à chaque ouverture)" : "(applied on every visit)" }}
106
+ </span>
107
+ </span>
108
+ </label>
109
+ </form>
110
+
111
+ <template #footer>
112
+ <div class="sv_actions">
113
+ <RmButton variant="secondary" :disabled="savingBusy" @click="saving = false">
114
+ {{ fr ? "Annuler" : "Cancel" }}
115
+ </RmButton>
116
+ <RmButton
117
+ variant="primary"
118
+ prefix-icon="BookmarkSimple"
119
+ :loading="savingBusy"
120
+ @click="confirmSave"
121
+ >
122
+ {{ fr ? "Sauvegarder" : "Save" }}
123
+ </RmButton>
124
+ </div>
125
+ </template>
126
+ </RmModal>
127
+ </template>
128
+
129
+ <script setup lang="ts">
130
+ import { sameViewPage } from "../../utils/viewPages";
131
+ const emit = defineEmits<{
132
+ /** Ouvrir la tuile « Mes vues » du rail. */
133
+ openViews: [];
134
+ }>();
135
+
136
+ const { locale } = useI18n();
137
+ const route = useRoute();
138
+ const viewsApi = useViews();
139
+ const brandSync = useAlertBrandSync();
140
+ const fr = computed(() => locale.value === "fr");
141
+
142
+ /**
143
+ * L'emplacement de la pastille est un noeud de la barre de filtres, donc du
144
+ * rendu de la page : on ne peut y teleporter qu'apres le premier montage, et
145
+ * seulement quand la page en a un.
146
+ */
147
+ const mounted = ref(false);
148
+ const hasTarget = ref(false);
149
+
150
+ function findTarget() {
151
+ hasTarget.value =
152
+ typeof document !== "undefined" && document.getElementById("rm_fbar_views") !== null;
153
+ }
154
+
155
+ /**
156
+ * La barre de filtres ne parait qu'avec la donnee de la page : son emplacement
157
+ * n'existe pas encore au montage du cadre. On le cherche a l'arrivee, puis on
158
+ * suit le contenu de la page plutot que de deviner un delai.
159
+ */
160
+ let observer: MutationObserver | null = null;
161
+
162
+ onMounted(() => {
163
+ mounted.value = true;
164
+ nextTick(findTarget);
165
+ if (typeof MutationObserver === "undefined") return;
166
+ observer = new MutationObserver(findTarget);
167
+ observer.observe(document.body, { childList: true, subtree: true });
168
+ });
169
+
170
+ onBeforeUnmount(() => {
171
+ observer?.disconnect();
172
+ observer = null;
173
+ });
174
+
175
+ watch(() => route.fullPath, () => nextTick(findTarget));
176
+
177
+ const source = computed(() => viewsApi.current.value);
178
+ const has = computed(() => viewsApi.has.value && hasTarget.value);
179
+ const store = () => viewsApi.scopes.value();
180
+
181
+ // La vue active de cette page, pas la selection de l'app : une vue = une page
182
+ // (cf. `active` dans useViews).
183
+ const active = computed(() => viewsApi.active.value);
184
+ const dirty = computed(() => viewsApi.dirty.value);
185
+ const templates = useViewTemplates(() => source.value?.page ?? route.path);
186
+
187
+ /** Les vues de cette page et des pages qui partagent son périmètre. */
188
+ const pageViews = computed(() =>
189
+ store()
190
+ .scopeList.value.filter((scope: any) => sameViewPage(scope.page, source.value?.page ?? route.path)),
191
+ );
192
+
193
+ /**
194
+ * Quand une navigation recrée le scope sélectionné sans son id, retrouver la
195
+ * vue sauvegardée par ses critères permet de conserver son nom dans la pill.
196
+ */
197
+ const namedActive = computed(() => {
198
+ const scope = active.value;
199
+ if (!scope) return null;
200
+ if (!isTemporaryView(scope)) return scope;
201
+
202
+ const signature = viewSignature(parseViewCriteria(scope));
203
+ if (!signature) return null;
204
+ return (
205
+ pageViews.value.find(
206
+ (saved: any) => viewSignature(parseViewCriteria(saved)) === signature,
207
+ ) ?? null
208
+ );
209
+ });
210
+
211
+ /**
212
+ * Nom affiche. Une vue qui n'est pas enregistree n'a pas de nom a porter (cf.
213
+ * `isTemporaryView`) : elle se dit temporaire plutot que de laisser la
214
+ * pastille vide ou de montrer le marqueur « Custom » de l'API.
215
+ */
216
+ const viewName = computed(() => {
217
+ const scope = active.value;
218
+ if (!scope) return null;
219
+ if (namedActive.value?.title && namedActive.value.title !== "Custom") {
220
+ return String(namedActive.value.title);
221
+ }
222
+ // Une vue temporaire venue d'un template Tekkare porte son nom : « Vue
223
+ // temporaire » ne disait pas d'ou sortait la selection affichee.
224
+ const templateTitle = scope.template ? templates.titleOf(scope.template) : null;
225
+ if (templateTitle) {
226
+ return fr.value ? `Template Tekkare : ${templateTitle}` : `Tekkare template: ${templateTitle}`;
227
+ }
228
+ return fr.value ? "Vue temporaire" : "Temporary view";
229
+ });
230
+
231
+ /**
232
+ * Les autres vues de la page, favorites d'abord : la pastille sert a basculer.
233
+ * La vue affichee n'y figure pas, son nom est deja sur la pastille.
234
+ */
235
+ const views = computed(() =>
236
+ pageViews.value
237
+ .filter((scope: any) => scope.id !== namedActive.value?.id)
238
+ .sort((a: any, b: any) => Number(b.starred === true) - Number(a.starred === true))
239
+ .slice(0, 10)
240
+ .map((scope: any) => ({ id: String(scope.id), title: String(scope.title) })),
241
+ );
242
+ const viewsLabel = computed(() => (fr.value ? "Autres vues" : "Other views"));
243
+
244
+ /**
245
+ * Deux gestes sous la liste. « Nouvelle vue » repart d'un ecran vierge. Tout
246
+ * ce qu'on fait de la vue affichee (renommer, decrire, changer son contenu) se
247
+ * fait dans le mode modification du panneau « Mes vues ». Une vue temporaire
248
+ * ou recue y ouvre une creation.
249
+ */
250
+ const actions = computed(() => [
251
+ {
252
+ key: "new",
253
+ label: fr.value ? "Nouvelle vue" : "New view",
254
+ icon: "Plus",
255
+ },
256
+ {
257
+ key: "edit",
258
+ label: fr.value ? "Modifier ma vue" : "Edit my view",
259
+ icon: "PencilSimple",
260
+ },
261
+ ]);
262
+
263
+ function apply(id: string) {
264
+ const scope = pageViews.value.find((item: any) => String(item.id) === id);
265
+ if (!scope) return;
266
+ // Copie volontaire : les filtres se reappliquent sur un changement de
267
+ // reference de `selectedScope`, cf. le panneau du rail.
268
+ store().setSelectedScope({ ...scope });
269
+ viewsApi.applyPrefs(scope, source.value?.prefsScope);
270
+ source.value?.onApply?.(scope);
271
+ }
272
+
273
+ /**
274
+ * Ecrit l'ecran courant dans la vue active.
275
+ *
276
+ * Sans vue active il n'y a rien a ecrire, mais il y a bien quelque chose a
277
+ * enregistrer : la pastille pulse desormais aussi dans ce cas (cf. `dirty`
278
+ * dans useViews), et son bouton ouvrait alors une action sans effet. Il ouvre
279
+ * la modale de creation, qui est le geste que la situation demande.
280
+ */
281
+ async function saveCurrent() {
282
+ const scope = active.value;
283
+ // Une vue partagee par quelqu'un d'autre est en lecture seule : ecrire
284
+ // l'ecran dedans reecrirait la vue d'un collegue. Le geste devient donc une
285
+ // creation, comme quand aucune vue n'est active.
286
+ if (!scope?.id || scope.isOwner === false) {
287
+ openSave();
288
+ return;
289
+ }
290
+ const criteria = viewsApi.criteriaOf();
291
+ await store().updateScope({
292
+ id: scope.id,
293
+ title: scope.title,
294
+ description: scope.description,
295
+ criteria,
296
+ currentPage: scope.page,
297
+ });
298
+ apply(String(scope.id));
299
+ // Meme demande que depuis le panneau « Mes vues » : l'alerte de marque de
300
+ // cette vue ne suit pas ses produits toute seule.
301
+ brandSync.ask(String(scope.id), viewBrandsFromCriteria(criteria));
302
+ }
303
+
304
+ const editing = useViewEditing();
305
+
306
+ function run(key: string) {
307
+ if (key === "new") {
308
+ startNew();
309
+ return;
310
+ }
311
+ if (key !== "edit") return;
312
+ const scope = namedActive.value;
313
+ editing.value = { id: scope?.id && scope.isOwner !== false ? String(scope.id) : null };
314
+ emit("openViews");
315
+ }
316
+
317
+ /**
318
+ * Repart d'un ecran vierge : plus de vue, filtres et perimetre a leur defaut
319
+ * (cf. `resetAll`). Sur une page a selection de produits, la vue se compose
320
+ * dans son tiroir : on l'ouvre, sinon l'ecran reste vide sans dire quoi faire.
321
+ */
322
+ function startNew() {
323
+ const page = source.value;
324
+ viewsApi.resetAll();
325
+ page?.onEdit?.();
326
+ }
327
+
328
+ // ------------------------------------------------------ nouvelle vue (modale)
329
+
330
+ const saving = ref(false);
331
+ const savingBusy = ref(false);
332
+ const draftTitle = ref("");
333
+ const draftDescription = ref("");
334
+ const draftDefault = ref(false);
335
+ const titleMissing = ref(false);
336
+ const nameInput = ref<HTMLInputElement | null>(null);
337
+
338
+ /** Nom propose : date du jour, deja selectionne pour etre remplace d'un mot. */
339
+ const suggestedTitle = computed(() => {
340
+ const date = new Intl.DateTimeFormat(fr.value ? "fr-FR" : "en-GB", {
341
+ day: "numeric",
342
+ month: "short",
343
+ }).format(new Date());
344
+ return fr.value ? `Vue du ${date}` : `View of ${date}`;
345
+ });
346
+
347
+ const filterLabel = useViewFilterLabels(() => source.value?.filterNamespace);
348
+
349
+ /**
350
+ * Recapitulatif de ce que la vue emporte : un jeton par filtre pose, avec le
351
+ * nombre de valeurs. Les filtres vides n'y figurent pas, comme ils ne partent
352
+ * pas dans la vue.
353
+ */
354
+ const recap = computed(() =>
355
+ viewsApi
356
+ .criteriaOf()
357
+ .map((item: any) => ({ k: filterLabel(item.filter), v: viewFilledValues(item).length }))
358
+ .filter((chip: any) => chip.v > 0)
359
+ .map((chip: any) => ({ k: chip.k, v: String(chip.v) })),
360
+ );
361
+
362
+ function openSave() {
363
+ draftTitle.value = suggestedTitle.value;
364
+ draftDescription.value = "";
365
+ draftDefault.value = false;
366
+ titleMissing.value = false;
367
+ saving.value = true;
368
+ nextTick(() => nameInput.value?.select());
369
+ }
370
+
371
+ async function confirmSave() {
372
+ const title = draftTitle.value.trim();
373
+ if (!title) {
374
+ titleMissing.value = true;
375
+ return;
376
+ }
377
+ const page = source.value?.page ?? route.path;
378
+ savingBusy.value = true;
379
+ // Les identifiants d'avant : la creation ne rend pas la vue, elle recharge
380
+ // la liste. La chercher par titre appliquait une homonyme plus ancienne.
381
+ const before = new Set(store().scopeList.value.map((scope: any) => scope.id));
382
+ try {
383
+ await store().createScope({
384
+ title,
385
+ description: draftDescription.value.trim(),
386
+ criteria: viewsApi.criteriaOf(),
387
+ currentPage: page,
388
+ });
389
+ } finally {
390
+ savingBusy.value = false;
391
+ }
392
+
393
+ // La vue tout juste creee devient la vue active : c'est ce qui est a l'ecran.
394
+ const created = store()
395
+ .scopeList.value.filter((scope: any) => sameViewPage(scope.page, page))
396
+ .find((scope: any) => !before.has(scope.id));
397
+
398
+ saving.value = false;
399
+ if (!created) return;
400
+ if (draftDefault.value) await store().setDefault(created.id, true);
401
+ apply(String(created.id));
402
+ }
403
+ </script>
404
+
405
+ <style scoped>
406
+ .sv_title {
407
+ display: flex;
408
+ align-items: center;
409
+ gap: 8px;
410
+ margin: 0;
411
+ font-size: 18px;
412
+ font-weight: 700;
413
+ color: var(--text-primary);
414
+ }
415
+
416
+ .sv_title_icon {
417
+ color: var(--color-secondary);
418
+ }
419
+
420
+ .sv_form {
421
+ display: flex;
422
+ flex-direction: column;
423
+ gap: 16px;
424
+ }
425
+
426
+ .sv_field {
427
+ display: flex;
428
+ flex-direction: column;
429
+ gap: 6px;
430
+ }
431
+
432
+ .sv_label {
433
+ font-size: 13px;
434
+ font-weight: 600;
435
+ color: var(--text-secondary);
436
+ }
437
+
438
+ .sv_optional {
439
+ font-weight: 400;
440
+ color: var(--text-muted);
441
+ }
442
+
443
+ .sv_input {
444
+ box-sizing: border-box;
445
+ width: 100%;
446
+ padding: 9px 12px;
447
+ border: 1px solid var(--border-default);
448
+ border-radius: 6px;
449
+ background: var(--bg-surface);
450
+ color: var(--text-primary);
451
+ font-family: var(--font-sans);
452
+ font-size: 14px;
453
+ resize: vertical;
454
+ }
455
+
456
+ .sv_input:focus {
457
+ outline: none;
458
+ border-color: var(--color-healthcare);
459
+ box-shadow: 0 0 0 3px var(--bg-hover);
460
+ }
461
+
462
+ .sv_input_error {
463
+ border-color: var(--color-danger);
464
+ }
465
+
466
+ .sv_hint,
467
+ .sv_error {
468
+ font-size: 12px;
469
+ color: var(--text-muted);
470
+ }
471
+
472
+ .sv_error {
473
+ color: var(--color-danger);
474
+ }
475
+
476
+ /* Recapitulatif : des jetons en lecture seule, pas des filtres cliquables. */
477
+ .sv_recap {
478
+ display: flex;
479
+ flex-wrap: wrap;
480
+ align-items: center;
481
+ gap: 8px;
482
+ padding: 10px 12px;
483
+ border: 1px solid var(--border-soft);
484
+ border-radius: 8px;
485
+ background: var(--bg-surface-2);
486
+ font-size: 12px;
487
+ }
488
+
489
+ .sv_recap_label {
490
+ color: var(--text-muted);
491
+ }
492
+
493
+ .sv_recap_empty {
494
+ color: var(--text-muted);
495
+ font-style: italic;
496
+ }
497
+
498
+ .sv_chip {
499
+ display: inline-flex;
500
+ align-items: center;
501
+ gap: 6px;
502
+ padding: 4px 8px;
503
+ border: 1px solid var(--border-default);
504
+ border-radius: var(--radius-pill);
505
+ background: var(--bg-surface);
506
+ }
507
+
508
+ .sv_chip_k {
509
+ color: var(--text-muted);
510
+ }
511
+
512
+ .sv_chip_v {
513
+ font-weight: 600;
514
+ color: var(--color-secondary);
515
+ }
516
+
517
+ .sv_check {
518
+ display: flex;
519
+ align-items: center;
520
+ gap: 10px;
521
+ padding: 10px 12px;
522
+ border-radius: 8px;
523
+ background: var(--bg-hover);
524
+ font-size: 13px;
525
+ color: var(--text-secondary);
526
+ cursor: pointer;
527
+ }
528
+
529
+ .sv_check input {
530
+ width: 14px;
531
+ height: 14px;
532
+ accent-color: var(--color-secondary);
533
+ cursor: pointer;
534
+ }
535
+
536
+ .sv_check_icon {
537
+ color: var(--color-secondary);
538
+ vertical-align: -2px;
539
+ }
540
+
541
+ .sv_check_hint {
542
+ color: var(--text-muted);
543
+ }
544
+
545
+ .sv_actions {
546
+ display: flex;
547
+ justify-content: flex-end;
548
+ gap: 8px;
549
+ }
550
+ </style>
@@ -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
+ };