@tekkare/romulus 0.1.35 → 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.
- package/dist/module.json +1 -1
- package/dist/runtime/components/Button.vue +2 -2
- package/dist/runtime/components/Callout.vue +55 -0
- package/dist/runtime/components/ExportPanel.vue +744 -0
- package/dist/runtime/components/SavedViewCard.vue +295 -0
- package/dist/runtime/components/SavedViews.vue +282 -0
- package/dist/runtime/composables/useRmExport.d.ts +29 -0
- package/dist/runtime/composables/useRmExport.js +404 -0
- package/dist/runtime/utils/export.d.ts +199 -0
- package/dist/runtime/utils/export.js +98 -0
- package/package.json +9 -2
|
@@ -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;
|