@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,345 @@
1
+ /**
2
+ * useAlerts - les alertes posees sur les vues sauvegardees.
3
+ *
4
+ * Une alerte = une vue + ce qu'on y surveille. Elle vit cote API (worker
5
+ * quotidien, journal d'evenements) ; ici on ne fait que lire la liste, la
6
+ * creer depuis la page et marquer les evenements comme lus.
7
+ *
8
+ * Le point delicat est la requete : c'est la page qui la fournit, via
9
+ * `useAlertSource`. Une alerte ne peut donc etre creee que depuis la page
10
+ * qu'elle surveille, et une vue temporaire (posee par un lien profond, jamais
11
+ * enregistree) n'en accepte pas : il n'y a rien a quoi l'attacher.
12
+ */
13
+ import { useOipApi, type Alert, type AlertEvent, type AlertTrigger } from "#oip-app/api";
14
+ import { sameViewPage } from "../utils/viewPages";
15
+
16
+ export function useAlerts() {
17
+ const elysiaApi = useOipApi();
18
+ const source = useAlertSource();
19
+ const views = useViews();
20
+
21
+ const alerts = useState<Alert[]>("alerts:list", () => []);
22
+ /**
23
+ * Le mode d'essai est ouvert cote API : cadence de cinq minutes et passage
24
+ * manuel. Faux en production, et l'ecran n'en montre alors aucune trace.
25
+ */
26
+ const testCadence = useState<boolean>("alerts:testCadence", () => false);
27
+ const loaded = useState<boolean>("alerts:loaded", () => false);
28
+ const loading = useState<boolean>("alerts:loading", () => false);
29
+ /**
30
+ * Formulaire de creation ouvert.
31
+ *
32
+ * L'etat est partage plutot que local au panneau parce que la visite guidee
33
+ * des alertes doit pouvoir ouvrir le formulaire : ses etapes expliquent le
34
+ * parametrage, elles n'auraient rien a designer devant un panneau ferme. Le
35
+ * panneau le remet a faux en se demontant (cf. son composant), donc rouvrir
36
+ * la tuile ne rouvre pas la modale d'hier.
37
+ */
38
+ const creating = useState<boolean>("alerts:creating", () => false);
39
+
40
+ const load = async (force = false) => {
41
+ if (loading.value) return;
42
+ if (loaded.value && !force) return;
43
+ loading.value = true;
44
+ try {
45
+ const response = await elysiaApi.alerts.list();
46
+ alerts.value = response?.alerts ?? [];
47
+ testCadence.value = !!response?.testCadence;
48
+ loaded.value = true;
49
+ } finally {
50
+ loading.value = false;
51
+ }
52
+ };
53
+
54
+ /**
55
+ * Suit une alerte jusqu'a ce que sa reference soit posee.
56
+ *
57
+ * La capture prend quelques secondes cote worker. Sans ce suivi, la carte
58
+ * reste sur « en préparation » indefiniment alors que le passage a peut-etre
59
+ * deja echoue — et le message d'echec est justement ce qu'il faut lire.
60
+ */
61
+ const followSeeding = async (attempts = 6, delayMs = 2500) => {
62
+ for (let i = 0; i < attempts; i++) {
63
+ if (!alerts.value.some((alert) => alert.status === "seeding")) return;
64
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
65
+ await load(true);
66
+ }
67
+ };
68
+
69
+ const router = useRouter();
70
+
71
+ /**
72
+ * L'alerte a ete posee depuis cette app.
73
+ *
74
+ * L'API rend les alertes de tout l'espace de travail, toutes apps
75
+ * confondues : sans ce tri, le panneau de France Drugs montrait celles de
76
+ * France Epidemio. L'hote de la vue surveillee dit d'ou elle vient. Une
77
+ * alerte sans URL (projet) n'appartient a aucune app : elle reste.
78
+ *
79
+ * En local, l'hote ne dit rien (les vues portent celui du staging) : on
80
+ * demande alors au routeur si la page existe ici.
81
+ */
82
+ const fromThisApp = (alert: Alert): boolean => {
83
+ if (!alert.url || !import.meta.client) return true;
84
+ let url: URL;
85
+ try {
86
+ url = new URL(alert.url);
87
+ } catch {
88
+ return true;
89
+ }
90
+ if (url.host === window.location.host) return true;
91
+ if (!/^(localhost|127\.0\.0\.1)$|\.localhost$/.test(window.location.hostname)) return false;
92
+ return router.resolve(url.pathname).matched.length > 0;
93
+ };
94
+
95
+ /**
96
+ * Pastille de la tuile du rail : ce que l'utilisateur n'a pas encore lu,
97
+ * dans cette app. Les autres apps ont leur propre pastille.
98
+ */
99
+ const unreadTotal = computed(() =>
100
+ alerts.value
101
+ .filter(fromThisApp)
102
+ .reduce((total, alert) => total + alert.unreadCount, 0),
103
+ );
104
+
105
+ /** Les alertes de la page affichee, celles que son panneau montre en premier. */
106
+ const forCurrentPage = computed(() => {
107
+ const page = views.current.value?.page;
108
+ if (!page) return [];
109
+ return alerts.value.filter((alert) => samePage(alert.page, page));
110
+ });
111
+
112
+ /**
113
+ * Vue active, quand elle existe en base.
114
+ *
115
+ * Une vue temporaire n'a pas d'identifiant (cf. `isTemporaryView`) : elle
116
+ * n'existe pas en base, une alerte ne peut pas s'y accrocher telle quelle.
117
+ */
118
+ const watchableView = computed(() => {
119
+ const active = views.active.value;
120
+ return active && !isTemporaryView(active) ? active : null;
121
+ });
122
+
123
+ /** Les vues enregistrees de la page affichee. */
124
+ const pageViews = computed(() => {
125
+ const page = views.current.value?.page;
126
+ if (!page) return [];
127
+ return views.scopes
128
+ .value()
129
+ .scopeList.value.filter((scope: any) => sameViewPage(scope.page, page));
130
+ });
131
+
132
+ /**
133
+ * Une alerte peut naitre des que la page sait livrer sa requete et qu'il y
134
+ * a de quoi la rattacher : une vue deja enregistree, ou des filtres a
135
+ * l'ecran dont on fera une vue.
136
+ *
137
+ * Ne pas exiger une vue ACTIVE : c'est la modale qui choisit, et exiger
138
+ * l'application prealable obligeait a passer par un autre panneau avant de
139
+ * pouvoir cliquer.
140
+ *
141
+ * Sans source declaree, il reste l'alerte de MARQUE : elle ne surveille
142
+ * aucune page, donc aucune requete d'ecran ne lui manque, et depuis que
143
+ * l'API accepte une alerte sans pulse, aucune vue non plus. C'est ce qui
144
+ * ouvre les alertes aux pages de ventes (Medic'AM, delivrances
145
+ * hospitalieres, statistiques), dont les ecrans sont des agregats, et aux
146
+ * pages qui nomment un produit sans rien offrir a filtrer : la fiche
147
+ * produit, l'ecran de recherche.
148
+ */
149
+ const canCreate = computed(() => {
150
+ if (source.brands.value.length > 0) return true;
151
+ // Sans requete, une alerte de marque suffit : les produits de la vue
152
+ // affichee, enregistree ou non, en font une. Exiger une vue en base
153
+ // grisait le bouton sur tout le portail des qu'aucune vue n'existait sur
154
+ // ce domaine, alors que la selection etait bien a l'ecran.
155
+ if (!source.current.value) {
156
+ return (
157
+ pageViews.value.length > 0 ||
158
+ viewBrandsFromCriteria(views.current.value?.criteria?.() ?? []).length > 0
159
+ );
160
+ }
161
+ return (
162
+ pageViews.value.length > 0 ||
163
+ (views.current.value?.criteria?.().length ?? 0) > 0
164
+ );
165
+ });
166
+
167
+ const create = async (payload: {
168
+ title: string;
169
+ /** `product` : des marques, sur tout le hub. Defaut : une vue. */
170
+ kind?: "view" | "product";
171
+ brands?: string[];
172
+ /** Vue a surveiller. Absent : la vue active. */
173
+ viewId?: string | null;
174
+ triggers: AlertTrigger[];
175
+ watchedFields: string[];
176
+ channels?: string[];
177
+ cadence?: string;
178
+ visibility?: "private" | "workspace";
179
+ }) => {
180
+ const declared = source.current.value;
181
+ const product = payload.kind === "product";
182
+ // La vue nommee par la modale l'emporte : elle vient d'etre appliquee a
183
+ // l'ecran, donc c'est bien elle que la requete decrit. Pas de repli sur
184
+ // la premiere vue de la page : quand la vue active est temporaire,
185
+ // l'alerte s'accrocherait a une vue qui n'a rien a voir avec l'ecran.
186
+ //
187
+ // Une alerte de produit, elle, ne surveille aucune vue : elle nomme des
188
+ // marques, couvre tous les index du hub et ne tient d'aucune page. Elle
189
+ // garde pourtant l'identifiant de la vue active quand il y en a une : c'est
190
+ // par lui que `useAlertBrandSync` la retrouve pour proposer de suivre les
191
+ // marques de la vue. Il ne sert qu'a ce lien, jamais a l'affichage, qui se
192
+ // decide sur `kind`.
193
+ const pulseId = product
194
+ ? (watchableView.value?.id ?? null)
195
+ : (payload.viewId ?? watchableView.value?.id ?? null);
196
+ if (!product && (!pulseId || !declared)) return null;
197
+
198
+ const alert = await elysiaApi.alerts.create({
199
+ pulseId: pulseId ?? undefined,
200
+ kind: payload.kind,
201
+ brands: payload.brands,
202
+ title: payload.title,
203
+ // Une alerte de produit n'a ni index ni requete : le moteur les engendre
204
+ // pour les sept index du hub.
205
+ index: product ? undefined : declared!.index,
206
+ // Relue maintenant : c'est l'ecran tel qu'il est, pas tel qu'il etait a
207
+ // l'ouverture du panneau.
208
+ query: product ? undefined : declared!.query(),
209
+ resourceType: product ? undefined : declared!.resourceType,
210
+ // L'API nomme l'app par son registre quand elle connait l'hote ; ce
211
+ // nom-ci sert de repli, pour que le digest ne dise pas « OIP » a un
212
+ // client tant que l'app n'y est pas inscrite.
213
+ appName: String(useRuntimeConfig().public.app_name ?? "") || undefined,
214
+ watchedFields: payload.watchedFields,
215
+ // Les libelles partent avec l'alerte : seule l'app les traduit, et le
216
+ // digest ne peut pas ecrire `last_price_update` dans la boite d'un
217
+ // client.
218
+ fieldLabels: Object.fromEntries(
219
+ (declared?.fields ?? [])
220
+ .filter(
221
+ (field) =>
222
+ // « * » suit le document entier : tous les libelles servent.
223
+ payload.watchedFields.includes("*") ||
224
+ payload.watchedFields.includes(field.value),
225
+ )
226
+ .map((field) => [field.value, field.label]),
227
+ ),
228
+ labelFields: product ? ["label", "brand"] : declared!.labelFields,
229
+ triggers: payload.triggers,
230
+ channels: payload.channels,
231
+ cadence: payload.cadence,
232
+ visibility: payload.visibility,
233
+ });
234
+
235
+ if (alert) {
236
+ alerts.value = [alert, ...alerts.value];
237
+ // Pas d'attente : la modale se ferme, la carte se met a jour derriere.
238
+ followSeeding();
239
+ }
240
+ return alert;
241
+ };
242
+
243
+ const update = async (
244
+ id: string,
245
+ payload: Parameters<typeof elysiaApi.alerts.update>[1],
246
+ ) => {
247
+ const updated = await elysiaApi.alerts.update(id, payload);
248
+ if (updated) {
249
+ alerts.value = alerts.value.map((alert) =>
250
+ alert.id === id ? { ...alert, ...updated } : alert,
251
+ );
252
+ }
253
+ return updated;
254
+ };
255
+
256
+ const remove = async (id: string) => {
257
+ await elysiaApi.alerts.delete(id);
258
+ alerts.value = alerts.value.filter((alert) => alert.id !== id);
259
+ };
260
+
261
+ /**
262
+ * Repointe une alerte perimee sur la requete courante de la page.
263
+ *
264
+ * Ne marche que depuis la page surveillee, seule a savoir construire la
265
+ * requete. L'alerte repart d'une nouvelle reference : l'ancienne repondait a
266
+ * une autre question.
267
+ */
268
+ const rearm = async (id: string) => {
269
+ const declared = source.current.value;
270
+ if (!declared) return null;
271
+ const result = await elysiaApi.alerts.rearm(id, {
272
+ query: declared.query(),
273
+ index: declared.index,
274
+ });
275
+ await load(true);
276
+ return result;
277
+ };
278
+
279
+ /** Rejoue une alerte et relit la liste : le resultat se voit tout de suite. */
280
+ const runNow = async (id: string) => {
281
+ const result = await elysiaApi.alerts.run(id);
282
+ await load(true);
283
+ return result;
284
+ };
285
+
286
+ /** Envoie son propre digest tout de suite. */
287
+ const simulate = async (
288
+ id: string,
289
+ payload: { kind?: "appeared" | "changed" | "disappeared"; count?: number; sources?: number } = {},
290
+ ) => {
291
+ const result = await elysiaApi.alerts.simulate(id, payload);
292
+ await load(true);
293
+ return result;
294
+ };
295
+
296
+ const sendDigestNow = async () => elysiaApi.alerts.runDigest();
297
+
298
+ const events = async (id: string, limit = 50): Promise<AlertEvent[]> => {
299
+ const response = await elysiaApi.alerts.events(id, limit);
300
+ return response?.events ?? [];
301
+ };
302
+
303
+ const markRead = async (payload: { alertId?: string; eventIds?: string[] }) => {
304
+ const result = await elysiaApi.alerts.markRead(payload);
305
+ if (payload.alertId) {
306
+ alerts.value = alerts.value.map((alert) =>
307
+ alert.id === payload.alertId ? { ...alert, unreadCount: 0 } : alert,
308
+ );
309
+ }
310
+ return result;
311
+ };
312
+
313
+ return {
314
+ alerts,
315
+ loading,
316
+ creating,
317
+ load,
318
+ followSeeding,
319
+ unreadTotal,
320
+ fromThisApp,
321
+ forCurrentPage,
322
+ watchableView,
323
+ pageViews,
324
+ canCreate,
325
+ create,
326
+ update,
327
+ remove,
328
+ rearm,
329
+ events,
330
+ markRead,
331
+ testCadence,
332
+ runNow,
333
+ simulate,
334
+ sendDigestNow,
335
+ };
336
+ }
337
+
338
+ /**
339
+ * Deux chemins qui designent la meme page. Meme raison que dans `useViews` :
340
+ * `trailingSlash` est actif et les vues d'un lien profond portent le chemin
341
+ * sans barre finale.
342
+ */
343
+ function samePage(a: unknown, b: unknown): boolean {
344
+ return String(a ?? "").replace(/\/$/, "") === String(b ?? "").replace(/\/$/, "");
345
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * useExportSource - ce qu'une page donne a exporter.
3
+ *
4
+ * Le panneau d'export (`RmExportPanel`, via `LayoutToolExport`) vit dans le
5
+ * cadre, pas dans la page : il ne peut pas savoir ce que l'ecran affiche. Une
6
+ * page declare donc sa source ici, et le cadre s'adapte — la tuile Export ne
7
+ * sort que sur les pages qui en ont une, et les formats proposes dependent de
8
+ * ce que la source contient (pas de graphique declare, pas de PNG).
9
+ *
10
+ * Le registre est un `shallowRef` de module, rempli au montage et vide au
11
+ * demontage : une page qui quitte l'ecran emporte sa source, et rien ne
12
+ * traverse le rendu serveur (les descripteurs portent des fonctions, qui ne
13
+ * survivraient pas a la serialisation du payload Nuxt).
14
+ */
15
+ import { useExportDirectusSources } from "#oip-app/export";
16
+ import type {
17
+ RmExportCard,
18
+ RmExportFilter,
19
+ RmExportFormat,
20
+ RmExportKpi,
21
+ RmExportRange,
22
+ RmExportSheet,
23
+ RmExportSource as RmSource,
24
+ RmExportTemplate,
25
+ } from "@tekkare/romulus/utils/export";
26
+
27
+ export interface ExportSource {
28
+ /** Titre du document exporte. */
29
+ title: string;
30
+ subtitle?: string;
31
+ /** Nom de fichier propose, sans extension (cf. rmExportFileName). */
32
+ fileName: string;
33
+ /** Onglets du classeur, dans la forme deja utilisee par les exports XLSX. */
34
+ sheets: RmExportSheet[];
35
+ kpi?: RmExportKpi[];
36
+ sources?: RmSource[];
37
+ filters?: RmExportFilter[];
38
+ /** Plages proposees a l'etape « Donnees ». Vide : pas de choix de plage. */
39
+ ranges?: RmExportRange[];
40
+ /**
41
+ * Plages qui partent avec TOUTES les colonnes : le choix des colonnes
42
+ * disparait du panneau et chaque onglet garde son schema entier.
43
+ *
44
+ * « Toute la donnee » d'une fiche produit est une demande d'archive, pas de
45
+ * mise en forme : faire cocher des colonnes d'un onglet a l'autre n'a pas de
46
+ * sens quand le classeur en porte quinze de natures differentes.
47
+ */
48
+ fullRanges?: string[];
49
+ /** Catalogue de formats, si la page en restreint la liste. */
50
+ formats?: RmExportFormat[];
51
+ /**
52
+ * Modeles de depart propres a la page, poses a la place de la tuile Excel
53
+ * generique du catalogue.
54
+ *
55
+ * Une fiche produit porte dix onglets de natures differentes : « Data brute
56
+ * Excel » ne dit pas lequel part dans le fichier. La page nomme donc ses
57
+ * propres depart (« toute la donnee de KEYTRUDA », « KEYTRUDA HAS ») en
58
+ * preselectionnant la plage qui va avec. Les autres tuiles (rapport PDF,
59
+ * lien partageable) restent celles du cadre : leur libelle ne depend pas de
60
+ * l'ecran.
61
+ */
62
+ templates?: RmExportTemplate[];
63
+ /**
64
+ * Graphiques exportables. Retourne des noeuds du DOM : les charts sont
65
+ * rendus en SVG, on les recupere tels quels plutot qu'en capture d'ecran.
66
+ */
67
+ charts?: () => { title?: string; el: Element }[];
68
+ /**
69
+ * Forme de la page, pour les formats mis en page (le rapport PDF).
70
+ *
71
+ * `cards` : une liste de resultats, une fiche par ligne, comme les pages de
72
+ * recherche du hub les empilent. `table` : un ecran d'analyse, ou la lecture
73
+ * est le tableau lui-meme et ou une fiche par ligne n'aurait aucun sens.
74
+ * Sans valeur, la forme est deduite du rendu (`visibleExportLayout`) : une
75
+ * liste de fiches donne `cards`, un ecran de graphiques et de tableaux donne
76
+ * `table`.
77
+ */
78
+ layout?: "cards" | "table";
79
+ /**
80
+ * Ce que l'ecran montre, relu au moment de l'export.
81
+ *
82
+ * Les formats mis en page (PDF) doivent ressembler a la page : ses tableaux,
83
+ * ses fiches, ses chiffres cles, avec les titres qu'elle affiche. `sheets`,
84
+ * lui, reste la donnee complete, celle du classeur : dix mille lignes dans un
85
+ * rapport ne se lisent pas.
86
+ *
87
+ * Sans ce point d'entree, le panneau relit l'ecran lui-meme
88
+ * (`visibleExportScreen`) : une page n'a rien a declarer pour que son rapport
89
+ * lui ressemble, elle n'a a le faire que si le rendu ne suffit pas.
90
+ */
91
+ screen?: () => { sheets?: RmExportSheet[]; kpi?: RmExportKpi[]; cards?: RmExportCard[] };
92
+ /** Plafond de lignes par onglet. */
93
+ limit?: number;
94
+ /**
95
+ * Motif pour lequel la page refuse d'exporter sa donnee en l'etat : la
96
+ * selection couvre trop d'entites. Le cadre l'affiche a l'ouverture du
97
+ * panneau et grise les formats qui emportent des enregistrements ; les
98
+ * formats sans enregistrement (image d'un graphique, lien partageable)
99
+ * restent proposes. Refuser plutot que rogner : la moitie d'un perimetre se
100
+ * lit comme un resultat complet.
101
+ */
102
+ blocked?: string;
103
+ /** URL a partager pour le format « Permalink ». Defaut : l'URL courante. */
104
+ permalink?: () => string;
105
+ /**
106
+ * Appele a l'ouverture du panneau.
107
+ *
108
+ * Les pages du portail ne gardent pas leurs lignes d'export en memoire :
109
+ * c'est une requete plafonnee a 10 000 lignes, la charger a l'affichage de
110
+ * la page serait la payer sans que personne l'ait demandee. Sans ce point
111
+ * d'entree, le panneau s'ouvrirait sur des onglets vides et son etape
112
+ * « Colonnes » n'aurait rien a proposer avant le clic sur Exporter.
113
+ */
114
+ prepare?: () => void | Promise<void>;
115
+ /**
116
+ * Appele des que la plage change a l'etape « Donnees », avant l'export.
117
+ *
118
+ * A implementer quand la plage change les COLONNES : l'etape suivante lit
119
+ * les onglets de la page pour lister ce qu'on peut decocher, et une colonne
120
+ * qui n'existe que dans l'autre plage n'y figurerait pas. Le rechargement
121
+ * des lignes, lui, reste dans `beforeExport` : personne n'a a payer une
122
+ * requete pour avoir clique sur un bouton radio.
123
+ */
124
+ onRange?: (range: string) => void | Promise<void>;
125
+ /**
126
+ * Appele avant de generer, avec la plage retenue : c'est la que la page
127
+ * recharge ses donnees quand l'utilisateur demande toute la base plutot que
128
+ * la page courante.
129
+ */
130
+ beforeExport?: (range: string) => void | Promise<void>;
131
+ /** Nom de l'export dans PostHog. Defaut : le nom de fichier. */
132
+ posthogName?: string;
133
+ }
134
+
135
+ /**
136
+ * Une seule source a la fois : c'est la page visible qui la porte. Le
137
+ * descripteur peut rendre `null` — une fiche produit declare sa source avant
138
+ * que la fiche soit chargee, et n'a rien a exporter tant qu'elle est vide.
139
+ */
140
+ const registry = shallowRef<(() => ExportSource | null) | null>(null);
141
+
142
+ /**
143
+ * Sources de la page affichee, lues dans Directus : le meme referentiel que le
144
+ * panneau « Sources » du rail, avec sa `latest_download_date`. Posees par le
145
+ * cadre (layout), elles valent pour le panneau d'export comme pour le menu
146
+ * de chaque carte et le bouton d'export inline (`provideRmExportDefaults`).
147
+ */
148
+ const pageSources = shallowRef<RmSource[]>([]);
149
+
150
+ /**
151
+ * Un export demande depuis une autre tuile (le raccourci « Exporter en Excel »
152
+ * d'une carte de vue), par son format. Le panneau d'export le lit a son
153
+ * montage, le lance avec sa recette par defaut et l'efface : la tuile Export
154
+ * est la seule a savoir generer, et elle n'est montee que quand elle est
155
+ * ouverte.
156
+ */
157
+ const quick = useState<string | null>("export:quick", () => null);
158
+
159
+ /**
160
+ * Declare les sources de la page a partir des uuids que le cadre suit deja
161
+ * pour son panneau « Sources ». A appeler dans le `setup` du layout.
162
+ */
163
+ export function providePageExportSources(uuids: Ref<string[]>) {
164
+ const { locale } = useI18n();
165
+ const { load, exportSources } = useExportDirectusSources();
166
+ const route = useRoute();
167
+ const permalink = usePermalink();
168
+
169
+ watch(uuids, (list) => list.length && load(list), { immediate: true });
170
+ watchEffect(() => {
171
+ pageSources.value = exportSources(uuids.value, locale.value);
172
+ });
173
+
174
+ // Le lien que copie le menu d'une carte : le permalien de l'ecran, vue et
175
+ // filtres compris, le meme que la tuile « Lien partageable ». L'URL courante
176
+ // ne disait rien de ce qui etait affiche, et « lien direct » promettait plus.
177
+ // Recalcule quand la page ou ses criteres bougent : `build` les lit avant
178
+ // sa premiere attente, `route.fullPath` suit la navigation.
179
+ const pageLink = shallowRef<string | undefined>(undefined);
180
+ watchEffect(async () => {
181
+ void route.fullPath;
182
+ if (!import.meta.client) return;
183
+ try {
184
+ pageLink.value = await permalink.build();
185
+ } catch {
186
+ pageLink.value = undefined;
187
+ }
188
+ });
189
+
190
+ // Les sources sont un etat de module : sans nettoyage, celles du dernier
191
+ // cadre monte restaient en place quand on changeait de portail, et la page
192
+ // de garde d'un export PHMEV citait les sources du hub.
193
+ onScopeDispose(() => {
194
+ pageSources.value = [];
195
+ });
196
+
197
+ provideRmExportDefaults(() => ({ sources: pageSources.value, permalink: pageLink.value }));
198
+ }
199
+
200
+ export function useExportSource() {
201
+ /**
202
+ * Declare la source de la page courante. A appeler dans le `setup` d'un
203
+ * composant de la page : la source est posee au montage et retiree au
204
+ * demontage, donc la tuile Export suit la navigation sans nettoyage manuel.
205
+ */
206
+ function declare(build: () => ExportSource | null) {
207
+ // Page du tiroir de projet : l'export reste celui de l'ecran dessous.
208
+ if (useHubrEmbedded()) return;
209
+ onMounted(() => {
210
+ registry.value = build;
211
+ });
212
+ onBeforeUnmount(() => {
213
+ if (registry.value === build) registry.value = null;
214
+ });
215
+ // Page gardee en vie (keepalive, la fiche projet) : elle ne se remonte pas
216
+ // au retour, la tuile Export disparaissait apres l'ouverture d'une fiche.
217
+ onActivated(() => {
218
+ registry.value = build;
219
+ });
220
+ onDeactivated(() => {
221
+ if (registry.value === build) registry.value = null;
222
+ });
223
+ }
224
+
225
+ return {
226
+ declare,
227
+ /** Format d'un export a lancer des que le panneau s'ouvre (cf. ci-dessus). */
228
+ quick,
229
+ /** La source courante, relue a chaque acces : elle suit les filtres. */
230
+ current: computed(() => registry.value?.() ?? null),
231
+ /** Vrai quand la page declare une source qui a effectivement du contenu. */
232
+ has: computed(() => (registry.value?.() ?? null) !== null),
233
+ /** Sources Directus de la page, avec leur date de mise a jour. */
234
+ pageSources: computed(() => pageSources.value),
235
+ };
236
+ }
@@ -0,0 +1,13 @@
1
+ import { hasInjectionContext, inject, type InjectionKey } from "vue";
2
+
3
+ /**
4
+ * Vrai sous une page montee dans le tiroir de la fiche projet (cf.
5
+ * HubrEmbeddedPage). La page y vit par-dessus un autre ecran : ce qu'elle
6
+ * declare au cadre (export, alertes) remplacerait ce que declare l'ecran
7
+ * dessous, et le retirerait a la fermeture du tiroir.
8
+ */
9
+ export const HUBR_EMBEDDED: InjectionKey<boolean> = Symbol("hubr-embedded");
10
+
11
+ export function useHubrEmbedded(): boolean {
12
+ return hasInjectionContext() ? inject(HUBR_EMBEDDED, false) : false;
13
+ }