@tekkare/romulus 0.1.133 → 0.1.135

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 CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=3.16.0"
6
6
  },
7
- "version": "0.1.132",
7
+ "version": "0.1.134",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "0.8.4",
10
10
  "unbuild": "2.0.0"
@@ -110,7 +110,7 @@ const FR = {
110
110
  kindView: 'Une vue',
111
111
  kindViewHint: 'Un écran filtré, aussi fin que vous voulez, mais limité à cette page.',
112
112
  kindProduct: 'Une marque',
113
- kindProductHint: 'Une à {max} marques, suivies partout : prix, avis HAS, indications, ANSM, PUT, MITM, Journal officiel.',
113
+ kindProductHint: 'Une à {max} marques, suivies partout : prix, avis HAS, indications, ANSM, PUT, MITM, Journal officiel, ventes ville et hospitalières.',
114
114
  brands: 'Marques surveillées',
115
115
  brandsSearch: 'Rechercher',
116
116
  brandsHint: 'Cherchez par marque, molécule, classe ATC ou laboratoire. L’alerte couvre tout le hub pour chaque marque retenue.',
@@ -163,7 +163,7 @@ const EN = {
163
163
  kindView: 'A view',
164
164
  kindViewHint: 'A filtered screen, as precise as you like, but tied to that page.',
165
165
  kindProduct: 'A brand',
166
- kindProductHint: 'One to {max} brands, watched everywhere: prices, HAS opinions, indications, ANSM, PUT, MITM, official journal.',
166
+ kindProductHint: 'One to {max} brands, watched everywhere: prices, HAS opinions, indications, ANSM, PUT, MITM, official journal, retail and hospital sales.',
167
167
  brands: 'Watched brands',
168
168
  brandsSearch: 'Search',
169
169
  brandsHint: 'Search by brand, molecule, ATC class or company. The alert covers the whole hub for each brand kept.',
@@ -23,7 +23,7 @@
23
23
  -->
24
24
  <script setup lang="ts">
25
25
  import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
26
- import type { RmExportPayload } from '../utils/export'
26
+ import type { RmExportPayload, RmExportSource } from '../utils/export'
27
27
  import { rmCardCharts, rmCardOf } from '../utils/cardData'
28
28
  import { useRmCardActions } from '../composables/useRmCardActions'
29
29
 
@@ -34,6 +34,12 @@ const props = withDefaults(defineProps<{
34
34
  link?: string
35
35
  /** Nom du fichier et de l'onglet. A defaut, le titre lu dans la carte. */
36
36
  name?: string
37
+ /**
38
+ * Source citee par le pied de la carte. Dernier recours : la page
39
+ * (`payload.sources`) puis le cadre (`provideRmExportDefaults`) passent
40
+ * devant, parce qu'eux lisent le referentiel et sa date de mise a jour.
41
+ */
42
+ sources?: RmExportSource[]
37
43
  /**
38
44
  * Ce que la page a de mieux que le rendu de la carte : ses onglets complets
39
45
  * (`sheets`), mais aussi ses chiffres cles, ses sources, son sous-titre. Ce
@@ -54,6 +60,7 @@ const props = withDefaults(defineProps<{
54
60
  lang: 'fr',
55
61
  link: undefined,
56
62
  name: undefined,
63
+ sources: undefined,
57
64
  payload: undefined,
58
65
  visual: undefined,
59
66
  })
@@ -168,6 +175,7 @@ async function act(action: 'png' | 'svg' | 'csv' | 'xlsx' | 'link') {
168
175
  lang: props.lang,
169
176
  name: props.name,
170
177
  link: props.link,
178
+ sources: props.sources,
171
179
  payload: props.payload,
172
180
  })
173
181
  if (action === 'link') {
@@ -11,6 +11,7 @@
11
11
  -->
12
12
  <script setup lang="ts">
13
13
  import { computed } from 'vue'
14
+ import type { RmExportPayload } from '../utils/export'
14
15
  import { rmDate } from '../utils/format'
15
16
 
16
17
  const props = withDefaults(defineProps<{
@@ -37,6 +38,12 @@ const props = withDefaults(defineProps<{
37
38
  * qui est le bon lien pour une carte d'analyse : elle vit dans sa page.
38
39
  */
39
40
  link?: string
41
+ /**
42
+ * Ce que la page a de mieux que le rendu de la carte pour son export :
43
+ * onglets complets, sources, sous-titre (cf. RmCardActions). En fonction
44
+ * des qu'une liste monte la carte plusieurs fois.
45
+ */
46
+ payload?: Partial<RmExportPayload> | (() => Partial<RmExportPayload> | null | undefined | Promise<Partial<RmExportPayload> | null | undefined>)
40
47
  /**
41
48
  * Etat de la donnee. Les trois variantes se posent dans le CORPS ; la tete
42
49
  * et le pied restent stables, pour que le titre et la source restent sous
@@ -56,6 +63,7 @@ const props = withDefaults(defineProps<{
56
63
  extra: undefined,
57
64
  actions: true,
58
65
  link: undefined,
66
+ payload: undefined,
59
67
  state: 'ready',
60
68
  retryLabel: '',
61
69
  emptyActionLabel: '',
@@ -135,6 +143,8 @@ const verifiedText = computed(() => {
135
143
  :lang="lang"
136
144
  :link="link"
137
145
  :name="context ? `${title} (${context})` : title"
146
+ :sources="[{ name: source, date: verifiedText }]"
147
+ :payload="payload"
138
148
  @export-png="emit('exportPng')"
139
149
  @export-svg="emit('exportSvg')"
140
150
  @export-csv="emit('exportCsv')"
@@ -42,6 +42,7 @@
42
42
  <script setup lang="ts">
43
43
  import { computed, ref } from 'vue'
44
44
  import { useRmExport } from '../composables/useRmExport'
45
+ import { rmExportDefaults } from '../composables/useRmExportDefaults'
45
46
  import type { RmExportKpi, RmExportSheet, RmExportSource } from '../utils/export'
46
47
 
47
48
  const props = withDefaults(
@@ -151,6 +152,9 @@ async function write() {
151
152
  if (held.length) await Promise.all(held)
152
153
 
153
154
  const { run } = useRmExport()
155
+ // Sans sources declarees, celles du cadre : la page les cite deja dans
156
+ // son panneau « Sources », le classeur ne doit pas sortir sans.
157
+ const defaults = rmExportDefaults()
154
158
  const result = await run(
155
159
  {
156
160
  format: 'xlsx',
@@ -168,7 +172,7 @@ async function write() {
168
172
  title: props.title || buttonLabel.value,
169
173
  subtitle: props.subtitle,
170
174
  kpi: props.kpi,
171
- sources: props.sources,
175
+ sources: props.sources.length ? props.sources : defaults.sources,
172
176
  sheets: props.sheets,
173
177
  limit: props.limit,
174
178
  },
@@ -12,7 +12,7 @@
12
12
  * d'une declaration de la page : c'est ce qui rend le menu utilisable sur les
13
13
  * cartes deja ecrites, sans les rouvrir une par une.
14
14
  */
15
- import type { RmExportPayload } from '../utils/export.js';
15
+ import type { RmExportPayload, RmExportSource } from '../utils/export.js';
16
16
  /** Les actions qu'une carte propose. `link` copie, il ne telecharge pas. */
17
17
  export type RmCardAction = 'png' | 'svg' | 'csv' | 'xlsx' | 'link';
18
18
  export type RmCardPayload = Partial<RmExportPayload>;
@@ -24,6 +24,13 @@ export interface RmCardActionContext {
24
24
  name?: string;
25
25
  /** Lien copie par « Copier le lien direct ». A defaut, l'URL courante. */
26
26
  link?: string;
27
+ /**
28
+ * Ce que le pied de la carte dit de sa source. Ne sert que si ni la page
29
+ * (`payload`) ni le cadre (`provideRmExportDefaults`) n'ont mieux : le cadre
30
+ * lit le referentiel des sources, le pied d'une carte peut n'en porter
31
+ * qu'un libelle.
32
+ */
33
+ sources?: RmExportSource[];
27
34
  /**
28
35
  * Ce que la page a de mieux que le rendu a l'ecran : ses onglets complets,
29
36
  * ses chiffres cles, ses sources, son sous-titre. Ce qui est pose ici
@@ -1,5 +1,6 @@
1
1
  import { rmCardCharts, rmCardSheets, rmCardTitle } from "../utils/cardData.js";
2
2
  import { useRmExport } from "./useRmExport.js";
3
+ import { rmExportDefaults } from "./useRmExportDefaults.js";
3
4
  const FILE_NAME_MAX = 60;
4
5
  function fileNameOf(label) {
5
6
  const flat = label.normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -42,15 +43,19 @@ export function useRmCardActions() {
42
43
  async function runCardAction(action, context) {
43
44
  const { card, lang = "fr" } = context;
44
45
  const provided = await (typeof context.payload === "function" ? context.payload() : context.payload) ?? {};
46
+ const defaults = rmExportDefaults();
45
47
  const title = context.name || provided.title || rmCardTitle(card) || "Export";
48
+ const sources = provided.sources?.length ? provided.sources : defaults.sources?.length ? defaults.sources : context.sources;
46
49
  const format = action === "link" ? "link" : action;
47
50
  const sheets = action === "png" || action === "svg" ? [] : provided.sheets?.length ? provided.sheets : await rmCardSheets(card, title, lang);
48
51
  return run(configOf(format, fileNameOf(title)), {
52
+ ...defaults,
49
53
  title,
50
54
  charts: rmCardCharts(card).map((el) => ({ title, el })),
51
55
  permalink: absolute(context.link),
52
56
  ...provided,
53
- sheets
57
+ sheets,
58
+ sources
54
59
  }, lang);
55
60
  }
56
61
  async function toggleCardFullscreen(card) {
@@ -2,6 +2,8 @@ import { RM_EXPORT_EXTENSIONS } from "../utils/export.js";
2
2
  const BRAND_TITLE = "OIP Healthcare";
3
3
  const BRAND_LINE = "By Tekkare \u{1F49C}";
4
4
  const COVER_SHEET = "Tekkare";
5
+ const CARD_PAGES_MAX = 10;
6
+ const CARD_COUNT_MAX = 2e3;
5
7
  function longDate(lang) {
6
8
  return (/* @__PURE__ */ new Date()).toLocaleDateString(lang === "en" ? "en-US" : "fr-FR", {
7
9
  year: "numeric",
@@ -9,6 +11,27 @@ function longDate(lang) {
9
11
  day: "numeric"
10
12
  });
11
13
  }
14
+ function sourceDate(source, lang) {
15
+ if (!source.date) return null;
16
+ const parsed = new Date(source.date);
17
+ if (Number.isNaN(parsed.getTime())) return String(source.date);
18
+ return parsed.toLocaleDateString(lang === "en" ? "en-US" : "fr-FR", {
19
+ year: "numeric",
20
+ month: "long",
21
+ day: "numeric"
22
+ });
23
+ }
24
+ function sourceLabel(source, lang) {
25
+ const date = sourceDate(source, lang);
26
+ return date ? `${source.name} (${date})` : source.name;
27
+ }
28
+ function sourcesOf(config, payload) {
29
+ const wanted = config.sections.includes("sources") || config.options.includes("sources");
30
+ return wanted ? payload.sources ?? [] : [];
31
+ }
32
+ function escapeXml(text) {
33
+ return text.replace(/[<>&"']/g, (char) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;", "'": "&apos;" })[char] ?? char);
34
+ }
12
35
  function rowsOf(sheet, limit) {
13
36
  return limit && sheet.data.length > limit ? sheet.data.slice(0, limit) : sheet.data;
14
37
  }
@@ -105,7 +128,7 @@ function printable(text) {
105
128
  return substituted.replace(/[^\u0000-\u00ff]/g, "").replace(/\s{2,}/g, " ").trim();
106
129
  }
107
130
  function cardsOf(payload, config, lang) {
108
- if (payload.cards?.length) return payload.cards.slice(0, payload.cardLimit ?? 100);
131
+ if (payload.cards?.length) return payload.cards.slice(0, payload.cardLimit ?? CARD_COUNT_MAX);
109
132
  const sheet = payload.sheets[0];
110
133
  if (!sheet) return [];
111
134
  const pick = (name) => name ? sheet.schema.find((column) => column.column === name) : void 0;
@@ -115,7 +138,7 @@ function cardsOf(payload, config, lang) {
115
138
  const head = title ?? columns[0];
116
139
  if (!head) return [];
117
140
  const fields = columns.filter((column) => column !== head && column !== subtitle);
118
- return rowsOf(sheet, payload.cardLimit ?? 100).map((row) => ({
141
+ return rowsOf(sheet, payload.cardLimit ?? CARD_COUNT_MAX).map((row) => ({
119
142
  title: cellText(head.value(row), lang) || "\u2014",
120
143
  subtitle: subtitle ? cellText(subtitle.value(row), lang) : void 0,
121
144
  fields: fields.map((column) => ({ label: column.column, value: cellText(column.value(row), lang) })).filter((field) => field.value !== "")
@@ -185,15 +208,11 @@ export function useRmExport() {
185
208
  (filter) => line(`${filter.label} : ${filter.value}`, { fontSize: 16, color: "#b2b2b2" })
186
209
  );
187
210
  }
188
- const withSources = config.sections.includes("sources") || config.options.includes("sources");
189
- if (payload.sources?.length && withSources) {
211
+ const sources = sourcesOf(config, payload);
212
+ if (sources.length) {
190
213
  rows.push(filler());
191
- payload.sources.forEach((source) => {
192
- const date = source.date ? new Date(source.date).toLocaleDateString(lang === "en" ? "en-US" : "fr-FR", {
193
- year: "numeric",
194
- month: "long",
195
- day: "numeric"
196
- }) : null;
214
+ sources.forEach((source) => {
215
+ const date = sourceDate(source, lang);
197
216
  const update = date ? lang === "en" ? ` - Last update: ${date}` : ` - Derni\xE8re mise \xE0 jour : ${date}` : "";
198
217
  line(`${source.emoji ? `${source.emoji} ` : ""}Source : ${source.name}${update}`, { fontSize: 18 });
199
218
  });
@@ -235,6 +254,16 @@ export function useRmExport() {
235
254
  );
236
255
  blocks.push(lines.join("\n"));
237
256
  });
257
+ const sources = sourcesOf(config, payload);
258
+ if (sources.length) {
259
+ blocks.push(
260
+ sources.map((source) => {
261
+ const date = sourceDate(source, lang);
262
+ const update = date ? lang === "en" ? ` - Last update: ${date}` : ` - Derni\xE8re mise \xE0 jour : ${date}` : "";
263
+ return `# Source : ${source.name}${update}`;
264
+ }).join("\n")
265
+ );
266
+ }
238
267
  const fileName = `${config.fileName}.csv`;
239
268
  const blob = new Blob([`\uFEFF${blocks.join("\n\n")}
240
269
  `], { type: "text/csv;charset=utf-8" });
@@ -245,7 +274,8 @@ export function useRmExport() {
245
274
  if (!charts.length) throw new Error(lang === "en" ? "No chart on this page" : "Aucun graphique sur cette page");
246
275
  const scale = 2;
247
276
  const pad = 24;
248
- const head = config.options.includes("logo") ? 72 : 0;
277
+ const sources = sourcesOf(config, payload);
278
+ const head = config.options.includes("logo") ? sources.length ? 88 : 72 : 0;
249
279
  const parts = charts.map((svg) => serialize(svg));
250
280
  const width = Math.max(...parts.map((part) => part.width)) + pad * 2;
251
281
  const height = parts.reduce((total, part) => total + part.height + pad, pad) + head;
@@ -263,6 +293,9 @@ export function useRmExport() {
263
293
  context.fillStyle = "#6C6C8A";
264
294
  context.font = "12px Figtree, Helvetica, sans-serif";
265
295
  context.fillText(`${payload.title} \xB7 ${BRAND_LINE} \xB7 ${longDate(lang)}`, pad, 52);
296
+ if (sources.length) {
297
+ context.fillText(`Source${sources.length > 1 ? "s" : ""} : ${sources.map((source) => sourceLabel(source, lang)).join(" \xB7 ")}`, pad, 68);
298
+ }
266
299
  }
267
300
  let y = head + pad;
268
301
  for (const part of parts) {
@@ -280,7 +313,8 @@ export function useRmExport() {
280
313
  const charts = (payload.charts ?? []).map((chart) => svgOf(chart.el)).filter(Boolean);
281
314
  if (!charts.length) throw new Error(lang === "en" ? "No chart on this page" : "Aucun graphique sur cette page");
282
315
  const pad = 24;
283
- const head = config.options.includes("logo") ? 72 : 0;
316
+ const sources = sourcesOf(config, payload);
317
+ const head = config.options.includes("logo") ? sources.length ? 88 : 72 : 0;
284
318
  const parts = charts.map((svg) => serialize(svg));
285
319
  const width = Math.max(...parts.map((part) => part.width)) + pad * 2;
286
320
  const height = parts.reduce((total, part) => total + part.height + pad, pad) + head;
@@ -290,7 +324,7 @@ export function useRmExport() {
290
324
  y += part.height + pad;
291
325
  return markup2;
292
326
  }).join("\n");
293
- const header = head ? `<text x="${pad}" y="32" font-family="Figtree, Helvetica, sans-serif" font-size="20" font-weight="bold" fill="#1A1A2E">${BRAND_TITLE}</text><text x="${pad}" y="52" font-family="Figtree, Helvetica, sans-serif" font-size="12" fill="#6C6C8A">${payload.title} \xB7 ${BRAND_LINE} \xB7 ${longDate(lang)}</text>` : "";
327
+ const header = head ? `<text x="${pad}" y="32" font-family="Figtree, Helvetica, sans-serif" font-size="20" font-weight="bold" fill="#1A1A2E">${BRAND_TITLE}</text><text x="${pad}" y="52" font-family="Figtree, Helvetica, sans-serif" font-size="12" fill="#6C6C8A">${escapeXml(`${payload.title} \xB7 ${BRAND_LINE} \xB7 ${longDate(lang)}`)}</text>` + (sources.length ? `<text x="${pad}" y="68" font-family="Figtree, Helvetica, sans-serif" font-size="12" fill="#6C6C8A">${escapeXml(`Source${sources.length > 1 ? "s" : ""} : ${sources.map((source) => sourceLabel(source, lang)).join(" \xB7 ")}`)}</text>` : "") : "";
294
328
  const markup = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><rect width="${width}" height="${height}" fill="#FFFFFF"/>${header}${nested}</svg>`;
295
329
  const fileName = `${config.fileName}.svg`;
296
330
  const blob = new Blob([markup], { type: "image/svg+xml;charset=utf-8" });
@@ -405,13 +439,14 @@ export function useRmExport() {
405
439
  let rowCount = 0;
406
440
  if (config.sections.includes("cards")) {
407
441
  const cards = cardsOf(payload, config, lang);
408
- rowCount = cards.length;
409
442
  const pageHeight = doc.internal.pageSize.getHeight();
410
443
  const PAD = 6;
411
444
  const COUNT_WIDTH = 32;
412
445
  const COUNT_GAP = 6;
413
446
  const LABEL_WIDTH = 26;
414
- cards.forEach((card) => {
447
+ const firstCardPage = doc.getNumberOfPages();
448
+ let drawn = 0;
449
+ for (const card of cards) {
415
450
  const hasCount = !!card.count?.value;
416
451
  const bodyX = margin + PAD + (hasCount ? COUNT_WIDTH + COUNT_GAP : 0);
417
452
  const bodyWidth = inner - PAD * 2 - (hasCount ? COUNT_WIDTH + COUNT_GAP : 0);
@@ -476,6 +511,7 @@ export function useRmExport() {
476
511
  const countHeight = hasCount ? 9 + (card.count?.label ? 4 : 0) + (card.count?.extra ? 4.5 : 0) : 0;
477
512
  const height = PAD + headHeight + 3 + Math.max(bodyHeight, countHeight) + PAD;
478
513
  if (y + height > pageHeight - margin) {
514
+ if (doc.getNumberOfPages() - firstCardPage + 1 >= CARD_PAGES_MAX) break;
479
515
  doc.addPage();
480
516
  y = margin;
481
517
  }
@@ -583,15 +619,17 @@ export function useRmExport() {
583
619
  cursor += Math.max(field.label.length, field.value.length) * 4.4;
584
620
  });
585
621
  y += height + 4;
586
- });
587
- const total = payload.sheets[0]?.data.length ?? 0;
588
- if (total > cards.length) {
622
+ drawn += 1;
623
+ }
624
+ rowCount = drawn;
625
+ const total = payload.cards?.length || (payload.sheets[0]?.data.length ?? 0);
626
+ if (total > drawn) {
589
627
  doc.setTextColor(...grey);
590
628
  doc.setFont("helvetica", "italic");
591
629
  doc.setFontSize(8);
592
630
  doc.text(
593
631
  printable(
594
- lang === "en" ? `${cards.length} of ${total} records shown. Export to Excel for the full set.` : `${cards.length} fiches sur ${total}. Passez par l'export Excel pour la totalit\xE9.`
632
+ lang === "en" ? `${drawn} of ${total} records shown. Export to Excel for the full set.` : `${drawn} fiches sur ${total}. Passez par l'export Excel pour la totalit\xE9.`
595
633
  ),
596
634
  margin,
597
635
  y + 2
@@ -645,12 +683,12 @@ export function useRmExport() {
645
683
  y = doc.lastAutoTable?.finalY ?? margin;
646
684
  });
647
685
  }
648
- const withSources = config.sections.includes("sources") || config.options.includes("sources");
649
- if (payload.sources?.length && withSources) {
686
+ const sources = sourcesOf(config, payload);
687
+ if (sources.length) {
650
688
  doc.setTextColor(...grey);
651
689
  doc.setFont("helvetica", "normal");
652
690
  doc.setFontSize(8);
653
- const names = payload.sources.map((source) => `${source.name}${source.date ? ` (${new Date(source.date).toLocaleDateString(lang === "en" ? "en-US" : "fr-FR", { year: "numeric", month: "long", day: "numeric" })})` : ""}`).join(" \xB7 ");
691
+ const names = sources.map((source) => sourceLabel(source, lang)).join(" \xB7 ");
654
692
  const lines = doc.splitTextToSize(printable(`Sources : ${names}`), inner);
655
693
  doc.text(lines, margin, Math.min(y + 8, doc.internal.pageSize.getHeight() - margin));
656
694
  }
@@ -0,0 +1,9 @@
1
+ import type { RmExportPayload } from '../utils/export.js';
2
+ export type RmExportDefaultsBuild = () => Partial<RmExportPayload> | null | undefined;
3
+ /**
4
+ * Pose les defauts de la page affichee. A appeler dans le `setup` du cadre
5
+ * (layout) : ils sont retires quand ce cadre est demonte.
6
+ */
7
+ export declare function provideRmExportDefaults(build: RmExportDefaultsBuild): void;
8
+ /** Les defauts courants, relus a chaque export : ils suivent la navigation. */
9
+ export declare function rmExportDefaults(): Partial<RmExportPayload>;
@@ -0,0 +1,13 @@
1
+ import { getCurrentScope, onScopeDispose, shallowRef } from "vue";
2
+ const registry = shallowRef(null);
3
+ export function provideRmExportDefaults(build) {
4
+ registry.value = build;
5
+ if (getCurrentScope()) {
6
+ onScopeDispose(() => {
7
+ if (registry.value === build) registry.value = null;
8
+ });
9
+ }
10
+ }
11
+ export function rmExportDefaults() {
12
+ return registry.value?.() ?? {};
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekkare/romulus",
3
- "version": "0.1.133",
3
+ "version": "0.1.135",
4
4
  "description": "Romulus Design System - Nuxt module by Tekkare",
5
5
  "license": "MIT",
6
6
  "type": "module",