@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,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
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * usePermalink - l'URL d'un ecran filtre, et sa relecture a l'arrivee.
3
+ *
4
+ * Le format « Permalink » du panneau d'export copiait l'URL de la page. Or les
5
+ * filtres d'un ecran du hub ne sont pas dans l'URL : ils vivent dans le magasin
6
+ * de scopes (cf. `useViews`). Le lien partage ouvrait donc la page nue, avec la
7
+ * vue par defaut du destinataire, et pas l'ecran que l'expediteur avait sous
8
+ * les yeux.
9
+ *
10
+ * Un permalink porte donc les criteres de la page, dans la forme exacte d'une
11
+ * vue : le lien est une vue temporaire transportee par l'URL. A l'arrivee, ils
12
+ * sont reposes par le meme chemin qu'une vue appliquee, et la vue par defaut de
13
+ * la page leur cede la place (cf. `useViews().declare`).
14
+ *
15
+ * Les criteres voyagent compresses, en base64url : le routeur aplatit les
16
+ * tableaux imbriques d'une requete, et un JSON pose tel quel dans l'URL en
17
+ * ressortirait deforme. La compression n'est pas une coquetterie — une
18
+ * selection de cinquante produits fait 3 300 caracteres en clair, au-dela de ce
19
+ * qu'un client mail transmet sans couper, contre 450 compressee. Elle est
20
+ * portee par `CompressionStream`, native, donc rien a installer.
21
+ */
22
+
23
+ /** Parametre de requete qui porte les criteres d'un lien partage. */
24
+ export const PERMALINK_PARAM = "vue";
25
+
26
+ /**
27
+ * Premier caractere de la charge : il dit comment lire la suite. Un navigateur
28
+ * sans `CompressionStream` ecrit en clair, et le lien reste lisible par les
29
+ * autres.
30
+ */
31
+ const DEFLATED = "1";
32
+ const PLAIN = "0";
33
+
34
+ function toBase64Url(bytes: Uint8Array): string {
35
+ let binary = "";
36
+ bytes.forEach((byte) => {
37
+ binary += String.fromCharCode(byte);
38
+ });
39
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
40
+ }
41
+
42
+ function fromBase64Url(raw: string): Uint8Array {
43
+ const binary = atob(raw.replace(/-/g, "+").replace(/_/g, "/"));
44
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
45
+ }
46
+
47
+ /** Passe des octets dans un flux de (de)compression et rend le resultat. */
48
+ async function through(bytes: Uint8Array, stream: TransformStream): Promise<Uint8Array> {
49
+ const blob = new Blob([bytes as BlobPart]);
50
+ const piped = blob.stream().pipeThrough(stream);
51
+ return new Uint8Array(await new Response(piped).arrayBuffer());
52
+ }
53
+
54
+ /**
55
+ * La charge a poser dans l'URL pour ces criteres. Compressee quand le
56
+ * navigateur sait le faire, en clair sinon.
57
+ */
58
+ async function encode(criteria: any[]): Promise<string> {
59
+ const bytes = new TextEncoder().encode(JSON.stringify(criteria));
60
+ if (typeof CompressionStream === "undefined") return PLAIN + toBase64Url(bytes);
61
+ // « deflate-raw » et pas « deflate » : l'en-tete zlib ne sert a rien ici, et
62
+ // ce sont six caracteres de base64 en moins sur chaque lien.
63
+ return DEFLATED + toBase64Url(await through(bytes, new CompressionStream("deflate-raw")));
64
+ }
65
+
66
+ /**
67
+ * Les criteres portes par une URL, dans la forme de ceux d'une vue. Vide quand
68
+ * le lien n'en porte pas — ou quand il a ete tronque par un client mail : la
69
+ * page s'ouvre alors sur ses propres filtres plutot que sur une erreur.
70
+ */
71
+ export async function permalinkCriteria(query: Record<string, any>): Promise<any[]> {
72
+ const raw = query?.[PERMALINK_PARAM];
73
+ if (typeof raw !== "string" || raw.length < 2) return [];
74
+
75
+ try {
76
+ const bytes = fromBase64Url(raw.slice(1));
77
+ const json =
78
+ raw[0] === DEFLATED
79
+ ? new TextDecoder().decode(await through(bytes, new DecompressionStream("deflate-raw")))
80
+ : new TextDecoder().decode(bytes);
81
+ const parsed = JSON.parse(json);
82
+ return Array.isArray(parsed) ? parsed : [];
83
+ } catch {
84
+ return [];
85
+ }
86
+ }
87
+
88
+ export function usePermalink() {
89
+ const views = useViews();
90
+
91
+ /**
92
+ * L'URL a partager pour l'ecran courant. Sans filtre pose, c'est l'URL de la
93
+ * page : un lien vide de criteres n'a rien a transporter.
94
+ */
95
+ async function build(): Promise<string> {
96
+ const url = new URL(window.location.href);
97
+ url.searchParams.delete(PERMALINK_PARAM);
98
+
99
+ // Meme lecture que la sauvegarde d'une vue et que le recapitulatif d'un
100
+ // rapport : un filtre ajoute a une page suit sans retouche ici.
101
+ const criteria = views.criteriaOf().filter((item: any) => viewFilledValues(item).length > 0);
102
+ if (criteria.length > 0) url.searchParams.set(PERMALINK_PARAM, await encode(criteria));
103
+
104
+ return url.toString();
105
+ }
106
+
107
+ return { build };
108
+ }
@@ -0,0 +1,286 @@
1
+ /**
2
+ * useSavedExports - les exports deja tires, conserves dans le compte.
3
+ *
4
+ * Un export n'est pas un fichier stocke : les documents partent du navigateur
5
+ * et personne ne les garde. Ce qui est conserve, c'est la *recette* : la
6
+ * configuration du wizard, plus les filtres poses a l'ecran au moment du
7
+ * tirage. Rejouer un export repose donc ces filtres et regenere le document
8
+ * avec la donnee du jour.
9
+ *
10
+ * Le magasin est celui des vues : les pulses de `elysiaApi.pulses`, le seul
11
+ * endroit ou le hub sait deja ranger quelque chose par utilisateur, par app et
12
+ * par page. Deux marques les distinguent d'une vue :
13
+ *
14
+ * - `page` porte le prefixe `export:`. Les listes de vues filtrent sur
15
+ * l'egalite avec la page courante (cf. `layout/tool/views.vue` et la
16
+ * pastille), donc un export prefixe n'y parait jamais et la vue par defaut
17
+ * d'une page ne peut pas tomber sur lui.
18
+ * - la configuration voyage dans un critere nomme `__export`, comme l'etat du
19
+ * « Personnaliser » voyage dans `__cardPrefs` : `criteria` est le seul champ
20
+ * libre du pulse.
21
+ *
22
+ * Les criteres qui restent sont donc lisibles tels quels par les composants de
23
+ * filtre, et rejouer un export emprunte exactement le chemin d'une vue
24
+ * appliquee.
25
+ */
26
+ import type { RmExportConfig } from "@tekkare/romulus/utils/export";
27
+ import { useOipApi } from "#oip-app/api";
28
+ import { normalizePage } from "../utils/viewTemplates";
29
+
30
+ /** Prefixe de `pulse.page` qui distingue un export d'une vue. */
31
+ const EXPORT_PAGE_PREFIX = "export:";
32
+
33
+ /** Critere qui porte la configuration du wizard. Ce n'est pas un filtre. */
34
+ export const EXPORT_CONFIG_FILTER = "__export";
35
+
36
+ /**
37
+ * Nombre d'exports gardes. Au-dela, les plus anciens partent : la liste est
38
+ * un historique consultable, pas une archive, et une page en tire plusieurs
39
+ * par session.
40
+ */
41
+ const KEPT = 20;
42
+
43
+ export interface SavedExport {
44
+ id: string;
45
+ /** Nom du fichier tel qu'il est sorti. */
46
+ title: string;
47
+ /** Ecran d'origine, sans le prefixe. */
48
+ page: string;
49
+ /** Titre de cet ecran, garde avec l'entree. */
50
+ pageLabel: string;
51
+ /** Dernier tirage : un rejeu remplace l'entree, il n'en cree pas une autre. */
52
+ at: string;
53
+ format: string;
54
+ rows?: number;
55
+ size?: string;
56
+ config: RmExportConfig;
57
+ /** Filtres du moment du tirage, dans la forme des criteres d'une vue. */
58
+ criteria: any[];
59
+ }
60
+
61
+ /**
62
+ * Liste partagee entre les montages du panneau : la tuile monte et demonte le
63
+ * composant a chaque ouverture, un etat local repartirait vide. `useState`
64
+ * plutot qu'un `ref` de module : la liste porte de la donnee d'utilisateur, et
65
+ * un ref de module est partage par toutes les requetes d'un meme serveur.
66
+ */
67
+ const useList = () => useState<SavedExport[]>("oip-saved-exports", () => []);
68
+
69
+ function parseCriteria(pulse: any): any[] {
70
+ const raw = pulse?.criteria;
71
+ if (Array.isArray(raw)) return raw;
72
+ if (typeof raw !== "string") return [];
73
+ try {
74
+ return JSON.parse(raw) ?? [];
75
+ } catch {
76
+ return [];
77
+ }
78
+ }
79
+
80
+ /** Un pulse prefixe devient une entree, ou rien s'il ne porte pas de config. */
81
+ function toSavedExport(pulse: any): SavedExport | null {
82
+ const criteria = parseCriteria(pulse);
83
+ const entry = criteria.find((item: any) => item?.filter === EXPORT_CONFIG_FILTER);
84
+ const raw = Array.isArray(entry?.values) ? entry.values[0] : null;
85
+ if (typeof raw !== "string") return null;
86
+
87
+ let payload: any;
88
+ try {
89
+ payload = JSON.parse(raw);
90
+ } catch {
91
+ return null;
92
+ }
93
+ if (!payload?.config?.format) return null;
94
+
95
+ return {
96
+ id: String(pulse.id),
97
+ title: String(pulse.title ?? ""),
98
+ page: String(pulse.page ?? "").slice(EXPORT_PAGE_PREFIX.length),
99
+ pageLabel: String(pulse.description ?? ""),
100
+ // La date du dernier tirage voyage dans la config : un rejeu ecrit le
101
+ // meme pulse, et `createdAt` resterait celle du premier. Les entrees
102
+ // d'avant ce champ retombent dessus.
103
+ at: String(payload.at ?? pulse.createdAt ?? pulse.updatedAt ?? ""),
104
+ format: String(payload.config.format),
105
+ rows: typeof payload.rows === "number" ? payload.rows : undefined,
106
+ size: typeof payload.size === "string" ? payload.size : undefined,
107
+ config: payload.config as RmExportConfig,
108
+ criteria: criteria.filter((item: any) => item?.filter !== EXPORT_CONFIG_FILTER),
109
+ };
110
+ }
111
+
112
+ export function useSavedExports() {
113
+ const list = useList();
114
+ const elysiaApi = useOipApi();
115
+ const views = useViews();
116
+ const route = useRoute();
117
+
118
+ /**
119
+ * Page de rangement : celle que la page a declaree, sinon la route. Sans le
120
+ * prefixe de langue : les pages declarent `route.path`, et « Mes exports »
121
+ * se vidait au passage de `/fr/` a `/en/`.
122
+ */
123
+ const pageKey = () => normalizePage(views.current.value?.page ?? route.path);
124
+
125
+ /** Les exports du compte pour cette app, les plus recents d'abord. */
126
+ async function load() {
127
+ try {
128
+ // Meme plafond que les listes de vues : les deux lisent le meme magasin,
129
+ // et un compte charge tronquerait sinon son propre historique.
130
+ const response = await elysiaApi.pulses.list({
131
+ domain: window.location.host,
132
+ limit: 500,
133
+ });
134
+ list.value = response.data
135
+ .filter((pulse: any) => String(pulse.page ?? "").startsWith(EXPORT_PAGE_PREFIX))
136
+ .map(toSavedExport)
137
+ .filter((entry): entry is SavedExport => entry !== null)
138
+ .sort((a, b) => b.at.localeCompare(a.at));
139
+ } catch (error) {
140
+ // Compte sans acces aux pulses, ou API muette : l'onglet reste vide
141
+ // plutot que de faire echouer l'export lui-meme.
142
+ console.error("[useSavedExports] chargement impossible", error);
143
+ list.value = [];
144
+ }
145
+ }
146
+
147
+ interface ExportEntry {
148
+ title: string;
149
+ /** Titre de l'ecran d'origine, garde avec l'entree. */
150
+ pageLabel: string;
151
+ config: RmExportConfig;
152
+ rows?: number;
153
+ size?: string;
154
+ }
155
+
156
+ /** Les filtres de l'ecran, suivis de la config du tirage. */
157
+ function criteriaFor(entry: ExportEntry, page: string) {
158
+ return [
159
+ // Les filtres de l'ecran, lus la ou les vues les lisent : un filtre
160
+ // ajoute a une page suit sans retouche ici.
161
+ ...views.criteriaOf(),
162
+ {
163
+ filter: EXPORT_CONFIG_FILTER,
164
+ page,
165
+ values: [
166
+ JSON.stringify({
167
+ config: entry.config,
168
+ rows: entry.rows,
169
+ size: entry.size,
170
+ at: new Date().toISOString(),
171
+ }),
172
+ ],
173
+ },
174
+ ];
175
+ }
176
+
177
+ /**
178
+ * Garde l'export qui vient de partir. Appele apres coup : un echec
179
+ * d'enregistrement ne doit pas priver l'utilisateur de son fichier, il est
180
+ * deja telecharge.
181
+ *
182
+ * `replaces` nomme l'entree rejouee : un rejeu refait le meme document a
183
+ * partir de la meme recette, il met a jour son entree (date, lignes, taille)
184
+ * au lieu d'empiler un doublon a chaque clic.
185
+ */
186
+ async function save(entry: ExportEntry, replaces?: string) {
187
+ const page = pageKey();
188
+ try {
189
+ if (replaces) {
190
+ await elysiaApi.pulses.update(replaces, {
191
+ title: entry.title,
192
+ criteria: criteriaFor(entry, page),
193
+ });
194
+ } else {
195
+ await elysiaApi.pulses.create({
196
+ title: entry.title,
197
+ description: entry.pageLabel,
198
+ url: window.location.href,
199
+ page: `${EXPORT_PAGE_PREFIX}${page}`,
200
+ criteria: criteriaFor(entry, page),
201
+ });
202
+ }
203
+ await load();
204
+ await trim();
205
+ } catch (error) {
206
+ console.error("[useSavedExports] enregistrement impossible", error);
207
+ }
208
+ }
209
+
210
+ /** Retire les entrees au-dela du plafond, les plus anciennes d'abord. */
211
+ async function trim() {
212
+ const surplus = list.value.slice(KEPT);
213
+ if (surplus.length === 0) return;
214
+ await Promise.all(
215
+ surplus.map((entry) =>
216
+ elysiaApi.pulses
217
+ .delete(entry.id)
218
+ .catch((error) => console.error("[useSavedExports] purge impossible", error)),
219
+ ),
220
+ );
221
+ list.value = list.value.slice(0, KEPT);
222
+ }
223
+
224
+ async function remove(id: string) {
225
+ const kept = list.value.filter((entry) => entry.id !== id);
226
+ try {
227
+ await elysiaApi.pulses.delete(id);
228
+ list.value = kept;
229
+ } catch (error) {
230
+ console.error("[useSavedExports] suppression impossible", error);
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Repose les filtres que porte un export.
236
+ *
237
+ * Meme chemin qu'une vue appliquee : les composants de filtre de heart
238
+ * ecoutent la reference de `selectedScope`, drugsstats et le portail passent
239
+ * par `onApply`. La vue posee n'a ni identifiant ni titre, comme celle d'un
240
+ * lien partage : l'ecran ne correspond plus a une vue enregistree, la
241
+ * pastille le dit (« Vue temporaire ») plutot que de porter le nom de
242
+ * l'export.
243
+ *
244
+ * Un export tire sans filtre repose un ecran vierge : garder les filtres du
245
+ * moment aurait donne un autre perimetre que celui de l'export.
246
+ *
247
+ * Rend ce que `onApply` rend : il est type `void`, mais une page qui
248
+ * renverrait sa promesse de rechargement serait attendue par le rejeu.
249
+ */
250
+ async function applyFilters(entry: SavedExport) {
251
+ const source = views.current.value;
252
+ if (!source) return;
253
+
254
+ if (entry.criteria.length === 0) {
255
+ views.resetAll();
256
+ return;
257
+ }
258
+
259
+ const scope = {
260
+ id: null,
261
+ title: "",
262
+ page: source.page,
263
+ criteria: JSON.stringify(entry.criteria),
264
+ };
265
+ views.scopes.value().setSelectedScope(scope);
266
+ views.applyPrefs(scope, source.prefsScope);
267
+ await source.onApply?.(scope);
268
+ }
269
+
270
+ return {
271
+ /**
272
+ * Les exports de l'ecran courant, et eux seuls : un export nomme les
273
+ * filtres et les colonnes d'une page, il ne se lit pas depuis une autre.
274
+ * La liste complete reste chargee derriere : le plafond se compte par
275
+ * compte et non par page, et `find` cherche par identifiant.
276
+ */
277
+ list: computed(() => list.value.filter((entry) => entry.page === pageKey())),
278
+ /** Vrai quand l'export a ete tire de l'ecran courant : il peut se rejouer. */
279
+ isHere: (entry: SavedExport) => entry.page === pageKey(),
280
+ find: (id: string) => list.value.find((entry) => entry.id === id) ?? null,
281
+ load,
282
+ save,
283
+ remove,
284
+ applyFilters,
285
+ };
286
+ }