@tekkare/romulus 0.1.36 → 0.1.37

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.
@@ -291,5 +291,5 @@ function onCardClick() {
291
291
  </template>
292
292
 
293
293
  <style scoped>
294
- .rm-view{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-lg);color:var(--text-primary);cursor:pointer;font-family:var(--font-sans);padding:14px 18px;transition:border-color var(--duration-fast) var(--ease-out),box-shadow var(--duration-fast) var(--ease-out),background var(--duration-fast) var(--ease-out)}.rm-view:not(.rm-view--active):hover{background:var(--bg-hover);border-color:var(--color-accent-soft)}.rm-view--active{border-color:var(--color-secondary);box-shadow:0 0 0 3px rgba(var(--brand-rgb),.08);cursor:default}.rm-view__strip{background:var(--bg-hover);border-bottom:1px solid var(--border-soft);border-radius:7px 7px 0 0;color:var(--color-secondary);font-size:var(--font-11);font-weight:var(--weight-bold);gap:var(--space-8);letter-spacing:.06em;margin:-14px -18px var(--space-12);padding:var(--space-8) var(--space-16);text-transform:uppercase}.rm-view__head,.rm-view__strip{align-items:center;display:flex}.rm-view__head{gap:10px}.rm-view__star{align-items:center;background:transparent;border:1px solid transparent;border-radius:var(--radius-md);color:var(--text-muted);cursor:pointer;display:inline-flex;flex:0 0 auto;height:28px;justify-content:center;padding:0;width:28px}.rm-view__star:hover{background:var(--bg-hover)}.rm-view__star--on,.rm-view__star:hover{color:var(--color-warning)}.rm-view__title,.rm-view__title-edit{flex:1;font-size:var(--font-16);font-weight:var(--weight-bold);min-width:0}.rm-view__title-edit{background:var(--bg-surface);border:1px solid var(--color-secondary);border-radius:var(--radius-md);color:var(--text-primary);font-family:inherit;outline:none;padding:2px var(--space-8)}.rm-view__title-edit:focus{box-shadow:0 0 0 3px rgba(var(--brand-rgb),.12)}.rm-view__actions{align-items:center;display:flex;gap:var(--space-8)}.rm-view__badge{align-items:center;background:var(--color-success-bg);border-radius:var(--radius-pill);color:var(--color-success);display:inline-flex;font-size:var(--font-10);font-weight:var(--weight-bold);gap:var(--space-4);letter-spacing:.06em;padding:3px var(--space-8);text-transform:uppercase}.rm-view__menu-wrap{position:relative}.rm-view__more{align-items:center;background:transparent;border:none;border-radius:var(--radius-md);color:var(--text-muted);cursor:pointer;display:inline-flex;height:28px;justify-content:center;padding:0;width:28px}.rm-view__more:hover{background:var(--bg-hover);color:var(--text-primary)}.rm-view__menu-shade{inset:0;position:fixed;z-index:60}.rm-view__menu{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);display:flex;flex-direction:column;min-width:200px;padding:var(--space-4);position:absolute;right:0;top:calc(100% + 6px);z-index:61}.rm-view__menu-item{align-items:center;background:transparent;border:none;border-radius:var(--radius-md);color:var(--text-primary);cursor:pointer;display:flex;font-family:inherit;font-size:var(--font-13);gap:var(--space-8);padding:var(--space-8) var(--space-12);text-align:left;width:100%}.rm-view__menu-item:hover{background:var(--bg-hover);color:var(--color-secondary)}.rm-view__menu-item--danger{color:var(--color-danger)}.rm-view__menu-item--danger:hover{background:var(--color-danger-bg);color:var(--color-danger)}.rm-view__desc{color:var(--text-secondary);font-size:var(--font-13);margin:var(--space-8) 0 var(--space-12)}.rm-view__config{background:var(--bg-surface-2);border:1px solid var(--border-soft);border-radius:var(--radius-md);display:flex;flex-direction:column;gap:var(--space-6);margin:10px 0 var(--space-12);padding:10px 14px}.rm-view__row{align-items:flex-start;display:flex;font-size:var(--font-12);gap:14px}.rm-view__row-key{color:var(--text-muted);display:inline-flex;flex-shrink:0;font-size:var(--font-10);font-weight:var(--weight-semibold);letter-spacing:.04em;min-width:96px;padding-top:4px;text-transform:uppercase}.rm-view__row-key,.rm-view__row-val{align-items:center;gap:var(--space-6)}.rm-view__row-val{color:var(--text-primary);display:flex;flex-wrap:wrap;padding:2px 0}.rm-view__pill{align-items:center;background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-md);display:inline-flex;font-size:var(--font-12);gap:var(--space-4);padding:5px 10px}.rm-view__pill-k{color:var(--color-primary);font-weight:var(--weight-semibold)}.rm-view__pill-v{color:var(--text-primary);font-weight:var(--weight-medium)}.rm-view__foot{border-top:1px solid var(--border-soft);color:var(--text-muted);display:flex;flex-wrap:wrap;font-size:var(--font-11);gap:var(--space-16);padding-top:10px}.rm-view__foot span{align-items:center;display:inline-flex;gap:var(--space-4)}.rm-view__confirm{align-items:center;background:var(--color-danger-bg);border:1px solid var(--color-danger);border-radius:var(--radius-md);color:var(--color-danger);display:flex;font-size:var(--font-13);gap:var(--space-12);margin-top:var(--space-12);padding:var(--space-8) var(--space-12)}.rm-view__confirm>span{align-items:center;display:inline-flex;flex:1;gap:var(--space-8)}.rm-view__confirm-actions{display:flex;flex-shrink:0;gap:var(--space-8)}@media (max-width:480px){.rm-view__row{flex-direction:column;gap:var(--space-4)}.rm-view__confirm{flex-wrap:wrap}}
294
+ .rm-view{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-lg);color:var(--text-primary);cursor:pointer;font-family:var(--font-sans);padding:14px 18px;transition:border-color var(--duration-fast) var(--ease-out),box-shadow var(--duration-fast) var(--ease-out),background var(--duration-fast) var(--ease-out)}.rm-view:not(.rm-view--active):hover{background:var(--bg-hover);border-color:var(--color-accent-soft)}.rm-view--active{border-color:var(--color-secondary);box-shadow:0 0 0 3px rgba(var(--brand-rgb),.08);cursor:default}.rm-view__strip{background:var(--bg-hover);border-bottom:1px solid var(--border-soft);border-radius:7px 7px 0 0;color:var(--color-secondary);font-size:var(--font-11);font-weight:var(--weight-bold);gap:var(--space-8);letter-spacing:.06em;margin:-14px -18px var(--space-12);padding:var(--space-8) var(--space-16);text-transform:uppercase}.rm-view__head,.rm-view__strip{align-items:center;display:flex}.rm-view__head{gap:10px}.rm-view__star{align-items:center;background:transparent;border:1px solid transparent;border-radius:var(--radius-md);color:var(--text-muted);cursor:pointer;display:inline-flex;flex:0 0 auto;height:28px;justify-content:center;padding:0;width:28px}.rm-view__star:hover{background:var(--bg-hover)}.rm-view__star--on,.rm-view__star:hover{color:var(--color-warning)}.rm-view__title,.rm-view__title-edit{flex:1;font-size:var(--font-16);font-weight:var(--weight-bold);min-width:0}.rm-view__title-edit{background:var(--bg-surface);border:1px solid var(--color-secondary);border-radius:var(--radius-md);color:var(--text-primary);font-family:inherit;outline:none;padding:2px var(--space-8)}.rm-view__title-edit:focus{box-shadow:0 0 0 3px rgba(var(--brand-rgb),.12)}.rm-view__actions{align-items:center;display:flex;gap:var(--space-8)}.rm-view__badge{align-items:center;background:var(--color-success-bg);border-radius:var(--radius-pill);color:var(--color-success);display:inline-flex;font-size:var(--font-10);font-weight:var(--weight-bold);gap:var(--space-4);letter-spacing:.06em;padding:3px var(--space-8);text-transform:uppercase}.rm-view__menu-wrap{position:relative}.rm-view__more{align-items:center;background:transparent;border:none;border-radius:var(--radius-md);color:var(--text-muted);cursor:pointer;display:inline-flex;height:28px;justify-content:center;padding:0;width:28px}.rm-view__more:hover{background:var(--bg-hover);color:var(--text-primary)}.rm-view__menu-shade{inset:0;position:fixed;z-index:60}.rm-view__menu{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);display:flex;flex-direction:column;min-width:200px;padding:var(--space-4);position:absolute;right:0;top:calc(100% + 6px);z-index:61}.rm-view__menu-item{align-items:center;background:transparent;border:none;border-radius:var(--radius-md);color:var(--text-primary);cursor:pointer;display:flex;font-family:inherit;font-size:var(--font-13);gap:var(--space-8);padding:var(--space-8) var(--space-12);text-align:left;width:100%}.rm-view__menu-item:hover{background:var(--bg-hover);color:var(--color-secondary)}.rm-view__menu-item--danger{color:var(--color-danger)}.rm-view__menu-item--danger:hover{background:var(--color-danger-bg);color:var(--color-danger)}.rm-view__desc{color:var(--text-secondary);font-size:var(--font-13);margin:var(--space-8) 0 var(--space-12)}.rm-view__config{background:var(--bg-surface-2);border:1px solid var(--border-soft);border-radius:var(--radius-md);display:flex;flex-direction:column;gap:var(--space-6);margin:10px 0 var(--space-12);padding:10px 14px}.rm-view__row{align-items:flex-start;display:flex;flex-wrap:wrap;font-size:var(--font-12);gap:14px}.rm-view__row-key{color:var(--text-muted);display:inline-flex;flex-shrink:0;font-size:var(--font-10);font-weight:var(--weight-semibold);letter-spacing:.04em;min-width:96px;padding-top:4px;text-transform:uppercase}.rm-view__row-key,.rm-view__row-val{align-items:center;gap:var(--space-6)}.rm-view__row-val{color:var(--text-primary);display:flex;flex:1 1 200px;flex-wrap:wrap;padding:2px 0}.rm-view__pill{align-items:center;background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-md);display:inline-flex;font-size:var(--font-12);gap:var(--space-4);padding:5px 10px}.rm-view__pill-k{color:var(--color-primary);font-weight:var(--weight-semibold)}.rm-view__pill-v{color:var(--text-primary);font-weight:var(--weight-medium)}.rm-view__foot{border-top:1px solid var(--border-soft);color:var(--text-muted);display:flex;flex-wrap:wrap;font-size:var(--font-11);gap:var(--space-16);padding-top:10px}.rm-view__foot span{align-items:center;display:inline-flex;gap:var(--space-4)}.rm-view__confirm{align-items:center;background:var(--color-danger-bg);border:1px solid var(--color-danger);border-radius:var(--radius-md);color:var(--color-danger);display:flex;flex-wrap:wrap;font-size:var(--font-13);gap:var(--space-12);margin-top:var(--space-12);padding:var(--space-8) var(--space-12)}.rm-view__confirm>span{align-items:center;display:inline-flex;flex:1 1 220px;gap:var(--space-8)}.rm-view__confirm-actions{display:flex;flex-shrink:0;gap:var(--space-8)}
295
295
  </style>
@@ -0,0 +1,29 @@
1
+ /**
2
+ * useRmExport - les generateurs de fichiers derriere `RmExportPanel`.
3
+ *
4
+ * Le wizard produit une configuration, la page produit un contenu
5
+ * (`RmExportPayload`) ; ce composable croise les deux et rend un fichier. Il
6
+ * vit dans le paquet et pas dans les apps parce que l'en-tete « OIP Healthcare
7
+ * par Tekkare », la page de garde du classeur et la mise en page A4 doivent
8
+ * etre les memes partout : un export de Hub-R et un export d'une app pays ne
9
+ * peuvent pas se presenter differemment devant un client.
10
+ *
11
+ * Toutes les bibliotheques lourdes (write-excel-file, jsPDF) sont importees a
12
+ * l'appel, pas au chargement de la page : un ecran qui n'exporte rien ne paie
13
+ * pas leur poids.
14
+ */
15
+ import type { RmExportConfig, RmExportPayload } from '../utils/export.js';
16
+ export interface RmExportResult {
17
+ /** Nom du fichier rendu, extension comprise. Vide pour un permalink. */
18
+ fileName: string;
19
+ /** Nombre de lignes reellement ecrites. */
20
+ rows: number;
21
+ /** Taille du fichier rendu, en octets. */
22
+ bytes: number;
23
+ /** URL, pour le format « Permalink ». */
24
+ url?: string;
25
+ }
26
+ export declare function useRmExport(): {
27
+ run: (config: RmExportConfig, payload: RmExportPayload, lang?: "fr" | "en") => Promise<RmExportResult>;
28
+ extensions: Record<string, string>;
29
+ };
@@ -0,0 +1,404 @@
1
+ import { RM_EXPORT_EXTENSIONS } from "../utils/export.js";
2
+ const BRAND_TITLE = "OIP Healthcare";
3
+ const BRAND_LINE = "par Tekkare";
4
+ function longDate(lang) {
5
+ return (/* @__PURE__ */ new Date()).toLocaleDateString(lang === "en" ? "en-US" : "fr-FR", {
6
+ year: "numeric",
7
+ month: "long",
8
+ day: "numeric"
9
+ });
10
+ }
11
+ function rowsOf(sheet, limit) {
12
+ return limit && sheet.data.length > limit ? sheet.data.slice(0, limit) : sheet.data;
13
+ }
14
+ function columnsOf(sheet, keep) {
15
+ if (!keep.length) return sheet.schema;
16
+ const kept = sheet.schema.filter((column) => keep.includes(column.column));
17
+ return kept.length ? kept : sheet.schema;
18
+ }
19
+ function cellText(value, lang) {
20
+ if (value === null || value === void 0) return "";
21
+ if (value instanceof Date) {
22
+ return value.toLocaleDateString(lang === "en" ? "en-US" : "fr-FR", {
23
+ year: "numeric",
24
+ month: "long",
25
+ day: "numeric"
26
+ });
27
+ }
28
+ if (Array.isArray(value)) return value.join(", ");
29
+ return String(value);
30
+ }
31
+ function download(blob, fileName) {
32
+ const url = URL.createObjectURL(blob);
33
+ const link = document.createElement("a");
34
+ link.href = url;
35
+ link.download = fileName;
36
+ document.body.appendChild(link);
37
+ link.click();
38
+ link.remove();
39
+ setTimeout(() => URL.revokeObjectURL(url), 1e4);
40
+ return blob.size;
41
+ }
42
+ function svgOf(el) {
43
+ return el instanceof SVGSVGElement ? el : el.querySelector("svg");
44
+ }
45
+ function sizeOf(svg) {
46
+ const box = svg.getBoundingClientRect();
47
+ const width = Math.round(box.width || svg.viewBox?.baseVal?.width || 800);
48
+ const height = Math.round(box.height || svg.viewBox?.baseVal?.height || 400);
49
+ return { width, height };
50
+ }
51
+ function serialize(svg) {
52
+ const clone = svg.cloneNode(true);
53
+ const { width, height } = sizeOf(svg);
54
+ clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
55
+ clone.setAttribute("width", String(width));
56
+ clone.setAttribute("height", String(height));
57
+ if (!clone.getAttribute("viewBox")) clone.setAttribute("viewBox", `0 0 ${width} ${height}`);
58
+ return { markup: new XMLSerializer().serializeToString(clone), width, height };
59
+ }
60
+ async function raster(markup, width, height, scale = 2) {
61
+ const image = new Image();
62
+ image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(markup)}`;
63
+ await new Promise((resolve, reject) => {
64
+ image.onload = () => resolve();
65
+ image.onerror = () => reject(new Error("SVG illisible"));
66
+ });
67
+ const canvas = document.createElement("canvas");
68
+ canvas.width = width * scale;
69
+ canvas.height = height * scale;
70
+ const context = canvas.getContext("2d");
71
+ context.fillStyle = "#FFFFFF";
72
+ context.fillRect(0, 0, canvas.width, canvas.height);
73
+ context.drawImage(image, 0, 0, canvas.width, canvas.height);
74
+ return canvas;
75
+ }
76
+ function printable(text) {
77
+ return text.replace(/[^\u0000-\u00ff]/g, "").replace(/\s{2,}/g, " ").trim();
78
+ }
79
+ export function useRmExport() {
80
+ function coverSheet(payload, config, lang) {
81
+ const center = { align: "left", alignVertical: "center", fontFamily: "Figtree" };
82
+ const rows = [];
83
+ const line = (value, style = {}) => rows.push([{ ...center, value, ...style }]);
84
+ if (config.options.includes("logo")) {
85
+ line(BRAND_TITLE, { fontSize: 36, fontWeight: "bold", height: 46 });
86
+ line(BRAND_LINE, { fontSize: 14, color: "#6B3FFF" });
87
+ rows.push([]);
88
+ }
89
+ line(payload.title, { fontSize: 20, fontWeight: "bold" });
90
+ if (payload.subtitle) line(payload.subtitle, { fontSize: 13, color: "#6C6C8A" });
91
+ line(
92
+ lang === "en" ? `Generated on ${longDate(lang)}` : `G\xE9n\xE9r\xE9 le ${longDate(lang)}`,
93
+ { fontSize: 11, color: "#6C6C8A" }
94
+ );
95
+ rows.push([]);
96
+ if (payload.kpi?.length && config.sections.includes("kpi")) {
97
+ line(lang === "en" ? "Key figures" : "Chiffres cl\xE9s", { fontSize: 13, fontWeight: "bold" });
98
+ payload.kpi.forEach(
99
+ (kpi) => rows.push([
100
+ { ...center, value: kpi.label, fontSize: 11, color: "#6C6C8A", width: 34 },
101
+ { ...center, value: String(kpi.value), fontSize: 11, fontWeight: "bold", width: 24 }
102
+ ])
103
+ );
104
+ rows.push([]);
105
+ }
106
+ if (payload.filters?.length && config.sections.includes("filters")) {
107
+ line(lang === "en" ? "Active filters" : "Filtres actifs", { fontSize: 13, fontWeight: "bold" });
108
+ payload.filters.forEach(
109
+ (filter) => rows.push([
110
+ { ...center, value: filter.label, fontSize: 11, color: "#6C6C8A" },
111
+ { ...center, value: filter.value, fontSize: 11 }
112
+ ])
113
+ );
114
+ rows.push([]);
115
+ }
116
+ const withSources = config.sections.includes("sources") || config.options.includes("sources");
117
+ if (payload.sources?.length && withSources) {
118
+ line(lang === "en" ? "Sources" : "Sources", { fontSize: 13, fontWeight: "bold" });
119
+ payload.sources.forEach((source) => {
120
+ const date = source.date ? new Date(source.date).toLocaleDateString(lang === "en" ? "en-US" : "fr-FR", {
121
+ year: "numeric",
122
+ month: "long",
123
+ day: "numeric"
124
+ }) : null;
125
+ rows.push([
126
+ { ...center, value: `${source.emoji ? `${source.emoji} ` : ""}${source.name}`, fontSize: 11 },
127
+ {
128
+ ...center,
129
+ value: date ? lang === "en" ? `Last update: ${date}` : `Derni\xE8re mise \xE0 jour : ${date}` : "",
130
+ fontSize: 11,
131
+ color: "#6C6C8A"
132
+ }
133
+ ]);
134
+ });
135
+ }
136
+ return rows;
137
+ }
138
+ async function toXlsx(config, payload, lang) {
139
+ const { default: writeXlsxFile } = await import("write-excel-file");
140
+ const fileName = `${config.fileName}.xlsx`;
141
+ const sheets = payload.sheets;
142
+ const data = [coverSheet(payload, config, lang)];
143
+ const schema = [null];
144
+ const names = [BRAND_TITLE];
145
+ let rowCount = 0;
146
+ sheets.forEach((sheet) => {
147
+ const rows = rowsOf(sheet, payload.limit);
148
+ rowCount += rows.length;
149
+ data.push(rows);
150
+ schema.push(columnsOf(sheet, config.columns));
151
+ names.push(sheet.name.replace(/[[\]:*?/\\]/g, " ").slice(0, 31));
152
+ });
153
+ await writeXlsxFile(data, { schema, sheets: names, fileName, stickyRowsCount: 1 });
154
+ return { fileName, rows: rowCount, bytes: 0 };
155
+ }
156
+ async function toCsv(config, payload, lang) {
157
+ const escape = (text) => /[",\n;]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
158
+ const blocks = [];
159
+ let rowCount = 0;
160
+ payload.sheets.forEach((sheet) => {
161
+ const columns = columnsOf(sheet, config.columns);
162
+ const rows = rowsOf(sheet, payload.limit);
163
+ rowCount += rows.length;
164
+ const lines = [];
165
+ if (payload.sheets.length > 1) lines.push(`# ${sheet.name}`);
166
+ lines.push(columns.map((column) => escape(column.column)).join(","));
167
+ rows.forEach(
168
+ (row) => lines.push(columns.map((column) => escape(cellText(column.value(row), lang))).join(","))
169
+ );
170
+ blocks.push(lines.join("\n"));
171
+ });
172
+ const fileName = `${config.fileName}.csv`;
173
+ const blob = new Blob([`\uFEFF${blocks.join("\n\n")}
174
+ `], { type: "text/csv;charset=utf-8" });
175
+ return { fileName, rows: rowCount, bytes: download(blob, fileName) };
176
+ }
177
+ async function toPng(config, payload, lang) {
178
+ const charts = (payload.charts ?? []).map((chart) => svgOf(chart.el)).filter(Boolean);
179
+ if (!charts.length) throw new Error(lang === "en" ? "No chart on this page" : "Aucun graphique sur cette page");
180
+ const scale = 2;
181
+ const pad = 24;
182
+ const head = config.options.includes("logo") ? 72 : 0;
183
+ const parts = charts.map((svg) => serialize(svg));
184
+ const width = Math.max(...parts.map((part) => part.width)) + pad * 2;
185
+ const height = parts.reduce((total, part) => total + part.height + pad, pad) + head;
186
+ const canvas = document.createElement("canvas");
187
+ canvas.width = width * scale;
188
+ canvas.height = height * scale;
189
+ const context = canvas.getContext("2d");
190
+ context.scale(scale, scale);
191
+ context.fillStyle = "#FFFFFF";
192
+ context.fillRect(0, 0, width, height);
193
+ if (head) {
194
+ context.fillStyle = "#1A1A2E";
195
+ context.font = "bold 20px Figtree, Helvetica, sans-serif";
196
+ context.fillText(BRAND_TITLE, pad, 32);
197
+ context.fillStyle = "#6C6C8A";
198
+ context.font = "12px Figtree, Helvetica, sans-serif";
199
+ context.fillText(`${payload.title} \xB7 ${BRAND_LINE} \xB7 ${longDate(lang)}`, pad, 52);
200
+ }
201
+ let y = head + pad;
202
+ for (const part of parts) {
203
+ const raw = await raster(part.markup, part.width, part.height, scale);
204
+ context.drawImage(raw, pad, y, part.width, part.height);
205
+ y += part.height + pad;
206
+ }
207
+ const blob = await new Promise(
208
+ (resolve, reject) => canvas.toBlob((result) => result ? resolve(result) : reject(new Error("PNG illisible")), "image/png")
209
+ );
210
+ const fileName = `${config.fileName}.png`;
211
+ return { fileName, rows: 0, bytes: download(blob, fileName) };
212
+ }
213
+ async function toSvg(config, payload, lang) {
214
+ const charts = (payload.charts ?? []).map((chart) => svgOf(chart.el)).filter(Boolean);
215
+ if (!charts.length) throw new Error(lang === "en" ? "No chart on this page" : "Aucun graphique sur cette page");
216
+ const pad = 24;
217
+ const head = config.options.includes("logo") ? 72 : 0;
218
+ const parts = charts.map((svg) => serialize(svg));
219
+ const width = Math.max(...parts.map((part) => part.width)) + pad * 2;
220
+ const height = parts.reduce((total, part) => total + part.height + pad, pad) + head;
221
+ let y = head + pad;
222
+ const nested = parts.map((part) => {
223
+ const markup2 = part.markup.replace(/^<svg /, `<svg x="${pad}" y="${y}" `);
224
+ y += part.height + pad;
225
+ return markup2;
226
+ }).join("\n");
227
+ 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>` : "";
228
+ 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>`;
229
+ const fileName = `${config.fileName}.svg`;
230
+ const blob = new Blob([markup], { type: "image/svg+xml;charset=utf-8" });
231
+ return { fileName, rows: 0, bytes: download(blob, fileName) };
232
+ }
233
+ async function toPdf(config, payload, lang) {
234
+ const { default: JsPdf } = await import("jspdf");
235
+ const { default: autoTable } = await import("jspdf-autotable");
236
+ const landscape = config.layout === "a4-landscape";
237
+ const doc = new JsPdf({ orientation: landscape ? "landscape" : "portrait", unit: "mm", format: "a4" });
238
+ const pageWidth = doc.internal.pageSize.getWidth();
239
+ const margin = 14;
240
+ const inner = pageWidth - margin * 2;
241
+ let y = margin;
242
+ const violet = [107, 63, 255];
243
+ const ink = [26, 26, 46];
244
+ const grey = [108, 108, 138];
245
+ if (config.options.includes("logo")) {
246
+ doc.setFillColor(...violet);
247
+ doc.rect(0, 0, pageWidth, 42, "F");
248
+ doc.setTextColor(255, 255, 255);
249
+ doc.setFont("helvetica", "bold");
250
+ doc.setFontSize(22);
251
+ doc.text(BRAND_TITLE, margin, 20);
252
+ doc.setFont("helvetica", "normal");
253
+ doc.setFontSize(11);
254
+ doc.text(BRAND_LINE, margin, 28);
255
+ doc.setFontSize(9);
256
+ doc.text(longDate(lang), pageWidth - margin, 28, { align: "right" });
257
+ if (payload.logoDataUrl) {
258
+ const logoHeight = 8;
259
+ doc.addImage(payload.logoDataUrl, "PNG", pageWidth - margin - logoHeight * 7.6, 10, logoHeight * 7.6, logoHeight);
260
+ }
261
+ y = 54;
262
+ }
263
+ doc.setTextColor(...ink);
264
+ doc.setFont("helvetica", "bold");
265
+ doc.setFontSize(16);
266
+ doc.text(doc.splitTextToSize(printable(payload.title), inner), margin, y);
267
+ y += 8;
268
+ if (payload.subtitle) {
269
+ doc.setFont("helvetica", "normal");
270
+ doc.setFontSize(10);
271
+ doc.setTextColor(...grey);
272
+ const lines = doc.splitTextToSize(printable(payload.subtitle), inner);
273
+ doc.text(lines, margin, y);
274
+ y += lines.length * 5 + 2;
275
+ }
276
+ if (payload.kpi?.length && config.sections.includes("kpi")) {
277
+ const perRow = Math.min(payload.kpi.length, 4);
278
+ const boxWidth = inner / perRow;
279
+ payload.kpi.slice(0, 8).forEach((kpi, index) => {
280
+ const column = index % perRow;
281
+ const row = Math.floor(index / perRow);
282
+ const x = margin + column * boxWidth;
283
+ const top = y + row * 20;
284
+ doc.setFillColor(246, 246, 250);
285
+ doc.roundedRect(x + 1, top, boxWidth - 2, 17, 2, 2, "F");
286
+ doc.setTextColor(...violet);
287
+ doc.setFont("helvetica", "bold");
288
+ doc.setFontSize(14);
289
+ doc.text(printable(String(kpi.value)), x + boxWidth / 2, top + 8, { align: "center" });
290
+ doc.setTextColor(...grey);
291
+ doc.setFont("helvetica", "normal");
292
+ doc.setFontSize(7);
293
+ doc.text(doc.splitTextToSize(printable(kpi.label), boxWidth - 6)[0] ?? "", x + boxWidth / 2, top + 13, { align: "center" });
294
+ });
295
+ y += Math.ceil(Math.min(payload.kpi.length, 8) / perRow) * 20 + 4;
296
+ }
297
+ if (payload.filters?.length && config.sections.includes("filters")) {
298
+ doc.setTextColor(...ink);
299
+ doc.setFont("helvetica", "bold");
300
+ doc.setFontSize(10);
301
+ doc.text(lang === "en" ? "Active filters" : "Filtres actifs", margin, y);
302
+ y += 5;
303
+ doc.setFont("helvetica", "normal");
304
+ doc.setFontSize(9);
305
+ doc.setTextColor(...grey);
306
+ payload.filters.forEach((filter) => {
307
+ const lines = doc.splitTextToSize(printable(`${filter.label} : ${filter.value}`), inner);
308
+ doc.text(lines, margin, y);
309
+ y += lines.length * 4.5;
310
+ });
311
+ y += 4;
312
+ }
313
+ const charts = config.sections.includes("charts") ? payload.charts ?? [] : [];
314
+ for (const chart of charts) {
315
+ const svg = svgOf(chart.el);
316
+ if (!svg) continue;
317
+ const part = serialize(svg);
318
+ const canvas = await raster(part.markup, part.width, part.height, Math.min(2, 1400 / part.width));
319
+ const drawWidth = inner;
320
+ const drawHeight = part.height / part.width * drawWidth;
321
+ if (y + drawHeight > doc.internal.pageSize.getHeight() - margin) {
322
+ doc.addPage();
323
+ y = margin;
324
+ }
325
+ if (chart.title) {
326
+ doc.setTextColor(...ink);
327
+ doc.setFont("helvetica", "bold");
328
+ doc.setFontSize(10);
329
+ doc.text(printable(chart.title), margin, y);
330
+ y += 5;
331
+ }
332
+ doc.addImage(canvas.toDataURL("image/png"), "PNG", margin, y, drawWidth, drawHeight, void 0, "FAST");
333
+ y += drawHeight + 6;
334
+ }
335
+ if (config.sections.includes("table")) {
336
+ let rowCount = 0;
337
+ payload.sheets.forEach((sheet) => {
338
+ const columns = columnsOf(sheet, config.columns);
339
+ const rows = rowsOf(sheet, payload.limit);
340
+ rowCount += rows.length;
341
+ autoTable(doc, {
342
+ startY: y + 2,
343
+ head: [columns.map((column) => printable(column.column))],
344
+ body: rows.map((row) => columns.map((column) => printable(cellText(column.value(row), lang)))),
345
+ styles: { fontSize: 7, cellPadding: 1.6, overflow: "linebreak", textColor: ink },
346
+ headStyles: { fillColor: violet, textColor: [255, 255, 255], fontStyle: "bold" },
347
+ alternateRowStyles: { fillColor: [250, 250, 253] },
348
+ margin: { left: margin, right: margin },
349
+ didDrawPage: () => {
350
+ if (payload.sheets.length > 1) {
351
+ doc.setTextColor(...grey);
352
+ doc.setFont("helvetica", "bold");
353
+ doc.setFontSize(8);
354
+ doc.text(printable(sheet.name), margin, margin - 4);
355
+ }
356
+ }
357
+ });
358
+ y = doc.lastAutoTable?.finalY ?? y;
359
+ });
360
+ const withSources = config.sections.includes("sources") || config.options.includes("sources");
361
+ if (payload.sources?.length && withSources) {
362
+ doc.setTextColor(...grey);
363
+ doc.setFont("helvetica", "normal");
364
+ doc.setFontSize(8);
365
+ 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 ");
366
+ const lines = doc.splitTextToSize(printable(`Sources : ${names}`), inner);
367
+ doc.text(lines, margin, Math.min(y + 8, doc.internal.pageSize.getHeight() - margin));
368
+ }
369
+ const fileName2 = `${config.fileName}.pdf`;
370
+ const blob2 = doc.output("blob");
371
+ return { fileName: fileName2, rows: rowCount, bytes: download(blob2, fileName2) };
372
+ }
373
+ const fileName = `${config.fileName}.pdf`;
374
+ const blob = doc.output("blob");
375
+ return { fileName, rows: 0, bytes: download(blob, fileName) };
376
+ }
377
+ async function toPermalink(payload) {
378
+ const url = payload.permalink || (typeof window !== "undefined" ? window.location.href : "");
379
+ try {
380
+ await navigator.clipboard.writeText(url);
381
+ } catch {
382
+ }
383
+ return { fileName: "", rows: 0, bytes: 0, url };
384
+ }
385
+ async function run(config, payload, lang = "fr") {
386
+ switch (config.format) {
387
+ case "xlsx":
388
+ return toXlsx(config, payload, lang);
389
+ case "csv":
390
+ return toCsv(config, payload, lang);
391
+ case "png":
392
+ return toPng(config, payload, lang);
393
+ case "svg":
394
+ return toSvg(config, payload, lang);
395
+ case "pdf":
396
+ return toPdf(config, payload, lang);
397
+ case "link":
398
+ return toPermalink(payload);
399
+ default:
400
+ throw new Error(`Format d'export inconnu : ${config.format}`);
401
+ }
402
+ }
403
+ return { run, extensions: RM_EXPORT_EXTENSIONS };
404
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Vocabulaire de l'export OIP : ce qu'une page declare, ce que le wizard
3
+ * (`RmExportPanel`) en fait, ce que les generateurs (`useRmExport`) consomment.
4
+ *
5
+ * Un seul contrat pour toutes les apps, parce que l'export est la sortie de
6
+ * chaque ecran : sans vocabulaire commun, chaque page reecrit sa feuille de
7
+ * calcul, sa page de garde et son nom de fichier, et deux exports du meme
8
+ * produit ne se ressemblent plus.
9
+ *
10
+ * Le schema de colonne est celui de `write-excel-file` (`column`, `type`,
11
+ * `format`, `value(row)`) : c'est deja la forme utilisee par les apps, et la
12
+ * garder evite de traduire les schemas existants. Les autres formats (CSV,
13
+ * PDF) appellent le meme `value(row)`, donc une colonne se decrit une fois.
14
+ */
15
+ /** Une colonne de tableau, telle que `write-excel-file` l'attend. */
16
+ export interface RmExportColumn {
17
+ column: string;
18
+ type?: unknown;
19
+ format?: string;
20
+ width?: number;
21
+ wrap?: boolean;
22
+ alignVertical?: string;
23
+ value: (row: any) => any;
24
+ }
25
+ /** Un onglet du classeur : son nom, ses colonnes, ses lignes. */
26
+ export interface RmExportSheet {
27
+ name: string;
28
+ schema: RmExportColumn[];
29
+ data: any[];
30
+ }
31
+ /** Chiffre cle repris sur la page de garde. */
32
+ export interface RmExportKpi {
33
+ label: string;
34
+ value: string | number;
35
+ }
36
+ /** Source citee, avec sa derniere mise a jour. */
37
+ export interface RmExportSource {
38
+ name: string;
39
+ date?: string | Date | null;
40
+ emoji?: string;
41
+ }
42
+ /** Filtre actif, tel qu'il s'ecrit dans le recapitulatif de l'export. */
43
+ export interface RmExportFilter {
44
+ label: string;
45
+ value: string;
46
+ }
47
+ /**
48
+ * Un format d'export.
49
+ *
50
+ * Les trois drapeaux disent au wizard quelles etapes ont un sens, plutot que
51
+ * de lui faire connaitre les formats par leur nom : un PNG n'a ni colonnes ni
52
+ * mise en page, un CSV n'a pas de graphique. Un format ajoute plus tard
53
+ * (Parquet, PowerPoint) herite du bon comportement en posant ses drapeaux,
54
+ * sans toucher au composant.
55
+ */
56
+ export interface RmExportFormat {
57
+ key: string;
58
+ label: string;
59
+ /** Icone Phosphor, nom PascalCase sans le prefixe `Ph`. */
60
+ icon: string;
61
+ color?: string;
62
+ desc?: string;
63
+ /** Le format porte des lignes de tableau : plage et colonnes s'appliquent. */
64
+ tabular?: boolean;
65
+ /** Le format rend les graphiques. */
66
+ visual?: boolean;
67
+ /** Le format est mis en page : le choix A4 s'applique. */
68
+ paged?: boolean;
69
+ /** Format annonce mais pas encore livre : la carte reste visible, grisee. */
70
+ disabled?: boolean;
71
+ /** Raison affichee sur une carte grisee. */
72
+ disabledHint?: string;
73
+ }
74
+ /** Nature d'une section : elle decide de quels formats la section releve. */
75
+ export type RmExportSectionKind = 'table' | 'visual' | 'meta';
76
+ /** Section proposee a l'etape « Donnees ». */
77
+ export interface RmExportSection {
78
+ key: string;
79
+ label: string;
80
+ kind: RmExportSectionKind;
81
+ /** Cochee a l'ouverture. */
82
+ on?: boolean;
83
+ }
84
+ /** Plage de lignes : filtrees, toute la base, la selection. */
85
+ export interface RmExportRange {
86
+ key: string;
87
+ label: string;
88
+ rows: number;
89
+ }
90
+ /** Option de l'etape « Personnaliser » (logo, sources, ZIP...). */
91
+ export interface RmExportOption {
92
+ key: string;
93
+ label: string;
94
+ on?: boolean;
95
+ disabled?: boolean;
96
+ }
97
+ /** Mise en page, pour les formats pagines. */
98
+ export interface RmExportLayout {
99
+ key: string;
100
+ label: string;
101
+ }
102
+ /** Configuration complete, ce que le wizard emet. */
103
+ export interface RmExportConfig {
104
+ format: string;
105
+ range: string;
106
+ sections: string[];
107
+ columns: string[];
108
+ options: string[];
109
+ fileName: string;
110
+ layout: string;
111
+ schedule: 'once' | 'recurring';
112
+ frequency: string;
113
+ email: boolean;
114
+ }
115
+ /** Modele pre-rempli propose avant d'ouvrir le wizard. */
116
+ export interface RmExportTemplate {
117
+ key: string;
118
+ name: string;
119
+ meta: string;
120
+ icon: string;
121
+ color?: string;
122
+ config: Partial<RmExportConfig>;
123
+ }
124
+ /** Ligne de l'historique « Exports recents ». */
125
+ export interface RmExportRecent {
126
+ name: string;
127
+ format: string;
128
+ rows?: number;
129
+ size?: string;
130
+ /** Date de l'export, ISO ou Date. */
131
+ at: string | Date;
132
+ }
133
+ /**
134
+ * Ce que les generateurs consomment. La page fournit le contenu, le wizard
135
+ * fournit la configuration ; `useRmExport` croise les deux.
136
+ */
137
+ export interface RmExportPayload {
138
+ /** Titre du document (page de garde PDF, en-tete du classeur). */
139
+ title: string;
140
+ subtitle?: string;
141
+ kpi?: RmExportKpi[];
142
+ sources?: RmExportSource[];
143
+ filters?: RmExportFilter[];
144
+ sheets: RmExportSheet[];
145
+ /**
146
+ * Graphiques a rasteriser ou vectoriser. Les charts Romulus sont rendus par
147
+ * ECharts en SVG : on recupere le noeud, pas une capture d'ecran.
148
+ */
149
+ charts?: {
150
+ title?: string;
151
+ el: Element;
152
+ }[];
153
+ /** URL a copier pour le format « Permalink ». */
154
+ permalink?: string;
155
+ /**
156
+ * Logo a poser sur la page de garde PDF, en data URL PNG. Le paquet ne
157
+ * porte pas l'image : chaque app a la sienne dans ses assets, et un
158
+ * `import` depuis `node_modules` ne la trouverait pas.
159
+ */
160
+ logoDataUrl?: string;
161
+ /** Plafond de lignes par onglet, pour ne pas geler l'onglet du navigateur. */
162
+ limit?: number;
163
+ }
164
+ /** Extension de fichier par format. */
165
+ export declare const RM_EXPORT_EXTENSIONS: Record<string, string>;
166
+ /**
167
+ * Catalogue par defaut. Une app peut en retirer, en ajouter, ou en desactiver
168
+ * un ; l'ordre est celui d'affichage.
169
+ */
170
+ export declare const RM_EXPORT_FORMATS: RmExportFormat[];
171
+ /** Sections par defaut, dans l'ordre d'affichage. */
172
+ export declare const RM_EXPORT_SECTIONS: RmExportSection[];
173
+ /** Options par defaut de l'etape « Personnaliser ». */
174
+ export declare const RM_EXPORT_OPTIONS: RmExportOption[];
175
+ /** Mises en page par defaut. */
176
+ export declare const RM_EXPORT_LAYOUTS: RmExportLayout[];
177
+ /**
178
+ * Taille estimee, en ko. Une estimation, pas une mesure : elle sert a dire
179
+ * « quelques centaines de ko » contre « plusieurs Mo » avant de lancer.
180
+ */
181
+ export declare function rmExportSizeKb(input: {
182
+ format: string;
183
+ rows: number;
184
+ columns: number;
185
+ totalColumns: number;
186
+ sections: number;
187
+ includesTable: boolean;
188
+ zip?: boolean;
189
+ }): number;
190
+ /** « ~ 340 ko » · « ~ 1,8 Mo ». */
191
+ export declare function rmExportSize(kb: number): string;
192
+ /** Duree estimee, du meme ordre de precision que la taille. */
193
+ export declare function rmExportDuration(kb: number, rows: number): string;
194
+ /**
195
+ * Nom de fichier sans extension : minuscules, sans accent, tirets, date du
196
+ * jour. Un nom stable d'une app a l'autre rend les exports triables dans un
197
+ * dossier de telechargements.
198
+ */
199
+ export declare function rmExportFileName(...parts: (string | undefined | null)[]): string;