cve fixes
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* Export naar één zelfdragende HTML-pagina.
|
||||
*
|
||||
* Bedoeld als laatste vangnet: als de app niet meer draait, de database weg is
|
||||
* en er geen backup ligt, moet dit ene bestand nog steeds al je gegevens tonen.
|
||||
* Daarom zit alles erin — opmaak inline, geen scripts, geen lettertypen of
|
||||
* afbeeldingen van buiten. Het bestand is op een computer zonder internet, over
|
||||
* tien jaar, in elke browser nog te openen.
|
||||
*
|
||||
* Het is bovendien tegelijk een backup. Onderaan de pagina staat de complete
|
||||
* ruwe data in een `<script type="application/json">`-blok dat de browser niet
|
||||
* uitvoert en niet toont. "Backup herstellen" leest dat blok, dus je kunt dit
|
||||
* bestand net zo goed terugzetten als een backup-ZIP. Eén bestand dat zowel
|
||||
* leesbaar is voor jou als bruikbaar voor de app.
|
||||
*/
|
||||
|
||||
// ── Formatters ────────────────────────────────────────────────────────────────
|
||||
// Bewust met twee decimalen, anders dan de `fmt` op het scherm. Dit is een
|
||||
// archiefstuk: hier hoort de exacte waarde in te staan, niet de afgeronde.
|
||||
const euro = (n) =>
|
||||
new Intl.NumberFormat("nl-NL", { style: "currency", currency: "EUR", minimumFractionDigits: 2 })
|
||||
.format(Number(n) || 0);
|
||||
|
||||
const procent = (n) => `${((Number(n) || 0) * 100).toFixed(2).replace(".", ",")}%`;
|
||||
|
||||
const datumNl = (s) => {
|
||||
if (!s) return "";
|
||||
const [y, m, d] = String(s).split("-");
|
||||
return d ? `${d}-${m}-${y}` : String(s);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tekst veilig in HTML zetten. Een post die "Fonds <A> & co" heet mag de pagina
|
||||
* niet slopen, en opmerkingen zijn vrije tekst.
|
||||
*/
|
||||
const esc = (v) =>
|
||||
String(v ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
|
||||
const cel = (v, klasse = "") => `<td${klasse ? ` class="${klasse}"` : ""}>${esc(v)}</td>`;
|
||||
const kop = (v, klasse = "") => `<th${klasse ? ` class="${klasse}"` : ""}>${esc(v)}</th>`;
|
||||
|
||||
/**
|
||||
* Tabel met kopregel. `num` bevat de kolomindexen met bedragen; die worden
|
||||
* rechts uitgelijnd. Een rij mag `{ klasse, cellen }` zijn om zichzelf als
|
||||
* subtotaal of eindtotaal te markeren.
|
||||
*/
|
||||
const maakTabel = (kolommen, rijen, num = []) => `
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead><tr>${kolommen.map((k, i) => kop(k, num.includes(i) ? "num" : "")).join("")}</tr></thead>
|
||||
<tbody>${rijen.map((r) => `<tr${r.klasse ? ` class="${r.klasse}"` : ""}>${
|
||||
(r.cellen || r).map((c, i) => cel(c, num.includes(i) ? "num" : "")).join("")
|
||||
}</tr>`).join("")}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
|
||||
// ── Opmaak ────────────────────────────────────────────────────────────────────
|
||||
// Licht en rustig: dit is een document om te lezen en eventueel te printen, niet
|
||||
// het dashboard. Systeemlettertypen, zodat er niets van buiten geladen hoeft.
|
||||
const STIJL = `
|
||||
:root { color-scheme: light; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; padding: 32px 20px 64px;
|
||||
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
color: #1e2330; background: #f6f7fb;
|
||||
}
|
||||
.blad { max-width: 1180px; margin: 0 auto; }
|
||||
/* De voortgangstabel is Datum + één kolom per categorie + vier vaste kolommen.
|
||||
Bij zes categorieën zijn dat elf kolommen, en dat past op geen enkel scherm
|
||||
dat je toevallig bij de hand hebt. Liever horizontaal scrollen dan kolommen
|
||||
die buiten beeld vallen: zichtbaar is belangrijker dan in één oogopslag. */
|
||||
.scroll {
|
||||
overflow-x: auto;
|
||||
/* Schaduwtruc met vier achtergronden: de twee op 'local' schuiven mee en
|
||||
dekken de schaduw af zolang je aan die kant al bent, de twee op 'scroll'
|
||||
blijven staan. Resultaat is een randje dat alleen verschijnt aan de kant
|
||||
waar nog inhoud zit. Puur CSS — de pagina mag geen scripts bevatten, en
|
||||
zonder hint ziet niemand dat er rechts nog kolommen staan. */
|
||||
background:
|
||||
linear-gradient(to right, #fff 30%, rgba(255,255,255,0)) left center,
|
||||
linear-gradient(to left, #fff 30%, rgba(255,255,255,0)) right center,
|
||||
radial-gradient(farthest-side at 0 50%, rgba(30,35,48,0.16), rgba(30,35,48,0)) left center,
|
||||
radial-gradient(farthest-side at 100% 50%, rgba(30,35,48,0.16), rgba(30,35,48,0)) right center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 40px 100%, 40px 100%, 14px 100%, 14px 100%;
|
||||
background-attachment: local, local, scroll, scroll;
|
||||
}
|
||||
.scroll table { min-width: 100%; width: max-content; }
|
||||
/* Datum blijft staan tijdens het scrollen; zonder die kolom weet je bij elf
|
||||
kolommen niet meer naar welk moment je kijkt. */
|
||||
.scroll th:first-child, .scroll td:first-child { position: sticky; left: 0; background: #fff; }
|
||||
tr.subtotaal td:first-child { background: #fafbff; }
|
||||
tr.totaal td:first-child { background: #f4f2ff; }
|
||||
header { border-bottom: 3px solid #8b5cf6; padding-bottom: 16px; margin-bottom: 28px; }
|
||||
h1 { margin: 0 0 4px; font-size: 26px; letter-spacing: -0.4px; }
|
||||
.meta { margin: 0; color: #6b7280; font-size: 13px; }
|
||||
h2 {
|
||||
font-size: 15px; text-transform: uppercase; letter-spacing: 0.6px;
|
||||
color: #6b7280; margin: 34px 0 10px; font-weight: 700;
|
||||
}
|
||||
section { background: #fff; border: 1px solid #e3e6ef; border-radius: 10px; padding: 4px 18px 14px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th, td { padding: 8px 10px; text-align: left; border-bottom: 1px solid #eceef5; }
|
||||
th { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: #6b7280; }
|
||||
/* Bedragen rechts en met cijfers van gelijke breedte, zodat kolommen uitlijnen */
|
||||
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
tr.subtotaal td { font-weight: 700; background: #fafbff; }
|
||||
tr.totaal td { font-weight: 800; border-top: 2px solid #d9dced; background: #f4f2ff; }
|
||||
.kpi { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 12px; padding: 14px 0; }
|
||||
.kpi div { border: 1px solid #e3e6ef; border-radius: 8px; padding: 12px 14px; background: #fcfcff; }
|
||||
.kpi span { display: block; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: #6b7280; }
|
||||
.kpi strong { font-size: 19px; font-variant-numeric: tabular-nums; }
|
||||
.leeg { color: #9aa1b0; font-style: italic; padding: 10px 0; }
|
||||
footer { margin-top: 34px; color: #6b7280; font-size: 12px; line-height: 1.6; }
|
||||
footer code { background: #eceef5; padding: 1px 5px; border-radius: 4px; }
|
||||
@media print {
|
||||
body { background: #fff; padding: 0; }
|
||||
section { border: none; border-radius: 0; padding: 0; }
|
||||
h2 { margin-top: 22px; }
|
||||
tr { break-inside: avoid; }
|
||||
/* Papier scrollt niet, dus op print moet alles passen. Liggend formaat: een
|
||||
tabel van twaalf kolommen krijg je staand met geen enkele lettergrootte
|
||||
fatsoenlijk op een A4. Verder vervallen de scrollbalk en de schaduwhint,
|
||||
en gaat de nowrap van de bedragen af — die hield de kolommen op hun volle
|
||||
breedte en was precies waaraan het krimpen vastliep. */
|
||||
@page { size: A4 landscape; margin: 12mm; }
|
||||
.scroll { overflow: visible; background: none; }
|
||||
.scroll table { width: 100%; min-width: 0; table-layout: fixed; }
|
||||
.scroll th:first-child, .scroll td:first-child { position: static; }
|
||||
table { font-size: 9px; }
|
||||
th, td { padding: 3px 4px; }
|
||||
.num { white-space: normal; }
|
||||
.blad { max-width: none; }
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Bouwt de complete HTML-pagina.
|
||||
*
|
||||
* @returns {string} het volledige bestand, klaar om weg te schrijven
|
||||
*/
|
||||
export function bouwHtmlExport({
|
||||
cats = [],
|
||||
schuldBlokken = [],
|
||||
evRows = [],
|
||||
evVoortgangHistory = [],
|
||||
schuldHistory = [],
|
||||
totEV = 0,
|
||||
totSchuld = 0,
|
||||
doel = 0,
|
||||
initieleSchuld = 0,
|
||||
gebruiker = "",
|
||||
ruweData = null,
|
||||
}) {
|
||||
const nu = new Date();
|
||||
const exportDatum = `${String(nu.getDate()).padStart(2, "0")}-${String(nu.getMonth() + 1).padStart(2, "0")}-${nu.getFullYear()}`;
|
||||
|
||||
// ── Samenvatting ────────────────────────────────────────────
|
||||
// Zelfde opbouw als de dashboard-KPI's: het oudste voortgangsmoment is de
|
||||
// startinleg, elke latere rij telt zijn stortingen erbij op.
|
||||
const startEV = evRows.length > 0 ? (evRows[evRows.length - 1]?.nettoWaarde || 0) : 0;
|
||||
const extraFiat = evRows.length > 1
|
||||
? evRows.slice(0, -1).reduce((a, r) => a + cats.reduce((b, c) => b + (r[c.id] || 0), 0), 0)
|
||||
: 0;
|
||||
const totaalFiat = startEV + extraFiat;
|
||||
const winst = totEV - totaalFiat;
|
||||
|
||||
const kpis = [
|
||||
["Totaal eigen vermogen", euro(totEV)],
|
||||
["Totale schuld", euro(totSchuld)],
|
||||
["Netto waarde", euro(totEV - totSchuld)],
|
||||
["Totaal geïnvesteerde fiat", euro(totaalFiat)],
|
||||
["Winst op investering", euro(winst)],
|
||||
["ROI", totaalFiat > 0 ? procent(winst / totaalFiat) : "—"],
|
||||
["Doel eigen vermogen", euro(doel)],
|
||||
["Voortgang naar doel", doel > 0 ? procent(Math.min(totEV / doel, 1)) : "—"],
|
||||
];
|
||||
|
||||
// ── Eigen vermogen ──────────────────────────────────────────
|
||||
// Per categorie de posten met een subtotaalregel eronder, en helemaal
|
||||
// onderaan het eindtotaal. Zo is de pagina te lezen zonder te rekenen.
|
||||
const evRijen = [];
|
||||
for (const c of cats) {
|
||||
const items = c.items || [];
|
||||
for (const i of items) evRijen.push([c.naam, i.naam, euro(i.waarde)]);
|
||||
// Eerste kolom leeg: het label zegt al om welke categorie het gaat, en de
|
||||
// naam er nog eens naast maakt de regel alleen maar drukker.
|
||||
evRijen.push({
|
||||
klasse: "subtotaal",
|
||||
cellen: ["", `Subtotaal ${c.naam}`, euro(items.reduce((a, i) => a + (i.waarde || 0), 0))],
|
||||
});
|
||||
}
|
||||
if (evRijen.length) evRijen.push({ klasse: "totaal", cellen: ["", "Totaal eigen vermogen", euro(totEV)] });
|
||||
|
||||
// ── Schulden ────────────────────────────────────────────────
|
||||
const schuldRijen = [];
|
||||
for (const b of schuldBlokken) {
|
||||
const items = b.items || [];
|
||||
for (const i of items) schuldRijen.push([b.naam, i.naam, euro(i.waarde)]);
|
||||
schuldRijen.push({
|
||||
klasse: "subtotaal",
|
||||
cellen: ["", `Subtotaal ${b.naam}`, euro(items.reduce((a, i) => a + (i.waarde || 0), 0))],
|
||||
});
|
||||
}
|
||||
if (schuldRijen.length) schuldRijen.push({ klasse: "totaal", cellen: ["", "Totale schuld", euro(totSchuld)] });
|
||||
// De initiële schuld is het beginpunt waartegen het afbetalingsoverzicht in de
|
||||
// app rekent. Hij staat los van de blokken, dus zonder eigen regel valt hij
|
||||
// buiten de export — en dan mist de pagina een ingevoerd gegeven.
|
||||
if (initieleSchuld) {
|
||||
schuldRijen.push({ klasse: "subtotaal", cellen: ["", "Initiële schuld (startpunt)", euro(initieleSchuld)] });
|
||||
schuldRijen.push({ klasse: "subtotaal", cellen: ["", "Afgelost sinds start", euro(initieleSchuld - totSchuld)] });
|
||||
}
|
||||
|
||||
// ── Voortgang eigen vermogen ────────────────────────────────
|
||||
// Oplopend op datum: een archief lees je van oud naar nieuw.
|
||||
const oplopend = [...evRows].sort((a, b) => String(a.datum).localeCompare(String(b.datum)));
|
||||
|
||||
// Verwijder je een categorie, dan blijven de stortingen ervan in de oude
|
||||
// voortgangsrijen staan — de app toont alleen de kolommen van bestaande
|
||||
// categorieën, dus die bedragen zie je nergens meer. Voor een export die
|
||||
// "alles wat is ingevoerd" moet tonen is dat precies het verkeerde gedrag, dus
|
||||
// die wezen krijgen hier alsnog een kolom, gemerkt met hun ruwe sleutel.
|
||||
const bekend = new Set(cats.map((c) => c.id));
|
||||
const vaste = new Set(["datum", "nettoWaarde", "opmerkingen"]);
|
||||
const wezen = [...new Set(oplopend.flatMap((r) => Object.keys(r)))]
|
||||
.filter((k) => !bekend.has(k) && !vaste.has(k))
|
||||
.filter((k) => oplopend.some((r) => typeof r[k] === "number"));
|
||||
|
||||
const kolomIds = [...cats.map((c) => c.id), ...wezen];
|
||||
const voortgangRijen = oplopend.map((r, i) => {
|
||||
const storting = kolomIds.reduce((a, id) => a + (r[id] || 0), 0);
|
||||
const verschil = i > 0 ? (r.nettoWaarde || 0) - (oplopend[i - 1].nettoWaarde || 0) : null;
|
||||
return [
|
||||
datumNl(r.datum),
|
||||
...kolomIds.map((id) => euro(r[id])),
|
||||
euro(storting),
|
||||
euro(r.nettoWaarde),
|
||||
verschil === null ? "—" : euro(verschil),
|
||||
r.opmerkingen || "",
|
||||
];
|
||||
});
|
||||
|
||||
// ── Historie EV / schuld ────────────────────────────────────
|
||||
// Twee reeksen die los worden bijgehouden en alleen op datum samenkomen; een
|
||||
// moment kan er dus maar één van hebben.
|
||||
const datums = [...new Set([
|
||||
...evVoortgangHistory.map((r) => r.datum),
|
||||
...schuldHistory.map((s) => s.datum),
|
||||
])].sort((a, b) => String(a).localeCompare(String(b)));
|
||||
|
||||
const historieRijen = datums.map((d) => {
|
||||
const e = evVoortgangHistory.find((r) => r.datum === d);
|
||||
const s = schuldHistory.find((x) => x.datum === d);
|
||||
const ev = e ? (e.ev ?? null) : null;
|
||||
const sc = s ? (s.schuld ?? null) : null;
|
||||
return [
|
||||
datumNl(d),
|
||||
ev === null ? "—" : euro(ev),
|
||||
sc === null ? "—" : euro(sc),
|
||||
ev !== null && sc !== null ? euro(ev - sc) : "—",
|
||||
e?.opmerkingen || "",
|
||||
];
|
||||
});
|
||||
|
||||
const sectie = (titel, inhoud, leegTekst) =>
|
||||
`<h2>${esc(titel)}</h2><section>${inhoud || `<p class="leeg">${esc(leegTekst)}</p>`}</section>`;
|
||||
|
||||
// De ruwe data gaat mee zodat dit bestand ook als backup dient. `<` wordt
|
||||
// ge-escaped, anders sluit een `</script>` in de gegevens het blok voortijdig
|
||||
// af en is zowel de pagina als de backup stuk.
|
||||
const jsonBlok = ruweData
|
||||
? `\n<script type="application/json" id="vibefinance-backup">${
|
||||
JSON.stringify(ruweData).replace(/</g, "\\u003c")
|
||||
}</script>\n`
|
||||
: "";
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>VibeFinance — overzicht ${esc(exportDatum)}</title>
|
||||
<style>${STIJL}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="blad">
|
||||
<header>
|
||||
<h1>VibeFinance</h1>
|
||||
<p class="meta">Volledig overzicht · geëxporteerd op ${esc(exportDatum)}${gebruiker ? ` · ${esc(gebruiker)}` : ""}</p>
|
||||
</header>
|
||||
|
||||
<h2>Samenvatting</h2>
|
||||
<section>
|
||||
<div class="kpi">${kpis.map(([l, v]) => `<div><span>${esc(l)}</span><strong>${esc(v)}</strong></div>`).join("")}</div>
|
||||
</section>
|
||||
|
||||
${sectie("Eigen vermogen", evRijen.length ? maakTabel(["Categorie", "Post", "Waarde"], evRijen, [2]) : "", "Geen vermogensposten ingevoerd.")}
|
||||
|
||||
${sectie("Schulden", schuldRijen.length ? maakTabel(["Blok", "Onderdeel", "Waarde"], schuldRijen, [2]) : "", "Geen schulden ingevoerd.")}
|
||||
|
||||
${sectie("Voortgang eigen vermogen", voortgangRijen.length
|
||||
? maakTabel(
|
||||
[
|
||||
"Datum",
|
||||
...cats.map((c) => c.naam),
|
||||
// Een verwijderde categorie heeft geen naam meer; de sleutel is dan
|
||||
// het enige wat er nog van over is.
|
||||
...wezen.map((k) => `Verwijderd (${k})`),
|
||||
"Totaal storting", "Netto waarde", "Verschil", "Opmerkingen",
|
||||
],
|
||||
voortgangRijen,
|
||||
// Alle bedragkolommen: vanaf kolom 1 tot en met "Verschil".
|
||||
Array.from({ length: kolomIds.length + 3 }, (_, i) => i + 1),
|
||||
)
|
||||
: "", "Geen voortgangsmomenten vastgelegd.")}
|
||||
|
||||
${sectie("Historie eigen vermogen en schuld", historieRijen.length
|
||||
? maakTabel(["Datum", "Eigen vermogen", "Schuld", "Netto waarde", "Opmerkingen"], historieRijen, [1, 2, 3])
|
||||
: "", "Geen historie vastgelegd.")}
|
||||
|
||||
<footer>
|
||||
<p><strong>Dit bestand is ook een backup.</strong> Onderin staat je complete
|
||||
data in machineleesbare vorm, onzichtbaar op het scherm. Ga in VibeFinance
|
||||
naar <code>Instellingen → Backup herstellen</code> en kies dit
|
||||
<code>.html</code>-bestand om alles terug te zetten.</p>
|
||||
<p>De pagina staat volledig op zichzelf: geen internet, geen lettertypen of
|
||||
scripts van buitenaf. Bewaar hem naast je backup-ZIP, dan heb je één bestand
|
||||
dat je zowel kunt lézen als kunt terugzetten.</p>
|
||||
</footer>
|
||||
</div>${jsonBlok}</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt de ingebakken backup uit een geëxporteerde pagina terug.
|
||||
*
|
||||
* Bewust met DOMParser en niet met een reguliere expressie: de gegevens kunnen
|
||||
* zelf van alles bevatten, en een parser die HTML écht begrijpt trapt daar niet
|
||||
* in. De pagina wordt niet uitgevoerd — DOMParser draait geen scripts, en het
|
||||
* blok is `type="application/json"`, dus ook in een browser geen code.
|
||||
*
|
||||
* @returns {object|null} de data, of null als het geen export-pagina is
|
||||
*/
|
||||
export function leesHtmlBackup(htmlTekst) {
|
||||
const doc = new DOMParser().parseFromString(htmlTekst, "text/html");
|
||||
const blok = doc.getElementById("vibefinance-backup");
|
||||
if (!blok) return null;
|
||||
return JSON.parse(blok.textContent);
|
||||
}
|
||||
Reference in New Issue
Block a user