@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,590 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Panneau Export du cadre OIP 7.
|
|
3
|
+
|
|
4
|
+
Le composant fait le pont entre ce que la page declare
|
|
5
|
+
(`useExportSource().declare`) et le wizard du design system
|
|
6
|
+
(`RmExportPanel`) : il traduit la source en formats proposes, lance la
|
|
7
|
+
generation via `useRmExport`, trace l'evenement PostHog et tient l'onglet
|
|
8
|
+
« Mes exports » (cf. useSavedExports).
|
|
9
|
+
|
|
10
|
+
Il est unique pour les trois familles de layouts : le cadre est le meme, et
|
|
11
|
+
l'export ne doit pas se presenter differemment d'une partie du hub a l'autre.
|
|
12
|
+
-->
|
|
13
|
+
<template>
|
|
14
|
+
<div v-if="source" class="oip_export_tool">
|
|
15
|
+
<!-- Une page d'analyse ne montre rien tant qu'aucune vue n'est posee : ses
|
|
16
|
+
onglets d'export sont donc vides, et les formats de donnees sortent
|
|
17
|
+
grises avec « aucun tableau sur cette page », ce qui laisse croire que
|
|
18
|
+
la page n'exporte jamais. Le vrai motif est dit ici. -->
|
|
19
|
+
<RmCallout v-if="needsView" tone="info" icon="BookmarkSimple" class="oip_export_notice">
|
|
20
|
+
{{ emptyText }}
|
|
21
|
+
</RmCallout>
|
|
22
|
+
<!-- La page refuse d'exporter sa donnee (selection trop large) : dit ici,
|
|
23
|
+
a l'ouverture, pas a la derniere etape du wizard. -->
|
|
24
|
+
<RmCallout v-else-if="source.blocked" tone="warning" icon="Warning" class="oip_export_notice">
|
|
25
|
+
{{ source.blocked }}
|
|
26
|
+
</RmCallout>
|
|
27
|
+
|
|
28
|
+
<RmExportPanel
|
|
29
|
+
:file-name="source.fileName"
|
|
30
|
+
:formats="formats"
|
|
31
|
+
:sections="sections"
|
|
32
|
+
:layouts="layouts"
|
|
33
|
+
:ranges="source.ranges ?? []"
|
|
34
|
+
:columns="columns"
|
|
35
|
+
:templates="templates"
|
|
36
|
+
:recent="recent"
|
|
37
|
+
:busy="busy"
|
|
38
|
+
:notice="notice"
|
|
39
|
+
:error="error"
|
|
40
|
+
:lang="locale === 'en' ? 'en' : 'fr'"
|
|
41
|
+
@export="launch"
|
|
42
|
+
@range-change="onRange"
|
|
43
|
+
@rerun="rerun"
|
|
44
|
+
@forget="forget"
|
|
45
|
+
/>
|
|
46
|
+
</div>
|
|
47
|
+
<!-- Sans source declaree, le panneau dit pourquoi. Les pages d'analyse
|
|
48
|
+
retirent la leur tant qu'aucun produit n'est choisi : leur tuile Export
|
|
49
|
+
reste pourtant la, sinon le rail se reorganiserait a chaque selection et
|
|
50
|
+
personne n'apprendrait ce qui manque. -->
|
|
51
|
+
<RmCallout v-else tone="info" icon="BookmarkSimple" class="oip_export_notice">
|
|
52
|
+
{{ emptyText }}
|
|
53
|
+
</RmCallout>
|
|
54
|
+
</template>
|
|
55
|
+
|
|
56
|
+
<script setup lang="ts">
|
|
57
|
+
import type { RmExportConfig, RmExportTemplate } from "@tekkare/romulus/utils/export";
|
|
58
|
+
import { oipTrack } from "#oip-app/ui";
|
|
59
|
+
import { useExportTracking } from "#oip-app/export";
|
|
60
|
+
|
|
61
|
+
const { locale } = useI18n();
|
|
62
|
+
const { current: source, pageSources, quick } = useExportSource();
|
|
63
|
+
const { run } = useRmExport();
|
|
64
|
+
const { getExportPosthogProps } = useExportTracking();
|
|
65
|
+
const saved = useSavedExports();
|
|
66
|
+
const permalink = usePermalink();
|
|
67
|
+
|
|
68
|
+
const busy = ref(false);
|
|
69
|
+
const notice = ref("");
|
|
70
|
+
const error = ref("");
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Plage choisie dans le panneau. Suivie ici parce qu'elle decide de ce que le
|
|
74
|
+
* cadre propose (cf. `columns`) ; le panneau la pose lui-meme des l'ouverture,
|
|
75
|
+
* et un modele la change en passant.
|
|
76
|
+
*/
|
|
77
|
+
const range = ref("");
|
|
78
|
+
watchEffect(() => {
|
|
79
|
+
if (!range.value) range.value = source.value?.ranges?.[0]?.key ?? "";
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Graphiques a exporter, relus a chaque ouverture du panneau.
|
|
84
|
+
*
|
|
85
|
+
* A defaut de declaration, ils sont relus dans le rendu : un rapport doit
|
|
86
|
+
* ressembler a la page, et une page de tableaux de bord n'a pas a redire dans
|
|
87
|
+
* sa source d'export ce que son template affiche deja.
|
|
88
|
+
*/
|
|
89
|
+
function pageCharts() {
|
|
90
|
+
return source.value?.charts?.() ?? visibleExportCharts();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const charts = computed(() => pageCharts());
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Ce que l'ecran porte, pour les formats mis en page. Meme regle que pour les
|
|
97
|
+
* graphiques : la declaration de la page si elle existe, le rendu sinon.
|
|
98
|
+
*/
|
|
99
|
+
function pageScreen() {
|
|
100
|
+
return source.value?.screen?.() ?? visibleExportScreen();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Les onglets du classeur : ceux, et seulement ceux, que la page declare.
|
|
105
|
+
*
|
|
106
|
+
* Le classeur porte TOUTE la donnee, pas ce qui tient a l'ecran : lui faire
|
|
107
|
+
* relire le rendu donnerait un fichier tronque au nombre de lignes affichees.
|
|
108
|
+
* C'est le rapport mis en page, lui, qui ne montre que l'ecran.
|
|
109
|
+
*/
|
|
110
|
+
function pageSheets() {
|
|
111
|
+
return source.value?.sheets ?? [];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Ce que le rapport mis en page saurait capturer : les cartes du design
|
|
116
|
+
* system, ou a defaut les blocs de la page (cf. visibleExportNodes).
|
|
117
|
+
*/
|
|
118
|
+
function pageNodes() {
|
|
119
|
+
return visibleExportNodes();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const views = useViews();
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* La page attend une vue pour avoir quelque chose a exporter.
|
|
126
|
+
*
|
|
127
|
+
* Trois conditions ensemble : la page declare une vue (les pages qui n'en ont
|
|
128
|
+
* pas ne demandent rien), aucun critere n'est pose (meme lecture que le
|
|
129
|
+
* permalink : un filtre vide ne compte pas), et la source est vide. Une page
|
|
130
|
+
* qui sort ses lignes sans selection, comme les prix ou les avis HAS, ne voit
|
|
131
|
+
* donc jamais ce message.
|
|
132
|
+
*/
|
|
133
|
+
const needsView = computed(
|
|
134
|
+
() =>
|
|
135
|
+
!!views.current.value &&
|
|
136
|
+
(source.value?.sheets.length ?? 0) === 0 &&
|
|
137
|
+
views.criteriaOf().filter((item: any) => viewFilledValues(item).length > 0).length === 0,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
/** Le motif du panneau vide : une vue manquante, ou une page sans export. */
|
|
141
|
+
const emptyText = computed(() => {
|
|
142
|
+
const fr = locale.value !== "en";
|
|
143
|
+
if (needsView.value) {
|
|
144
|
+
return fr
|
|
145
|
+
? "Cette page exporte ce que porte la vue active : une vue définit les produits de l’analyse. Activez-en une depuis la tuile « Mes vues », puis revenez ici."
|
|
146
|
+
: "This page exports what an active view holds: a view defines the products of the analysis. Activate one from the \"My views\" tile, then come back.";
|
|
147
|
+
}
|
|
148
|
+
return fr
|
|
149
|
+
? "Cette page ne déclare pas encore de contenu exportable."
|
|
150
|
+
: "This page does not declare anything to export yet.";
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Un format que la page ne peut pas nourrir reste visible mais grise : le
|
|
155
|
+
* catalogue dit ce que la plateforme sait faire, la carte grisee dit pourquoi
|
|
156
|
+
* cette page-la ne le propose pas.
|
|
157
|
+
*/
|
|
158
|
+
const formats = computed(() => {
|
|
159
|
+
const base = source.value?.formats ?? RM_EXPORT_FORMATS;
|
|
160
|
+
const hasSheets = pageSheets().length > 0;
|
|
161
|
+
const hasCharts = charts.value.length > 0;
|
|
162
|
+
// Le rapport capture l'ecran : une page sans tableau ni graphique declares
|
|
163
|
+
// reste exportable tant qu'elle affiche quelque chose.
|
|
164
|
+
const hasPage = hasSheets || hasCharts || pageNodes().length > 0;
|
|
165
|
+
const noChart = locale.value === "en" ? "No chart on this page" : "Aucun graphique sur cette page";
|
|
166
|
+
const noTable = locale.value === "en" ? "No table on this page" : "Aucun tableau sur cette page";
|
|
167
|
+
|
|
168
|
+
const nothing =
|
|
169
|
+
locale.value === "en" ? "Nothing to export on this page yet" : "Rien à exporter sur cette page pour l'instant";
|
|
170
|
+
|
|
171
|
+
const blocked = source.value?.blocked;
|
|
172
|
+
|
|
173
|
+
return base.map((format) => {
|
|
174
|
+
// Un format qui emporte des enregistrements (tableau ou rapport) sort
|
|
175
|
+
// grise quand la page refuse : le motif est dans le bandeau au-dessus.
|
|
176
|
+
if (blocked && (format.tabular || format.paged)) {
|
|
177
|
+
return { ...format, disabled: true, disabledHint: blocked };
|
|
178
|
+
}
|
|
179
|
+
if (format.visual && !format.tabular && !hasCharts) {
|
|
180
|
+
return { ...format, disabled: true, disabledHint: noChart };
|
|
181
|
+
}
|
|
182
|
+
// Un format mis en page (le PDF) se nourrit des tableaux OU des
|
|
183
|
+
// graphiques : un onglet de graphiques sans tableau le proposait grise.
|
|
184
|
+
if (format.tabular && format.visual && !hasPage) {
|
|
185
|
+
return { ...format, disabled: true, disabledHint: nothing };
|
|
186
|
+
}
|
|
187
|
+
if (format.tabular && !format.visual && !hasSheets) {
|
|
188
|
+
return { ...format, disabled: true, disabledHint: noTable };
|
|
189
|
+
}
|
|
190
|
+
return format;
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Colonnes proposees a l'etape « Colonnes » : celles de TOUS les onglets, dans
|
|
196
|
+
* leur ordre d'apparition.
|
|
197
|
+
*
|
|
198
|
+
* Le generateur applique la liste retenue a chaque onglet du classeur, et
|
|
199
|
+
* garde le schema entier quand aucun de ses intitules n'y figure. Ne lui
|
|
200
|
+
* donner que le premier onglet marchait tant qu'une page n'en avait qu'un :
|
|
201
|
+
* sur la fiche produit, l'onglet MITM ne partageait que « Marque » avec les
|
|
202
|
+
* avis HAS, et sortait donc reduit a cette seule colonne.
|
|
203
|
+
*
|
|
204
|
+
* Un intitule porte par deux onglets ne se coche qu'une fois : le decocher les
|
|
205
|
+
* retire des deux, ce qui est la lecture attendue (« je ne veux pas de cette
|
|
206
|
+
* colonne dans l'export »).
|
|
207
|
+
*/
|
|
208
|
+
const columns = computed(() => {
|
|
209
|
+
// Plage « tout » : aucune colonne proposee, donc aucune retenue, donc chaque
|
|
210
|
+
// onglet garde son schema entier a la generation.
|
|
211
|
+
if (source.value?.fullRanges?.includes(range.value)) return [];
|
|
212
|
+
|
|
213
|
+
return [
|
|
214
|
+
...new Set(
|
|
215
|
+
pageSheets().flatMap((sheet) =>
|
|
216
|
+
(sheet.schema ?? []).map((column) => column.column),
|
|
217
|
+
),
|
|
218
|
+
),
|
|
219
|
+
];
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Sections cochees a l'ouverture : celles que l'ecran remplit, dans la forme
|
|
224
|
+
* qu'il leur donne.
|
|
225
|
+
*
|
|
226
|
+
* Une page qui declare sa forme garde le dernier mot (`layout`). Sinon chaque
|
|
227
|
+
* section suit le rendu : un ecran d'analyse coche son tableau et pas une
|
|
228
|
+
* fiche par ligne, qui redirait la meme chose en dix fois plus de pages ; une
|
|
229
|
+
* page de resultats coche ses fiches ; une page de tableaux de bord sans
|
|
230
|
+
* tableau ne coche que ses graphiques, plutot que de sortir en annexe les dix
|
|
231
|
+
* mille lignes que la page ne montre nulle part.
|
|
232
|
+
*/
|
|
233
|
+
const sections = computed(() => {
|
|
234
|
+
const declared = source.value?.layout;
|
|
235
|
+
const screen = pageScreen();
|
|
236
|
+
const hasCharts = charts.value.length > 0;
|
|
237
|
+
const hasTables = (screen.sheets?.length ?? 0) > 0;
|
|
238
|
+
const hasCards = (screen.cards?.length ?? 0) > 0;
|
|
239
|
+
|
|
240
|
+
// Ecran illisible (rendu pas encore monte, markup inconnu) : les fiches
|
|
241
|
+
// restent, c'est la forme par defaut du panneau depuis ses debuts.
|
|
242
|
+
const cards = declared ? declared === "cards" : hasCards || (!hasTables && !hasCharts);
|
|
243
|
+
const table = declared ? declared === "table" : hasTables;
|
|
244
|
+
|
|
245
|
+
return RM_EXPORT_SECTIONS.map((section) => {
|
|
246
|
+
if (section.key === "charts") return { ...section, on: hasCharts };
|
|
247
|
+
if (section.key === "cards") return { ...section, on: cards };
|
|
248
|
+
if (section.key === "table") return { ...section, on: table };
|
|
249
|
+
return section;
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Colonnes du tableau le plus large de l'ecran. C'est lui qui decide
|
|
255
|
+
* l'orientation du rapport : un suivi mensuel a quatorze colonnes, illisible
|
|
256
|
+
* en portrait, et un rapport dont la couverture et le tableau n'ont pas la
|
|
257
|
+
* meme largeur se lit comme deux fichiers agrafes.
|
|
258
|
+
*/
|
|
259
|
+
const widestTable = computed(() =>
|
|
260
|
+
(pageScreen().sheets ?? []).reduce(
|
|
261
|
+
(widest, sheet) => Math.max(widest, sheet.schema.length),
|
|
262
|
+
0,
|
|
263
|
+
),
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
/** Le paysage passe en tete des mises en page quand le tableau est large. */
|
|
267
|
+
const layouts = computed(() =>
|
|
268
|
+
widestTable.value > 8
|
|
269
|
+
? [...RM_EXPORT_LAYOUTS].sort((a, b) => Number(b.key === "a4-landscape") - Number(a.key === "a4-landscape"))
|
|
270
|
+
: RM_EXPORT_LAYOUTS,
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Modeles : les quatre depart les plus courants, reduits a ceux que la page
|
|
275
|
+
* peut honorer. Un modele qui ouvrirait sur un format grise n'a pas lieu
|
|
276
|
+
* d'etre propose.
|
|
277
|
+
*/
|
|
278
|
+
const templates = computed<RmExportTemplate[]>(() => {
|
|
279
|
+
const fr = locale.value !== "en";
|
|
280
|
+
const all: RmExportTemplate[] = [
|
|
281
|
+
{
|
|
282
|
+
key: "pdf-report",
|
|
283
|
+
name: fr ? "Rapport PDF" : "PDF report",
|
|
284
|
+
meta: fr
|
|
285
|
+
? `PDF · ${layouts.value[0]?.label ?? "A4 portrait"} · en-tête OIP`
|
|
286
|
+
: `PDF · ${layouts.value[0]?.label ?? "A4 portrait"} · OIP header`,
|
|
287
|
+
icon: "FilePdf",
|
|
288
|
+
color: "var(--color-danger)",
|
|
289
|
+
config: { format: "pdf", layout: layouts.value[0]?.key ?? "a4-portrait" },
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
key: "xlsx-raw",
|
|
293
|
+
name: fr ? "Data brute Excel" : "Raw Excel data",
|
|
294
|
+
meta: fr ? "Excel · page de garde · tous onglets" : "Excel · cover sheet · all tabs",
|
|
295
|
+
icon: "FileXls",
|
|
296
|
+
color: "var(--color-success)",
|
|
297
|
+
config: { format: "xlsx" },
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
key: "png-slides",
|
|
301
|
+
name: fr ? "Capture slides" : "Slide capture",
|
|
302
|
+
meta: fr ? "PNG · graphiques visibles · 2x" : "PNG · visible charts · 2x",
|
|
303
|
+
icon: "Image",
|
|
304
|
+
color: "var(--color-info)",
|
|
305
|
+
config: { format: "png" },
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
key: "permalink",
|
|
309
|
+
name: fr ? "Lien partageable" : "Shareable link",
|
|
310
|
+
meta: fr ? "URL · filtres compris" : "URL · filters included",
|
|
311
|
+
icon: "Link",
|
|
312
|
+
color: "var(--color-secondary)",
|
|
313
|
+
config: { format: "link" },
|
|
314
|
+
},
|
|
315
|
+
];
|
|
316
|
+
// Les modeles de la page prennent la place de la tuile Excel generique :
|
|
317
|
+
// « Data brute Excel » ne dit pas quoi, sur un ecran qui porte dix onglets
|
|
318
|
+
// de natures differentes, part dans le fichier. La page les nomme, le cadre
|
|
319
|
+
// garde les tuiles dont le libelle ne depend pas de l'ecran (PDF, lien).
|
|
320
|
+
const declared = source.value?.templates ?? [];
|
|
321
|
+
const catalog = declared.length
|
|
322
|
+
? all.flatMap((template) => (template.key === "xlsx-raw" ? declared : [template]))
|
|
323
|
+
: all;
|
|
324
|
+
|
|
325
|
+
// Un modele dont le format est grise reste affiche, grise lui aussi, avec
|
|
326
|
+
// le motif : cache, il disparaissait le temps du chargement des onglets et
|
|
327
|
+
// revenait ensuite, ce qui se lisait comme un export en panne.
|
|
328
|
+
return catalog.map((template) => {
|
|
329
|
+
// Un modele qui charge affiche son spinner : le griser d'un « aucun
|
|
330
|
+
// tableau » dirait l'inverse de ce qui se passe.
|
|
331
|
+
if (template.loading) return template;
|
|
332
|
+
const format = formats.value.find((item) => item.key === template.config.format);
|
|
333
|
+
return format?.disabled
|
|
334
|
+
? { ...template, disabled: true, disabledHint: format.disabledHint }
|
|
335
|
+
: template;
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Onglet « Mes exports ». Ce qui est conserve est la recette, pas le fichier :
|
|
341
|
+
* rejouer une entree repose ses filtres puis regenere le document. La liste
|
|
342
|
+
* ne montre que les exports de cet ecran (cf. `useSavedExports`) : ailleurs,
|
|
343
|
+
* ses filtres et ses colonnes ne nommeraient rien.
|
|
344
|
+
*/
|
|
345
|
+
const recent = computed(() =>
|
|
346
|
+
saved.list.value.map((entry) => ({
|
|
347
|
+
id: entry.id,
|
|
348
|
+
name: entry.title,
|
|
349
|
+
format: entry.format,
|
|
350
|
+
rows: entry.rows,
|
|
351
|
+
size: entry.size,
|
|
352
|
+
at: entry.at,
|
|
353
|
+
})),
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
onMounted(async () => {
|
|
357
|
+
saved.load();
|
|
358
|
+
|
|
359
|
+
// Le cadre monte ce panneau quand la tuile s'ouvre : c'est le moment ou une
|
|
360
|
+
// page qui ne garde pas ses lignes va les chercher, pour que l'etape
|
|
361
|
+
// « Colonnes » ait de quoi parler.
|
|
362
|
+
await source.value?.prepare?.();
|
|
363
|
+
await quickLaunch();
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* L'export demande par une autre tuile (le raccourci « Exporter en Excel »
|
|
368
|
+
* d'une carte de vue) : la recette par defaut du format, celle que le panneau
|
|
369
|
+
* proposerait, tous les onglets et toutes les colonnes. La vue vient d'etre
|
|
370
|
+
* appliquee : un tick pour que les watchers de la page aient pose ses filtres,
|
|
371
|
+
* `prepare` ci-dessus a relu les lignes, et `launch` rejoue `beforeExport`.
|
|
372
|
+
*/
|
|
373
|
+
async function quickLaunch() {
|
|
374
|
+
const format = quick.value;
|
|
375
|
+
if (!format) return;
|
|
376
|
+
quick.value = null;
|
|
377
|
+
if (!source.value) return;
|
|
378
|
+
if (formats.value.find((item) => item.key === format)?.disabled) return;
|
|
379
|
+
await nextTick();
|
|
380
|
+
await launch({
|
|
381
|
+
format,
|
|
382
|
+
range: source.value.ranges?.[0]?.key ?? "filtered",
|
|
383
|
+
sections: sections.value.filter((section) => section.on).map((section) => section.key),
|
|
384
|
+
columns: [],
|
|
385
|
+
options: RM_EXPORT_OPTIONS.filter((option) => option.on).map((option) => option.key),
|
|
386
|
+
fileName: source.value.fileName,
|
|
387
|
+
layout: layouts.value[0]?.key ?? "a4-portrait",
|
|
388
|
+
schedule: "once",
|
|
389
|
+
frequency: "monthly",
|
|
390
|
+
email: false,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Logo Tekkare pour la page de garde PDF. Le SVG passe par un canvas parce
|
|
396
|
+
* que jsPDF ne prend que du bitmap. Charge une fois, a la demande.
|
|
397
|
+
*/
|
|
398
|
+
let logo: string | null | undefined;
|
|
399
|
+
|
|
400
|
+
async function logoDataUrl() {
|
|
401
|
+
if (logo !== undefined) return logo ?? undefined;
|
|
402
|
+
try {
|
|
403
|
+
const { default: url } = await import("../../../assets/tekkare_logo.svg");
|
|
404
|
+
const image = new Image();
|
|
405
|
+
image.src = url as unknown as string;
|
|
406
|
+
await new Promise<void>((resolve, reject) => {
|
|
407
|
+
image.onload = () => resolve();
|
|
408
|
+
image.onerror = () => reject(new Error("logo illisible"));
|
|
409
|
+
});
|
|
410
|
+
const canvas = document.createElement("canvas");
|
|
411
|
+
canvas.width = image.naturalWidth || 426;
|
|
412
|
+
canvas.height = image.naturalHeight || 56;
|
|
413
|
+
canvas.getContext("2d")!.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
414
|
+
logo = canvas.toDataURL("image/png");
|
|
415
|
+
} catch {
|
|
416
|
+
logo = null;
|
|
417
|
+
}
|
|
418
|
+
return logo ?? undefined;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Plage retenue a l'etape « Donnees », transmise a la page.
|
|
423
|
+
*
|
|
424
|
+
* Une plage peut changer les colonnes : l'historique des prix ajoute la
|
|
425
|
+
* variation, que le jeu « prix actuels » n'a pas. L'etape « Colonnes » lit
|
|
426
|
+
* les onglets de la page ; sans ce relais elle listerait ceux de l'autre
|
|
427
|
+
* plage, et la colonne propre a celle qu'on a choisie serait decochee
|
|
428
|
+
* d'office, donc absente du fichier.
|
|
429
|
+
*/
|
|
430
|
+
async function onRange(key: string) {
|
|
431
|
+
range.value = key;
|
|
432
|
+
await source.value?.onRange?.(key);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Tire le document. `replaces` nomme l'entree de « Mes exports » a mettre a
|
|
437
|
+
* jour plutot qu'a doubler : un rejeu refait la meme recette.
|
|
438
|
+
*/
|
|
439
|
+
async function launch(config: RmExportConfig, replaces?: string) {
|
|
440
|
+
if (!source.value) return;
|
|
441
|
+
// Le rejeu d'un export conserve arrive ici sans passer par le wizard : un
|
|
442
|
+
// format grise (page qui refuse) ne part pas davantage.
|
|
443
|
+
if (formats.value.find((item) => item.key === config.format)?.disabled) return;
|
|
444
|
+
busy.value = true;
|
|
445
|
+
notice.value = "";
|
|
446
|
+
error.value = "";
|
|
447
|
+
|
|
448
|
+
try {
|
|
449
|
+
// La page recharge d'abord ses lignes si la plage demandee n'est pas
|
|
450
|
+
// celle affichee : sans ca, « toute la base » exporterait la page courante.
|
|
451
|
+
await source.value.beforeExport?.(config.range);
|
|
452
|
+
|
|
453
|
+
// Laisser le rendu suivre avant de le relire. Les pages de resultats
|
|
454
|
+
// rechargent dans la liste affichee : demander « tous les resultats »
|
|
455
|
+
// remplit l'ecran de fiches, mais elles ne sont dans le DOM qu'au tick
|
|
456
|
+
// suivant, et le rapport sortait avec les dix fiches d'avant.
|
|
457
|
+
await nextTick();
|
|
458
|
+
|
|
459
|
+
// Relu apres le rechargement : `source` est un getter, il rend l'etat
|
|
460
|
+
// courant de la page et non celui capture a l'ouverture du panneau.
|
|
461
|
+
const now = source.value;
|
|
462
|
+
|
|
463
|
+
// Un rapport mis en page montre ce que l'ecran montre : ses tableaux, ses
|
|
464
|
+
// chiffres cles. Les formats de donnees, eux, gardent la donnee complete
|
|
465
|
+
// que la page a chargee pour eux.
|
|
466
|
+
const paged = formats.value.find((format) => format.key === config.format)?.paged === true;
|
|
467
|
+
const screen = paged ? pageScreen() : undefined;
|
|
468
|
+
|
|
469
|
+
const result = await run(
|
|
470
|
+
// Les colonnes retenues a l'etape « Colonnes » nomment celles du tableau
|
|
471
|
+
// complet ; celui de l'ecran a les siennes (des annees, un intitule
|
|
472
|
+
// different). Croiser les deux listes ne garderait que les rares noms
|
|
473
|
+
// communs : le rapport sortait avec la seule colonne « Label ».
|
|
474
|
+
screen?.sheets?.length ? { ...config, columns: [] } : config,
|
|
475
|
+
{
|
|
476
|
+
title: now.title,
|
|
477
|
+
subtitle: now.subtitle,
|
|
478
|
+
kpi: screen?.kpi?.length ? screen.kpi : now.kpi,
|
|
479
|
+
// Le referentiel Directus d'abord : c'est lui qui porte la date de
|
|
480
|
+
// notre dernier chargement. La declaration de la page ne sert que si
|
|
481
|
+
// le cadre ne cite aucune source pour cet ecran.
|
|
482
|
+
sources: pageSources.value.length ? pageSources.value : now.sources,
|
|
483
|
+
filters: now.filters,
|
|
484
|
+
sheets: screen?.sheets?.length ? screen.sheets : pageSheets(),
|
|
485
|
+
// Les fiches de l'ecran plutot que des fiches derivees des lignes : la
|
|
486
|
+
// page de resultats montre un titre, un compteur et deux lignes de
|
|
487
|
+
// resume, pas les trente colonnes que le classeur porte.
|
|
488
|
+
cards: screen?.cards?.length ? screen.cards : undefined,
|
|
489
|
+
// Relus maintenant, et non a l'ouverture du panneau : `beforeExport`
|
|
490
|
+
// a pu recharger la page, et les noeuds captures avant seraient ceux
|
|
491
|
+
// d'un rendu qui n'existe plus.
|
|
492
|
+
charts: pageCharts(),
|
|
493
|
+
// Les cartes de l'ecran, capturees telles quelles par le rapport mis
|
|
494
|
+
// en page : ses tableaux reconstruits ne ressemblaient pas a la page.
|
|
495
|
+
nodes: paged ? visibleExportNodes() : undefined,
|
|
496
|
+
// Sans permalink declare par la page, l'URL courante ne suffit pas :
|
|
497
|
+
// les filtres du hub ne sont pas dedans (cf. usePermalink).
|
|
498
|
+
permalink: now.permalink?.() ?? (await permalink.build()),
|
|
499
|
+
limit: now.limit,
|
|
500
|
+
logoDataUrl: config.options.includes("logo") ? await logoDataUrl() : undefined,
|
|
501
|
+
},
|
|
502
|
+
locale.value === "en" ? "en" : "fr",
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
oipTrack(
|
|
506
|
+
"download",
|
|
507
|
+
getExportPosthogProps(now.posthogName ?? now.fileName, {
|
|
508
|
+
exportType: "page",
|
|
509
|
+
format: config.format,
|
|
510
|
+
rows: result.rows,
|
|
511
|
+
sections: config.sections.join(","),
|
|
512
|
+
columns: config.columns.length,
|
|
513
|
+
}),
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
if (config.format === "link") {
|
|
517
|
+
notice.value =
|
|
518
|
+
locale.value === "en"
|
|
519
|
+
? `Link copied: ${result.url}`
|
|
520
|
+
: `Lien copié : ${result.url}`;
|
|
521
|
+
} else {
|
|
522
|
+
notice.value =
|
|
523
|
+
locale.value === "en" ? `${result.fileName} ready.` : `${result.fileName} prêt.`;
|
|
524
|
+
// Garde apres coup : le fichier est deja parti, un echec
|
|
525
|
+
// d'enregistrement ne doit pas se lire comme un echec d'export.
|
|
526
|
+
await saved.save(
|
|
527
|
+
{
|
|
528
|
+
title: result.fileName,
|
|
529
|
+
pageLabel: now.title,
|
|
530
|
+
config,
|
|
531
|
+
rows: result.rows,
|
|
532
|
+
size: result.bytes ? rmExportSize(result.bytes / 1024) : undefined,
|
|
533
|
+
},
|
|
534
|
+
replaces,
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
} catch (exception: any) {
|
|
538
|
+
error.value =
|
|
539
|
+
exception?.message ||
|
|
540
|
+
(locale.value === "en" ? "Export failed." : "L'export a échoué.");
|
|
541
|
+
} finally {
|
|
542
|
+
busy.value = false;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Rejoue un export conserve : ses filtres, puis sa configuration.
|
|
548
|
+
*
|
|
549
|
+
* Le rejeu ecrit dans l'entree d'origine : la liste est l'historique des
|
|
550
|
+
* recettes tirees, pas celui des clics, et rejouer dix fois le meme export ne
|
|
551
|
+
* doit pas remplir « Mes exports » de dix lignes identiques.
|
|
552
|
+
*
|
|
553
|
+
* L'ordre compte. Les filtres reposes relancent les requetes de la page, et
|
|
554
|
+
* c'est `prepare()` qui va rechercher les lignes d'export — sans cette attente
|
|
555
|
+
* le document sortirait avec les lignes de l'ecran precedent. Les pages qui
|
|
556
|
+
* ne declarent ni `prepare` ni `beforeExport` gardent leurs lignes courantes :
|
|
557
|
+
* la leur arrive avec le rechargement de leurs propres watchers.
|
|
558
|
+
*
|
|
559
|
+
* Ce qu'on peut attendre : `onApply` (via applyFilters) est type `void` dans
|
|
560
|
+
* useViews et aucune source d'export n'expose de drapeau de chargement. On
|
|
561
|
+
* attend donc la promesse d'onApply si la page en rend une, puis un tick pour
|
|
562
|
+
* que les watchers de la page aient pose les nouveaux filtres dans le
|
|
563
|
+
* magasin : `prepare()` et `beforeExport()` relisent alors une requete a jour.
|
|
564
|
+
*/
|
|
565
|
+
async function rerun(id: string) {
|
|
566
|
+
const entry = saved.find(id);
|
|
567
|
+
// `find` cherche dans tous les exports du compte, la liste affichee n'en
|
|
568
|
+
// montre qu'une part et l'identifiant vient du DOM : le garde-fou reste.
|
|
569
|
+
if (!entry || !saved.isHere(entry)) return;
|
|
570
|
+
|
|
571
|
+
await saved.applyFilters(entry);
|
|
572
|
+
await nextTick();
|
|
573
|
+
await source.value?.prepare?.();
|
|
574
|
+
await launch(entry.config, entry.id);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function forget(id: string) {
|
|
578
|
+
saved.remove(id);
|
|
579
|
+
}
|
|
580
|
+
</script>
|
|
581
|
+
|
|
582
|
+
<style scoped>
|
|
583
|
+
.oip_export_tool {
|
|
584
|
+
padding-bottom: 24px;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
.oip_export_notice {
|
|
588
|
+
margin-bottom: 16px;
|
|
589
|
+
}
|
|
590
|
+
</style>
|