@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,447 @@
1
+ <!--
2
+ Le volet « Statistiques » du rail : l'audience PostHog de l'écran ouvert et
3
+ de toute l'app, aujourd'hui et sur 7, 30 et 90 jours.
4
+
5
+ Personnes, sessions et vues de page, puis les pages les plus vues et les
6
+ utilisateurs les plus actifs sur la période choisie (dix par dix). Les comptes @tekkare.com sont écartés par défaut :
7
+ sans eux, l'équipe qui recette l'app gonflerait chaque chiffre.
8
+
9
+ Réservé aux superadmins, comme les métadonnées : la tuile n'est posée par le
10
+ cadre que pour eux, et la route serveur revalide le droit.
11
+ -->
12
+ <template>
13
+ <div class="toolbar_tab_container stats_panel">
14
+ <div class="stats_controls">
15
+ <div class="stats_switches">
16
+ <RmSegmented
17
+ v-model="scope"
18
+ :options="scopeOptions"
19
+ size="sm"
20
+ variant="soft"
21
+ :aria-label="isFr ? 'Périmètre' : 'Scope'"
22
+ />
23
+ <RmSegmented
24
+ v-model="period"
25
+ :options="periodOptions"
26
+ size="sm"
27
+ variant="soft"
28
+ class="stats_periods"
29
+ :aria-label="isFr ? 'Période' : 'Period'"
30
+ />
31
+ </div>
32
+ <label class="stats_internal">
33
+ <RmToggle v-model="internal" :label="isFr ? 'Inclure Tekkare' : 'Include Tekkare'" />
34
+ <span>{{ isFr ? "Inclure les comptes Tekkare" : "Include Tekkare accounts" }}</span>
35
+ </label>
36
+ </div>
37
+
38
+ <div v-if="pending && !data" class="stats_loading">
39
+ <RmSkeleton v-for="i in 3" :key="i" width="100%" :height="88" radius="8px" />
40
+ </div>
41
+
42
+ <p v-else-if="error" class="stats_error">
43
+ {{ isFr ? "Statistiques illisibles : " : "Could not read statistics: " }}{{ errorMessage }}
44
+ </p>
45
+
46
+ <template v-else-if="data">
47
+ <section class="stats_section">
48
+ <p v-if="scope === 'page'" class="stats_section_title">
49
+ {{ isFr ? "Cette page" : "This page" }}
50
+ <span v-if="labelOf(data.path) !== data.path" class="stats_path_label">{{ labelOf(data.path) }}</span>
51
+ <code class="stats_path">{{ data.path }}</code>
52
+ </p>
53
+ <p v-else class="stats_section_title">{{ isFr ? "Toute l'app" : "Whole app" }}</p>
54
+ <div class="stats_kpis">
55
+ <RmKpi v-for="k in kpis(data[scope][period])" :key="k.label" :label="k.label" :value="k.value" />
56
+ </div>
57
+ </section>
58
+
59
+ <section class="stats_section">
60
+ <p class="stats_section_title">{{ isFr ? "Utilisateurs les plus actifs" : "Most active users" }}</p>
61
+ <p v-if="usersError" class="stats_error">{{ usersError }}</p>
62
+ <p v-else-if="!usersLoading && !users.length" class="stats_empty">
63
+ {{ isFr ? "Aucun utilisateur sur la période." : "No users in this period." }}
64
+ </p>
65
+ <table v-if="users.length" class="stats_table">
66
+ <thead>
67
+ <tr>
68
+ <th class="stats_rank">#</th>
69
+ <th>{{ isFr ? "Utilisateur" : "User" }}</th>
70
+ <th>Workspace</th>
71
+ <th class="stats_num">{{ isFr ? "Pages vues" : "Page views" }}</th>
72
+ <th class="stats_num">{{ isFr ? "Visites" : "Visits" }}</th>
73
+ <th class="stats_num">
74
+ <RmTooltip :text="screensHint" dir="bottom" max-width="300px" full>
75
+ <span class="stats_hint">{{ isFr ? "Écrans" : "Screens" }}</span>
76
+ </RmTooltip>
77
+ </th>
78
+ <th class="stats_num">{{ isFr ? "Dernière vue" : "Last seen" }}</th>
79
+ </tr>
80
+ </thead>
81
+ <tbody>
82
+ <tr v-for="(u, i) in users" :key="u.email || i">
83
+ <td class="stats_rank">{{ i + 1 }}</td>
84
+ <td>
85
+ <div class="stats_user">
86
+ <span v-if="u.name" class="stats_user_name">{{ u.name }}</span>
87
+ <span class="stats_user_email">{{ u.email || (isFr ? "anonyme" : "anonymous") }}</span>
88
+ </div>
89
+ </td>
90
+ <td class="stats_workspace">{{ u.workspace }}</td>
91
+ <td class="stats_num">{{ rmNumber(u.pageviews) }}</td>
92
+ <td class="stats_num">{{ rmNumber(u.sessions) }}</td>
93
+ <td class="stats_num">{{ rmNumber(u.pages) }}</td>
94
+ <td class="stats_num">{{ seen(u.lastSeen) }}</td>
95
+ </tr>
96
+ </tbody>
97
+ </table>
98
+ <div v-if="usersLoading" class="stats_loading">
99
+ <RmSkeleton v-for="i in 3" :key="i" width="100%" :height="28" radius="6px" />
100
+ </div>
101
+ <RmButton
102
+ v-else-if="usersHasMore"
103
+ variant="secondary"
104
+ size="sm"
105
+ class="stats_more"
106
+ @click="loadUsers()"
107
+ >
108
+ {{ isFr ? "Charger plus" : "Load more" }}
109
+ </RmButton>
110
+ </section>
111
+
112
+ <!-- Le classement des pages n'a de sens qu'a l'echelle de l'app. -->
113
+ <section v-if="scope === 'app'" class="stats_section">
114
+ <p class="stats_section_title">{{ isFr ? "Pages les plus vues" : "Most viewed pages" }}</p>
115
+ <p v-if="!topPages.length" class="stats_empty">
116
+ {{ isFr ? "Aucune vue sur la période." : "No views in this period." }}
117
+ </p>
118
+ <table v-else class="stats_table">
119
+ <thead>
120
+ <tr>
121
+ <th>{{ isFr ? "Page" : "Page" }}</th>
122
+ <th class="stats_num">{{ isFr ? "Pages vues" : "Page views" }}</th>
123
+ <th class="stats_num">{{ isFr ? "Utilisateurs" : "Users" }}</th>
124
+ </tr>
125
+ </thead>
126
+ <tbody>
127
+ <tr v-for="p in topPages" :key="p.path" :class="{ 'stats_row--current': p.path === data.path }">
128
+ <td>
129
+ <NuxtLink :to="p.path" class="stats_page">
130
+ <span class="stats_page_label">{{ labelOf(p.path) }}</span>
131
+ <span v-if="labelOf(p.path) !== p.path" class="stats_page_path">{{ p.path }}</span>
132
+ </NuxtLink>
133
+ </td>
134
+ <td class="stats_num">{{ rmNumber(p.pageviews[period]) }}</td>
135
+ <td class="stats_num">{{ rmNumber(p.users[period]) }}</td>
136
+ </tr>
137
+ </tbody>
138
+ </table>
139
+ </section>
140
+
141
+ <p class="stats_note">
142
+ {{ isFr ? "Source : PostHog, production" : "Source: PostHog, production" }} ({{ data.hosts.join(", ") }})
143
+ </p>
144
+ </template>
145
+ </div>
146
+ </template>
147
+
148
+ <script setup lang="ts">
149
+ import { rmNumber } from "@tekkare/romulus/utils/format";
150
+ import type { OipStats, OipStatsMetrics, OipStatsPeriod, OipStatsUser, OipStatsUsers } from "../../types/stats";
151
+
152
+ const props = defineProps<{
153
+ // Passees par OipShell a tous les panneaux ; les chiffres sont ceux de l'app.
154
+ appKey?: string;
155
+ superAdmin?: boolean;
156
+ /**
157
+ * Titre lisible d'un chemin (celui de la navigation), donne par l'app dans
158
+ * `panelProps.Statistics` : les lecteurs du panneau ne lisent pas les URL.
159
+ * Absent ou sans reponse, le chemin s'affiche tel quel.
160
+ */
161
+ pageLabel?: (path: string) => string | undefined;
162
+ }>();
163
+
164
+ const labelOf = (path: string) => props.pageLabel?.(path) || path;
165
+
166
+ const { locale } = useI18n();
167
+ const route = useRoute();
168
+ const isFr = computed(() => locale.value !== "en");
169
+
170
+ const period = ref<OipStatsPeriod>("30d");
171
+ const internal = ref(false);
172
+
173
+ const periodOptions = computed(() => [
174
+ { value: "today", label: isFr.value ? "Aujourd'hui" : "Today" },
175
+ { value: "7d", label: isFr.value ? "7 jours" : "7 days" },
176
+ { value: "30d", label: isFr.value ? "30 jours" : "30 days" },
177
+ { value: "90d", label: isFr.value ? "90 jours" : "90 days" },
178
+ ]);
179
+
180
+ // Une requete rend les quatre periodes : changer de periode ne refetch pas.
181
+ const panelConfig = useAppConfig().oip.panels.Statistics;
182
+ const { data, pending, error } = useFetch<OipStats>(panelConfig.endpoint, {
183
+ key: "oip-rail-statistics",
184
+ query: computed(() => ({ path: route.path, internal: internal.value ? "1" : "0" })),
185
+ });
186
+
187
+ const errorMessage = computed(
188
+ () => (error.value as any)?.data?.message || error.value?.message || ""
189
+ );
190
+
191
+ function kpis(m: OipStatsMetrics) {
192
+ return [
193
+ { label: isFr.value ? "Utilisateurs" : "Users", value: rmNumber(m.users) },
194
+ { label: isFr.value ? "Visites" : "Visits", value: rmNumber(m.sessions) },
195
+ { label: isFr.value ? "Pages vues" : "Page views", value: rmNumber(m.pageviews) },
196
+ ];
197
+ }
198
+
199
+ // Perimetre de tout le panneau : l'ecran ouvert (par defaut) ou toute l'app.
200
+ const scope = ref<"app" | "page">("page");
201
+
202
+ // Classement des utilisateurs : dix par dix, remis a zero quand le
203
+ // perimetre change (periode, ecran, comptes Tekkare).
204
+ const USERS_PAGE = 10;
205
+ const scopeOptions = computed(() => [
206
+ { value: "page", label: isFr.value ? "Cette page" : "This page" },
207
+ { value: "app", label: isFr.value ? "Toute l'app" : "Whole app" },
208
+ ]);
209
+ const users = ref<OipStatsUser[]>([]);
210
+ const usersHasMore = ref(false);
211
+ const usersLoading = ref(false);
212
+ const usersError = ref("");
213
+ let usersRequest = 0;
214
+
215
+ async function loadUsers(reset = false) {
216
+ const request = ++usersRequest;
217
+ if (reset) users.value = [];
218
+ usersLoading.value = true;
219
+ usersError.value = "";
220
+ try {
221
+ // Deduite de `endpoint` quand l'app ne la donne pas : une config d'app
222
+ // (ou un cache) d'avant cette cle enverrait sinon `$fetch(undefined)`.
223
+ const usersEndpoint = panelConfig.usersEndpoint ?? `${panelConfig.endpoint}/users`;
224
+ const res = await $fetch<OipStatsUsers>(usersEndpoint, {
225
+ query: {
226
+ period: period.value,
227
+ scope: scope.value,
228
+ path: route.path,
229
+ internal: internal.value ? "1" : "0",
230
+ offset: users.value.length,
231
+ limit: USERS_PAGE,
232
+ },
233
+ });
234
+ if (request !== usersRequest) return;
235
+ // Une reponse sans liste (page HTML d'un serveur qui ne connait pas la
236
+ // route, par exemple) : on montre ce qui est arrive plutot que planter.
237
+ if (!Array.isArray(res?.users)) {
238
+ throw new Error(`${isFr.value ? "Réponse inattendue" : "Unexpected response"} : ${String(typeof res === "string" ? res : JSON.stringify(res)).slice(0, 160)}`);
239
+ }
240
+ users.value = [...users.value, ...res.users];
241
+ usersHasMore.value = res.hasMore;
242
+ } catch (err: any) {
243
+ if (request !== usersRequest) return;
244
+ usersError.value = err?.data?.message || err?.message || "";
245
+ } finally {
246
+ if (request === usersRequest) usersLoading.value = false;
247
+ }
248
+ }
249
+ watch([period, scope, internal, () => route.path], () => loadUsers(true), { immediate: true });
250
+
251
+ const screensHint = computed(() =>
252
+ isFr.value
253
+ ? "Nombre de pages différentes ouvertes sur la période, langue et paramètres d'URL ignorés. 310 pages vues sur 22 écrans : 310 affichages répartis sur 22 pages. Chaque fiche (une marque, un produit) compte pour un écran."
254
+ : "Number of distinct pages opened in the period, ignoring language and URL parameters. 310 page views over 22 screens: 310 displays spread across 22 pages. Each detail page (a brand, a product) counts as one screen."
255
+ );
256
+
257
+ const seen = (iso: string) =>
258
+ iso ? new Date(iso).toLocaleDateString(isFr.value ? "fr-FR" : "en-GB", { day: "2-digit", month: "short" }) : "";
259
+
260
+ const topPages = computed(() =>
261
+ (data.value?.topPages ?? [])
262
+ .filter((p) => p.pageviews[period.value] > 0)
263
+ .sort((a, b) => b.pageviews[period.value] - a.pageviews[period.value])
264
+ .slice(0, 10)
265
+ );
266
+ </script>
267
+
268
+ <style scoped>
269
+ .stats_panel {
270
+ align-self: stretch;
271
+ display: flex;
272
+ flex-direction: column;
273
+ gap: 20px;
274
+ width: 100%;
275
+ }
276
+
277
+ .stats_controls {
278
+ display: flex;
279
+ flex-direction: column;
280
+ gap: 10px;
281
+ }
282
+
283
+ .stats_internal {
284
+ align-items: center;
285
+ color: var(--text-secondary);
286
+ cursor: pointer;
287
+ display: flex;
288
+ font-size: 13px;
289
+ gap: 8px;
290
+ }
291
+
292
+ .stats_loading {
293
+ display: flex;
294
+ flex-direction: column;
295
+ gap: 12px;
296
+ width: 100%;
297
+ }
298
+
299
+ .stats_section {
300
+ display: flex;
301
+ flex-direction: column;
302
+ gap: 10px;
303
+ width: 100%;
304
+ }
305
+
306
+ .stats_section_title {
307
+ align-items: center;
308
+ color: var(--text-secondary);
309
+ display: flex;
310
+ flex-wrap: wrap;
311
+ font-size: 12px;
312
+ font-weight: 600;
313
+ gap: 8px;
314
+ letter-spacing: 0.04em;
315
+ margin: 0;
316
+ text-transform: uppercase;
317
+ }
318
+
319
+ .stats_path {
320
+ font-weight: 400;
321
+ letter-spacing: 0;
322
+ text-transform: none;
323
+ word-break: break-all;
324
+ }
325
+
326
+ .stats_switches {
327
+ align-items: center;
328
+ display: flex;
329
+ flex-wrap: wrap;
330
+ gap: 10px;
331
+ }
332
+
333
+ .stats_periods {
334
+ flex: 1;
335
+ min-width: 280px;
336
+ }
337
+
338
+ .stats_rank {
339
+ color: var(--text-secondary);
340
+ width: 28px;
341
+ }
342
+
343
+ .stats_user {
344
+ display: flex;
345
+ flex-direction: column;
346
+ min-width: 0;
347
+ overflow-wrap: anywhere;
348
+ }
349
+
350
+ .stats_user_name {
351
+ color: var(--text-primary);
352
+ font-weight: 600;
353
+ }
354
+
355
+ .stats_user_email {
356
+ color: var(--text-secondary);
357
+ font-size: 12px;
358
+ }
359
+
360
+ .stats_workspace {
361
+ overflow-wrap: anywhere;
362
+ }
363
+
364
+ .stats_hint {
365
+ cursor: help;
366
+ text-decoration: underline dotted;
367
+ text-underline-offset: 3px;
368
+ }
369
+
370
+ .stats_more {
371
+ align-self: center;
372
+ }
373
+
374
+ .stats_kpis {
375
+ display: grid;
376
+ gap: 10px;
377
+ grid-template-columns: repeat(3, minmax(0, 1fr));
378
+ }
379
+
380
+ .stats_table {
381
+ border-collapse: collapse;
382
+ font-size: 13px;
383
+ width: 100%;
384
+ }
385
+
386
+ .stats_table th {
387
+ border-bottom: 1px solid var(--border-default);
388
+ color: var(--text-secondary);
389
+ font-weight: 600;
390
+ padding: 6px 4px;
391
+ text-align: left;
392
+ }
393
+
394
+ .stats_table td {
395
+ border-bottom: 1px solid var(--border-default);
396
+ padding: 6px 4px;
397
+ vertical-align: middle;
398
+ }
399
+
400
+ .stats_num {
401
+ text-align: right !important;
402
+ white-space: nowrap;
403
+ }
404
+
405
+ .stats_page {
406
+ color: var(--text-primary);
407
+ display: flex;
408
+ flex-direction: column;
409
+ overflow-wrap: anywhere;
410
+ text-decoration: none;
411
+ }
412
+
413
+ .stats_page:hover .stats_page_label {
414
+ color: var(--color-primary);
415
+ text-decoration: underline;
416
+ }
417
+
418
+ .stats_page_path {
419
+ color: var(--text-secondary);
420
+ font-size: 12px;
421
+ }
422
+
423
+ .stats_path_label {
424
+ color: var(--text-primary);
425
+ letter-spacing: 0;
426
+ text-transform: none;
427
+ }
428
+
429
+ .stats_row--current td {
430
+ background: var(--bg-surface-hover, var(--bg-surface));
431
+ font-weight: 600;
432
+ }
433
+
434
+ .stats_empty,
435
+ .stats_note {
436
+ color: var(--text-secondary);
437
+ font-size: 12px;
438
+ font-style: italic;
439
+ margin: 0;
440
+ }
441
+
442
+ .stats_error {
443
+ color: var(--color-danger, #d64545);
444
+ font-size: 13px;
445
+ margin: 0;
446
+ }
447
+ </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
+ };
@@ -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
+ }