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