@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.
- package/dist/module.json +1 -1
- package/layer/app/components/layout/alertBrandSync.vue +143 -0
- package/layer/app/components/layout/tool/adminTemplates.vue +261 -0
- package/layer/app/components/layout/tool/alerts.vue +969 -0
- package/layer/app/components/layout/tool/export.vue +590 -0
- package/layer/app/components/layout/tool/views.vue +933 -0
- package/layer/app/components/layout/viewEmptyState.vue +278 -0
- package/layer/app/components/layout/viewPill.vue +550 -0
- package/layer/app/composables/useAdminAccess.ts +86 -0
- package/layer/app/composables/useAlertBrandSync.ts +85 -0
- package/layer/app/composables/useAlertSource.ts +210 -0
- package/layer/app/composables/useAlerts.ts +345 -0
- package/layer/app/composables/useExportSource.ts +236 -0
- package/layer/app/composables/useHubrEmbedded.ts +13 -0
- package/layer/app/composables/usePermalink.ts +108 -0
- package/layer/app/composables/useSavedExports.ts +286 -0
- package/layer/app/composables/useViewCardActions.ts +500 -0
- package/layer/app/composables/useViewTemplates.ts +369 -0
- package/layer/app/composables/useViews.ts +574 -0
- package/layer/app/oip-defaults/alerts.ts +34 -0
- package/layer/app/oip-defaults/api.ts +18 -0
- package/layer/app/oip-defaults/export.ts +23 -0
- package/layer/app/oip-defaults/ui.ts +21 -0
- package/layer/app/oip-defaults/views.ts +59 -0
- package/layer/app/utils/alertFieldLabels.ts +191 -0
- package/layer/app/utils/oipScopeStore.ts +112 -0
- package/layer/app/utils/oipScopes.ts +376 -0
- package/layer/app/utils/viewPages.ts +30 -0
- package/layer/app/utils/viewTemplateCriteria.ts +215 -0
- package/layer/app/utils/viewTemplates.ts +116 -0
- package/layer/nuxt.config.ts +9 -0
- package/layer/server/api/oip/notes.get.ts +1 -1
- 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
|
+
}
|