355 lines
17 KiB
JavaScript
355 lines
17 KiB
JavaScript
/**
|
|
* 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);
|
|
}
|