Compare commits
31
Commits
e5f78f5368
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a996c767f0 | ||
|
|
4181ac3f0f | ||
|
|
65233fb5b4 | ||
|
|
2799bc12ee | ||
|
|
ec63906cb2 | ||
|
|
e9192a8aba | ||
|
|
2d7e67e315 | ||
|
|
2420a00777 | ||
|
|
539b42dde7 | ||
|
|
ad45e372e6 | ||
|
|
73ce30c5b0 | ||
|
|
0b67f346e1 | ||
|
|
4915a090a3 | ||
|
|
750dc69e56 | ||
|
|
6d1b114b78 | ||
|
|
b4d1e25e50 | ||
|
|
a9b6203dec | ||
|
|
e315039c35 | ||
|
|
36f49b216f | ||
|
|
0d41c0af0b | ||
|
|
796245fefe | ||
|
|
04e70208bd | ||
|
|
0bbb166b32 | ||
|
|
ceff734793 | ||
|
|
fe6b94aa7f | ||
|
|
624f51679a | ||
|
|
5f90331694 | ||
|
|
4e923fa998 | ||
|
|
857322a490 | ||
|
|
6fd4f4766b | ||
|
|
1b14553417 |
+5
-2
@@ -17,6 +17,9 @@ CORS_ORIGIN=http://localhost:3400
|
||||
|
||||
# Update-notificaties (leeg = melding uit)
|
||||
UPDATE_CHECK_URL=https://vibehoogie.duckdns.org/api/v1/repos/vibe/VibeFinance/releases/latest
|
||||
# Nodig zolang de Gitea-repo niet publiek leesbaar is; scope read:repository.
|
||||
# Zonder token geeft releases/latest 404 en blijft de update-melding leeg.
|
||||
# De Gitea-organisatie is publiek, dus de check werkt anoniem — laat dit staan.
|
||||
# Alleen nodig als de organisatie weer privé wordt; scope read:repository.
|
||||
# Dan geeft releases/latest zonder token 404 en blijft de melding leeg.
|
||||
# Let op: een ongeldig token geeft 401, ook op een publieke repo. Bij twijfel
|
||||
# de regel uitgecommentarieerd laten in plaats van een token te gokken.
|
||||
# UPDATE_CHECK_TOKEN=
|
||||
|
||||
@@ -32,6 +32,51 @@ Vite proxyt `/api` naar `localhost:3001` binnen dezelfde container.
|
||||
> ⚠️ Alleen `.\dev.ps1 down` wist de development-database (`docker compose down -v`).
|
||||
> `.\dev.ps1 up` doet een gewone `down` en laat het volume staan.
|
||||
|
||||
### Hot reload pakt wijzigingen van de host niet op
|
||||
|
||||
De naam "hot reload" in de tabel hierboven is optimistischer dan de praktijk.
|
||||
`docker-compose.dev.yml` mount `./frontend` de container in, maar over die
|
||||
bind-mount komen op Windows geen inotify-events aan. Vite's bestandswatcher ziet
|
||||
je wijziging dus niet, houdt de oude versie van de module in zijn geheugen, en
|
||||
blijft die serveren — ook na een harde herlaad in de browser.
|
||||
|
||||
Zo zie je het verschil tussen "de browser cachet" en "Vite serveert oud":
|
||||
|
||||
```powershell
|
||||
# Haalt de module rechtstreeks bij Vite op, langs de browser om
|
||||
(Invoke-WebRequest "http://localhost/src/pages/DashboardTab.jsx" -UseBasicParsing).Content -match "jouw-nieuwe-code"
|
||||
```
|
||||
|
||||
Komt daar `False` uit terwijl het wél in het bestand staat, dan is het de
|
||||
watcher. Een `touch` binnen de container helpt niet; herstarten wel:
|
||||
|
||||
```powershell
|
||||
docker compose -f docker-compose.dev.yml restart app
|
||||
```
|
||||
|
||||
Wil je het structureel oplossen, dan is `server: { watch: { usePolling: true,
|
||||
interval: 300 } }` in `frontend/vite.config.js` de knop daarvoor. Polling kost
|
||||
wel constant CPU, vandaar dat het er niet standaard in staat.
|
||||
|
||||
### Welke versie de dev-app toont
|
||||
|
||||
De versie in "Over VibeFinance" komt uit `VITE_APP_VERSION`. `dev.ps1` leest die
|
||||
uit de **root** `package.json`, zet hem in de omgeving en synct hem naar
|
||||
`frontend/package.json`. In de dev-container is het een *runtime*-variabele
|
||||
(`docker-compose.dev.yml`, sectie `environment`) en geen build-arg: Vite bakt
|
||||
`__APP_VERSION__` in bij het **starten** van de dev-server, niet bij het bouwen
|
||||
van het image. Is hij leeg, dan valt `vite.config.js` terug op
|
||||
`frontend/package.json`.
|
||||
|
||||
Ziet de app een oude versie, dan draait de dev-server nog van vóór de bump —
|
||||
`.\dev.ps1 up` hercreëert de container en start Vite opnieuw. Start je de stack
|
||||
handmatig met `docker compose -f docker-compose.dev.yml up`, dan staat
|
||||
`VITE_APP_VERSION` niet in je shell en zie je de versie uit
|
||||
`frontend/package.json`.
|
||||
|
||||
> Een versie met een suffix (`1.0.1-dev`) onderdrukt de update-melding
|
||||
> permanent — zie [Update-notificaties](#update-notificaties).
|
||||
|
||||
---
|
||||
|
||||
## Testen
|
||||
@@ -77,66 +122,121 @@ docker compose -f docker-compose.dev.yml exec -w /app/frontend app npm run lint
|
||||
|
||||
## Tokens
|
||||
|
||||
Twee tokens, gesplitst op machine en op read/write. De grens die iets betekent
|
||||
ligt tussen werkstation en server, niet tussen push en release: op één machine
|
||||
kan wie het ene token kan lezen, het andere ook.
|
||||
**Eén token, op het werkstation.** Dat is genoeg omdat de `Vibe`-organisatie op
|
||||
public staat: alles wat alleen *leest* heeft geen token meer nodig — `git clone`,
|
||||
`docker compose pull` en de update-check van de app werken anoniem. Wat overblijft
|
||||
is schrijven, en dat gebeurt allemaal vanaf deze ene machine.
|
||||
|
||||
| Token | Machine | Scopes | Gebruikt voor |
|
||||
|---|---|---|---|
|
||||
| `vibefinance-workstation` | dit werkstation | `write:package`, `write:repository` | `docker push` (`release.ps1`, `dev.ps1 push`) en het aanmaken van de Gitea-release |
|
||||
| `vibefinance-host` | de Docker-host | `read:package`, `read:repository` | `docker compose pull` en de update-check van de app |
|
||||
| `vibefinance-workstation` | dit werkstation | `write:package`, `write:repository` | `docker push` (`release.ps1`, `dev.ps1 push`) én het aanmaken van de Gitea-release |
|
||||
|
||||
Aanmaken: Gitea → Settings → Applications → Generate New Token. Doe dat onder een
|
||||
account dat lid is van de `Vibe`-organisatie — die is privé, dus een token van
|
||||
een buitenstaander ziet de repo niet.
|
||||
Er is geen apart host-token en geen apart registry-token:
|
||||
|
||||
**Waar ze terechtkomen**
|
||||
- **`git push`** gaat over SSH (`gitea@…:Vibe/VibeFinance.git`), dus die gebruikt
|
||||
je SSH-sleutel en niet dit token. Werkt SSH even niet — bijvoorbeeld omdat de
|
||||
ssh-agent niet meedoet in een niet-interactieve shell, wat `Permission denied
|
||||
(publickey,password)` geeft — dan kun je *lezen* nu anoniem over https, omdat
|
||||
de organisatie public is:
|
||||
|
||||
| | Werkstation | Docker-host |
|
||||
|---|---|---|
|
||||
| Registry | `docker login` (credential store), of `REGISTRY_USER` + `REGISTRY_TOKEN` in `.env.release` | `docker login` als de gebruiker die deployt |
|
||||
| Gitea-API | `GITEA_TOKEN` in `.env.release` | `UPDATE_CHECK_TOKEN` in `.env` |
|
||||
```powershell
|
||||
git fetch https://vibehoogie.duckdns.org/Vibe/VibeFinance.git --prune --tags "+refs/heads/*:refs/remotes/origin/*"
|
||||
```
|
||||
|
||||
**Waarom het host-token read-only blijft.** Dat staat in platte tekst in `.env` op
|
||||
een server die de app op poort 3400 publiceert. Met schrijfrechten kan wie bij dat
|
||||
bestand komt — via de container, een shell op die host, of een backup — een eigen
|
||||
image als `latest` naar de registry pushen, en die host pullt `latest` zelf.
|
||||
Read-only houdt je eigen deploy-pad uit de aanvalsketen.
|
||||
Die refspec werkt ook `origin/*` bij, dus daarna doen `git merge --ff-only
|
||||
origin/main` en `git log origin/main` het weer. Pushen blijft SSH; `release.ps1`
|
||||
leest daarvoor `core.sshCommand` uit, omdat op Windows alleen de OpenSSH van het
|
||||
systeem met de ssh-agent praat en Git's eigen `ssh` niet.
|
||||
- **De registry** heeft geen eigen variabele nodig: `release.ps1` en `dev.ps1`
|
||||
vallen bij een ontbrekende `REGISTRY_TOKEN` terug op `GITEA_TOKEN`. Zet alleen
|
||||
`REGISTRY_USER` erbij, zodat het script zelf kan inloggen als de credential
|
||||
store leeg is.
|
||||
- **De Docker-host** hoeft niets meer te hebben: pull en update-check zijn anoniem.
|
||||
|
||||
Aanmaken: Gitea → Settings → Applications → Generate New Token, met **beide**
|
||||
scopes aangevinkt. Doe dat onder een account dat push-rechten op de repo heeft —
|
||||
lid zijn van de organisatie is daarvoor niet automatisch genoeg. Scopes van een
|
||||
bestaand token zijn later niet aan te passen, dus vink ze in één keer goed aan.
|
||||
|
||||
**Waar hij terechtkomt** — `.env.release` naast de scripts, niet in git
|
||||
(`.gitignore` pakt `.env.*`):
|
||||
|
||||
```ini
|
||||
GITEA_TOKEN=<token met write:package + write:repository>
|
||||
REGISTRY_USER=<je Gitea-account>
|
||||
```
|
||||
|
||||
Een variabele die al in je shell staat wint, zodat je per run kunt afwijken. Een
|
||||
lege waarde in het bestand wordt overgeslagen.
|
||||
|
||||
**Als de organisatie weer privé gaat** heb je er wél een tweede bij nodig: een
|
||||
read-only token op de Docker-host, voor `docker compose pull` en
|
||||
`UPDATE_CHECK_TOKEN`. Houd dat token dan read-only. Het staat in platte tekst in
|
||||
`.env` op een server die de app op poort 3400 publiceert; met schrijfrechten kan
|
||||
wie bij dat bestand komt — via de container, een shell op die host, of een
|
||||
backup — een eigen image als `latest` naar de registry pushen, en die host pullt
|
||||
`latest` zelf. Read-only houdt je eigen deploy-pad uit de aanvalsketen.
|
||||
|
||||
### Wat een foutcode betekent
|
||||
|
||||
| Status | Betekenis |
|
||||
|---|---|
|
||||
| 401 | Token ongeldig of verlopen. Op de registry: helemaal niet ingelogd. |
|
||||
| 403 | Het token mag de repo wél zien, maar mist de **scope** voor dit endpoint. Gitea noemt de vereiste scope in de response-body. |
|
||||
| 404 | Het token mag de repo niet zien. Gitea geeft geen 403, om niet te verklappen dat iets bestaat: verkeerd account, of geen toegang tot de organisatie. |
|
||||
| 401 | Token ongeldig, verlopen of ingetrokken — Gitea herkent hem niet. Op de registry: helemaal niet ingelogd. |
|
||||
| 403 | Het token **bestaat en werkt**, maar mist de **scope** voor dit endpoint. Gitea noemt de vereiste scope in de response-body. |
|
||||
| 404 | Het token mag de repo niet zien. Gitea geeft geen 403, om niet te verklappen dat iets bestaat: verkeerd account, of geen toegang tot de organisatie. Nu de organisatie public is, wijst een 404 op een leesactie eerder op een verkeerde URL dan op rechten. |
|
||||
|
||||
Scopes van een bestaand token zijn in Gitea niet aan te passen. Bij een 403 maak
|
||||
je dus een nieuw token aan; bijwerken kan niet.
|
||||
|
||||
Nakijken wie een token is en wat hij ziet:
|
||||
**Leeft mijn token nog?** Juist dat onderscheid tussen 401 en 403 is de test.
|
||||
Vraag *niet* `/api/v1/user` op om te kijken "wie het token is": het
|
||||
werkstation-token heeft geen `read:user`, dus dat endpoint geeft altijd 403 en
|
||||
zegt niets over geldigheid. Kijk naar de statuscode, niet naar de inhoud:
|
||||
|
||||
```powershell
|
||||
$h = @{ Authorization = "token $env:GITEA_TOKEN" }
|
||||
(Invoke-RestMethod https://vibehoogie.duckdns.org/api/v1/user -Headers $h).login
|
||||
(Invoke-RestMethod https://vibehoogie.duckdns.org/api/v1/user/repos -Headers $h).full_name
|
||||
$u = "https://vibehoogie.duckdns.org/api/v1/user/repos"
|
||||
try { (Invoke-WebRequest $u -Headers @{ Authorization = "token $env:GITEA_TOKEN" } -UseBasicParsing).StatusCode }
|
||||
catch { $_.Exception.Response.StatusCode.value__ }
|
||||
```
|
||||
|
||||
`403` = het token leeft (alleen deze scope ontbreekt). `401` = ingetrokken of
|
||||
fout gekopieerd. Nagemeten op 2026-09-10: geen header `401`, een nep-token van 40
|
||||
nullen `401`, het werkstation-token `403`.
|
||||
|
||||
Vanuit de app-container op de host valt de body van een fout anders weg —
|
||||
busybox-`wget` gooit die bij een foutstatus weg, en Node zit in het image:
|
||||
|
||||
```bash
|
||||
docker exec vibefinance_app node -e 'fetch(process.env.UPDATE_CHECK_URL,{headers:{Accept:"application/json",Authorization:`token ${process.env.UPDATE_CHECK_TOKEN}`}}).then(async r=>console.log(r.status, (await r.text()).slice(0,300)))'
|
||||
# Anoniem, zoals de app het doet zonder UPDATE_CHECK_TOKEN
|
||||
docker exec vibefinance_app node -e 'fetch(process.env.UPDATE_CHECK_URL,{headers:{Accept:"application/json"}}).then(async r=>console.log(r.status, (await r.text()).slice(0,300)))'
|
||||
|
||||
# Mét token, precies zoals routes/update.js de header opbouwt
|
||||
docker exec vibefinance_app node -e 'const t=process.env.UPDATE_CHECK_TOKEN; fetch(process.env.UPDATE_CHECK_URL,{headers:{Accept:"application/json",...(t&&{Authorization:`token ${t}`})}}).then(async r=>console.log(r.status, (await r.text()).slice(0,300)))'
|
||||
```
|
||||
|
||||
### Tokens opruimen
|
||||
> Let op: een **ongeldig** token geeft `401`, ook op deze publieke repo — Gitea
|
||||
> valt bij een verkeerd token niet terug op anonieme toegang. Krijg je 401
|
||||
> terwijl anoniem `200` geeft, haal `UPDATE_CHECK_TOKEN` dan uit `.env` in plaats
|
||||
> van hem te vervangen. Een *leeg* token is onschuldig: `Authorization: token `
|
||||
> negeert Gitea (`200`), en `config/index.js` zet met `|| null` bij een lege
|
||||
> waarde helemaal geen header. Nagemeten op 2026-09-10:
|
||||
> geen header `200`, `token ongeldig123` `401`, leeg `200`.
|
||||
|
||||
De lijst onder Settings → Applications heeft een kolom **Last used**. Zet eerst
|
||||
beide machines om, draai daarna een release en een pull, en kijk een dag later
|
||||
welke tokens niet meer meebewegen — die kun je intrekken. Betrouwbaarder dan uit
|
||||
de naam afleiden wat waar in gebruik is. Trek niets in vóór die controle: het
|
||||
token achter je `docker login` op dit werkstation laat een release op de push
|
||||
falen.
|
||||
### Oude tokens opruimen
|
||||
|
||||
Nu er één token overblijft, kunnen de rest weg — maar niet op de gok. De lijst
|
||||
onder Settings → Applications heeft een kolom **Last used**. Werkwijze:
|
||||
|
||||
1. Zet het nieuwe token in `.env.release` en draai een release die helemaal
|
||||
doorloopt (inclusief `docker push` en stap 5).
|
||||
2. Kijk in de lijst welk token net **Last used** heeft gekregen.
|
||||
3. Wacht een dag en trek de tokens in die niet meer meebewegen.
|
||||
|
||||
Betrouwbaarder dan uit de naam afleiden wat waar in gebruik is. Trek vooral niets
|
||||
in vóór die controle: het token achter je `docker login` zit in de credential
|
||||
store en niet in een bestand, dus je ziet aan niets welke het is — trek je die
|
||||
in, dan valt de push in stap 3 om. Loopt dat toch mis, dan is
|
||||
`docker login vibehoogie.duckdns.org` met het nieuwe token genoeg.
|
||||
|
||||
---
|
||||
|
||||
@@ -144,7 +244,8 @@ falen.
|
||||
|
||||
```powershell
|
||||
# 1. Versienummer in package.json van "x.x.x-dev" naar "x.x.x"
|
||||
# 2. Changelog.md: kop van "Niet vrijgegeven" naar "[x.x.x] - datum"
|
||||
# 2. Wijzigingen in Changelog.md onder de kop "## [Niet vrijgegeven]"
|
||||
# (de kop zelf omzetten hoeft niet — dat doet het script)
|
||||
# 3. Token staat in .env.release (zie Tokens) — of eenmalig in de shell:
|
||||
$env:GITEA_TOKEN = "<vibefinance-workstation>"
|
||||
|
||||
@@ -152,8 +253,9 @@ $env:GITEA_TOKEN = "<vibefinance-workstation>"
|
||||
.\release.ps1 -DryRun # droogloop — toont alle stappen zonder uit te voeren
|
||||
.\release.ps1 -NoBuild # sla docker build over, alleen tag + release
|
||||
.\release.ps1 -SkipTests # sla de testsuite over (niet aanbevolen)
|
||||
.\release.ps1 -NoChangelog # publiceer bewust zonder release-notes
|
||||
|
||||
# 4. Versienummer in package.json naar "x.x.(x+1)-dev" voor volgende cyclus
|
||||
# Klaar. Het versienummer voor de volgende cyclus zet het script zelf.
|
||||
```
|
||||
|
||||
Het script bouwt één image voor `linux/amd64` en `linux/arm64`, zodat dezelfde
|
||||
@@ -161,38 +263,53 @@ tag op een Raspberry Pi en op een x86-server werkt.
|
||||
|
||||
**Wat het script zelf bijwerkt** — met de hand aanpassen is dus dubbel werk:
|
||||
|
||||
| Bestand | Wat |
|
||||
|---|---|
|
||||
| `frontend/package.json` | versie gelijkgetrokken met de root `package.json` |
|
||||
| `README.md` | de `versie-x.x.x`-badge bovenaan |
|
||||
| Bestand | Wat | Wanneer |
|
||||
|---|---|---|
|
||||
| `Changelog.md` | kop `## [Niet vrijgegeven]` → `## [x.x.x] - datum` | vóór de build |
|
||||
| Git-tag | `vx.x.x` op `HEAD`, gepusht naar origin | stap 4 |
|
||||
| `frontend/package.json` | versie gelijkgetrokken met de root `package.json` | vóór de build |
|
||||
| `README.md` | de `versie-x.x.x`-badge bovenaan | vóór de build |
|
||||
| `package.json` (root) | versie → `x.x.(x+1)-dev` voor de volgende cyclus | **ná** het publiceren |
|
||||
|
||||
Beide gebeuren vóór de build, en een `-DryRun` meldt ze zonder te schrijven. Ze
|
||||
blijven na een echte release als wijziging in je working tree staan; neem ze mee
|
||||
in de volgende commit.
|
||||
Een `-DryRun` meldt de vier bestandswijzigingen zonder ze te schrijven (de
|
||||
Git-tag is de uitzondering in die tabel: die hoort bij stap 4, niet bij de
|
||||
auto-updates). Na een echte release blijven die vier als wijziging in je working
|
||||
tree staan; neem ze mee in de volgende commit — de tag staat dan op de commit
|
||||
ervóór, dus de release bevat ze nog niet.
|
||||
|
||||
> **De changelog-kop moet exact `## [x.x.x]` zijn.** `release.ps1` zoekt die kop
|
||||
> om de release-notes te vullen. Staat je werk nog onder `## [Niet vrijgegeven]`,
|
||||
> dan vindt het script niets en valt het terug op de tekst `"Release x.x.x"` —
|
||||
> dan staat er geen inhoudelijke release-notes in Gitea en ziet een gebruiker in
|
||||
> het belletje een placeholder. Dat overkwam 0.1.9. Achteraf repareren kan met
|
||||
> een `PATCH` op `/repos/<org>/<repo>/releases/<id>`, maar vóór de release
|
||||
> omzetten is een stuk minder werk.
|
||||
De laatste staat bewust ná het publiceren: breekt de release eerder af, dan
|
||||
schuift het versienummer niet vooruit. Bijeffect dat je moet kennen — na een
|
||||
geslaagde release staat er een `-dev` nummer in `package.json`, en de controle
|
||||
bovenin het script weigert een `-dev` versie. Dat sluit een dubbele publicatie
|
||||
uit, maar wil je dezelfde versie bewust opnieuw releasen (bijvoorbeeld omdat een
|
||||
tag is verdwenen), zet het nummer dan eerst zelf terug naar `x.x.x`.
|
||||
|
||||
> **Zonder changelog-sectie breekt het script af.** De release-notes komen uit de
|
||||
> sectie `## [x.x.x]` in `Changelog.md`. Vond het script daar niets, dan
|
||||
> publiceerde het stil de tekst `"Release x.x.x"` — dat gebeurde bij 0.1.9 en
|
||||
> 1.0.2. Nu zet het de kop `## [Niet vrijgegeven]` zelf om, en als er ook dán
|
||||
> niets over deze versie in staat, stopt het vóór de build met de melding wat je
|
||||
> moet toevoegen. Wil je bewust zonder notes publiceren, gebruik `-NoChangelog`.
|
||||
>
|
||||
> De controle staat vóór de drie auto-updates hierboven, zodat een afgebroken
|
||||
> release geen gewijzigde bestanden achterlaat. Is een release toch al gepubliceerd
|
||||
> met de placeholder, dan repareer je dat met een `PATCH` op
|
||||
> `/repos/<org>/<repo>/releases/<id>` met de juiste `body`.
|
||||
|
||||
### `.env.release` — token niet elke sessie opnieuw zetten
|
||||
|
||||
Staat er een `.env.release` naast `release.ps1`, dan leest het script die in
|
||||
voordat er iets gebeurt, en hoeft stap 3 niet meer met de hand:
|
||||
|
||||
```
|
||||
```ini
|
||||
# .env.release — staat in .gitignore, hoort alleen op de release-machine
|
||||
GITEA_TOKEN=<vibefinance-workstation>
|
||||
REGISTRY_USER=<gitea-gebruiker>
|
||||
REGISTRY_TOKEN=<vibefinance-workstation>
|
||||
```
|
||||
|
||||
Beide tokenvariabelen krijgen hetzelfde token; zie [Tokens](#tokens) voor waarom
|
||||
dat op deze machine één token mag zijn, en voor wat een 401, 403 of 404 van Gitea
|
||||
betekent.
|
||||
`REGISTRY_TOKEN` hoeft er niet in: beide scripts vallen bij een ontbrekende
|
||||
`REGISTRY_TOKEN` terug op `GITEA_TOKEN`. Zie [Tokens](#tokens) voor de scopes en
|
||||
voor wat een 401, 403 of 404 van Gitea betekent.
|
||||
|
||||
Een variabele die al in je shell staat wint, zodat je per run kunt afwijken zonder
|
||||
het bestand aan te passen. Het script logt alleen welke namen het heeft geladen,
|
||||
@@ -210,12 +327,75 @@ docker compose pull && docker compose up -d
|
||||
docker compose down -v && docker compose up -d
|
||||
```
|
||||
|
||||
Pullen vereist een registry-login op die host — zie
|
||||
Draai dit in de map met `docker-compose.yml` en `.env`. `docker compose` zoekt
|
||||
dat bestand namelijk in de huidige map en kijkt niet omhoog; staat het er niet,
|
||||
dan is het antwoord `no configuration file provided: not found`. Met
|
||||
`docker compose -f /pad/naar/docker-compose.yml` kun je het expliciet meegeven.
|
||||
|
||||
Pullen zelf werkt anoniem zolang de organisatie public is — zie
|
||||
[Pullen op een deploy-machine](#pullen-op-een-deploy-machine).
|
||||
|
||||
### De scan meldt 46 CVE's op postgres:18-alpine
|
||||
|
||||
Dat klopt, en het blijft zo. Ze zitten allemaal in één bestand:
|
||||
|
||||
```bash
|
||||
# Alle bevindingen komen uit /usr/local/bin/gosu, niets uit de Alpine-pakketten
|
||||
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
|
||||
aquasec/trivy image --scanners vuln postgres:18-alpine
|
||||
```
|
||||
|
||||
`gosu` is een klein Go-programma dat het postgres-entrypoint gebruikt om van
|
||||
root naar de `postgres`-gebruiker te schakelen. Upstream compileert het met Go
|
||||
1.24.6, en alle meldingen zijn CVE's in die Go-standaardbibliotheek. Je kunt er
|
||||
niets aan verhelpen met een `pull`: dit ís de nieuwste versie.
|
||||
|
||||
Wat wél is gedaan: beide compose-bestanden starten de container met
|
||||
`user: "postgres"`. Het entrypoint roept gosu alleen aan als het als root start
|
||||
(`if [ "$(id -u)" = '0' ]`), dus die tak wordt overgeslagen en de binary draait
|
||||
nooit. De CVE's zijn daarmee onbereikbaar — de scanner kijkt naar wat aanwezig
|
||||
is, niet naar wat uitgevoerd wordt.
|
||||
|
||||
> Dit werkt ook op een volume dat ooit als root is aangemaakt: de
|
||||
> data-directory is dan al eigendom van postgres. Getest met een bestaande
|
||||
> database; die start en de data blijft intact.
|
||||
|
||||
**Waarom geen eigen image zonder gosu.** Dat kan (`FROM postgres:18-alpine` +
|
||||
`RUN rm -f /usr/local/bin/gosu`) en zet de scan op nul, maar je ruilt een
|
||||
onbereikbaar risico voor een echt risico: je moet dat image dan zelf herbouwen
|
||||
bij elke postgres-patch. Precies die achterstand was het probleem toen het image
|
||||
vier maanden op 18.4 stond met 143 bevindingen. Met het officiële image haalt
|
||||
`docker compose pull` de patches vanzelf op, en zodra upstream gosu met een
|
||||
nieuwere Go herbouwt is het gratis opgelost. `test.ps1` waarschuwt daarom bij
|
||||
stap 4 als `postgres:18-alpine` in de registry nieuwer is dan wat je lokaal hebt.
|
||||
|
||||
### PostgreSQL versie-upgrade
|
||||
|
||||
Bij een upgrade van PostgreSQL (bijv. 16 → 18) is de bestaande data niet compatibel. Stappen:
|
||||
Twee soorten, met een belangrijk verschil. Kijk eerst welke je voor je hebt:
|
||||
|
||||
```bash
|
||||
docker exec vibefinance-postgres postgres --version
|
||||
```
|
||||
|
||||
**Patch binnen dezelfde major** (18.4 → 18.6) — de data-directory blijft
|
||||
geldig, dus je hoeft niets te dumpen. Image ophalen en de container opnieuw
|
||||
laten maken; het volume blijft staan:
|
||||
|
||||
```bash
|
||||
docker compose pull postgres
|
||||
docker compose up -d postgres # let op: géén -v
|
||||
```
|
||||
|
||||
> Gebruik hier nooit `docker compose down -v`. Dat wist het volume en dan is je
|
||||
> database echt weg, terwijl deze upgrade dat helemaal niet vraagt.
|
||||
|
||||
De tag `postgres:18-alpine` beweegt mee met de patches, dus `pull` is genoeg —
|
||||
je hoeft niets in `docker-compose.yml` aan te passen. Loont de moeite: tussen de
|
||||
image van 18.4 en die van 18.6 zat een verschil van 143 naar 45 bevindingen in
|
||||
de kwetsbaarhedenscan, vooral in de Alpine-pakketten eronder.
|
||||
|
||||
**Major-upgrade** (bijv. 16 → 18) — dán is de bestaande data niet compatibel en
|
||||
moet je er wel doorheen met een dump:
|
||||
|
||||
```bash
|
||||
# 1. Backup maken
|
||||
@@ -239,9 +419,11 @@ De Express-app serveert zowel de API als de gebouwde frontend:
|
||||
|
||||
| Verzoek | Wat de app doet |
|
||||
|---|---|
|
||||
| `/api/...` | REST API (rate limited) |
|
||||
| `/assets/...` | statische build uit `STATIC_DIR`, 1 jaar `immutable` gecached |
|
||||
| al het overige | `index.html` — fallback voor React-routing |
|
||||
| `/api/...` | REST API (rate limited, `Cache-Control: no-store`) |
|
||||
| `/assets/...` | gehashte build-bestanden uit `STATIC_DIR`, 1 jaar `immutable` |
|
||||
| `/favicon.svg`, `/icons/...` | vaste namen, dus `max-age=0, must-revalidate` |
|
||||
| pad mét extensie zonder bestand | echte 404, niet `index.html` |
|
||||
| al het overige | `index.html` — fallback voor React-routing, `no-store` |
|
||||
|
||||
`STATIC_DIR` staat standaard op `/app/public`; daar zet de `Dockerfile` de
|
||||
Vite-build neer. Bestaat die map niet (bijv. in dev), dan serveert de app
|
||||
@@ -287,6 +469,12 @@ De backend slaat alle data op in **PostgreSQL**.
|
||||
|
||||
> PostgreSQL 18 gebruikt `PGDATA=/var/lib/postgresql/18/docker`. Het volume is gemount op `/var/lib/postgresql` zodat data bewaard blijft na een container restart.
|
||||
|
||||
Daarnaast heeft de app zelf een backup onder **Instellingen**: die schrijft een
|
||||
ZIP met de data van de *ingelogde* gebruiker (`data.json` + `meta.json`) en leest
|
||||
zo'n ZIP ook weer in. Handig per gebruiker, maar het is geen servercopie — accounts,
|
||||
wachtwoordhashes en de data van andere gebruikers zitten er niet in. Voor een
|
||||
volledige backup gebruik je `pg_dump` hieronder.
|
||||
|
||||
**Backup maken:**
|
||||
|
||||
```bash
|
||||
@@ -313,12 +501,25 @@ docker exec -i vibefinance_postgres psql -U vibefinance -d vibefinance < backup-
|
||||
|
||||
De eerste geregistreerde gebruiker krijgt automatisch de **Admin**-rol. Verdere accounts kunnen via de app aangemaakt worden (Gebruikersbeheer, Admin vereist) of via de API.
|
||||
|
||||
Rollen:
|
||||
| Rol | Rechten |
|
||||
### Rollen
|
||||
|
||||
De rolkeuze in Gebruikersbeheer biedt drie waarden, maar de backend dwingt er op
|
||||
dit moment één af:
|
||||
|
||||
| Rol | Wat de code ermee doet |
|
||||
|---|---|
|
||||
| `Admin` | Alles, inclusief gebruikersbeheer |
|
||||
| `Bewerker` | Data lezen en bewerken |
|
||||
| `Viewer` | Alleen lezen |
|
||||
| `Admin` | Enige rol die wordt gecontroleerd: `requireAdmin` in `middleware/auth.js` bewaakt alle routes onder `/api/users` |
|
||||
| `Bewerker` | Geen eigen rechten — gelijk aan `Viewer` |
|
||||
| `Viewer` | Standaardrol voor een nieuw account; mag de eigen data lezen én schrijven |
|
||||
|
||||
Dat `Viewer` toch mag schrijven is geen gat in de afscherming: `/api/data` werkt
|
||||
per gebruiker en gaat altijd over de rij van het eigen token (`req.user.sub`).
|
||||
Niemand ziet dus data van een ander. Wat ontbreekt is een read-only account —
|
||||
wil je dat, dan is er een controle op `rol` nodig bij `PUT`/`DELETE /api/data`.
|
||||
|
||||
> De "Gebruikers"-knop in de navigatiebalk staat bij iedereen in beeld; bij een
|
||||
> niet-Admin antwoordt `/api/users` met `403` en blijft de lijst leeg. De
|
||||
> afscherming zit dus in de API, niet in de navigatie.
|
||||
|
||||
---
|
||||
|
||||
@@ -380,6 +581,10 @@ Tags:
|
||||
x.x.x-dev development — specifieke dev versie
|
||||
```
|
||||
|
||||
> Image-tags hebben géén `v`-prefix (`1.0.4`), Git-tags wél (`v1.0.4`). Dat is
|
||||
> geen inconsistentie maar de conventie aan beide kanten: container-registries
|
||||
> taggen op het kale nummer, Git-releases met `v`.
|
||||
|
||||
Inloggen op de registry:
|
||||
|
||||
```bash
|
||||
@@ -391,8 +596,9 @@ machine — `vibefinance-workstation`, zie [Tokens](#tokens). Zonder login faalt
|
||||
een push met `401 Unauthorized`; `release.ps1` en `dev.ps1 push` controleren dit
|
||||
vóór de build en stoppen dan meteen.
|
||||
|
||||
Liever niet handmatig inloggen? Zet `REGISTRY_USER` en `REGISTRY_TOKEN` in
|
||||
`.env.release`, dan logt het script zelf in.
|
||||
Liever niet handmatig inloggen? Zet `REGISTRY_USER` in `.env.release`, dan logt
|
||||
het script zelf in met `GITEA_TOKEN` — een aparte `REGISTRY_TOKEN` is alleen
|
||||
nodig als je daar bewust een ánder token voor wilt gebruiken.
|
||||
|
||||
Twee valkuilen: log in op `vibehoogie.duckdns.org` **zonder** `:3000` (dat is een
|
||||
aparte sleutel in de credential store), en doe het als dezelfde Windows-gebruiker
|
||||
@@ -406,20 +612,8 @@ docker-credential-desktop list
|
||||
|
||||
### Pullen op een deploy-machine
|
||||
|
||||
Het package is privé, dus ook een **pull** vereist een login — anoniem geeft
|
||||
`401 Unauthorized` (`docker compose pull` meldt dan "no basic auth credentials"
|
||||
of "pull access denied"). Eenmalig op de Docker-host:
|
||||
|
||||
```bash
|
||||
docker login vibehoogie.duckdns.org
|
||||
# gebruikersnaam: je Gitea-account
|
||||
# wachtwoord: het token van deze host (vibefinance-host, read:package)
|
||||
```
|
||||
|
||||
Daarna werkt `docker compose pull && docker compose up -d`. De login komt in
|
||||
`~/.docker/config.json` van de gebruiker die het commando uitvoert — doe het dus
|
||||
als dezelfde gebruiker (bij `sudo docker` is dat root). Log in op
|
||||
`vibehoogie.duckdns.org` **zonder** `:3000`.
|
||||
De organisatie is public, dus een **pull** werkt anoniem — `docker compose pull &&
|
||||
docker compose up -d` en klaar, zonder `docker login`.
|
||||
|
||||
Controleren zonder te pullen:
|
||||
|
||||
@@ -427,8 +621,23 @@ Controleren zonder te pullen:
|
||||
docker manifest inspect vibehoogie.duckdns.org/vibe/vibefinance-app:latest
|
||||
```
|
||||
|
||||
Ditzelfde token gaat als `UPDATE_CHECK_TOKEN` in de `.env` op deze host, voor de
|
||||
update-check van de app — één token voor beide, zie [Tokens](#tokens).
|
||||
Krijg je `401 Unauthorized`, "no basic auth credentials" of "pull access denied",
|
||||
dan is het package niet publiek leesbaar (organisatie weer op privé, of de
|
||||
package-eigenaar wijkt af van de organisatie). Dan alsnog eenmalig op de host:
|
||||
|
||||
```bash
|
||||
docker login vibehoogie.duckdns.org
|
||||
# gebruikersnaam: je Gitea-account
|
||||
# wachtwoord: een token met read:package
|
||||
```
|
||||
|
||||
De login komt in `~/.docker/config.json` van de gebruiker die het commando
|
||||
uitvoert — doe het dus als dezelfde gebruiker (bij `sudo docker` is dat root).
|
||||
Log in op `vibehoogie.duckdns.org` **zonder** `:3000`.
|
||||
|
||||
Moet je terug naar een token, dan kan datzelfde token ook als
|
||||
`UPDATE_CHECK_TOKEN` in de `.env` op deze host — één token voor beide, zie
|
||||
[Tokens](#tokens).
|
||||
|
||||
---
|
||||
|
||||
@@ -447,7 +656,43 @@ dat op het belletje in de topbalk en onder "Over VibeFinance".
|
||||
|
||||
Builds met een `-dev` suffix melden nooit (die lopen voor op de laatste release).
|
||||
Wil je de melding tijdens ontwikkeling toch testen: open de app met `?updatecheck`
|
||||
in de URL.
|
||||
in de URL. Let op dat `isNieuwer` alleen bij een écht hoger nummer aanslaat — bij
|
||||
`1.0.2-dev` naast release `1.0.1` blijft de kaart dus ook met `?updatecheck` leeg.
|
||||
Zet dan tijdelijk een lagere versie in de root `package.json`.
|
||||
|
||||
**Wat de melding toont**
|
||||
|
||||
Alleen de kop, de versieregel en een link:
|
||||
|
||||
```
|
||||
🔔 Nieuwe versie beschikbaar
|
||||
v1.0.1 is uitgebracht. Je gebruikt momenteel v1.0.0.
|
||||
Release bekijken →
|
||||
```
|
||||
|
||||
De release-notes staan er bewust **niet** in. `/api/update-check` stuurt ze wel
|
||||
mee als `notes`, maar dat is de ruwe Markdown uit de changelog; in een kaart van
|
||||
280px leverde dat zichtbare `### `-koppen en afgekapte regels op. Wie de details
|
||||
wil, volgt de link. Het veld blijft in de API zitten voor het geval je ze ooit
|
||||
ergens met meer ruimte wilt tonen — dan is Markdown renderen de volgende stap.
|
||||
|
||||
**De release-link hangt aan Gitea's `ROOT_URL`**
|
||||
|
||||
De `url` in het antwoord is de `html_url` van de release, en die stelt Gitea bij
|
||||
elk verzoek samen uit `ROOT_URL` in `app.ini` — hij wordt *niet* per release
|
||||
opgeslagen. Staat `ROOT_URL` op een intern adres, dan wijst "Release bekijken"
|
||||
naar iets dat buiten je netwerk niet bestaat, terwijl de melding zelf gewoon
|
||||
werkt (die leest alleen het versienummer).
|
||||
|
||||
Nakijken welke waarde Gitea daadwerkelijk gebruikt — de `issuer` is `ROOT_URL`:
|
||||
|
||||
```bash
|
||||
curl -s https://vibehoogie.duckdns.org/.well-known/openid-configuration | grep issuer
|
||||
```
|
||||
|
||||
`app.ini` wordt alleen bij het opstarten gelezen, dus na een wijziging moet Gitea
|
||||
herstarten. Omdat de URL wordt samengesteld en niet bewaard, zijn daarna álle
|
||||
releases in één keer goed — ook de oude. Een `PATCH` per release is niet nodig.
|
||||
|
||||
**Configuratie**
|
||||
|
||||
@@ -464,7 +709,7 @@ uit `.env` (of de default in `docker-compose.yml`). Beschikbare backend-variabel
|
||||
| Variabele | Betekenis |
|
||||
|---|---|
|
||||
| `UPDATE_CHECK_URL` | Release-API die gepolld wordt. Leeg = melding uit. |
|
||||
| `UPDATE_CHECK_TOKEN` | Nodig zolang de `Vibe`-organisatie in Gitea privé is: het token van de host (`read:repository`, zie [Tokens](#tokens)). Zonder token geeft `releases/latest` 404, met een verkeerde scope 403 — in beide gevallen blijft de melding leeg, met alleen een `WARN` in het containerlog. |
|
||||
| `UPDATE_CHECK_TOKEN` | Optioneel. De `Vibe`-organisatie is public, dus `releases/latest` antwoordt anoniem met `200` en de check heeft geen token nodig. Gaat de organisatie weer privé, dan is het host-token nodig (`read:repository`, zie [Tokens](#tokens)): zonder geeft `releases/latest` dan 404, met een verkeerde scope 403 — in beide gevallen blijft de melding leeg, met alleen een `WARN` in het containerlog. |
|
||||
|
||||
**Een release publiceren**
|
||||
|
||||
|
||||
+402
@@ -2,11 +2,413 @@
|
||||
|
||||
## [Niet vrijgegeven]
|
||||
|
||||
### Opgelost
|
||||
- **De kaart "Rendement Eigen Vermogen" op Voortgang stond leeg.** Bedrag en
|
||||
percentage waren sinds de eerste commit hardcoded als `—` in de JSX; er zat
|
||||
geen berekening achter en `evRows` werd niet eens uit de context gehaald. Het
|
||||
oogde als een datafout — lege database, mislukte API-call — maar de kaart was
|
||||
nooit afgebouwd. Hij rekent nu met dezelfde formule als het blok "Rendement"
|
||||
op Eigen vermogen: huidige waarde min geïnvesteerde fiat, gedeeld door die
|
||||
inleg. Groen bij winst, rood bij verlies, en `—` zolang er niets is ingelegd,
|
||||
zodat er niet door nul wordt gedeeld
|
||||
- Beide kaarten tonen daarmee hetzelfde totaal. Dat is bewust: twee plekken die
|
||||
"rendement" noemen horen niet twee verschillende getallen te geven
|
||||
|
||||
### Gewijzigd
|
||||
- De achtergrond achter een popup is lichter: `rgba(0,0,0,0.75)` naar `0.45` in
|
||||
de gedeelde `Overlay`. Op 0.75 was van het scherm eronder vrijwel niets meer
|
||||
te zien, terwijl je bij het invullen van een modal juist wilt kunnen nakijken
|
||||
waar je vandaan kwam. Gedimd genoeg om de modal de aandacht te laten houden
|
||||
- De popups van NavBar, Instellingen en Gebruikersbeheer gebruiken de gedeelde
|
||||
`Overlay` niet maar hadden hun eigen backdrop op `rgba(0,0,0,0.55)` met
|
||||
`backdropFilter: blur(4px)`. Alle drie staan nu op dezelfde 0.45, en de blur
|
||||
is eruit: die maakte de achtergrond onscherp in plaats van zichtbaar, en
|
||||
zonder dat weg te halen bleven ze alsnog afwijken van de rest. Vier
|
||||
popup-backdrops, één waarde
|
||||
|
||||
## [1.0.12] - 2026-09-18
|
||||
|
||||
### Beveiliging
|
||||
- **De postgres-container draait niet meer als root.** Beide compose-bestanden
|
||||
starten hem met `user: "postgres"`. Alle 46 bevindingen op `postgres:18-alpine`
|
||||
zitten in één bestand — `/usr/local/bin/gosu`, dat upstream met Go 1.24.6
|
||||
compileert — en het entrypoint roept gosu alleen aan als het als root start.
|
||||
Non-root starten slaat die tak over, dus de binary draait nooit meer. De
|
||||
Alpine-pakketten in dat image zijn zelf schoon
|
||||
- Getest op een bestaande database die ooit als root was aangemaakt: die start
|
||||
als non-root en de data blijft intact. Ook een verse database initialiseert
|
||||
gewoon
|
||||
- Bewust géén eigen postgres-image gebouwd om die binary te verwijderen. Dat zou
|
||||
de scan op nul zetten maar je verplichten het image zelf te herbouwen bij elke
|
||||
postgres-patch — precies de achterstand die het image vier maanden op 18.4 met
|
||||
143 bevindingen hield. De afweging staat in `BEHEER.md`, zodat de volgende
|
||||
scan niet opnieuw tot dezelfde vraag leidt
|
||||
- `test.ps1` waarschuwt bij stap 4 wanneer `postgres:18-alpine` in de registry
|
||||
nieuwer is dan wat er lokaal staat. Die achterstand is het werkelijke risico;
|
||||
het getal in de scan is dat niet
|
||||
|
||||
## [1.0.11] - 2026-09-18
|
||||
|
||||
### Beveiliging
|
||||
- **PostgreSQL 18.4 → 18.6.** Het `postgres:18-alpine` image dat in gebruik was
|
||||
dateerde van vier maanden geleden. Dat is een patch binnen dezelfde major, dus
|
||||
de data-directory blijft geldig en er komt geen dump/restore aan te pas — het
|
||||
volume blijft gewoon staan. De kwetsbaarhedenscan op het image gaat van 143
|
||||
naar 45 bevindingen, het grootste deel in de Alpine-pakketten eronder. Wat
|
||||
overblijft (1 kritiek, 21 hoog) zit allemaal in Go's `stdlib` v1.24.6 in een
|
||||
meegecompileerde binary; dat kan alleen upstream oplossen
|
||||
- `BEHEER.md` beschreef alleen de major-upgrade, met een `docker compose down
|
||||
-v` erin. Wie dat bij een patch-upgrade zou volgen, wist zijn database zonder
|
||||
dat het nodig is. De twee gevallen staan nu apart beschreven
|
||||
- **De imagescan staat op nul.** De scan van 1.0.10 gaf 39 bevindingen
|
||||
(1 kritiek, 12 hoog, 15 middel, 11 laag). Geen enkele kwam uit deze
|
||||
applicatie: twintig zaten in `libssl3` en `libcrypto3` (Alpine in het
|
||||
node-image liep achter op 3.5.8-r0) en negentien in de dependencies die npm
|
||||
zelf meelevert — `tar`, `pacote`, `sigstore`, `brace-expansion`, `picomatch`,
|
||||
`ip-address`. De eigen `ip-address` van de app stond al op 10.7.0 en was dus
|
||||
nooit het probleem
|
||||
- Het productie-image doet nu een `apk upgrade` aan het eind van de build. Het
|
||||
node-image wordt minder vaak herbouwd dan Alpine patches uitbrengt, dus
|
||||
OpenSSL loopt er structureel op achter
|
||||
- npm, npx, corepack en yarn gaan uit het productie-image. De container start
|
||||
met `node src/server.js` en gebruikt nooit een pakketbeheerder; ze zaten er
|
||||
alleen in omdat het base-image ze meelevert. Weg met de tool is weg met die
|
||||
hele categorie, ook bij de volgende ronde — npm's dependencies zijn een
|
||||
terugkerende bron van meldingen
|
||||
- Nieuwe teststap **[4/5] Imagescan** met Trivy, die het gebouwde image scant en
|
||||
faalt op CRITICAL of HIGH waarvoor een fix bestaat (`--ignore-unfixed`, want
|
||||
een CVE zonder patch hoort je release niet te blokkeren). `-Strict` trekt de
|
||||
lat naar MEDIUM en LOW, `-SkipScan` slaat hem over. Stap 1 kijkt naar de
|
||||
`package.json`'s en had geen van deze 39 gezien; deze kijkt naar wat er
|
||||
werkelijk in het image zit. `release.ps1` draait dezelfde scan vóór het
|
||||
pushen, dus een image met openstaande CVE's komt de registry niet meer in
|
||||
|
||||
### Toegevoegd
|
||||
- **Export naar HTML.** Onder Instellingen staat naast backup en restore nu
|
||||
"Exporteren naar HTML": één zelfdragend bestand met al je gegevens, bedoeld
|
||||
als laatste vangnet. Draait de app niet meer, is de database weg en ligt er
|
||||
geen backup, dan is dit bestand nog steeds te openen — in elke browser, op een
|
||||
computer zonder internet. Er zit niets van buiten in: opmaak staat inline,
|
||||
geen lettertypen, geen afbeeldingen, geen scripts
|
||||
- De pagina toont een samenvatting met de KPI's, en daaronder eigen vermogen en
|
||||
schulden per categorie met subtotalen en een eindtotaal, de voortgangstabel
|
||||
met verschil per moment, en de historie van EV en schuld. Bedragen staan met
|
||||
twee decimalen — anders dan het scherm, want dit is een archiefstuk waar de
|
||||
exacte waarde in hoort — rechts uitgelijnd met cijfers van gelijke breedte
|
||||
- **Het bestand is tegelijk een backup.** Onderin staat de complete ruwe data in
|
||||
een `<script type="application/json">`-blok dat de browser niet uitvoert en
|
||||
niet toont. "Backup herstellen" accepteert daarom nu naast de ZIP ook zo'n
|
||||
HTML-bestand. Eén bestand dat je zowel kunt lezen als kunt terugzetten
|
||||
- Uitvoer wordt zowel als HTML als binnen het JSON-blok ge-escaped. Een post die
|
||||
`Fonds <A> & co` heet, of een `</script>` in de gegevens, sloopt de pagina
|
||||
niet en breekt de ingebakken backup niet open
|
||||
- Nieuwe `utils/htmlExport.js`, met `leesHtmlBackup` voor de weg terug. Die
|
||||
gebruikt `DOMParser` en geen reguliere expressie: de gegevens kunnen van alles
|
||||
bevatten, en een parser die HTML echt begrijpt trapt daar niet in. Geen nieuwe
|
||||
dependency
|
||||
- De voortgangstabel is Datum plus één kolom per categorie plus vier vaste
|
||||
kolommen — bij zes categorieën twaalf stuks, en die passen op geen enkel
|
||||
scherm. De tabellen scrollen daarom horizontaal in plaats van kolommen buiten
|
||||
beeld te laten vallen. De datumkolom blijft daarbij staan, want zonder die
|
||||
kolom weet je niet meer naar welk moment je kijkt, en een schaduwrandje
|
||||
verschijnt aan de kant waar nog inhoud zit. Dat laatste is een CSS-truc met
|
||||
meeschuivende achtergronden: de pagina mag geen scripts bevatten, maar zonder
|
||||
hint ziet niemand dat er rechts meer staat. Op print vervalt het allemaal en
|
||||
wordt de tabel kleiner gezet, zodat alle kolommen op papier alsnog meekomen
|
||||
- De **initiële schuld** stond wel in de gegevens maar niet op de pagina. Hij
|
||||
hoort niet bij een schuldblok, dus zonder eigen regel viel hij buiten de
|
||||
export. Staat er nu onder de schuldentabel, met "afgelost sinds start" erbij
|
||||
- Stortingen op een **verwijderde categorie** blijven in oude voortgangsrijen
|
||||
staan, maar de app toont alleen kolommen van bestaande categorieën — die
|
||||
bedragen waren dus nergens meer te zien. De export geeft ze alsnog een kolom,
|
||||
gemerkt als `Verwijderd (c9)`. Voor een bestand dat alles moet bewaren wat je
|
||||
ooit hebt ingevoerd is stil weglaten het verkeerde gedrag
|
||||
|
||||
## [1.0.10] - 2026-09-17
|
||||
|
||||
### Opgelost
|
||||
- Het laatste label op de x-as viel over zijn buurman heen (`mei '26` en `26`
|
||||
over elkaar). De stap werd vanaf het begin van de reeks geteld en het laatste
|
||||
punt werd daar los bovenop geforceerd, zodat het op willekeurige afstand van
|
||||
zijn voorganger landde — bij een reeks die niet toevallig op een veelvoud
|
||||
uitkwam, pal ernaast. De stap telt nu terug vanaf het laatste punt: dat valt
|
||||
daarmee per definitie op een stap en houdt volle tussenruimte, zonder dat er
|
||||
nog een uitzondering voor nodig is
|
||||
- De breedte waarop de grafiek rekent ging van 46 naar 54px per label, zodat er
|
||||
lucht tussen staat in plaats van kop-aan-kop. Over reekslengtes van 13 tot 120
|
||||
punten blijft de tussenruimte nu minimaal 57px
|
||||
|
||||
## [1.0.9] - 2026-09-17
|
||||
|
||||
### Gewijzigd
|
||||
- De x-as van beide grafieken toont korte maandlabels (`sep '26`) in plaats van
|
||||
een volledige datum bij elk punt, en ze staan rechtop in plaats van 45 graden
|
||||
gedraaid. Tien tekens gedraaid kosten zo'n 45px en er passen er maar een stuk
|
||||
of veertien op de tekenbreedte; bij een langere reeks liepen ze door elkaar
|
||||
heen tot één grijze band. De grafiek rekent nu uit hoeveel labels er passen en
|
||||
toont elke n-de, met het laatste punt er altijd bij — groeit je reeks, dan dunt
|
||||
hij vanzelf verder uit
|
||||
- Doordat de labels niet meer gedraaid zijn is de ruimte eronder kleiner: `pb`
|
||||
van 52 naar 30 op het dashboard en van 62 naar 34 op Voortgang. Die hoogte gaat
|
||||
naar de lijn zelf
|
||||
- Nieuwe formatter `fmtMaand` in `utils/format.js`, naast `fmtDatum`
|
||||
- De lijnen in de Voortgang-grafieken zijn vloeiend, net als op het dashboard.
|
||||
Ze werden met `<polyline>` als rechte segmenten getekend; nu met dezelfde
|
||||
Catmull-Rom afronding. De gradient eronder is daarmee ook een `<path>` in
|
||||
plaats van een `<polygon>`
|
||||
- `crPath` stond als lokale functie in `DashboardTab.jsx` en is verhuisd naar
|
||||
`utils/grafiek.js`, samen met `vlakPad` voor de vulling onder een lijn. Twee
|
||||
grafieken die dezelfde curve horen te tekenen moeten niet elk hun eigen kopie
|
||||
hebben. In dat bestand staat ook waaróm de inleglijn bewust géén afronding
|
||||
krijgt: een spline schiet tussen twee punten iets buiten hun bereik, en bij
|
||||
een reeks die nooit terugloopt tekent dat een daling die er niet is
|
||||
|
||||
### Opgelost
|
||||
- De tooltip van de Voortgang-grafiek toonde alleen een bedrag. Nu de x-as geen
|
||||
dagen meer noemt was dat de enige plek waar de exacte datum nog te lezen was,
|
||||
en die stond er niet in. De tooltip heeft er een datumregel bij
|
||||
|
||||
## [1.0.8] - 2026-09-15
|
||||
|
||||
### Toegevoegd
|
||||
- **Kleurthema's.** Negen paletten in `frontend/src/constants/themas.js`, te kiezen
|
||||
onder Instellingen naast de dark-mode schakelaar. Thema en licht/donker staan
|
||||
los van elkaar: het thema levert het accent, de schakelaar de ondergrond. Dat
|
||||
scheelt een compleet tweede palet per thema en laat de bestaande schakelaar
|
||||
doen wat hij deed
|
||||
- `useTheme(themaId, darkMode)` leidt randen, invoervelden en schaduwen af uit
|
||||
het accent van het thema, dus die kleuren vanzelf mee. Nieuwe sleutels op `T`:
|
||||
`accent`, `accentLicht`, `accentTekst`, `accentGradient`, `chartVan`,
|
||||
`chartTot`, `darkMode`, `themaId` en `themaNaam`. De bestaande sleutels houden
|
||||
hun naam en betekenis, zodat er niets hoeft te wijzigen dat ze al gebruikt
|
||||
- Het accent dat tekst draagt is per ondergrond een andere stap van dezelfde
|
||||
kleur — de lichte op donker, de donkere op licht. Eén stap voor beide modi
|
||||
haalt aan één kant altijd te weinig contrast
|
||||
|
||||
### Opgelost
|
||||
- De weergavevoorkeuren overleven een herlaad. `darkMode` stond op
|
||||
`useState(true)` en werd nergens bewaard: zette je de app op licht en laadde je
|
||||
opnieuw, dan stond je weer in het donker. Thema en modus gaan nu naar
|
||||
localStorage, net als de kolombreedtes. Ze staan los van je financiële data en
|
||||
gaan dus niet mee in de backup-ZIP
|
||||
- `VoortgangTab.jsx` bepaalde de modus met `T.bg === "#0f1117"`. Die kleur is de
|
||||
achtergrond allang niet meer (dat is `#241A39`), dus de grafiek gebruikte
|
||||
altijd de lichte rasterlijnen — ook in het donker. Leest nu `T.darkMode`, wat
|
||||
met instelbare thema's sowieso de enige werkbare manier is
|
||||
- De kolomkoppen van de categorieën hebben een `title`, net als de
|
||||
Opmerkingen-cellen al hadden: bij de standaardbreedte kapt "Crowdfunding" af
|
||||
- De dark-mode schakelaar in `InstellingenModal.jsx` was een `<div>` met een
|
||||
klik-handler en dus niet met het toetsenbord te bedienen. Nu een
|
||||
`<button role="switch">` met `aria-checked`
|
||||
|
||||
### Gewijzigd
|
||||
- De accenten door de hele app lopen via het thema. Ongeveer 80 verwijzingen in
|
||||
negen bestanden: knoppen, badges, de actieve tab, de doelbalk, de avatar, het
|
||||
logo in de navigatiebalk en de gradient van de vermogenslijn in de grafiek.
|
||||
`PURPLE` en `PURPLE_LIGHT` werden in vijf bestanden uit `constants/index.js`
|
||||
geïmporteerd; die blijven bestaan voor het loginscherm, maar de app leest nu
|
||||
`T.accent` en `T.accentTekst`
|
||||
- De knopstijlen `primaryBtn`, `editBtn` en `addBtn` in `EigenVermogenTab.jsx` en
|
||||
`SchuldTab.jsx` zijn functies van `T` geworden, zoals `cancelBtn` al was — een
|
||||
object op moduleniveau kan het thema niet volgen
|
||||
- `VBadge`, `TabBtn` en `VProgressBar` in `components/ui/index.jsx` lezen hun
|
||||
standaardkleur uit het thema in plaats van uit een geïmporteerde constante. Wie
|
||||
een kleur meegeeft houdt die, dus de categorie- en schuldkleuren blijven staan
|
||||
- Een paar plekken die in lichte modus nauwelijks werkten, lopen nu ook via het
|
||||
thema: de doelbalk had `rgba(255,255,255,0.07)` als achtergrond (onzichtbaar op
|
||||
wit) en de tooltips in beide grafieken hadden een vaste donkere vulling
|
||||
|
||||
### Bekend
|
||||
- Het loginscherm blijft bewust op de huisstijl. Daar zit bijna de helft van alle
|
||||
paarse accenten (32 van de 70) voor een scherm dat je enkele seconden ziet — en
|
||||
bij opslag per account weet je daar nog niet welk thema je moet tonen
|
||||
- De kleurenkiezer voor schuldblokken (`SchuldTab.jsx`) en de categoriekleuren
|
||||
houden hun vaste lijst. Dat is data van de gebruiker, geen thema
|
||||
|
||||
## [1.0.7] - 2026-09-11
|
||||
|
||||
### Opgelost
|
||||
- De tooltip van de dashboardgrafiek viel buiten de tekening zodra vermogen en
|
||||
inleg ver uit elkaar lagen. Hij werd boven het hoogste punt geplaatst en klapte
|
||||
naar onderen als daar geen ruimte was — maar dat "onderen" rekende vanaf het
|
||||
laagste punt, en met een kaart van 76px kwam hij dan onder het tekenvlak
|
||||
terecht: over de datumlabels heen en half afgekapt. Precies in het midden van
|
||||
de grafiek, waar je hem het meest gebruikt. De kaart staat nu náást de
|
||||
hoverlijn (links of rechts, afhankelijk van waar de meeste ruimte is) en wordt
|
||||
verticaal geklemd binnen het tekenvlak, dus hij past altijd
|
||||
|
||||
## [1.0.6] - 2026-09-11
|
||||
|
||||
### Toegevoegd
|
||||
- De grafiek "Ontwikkeling van Eigen Vermogen" op het dashboard heeft een tweede
|
||||
lijn: de **cumulatief geïnvesteerde fiat**. Die is opgebouwd uit dezelfde
|
||||
gegevens als de KPI's erboven — het oudste voortgangsmoment telt als inleg,
|
||||
elke latere rij telt zijn stortingen erbij op — zodat het laatste punt van de
|
||||
lijn exact gelijk is aan "Totaal geïnvesteerde fiat". Het verschil tussen de
|
||||
twee lijnen is je rendement, en dat staat nu ook in de tooltip: vermogen,
|
||||
ingelegd en resultaat per datum
|
||||
- Legenda boven de grafiek, nodig zodra er meer dan één lijn in staat
|
||||
|
||||
### Gewijzigd
|
||||
- De inleglijn wordt met rechte segmenten getekend en niet met de
|
||||
Catmull-Rom-afronding van de vermogenslijn. Cumulatieve inleg loopt nooit
|
||||
terug, maar de bezier-afronding trekt tussen twee ongelijke stortingen een
|
||||
doorbuiging onder het vorige punt — dan zou de grafiek een daling tonen die
|
||||
in de data niet bestaat
|
||||
- De y-as schaalt op beide reeksen. Op alleen de vermogenswaarden zou de
|
||||
inleglijn buiten beeld lopen zodra je meer hebt ingelegd dan het vermogen
|
||||
waard is
|
||||
- De schermentabel in `README.md` beschreef het Dashboard als "totaal eigen
|
||||
vermogen, schulden en netto vermogen". Dat klopte niet: `DashboardTab.jsx`
|
||||
leest `totSchuld` helemaal niet en toont geen schuld en geen nettowaarde — dat
|
||||
staat op het Voortgang-tabblad. De rij noemt nu wat er wél staat (KPI's over
|
||||
inleg, vermogen, winst en ROI, de doelbalk en de grafiek), met een alinea
|
||||
eronder over de twee lijnen
|
||||
|
||||
### Bekend
|
||||
- De screenshot bovenaan `README.md` is van vóór deze wijziging en toont de
|
||||
grafiek nog met één lijn
|
||||
|
||||
## [1.0.5] - 2026-09-11
|
||||
|
||||
### Gewijzigd
|
||||
- De documentatie is gelijkgetrokken met de code. `README.md`: de bestandsboom
|
||||
miste `backend/src/logger.js`, `docker/check-updates.mjs`,
|
||||
`frontend/public/`, `InstellingenModal.jsx`, `LoginBackground.jsx` en de
|
||||
losse bestanden in `utils/`, en noemde de update-route `update-check` terwijl
|
||||
het bestand `routes/update.js` heet. De schermentabel gebruikt nu de labels
|
||||
zoals ze in `constants/index.js` staan ("Schuld", "Voortgang Schuld & EV") en
|
||||
vermeldt **Instellingen** — thema plus backup/restore via ZIP, dat nergens in
|
||||
de README stond
|
||||
- De tabel "wat de app met een verzoek doet" (in beide bestanden) noemde alleen
|
||||
`/assets/...` en de SPA-fallback. `server.js` kent vier gevallen: `no-store`
|
||||
op de API en op HTML, een jaar `immutable` op de gehashte bundels,
|
||||
`must-revalidate` op bestanden met een vaste naam (`favicon.svg`, `icons/*`)
|
||||
en een echte 404 op een pad mét extensie waar geen bestand achter zit
|
||||
- `BEHEER.md` beschreef `Bewerker` als "data lezen en bewerken" en `Viewer` als
|
||||
"alleen lezen". Zo werkt het niet: `requireAdmin` is de enige rolcontrole in
|
||||
de code en bewaakt alleen `/api/users`. Een `Viewer` mag daardoor gewoon zijn
|
||||
eigen data schrijven — geen gat in de afscherming, want `/api/data` gaat altijd
|
||||
over de rij van het eigen token, maar wél iets anders dan er stond. De sectie
|
||||
zegt nu wat de code doet en wat er nodig is voor een echt read-only account
|
||||
- `README.md` en `BEHEER.md` wijzen bij backup naar het juiste niveau: de ZIP
|
||||
onder Instellingen bevat alleen de data van de ingelogde gebruiker, `pg_dump`
|
||||
is de volledige servercopie
|
||||
- `DATABASE_URL` staat in de variabelentabel van `README.md`. Hij wordt door
|
||||
`docker-compose.yml` samengesteld uit de `POSTGRES_*` waarden, dus zelf zetten
|
||||
is alleen nodig buiten Compose
|
||||
|
||||
## [1.0.4] - 2026-09-10
|
||||
|
||||
### Gewijzigd
|
||||
- Git-tags krijgen weer een `v`-prefix (`v1.0.4`), net als t/m `v1.0.0`. Sinds
|
||||
1.0.1 was dat `1.0.1`, `1.0.2`, `1.0.3` — en omdat Gitea de tag op de
|
||||
release-pagina toont, stond daar het kale nummer terwijl de release-naam wél
|
||||
`v1.0.3` was. De image-tags in de registry houden bewust géén `v`; dat is de
|
||||
conventie voor container-tags. De update-check verdraagt beide: backend en
|
||||
frontend strippen een leidende `v` voordat ze versies vergelijken
|
||||
|
||||
### Opgelost
|
||||
- Het omzetten van de changelog-kop slokte de lege regel eronder op, waardoor
|
||||
`## [x.x.x] - datum` direct tegen `### Toegevoegd` aan kwam te staan. Oorzaak
|
||||
was `\s*$` in het zoekpatroon: `\s` matcht ook newlines. Het patroon eindigt nu
|
||||
op `[ \t]*(?=\r?$)` — een lookahead, want dit bestand heeft CRLF en met alleen
|
||||
`[ \t]*$` matchte de kop helemaal niet meer (de `\r` zit ertussen). Zo blijft
|
||||
de match binnen één regel én blijft het regeleinde ongemoeid
|
||||
|
||||
## [1.0.3] - 2026-09-10
|
||||
|
||||
### Toegevoegd
|
||||
- `release.ps1` zet de changelog-kop `## [Niet vrijgegeven]` nu zelf om naar
|
||||
`## [x.x.x] - datum`, net zoals het al deed met `frontend/package.json` en de
|
||||
README-badge. Handmatig omzetten hoeft dus niet meer
|
||||
- Staat er ook na die omzetting niets over deze versie in `Changelog.md`, dan
|
||||
breekt het script af **vóór** de build met de melding wat je moet toevoegen.
|
||||
Eerder viel het stil terug op de tekst `"Release x.x.x"` en meldde het
|
||||
"Gepubliceerd" — dat overkwam 0.1.9 en 1.0.2, en is achteraf alleen met een
|
||||
`PATCH` op de release te herstellen. Nieuwe switch `-NoChangelog` om bewust
|
||||
zonder release-notes te publiceren
|
||||
- `release.ps1` zet na een geslaagde release de versie in de root
|
||||
`package.json` op `x.x.(x+1)-dev`. Dat was voorheen de laatste handmatige stap
|
||||
van de release-workflow. Het staat bewust ná het publiceren, zodat een
|
||||
afgebroken release het nummer niet vooruit schuift
|
||||
|
||||
### Opgelost
|
||||
- **`-DryRun` in combinatie met `-Force` verwijderde écht de Git-tag.** De twee
|
||||
commando's in stap 4 (`git tag -d` en `git push origin :refs/tags/x.x.x`)
|
||||
liepen niet via `Invoke-Cmd` en hadden dus geen droogloop-controle. Gitea
|
||||
ruimt de release op zodra de tag verdwijnt, dus een droogloop kon een
|
||||
gepubliceerde release wissen — zo is de release van 1.0.2 verloren gegaan en
|
||||
opnieuw aangemaakt. Onder `-DryRun` worden beide nu alleen gemeld
|
||||
|
||||
### Gewijzigd
|
||||
- De changelog-controle staat vóór de drie auto-updates, zodat een afgebroken
|
||||
release geen gewijzigde bestanden achterlaat terwijl hij "er is niets
|
||||
gepubliceerd" meldt
|
||||
- Stap 5 leest de release-notes uit het geheugen in plaats van opnieuw van
|
||||
schijf. Een droogloop schrijft de kop niet, en toonde daardoor de placeholder
|
||||
in de payload — precies wat je met een droogloop wilt uitsluiten
|
||||
- De Quick Start in `README.md` begint nu met het ophalen van
|
||||
`docker-compose.yml` (clone, of alleen die twee bestanden via `raw`). Zonder
|
||||
dat bestand antwoordt `docker compose pull` met `no configuration file
|
||||
provided: not found`, en die stap ontbrak. Beide foutmeldingen staan er nu bij
|
||||
met hun oorzaak
|
||||
|
||||
## [1.0.2] - 2026-09-10
|
||||
|
||||
### Gewijzigd
|
||||
- De update-melding op het belletje toont alleen nog de kop, de versieregel en de
|
||||
link naar de release. De release-notes stonden er als ruwe Markdown in — in een
|
||||
kaart van 280px leverde dat zichtbare `### `-koppen en afgekapte regels op.
|
||||
`/api/update-check` stuurt `notes` nog wel mee, zodat ze later ergens met meer
|
||||
ruimte gebruikt kunnen worden
|
||||
- `BEHEER.md` legt vast dat de release-link uit Gitea's `ROOT_URL` wordt
|
||||
samengesteld en niet per release wordt opgeslagen: na een `ROOT_URL`-wijziging
|
||||
plus herstart zijn álle releases in één keer goed, ook de oude. Verder staat er
|
||||
nu hoe je aan het verschil tussen `401` en `403` ziet of een token nog leeft, en
|
||||
hoe je anoniem over https fetcht als SSH niet meedoet
|
||||
|
||||
## [1.0.1] - 2026-09-10
|
||||
|
||||
### Toegevoegd
|
||||
- Kolommen in de tabellen op Eigen Vermogen ("Geïnvesteerde Fiat") en Voortgang
|
||||
zijn versleepbaar: pak de rechterrand van een kolomkop. De breedtes gaan per
|
||||
tabel naar localStorage, dus ze overleven een tabwissel en een herlaad;
|
||||
dubbelklik op de greep zet één kolom terug op zijn standaardbreedte. De logica
|
||||
staat in de nieuwe hook `frontend/src/hooks/useKolomBreedtes.js`, de greep zelf
|
||||
is `.kolom-grip` in `App.css`
|
||||
|
||||
### Gewijzigd
|
||||
- De `Vibe`-organisatie in Gitea staat op **public**, dus lezen vereist geen
|
||||
token meer: `git clone`, `docker compose pull` en de update-check werken
|
||||
anoniem. `UPDATE_CHECK_TOKEN` in `.env` is daarmee optioneel — de code
|
||||
ondersteunt hem nog als vangnet voor het geval de organisatie weer privé
|
||||
wordt. Schrijven verandert niet: `docker push` en het aanmaken van een release
|
||||
hebben nog steeds het werkstation-token nodig
|
||||
- Het tweetoken-model in `BEHEER.md` is teruggebracht tot **één** Gitea-token op
|
||||
het werkstation, met `write:package` en `write:repository`. Het host-token is
|
||||
niet meer nodig (pull en update-check zijn anoniem), `git push` gaat over SSH,
|
||||
en `REGISTRY_TOKEN` hoeft niet apart: `release.ps1` en `dev.ps1` vielen daar al
|
||||
terug op `GITEA_TOKEN`. Geen code-wijziging, alleen minder te beheren
|
||||
- De tabellen op Eigen Vermogen en Voortgang staan op `table-layout: fixed`,
|
||||
nodig om ingestelde kolombreedtes te respecteren. Gevolg: lange tekst rekt een
|
||||
kolom niet meer op maar wordt afgekapt met een ellips. De Opmerkingen-cellen
|
||||
hebben een `title`, dus hover geeft de volledige tekst
|
||||
- Het label bij een beschikbare update in "Over VibeFinance" is "Nieuwe versie"
|
||||
(was "Nieuwste versie" in 1.0.0)
|
||||
|
||||
### Opgelost
|
||||
- De sticky kolomkop van die tabellen was in dark mode maar 30% dekkend: hij
|
||||
gebruikte `T.card` (`rgba(22,23,27,0.30)`), waardoor de rijen tijdens het
|
||||
scrollen zichtbaar door de koptekst heen liepen. Nu `T.popupBg`, de
|
||||
ondoorzichtige variant die al in het thema stond
|
||||
- In dev bakte Vite de versie uit `frontend/package.json` in in plaats van de
|
||||
versie uit de root `package.json`. `VITE_APP_VERSION` kwam alleen als
|
||||
build-arg mee, maar de `development`-stage in de `Dockerfile` declareert die
|
||||
`ARG` niet — en Vite bakt de constante in bij het *starten* van de dev-server,
|
||||
niet bij het bouwen van het image. De variabele staat nu in de
|
||||
runtime-omgeving van de dev-container; `vite.config.js` valt met `||` (was
|
||||
`??`) terug op `package.json` als hij leeg is
|
||||
- API-antwoorden sturen nu `Cache-Control: no-store`. Ze hadden alleen een
|
||||
`ETag` en geen cache-beleid, waardoor de browser zelf mocht bepalen hoe lang
|
||||
hij ze hergebruikte — per browser en profiel verschillend. Gevolg: de
|
||||
|
||||
+23
@@ -60,6 +60,29 @@ ARG APP_VERSION=dev
|
||||
ENV NODE_ENV=production
|
||||
ENV APP_VERSION=$APP_VERSION
|
||||
|
||||
# ── Aanvalsoppervlak beperken ─────────────────────────────────────────────────
|
||||
# Twee stappen die samen elke CVE uit de imagescan wegnemen. Ze staan hier en
|
||||
# niet in een andere stage: alleen dit image gaat naar de registry.
|
||||
#
|
||||
# 1. OS-pakketten bijwerken. Het node-image wordt minder vaak herbouwd dan
|
||||
# Alpine patches uitbrengt, dus libssl3 en libcrypto3 lopen er structureel
|
||||
# op achter — goed voor twintig CVE's in de laatste scan. Dit haalt ze op
|
||||
# het moment van bouwen alsnog bij.
|
||||
#
|
||||
# 2. De pakketbeheerders eruit. De container start met `node src/server.js` en
|
||||
# gebruikt npm, npx, corepack of yarn nooit; ze zitten er alleen in omdat
|
||||
# het base-image ze meelevert. Alle negentien Node-CVE's uit de scan zaten
|
||||
# in npm's eigen meegeleverde dependencies (tar, pacote, sigstore,
|
||||
# brace-expansion, picomatch, ip-address) en hadden niets met deze
|
||||
# applicatie te maken. Weg met de tool is weg met die hele categorie —
|
||||
# inclusief de volgende ronde, want ze komen steeds terug.
|
||||
RUN apk upgrade --no-cache \
|
||||
&& rm -rf /usr/local/lib/node_modules/npm \
|
||||
/usr/local/lib/node_modules/corepack \
|
||||
/usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack \
|
||||
/usr/local/bin/yarn /usr/local/bin/yarnpkg \
|
||||
/opt/yarn-v*
|
||||
|
||||
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Eigen vermogen · Schulden · Voortgang · Gebruikersbeheer
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
<img src="docs/screenshot-dashboard.png" alt="VibeFinance dashboard" width="820">
|
||||
@@ -32,7 +32,25 @@ Deze applicatie is volledig ontworpen en ontwikkeld met behulp van [Claude.ai](h
|
||||
De app draait in **2 containers**: `app` (Express API + gebouwde frontend) en
|
||||
`postgres` (database).
|
||||
|
||||
**`.env`** — kopieer `.env.example` en vul in:
|
||||
**1. De bestanden op de server zetten.** `docker compose` leest
|
||||
`docker-compose.yml` uit de map waarin je het commando draait, dus dat bestand
|
||||
moet er eerst staan. De organisatie in Gitea is publiek, dus dit kan anoniem:
|
||||
|
||||
```bash
|
||||
git clone https://vibehoogie.duckdns.org/Vibe/VibeFinance.git
|
||||
cd VibeFinance
|
||||
```
|
||||
|
||||
Wil je de repo niet op je server, dan zijn deze twee bestanden genoeg:
|
||||
|
||||
```bash
|
||||
mkdir -p vibefinance && cd vibefinance
|
||||
base=https://vibehoogie.duckdns.org/Vibe/VibeFinance/raw/branch/main
|
||||
curl -fsSLO $base/docker-compose.yml
|
||||
curl -fsSL $base/.env.example -o .env.example
|
||||
```
|
||||
|
||||
**2. `.env` aanmaken** — kopieer `.env.example` en vul in:
|
||||
|
||||
```env
|
||||
POSTGRES_USER=vibefinance
|
||||
@@ -46,15 +64,26 @@ APP_PORT=3400
|
||||
openssl rand -hex 64
|
||||
```
|
||||
|
||||
**3. Starten:**
|
||||
|
||||
```bash
|
||||
cp .env.example .env # en vul de waarden in
|
||||
|
||||
# Het app-image staat in een privé-registry, dus eerst inloggen:
|
||||
docker login vibehoogie.duckdns.org # wachtwoord = Gitea-token met read:package
|
||||
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
Twee foutmeldingen en wat ze betekenen:
|
||||
|
||||
- **`no configuration file provided: not found`** — je staat in een map zonder
|
||||
`docker-compose.yml`. Controleer met `ls docker-compose.yml`, of geef het
|
||||
bestand expliciet mee: `docker compose -f /pad/naar/docker-compose.yml up -d`.
|
||||
- **`401 Unauthorized`** of **"pull access denied"** — de organisatie staat niet
|
||||
meer op publiek. Dan alsnog inloggen:
|
||||
|
||||
```bash
|
||||
docker login vibehoogie.duckdns.org # wachtwoord = Gitea-token met read:package
|
||||
```
|
||||
|
||||
Navigeer naar `http://<server-ip>:3400` en registreer je account. De **eerste** geregistreerde gebruiker krijgt automatisch de Admin-rol.
|
||||
|
||||
De container luistert intern op poort 3001 en serveert daar zowel de frontend
|
||||
@@ -88,11 +117,12 @@ uitzonderingen nodig. Handige acties:
|
||||
### Testen
|
||||
|
||||
```powershell
|
||||
.\test.ps1 # dependency-check + backend-tests + build + smoketest
|
||||
.\test.ps1 # deps + backend-tests + build + imagescan + smoketest
|
||||
.\test.ps1 -Unit # dependency-check + backend-tests
|
||||
.\test.ps1 -NoSmoke # zonder de stack te starten
|
||||
.\test.ps1 -Strict # faal als een dependency achterloopt
|
||||
.\test.ps1 -NoSmoke # zonder de stack te starten (imagescan draait wél)
|
||||
.\test.ps1 -Strict # faal op achterlopende deps; scan ook op MEDIUM/LOW
|
||||
.\test.ps1 -SkipDeps # dependency-check overslaan (bijv. offline)
|
||||
.\test.ps1 -SkipScan # imagescan overslaan (bijv. offline)
|
||||
```
|
||||
|
||||
Alles loopt in Docker — Node op de host is niet nodig. De backend-tests draaien
|
||||
@@ -112,28 +142,122 @@ backend/package.json (15 dependencies)
|
||||
alles actueel
|
||||
```
|
||||
|
||||
Stap 4 scant het gebouwde image met [Trivy](https://trivy.dev) en blokkeert bij
|
||||
CRITICAL of HIGH waarvoor een fix bestaat. Dat is iets anders dan stap 1: die
|
||||
kijkt naar de `package.json`'s, deze naar wat er daadwerkelijk in het image zit
|
||||
— de Alpine-pakketten en alles wat het base-image meelevert. Dat onderscheid is
|
||||
niet theoretisch. Bij de scan van 1.0.10 kwamen alle 39 bevindingen daarvandaan
|
||||
en geen enkele uit de applicatie zelf:
|
||||
|
||||
| Bron | Aantal | Waar het vandaan kwam |
|
||||
|---|---|---|
|
||||
| `libssl3` + `libcrypto3` | 20 | Alpine in het node-image liep achter op 3.5.8-r0 |
|
||||
| npm's eigen dependencies | 19 | `tar`, `pacote`, `sigstore`, `brace-expansion`, `picomatch`, `ip-address` |
|
||||
|
||||
Een dependency-check had er nul van gezien. Het productie-image doet daarom aan
|
||||
het eind van de build een `apk upgrade` en gooit npm, npx, corepack en yarn
|
||||
eruit: de container start met `node src/server.js` en gebruikt nooit een
|
||||
pakketbeheerder. Dat bracht de scan op nul, en houdt hem daar — npm's
|
||||
dependencies zijn een terugkerende bron die nu simpelweg niet meer in het image
|
||||
zit. `release.ps1` draait dezelfde scan vóór het pushen, dus een image met
|
||||
openstaande CVE's komt de registry niet in.
|
||||
|
||||
### Productie release
|
||||
|
||||
```powershell
|
||||
# token: staat in .env.release, of eenmalig in de shell
|
||||
$env:GITEA_TOKEN = "<token met write:repository>"
|
||||
# één Gitea-token met write:package (push) én write:repository (release)
|
||||
$env:GITEA_TOKEN = "<token met write:package + write:repository>"
|
||||
.\release.ps1
|
||||
```
|
||||
|
||||
Draait de tests, bouwt het app-image voor `amd64` + `arm64` (Raspberry Pi),
|
||||
pusht naar de registry, zet de Git-tag en publiceert de Gitea-release.
|
||||
|
||||
Het script regelt ook het bijwerken eromheen: de changelog-kop
|
||||
`## [Niet vrijgegeven]` wordt `## [x.x.x] - datum`, de versie in
|
||||
`frontend/package.json` en de badge hierboven gaan mee, en ná het publiceren
|
||||
zet het de root `package.json` op `x.x.(x+1)-dev` voor de volgende cyclus.
|
||||
Beschrijft de changelog niets over deze versie, dan stopt het script vóór de
|
||||
build — anders zou de release met een placeholder als notes online komen.
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ Functionaliteit
|
||||
|
||||
| Scherm | Wat het doet |
|
||||
|---|---|
|
||||
| 📊 **Dashboard** | Overzicht van totaal eigen vermogen, schulden en netto vermogen |
|
||||
| 📊 **Dashboard** | KPI's over inleg, vermogen, winst en ROI, de voortgang naar je doel, en de grafiek met vermogen tegen inleg |
|
||||
| 💰 **Eigen vermogen** | Vermogensposten per categorie beheren + geïnvesteerde fiat historiek |
|
||||
| 📉 **Schulden** | Schulden bijhouden per blok met afbetalingsoverzicht |
|
||||
| 📈 **Voortgang** | Grafieken en trends van EV en schuld over tijd (apart bij te houden) |
|
||||
| 📉 **Schuld** | Schulden bijhouden per blok met afbetalingsoverzicht |
|
||||
| 📈 **Voortgang Schuld & EV** | KPI-kaarten met de verdeling van schuld en EV, het rendement en de netto waarde, plus grafieken en trends van EV en schuld over tijd (apart bij te houden) |
|
||||
| 👥 **Gebruikers** | Accounts aanmaken, bewerken en beheren (Admin-rol vereist) |
|
||||
| ⚙️ **Instellingen** | Kleurthema, licht/donker, backup downloaden, backup terugzetten en export naar HTML |
|
||||
|
||||
De grafiek "Ontwikkeling van Eigen Vermogen" op het dashboard tekent twee lijnen
|
||||
op één as: je netto waarde per voortgangsmoment, en daaronder de cumulatief
|
||||
geïnvesteerde fiat. Het verschil tussen de twee is je rendement; de tooltip zet
|
||||
vermogen, inleg en resultaat per datum onder elkaar. Het laatste punt van de
|
||||
inleglijn is per definitie gelijk aan de KPI "Totaal geïnvesteerde fiat" — beide
|
||||
rekenen het oudste voortgangsmoment als startinleg en tellen daar de stortingen
|
||||
per rij bij op.
|
||||
|
||||
Rendement betekent overal hetzelfde: huidige waarde min geïnvesteerde fiat,
|
||||
gedeeld door die inleg. De kaart "Rendement Eigen Vermogen" op Voortgang en het
|
||||
blok "Rendement" op Eigen vermogen tonen daarom hetzelfde totaal — dat laatste
|
||||
splitst het alleen nog uit per categorie. Zolang er geen inleg is vastgelegd
|
||||
staat er `—` in plaats van een percentage, want zonder noemer is er geen
|
||||
rendement te berekenen.
|
||||
|
||||
De x-as noemt maanden (`sep '26`), niet losse datums: de grafiek rekent uit
|
||||
hoeveel labels er naast elkaar passen en toont er zoveel, terugtellend vanaf het
|
||||
laatste punt zodat dat er altijd bij staat. Bij een langere reeks dunt hij
|
||||
vanzelf verder uit. De exacte dag lees je in de tooltip. De vermogenslijn is een
|
||||
Catmull-Rom spline; de inleglijn krijgt bewust rechte segmenten, omdat een
|
||||
spline tussen twee punten iets buiten hun bereik uitschiet en dat bij een reeks
|
||||
die nooit terugloopt een daling zou tekenen die er niet is. Beide lijnen delen
|
||||
de tekenhulp in
|
||||
[`frontend/src/utils/grafiek.js`](frontend/src/utils/grafiek.js).
|
||||
|
||||
Onder **Instellingen** kies je een van de negen kleurthema's. Thema en
|
||||
licht/donker staan los van elkaar: het thema levert het accent, de schakelaar de
|
||||
ondergrond. De paletten staan in
|
||||
[`frontend/src/constants/themas.js`](frontend/src/constants/themas.js);
|
||||
`useTheme(themaId, darkMode)` leidt daar randen, invoervelden en schaduwen uit af.
|
||||
Groen, rood en goud veranderen bewust nooit mee — die betekenen winst, verlies en
|
||||
inleg, en betekenis hoort niet van een kleurkeuze af te hangen. Thema en modus
|
||||
worden per browser bewaard.
|
||||
|
||||
De kolommen in de tabellen op Eigen vermogen en Voortgang zijn versleepbaar: pak
|
||||
de rechterrand van een kolomkop. De breedtes worden per tabel in de browser
|
||||
bewaard; dubbelklik op de greep voor de standaardbreedte.
|
||||
|
||||
Backup en restore lopen via **Instellingen** in de navigatiebalk: "Backup maken"
|
||||
downloadt een ZIP (`data.json` + `meta.json`) met je eigen financiële data,
|
||||
"Backup terugzetten" leest zo'n ZIP weer in en schrijft hem via `/api/data` weg.
|
||||
Dat is de gebruikersvariant; een volledige database-dump staat in `BEHEER.md`.
|
||||
|
||||
Daarnaast staat er **"Exporteren naar HTML"**: één zelfdragend bestand met al je
|
||||
gegevens, bedoeld als laatste vangnet. Draait de app niet meer, is de database
|
||||
weg en ligt er geen backup, dan opent dit bestand nog steeds — in elke browser,
|
||||
op een computer zonder internet. Er zit niets van buiten in: de opmaak staat
|
||||
inline, er worden geen lettertypen, afbeeldingen of scripts geladen. Je ziet een
|
||||
samenvatting met de KPI's, eigen vermogen en schulden per categorie met
|
||||
subtotalen, de initiële schuld met wat er sinds de start is afgelost, de
|
||||
voortgangstabel en de historie.
|
||||
|
||||
Alles wat je ooit hebt ingevoerd komt mee, ook wat de app zelf niet meer toont:
|
||||
stortingen op een inmiddels verwijderde categorie krijgen een eigen kolom
|
||||
(`Verwijderd (c9)`). De voortgangstabel is bij zes categorieën twaalf kolommen
|
||||
breed en scrollt horizontaal in plaats van kolommen af te kappen; de datumkolom
|
||||
blijft daarbij staan. Op print vervalt de scrollbalk en wordt de tabel kleiner
|
||||
gezet, zodat alle kolommen op papier alsnog passen.
|
||||
|
||||
Het bestand is tegelijk een backup: onderin staat de complete ruwe data in een
|
||||
`<script type="application/json">`-blok dat de browser niet uitvoert en niet
|
||||
toont. "Backup herstellen" accepteert daarom naast de ZIP ook zo'n HTML-bestand.
|
||||
Eén bestand dat je zowel kunt lézen als kunt terugzetten — zie
|
||||
[`frontend/src/utils/htmlExport.js`](frontend/src/utils/htmlExport.js).
|
||||
|
||||
---
|
||||
|
||||
@@ -152,26 +276,34 @@ vibefinance/
|
||||
├── release.ps1 / release.sh # Release (test, push, tag, Gitea-release)
|
||||
│
|
||||
├── docker/
|
||||
│ └── dev-entrypoint.sh # Start API + Vite samen in de dev-container
|
||||
│ ├── dev-entrypoint.sh # Start API + Vite samen in de dev-container
|
||||
│ ├── check-updates.mjs # Dependency-check tegen het npm-register
|
||||
│ └── update-exceptions.json # Pakketten die bewust achterblijven, met reden
|
||||
│
|
||||
├── docs/
|
||||
│ └── screenshot-dashboard.png # Screenshot bovenaan deze README
|
||||
│
|
||||
├── frontend/ # React / Vite SPA (build → dist)
|
||||
│ ├── vite.config.js # /api proxy naar de API in dezelfde container
|
||||
│ ├── index.html # Vite-entry, verwijst naar /favicon.svg
|
||||
│ ├── public/ # Ongebundeld: favicon.svg + icons/
|
||||
│ └── src/
|
||||
│ ├── App.jsx # Root – wiring + idle-logout
|
||||
│ ├── App.css # Reset + losse klassen (o.a. .kolom-grip)
|
||||
│ ├── main.jsx
|
||||
│ ├── version.js # Draaiende versie (__APP_VERSION__ uit de build)
|
||||
│ ├── constants/ # Kleuren, tabs, begindata
|
||||
│ ├── utils/ # Formatters, helpers
|
||||
│ ├── hooks/ # useTheme, useUpdateCheck
|
||||
│ ├── context/ # AppContext (global state + API sync)
|
||||
│ ├── constants/ # index.js (kleuren, tabs, begindata), themas.js
|
||||
│ ├── utils/ # format.js (bedragen/datums), storage.js,
|
||||
│ │ # grafiek.js (SVG), htmlExport.js (export)
|
||||
│ ├── hooks/ # useTheme, useUpdateCheck, useKolomBreedtes
|
||||
│ ├── context/ # AppContext (global state + API sync + backup)
|
||||
│ ├── components/
|
||||
│ │ ├── ui/ # Primitieve UI (VCard, DonutChart…)
|
||||
│ │ ├── LoginPage.jsx
|
||||
│ │ ├── LoginBackground.jsx
|
||||
│ │ ├── NavBar.jsx
|
||||
│ │ ├── ProfielPopup.jsx
|
||||
│ │ ├── InstellingenModal.jsx # Thema + backup/restore + export
|
||||
│ │ └── GebruikersBeheer.jsx
|
||||
│ └── pages/
|
||||
│ ├── DashboardTab.jsx
|
||||
@@ -184,10 +316,11 @@ vibefinance/
|
||||
└── src/
|
||||
├── server.js # Entry point: API op /api, SPA op /
|
||||
├── db.js # PostgreSQL pool + initDb() schema-migraties
|
||||
├── logger.js # pino (pino-pretty buiten productie)
|
||||
├── config/ # Omgevingsvariabelen (incl. STATIC_DIR)
|
||||
├── constants/ # Begindata (initialData.js)
|
||||
├── middleware/ # auth.js, errorHandler.js
|
||||
├── routes/ # auth, data, users, health, update-check
|
||||
├── routes/ # auth, data, users, health, update
|
||||
└── stores/ # userStore, dataStore
|
||||
```
|
||||
|
||||
@@ -203,9 +336,11 @@ frontend en API.
|
||||
|
||||
| Verzoek | Wat de app doet |
|
||||
|---|---|
|
||||
| `/api/...` | REST API (rate limited) |
|
||||
| `/assets/...` | Statische build, 1 jaar `immutable` gecached |
|
||||
| al het overige | `index.html` — fallback voor React-routing |
|
||||
| `/api/...` | REST API (rate limited, `Cache-Control: no-store`) |
|
||||
| `/assets/...` | Gehashte build-bestanden, 1 jaar `immutable` gecached |
|
||||
| `/favicon.svg`, `/icons/...` | Vaste namen, dus `max-age=0, must-revalidate` |
|
||||
| pad mét extensie, geen bestand | echte 404 (geen HTML waar een icoon wordt verwacht) |
|
||||
| al het overige | `index.html` — fallback voor React-routing, niet gecached |
|
||||
|
||||
---
|
||||
|
||||
@@ -253,13 +388,14 @@ Het schema wordt bij het starten van de app aangemaakt/bijgewerkt via
|
||||
| `JWT_SECRET` | — | **Verplicht** — minimaal 64 tekens |
|
||||
| `POSTGRES_USER` | `vibefinance` | Database gebruikersnaam |
|
||||
| `POSTGRES_DB` | `vibefinance` | Database naam |
|
||||
| `DATABASE_URL` | — | Connectiestring; `docker-compose.yml` stelt hem samen uit de drie `POSTGRES_*` waarden, dus zelf zetten hoeft alleen buiten Compose |
|
||||
| `JWT_EXPIRES` | `7d` | Geldigheidsduur token |
|
||||
| `CORS_ORIGIN` | `http://localhost` | Externe domeinnaam (één origin, dus zelden nodig) |
|
||||
| `APP_PORT` | `3400` | Poort op de host (container luistert op 3001) |
|
||||
| `PORT` | `3001` | Poort binnen de container |
|
||||
| `STATIC_DIR` | `/app/public` | Map met de frontend-build |
|
||||
| `UPDATE_CHECK_URL` | Gitea releases-API | Bron update-melding (leeg = uit) |
|
||||
| `UPDATE_CHECK_TOKEN` | — | Nodig zolang de Gitea-repo privé is; scope `read:repository` |
|
||||
| `UPDATE_CHECK_TOKEN` | — | Optioneel: de Gitea-repo is publiek, dus de check werkt anoniem. Alleen nodig als de organisatie weer privé wordt; scope `read:repository` |
|
||||
|
||||
---
|
||||
|
||||
@@ -267,6 +403,10 @@ Het schema wordt bij het starten van de app aangemaakt/bijgewerkt via
|
||||
|
||||
- Wachtwoorden gehasht met bcrypt (cost factor 12)
|
||||
- JWT authenticatie met instelbare vervaltijd
|
||||
- Data is per gebruiker: `/api/data` leest en schrijft alleen de rij van de
|
||||
ingelogde gebruiker (`sub` uit het token)
|
||||
- Van de rollen wordt alleen **Admin** afgedwongen, op `/api/users` — zie de
|
||||
sectie Rollen in `BEHEER.md`
|
||||
- Frontend en API op één origin — geen interne proxy, geen CORS-uitzonderingen
|
||||
- Content-Security-Policy via Helmet, toegespitst op wat de app laadt
|
||||
- Database bereikbaar alleen binnen het interne Docker netwerk
|
||||
@@ -289,8 +429,9 @@ Het schema wordt bij het starten van de app aangemaakt/bijgewerkt via
|
||||
## 🛠️ Beheer
|
||||
|
||||
Zie `BEHEER.md` voor commando's voor lokale ontwikkeling, releases, logs en
|
||||
database-toegang — en voor de sectie **Tokens**: welk Gitea-token op welke
|
||||
machine hoort, met welke scopes, en wat een 401, 403 of 404 van Gitea betekent.
|
||||
database-toegang — en voor de sectie **Tokens**: waarom één Gitea-token op het
|
||||
werkstation genoeg is, welke scopes het nodig heeft, en wat een 401, 403 of 404
|
||||
van Gitea betekent.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ services:
|
||||
postgres:
|
||||
image: postgres:18-alpine
|
||||
container_name: vibefinance-postgres-dev
|
||||
# Zie docker-compose.yml: non-root starten omzeilt gosu, waar alle CVE's
|
||||
# van dit image in zitten.
|
||||
user: "postgres"
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-vibefinance}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-vibefinance}
|
||||
@@ -49,6 +52,11 @@ services:
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
PORT: 3001
|
||||
# Vite bakt de versie in bij het STARTEN van de dev-server, dus die moet
|
||||
# in de runtime-omgeving staan; de build-arg hierboven bereikt alleen de
|
||||
# frontend-build-stage en doet niets voor target: development.
|
||||
# Leeg = vite.config.js valt terug op frontend/package.json.
|
||||
VITE_APP_VERSION: ${VITE_APP_VERSION:-}
|
||||
JWT_SECRET: dev-secret-change-in-production
|
||||
JWT_EXPIRES: 7d
|
||||
CORS_ORIGIN: http://localhost
|
||||
|
||||
@@ -16,6 +16,13 @@ services:
|
||||
image: postgres:18-alpine
|
||||
container_name: vibefinance_postgres
|
||||
restart: unless-stopped
|
||||
# Meteen als postgres draaien in plaats van als root. Het entrypoint schakelt
|
||||
# anders zelf terug met `gosu`, en juist die binary bevat alle 46 CVE's van
|
||||
# dit image (Go stdlib 1.24.6, meegecompileerd door upstream). Start de
|
||||
# container al als postgres, dan slaat het entrypoint die tak over en wordt
|
||||
# gosu nooit uitgevoerd. Werkt ook op een volume dat ooit als root is
|
||||
# aangemaakt — de data-directory is dan al van postgres.
|
||||
user: "postgres"
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-vibefinance}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-vibefinance}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vibefinance-frontend",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.13-dev",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
@@ -26,3 +26,37 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Sleepvlak op de rechterrand van een tabelkop — zie hooks/useKolomBreedtes.js.
|
||||
Blijft binnen de cel (geen negatieve offset), anders duwt de laatste kolom
|
||||
een horizontale scrollbar tevoorschijn. */
|
||||
.kolom-grip {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* Het streepje erft de kleur van de kop, dus per categorie de accentkleur. */
|
||||
.kolom-grip::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 25%;
|
||||
right: 2px;
|
||||
width: 1px;
|
||||
height: 50%;
|
||||
background: currentColor;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.kolom-grip:hover::after,
|
||||
.kolom-grip:active::after {
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 2px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
+10
-14
@@ -8,7 +8,7 @@ import DashboardTab from "./pages/DashboardTab.jsx";
|
||||
import EigenVermogenTab from "./pages/EigenVermogenTab.jsx";
|
||||
import SchuldTab from "./pages/SchuldTab.jsx";
|
||||
import VoortgangTab from "./pages/VoortgangTab.jsx";
|
||||
import { RED, PURPLE_LIGHT } from "./constants/index.js";
|
||||
import { RED } from "./constants/index.js";
|
||||
import { fmt } from "./utils/format.js";
|
||||
import { version } from "./version.js";
|
||||
import { useUpdateCheck } from "./hooks/useUpdateCheck.js";
|
||||
@@ -86,10 +86,10 @@ function AppInner() {
|
||||
</div>
|
||||
<button onClick={() => { setSessionVerlopen(false); }} style={{
|
||||
width: "100%", padding: "14px",
|
||||
background: "linear-gradient(135deg, #8b5cf6, #a855f7)",
|
||||
background: T.accentGradient,
|
||||
border: "none", borderRadius: 12,
|
||||
color: "#fff", fontWeight: 700, fontSize: 15, cursor: "pointer",
|
||||
boxShadow: "0 4px 16px rgba(139,92,246,0.4)",
|
||||
boxShadow: T.cardShadow,
|
||||
}}>
|
||||
Opnieuw inloggen
|
||||
</button>
|
||||
@@ -110,8 +110,8 @@ function AppInner() {
|
||||
}}>
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: "50%",
|
||||
border: `3px solid rgba(139,92,246,0.2)`,
|
||||
borderTop: `3px solid ${PURPLE_LIGHT}`,
|
||||
border: `3px solid ${T.border}`,
|
||||
borderTop: `3px solid ${T.accentTekst}`,
|
||||
animation: "spin 0.8s linear infinite",
|
||||
}} />
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||
@@ -209,14 +209,10 @@ function AppInner() {
|
||||
<div style={{ fontSize: 12, color: T.muted, lineHeight: 1.5 }}>
|
||||
v{nieuweVersie.latest} is uitgebracht. Je gebruikt momenteel v{version}.
|
||||
</div>
|
||||
{nieuweVersie.notes && (
|
||||
<div style={{
|
||||
fontSize: 11, color: T.subtext, marginTop: 6, lineHeight: 1.5,
|
||||
maxHeight: 130, overflowY: "auto", whiteSpace: "pre-wrap",
|
||||
}}>
|
||||
{nieuweVersie.notes}
|
||||
</div>
|
||||
)}
|
||||
{/* De release-notes zelf staan hier niet: dat is Markdown
|
||||
uit de changelog, en die kwam als ruwe `### `-koppen in
|
||||
een smalle kolom te staan. Wie de details wil, volgt de
|
||||
link naar de release. */}
|
||||
{nieuweVersie.url && (
|
||||
<a href={nieuweVersie.url} target="_blank" rel="noreferrer" style={{
|
||||
display: "inline-block", marginTop: 8,
|
||||
@@ -246,7 +242,7 @@ function AppInner() {
|
||||
>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: "50%", overflow: "hidden", flexShrink: 0,
|
||||
background: avatar ? "transparent" : "linear-gradient(135deg, #7c3aed, #a855f7)",
|
||||
background: avatar ? "transparent" : T.accentGradient,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: 12, fontWeight: 800, color: "#fff",
|
||||
border: `2px solid ${T.border}`,
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { RED, GREEN } from "../constants/index.js";
|
||||
|
||||
const TEAL = "linear-gradient(135deg, #7c3aed, #a855f7)";
|
||||
const TEAL_COLOR = "#a855f7";
|
||||
const TEAL_BG = "rgba(124,58,237,0.15)";
|
||||
const TEAL_BORDER = "rgba(124,58,237,0.35)";
|
||||
import { useApp } from "../context/AppContext.jsx";
|
||||
|
||||
const getToken = () => localStorage.getItem("vf_token");
|
||||
@@ -56,11 +52,11 @@ function RolSelect({ value, onChange, opties, T, darkMode }) {
|
||||
{opties.map((o) => (
|
||||
<div key={o} onClick={() => { onChange(o); setOpen(false); }} style={{
|
||||
padding: "9px 12px", fontSize: 13, cursor: "pointer",
|
||||
color: o === value ? TEAL_COLOR : T.text,
|
||||
background: o === value ? TEAL_BG : "transparent",
|
||||
color: o === value ? T.accentTekst : T.text,
|
||||
background: o === value ? T.inputBg : "transparent",
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = "rgba(14,221,212,0.10)"}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = o === value ? TEAL_BG : "transparent"}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = o === value ? T.inputBg : "transparent"}
|
||||
>
|
||||
{o}
|
||||
</div>
|
||||
@@ -176,7 +172,7 @@ export default function GebruikersBeheer({ onClose }) {
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: "fixed", inset: 0, zIndex: 400,
|
||||
background: "rgba(0,0,0,0.55)", backdropFilter: "blur(4px)",
|
||||
background: "rgba(0,0,0,0.45)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
@@ -197,8 +193,8 @@ export default function GebruikersBeheer({ onClose }) {
|
||||
}}>
|
||||
<div>
|
||||
<span style={{ fontWeight: 700, fontSize: 15, color: T.text }}>Gebruikersbeheer</span>
|
||||
<span style={{ marginLeft: 10, fontSize: 11, fontWeight: 600, color: TEAL_COLOR,
|
||||
background: TEAL_BG, borderRadius: 99, padding: "2px 8px", border: `1px solid ${TEAL_BORDER}`,
|
||||
<span style={{ marginLeft: 10, fontSize: 11, fontWeight: 600, color: T.accentTekst,
|
||||
background: T.inputBg, borderRadius: 99, padding: "2px 8px", border: `1px solid ${T.inputBorder}`,
|
||||
}}>
|
||||
{users.length} gebruiker{users.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
@@ -236,18 +232,18 @@ export default function GebruikersBeheer({ onClose }) {
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: "50%",
|
||||
background: TEAL_BG, display: "flex",
|
||||
background: T.inputBg, display: "flex",
|
||||
alignItems: "center", justifyContent: "center",
|
||||
fontSize: 12, fontWeight: 700, color: TEAL_COLOR, flexShrink: 0,
|
||||
fontSize: 12, fontWeight: 700, color: T.accentTekst, flexShrink: 0,
|
||||
}}>
|
||||
{initials}
|
||||
</div>
|
||||
<span style={{ fontWeight: 600 }}>{u.naam}</span>
|
||||
{isSelf && (
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 700, color: TEAL_COLOR,
|
||||
background: TEAL_BG, borderRadius: 99,
|
||||
padding: "1px 7px", border: `1px solid ${TEAL_BORDER}`,
|
||||
fontSize: 10, fontWeight: 700, color: T.accentTekst,
|
||||
background: T.inputBg, borderRadius: 99,
|
||||
padding: "1px 7px", border: `1px solid ${T.inputBorder}`,
|
||||
}}>jij</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -263,9 +259,9 @@ export default function GebruikersBeheer({ onClose }) {
|
||||
<td style={{ ...td, whiteSpace: "nowrap" }}>
|
||||
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
||||
<button onClick={() => openEdit(u)} style={{
|
||||
padding: "3px 8px", background: TEAL_BG,
|
||||
border: `1px solid ${TEAL_BORDER}`, borderRadius: 6,
|
||||
color: TEAL_COLOR, cursor: "pointer", fontSize: 12,
|
||||
padding: "3px 8px", background: T.inputBg,
|
||||
border: `1px solid ${T.inputBorder}`, borderRadius: 6,
|
||||
color: T.accentTekst, cursor: "pointer", fontSize: 12,
|
||||
}}>✏️</button>
|
||||
{!isSelf && (
|
||||
<>
|
||||
@@ -303,10 +299,10 @@ export default function GebruikersBeheer({ onClose }) {
|
||||
{/* Bewerken modal */}
|
||||
{editUser && (
|
||||
<div style={{
|
||||
background: darkMode ? "rgba(124,58,237,0.06)" : "#f5f0ff",
|
||||
border: `1px solid ${TEAL_BORDER}`, borderRadius: 10, padding: "16px", marginBottom: 16,
|
||||
background: darkMode ? "rgba(124,58,237,0.06)" : T.inputBg,
|
||||
border: `1px solid ${T.inputBorder}`, borderRadius: 10, padding: "16px", marginBottom: 16,
|
||||
}}>
|
||||
<div style={{ fontSize: 11, color: TEAL_COLOR, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 11, color: T.accentTekst, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 12 }}>
|
||||
{editUser.naam} bewerken
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 10 }}>
|
||||
@@ -322,7 +318,7 @@ export default function GebruikersBeheer({ onClose }) {
|
||||
}}>Annuleren</button>
|
||||
<button onClick={saveEdit} disabled={saving} style={{
|
||||
flex: 2, padding: "8px",
|
||||
background: TEAL,
|
||||
background: T.accentGradient,
|
||||
border: "none", borderRadius: 8, color: "#fff",
|
||||
fontWeight: 700, fontSize: 13, cursor: saving ? "not-allowed" : "pointer",
|
||||
}}>{saving ? "Opslaan..." : "Opslaan"}</button>
|
||||
@@ -333,7 +329,7 @@ export default function GebruikersBeheer({ onClose }) {
|
||||
{/* Nieuwe gebruiker */}
|
||||
{showAdd ? (
|
||||
<div style={{
|
||||
background: darkMode ? "rgba(124,58,237,0.06)" : "#f5f0ff",
|
||||
background: darkMode ? "rgba(124,58,237,0.06)" : T.inputBg,
|
||||
border: `1px solid ${T.border}`, borderRadius: 10, padding: "16px",
|
||||
}}>
|
||||
<div style={{ fontSize: 11, color: T.muted, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 12 }}>
|
||||
@@ -353,7 +349,7 @@ export default function GebruikersBeheer({ onClose }) {
|
||||
}}>Annuleren</button>
|
||||
<button onClick={addUser} disabled={saving} style={{
|
||||
flex: 2, padding: "8px",
|
||||
background: TEAL,
|
||||
background: T.accentGradient,
|
||||
border: "none", borderRadius: 8, color: "#fff",
|
||||
fontWeight: 700, fontSize: 13, cursor: saving ? "not-allowed" : "pointer",
|
||||
}}>
|
||||
@@ -364,7 +360,7 @@ export default function GebruikersBeheer({ onClose }) {
|
||||
) : (
|
||||
<button onClick={() => setShowAdd(true)} style={{
|
||||
padding: "8px 18px",
|
||||
background: TEAL,
|
||||
background: T.accentGradient,
|
||||
border: "none", borderRadius: 8, color: "#fff",
|
||||
fontWeight: 700, fontSize: 13, cursor: "pointer",
|
||||
}}>+ Nieuwe gebruiker</button>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useApp } from "../context/AppContext.jsx";
|
||||
|
||||
const TEAL = "linear-gradient(135deg, #7c3aed, #a855f7)";
|
||||
import { THEMAS } from "../constants/themas.js";
|
||||
|
||||
const BACKUP_ICON = (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
@@ -16,6 +15,14 @@ const RESTORE_ICON = (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const EXPORT_ICON = (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M14 3H7a1 1 0 00-1 1v16a1 1 0 001 1h10a1 1 0 001-1V7l-4-4z" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round"/>
|
||||
<path d="M14 3v4h4" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round"/>
|
||||
<path d="M9 12h6M9 16h6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
function ActionRow({ icon, title, description, onClick }) {
|
||||
return (
|
||||
<div
|
||||
@@ -35,14 +42,14 @@ function ActionRow({ icon, title, description, onClick }) {
|
||||
}
|
||||
|
||||
export default function InstellingenModal({ onClose }) {
|
||||
const { T, darkMode, setDarkMode, doBackup, doRestore } = useApp();
|
||||
const { T, darkMode, setDarkMode, thema, setThema, doBackup, doRestore, doExport } = useApp();
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: "fixed", inset: 0, zIndex: 400,
|
||||
background: "rgba(0,0,0,0.55)", backdropFilter: "blur(4px)",
|
||||
background: "rgba(0,0,0,0.45)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
@@ -70,21 +77,63 @@ export default function InstellingenModal({ onClose }) {
|
||||
padding: "4px 0 16px",
|
||||
}}>
|
||||
<span style={{ fontWeight: 600, fontSize: 14 }}>Dark mode</span>
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={darkMode}
|
||||
aria-label="Donkere modus"
|
||||
onClick={() => setDarkMode((d) => !d)}
|
||||
style={{
|
||||
width: 44, height: 24, borderRadius: 12, cursor: "pointer",
|
||||
background: darkMode ? TEAL : "#334155",
|
||||
background: darkMode ? T.accent : "#334155",
|
||||
border: "none", padding: 0,
|
||||
position: "relative", transition: "background 0.2s", flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
<span style={{
|
||||
position: "absolute", top: 3, borderRadius: "50%",
|
||||
width: 18, height: 18, background: "#fff",
|
||||
left: darkMode ? 23 : 3,
|
||||
transition: "left 0.2s",
|
||||
boxShadow: "0 1px 4px rgba(0,0,0,0.3)",
|
||||
}} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Kleurthema */}
|
||||
<div style={{ padding: "0 0 18px" }}>
|
||||
<div style={{
|
||||
display: "flex", alignItems: "baseline", justifyContent: "space-between",
|
||||
gap: 10, marginBottom: 10,
|
||||
}}>
|
||||
<span style={{ fontWeight: 600, fontSize: 14 }}>Kleurthema</span>
|
||||
<span style={{ fontSize: 12, color: T.muted }}>{T.themaNaam}</span>
|
||||
</div>
|
||||
{/* Drie kolommen: negen thema's vullen zo precies drie rijen, zonder
|
||||
een halve rij onderaan. */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 8 }}>
|
||||
{THEMAS.map((t) => {
|
||||
const gekozen = t.id === thema;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setThema(t.id)}
|
||||
title={t.naam}
|
||||
aria-label={`Thema ${t.naam}`}
|
||||
aria-pressed={gekozen}
|
||||
style={{
|
||||
height: 30, borderRadius: 9, cursor: "pointer", padding: 0,
|
||||
background: `linear-gradient(135deg, ${t.hoofd}, ${t.licht})`,
|
||||
// De gekozen kleur krijgt een ring in de kaartkleur plus een
|
||||
// rand, zodat hij ook opvalt als het thema zelf licht is.
|
||||
border: `2px solid ${gekozen ? T.text : "transparent"}`,
|
||||
boxShadow: gekozen ? `0 0 0 2px ${T.accentLicht}` : "none",
|
||||
transition: "box-shadow 0.15s, border-color 0.15s",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -102,9 +151,16 @@ export default function InstellingenModal({ onClose }) {
|
||||
<ActionRow
|
||||
icon={RESTORE_ICON}
|
||||
title="Backup herstellen"
|
||||
description="Herstel data vanuit een backup-bestand"
|
||||
description="Herstel vanuit een backup-ZIP of HTML"
|
||||
onClick={() => { doRestore(); onClose(); }}
|
||||
/>
|
||||
<div style={{ height: 1, background: T.border }} />
|
||||
<ActionRow
|
||||
icon={EXPORT_ICON}
|
||||
title="Exporteren naar HTML"
|
||||
description="Leesbare pagina die ook als backup werkt"
|
||||
onClick={() => { doExport(); onClose(); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { TABS } from "../constants/index.js";
|
||||
import { TABS, RED } from "../constants/index.js";
|
||||
import { version } from "../version.js";
|
||||
import { useUpdateCheck } from "../hooks/useUpdateCheck.js";
|
||||
import { useApp } from "../context/AppContext.jsx";
|
||||
@@ -82,9 +82,6 @@ function SectionLabel({ children }) {
|
||||
);
|
||||
}
|
||||
|
||||
const ACTIVE_BG = "#7c3aed";
|
||||
const ACTIVE_SHADOW = "0 4px 14px rgba(124,58,237,0.4)";
|
||||
|
||||
function NavItem({ active, onClick, icon, label, T, danger }) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const isLit = active || hovered;
|
||||
@@ -98,12 +95,12 @@ function NavItem({ active, onClick, icon, label, T, danger }) {
|
||||
display: "flex", alignItems: "center", gap: 10,
|
||||
padding: "11px 12px", borderRadius: 9, cursor: "pointer", marginBottom: 4,
|
||||
background: active
|
||||
? ACTIVE_BG
|
||||
: hovered ? "rgba(139,92,246,0.08)" : "transparent",
|
||||
color: active ? "#fff" : hovered ? T.text : (danger ? "#ef4444" : T.muted),
|
||||
? T.accent
|
||||
: hovered ? T.inputBg : "transparent",
|
||||
color: active ? "#fff" : hovered ? T.text : (danger ? RED : T.muted),
|
||||
fontWeight: active ? 600 : 400,
|
||||
fontSize: 15,
|
||||
boxShadow: active ? ACTIVE_SHADOW : "none",
|
||||
boxShadow: active ? T.cardShadow : "none",
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
>
|
||||
@@ -119,7 +116,7 @@ function OverPopup({ onClose, nieuweVersie, T }) {
|
||||
{/* Overlay */}
|
||||
<div onClick={onClose} style={{
|
||||
position: "fixed", inset: 0, zIndex: 490,
|
||||
background: "rgba(0,0,0,0.55)", backdropFilter: "blur(4px)",
|
||||
background: "rgba(0,0,0,0.45)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
}}>
|
||||
{/* Modal */}
|
||||
@@ -141,12 +138,12 @@ function OverPopup({ onClose, nieuweVersie, T }) {
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 20 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 12, flexShrink: 0,
|
||||
background: "rgba(124,58,237,0.15)", border: "1px solid rgba(124,58,237,0.3)",
|
||||
background: T.inputBg, border: `1px solid ${T.inputBorder}`,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
}}>
|
||||
<svg width="22" height="22" viewBox="0 0 20 20" fill="none">
|
||||
<polyline points="2,15 6,9 10,12 14,5 18,8" stroke="#a855f7" strokeWidth="2.2" strokeLinejoin="round" strokeLinecap="round"/>
|
||||
<line x1="2" y1="17" x2="18" y2="17" stroke="rgba(168,85,247,0.35)" strokeWidth="1.4"/>
|
||||
<polyline points="2,15 6,9 10,12 14,5 18,8" stroke={T.accentTekst} strokeWidth="2.2" strokeLinejoin="round" strokeLinecap="round"/>
|
||||
<line x1="2" y1="17" x2="18" y2="17" stroke={T.accentTekst} strokeOpacity="0.35" strokeWidth="1.4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
@@ -158,7 +155,7 @@ function OverPopup({ onClose, nieuweVersie, T }) {
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
|
||||
<span style={{ fontSize: 13, color: T.muted }}>Versie</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: "#a855f7", background: "rgba(124,58,237,0.15)", borderRadius: 6, padding: "3px 10px" }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: T.accentTekst, background: T.inputBg, borderRadius: 6, padding: "3px 10px" }}>
|
||||
v{version}
|
||||
</span>
|
||||
</div>
|
||||
@@ -198,7 +195,7 @@ function DarkModeToggle({ darkMode, setDarkMode, T }) {
|
||||
{/* Toggle pill */}
|
||||
<div style={{
|
||||
width: 36, height: 20, borderRadius: 99, flexShrink: 0,
|
||||
background: darkMode ? "#7c3aed" : "rgba(255,255,255,0.15)",
|
||||
background: darkMode ? T.accent : "rgba(255,255,255,0.15)",
|
||||
position: "relative", transition: "background 0.2s",
|
||||
}}>
|
||||
<div style={{
|
||||
@@ -231,16 +228,16 @@ export default function NavBar() {
|
||||
<div style={{ padding: "22px 16px 28px", display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<div style={{
|
||||
width: 32, height: 32, flexShrink: 0, borderRadius: 9,
|
||||
background: "rgba(124,58,237,0.15)",
|
||||
border: "1px solid rgba(124,58,237,0.3)",
|
||||
background: T.inputBg,
|
||||
border: `1px solid ${T.inputBorder}`,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
}}>
|
||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none">
|
||||
<polyline points="2,15 6,9 10,12 14,5 18,8"
|
||||
stroke="#a855f7" strokeWidth="2.2"
|
||||
stroke={T.accentTekst} strokeWidth="2.2"
|
||||
strokeLinejoin="round" strokeLinecap="round"/>
|
||||
<line x1="2" y1="17" x2="18" y2="17"
|
||||
stroke="rgba(168,85,247,0.35)" strokeWidth="1.4"/>
|
||||
stroke={T.accentTekst} strokeOpacity="0.35" strokeWidth="1.4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span style={{ fontSize: 17, fontWeight: 700, color: T.text, letterSpacing: "-0.3px" }}>VibeFinance</span>
|
||||
|
||||
@@ -2,8 +2,6 @@ import { useState } from "react";
|
||||
import { useApp } from "../context/AppContext.jsx";
|
||||
import { RED } from "../constants/index.js";
|
||||
|
||||
const TEAL = "linear-gradient(135deg, #7c3aed, #a855f7)";
|
||||
const TEAL_SHADOW = "0 4px 14px rgba(124,58,237,0.4)";
|
||||
|
||||
function initials(naam = "") {
|
||||
const parts = naam.trim().split(/\s+/);
|
||||
@@ -149,7 +147,7 @@ export default function ProfielPopup({ onClose }) {
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", padding: "24px 18px 20px", gap: 6 }}>
|
||||
<div style={{
|
||||
width: 72, height: 72, borderRadius: "50%", overflow: "hidden",
|
||||
background: avatar ? "transparent" : TEAL,
|
||||
background: avatar ? "transparent" : T.accentGradient,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: 24, fontWeight: 800, color: "#fff",
|
||||
boxShadow: "0 0 20px rgba(124,58,237,0.4)", marginBottom: 4,
|
||||
@@ -163,10 +161,10 @@ export default function ProfielPopup({ onClose }) {
|
||||
<div style={{ fontSize: 13, color: T.muted }}>{currentUser?.email}</div>
|
||||
<button onClick={() => setView("profiel")} style={{
|
||||
marginTop: 8, padding: "9px 24px",
|
||||
background: TEAL,
|
||||
background: T.accentGradient,
|
||||
border: "none", borderRadius: 10, color: "#fff",
|
||||
fontWeight: 700, fontSize: 13, cursor: "pointer",
|
||||
boxShadow: TEAL_SHADOW,
|
||||
boxShadow: T.cardShadow,
|
||||
}}>Profiel bewerken</button>
|
||||
</div>
|
||||
|
||||
@@ -189,7 +187,7 @@ export default function ProfielPopup({ onClose }) {
|
||||
<label style={{ cursor: "pointer", position: "relative", display: "block" }} title="Klik om foto te uploaden">
|
||||
<div style={{
|
||||
width: 64, height: 64, borderRadius: "50%", overflow: "hidden",
|
||||
background: avatar ? "transparent" : TEAL,
|
||||
background: avatar ? "transparent" : T.accentGradient,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: 20, fontWeight: 800, color: "#fff",
|
||||
}}>
|
||||
@@ -229,7 +227,7 @@ export default function ProfielPopup({ onClose }) {
|
||||
{profielErr && <div style={{ fontSize: 12, color: RED }}>{profielErr}</div>}
|
||||
{profielOk && <div style={{ fontSize: 12, color: "#10b981" }}>Profiel opgeslagen.</div>}
|
||||
<button onClick={saveProfiel} disabled={profielBusy} style={{
|
||||
padding: "11px", background: TEAL,
|
||||
padding: "11px", background: T.accentGradient,
|
||||
border: "none", borderRadius: 10, color: "#fff", fontWeight: 700,
|
||||
fontSize: 13, cursor: profielBusy ? "default" : "pointer", opacity: profielBusy ? 0.7 : 1,
|
||||
}}>{profielBusy ? "Opslaan…" : "Opslaan"}</button>
|
||||
@@ -252,7 +250,7 @@ export default function ProfielPopup({ onClose }) {
|
||||
{pwErr && <div style={{ fontSize: 12, color: RED }}>{pwErr}</div>}
|
||||
{pwOk && <div style={{ fontSize: 12, color: "#10b981" }}>Wachtwoord gewijzigd.</div>}
|
||||
<button onClick={saveWachtwoord} disabled={pwBusy} style={{
|
||||
padding: "11px", background: TEAL,
|
||||
padding: "11px", background: T.accentGradient,
|
||||
border: "none", borderRadius: 10, color: "#fff", fontWeight: 700,
|
||||
fontSize: 13, cursor: pwBusy ? "default" : "pointer", opacity: pwBusy ? 0.7 : 1,
|
||||
}}>{pwBusy ? "Opslaan…" : "Opslaan"}</button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRef, useState, useCallback, useContext, createContext } from "react";
|
||||
import { PURPLE, PURPLE_LIGHT, EMOJI_OPTIONS } from "../../constants/index.js";
|
||||
import { EMOJI_OPTIONS } from "../../constants/index.js";
|
||||
import { useApp } from "../../context/AppContext.jsx";
|
||||
|
||||
const DragContext = createContext(null);
|
||||
@@ -18,11 +18,13 @@ export function VCard({ children, style = {} }) {
|
||||
}
|
||||
|
||||
// ── VBadge ────────────────────────────────────────────────────────────────────
|
||||
export function VBadge({ label, color = PURPLE }) {
|
||||
export function VBadge({ label, color }) {
|
||||
const { T } = useApp();
|
||||
const kleur = color ?? T.accentTekst;
|
||||
return (
|
||||
<span style={{
|
||||
background: `${color}22`, color,
|
||||
border: `1px solid ${color}44`,
|
||||
background: `${kleur}22`, color: kleur,
|
||||
border: `1px solid ${kleur}44`,
|
||||
borderRadius: 99, padding: "2px 10px",
|
||||
fontSize: 11, fontWeight: 700,
|
||||
}}>
|
||||
@@ -32,16 +34,17 @@ export function VBadge({ label, color = PURPLE }) {
|
||||
}
|
||||
|
||||
// ── TabBtn ────────────────────────────────────────────────────────────────────
|
||||
export function TabBtn({ active, onClick, children, darkMode = true }) {
|
||||
export function TabBtn({ active, onClick, children }) {
|
||||
const { T } = useApp();
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
padding: "8px 16px", border: "none", borderRadius: 8, cursor: "pointer",
|
||||
fontSize: 13, fontWeight: active ? 700 : 500,
|
||||
background: active ? `${PURPLE}22` : "transparent",
|
||||
color: active ? PURPLE_LIGHT : (darkMode ? "#94a3b8" : "#64748b"),
|
||||
borderBottom: active ? `2px solid ${PURPLE_LIGHT}` : "2px solid transparent",
|
||||
background: active ? `${T.accent}22` : "transparent",
|
||||
color: active ? T.accentTekst : T.muted,
|
||||
borderBottom: active ? `2px solid ${T.accentTekst}` : "2px solid transparent",
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
>
|
||||
@@ -51,12 +54,14 @@ export function TabBtn({ active, onClick, children, darkMode = true }) {
|
||||
}
|
||||
|
||||
// ── VProgressBar ──────────────────────────────────────────────────────────────
|
||||
export function VProgressBar({ value, max, color = PURPLE }) {
|
||||
export function VProgressBar({ value, max, color }) {
|
||||
const { T } = useApp();
|
||||
const kleur = color ?? T.accent;
|
||||
const p = Math.min((value / (max || 1)) * 100, 100);
|
||||
return (
|
||||
<div style={{ height: 8, background: "rgba(255,255,255,0.07)", borderRadius: 99, overflow: "hidden" }}>
|
||||
<div style={{
|
||||
height: "100%", width: `${p}%`, background: color,
|
||||
height: "100%", width: `${p}%`, background: kleur,
|
||||
borderRadius: 99, transition: "width 0.5s ease",
|
||||
}} />
|
||||
</div>
|
||||
@@ -314,7 +319,7 @@ export function Overlay({ children, onClose, zIndex = 400 }) {
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
style={{
|
||||
position: "fixed", inset: 0,
|
||||
background: "rgba(0,0,0,0.75)",
|
||||
background: "rgba(0,0,0,0.45)",
|
||||
zIndex,
|
||||
}}
|
||||
>
|
||||
@@ -527,7 +532,7 @@ export function EmptyState({ icon, text, hint, onClick, locked, T }) {
|
||||
<div style={{ fontSize: 32, marginBottom: 10 }}>{icon}</div>
|
||||
<div style={{ fontSize: 13, marginBottom: 4 }}>{text}</div>
|
||||
{!locked && hint && (
|
||||
<div style={{ fontSize: 12, color: PURPLE_LIGHT }}>{hint}</div>
|
||||
<div style={{ fontSize: 12, color: T.accentTekst }}>{hint}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Kleurthema's.
|
||||
*
|
||||
* Een thema bepaalt het **accent**, de dark-mode schakelaar bepaalt de
|
||||
* **ondergrond**. Dat scheelt een compleet tweede palet per thema, en de
|
||||
* bestaande schakelaar blijft doen wat hij deed.
|
||||
*
|
||||
* Per thema:
|
||||
* hoofd de donkere stap van het accent — draagt tekst op een lichte ondergrond
|
||||
* licht de lichte stap — draagt tekst op een donkere ondergrond
|
||||
* chart startkleur van de gradient in de vermogensgrafiek (loopt naar `licht`)
|
||||
* donker / helder: de ondergronden, licht naar het accent toe getint
|
||||
*
|
||||
* Eén stap voor beide modi haalt aan één kant altijd te weinig contrast; daarom
|
||||
* kiest `useTheme` per modus de passende stap als `accentTekst`.
|
||||
*
|
||||
* Wat hier NIET in staat en dat ook niet hoort: GREEN, RED en GOLD uit
|
||||
* `constants/index.js`. Die betekenen iets — winst, verlies en schuld, en de
|
||||
* inleglijn in de grafiek. Betekenis mag niet van een themakeuze afhangen.
|
||||
* Hetzelfde geldt voor de kleuren die de gebruiker zelf aan categorieën en
|
||||
* schuldblokken geeft.
|
||||
*/
|
||||
|
||||
export const THEMAS = [
|
||||
{
|
||||
id: "violet", naam: "Violet",
|
||||
hoofd: "#7c3aed", licht: "#a855f7", chart: "#0eddd4",
|
||||
donker: { bg: "#241A39", diep: "#1b1330", card: "#201930", tekst: "#ffffff", sub: "#a0aec0", muted: "#8b8fa8" },
|
||||
helder: { bg: "#eef1f8", diep: "#e6eaf5", card: "#ffffff", tekst: "#0f172a", sub: "#475569", muted: "#7c8399" },
|
||||
},
|
||||
{
|
||||
id: "oceaan", naam: "Oceaan",
|
||||
hoofd: "#0369a1", licht: "#38bdf8", chart: "#22d3ee",
|
||||
donker: { bg: "#11202f", diep: "#0c1826", card: "#16283a", tekst: "#ffffff", sub: "#9fb4c7", muted: "#7e93a6" },
|
||||
helder: { bg: "#eef3f9", diep: "#e3ebf4", card: "#ffffff", tekst: "#0c1a26", sub: "#41576b", muted: "#748699" },
|
||||
},
|
||||
{
|
||||
id: "indigo", naam: "Indigo",
|
||||
hoofd: "#4338ca", licht: "#818cf8", chart: "#67e8f9",
|
||||
donker: { bg: "#1a1c35", diep: "#13152a", card: "#1f2240", tekst: "#ffffff", sub: "#a5abcd", muted: "#868cb0" },
|
||||
helder: { bg: "#eef0fa", diep: "#e5e8f6", card: "#ffffff", tekst: "#12142b", sub: "#474d75", muted: "#787ea3" },
|
||||
},
|
||||
{
|
||||
id: "azuur", naam: "Azuur",
|
||||
hoofd: "#1d4ed8", licht: "#60a5fa", chart: "#7dd3fc",
|
||||
donker: { bg: "#131c33", diep: "#0e1628", card: "#1a2440", tekst: "#ffffff", sub: "#a3b2d4", muted: "#838fb3" },
|
||||
helder: { bg: "#eef1fb", diep: "#e4e9f7", card: "#ffffff", tekst: "#0d1426", sub: "#42506e", muted: "#737e9c" },
|
||||
},
|
||||
{
|
||||
id: "jade", naam: "Jade",
|
||||
// Ligt dicht bij de GREEN die winst betekent: in de KPI-rij staan dan twee
|
||||
// soorten groen naast elkaar.
|
||||
hoofd: "#0f766e", licht: "#2dd4bf", chart: "#5eead4",
|
||||
donker: { bg: "#0f2629", diep: "#0a1c1f", card: "#143035", tekst: "#ffffff", sub: "#9dbdbd", muted: "#7d9d9d" },
|
||||
helder: { bg: "#edf5f4", diep: "#e2eeed", card: "#ffffff", tekst: "#0b1f21", sub: "#3c5c5c", muted: "#6e8b8b" },
|
||||
},
|
||||
{
|
||||
id: "magenta", naam: "Magenta",
|
||||
hoofd: "#a21caf", licht: "#e879f9", chart: "#f5d0fe",
|
||||
donker: { bg: "#2a1130", diep: "#200b26", card: "#331639", tekst: "#ffffff", sub: "#c3a3c9", muted: "#a084a6" },
|
||||
helder: { bg: "#faeefb", diep: "#f3e2f5", card: "#ffffff", tekst: "#25092a", sub: "#6a4370", muted: "#94709a" },
|
||||
},
|
||||
// Roos (#be123c) en Koper (#9a3412) stonden hier ook. Verwijderd: ze botsten
|
||||
// met kleuren die al betekenis dragen — roos met de RED van verlies en schuld,
|
||||
// koper met de gouden inleglijn en het oranje Belastingdienst-blok.
|
||||
{
|
||||
id: "grafiet", naam: "Grafiet",
|
||||
hoofd: "#475569", licht: "#94a3b8", chart: "#cbd5e1",
|
||||
donker: { bg: "#17191f", diep: "#111318", card: "#1e2129", tekst: "#ffffff", sub: "#a8b0bd", muted: "#868e9c" },
|
||||
helder: { bg: "#f1f2f5", diep: "#e7e9ee", card: "#ffffff", tekst: "#14161a", sub: "#414751", muted: "#737a86" },
|
||||
},
|
||||
{
|
||||
id: "papier", naam: "Papier",
|
||||
// Warm van toon en bijna monochroom: het accent doet een stap terug.
|
||||
hoofd: "#3f5570", licht: "#8fa5c0", chart: "#b6c9dc",
|
||||
donker: { bg: "#1c1a17", diep: "#161412", card: "#24211d", tekst: "#f6f2eb", sub: "#b8b0a4", muted: "#948c80" },
|
||||
helder: { bg: "#f5f1e8", diep: "#ece7db", card: "#fffdf8", tekst: "#1f1c17", sub: "#4d4a43", muted: "#7c766a" },
|
||||
},
|
||||
{
|
||||
id: "puur", naam: "Puur",
|
||||
// Zwart of wit, niets ertussenin. Op OLED scheelt de zwarte variant stroom.
|
||||
hoofd: "#6d28d9", licht: "#c4b5fd", chart: "#22d3ee",
|
||||
donker: { bg: "#000000", diep: "#000000", card: "#0e0e12", tekst: "#ffffff", sub: "#d4d4d8", muted: "#a1a1aa" },
|
||||
helder: { bg: "#f7f7f8", diep: "#efeff1", card: "#ffffff", tekst: "#000000", sub: "#27272a", muted: "#52525b" },
|
||||
},
|
||||
];
|
||||
|
||||
export const STANDAARD_THEMA = "violet";
|
||||
|
||||
/** Zoekt een thema op id; valt terug op het standaardthema. */
|
||||
export const zoekThema = (id) =>
|
||||
THEMAS.find((t) => t.id === id) ?? THEMAS.find((t) => t.id === STANDAARD_THEMA);
|
||||
@@ -1,7 +1,9 @@
|
||||
import { createContext, useContext, useState, useMemo, useRef } from "react";
|
||||
import JSZip from "jszip";
|
||||
import { INITIAL_DATA } from "../constants/index.js";
|
||||
import { migrateData } from "../utils/storage.js";
|
||||
import { STANDAARD_THEMA } from "../constants/themas.js";
|
||||
import { migrateData, loadFromStorage, saveToStorage } from "../utils/storage.js";
|
||||
import { bouwHtmlExport, leesHtmlBackup } from "../utils/htmlExport.js";
|
||||
import { useTheme } from "../hooks/useTheme.js";
|
||||
|
||||
const AppContext = createContext(null);
|
||||
@@ -34,7 +36,22 @@ export function AppProvider({ children }) {
|
||||
};
|
||||
|
||||
// ── UI ────────────────────────────────────────────────────────
|
||||
const [darkMode, setDarkMode] = useState(true);
|
||||
// Weergavevoorkeuren overleven een herlaad. Ze stonden eerder alleen in
|
||||
// state, dus na een refresh stond je weer in het donker.
|
||||
const [darkMode, setDarkModeRaw] = useState(() => loadFromStorage("darkMode") ?? true);
|
||||
const [thema, setThemaRaw] = useState(() => loadFromStorage("thema") ?? STANDAARD_THEMA);
|
||||
|
||||
const setDarkMode = (v) => {
|
||||
const n = typeof v === "function" ? v(darkMode) : v;
|
||||
saveToStorage("darkMode", n);
|
||||
setDarkModeRaw(n);
|
||||
};
|
||||
|
||||
const setThema = (id) => {
|
||||
saveToStorage("thema", id);
|
||||
setThemaRaw(id);
|
||||
};
|
||||
|
||||
const [locked, setLocked] = useState(false);
|
||||
const [tab, setTab] = useState(0);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
@@ -87,7 +104,7 @@ export function AppProvider({ children }) {
|
||||
const setUsers = (u) => { localStorage.setItem("wbjw_users", JSON.stringify(u)); setUsersRaw(u); };
|
||||
|
||||
// ── Thema ─────────────────────────────────────────────────────
|
||||
const T = useTheme(darkMode);
|
||||
const T = useTheme(thema, darkMode);
|
||||
|
||||
// ── Afgeleide waarden ─────────────────────────────────────────
|
||||
const ev = data.eigenVermogen;
|
||||
@@ -152,17 +169,43 @@ export function AppProvider({ children }) {
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
};
|
||||
|
||||
// ── Backup / restore ──────────────────────────────────────────
|
||||
// ── Backup / restore / export ─────────────────────────────────
|
||||
const downloadZip = async (zip, bestandsnaam) => {
|
||||
const blob = await zip.generateAsync({ type: "blob", compression: "DEFLATE" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = bestandsnaam;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const doBackup = async () => {
|
||||
const zip = new JSZip();
|
||||
const datum = new Date().toISOString().slice(0, 10);
|
||||
zip.file("data.json", JSON.stringify({ ...data, evVoortgang: evRows, evVoortgangHistory }, null, 2));
|
||||
zip.file("meta.json", JSON.stringify({ versie: 1, datum, app: "vibefinance", gebruiker: currentUser?.email }, null, 2));
|
||||
const blob = await zip.generateAsync({ type: "blob", compression: "DEFLATE" });
|
||||
await downloadZip(zip, `vibefinance_backup_${datum}.zip`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Export naar één zelfdragende HTML-pagina: leesbaar zonder de app, en met de
|
||||
* ruwe data ingebakken zodat `doRestore` hem ook weer terug kan zetten.
|
||||
*/
|
||||
const doExport = () => {
|
||||
const datum = new Date().toISOString().slice(0, 10);
|
||||
const html = bouwHtmlExport({
|
||||
cats, schuldBlokken, evRows, evVoortgangHistory, schuldHistory,
|
||||
totEV, totSchuld, doel: ev?.doel || 0,
|
||||
initieleSchuld: schuld?.initieleSchuld || 0,
|
||||
gebruiker: currentUser?.email || "",
|
||||
ruweData: { ...data, evVoortgang: evRows, evVoortgangHistory },
|
||||
});
|
||||
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `vibefinance_backup_${datum}.zip`;
|
||||
a.download = `vibefinance_${datum}.html`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
@@ -170,15 +213,25 @@ export function AppProvider({ children }) {
|
||||
const doRestore = () => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = ".zip";
|
||||
input.accept = ".zip,.html";
|
||||
input.onchange = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
// Twee soorten bestanden met dezelfde inhoud: de backup-ZIP met
|
||||
// `data.json`, en de geëxporteerde HTML-pagina met hetzelfde JSON in
|
||||
// een script-blok. Zonder dit tweede pad is de belofte onderaan die
|
||||
// pagina — "hiermee kun je herstellen" — niet waar te maken.
|
||||
let parsed;
|
||||
if (file.name.toLowerCase().endsWith(".html")) {
|
||||
parsed = leesHtmlBackup(await file.text());
|
||||
if (!parsed) throw new Error("Deze HTML-pagina bevat geen VibeFinance-gegevens.");
|
||||
} else {
|
||||
const zip = await JSZip.loadAsync(file);
|
||||
const entry = zip.file("data.json");
|
||||
if (!entry) throw new Error("Geen data.json gevonden in backup.");
|
||||
const parsed = JSON.parse(await entry.async("string"));
|
||||
parsed = JSON.parse(await entry.async("string"));
|
||||
}
|
||||
const migrated = migrateData({ eigenVermogen: parsed.eigenVermogen, schuld: parsed.schuld });
|
||||
const newEvRows = parsed.evVoortgang || [];
|
||||
const newEvVH = parsed.evVoortgangHistory || [];
|
||||
@@ -199,6 +252,7 @@ export function AppProvider({ children }) {
|
||||
loggedIn, currentUser, login, logout, loading, updateProfile, changePassword,
|
||||
avatar, setAvatar,
|
||||
darkMode, setDarkMode,
|
||||
thema, setThema,
|
||||
locked, setLocked,
|
||||
tab, setTab,
|
||||
menuOpen, setMenuOpen,
|
||||
@@ -212,7 +266,7 @@ export function AppProvider({ children }) {
|
||||
evRows, setEvRows,
|
||||
evVoortgangHistory, setEvVoortgangHistory,
|
||||
users, setUsers,
|
||||
doBackup, doRestore,
|
||||
doBackup, doRestore, doExport,
|
||||
}}>
|
||||
{children}
|
||||
</AppContext.Provider>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { loadFromStorage, saveToStorage } from "../utils/storage.js";
|
||||
|
||||
// Smaller dan dit wordt een kolom onleesbaar; de greep blijft wel pakbaar.
|
||||
const MIN_BREEDTE = 60;
|
||||
|
||||
/**
|
||||
* Versleepbare kolombreedtes voor een tabel.
|
||||
*
|
||||
* `kolommen` is een array van { id, breedte } in weergave-volgorde, waarbij
|
||||
* `breedte` de standaard in pixels is. De aanpassingen van de gebruiker gaan
|
||||
* per tabel naar localStorage, zodat ze een tabwissel of herlaad overleven.
|
||||
* Kolommen die later verschijnen (een categorie die de gebruiker toevoegt)
|
||||
* krijgen hun standaardbreedte; opgeslagen breedtes van verdwenen kolommen
|
||||
* blijven ongebruikt staan en storen niets.
|
||||
*
|
||||
* De tabel moet `tableLayout: "fixed"` krijgen met een <colgroup>, en als
|
||||
* `width` de teruggegeven `totaal`. Met `width: "100%"` verdeelt de browser de
|
||||
* ruimte namelijk proportioneel en levert slepen niet exact de gesleepte
|
||||
* pixels op.
|
||||
*
|
||||
* Geeft terug:
|
||||
* breedtes px per kolom-id, standaard plus aanpassing
|
||||
* totaal som van de breedtes, voor de `width` van de tabel
|
||||
* grip props voor het sleepvlak in de <th> van die kolom
|
||||
* herstel alle kolommen terug naar hun standaardbreedte
|
||||
*/
|
||||
export function useKolomBreedtes(opslagKey, kolommen) {
|
||||
const [aangepast, setAangepast] = useState(() => loadFromStorage(opslagKey) ?? {});
|
||||
// Spiegelt `aangepast` zodat het slepen en het opslaan bij het loslaten niet
|
||||
// van de state-updater afhangen (die mag geen bijwerkingen hebben).
|
||||
const laatste = useRef(aangepast);
|
||||
const sleep = useRef(null);
|
||||
|
||||
const breedtes = {};
|
||||
for (const k of kolommen) breedtes[k.id] = aangepast[k.id] ?? k.breedte;
|
||||
const totaal = kolommen.reduce((som, k) => som + breedtes[k.id], 0);
|
||||
|
||||
const zet = (volgende) => { laatste.current = volgende; setAangepast(volgende); };
|
||||
|
||||
const grip = (id) => ({
|
||||
className: "kolom-grip",
|
||||
role: "separator",
|
||||
title: "Sleep om de kolom breder of smaller te maken — dubbelklik voor de standaardbreedte",
|
||||
onPointerDown: (e) => {
|
||||
// Pointer capture: het slepen loopt door buiten de greep, ook boven de
|
||||
// rijen of naast de tabel, zonder losse listeners op window.
|
||||
e.preventDefault();
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
sleep.current = { id, startX: e.clientX, startBreedte: breedtes[id] };
|
||||
},
|
||||
onPointerMove: (e) => {
|
||||
if (!sleep.current) return;
|
||||
const { id: kolomId, startX, startBreedte } = sleep.current;
|
||||
const nieuw = Math.max(MIN_BREEDTE, Math.round(startBreedte + e.clientX - startX));
|
||||
if (laatste.current[kolomId] === nieuw) return;
|
||||
zet({ ...laatste.current, [kolomId]: nieuw });
|
||||
},
|
||||
onPointerUp: () => {
|
||||
if (!sleep.current) return;
|
||||
sleep.current = null;
|
||||
// Pas bij het loslaten opslaan; tijdens het slepen zou elke pixel een
|
||||
// localStorage-write kosten.
|
||||
saveToStorage(opslagKey, laatste.current);
|
||||
},
|
||||
onDoubleClick: () => {
|
||||
// Zonder destructuring-omit: die laat een ongebruikte variabele achter.
|
||||
const rest = { ...laatste.current };
|
||||
delete rest[id];
|
||||
zet(rest);
|
||||
saveToStorage(opslagKey, rest);
|
||||
},
|
||||
});
|
||||
|
||||
const herstel = () => { zet({}); saveToStorage(opslagKey, {}); };
|
||||
|
||||
return { breedtes, totaal, grip, herstel };
|
||||
}
|
||||
@@ -1,27 +1,68 @@
|
||||
import { zoekThema } from "../constants/themas.js";
|
||||
|
||||
/** `#rrggbb` (of `#rgb`) → `rgba(r, g, b, alpha)` */
|
||||
function metAlpha(hex, alpha) {
|
||||
const h = hex.replace("#", "");
|
||||
const vol = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
|
||||
const n = parseInt(vol, 16);
|
||||
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Geeft een thema-object (T) terug op basis van darkMode.
|
||||
* Wordt gebruikt door alle componenten om consistent kleuren te krijgen.
|
||||
* Geeft een thema-object (T) terug op basis van het gekozen kleurthema en
|
||||
* licht/donker. Wordt gebruikt door alle componenten om consistent kleuren te
|
||||
* krijgen.
|
||||
*
|
||||
* Thema en modus staan los van elkaar: het thema levert het accent, `darkMode`
|
||||
* de ondergrond. De randen, invoervelden en schaduwen worden uit het accent
|
||||
* afgeleid, zodat ze vanzelf meekleuren.
|
||||
*
|
||||
* @param {string} themaId id uit `constants/themas.js`
|
||||
* @param {boolean} darkMode
|
||||
*/
|
||||
export function useTheme(darkMode) {
|
||||
const bg = darkMode ? "#241A39" : "#eef1f8";
|
||||
const card = darkMode ? "rgba(22,23,27,0.30)" : "#ffffff";
|
||||
export function useTheme(themaId, darkMode) {
|
||||
const thema = zoekThema(themaId);
|
||||
const p = darkMode ? thema.donker : thema.helder;
|
||||
|
||||
// Het accent dat tekst draagt is per ondergrond een andere stap van dezelfde
|
||||
// kleur: de lichte op donker, de donkere op licht. Eén stap voor beide haalt
|
||||
// aan één kant altijd te weinig contrast.
|
||||
const accentTekst = darkMode ? thema.licht : thema.hoofd;
|
||||
|
||||
return {
|
||||
// Exacte Finex kleuren (dark) / licht alternatief
|
||||
bg,
|
||||
card,
|
||||
bg: p.bg,
|
||||
card: p.card,
|
||||
// Ondoorzichtige achtergrond voor popups/modals: kaartkleur over de pagina-bg.
|
||||
popupBg: `linear-gradient(${card}, ${card}), ${bg}`,
|
||||
sidebar: darkMode ? "#16171B" : "#f8faff",
|
||||
border: darkMode ? "rgba(139,92,246,0.15)" : "rgba(0,0,0,0.07)",
|
||||
muted: darkMode ? "#8b8fa8" : "#94a3b8",
|
||||
text: darkMode ? "#ffffff" : "#0f172a",
|
||||
subtext: darkMode ? "#a0aec0" : "#64748b",
|
||||
inputBg: darkMode ? "rgba(139,92,246,0.08)" : "#f1f5ff",
|
||||
inputBorder:darkMode ? "rgba(139,92,246,0.2)" : "#e2e8f0",
|
||||
contentBg: darkMode ? "#241A39" : "#eef1f8",
|
||||
shadow: darkMode ? "0 4px 24px rgba(124,58,237,0.25)" : "0 4px 20px rgba(0,0,0,0.07)",
|
||||
cardShadow: darkMode ? "0 4px 20px rgba(124,58,237,0.2)" : "0 2px 8px rgba(0,0,0,0.06)",
|
||||
popupBg: `linear-gradient(${p.card}, ${p.card}), ${p.bg}`,
|
||||
sidebar: p.diep,
|
||||
border: darkMode ? metAlpha(thema.licht, 0.15) : "rgba(0,0,0,0.07)",
|
||||
muted: p.muted,
|
||||
text: p.tekst,
|
||||
subtext: p.sub,
|
||||
inputBg: darkMode ? metAlpha(thema.licht, 0.08) : metAlpha(thema.hoofd, 0.05),
|
||||
inputBorder: darkMode ? metAlpha(thema.licht, 0.2) : "#e2e8f0",
|
||||
contentBg: p.bg,
|
||||
shadow: darkMode ? `0 4px 24px ${metAlpha(thema.hoofd, 0.25)}` : "0 4px 20px rgba(0,0,0,0.07)",
|
||||
cardShadow: darkMode ? `0 4px 20px ${metAlpha(thema.hoofd, 0.2)}` : "0 2px 8px rgba(0,0,0,0.06)",
|
||||
radius: "14px",
|
||||
font: "'Poppins', system-ui, sans-serif",
|
||||
|
||||
// ── Accenten ──────────────────────────────────────────────────────────────
|
||||
// Componenten die nu nog PURPLE / PURPLE_LIGHT uit constants/index.js
|
||||
// importeren horen hier naartoe: een const op moduleniveau is niet
|
||||
// reactief en kan dus niet met een themakeuze meeveranderen.
|
||||
accent: thema.hoofd,
|
||||
accentLicht: thema.licht,
|
||||
accentTekst,
|
||||
accentGradient: `linear-gradient(135deg, ${thema.hoofd}, ${thema.licht})`,
|
||||
// Start- en eindkleur van de vermogenslijn in de grafiek.
|
||||
chartVan: thema.chart,
|
||||
chartTot: thema.licht,
|
||||
|
||||
// Voor componenten die op de modus moeten beslissen. Eerder werd dat gedaan
|
||||
// door T.bg met een vaste hex te vergelijken; met elf thema's kan dat niet meer.
|
||||
darkMode,
|
||||
themaId: thema.id,
|
||||
themaNaam: thema.naam,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { useApp } from "../context/AppContext.jsx";
|
||||
import { fmt, pct, fmtDatum } from "../utils/format.js";
|
||||
import { GREEN, RED } from "../constants/index.js";
|
||||
import { fmt, pct, fmtDatum, fmtMaand } from "../utils/format.js";
|
||||
import { crPath } from "../utils/grafiek.js";
|
||||
import { GREEN, RED, GOLD } from "../constants/index.js";
|
||||
|
||||
|
||||
const TEAL = "#a855f7";
|
||||
import { VCard, VBadge } from "../components/ui/index.jsx";
|
||||
|
||||
export default function DashboardTab() {
|
||||
@@ -27,9 +28,9 @@ export default function DashboardTab() {
|
||||
const progressPct = Math.min(totEV / doel, 1);
|
||||
const milestones = [0, 0.25, 0.5, 0.75, 1].map((f) => f * doel);
|
||||
const kpis = [
|
||||
{ label: "Start eigen vermogen", val: fmt(startEV), accent: TEAL},
|
||||
{ label: "Extra geïnvesteerde fiat", val: fmt(extraFiat), accent: TEAL},
|
||||
{ label: "Totaal geïnvesteerde fiat", val: fmt(totaalFiat), accent: TEAL},
|
||||
{ label: "Start eigen vermogen", val: fmt(startEV), accent: T.accentTekst},
|
||||
{ label: "Extra geïnvesteerde fiat", val: fmt(extraFiat), accent: T.accentTekst},
|
||||
{ label: "Totaal geïnvesteerde fiat", val: fmt(totaalFiat), accent: T.accentTekst},
|
||||
{ label: "Totaal eigen vermogen", val: fmt(totEV), accent: GREEN },
|
||||
{ label: "Winst op investering", val: fmt(winst), accent: winst >= 0 ? GREEN : RED },
|
||||
{ label: "ROI %", val: totaalFiat > 0 ? pct(roi, 2) : "—", accent: roi >= 0 ? GREEN : RED },
|
||||
@@ -38,6 +39,16 @@ export default function DashboardTab() {
|
||||
// Minigrafiek data
|
||||
const vData = [...evRows].sort((a, b) => a.datum.localeCompare(b.datum));
|
||||
|
||||
// Cumulatief geïnvesteerde fiat, op dezelfde datums als vData. Dezelfde
|
||||
// opbouw als de KPI's hierboven: het oudste punt is de start-EV (daar is de
|
||||
// inleg per definitie gelijk aan de waarde), elk volgend punt telt de
|
||||
// stortingen van die rij erbij op. Het laatste punt is daarmee gelijk aan
|
||||
// "Totaal geïnvesteerde fiat".
|
||||
const fiatData = vData.reduce((acc, row, i) => {
|
||||
acc.push(i === 0 ? (row.nettoWaarde || 0) : acc[i - 1] + catSum(row));
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
@@ -81,19 +92,19 @@ export default function DashboardTab() {
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<span style={{ fontSize: 16 }}>🎯</span>
|
||||
<span style={{ fontWeight: 600 }}>Doel & Bijbehorende Progressie</span>
|
||||
<VBadge label={fmt(doel)} color={TEAL} />
|
||||
<VBadge label={fmt(doel)} color={T.accentTekst} />
|
||||
</div>
|
||||
<span style={{ color: T.muted, fontSize: 13 }}>{pct(progressPct)} behaald</span>
|
||||
</div>
|
||||
<div style={{
|
||||
height: 14, background: "rgba(255,255,255,0.07)",
|
||||
height: 14, background: T.inputBg,
|
||||
borderRadius: 99, overflow: "hidden",
|
||||
position: "relative", marginBottom: 8,
|
||||
}}>
|
||||
<div style={{
|
||||
position: "absolute", inset: 0,
|
||||
width: `${progressPct * 100}%`,
|
||||
background: "linear-gradient(90deg, #7c3aed, #a855f7)",
|
||||
background: `linear-gradient(90deg, ${T.accent}, ${T.accentLicht})`,
|
||||
borderRadius: 99, transition: "width 0.7s ease",
|
||||
}} />
|
||||
{[0.25, 0.5, 0.75].map((f) => (
|
||||
@@ -113,7 +124,21 @@ export default function DashboardTab() {
|
||||
|
||||
{/* EV Grafiek */}
|
||||
<VCard style={{ background: T.card, border: `1px solid ${T.border}`, marginBottom: 20 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 16 }}>Ontwikkeling van Eigen Vermogen</div>
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
gap: 12, flexWrap: "wrap", marginBottom: 16,
|
||||
}}>
|
||||
<div style={{ fontWeight: 600 }}>Ontwikkeling van Eigen Vermogen</div>
|
||||
{vData.length >= 2 && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14, fontSize: 11, color: T.muted }}>
|
||||
<Legenda kleur={`linear-gradient(90deg, ${T.chartVan}, ${T.chartTot})`} label="Eigen vermogen" />
|
||||
<Legenda
|
||||
kleur={`repeating-linear-gradient(90deg, ${GOLD} 0 5px, transparent 5px 9px)`}
|
||||
label="Geïnvesteerde fiat"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{vData.length < 2 ? (
|
||||
<div style={{ textAlign: "center", padding: "32px 0", color: T.muted }}>
|
||||
<div style={{ fontSize: 28, marginBottom: 8 }}>📈</div>
|
||||
@@ -122,7 +147,7 @@ export default function DashboardTab() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<MiniLineChart vData={vData} darkMode={darkMode} />
|
||||
<MiniLineChart vData={vData} fiatData={fiatData} darkMode={darkMode} T={T} />
|
||||
)}
|
||||
</VCard>
|
||||
|
||||
@@ -130,39 +155,47 @@ export default function DashboardTab() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Smooth Catmull-Rom bezier path ──────────────────────────────────────────
|
||||
function crPath(xs, ys, t = 0.4) {
|
||||
if (xs.length < 2) return "";
|
||||
let d = `M ${xs[0]},${ys[0]}`;
|
||||
for (let i = 0; i < xs.length - 1; i++) {
|
||||
const p0 = i > 0 ? [xs[i-1], ys[i-1]] : [xs[i], ys[i]];
|
||||
const p1 = [xs[i], ys[i]];
|
||||
const p2 = [xs[i+1], ys[i+1]];
|
||||
const p3 = i < xs.length - 2 ? [xs[i+2], ys[i+2]] : [xs[i+1], ys[i+1]];
|
||||
const cp1x = (p1[0] + (p2[0] - p0[0]) * t).toFixed(2);
|
||||
const cp1y = (p1[1] + (p2[1] - p0[1]) * t).toFixed(2);
|
||||
const cp2x = (p2[0] - (p3[0] - p1[0]) * t).toFixed(2);
|
||||
const cp2y = (p2[1] - (p3[1] - p1[1]) * t).toFixed(2);
|
||||
d += ` C ${cp1x},${cp1y} ${cp2x},${cp2y} ${p2[0]},${p2[1]}`;
|
||||
}
|
||||
return d;
|
||||
// ── Legenda-item bij de grafiek ─────────────────────────────────────────────
|
||||
function Legenda({ kleur, label }) {
|
||||
return (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
||||
<span style={{ width: 16, height: 3, borderRadius: 2, background: kleur }} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Mini inline SVG grafiek ────────────────────────────────────────────────────
|
||||
function MiniLineChart({ vData, darkMode }) {
|
||||
function MiniLineChart({ vData, fiatData, darkMode, T }) {
|
||||
const [hovered, setHovered] = useState(null);
|
||||
const W = 700, H = 230, pl = 62, pr = 16, pt = 16, pb = 52;
|
||||
// pb was 52 voor gedraaide datumlabels. Die staan nu rechtop en zijn korter,
|
||||
// dus er is minder hoogte nodig en houdt de lijn zelf meer ruimte over.
|
||||
const W = 700, H = 230, pl = 62, pr = 16, pt = 16, pb = 30;
|
||||
const vals = vData.map((d) => d.nettoWaarde || 0);
|
||||
const mn = Math.min(...vals) * 0.95;
|
||||
const mx = Math.max(...vals) * 1.05;
|
||||
const fVals = fiatData;
|
||||
// Beide reeksen staan in euro's op dezelfde as, dus het bereik moet ze allebei
|
||||
// omvatten — anders loopt de inleglijn buiten beeld zodra die boven de EV uitkomt.
|
||||
const mn = Math.min(...vals, ...fVals) * 0.95;
|
||||
const mx = Math.max(...vals, ...fVals) * 1.05;
|
||||
const rng = mx - mn || 1;
|
||||
const iW = W - pl - pr, iH = H - pt - pb;
|
||||
// Eén label "sep '26" is bij deze lettergrootte ongeveer 54px breed inclusief
|
||||
// wat lucht ernaast. Zoveel passen er naast elkaar; de rest wordt
|
||||
// overgeslagen. De stap telt vanaf het láátste punt terug, niet vanaf het
|
||||
// eerste: dat punt is de datum waar je naar kijkt en moet er altijd bij staan.
|
||||
// Forceerden we hem er los bovenop, dan botste hij op willekeurige afstand
|
||||
// tegen zijn voorganger — precies wat er eerst misging.
|
||||
const labelStap = Math.max(1, Math.ceil(vData.length / Math.max(1, Math.floor(iW / 54))));
|
||||
const xs = vData.map((_, i) => pl + (i / Math.max(vData.length - 1, 1)) * iW);
|
||||
const ys = vals.map((v) => pt + (1 - (v - mn) / rng) * iH);
|
||||
const fYs = fVals.map((v) => pt + (1 - (v - mn) / rng) * iH);
|
||||
const linePath = crPath(xs, ys);
|
||||
const areaPath = `${linePath} L ${xs[xs.length-1]},${pt+iH} L ${xs[0]},${pt+iH} Z`;
|
||||
// Rechte segmenten, geen crPath: de inleg loopt nooit terug, en de bezier-
|
||||
// afronding zou tussen twee stortingen een dip tekenen die er niet is.
|
||||
const fiatPath = xs.map((x, i) => `${i ? "L" : "M"} ${x},${fYs[i]}`).join(" ");
|
||||
const gc = darkMode ? "rgba(255,255,255,0.07)" : "rgba(0,0,0,0.06)";
|
||||
const lc = darkMode ? "#6b7280" : "#94a3b8";
|
||||
const lc = T.muted;
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
@@ -172,9 +205,19 @@ function MiniLineChart({ vData, darkMode }) {
|
||||
};
|
||||
|
||||
const h = hovered;
|
||||
const tipW = 114, tipH = 46;
|
||||
const tipX = h !== null ? Math.min(Math.max(xs[h] - tipW / 2, pl), W - tipW - 4) : 0;
|
||||
const tipY = h !== null ? (ys[h] - tipH - 14 < pt ? ys[h] + 14 : ys[h] - tipH - 14) : 0;
|
||||
const tipW = 150, tipH = 76;
|
||||
// Náást de hoverlijn, niet erboven of eronder. Zodra de twee punten ver uit
|
||||
// elkaar liggen is er verticaal geen ruimte voor een kaart van 76px: dan
|
||||
// klapte hij naar onderen tot buiten de tekening, over de datumlabels heen en
|
||||
// half afgekapt. Zijwaarts past hij altijd; de klemmen houden hem binnen het
|
||||
// tekenvlak.
|
||||
const naarRechts = h !== null && xs[h] < pl + iW / 2;
|
||||
const tipX = h === null ? 0 : naarRechts
|
||||
? Math.min(xs[h] + 16, W - pr - tipW)
|
||||
: Math.max(xs[h] - tipW - 16, pl);
|
||||
const tipY = h === null ? 0
|
||||
: Math.max(pt, Math.min((ys[h] + fYs[h]) / 2 - tipH / 2, pt + iH - tipH));
|
||||
const resultaat = h !== null ? vals[h] - fVals[h] : 0;
|
||||
|
||||
return (
|
||||
<svg
|
||||
@@ -185,8 +228,8 @@ function MiniLineChart({ vData, darkMode }) {
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="chartLineGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#0eddd4" />
|
||||
<stop offset="100%" stopColor="#8b5cf6" />
|
||||
<stop offset="0%" stopColor={T.chartVan} />
|
||||
<stop offset="100%" stopColor={T.chartTot} />
|
||||
</linearGradient>
|
||||
<linearGradient id="chartFadeMask" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="white" stopOpacity="1" />
|
||||
@@ -214,6 +257,11 @@ function MiniLineChart({ vData, darkMode }) {
|
||||
{/* Vlakkleur met gradient + fade */}
|
||||
<path d={areaPath} fill="url(#chartLineGrad)" mask="url(#chartAreaMask)" opacity="0.4" />
|
||||
|
||||
{/* Geïnvesteerde fiat — gestippeld, zodat het als referentielijn leest
|
||||
en de EV-lijn de hoofdrol houdt */}
|
||||
<path d={fiatPath} fill="none" stroke={GOLD} strokeWidth="1.8"
|
||||
strokeDasharray="5,4" strokeLinecap="round" strokeLinejoin="round" opacity="0.85" />
|
||||
|
||||
{/* Lijn */}
|
||||
<path d={linePath} fill="none" stroke="url(#chartLineGrad)"
|
||||
strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
@@ -221,38 +269,71 @@ function MiniLineChart({ vData, darkMode }) {
|
||||
{/* Verticale hover-lijn */}
|
||||
{h !== null && (
|
||||
<line x1={xs[h]} y1={pt} x2={xs[h]} y2={pt + iH}
|
||||
stroke="rgba(255,255,255,0.25)" strokeWidth="1" strokeDasharray="4,3" />
|
||||
stroke={T.muted} strokeOpacity="0.6" strokeWidth="1" strokeDasharray="4,3" />
|
||||
)}
|
||||
|
||||
{/* Hover punt */}
|
||||
{/* Hover punten */}
|
||||
{h !== null && (
|
||||
<>
|
||||
<circle cx={xs[h]} cy={fYs[h]} r="4.5"
|
||||
fill={GOLD} stroke={T.card} strokeWidth="2.5" />
|
||||
<circle cx={xs[h]} cy={ys[h]} r="5.5"
|
||||
fill="#0eddd4" stroke={darkMode ? "#151c2e" : "#fff"} strokeWidth="2.5" />
|
||||
fill={T.chartVan} stroke={T.card} strokeWidth="2.5" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Tooltip */}
|
||||
{h !== null && (
|
||||
<g>
|
||||
<rect x={tipX} y={tipY} width={tipW} height={tipH} rx="7"
|
||||
fill={darkMode ? "#1a2035" : "#fff"}
|
||||
stroke="rgba(14,221,212,0.35)" strokeWidth="1" />
|
||||
fill={T.card}
|
||||
stroke={T.border} strokeWidth="1" />
|
||||
<text x={tipX + tipW / 2} y={tipY + 16} fill={lc} fontSize="9" textAnchor="middle">
|
||||
{fmtDatum(vData[h].datum)}
|
||||
</text>
|
||||
<text x={tipX + tipW / 2} y={tipY + 34} fill="#0eddd4" fontSize="11"
|
||||
fontWeight="700" textAnchor="middle">
|
||||
<text x={tipX + 10} y={tipY + 33} fill={lc} fontSize="9">Vermogen</text>
|
||||
<text x={tipX + tipW - 10} y={tipY + 33} fill={T.chartVan} fontSize="10"
|
||||
fontWeight="700" textAnchor="end">
|
||||
{fmt(vals[h])}
|
||||
</text>
|
||||
<text x={tipX + 10} y={tipY + 48} fill={lc} fontSize="9">Ingelegd</text>
|
||||
<text x={tipX + tipW - 10} y={tipY + 48} fill={GOLD} fontSize="10"
|
||||
fontWeight="700" textAnchor="end">
|
||||
{fmt(fVals[h])}
|
||||
</text>
|
||||
<line x1={tipX + 10} y1={tipY + 55} x2={tipX + tipW - 10} y2={tipY + 55}
|
||||
stroke={gc} strokeWidth="1" />
|
||||
<text x={tipX + 10} y={tipY + 68} fill={lc} fontSize="9">Resultaat</text>
|
||||
<text x={tipX + tipW - 10} y={tipY + 68}
|
||||
fill={resultaat >= 0 ? GREEN : RED} fontSize="10"
|
||||
fontWeight="700" textAnchor="end">
|
||||
{`${resultaat >= 0 ? "+" : ""}${fmt(resultaat)}`}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* X-as labels */}
|
||||
{xs.map((x, i) => (
|
||||
<text key={i} x={x} y={H - pb + 16} fill={lc} fontSize="8"
|
||||
textAnchor="end" transform={`rotate(-45, ${x}, ${H - pb + 16})`}>
|
||||
{fmtDatum(vData[i].datum)}
|
||||
{/* X-as: korte maandlabels, rechtop. Elk punt een volledige datum geven
|
||||
liep bij een lange reeks over in een onleesbare band — tien tekens
|
||||
gedraaid kost ~45px en er passen er maar een stuk of veertien. De
|
||||
grafiek rekent nu uit hoeveel er passen en toont elke n-de; het
|
||||
laatste punt staat er altijd bij, want dat is de datum waar je naar
|
||||
kijkt. De exacte dag lees je in de tooltip. */}
|
||||
{xs.map((x, i) => {
|
||||
const laatste = i === xs.length - 1;
|
||||
// Terugtellen vanaf het eind: het laatste punt valt zo vanzelf op een
|
||||
// stap en houdt volle afstand tot zijn voorganger.
|
||||
if ((xs.length - 1 - i) % labelStap !== 0) return null;
|
||||
return (
|
||||
<g key={i}>
|
||||
<line x1={x} y1={pt + iH} x2={x} y2={pt + iH + 4}
|
||||
stroke={lc} strokeOpacity="0.5" strokeWidth="1" />
|
||||
<text x={x} y={pt + iH + 17} fill={lc} fontSize="9"
|
||||
textAnchor={i === 0 ? "start" : laatste ? "end" : "middle"}>
|
||||
{fmtMaand(vData[i].datum)}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { useApp } from "../context/AppContext.jsx";
|
||||
import { fmt, fmtDatum } from "../utils/format.js";
|
||||
import { PURPLE, PURPLE_LIGHT, GREEN, RED, CAT_COLORS } from "../constants/index.js";
|
||||
import { useKolomBreedtes } from "../hooks/useKolomBreedtes.js";
|
||||
import { GREEN, RED, CAT_COLORS } from "../constants/index.js";
|
||||
import {
|
||||
VCard, DonutChart, Overlay, ModalBox, ModalHeader, ModalFooter,
|
||||
EmojiPicker, AppIcon, ConfirmDeleteDialog, EmptyState,
|
||||
@@ -129,14 +130,14 @@ function AanpassenPopup({ cat, onClose }) {
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 8,
|
||||
background: darkMode ? "rgba(255,255,255,0.03)" : "#f8fafc",
|
||||
border: `1px solid ${T.border}`, borderLeft: `3px solid ${PURPLE}`,
|
||||
border: `1px solid ${T.border}`, borderLeft: `3px solid ${T.accent}`,
|
||||
borderRadius: 10, padding: "10px 12px",
|
||||
}}>
|
||||
<input value={nieuwNaam}
|
||||
onChange={(e) => { setNieuwNaam(e.target.value); setErr(""); }}
|
||||
placeholder="Naam" style={{ ...is, flex: 1 }} />
|
||||
<EuroInput value={nieuwVal} onChange={setNieuwVal} is={is} T={T} />
|
||||
<button onClick={addNieuw} style={addBtn}>+ Voeg toe</button>
|
||||
<button onClick={addNieuw} style={addBtn(T)}>+ Voeg toe</button>
|
||||
</div>
|
||||
{err && <ErrMsg msg={err} />}
|
||||
</div>
|
||||
@@ -250,8 +251,22 @@ function EVVoortgangTabel() {
|
||||
|
||||
const removeRow = (datum) => setEvRows(evRows.filter((r) => r.datum !== datum));
|
||||
|
||||
const th = { padding: "8px 10px", fontSize: 11, color: T.muted, fontWeight: 600, textAlign: "left", whiteSpace: "nowrap", background: T.card, position: "sticky", top: 0, zIndex: 1, borderBottom: `1px solid ${T.border}` };
|
||||
const td = { padding: "7px 10px", fontSize: 12, color: T.text, borderBottom: `1px solid ${T.border}` };
|
||||
// Kolommen in weergave-volgorde, met hun standaardbreedte. De categorieën
|
||||
// zijn dynamisch, dus de lijst wordt per render opgebouwd.
|
||||
const kolommen = [
|
||||
{ id: "datum", breedte: 100 },
|
||||
...cats.map((c) => ({ id: c.id, breedte: 100 })),
|
||||
{ id: "nettoWaarde", breedte: 115 },
|
||||
{ id: "verschil", breedte: 105 },
|
||||
{ id: "opmerkingen", breedte: 150 },
|
||||
...(!locked ? [{ id: "acties", breedte: 75 }] : []),
|
||||
];
|
||||
const { breedtes, totaal, grip } = useKolomBreedtes("ev_kolom_breedtes", kolommen);
|
||||
|
||||
// overflow/ellipsis horen bij tableLayout "fixed": de inhoud rekt de kolom
|
||||
// niet meer op, dus zonder afkappen loopt lange tekst de buurcel binnen.
|
||||
const th = { padding: "8px 10px", fontSize: 11, color: T.muted, fontWeight: 600, textAlign: "left", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", background: T.popupBg, position: "sticky", top: 0, zIndex: 1, borderBottom: `1px solid ${T.border}` };
|
||||
const td = { padding: "7px 10px", fontSize: 12, color: T.text, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", borderBottom: `1px solid ${T.border}` };
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -260,12 +275,12 @@ function EVVoortgangTabel() {
|
||||
<Overlay onClose={closeModal}>
|
||||
<ModalBox maxWidth={560}>
|
||||
<ModalHeader
|
||||
icon="📊" iconBg={PURPLE}
|
||||
icon="📊" iconBg={T.accent}
|
||||
title={editDatum ? "Rij bewerken" : "Nieuw geïnvesteerde fiat"}
|
||||
subtitle="Vul de waarden in voor dit moment"
|
||||
subtitleColor={T.muted}
|
||||
onClose={closeModal}
|
||||
headerBg={darkMode ? `${PURPLE}08` : "#f8f5ff"}
|
||||
headerBg={darkMode ? `${T.accent}08` : T.inputBg}
|
||||
T={T}
|
||||
/>
|
||||
<div style={{ padding: "20px 22px", overflowY: "auto", maxHeight: "70vh" }}>
|
||||
@@ -310,7 +325,7 @@ function EVVoortgangTabel() {
|
||||
</div>
|
||||
<ModalFooter T={T}>
|
||||
<button onClick={closeModal} style={cancelBtn(T)}>Annuleren</button>
|
||||
<button onClick={addRow} style={primaryBtn}>{editDatum ? "Opslaan" : "Toevoegen"}</button>
|
||||
<button onClick={addRow} style={primaryBtn(T)}>{editDatum ? "Opslaan" : "Toevoegen"}</button>
|
||||
</ModalFooter>
|
||||
</ModalBox>
|
||||
</Overlay>
|
||||
@@ -319,22 +334,31 @@ function EVVoortgangTabel() {
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 14, color: T.text }}>Geïnvesteerde Fiat</div>
|
||||
{!locked && (
|
||||
<button onClick={openModal} style={primaryBtn}>+ Toevoegen</button>
|
||||
<button onClick={openModal} style={primaryBtn(T)}>+ Toevoegen</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{evRows.length > 0 ? (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<div style={{ maxHeight: 340, overflowY: "auto" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", minWidth: 600 }}>
|
||||
<table style={{ width: totaal, borderCollapse: "collapse", tableLayout: "fixed" }}>
|
||||
<colgroup>
|
||||
{kolommen.map((k) => <col key={k.id} style={{ width: breedtes[k.id] }} />)}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={th}>Datum</th>
|
||||
{cats.map((c) => <th key={c.id} style={{ ...th, color: c.color }}>{c.naam}</th>)}
|
||||
<th style={th}>Netto Waarde</th>
|
||||
<th style={th}>Verschil</th>
|
||||
<th style={th}>Opmerkingen</th>
|
||||
{!locked && <th style={th}></th>}
|
||||
<th style={th}>Datum<span {...grip("datum")} /></th>
|
||||
{cats.map((c) => (
|
||||
// title: een lange categorienaam ("Crowdfunding") wordt bij
|
||||
// de standaardbreedte afgekapt, dus hover geeft de volle naam.
|
||||
<th key={c.id} style={{ ...th, color: c.color }} title={c.naam}>
|
||||
{c.naam}<span {...grip(c.id)} />
|
||||
</th>
|
||||
))}
|
||||
<th style={th}>Netto Waarde<span {...grip("nettoWaarde")} /></th>
|
||||
<th style={th}>Verschil<span {...grip("verschil")} /></th>
|
||||
<th style={th}>Opmerkingen<span {...grip("opmerkingen")} /></th>
|
||||
{!locked && <th style={th}><span {...grip("acties")} /></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -347,13 +371,14 @@ function EVVoortgangTabel() {
|
||||
<tr key={i}>
|
||||
<td style={td}>{fmtDatum(row.datum)}</td>
|
||||
{cats.map((c) => <td key={c.id} style={{ ...td, color: c.color }}>{fmt(row[c.id] || 0)}</td>)}
|
||||
<td style={{ ...td, fontWeight: 700, color: PURPLE_LIGHT }}>{fmt(row.nettoWaarde || 0)}</td>
|
||||
<td style={{ ...td, fontWeight: 700, color: T.accentTekst }}>{fmt(row.nettoWaarde || 0)}</td>
|
||||
<td style={{ ...td, fontWeight: 700, color: vColor }}>{vLabel}</td>
|
||||
<td style={{ ...td, color: T.muted }}>{row.opmerkingen || "—"}</td>
|
||||
{/* title: bij een smalle kolom is de tekst afgekapt */}
|
||||
<td style={{ ...td, color: T.muted }} title={row.opmerkingen || ""}>{row.opmerkingen || "—"}</td>
|
||||
{!locked && (
|
||||
<td style={td}>
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
<button onClick={() => openEdit(row)} style={editBtn}>✏️</button>
|
||||
<button onClick={() => openEdit(row)} style={editBtn(T)}>✏️</button>
|
||||
<button onClick={() => removeRow(row.datum)} style={deleteSmBtn}>×</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -399,7 +424,7 @@ function DoelProgressie({ ev, data, setData, totVerdeling, locked, T }) {
|
||||
<span style={{ fontWeight: 600, fontSize: 14 }}>Gewenst doel EV</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<span style={{ fontWeight: 700, fontSize: 15, color: PURPLE_LIGHT }}>{fmt(totVerdeling)}</span>
|
||||
<span style={{ fontWeight: 700, fontSize: 15, color: T.accentTekst }}>{fmt(totVerdeling)}</span>
|
||||
{editing ? (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<span style={{ color: T.muted, fontSize: 13 }}>/</span>
|
||||
@@ -409,10 +434,10 @@ function DoelProgressie({ ev, data, setData, totVerdeling, locked, T }) {
|
||||
onKeyDown={(e) => { if (e.key === "Enter") save(); if (e.key === "Escape") setEditing(false); }}
|
||||
style={{
|
||||
width: 130, padding: "4px 8px", borderRadius: 8, fontSize: 13,
|
||||
background: T.inputBg, border: `1px solid ${PURPLE}`, color: T.text, outline: "none",
|
||||
background: T.inputBg, border: `1px solid ${T.accent}`, color: T.text, outline: "none",
|
||||
}}
|
||||
/>
|
||||
<button onClick={save} style={{ padding: "4px 10px", background: PURPLE, border: "none", borderRadius: 7, color: "#fff", cursor: "pointer", fontSize: 12, fontWeight: 700 }}>OK</button>
|
||||
<button onClick={save} style={{ padding: "4px 10px", background: T.accent, border: "none", borderRadius: 7, color: "#fff", cursor: "pointer", fontSize: 12, fontWeight: 700 }}>OK</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -422,13 +447,13 @@ function DoelProgressie({ ev, data, setData, totVerdeling, locked, T }) {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<span style={{ color: PURPLE_LIGHT, fontWeight: 700, fontSize: 13 }}>{Math.round(progressPct * 100)}%</span>
|
||||
<span style={{ color: T.accentTekst, fontWeight: 700, fontSize: 13 }}>{Math.round(progressPct * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ height: 12, background: "rgba(255,255,255,0.07)", borderRadius: 99, overflow: "hidden" }}>
|
||||
<div style={{
|
||||
height: "100%", width: `${progressPct * 100}%`,
|
||||
background: `linear-gradient(90deg,${PURPLE},#a855f7)`,
|
||||
background: `linear-gradient(90deg, ${T.accent}, ${T.accentLicht})`,
|
||||
borderRadius: 99, transition: "width 0.7s ease",
|
||||
}} />
|
||||
</div>
|
||||
@@ -471,9 +496,9 @@ export default function EigenVermogenTab() {
|
||||
total: fmt(totVerdeling), totalColor: GREEN,
|
||||
},
|
||||
{
|
||||
title: "Geïnvesteerde Fiat", accent: PURPLE_LIGHT,
|
||||
title: "Geïnvesteerde Fiat", accent: T.accentTekst,
|
||||
rows: cats.map((c) => ({ label: c.naam, value: evRows.length > 0 ? fmt(sumCat(c.id)) : "—", color: c.color })),
|
||||
total: evRows.length > 0 ? fmt(totGeinv) : "—", totalColor: PURPLE_LIGHT,
|
||||
total: evRows.length > 0 ? fmt(totGeinv) : "—", totalColor: T.accentTekst,
|
||||
},
|
||||
{
|
||||
title: "Rendement", accent: "#f59e0b",
|
||||
@@ -556,16 +581,16 @@ export default function EigenVermogenTab() {
|
||||
{!locked && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<button onClick={() => setShowAddCat(true)} style={{
|
||||
padding: "10px 20px", background: `${PURPLE}22`,
|
||||
border: `1px solid ${PURPLE}44`, borderRadius: 10,
|
||||
color: PURPLE_LIGHT, cursor: "pointer", fontWeight: 700, fontSize: 13,
|
||||
padding: "10px 20px", background: `${T.accent}22`,
|
||||
border: `1px solid ${T.accent}44`, borderRadius: 10,
|
||||
color: T.accentTekst, cursor: "pointer", fontWeight: 700, fontSize: 13,
|
||||
}}>+ Nieuwe categorie</button>
|
||||
</div>
|
||||
)}
|
||||
{showAddCat && (
|
||||
<Overlay onClose={() => setShowAddCat(false)}>
|
||||
<ModalBox maxWidth={400}>
|
||||
<ModalHeader icon="➕" iconBg={PURPLE} title="Nieuwe categorie" subtitle="Voeg een EV-categorie toe" subtitleColor={PURPLE_LIGHT} onClose={() => setShowAddCat(false)} headerBg={darkMode ? `${PURPLE}08` : "#f8f5ff"} T={T} />
|
||||
<ModalHeader icon="➕" iconBg={T.accent} title="Nieuwe categorie" subtitle="Voeg een EV-categorie toe" subtitleColor={T.accentTekst} onClose={() => setShowAddCat(false)} headerBg={darkMode ? `${T.accent}08` : T.inputBg} T={T} />
|
||||
<div style={{ padding: "20px 22px" }}>
|
||||
<div style={{ fontSize: 11, color: T.muted, fontWeight: 700, textTransform: "uppercase", marginBottom: 6 }}>Naam *</div>
|
||||
<input autoFocus placeholder="Bijv. Aandelen" value={newNaam}
|
||||
@@ -585,7 +610,7 @@ export default function EigenVermogenTab() {
|
||||
</div>
|
||||
<ModalFooter T={T}>
|
||||
<button onClick={() => setShowAddCat(false)} style={{ flex: 1, padding: "10px", background: "transparent", border: `1px solid ${T.border}`, borderRadius: 10, color: T.muted, cursor: "pointer", fontWeight: 600, fontSize: 13 }}>Annuleren</button>
|
||||
<button onClick={addCat} style={{ flex: 2, padding: "10px", background: `linear-gradient(135deg, ${PURPLE}, #a855f7)`, border: "none", borderRadius: 10, color: "#fff", fontWeight: 700, cursor: "pointer", fontSize: 13 }}>Aanmaken</button>
|
||||
<button onClick={addCat} style={{ flex: 2, padding: "10px", background: T.accentGradient, border: "none", borderRadius: 10, color: "#fff", fontWeight: 700, cursor: "pointer", fontSize: 13 }}>Aanmaken</button>
|
||||
</ModalFooter>
|
||||
</ModalBox>
|
||||
</Overlay>
|
||||
@@ -645,9 +670,9 @@ function ErrMsg({ msg }) {
|
||||
}}>{msg}</div>
|
||||
);
|
||||
}
|
||||
const primaryBtn = { padding: "10px 20px", background: PURPLE, border: "none", borderRadius: 8, color: "#fff", fontWeight: 700, cursor: "pointer", fontSize: 13 };
|
||||
const editBtn = { padding: "2px 10px", background: `${PURPLE}22`, border: `1px solid ${PURPLE}44`, borderRadius: 6, color: PURPLE_LIGHT, cursor: "pointer", fontSize: 12, fontWeight: 600 };
|
||||
const primaryBtn = (T) => ({ padding: "10px 20px", background: T.accent, border: "none", borderRadius: 8, color: "#fff", fontWeight: 700, cursor: "pointer", fontSize: 13 });
|
||||
const editBtn = (T) => ({ padding: "2px 10px", background: `${T.accent}22`, border: `1px solid ${T.accent}44`, borderRadius: 6, color: T.accentTekst, cursor: "pointer", fontSize: 12, fontWeight: 600 });
|
||||
const deleteSmBtn = { padding: "2px 8px", background: "rgba(239,68,68,0.1)", border: "1px solid rgba(239,68,68,0.25)", borderRadius: 6, color: RED, cursor: "pointer", fontSize: 12 };
|
||||
const trashBtn = { padding: "10px 14px", background: "rgba(239,68,68,0.08)", border: "1px solid rgba(239,68,68,0.25)", borderRadius: 10, color: RED, cursor: "pointer", fontWeight: 600, fontSize: 13 };
|
||||
const addBtn = { padding: "6px 14px", background: `${PURPLE}22`, border: `1px solid ${PURPLE}44`, borderRadius: 8, color: PURPLE_LIGHT, cursor: "pointer", fontSize: 12, fontWeight: 700, flexShrink: 0 };
|
||||
const addBtn = (T) => ({ padding: "6px 14px", background: `${T.accent}22`, border: `1px solid ${T.accent}44`, borderRadius: 8, color: T.accentTekst, cursor: "pointer", fontSize: 12, fontWeight: 700, flexShrink: 0 });
|
||||
const cancelBtn = (T) => ({ padding: "10px", background: "transparent", border: `1px solid ${T.border}`, borderRadius: 10, color: T.muted, cursor: "pointer", fontWeight: 600, fontSize: 13 });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useApp } from "../context/AppContext.jsx";
|
||||
import { fmt, pct } from "../utils/format.js";
|
||||
import { PURPLE, PURPLE_LIGHT, RED, GREEN } from "../constants/index.js";
|
||||
import { RED, GREEN } from "../constants/index.js";
|
||||
import { VCard, DonutChart, Overlay, ModalBox, ModalHeader, EmojiPicker, AppIcon, ConfirmDeleteDialog } from "../components/ui/index.jsx";
|
||||
|
||||
// ── SchuldAanpassenPopup ──────────────────────────────────────────────────────
|
||||
@@ -108,14 +108,14 @@ function SchuldAanpassenPopup({ blok, onClose }) {
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 8,
|
||||
background: darkMode ? "rgba(255,255,255,0.03)" : "#f8fafc",
|
||||
border: `1px solid ${T.border}`, borderLeft: `3px solid ${PURPLE}`,
|
||||
border: `1px solid ${T.border}`, borderLeft: `3px solid ${T.accent}`,
|
||||
borderRadius: 10, padding: "10px 12px",
|
||||
}}>
|
||||
<input value={nieuwNaam}
|
||||
onChange={(e) => { setNieuwNaam(e.target.value); setNieuwErr(""); }}
|
||||
placeholder="Naam" style={{ ...is, flex: 1 }} />
|
||||
<EuroInput value={nieuwVal} onChange={setNieuwVal} is={is} T={T} />
|
||||
<button onClick={addNieuw} style={addBtn}>+ Voeg toe</button>
|
||||
<button onClick={addNieuw} style={addBtn(T)}>+ Voeg toe</button>
|
||||
</div>
|
||||
{nieuwErr && <ErrMsg msg={nieuwErr} />}
|
||||
</div>
|
||||
@@ -448,5 +448,5 @@ function ErrMsg({ msg }) {
|
||||
}
|
||||
const deleteSmBtn = { padding: "4px 8px", background: "rgba(239,68,68,0.1)", border: "1px solid rgba(239,68,68,0.25)", borderRadius: 6, color: RED, cursor: "pointer", fontSize: 13, flexShrink: 0 };
|
||||
const trashBtn = { padding: "10px 14px", background: "rgba(239,68,68,0.08)", border: "1px solid rgba(239,68,68,0.25)", borderRadius: 10, color: RED, cursor: "pointer", fontWeight: 600, fontSize: 13 };
|
||||
const addBtn = { padding: "6px 14px", background: `${PURPLE}22`, border: `1px solid ${PURPLE}44`, borderRadius: 8, color: PURPLE_LIGHT, cursor: "pointer", fontSize: 12, fontWeight: 700, flexShrink: 0 };
|
||||
const addBtn = (T) => ({ padding: "6px 14px", background: `${T.accent}22`, border: `1px solid ${T.accent}44`, borderRadius: 8, color: T.accentTekst, cursor: "pointer", fontSize: 12, fontWeight: 700, flexShrink: 0 });
|
||||
const cancelBtn = (T) => ({ padding: "10px", background: "transparent", border: `1px solid ${T.border}`, borderRadius: 10, color: T.muted, cursor: "pointer", fontWeight: 600, fontSize: 13 });
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { useApp } from "../context/AppContext.jsx";
|
||||
import { fmt, fmtDatum } from "../utils/format.js";
|
||||
import { PURPLE, PURPLE_LIGHT, RED, GREEN } from "../constants/index.js";
|
||||
import { fmt, fmtDatum, fmtMaand } from "../utils/format.js";
|
||||
import { crPath, vlakPad } from "../utils/grafiek.js";
|
||||
import { useKolomBreedtes } from "../hooks/useKolomBreedtes.js";
|
||||
import { RED, GREEN } from "../constants/index.js";
|
||||
import { VCard, DonutChart, Overlay, ModalBox, ModalHeader, ModalFooter } from "../components/ui/index.jsx";
|
||||
|
||||
// ── Inline SVG line chart ─────────────────────────────────────────────────────
|
||||
@@ -15,8 +17,13 @@ function SvgLineChart({ pts, lines, T, H = 200 }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const W = 700, pl = 64, pr = 16, pt2 = 16, pb = 62;
|
||||
// pb was 62 voor gedraaide datumlabels. Die staan nu rechtop en zijn korter.
|
||||
const W = 700, pl = 64, pr = 16, pt2 = 16, pb = 34;
|
||||
const iW = W - pl - pr, iH = H - pt2 - pb;
|
||||
// Eén label "sep '26" is ongeveer 54px breed inclusief lucht ernaast. De stap
|
||||
// telt vanaf het laatste punt terug, zodat dat er altijd bij staat zonder op
|
||||
// zijn voorganger te botsen. Zie DashboardTab voor de uitleg.
|
||||
const labelStap = Math.max(1, Math.ceil(pts.length / Math.max(1, Math.floor(iW / 54))));
|
||||
const allVals = lines.flatMap((l) => pts.map((p) => p[l.key] ?? 0));
|
||||
const dataMin = Math.min(...allVals);
|
||||
const dataMax = Math.max(...allVals);
|
||||
@@ -26,8 +33,11 @@ function SvgLineChart({ pts, lines, T, H = 200 }) {
|
||||
const rng = mx - mn || 1;
|
||||
const xp = (i) => pl + (i / (pts.length - 1)) * iW;
|
||||
const yp = (v) => pt2 + (1 - ((v ?? 0) - mn) / rng) * iH;
|
||||
const gc = T.bg === "#0f1117" ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.05)";
|
||||
const darkMode = T.bg === "#0f1117";
|
||||
// Was een vergelijking met een vaste hex (`T.bg === "#0f1117"`). Die kleur is
|
||||
// de achtergrond allang niet meer, dus beide stonden altijd op licht — en met
|
||||
// instelbare thema's valt er sowieso niets meer aan de bg af te lezen.
|
||||
const darkMode = T.darkMode;
|
||||
const gc = darkMode ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.05)";
|
||||
|
||||
return (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
@@ -56,17 +66,21 @@ function SvgLineChart({ pts, lines, T, H = 200 }) {
|
||||
if (validPts.length < 2) return null;
|
||||
const xs = validPts.map((p) => xp(p._i));
|
||||
const ys = validPts.map((p) => yp(p[l.key]));
|
||||
const ls = xs.map((x, i) => `${x},${ys[i]}`).join(" ");
|
||||
const ar = `${xs[0]},${pt2 + iH} ${ls} ${xs[xs.length - 1]},${pt2 + iH}`;
|
||||
// Dezelfde Catmull-Rom afronding als de dashboardgrafiek, in plaats
|
||||
// van rechte segmenten tussen de punten.
|
||||
const lijn = crPath(xs, ys);
|
||||
const vlak = vlakPad(lijn, xs[0], xs[xs.length - 1], pt2 + iH);
|
||||
return (
|
||||
<g key={l.key}>
|
||||
<polygon points={ar} fill={`url(#g_${l.key})`} />
|
||||
<polyline points={ls} fill="none" stroke={l.color} strokeWidth="2.5"
|
||||
<path d={vlak} fill={`url(#g_${l.key})`} />
|
||||
<path d={lijn} fill="none" stroke={l.color} strokeWidth="2.5"
|
||||
strokeLinejoin="round" strokeLinecap="round" />
|
||||
{validPts.map((p, i) => {
|
||||
const x = xs[i], y = ys[i];
|
||||
const isHov = hovered?.lineKey === l.key && hovered?.ptIdx === i;
|
||||
const tipW = 90, tipH = 22;
|
||||
// Twee regels: sinds de x-as alleen nog maanden noemt, is dit
|
||||
// de enige plek in de grafiek waar de exacte dag te lezen is.
|
||||
const tipW = 104, tipH = 34;
|
||||
const tipX = Math.min(Math.max(x - tipW / 2, pl), W - tipW - 4);
|
||||
const tipY = y - tipH - 8 < pt2 ? y + 10 : y - tipH - 8;
|
||||
return (
|
||||
@@ -80,10 +94,14 @@ function SvgLineChart({ pts, lines, T, H = 200 }) {
|
||||
fill={l.color} stroke={T.card} strokeWidth="2" />
|
||||
{isHov && (
|
||||
<g>
|
||||
<rect x={tipX} y={tipY} width={tipW} height={22}
|
||||
rx="5" fill={darkMode ? "#1e2130" : "#fff"}
|
||||
<rect x={tipX} y={tipY} width={tipW} height={tipH}
|
||||
rx="5" fill={T.card}
|
||||
stroke={l.color} strokeWidth="1" />
|
||||
<text x={tipX + tipW / 2} y={tipY + 15} fill={l.color} fontSize="8"
|
||||
<text x={tipX + tipW / 2} y={tipY + 13} fill={T.muted} fontSize="8"
|
||||
textAnchor="middle">
|
||||
{fmtDatum(p.datum)}
|
||||
</text>
|
||||
<text x={tipX + tipW / 2} y={tipY + 26} fill={l.color} fontSize="9"
|
||||
fontWeight="700" textAnchor="middle">
|
||||
{fmt(p[l.key])}
|
||||
</text>
|
||||
@@ -95,15 +113,24 @@ function SvgLineChart({ pts, lines, T, H = 200 }) {
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{pts.map((p, i) => (
|
||||
<text key={i}
|
||||
x={xp(i)} y={H - pb + 16}
|
||||
fill={T.muted} fontSize="8"
|
||||
textAnchor="end"
|
||||
transform={`rotate(-45, ${xp(i)}, ${H - pb + 16})`}>
|
||||
{fmtDatum(p.datum)}
|
||||
{/* X-as: korte maandlabels, rechtop en uitgedund op wat er past. Elk
|
||||
punt een volledige datum geven liep bij een lange reeks over in een
|
||||
onleesbare band. De exacte dag staat in de tooltip op elk punt. */}
|
||||
{pts.map((p, i) => {
|
||||
const laatste = i === pts.length - 1;
|
||||
// Terugtellen vanaf het eind, zodat het laatste punt op een stap valt.
|
||||
if ((pts.length - 1 - i) % labelStap !== 0) return null;
|
||||
return (
|
||||
<g key={i}>
|
||||
<line x1={xp(i)} y1={pt2 + iH} x2={xp(i)} y2={pt2 + iH + 4}
|
||||
stroke={T.muted} strokeOpacity="0.5" strokeWidth="1" />
|
||||
<text x={xp(i)} y={pt2 + iH + 17} fill={T.muted} fontSize="9"
|
||||
textAnchor={i === 0 ? "start" : laatste ? "end" : "middle"}>
|
||||
{fmtMaand(p.datum)}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<div style={{ display: "flex", gap: 20, justifyContent: "center", marginTop: 10 }}>
|
||||
{lines.map((l) => (
|
||||
@@ -144,12 +171,12 @@ function VoortgangModal({ onSave, onClose, T, darkMode, initialData }) {
|
||||
<Overlay onClose={onClose}>
|
||||
<ModalBox maxWidth={480}>
|
||||
<ModalHeader
|
||||
icon="📊" iconBg={PURPLE}
|
||||
icon="📊" iconBg={T.accent}
|
||||
title={isEdit ? "Voortgang bewerken" : "Voortgang toevoegen"}
|
||||
subtitle="Vul de waarden in voor dit moment"
|
||||
subtitleColor={T.muted}
|
||||
onClose={onClose}
|
||||
headerBg={darkMode ? `${PURPLE}08` : "#f8f5ff"}
|
||||
headerBg={darkMode ? `${T.accent}08` : T.inputBg}
|
||||
T={T}
|
||||
/>
|
||||
<div style={{ padding: "20px 22px", overflowY: "auto", maxHeight: "70vh" }}>
|
||||
@@ -188,7 +215,7 @@ function VoortgangModal({ onSave, onClose, T, darkMode, initialData }) {
|
||||
<button onClick={onClose} style={{ padding: "10px", background: "transparent", border: `1px solid ${T.border}`, borderRadius: 10, color: T.muted, cursor: "pointer", fontWeight: 600, fontSize: 13 }}>
|
||||
Annuleren
|
||||
</button>
|
||||
<button onClick={save} style={{ flex: 2, padding: "10px", background: `linear-gradient(135deg, ${PURPLE}, ${PURPLE}cc)`, border: "none", borderRadius: 10, color: "#fff", fontWeight: 700, cursor: "pointer", fontSize: 13 }}>
|
||||
<button onClick={save} style={{ flex: 2, padding: "10px", background: `linear-gradient(135deg, ${T.accent}, ${T.accent}cc)`, border: "none", borderRadius: 10, color: "#fff", fontWeight: 700, cursor: "pointer", fontSize: 13 }}>
|
||||
{isEdit ? "Opslaan" : "Toevoegen"}
|
||||
</button>
|
||||
</ModalFooter>
|
||||
@@ -210,13 +237,22 @@ export default function VoortgangTab() {
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [editRow, setEditRow] = useState(null);
|
||||
const {
|
||||
totEV, totSchuld, catTotals, schuldBlokken, schuldHistory,
|
||||
totEV, totSchuld, cats, catTotals, evRows, schuldBlokken, schuldHistory,
|
||||
evVoortgangHistory, setEvVoortgangHistory,
|
||||
data, setData, schuld, locked, darkMode, T,
|
||||
} = useApp();
|
||||
|
||||
const nettoWaarde = totEV - totSchuld;
|
||||
|
||||
// Rendement t.o.v. de geinvesteerde fiat, zelfde formule als op de EV-tab.
|
||||
const totGeinvesteerd = cats.reduce(
|
||||
(a, c) => a + evRows.reduce((b, r) => b + (r[c.id] || 0), 0),
|
||||
0
|
||||
);
|
||||
const rendementBedrag = totGeinvesteerd > 0 ? totEV - totGeinvesteerd : null;
|
||||
const rendementPct = totGeinvesteerd > 0 ? (rendementBedrag / totGeinvesteerd) * 100 : null;
|
||||
const rendementKleur = rendementBedrag === null ? T.muted : rendementBedrag >= 0 ? GREEN : RED;
|
||||
|
||||
const schuldCatsDash = schuldBlokken.map((b) => ({
|
||||
naam: b.naam, color: b.color,
|
||||
subtot: b.items.reduce((a, i) => a + (i.waarde || 0), 0),
|
||||
@@ -261,8 +297,21 @@ export default function VoortgangTab() {
|
||||
setData({ ...data, schuld: { ...schuld, schuldHistory: schuldHistory.filter((s) => s.datum !== datum) } });
|
||||
};
|
||||
|
||||
const th = { padding: "8px 10px", fontSize: 11, color: T.muted, fontWeight: 600, textAlign: "left", whiteSpace: "nowrap", background: T.card, position: "sticky", top: 0, zIndex: 1, borderBottom: `1px solid ${T.border}` };
|
||||
const td = { padding: "7px 10px", fontSize: 12, color: T.text, borderBottom: `1px solid ${T.border}` };
|
||||
// Kolommen in weergave-volgorde, met hun standaardbreedte.
|
||||
const kolommen = [
|
||||
{ id: "datum", breedte: 120 },
|
||||
{ id: "ev", breedte: 175 },
|
||||
{ id: "schuld", breedte: 160 },
|
||||
{ id: "netto", breedte: 175 },
|
||||
{ id: "opmerkingen", breedte: 340 },
|
||||
...(!locked ? [{ id: "acties", breedte: 90 }] : []),
|
||||
];
|
||||
const { breedtes, totaal, grip } = useKolomBreedtes("voortgang_kolom_breedtes", kolommen);
|
||||
|
||||
// overflow/ellipsis horen bij tableLayout "fixed": de inhoud rekt de kolom
|
||||
// niet meer op, dus zonder afkappen loopt lange tekst de buurcel binnen.
|
||||
const th = { padding: "8px 10px", fontSize: 11, color: T.muted, fontWeight: 600, textAlign: "left", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", background: T.popupBg, position: "sticky", top: 0, zIndex: 1, borderBottom: `1px solid ${T.border}` };
|
||||
const td = { padding: "7px 10px", fontSize: 12, color: T.text, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", borderBottom: `1px solid ${T.border}` };
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -272,8 +321,8 @@ export default function VoortgangTab() {
|
||||
|
||||
{/* Hero netto */}
|
||||
<VCard style={{
|
||||
background: `linear-gradient(135deg, ${PURPLE}22, rgba(16,185,129,0.08))`,
|
||||
border: `1px solid ${T.border}`, borderLeft: `4px solid ${PURPLE}`,
|
||||
background: `linear-gradient(135deg, ${T.accent}22, rgba(16,185,129,0.08))`,
|
||||
border: `1px solid ${T.border}`, borderLeft: `4px solid ${T.accent}`,
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 12 }}>
|
||||
@@ -337,11 +386,15 @@ export default function VoortgangTab() {
|
||||
<div style={{ fontWeight: 700, fontSize: 12, letterSpacing: "0.08em", textTransform: "uppercase", color: "#f59e0b", marginBottom: 12 }}>Rendement Eigen Vermogen</div>
|
||||
<div style={{ padding: "10px 0", borderBottom: `1px solid ${T.border}` }}>
|
||||
<div style={{ fontSize: 11, color: T.muted, marginBottom: 4 }}>Bedrag</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: T.muted }}>—</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: rendementKleur }}>
|
||||
{rendementBedrag === null ? "—" : fmt(rendementBedrag)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: "10px 0" }}>
|
||||
<div style={{ fontSize: 11, color: T.muted, marginBottom: 4 }}>Percentage</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: T.muted }}>—</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: rendementKleur }}>
|
||||
{rendementPct === null ? "—" : `${rendementPct >= 0 ? "+" : ""}${rendementPct.toFixed(1)}%`}
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
@@ -383,7 +436,7 @@ export default function VoortgangTab() {
|
||||
<div style={{ fontWeight: 700, fontSize: 14 }}>Voortgang overzicht</div>
|
||||
{!locked && (
|
||||
<button onClick={() => setShowAdd(true)} style={{
|
||||
padding: "6px 14px", background: PURPLE, border: `1px solid ${PURPLE}`,
|
||||
padding: "6px 14px", background: T.accent, border: `1px solid ${T.accent}`,
|
||||
borderRadius: 8, color: "#fff", fontWeight: 700, fontSize: 12, cursor: "pointer",
|
||||
}}>+ Toevoegen</button>
|
||||
)}
|
||||
@@ -396,15 +449,18 @@ export default function VoortgangTab() {
|
||||
)}
|
||||
{voortgangRows.length > 0 ? (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", minWidth: 560 }}>
|
||||
<table style={{ width: totaal, borderCollapse: "collapse", tableLayout: "fixed" }}>
|
||||
<colgroup>
|
||||
{kolommen.map((k) => <col key={k.id} style={{ width: breedtes[k.id] }} />)}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={th}>Datum</th>
|
||||
<th style={{ ...th, color: GREEN }}>Eigen Vermogen</th>
|
||||
<th style={{ ...th, color: RED }}>Schuld</th>
|
||||
<th style={{ ...th, color: PURPLE_LIGHT }}>Nettowaarde</th>
|
||||
<th style={th}>Opmerkingen</th>
|
||||
{!locked && <th style={th}></th>}
|
||||
<th style={th}>Datum<span {...grip("datum")} /></th>
|
||||
<th style={{ ...th, color: GREEN }}>Eigen Vermogen<span {...grip("ev")} /></th>
|
||||
<th style={{ ...th, color: RED }}>Schuld<span {...grip("schuld")} /></th>
|
||||
<th style={{ ...th, color: T.accentTekst }}>Nettowaarde<span {...grip("netto")} /></th>
|
||||
<th style={th}>Opmerkingen<span {...grip("opmerkingen")} /></th>
|
||||
{!locked && <th style={th}><span {...grip("acties")} /></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -414,10 +470,11 @@ export default function VoortgangTab() {
|
||||
<td style={{ ...td, color: GREEN, fontWeight: 600 }}>{row.ev !== null ? fmt(row.ev) : "—"}</td>
|
||||
<td style={{ ...td, color: RED, fontWeight: 600 }}>{row.schuld !== null ? fmt(row.schuld) : "—"}</td>
|
||||
<td style={{ ...td, fontWeight: 700, color: row.netto !== null ? (row.netto >= 0 ? GREEN : RED) : T.muted }}>{row.netto !== null ? fmt(row.netto) : "—"}</td>
|
||||
<td style={{ ...td, color: T.muted }}>{row.opmerkingen || "—"}</td>
|
||||
{/* title: bij een smalle kolom is de tekst afgekapt */}
|
||||
<td style={{ ...td, color: T.muted }} title={row.opmerkingen || ""}>{row.opmerkingen || "—"}</td>
|
||||
{!locked && (
|
||||
<td style={{ ...td, whiteSpace: "nowrap" }}>
|
||||
<button onClick={() => setEditRow(row)} style={{ padding: "2px 8px", background: `${PURPLE}22`, border: `1px solid ${PURPLE}44`, borderRadius: 6, color: PURPLE_LIGHT, cursor: "pointer", fontSize: 12, marginRight: 4 }}>✏️</button>
|
||||
<button onClick={() => setEditRow(row)} style={{ padding: "2px 8px", background: `${T.accent}22`, border: `1px solid ${T.accent}44`, borderRadius: 6, color: T.accentTekst, cursor: "pointer", fontSize: 12, marginRight: 4 }}>✏️</button>
|
||||
<button onClick={() => removeVoortgang(row.datum)} style={{ padding: "2px 8px", background: "rgba(239,68,68,0.1)", border: "1px solid rgba(239,68,68,0.25)", borderRadius: 6, color: RED, cursor: "pointer", fontSize: 12 }}>×</button>
|
||||
</td>
|
||||
)}
|
||||
|
||||
@@ -14,6 +14,21 @@ export const fmtDatum = (s) => {
|
||||
return `${d}-${m}-${y}`;
|
||||
};
|
||||
|
||||
const MAANDEN = ["jan", "feb", "mrt", "apr", "mei", "jun", "jul", "aug", "sep", "okt", "nov", "dec"];
|
||||
|
||||
/**
|
||||
* ISO datum → korte maandnotatie (`2026-09-16` → `sep '26`).
|
||||
*
|
||||
* Voor de x-as van de grafieken: een volledige datum is tien tekens en past
|
||||
* daar alleen gedraaid, wat bij een lange reeks onleesbaar wordt. De exacte
|
||||
* dag staat in de tooltip.
|
||||
*/
|
||||
export const fmtMaand = (s) => {
|
||||
if (!s) return s;
|
||||
const [y, m] = s.split("-");
|
||||
return `${MAANDEN[parseInt(m, 10) - 1] ?? m} '${y.slice(2)}`;
|
||||
};
|
||||
|
||||
/** Crypto-bedrag formatter (4 decimalen standaard) */
|
||||
export const fmtCrypto = (n, d = 4) => (parseFloat(n) || 0).toFixed(d);
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Tekenhulp voor de SVG-grafieken.
|
||||
*
|
||||
* Stond eerder als lokale functie in `DashboardTab.jsx`. Nu de Voortgang-grafiek
|
||||
* dezelfde afronding gebruikt, hoort hij op één plek te staan — anders lopen de
|
||||
* twee vroeg of laat uit elkaar.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Catmull-Rom spline als bezier-pad: een vloeiende lijn door alle punten heen.
|
||||
*
|
||||
* @param {number[]} xs x-posities in SVG-eenheden
|
||||
* @param {number[]} ys y-posities in SVG-eenheden
|
||||
* @param {number} t spanning; hoger is boller, 0.4 is de huisstijl
|
||||
*
|
||||
* Let op: een spline kan tussen twee punten iets buiten hun bereik uitschieten.
|
||||
* Voor een reeks die per definitie nooit terugloopt — zoals de cumulatief
|
||||
* geïnvesteerde fiat — tekent dat een daling die niet in de data zit. Gebruik
|
||||
* daar rechte segmenten.
|
||||
*/
|
||||
export function crPath(xs, ys, t = 0.4) {
|
||||
if (xs.length < 2) return "";
|
||||
let d = `M ${xs[0]},${ys[0]}`;
|
||||
for (let i = 0; i < xs.length - 1; i++) {
|
||||
const p0 = i > 0 ? [xs[i-1], ys[i-1]] : [xs[i], ys[i]];
|
||||
const p1 = [xs[i], ys[i]];
|
||||
const p2 = [xs[i+1], ys[i+1]];
|
||||
const p3 = i < xs.length - 2 ? [xs[i+2], ys[i+2]] : [xs[i+1], ys[i+1]];
|
||||
const cp1x = (p1[0] + (p2[0] - p0[0]) * t).toFixed(2);
|
||||
const cp1y = (p1[1] + (p2[1] - p0[1]) * t).toFixed(2);
|
||||
const cp2x = (p2[0] - (p3[0] - p1[0]) * t).toFixed(2);
|
||||
const cp2y = (p2[1] - (p3[1] - p1[1]) * t).toFixed(2);
|
||||
d += ` C ${cp1x},${cp1y} ${cp2x},${cp2y} ${p2[0]},${p2[1]}`;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sluit een lijnpad af tot een vlak: door naar de basislijn en terug naar de
|
||||
* start. Voor de gradient-vulling onder een lijn.
|
||||
*/
|
||||
export const vlakPad = (lijn, xEerste, xLaatste, yBasis) =>
|
||||
`${lijn} L ${xLaatste},${yBasis} L ${xEerste},${yBasis} Z`;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from "./format.js";
|
||||
export * from "./storage.js";
|
||||
export * from "./grafiek.js";
|
||||
export * from "./htmlExport.js";
|
||||
|
||||
@@ -3,6 +3,10 @@ const KEYS = {
|
||||
evRows: "ev_voortgang_rows",
|
||||
users: "wbjw_users",
|
||||
exitplan: "exitplan_data",
|
||||
// Weergavevoorkeuren: per browser, net als de kolombreedtes. Ze staan bewust
|
||||
// los van de financiële data, dus ze gaan niet mee in de backup-ZIP.
|
||||
thema: "vibefinance_thema",
|
||||
darkMode: "vibefinance_darkmode",
|
||||
};
|
||||
|
||||
export function loadFromStorage(key) {
|
||||
|
||||
@@ -41,6 +41,6 @@ export default defineConfig({
|
||||
define: {
|
||||
// VITE_API_URL wordt ingebakken tijdens build
|
||||
__API_URL__: JSON.stringify(process.env.VITE_API_URL ?? "/api"),
|
||||
__APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION ?? pkg.version),
|
||||
__APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || pkg.version),
|
||||
},
|
||||
});
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vibefinance",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.13",
|
||||
"description": "VibeFinance — persoonlijk vermogensbeheer",
|
||||
"private": true,
|
||||
"repository": "https://vibehoogie.duckdns.org/vibe/VibeFinance",
|
||||
|
||||
+170
-44
@@ -9,6 +9,16 @@
|
||||
# .\release.ps1 -SkipTests # sla de testsuite over (niet aanbevolen)
|
||||
# .\release.ps1 -DryRun # toon alle commando's zonder iets uit te voeren
|
||||
# .\release.ps1 -Force # sla conflictcontrole over, overschrijf tags
|
||||
# .\release.ps1 -NoChangelog # publiceer bewust zonder release-notes
|
||||
#
|
||||
# Het script werkt zelf vier dingen bij, dus die hoef je niet met de hand te
|
||||
# doen: de changelog-kop `## [Niet vrijgegeven]` -> `## [x.x.x] - datum`, de
|
||||
# versie in frontend/package.json, de versiebadge in README.md, en ná het
|
||||
# publiceren de versie in de root package.json -> `x.x.(x+1)-dev`.
|
||||
#
|
||||
# LET OP bij -Force: dat verwijdert de bestaande tag lokaal en op origin, en
|
||||
# Gitea gooit de bijbehorende release daarmee weg. Onder -DryRun wordt dat
|
||||
# alleen gemeld (dat was eerder niet zo — zie stap 4).
|
||||
#
|
||||
# Vereisten:
|
||||
# - Docker Desktop actief
|
||||
@@ -16,6 +26,9 @@
|
||||
# - docker buildx builder actief: docker buildx create --use
|
||||
# - Git geinstalleerd en working directory is de repo root
|
||||
# - root package.json bevat een schoon versienummer ZONDER -dev suffix
|
||||
# - Changelog.md beschrijft deze versie, onder `## [Niet vrijgegeven]` of
|
||||
# onder een kop `## [x.x.x]`; anders breekt het script af (-NoChangelog
|
||||
# omzeilt dat)
|
||||
# - GITEA_TOKEN in de omgeving (token met release-rechten) voor stap 5,
|
||||
# de Gitea-release waar de update-melding in de app op afgaat
|
||||
|
||||
@@ -23,7 +36,10 @@ param(
|
||||
[switch]$NoBuild,
|
||||
[switch]$SkipTests,
|
||||
[switch]$DryRun,
|
||||
[switch]$Force
|
||||
[switch]$Force,
|
||||
# Releasen zonder inhoudelijke release-notes. Zonder deze switch breekt het
|
||||
# script af wanneer de changelog niets over deze versie zegt.
|
||||
[switch]$NoChangelog
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -75,42 +91,13 @@ if ($appVersion -match "-dev$") {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── Versie synchroniseren naar frontend/package.json ──────────────────────
|
||||
# Een droogloop schrijft hier niet: anders laat -DryRun een gewijzigd bestand
|
||||
# achter, en tagt de echte release daarna een vervuilde working tree.
|
||||
$frontendPkgPath = Join-Path $PSScriptRoot "frontend/package.json"
|
||||
$frontendPkg = Get-Content $frontendPkgPath -Raw | ConvertFrom-Json
|
||||
if ($frontendPkg.version -ne $appVersion) {
|
||||
if ($DryRun) {
|
||||
Write-Host "[DRY-RUN] Versie naar frontend synchroniseren: $($frontendPkg.version) -> $appVersion" -ForegroundColor Yellow
|
||||
} else {
|
||||
$frontendPkg.version = $appVersion
|
||||
[System.IO.File]::WriteAllText($frontendPkgPath, ($frontendPkg | ConvertTo-Json -Depth 10))
|
||||
Write-Host "Versie gesynchroniseerd naar frontend: $appVersion" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
# ── Versiebadge in README.md ──────────────────────────────────────────────
|
||||
# Anders loopt die badge stil achter: hij stond drie releases achter voordat
|
||||
# dit erin zat, en het script kent het nummer toch al. Zelfde regel als
|
||||
# hierboven: een droogloop meldt het, maar schrijft niet.
|
||||
$readmePath = Join-Path $PSScriptRoot "README.md"
|
||||
if (Test-Path $readmePath) {
|
||||
$readme = [System.IO.File]::ReadAllText($readmePath)
|
||||
$patroon = "badge/versie-[^-]+-"
|
||||
$nieuw = "badge/versie-$appVersion-"
|
||||
$huidig = [regex]::Match($readme, $patroon)
|
||||
if ($huidig.Success -and $huidig.Value -ne $nieuw) {
|
||||
if ($DryRun) {
|
||||
Write-Host "[DRY-RUN] Versiebadge in README.md: $($huidig.Value) -> $nieuw" -ForegroundColor Yellow
|
||||
} else {
|
||||
[System.IO.File]::WriteAllText($readmePath, ($readme -replace $patroon, $nieuw))
|
||||
Write-Host "Versiebadge in README.md bijgewerkt: $appVersion" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$gitTag = "$appVersion"
|
||||
# Git-tag met `v`-prefix, net als de release-naam hieronder en net als de tags
|
||||
# t/m v1.0.0. Gitea toont de tag op de release-pagina, dus zonder prefix stond
|
||||
# daar het kale nummer. De image-tags in de registry houden bewust géén `v`:
|
||||
# dat is de conventie voor container-tags.
|
||||
# De update-check verdraagt beide: zowel backend/src/routes/update.js als
|
||||
# useUpdateCheck.js strippen een leidende `v` voordat ze versies vergelijken.
|
||||
$gitTag = "v$appVersion"
|
||||
|
||||
# ── Registry tags (één image voor frontend + backend) ─────────────────────
|
||||
$appBase = "$registry/$appImage"
|
||||
@@ -151,12 +138,13 @@ function Write-Step {
|
||||
Write-Host "── $Label" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
# Haalt de sectie van deze versie uit Changelog.md als release-notes.
|
||||
function Get-ChangelogSection {
|
||||
param([string]$Version)
|
||||
$path = Join-Path $PSScriptRoot "Changelog.md"
|
||||
if (-not (Test-Path $path)) { return "" }
|
||||
$lines = Get-Content $path
|
||||
# Haalt de sectie van deze versie uit changelog-tekst. Werkt op een string en
|
||||
# niet op het bestand, zodat een droogloop kan valideren tegen de kop die hij
|
||||
# zou schrijven zonder die daadwerkelijk te schrijven.
|
||||
function Get-ChangelogSectionFromText {
|
||||
param([string]$Text, [string]$Version)
|
||||
if (-not $Text) { return "" }
|
||||
$lines = $Text -split "\r?\n"
|
||||
$start = -1
|
||||
$needle = "^##\s*\[" + [regex]::Escape($Version) + "\]"
|
||||
for ($i = 0; $i -lt $lines.Count; $i++) {
|
||||
@@ -172,6 +160,99 @@ function Get-ChangelogSection {
|
||||
return (($out -join "`n").Trim())
|
||||
}
|
||||
|
||||
# ── Changelog-kop vrijgeven ───────────────────────────────────────────────
|
||||
# De release-notes komen uit de sectie `## [x.x.x]` in Changelog.md. Stond het
|
||||
# werk nog onder `## [Niet vrijgegeven]`, dan vond het script niets en werd stil
|
||||
# de placeholder "Release x.x.x" gepubliceerd — dat gebeurde bij 0.1.9 en 1.0.2,
|
||||
# en is achteraf alleen met een PATCH op de release te herstellen. Daarom zet het
|
||||
# script de kop nu zelf om, en breekt het af als er niets te melden valt.
|
||||
# Zelfde regel als bij de andere auto-updates: een droogloop meldt, maar schrijft
|
||||
# niet.
|
||||
$changelogPath = Join-Path $PSScriptRoot "Changelog.md"
|
||||
$nieuweKop = "## [$appVersion] - $(Get-Date -Format 'yyyy-MM-dd')"
|
||||
$changelogTxt = if (Test-Path $changelogPath) { [System.IO.File]::ReadAllText($changelogPath) } else { "" }
|
||||
|
||||
$kopPatroon = "(?m)^##\s*\[" + [regex]::Escape($appVersion) + "\]"
|
||||
# Niet `\s*$`: `\s` matcht ook newlines, en dan slokt de match de lege regel ná
|
||||
# de kop op — die verdween daardoor bij het omzetten. En niet `[ \t]*$` alleen:
|
||||
# dit bestand heeft CRLF (zie .gitattributes), dus `$` staat achter een `\r` die
|
||||
# geen space of tab is. De lookahead laat het regeleinde buiten de match, zodat
|
||||
# de CRLF intact blijft.
|
||||
$onderwegPatroon = "(?m)^##[ \t]*\[[ \t]*Niet vrijgegeven[ \t]*\][ \t]*(?=\r?$)"
|
||||
|
||||
# Eerst alleen in het geheugen omzetten. Schrijven gebeurt na de controle
|
||||
# hieronder: bij een lege `[Niet vrijgegeven]`-sectie zou de kop anders al
|
||||
# vervangen zijn terwijl het script daarna afbreekt, en dan blijft er een
|
||||
# aangepaste changelog achter bij een release die niet doorging.
|
||||
$kopOmzetten = ($changelogTxt -notmatch $kopPatroon -and $changelogTxt -match $onderwegPatroon)
|
||||
if ($kopOmzetten) {
|
||||
# $nieuweKop bevat geen '$', dus veilig als vervangingstekst.
|
||||
$changelogTxt = [regex]::Replace($changelogTxt, $onderwegPatroon, $nieuweKop)
|
||||
}
|
||||
|
||||
if (-not (Get-ChangelogSectionFromText $changelogTxt $appVersion) -and -not $NoChangelog) {
|
||||
Write-Host ""
|
||||
Write-Host "Changelog.md zegt niets over $appVersion." -ForegroundColor Red
|
||||
Write-Host ""
|
||||
Write-Host "Zonder notes komt er alleen de tekst 'Release $appVersion' in de Gitea-release," -ForegroundColor Yellow
|
||||
Write-Host "en dat is achteraf alleen met een PATCH op de release te herstellen." -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
Write-Host "Zet je wijzigingen in Changelog.md onder een kop:" -ForegroundColor White
|
||||
Write-Host " ## [Niet vrijgegeven]" -ForegroundColor White
|
||||
Write-Host "Die zet dit script bij de volgende run zelf om naar '$nieuweKop'." -ForegroundColor White
|
||||
Write-Host ""
|
||||
Write-Host "Bewust zonder release-notes publiceren? Draai met -NoChangelog." -ForegroundColor DarkGray
|
||||
Write-Host ""
|
||||
Write-Host "Er is niets gepubliceerd." -ForegroundColor DarkGray
|
||||
exit 1
|
||||
}
|
||||
|
||||
# De controle is doorstaan, dus nu pas schrijven.
|
||||
if ($kopOmzetten) {
|
||||
if ($DryRun) {
|
||||
Write-Host "[DRY-RUN] Changelog-kop vrijgeven: '## [Niet vrijgegeven]' -> '$nieuweKop'" -ForegroundColor Yellow
|
||||
} else {
|
||||
[System.IO.File]::WriteAllText($changelogPath, $changelogTxt)
|
||||
Write-Host "Changelog-kop vrijgegeven: $nieuweKop" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
# ── Versie synchroniseren naar frontend/package.json ──────────────────────
|
||||
# Staat na de changelog-controle: anders laat een afgebroken release wél een
|
||||
# gewijzigde working tree achter terwijl hij "niets gepubliceerd" meldt.
|
||||
# Een droogloop schrijft hier niet, om dezelfde reden.
|
||||
$frontendPkgPath = Join-Path $PSScriptRoot "frontend/package.json"
|
||||
$frontendPkg = Get-Content $frontendPkgPath -Raw | ConvertFrom-Json
|
||||
if ($frontendPkg.version -ne $appVersion) {
|
||||
if ($DryRun) {
|
||||
Write-Host "[DRY-RUN] Versie naar frontend synchroniseren: $($frontendPkg.version) -> $appVersion" -ForegroundColor Yellow
|
||||
} else {
|
||||
$frontendPkg.version = $appVersion
|
||||
[System.IO.File]::WriteAllText($frontendPkgPath, ($frontendPkg | ConvertTo-Json -Depth 10))
|
||||
Write-Host "Versie gesynchroniseerd naar frontend: $appVersion" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
# ── Versiebadge in README.md ──────────────────────────────────────────────
|
||||
# Anders loopt die badge stil achter: hij stond drie releases achter voordat
|
||||
# dit erin zat, en het script kent het nummer toch al. Zelfde regel als
|
||||
# hierboven: een droogloop meldt het, maar schrijft niet.
|
||||
$readmePath = Join-Path $PSScriptRoot "README.md"
|
||||
if (Test-Path $readmePath) {
|
||||
$readme = [System.IO.File]::ReadAllText($readmePath)
|
||||
$patroon = "badge/versie-[^-]+-"
|
||||
$nieuw = "badge/versie-$appVersion-"
|
||||
$huidig = [regex]::Match($readme, $patroon)
|
||||
if ($huidig.Success -and $huidig.Value -ne $nieuw) {
|
||||
if ($DryRun) {
|
||||
Write-Host "[DRY-RUN] Versiebadge in README.md: $($huidig.Value) -> $nieuw" -ForegroundColor Yellow
|
||||
} else {
|
||||
[System.IO.File]::WriteAllText($readmePath, ($readme -replace $patroon, $nieuw))
|
||||
Write-Host "Versiebadge in README.md bijgewerkt: $appVersion" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ── Samenvatting ───────────────────────────────────────────────────────────
|
||||
Write-Host ""
|
||||
Write-Host "════════════════════════════════════════" -ForegroundColor Cyan
|
||||
@@ -365,15 +446,25 @@ if (-not $NoBuild) {
|
||||
Write-Step "[4/5] Git-tag aanmaken en pushen"
|
||||
|
||||
if ($Force) {
|
||||
# LET OP: deze twee commando's gingen niet via Invoke-Cmd en hadden dus geen
|
||||
# -DryRun-controle. Een `-DryRun -Force` verwijderde daardoor écht de tag,
|
||||
# lokaal en op origin — en Gitea ruimt de release op zodra de tag verdwijnt.
|
||||
# Zo is de release van 1.0.2 verloren gegaan.
|
||||
if ($DryRun) {
|
||||
Write-Host "[DRY-RUN] git tag -d $gitTag" -ForegroundColor Yellow
|
||||
Write-Host "[DRY-RUN] git push origin :refs/tags/$gitTag" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Bestaande lokale tag verwijderen (indien aanwezig)..." -ForegroundColor Yellow
|
||||
$ErrorActionPreference = "SilentlyContinue"
|
||||
git tag -d $gitTag 2>$null | Out-Null
|
||||
$ErrorActionPreference = "Stop"
|
||||
Write-Host "Bestaande remote tag verwijderen (indien aanwezig)..." -ForegroundColor Yellow
|
||||
Write-Host " Gitea verwijdert de bijbehorende release mee." -ForegroundColor DarkGray
|
||||
$ErrorActionPreference = "SilentlyContinue"
|
||||
git push origin ":refs/tags/$gitTag" 2>$null | Out-Null
|
||||
$ErrorActionPreference = "Stop"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Git-tag aanmaken: $gitTag" -ForegroundColor White
|
||||
Invoke-Cmd @("git", "tag", "-a", $gitTag, "-m", "Release $gitTag")
|
||||
@@ -405,7 +496,10 @@ if (-not $releaseApi) {
|
||||
Write-Host "Geen GITEA_TOKEN in de omgeving — release overgeslagen." -ForegroundColor Yellow
|
||||
Write-Host " Zonder release ziet de app geen update-melding voor $appVersion." -ForegroundColor DarkGray
|
||||
} else {
|
||||
$notes = Get-ChangelogSection $appVersion
|
||||
# Uit $changelogTxt en niet opnieuw van schijf: in een droogloop is de kop
|
||||
# nog niet geschreven, en dan zou de payload hier de placeholder tonen —
|
||||
# precies wat je met een droogloop wilt uitsluiten.
|
||||
$notes = Get-ChangelogSectionFromText $changelogTxt $appVersion
|
||||
if (-not $notes) { $notes = "Release $appVersion" }
|
||||
$payload = @{
|
||||
tag_name = $gitTag
|
||||
@@ -451,6 +545,38 @@ Write-Host " Git-tag: $gitTag -> origin" -ForegroundColor Green
|
||||
Write-Host "════════════════════════════════════════" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# ── Versie naar de volgende dev-cyclus ────────────────────────────────────
|
||||
# Was voorheen stap 4 met de hand. Gebeurt hier, ná het publiceren, zodat een
|
||||
# afgebroken release het versienummer niet vooruit zet. Bijkomend voordeel: de
|
||||
# `-dev` suffix laat dezelfde release niet nog eens draaien (de controle boven
|
||||
# in dit script weigert een `-dev` versie), dus een dubbele publicatie is
|
||||
# meteen uitgesloten. Wil je bewust opnieuw releasen, zet het nummer dan zelf
|
||||
# terug.
|
||||
if ($appVersion -match '^(\d+)\.(\d+)\.(\d+)$') {
|
||||
$volgende = "{0}.{1}.{2}-dev" -f $Matches[1], $Matches[2], ([int]$Matches[3] + 1)
|
||||
$rootPkgPath = Join-Path $PSScriptRoot "package.json"
|
||||
|
||||
if ($DryRun) {
|
||||
Write-Host "[DRY-RUN] Versie in package.json: $appVersion -> $volgende" -ForegroundColor Yellow
|
||||
} else {
|
||||
# Gerichte tekstvervanging in plaats van ConvertTo-Json: dat zou het hele
|
||||
# bestand herschrijven en de opmaak van de andere velden aantasten.
|
||||
$rootTxt = [System.IO.File]::ReadAllText($rootPkgPath)
|
||||
$treffer = [regex]::Match($rootTxt, '"version"\s*:\s*"' + [regex]::Escape($appVersion) + '"')
|
||||
if ($treffer.Success) {
|
||||
$rootTxt = $rootTxt.Remove($treffer.Index, $treffer.Length).Insert($treffer.Index, '"version": "' + $volgende + '"')
|
||||
[System.IO.File]::WriteAllText($rootPkgPath, $rootTxt)
|
||||
Write-Host "Versie in package.json naar de volgende cyclus: $volgende" -ForegroundColor Green
|
||||
Write-Host " Commit dit mee, samen met de changelog-kop en de README-badge." -ForegroundColor DarkGray
|
||||
Write-Host ""
|
||||
} else {
|
||||
Write-Host "Kon '$appVersion' niet terugvinden in package.json — versie niet bijgewerkt." -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Write-Host "Versie '$appVersion' is geen x.y.z — volgende dev-versie niet automatisch gezet." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# Expliciete 0: anders erft de caller de exitcode van het laatste
|
||||
# docker-commando (bijv. de registry-check).
|
||||
exit 0
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
# ./release.sh -SkipTests # sla de testsuite over (niet aanbevolen)
|
||||
# ./release.sh -DryRun # toon alle commando's zonder iets uit te voeren
|
||||
# ./release.sh -Force # sla conflictcontrole over, overschrijf tags
|
||||
# ./release.sh -NoChangelog # publiceer bewust zonder release-notes
|
||||
#
|
||||
# Combinaties:
|
||||
# ./release.sh -Force -NoBuild
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
# 1. Dependency-check (staan package.json's op de laatste versies?)
|
||||
# 2. Backend-tests (Dockerfile target 'test' → node --test, zonder database)
|
||||
# 3. Productiebuild (Vite-build + app-image; faalt de frontend, faalt dit)
|
||||
# 4. Smoketest (productie-image + postgres, /api/health en / opvragen)
|
||||
# 4. Imagescan (Trivy op het gebouwde image; OS- én npm-pakketten)
|
||||
# 5. Smoketest (productie-image + postgres, /api/health en / opvragen)
|
||||
#
|
||||
# De smoketest publiceert geen hostpoorten en gebruikt een eigen netwerk, dus
|
||||
# hij botst niet met een draaiende dev-stack.
|
||||
@@ -14,14 +15,17 @@
|
||||
# .\test.ps1 # alle stappen
|
||||
# .\test.ps1 -Unit # dependency-check + backend-tests
|
||||
# .\test.ps1 -NoSmoke # zonder de stack te starten
|
||||
# .\test.ps1 -Strict # faal als een dependency achterloopt
|
||||
# .\test.ps1 -Strict # faal als een dependency achterloopt; scan ook op
|
||||
# # MEDIUM en LOW in plaats van alleen CRITICAL/HIGH
|
||||
# .\test.ps1 -SkipDeps # dependency-check overslaan (bijv. offline)
|
||||
# .\test.ps1 -SkipScan # imagescan overslaan (bijv. offline)
|
||||
|
||||
param(
|
||||
[switch]$Unit,
|
||||
[switch]$NoSmoke,
|
||||
[switch]$Strict,
|
||||
[switch]$SkipDeps
|
||||
[switch]$SkipDeps,
|
||||
[switch]$SkipScan
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -43,7 +47,7 @@ function Write-Step {
|
||||
# Vergelijkt de versies in frontend/ en backend/package.json met het
|
||||
# npm-register. Bewuste uitzonderingen staan in docker/update-exceptions.json.
|
||||
if (-not $SkipDeps) {
|
||||
Write-Step "[1/4] Dependency-check"
|
||||
Write-Step "[1/5] Dependency-check"
|
||||
|
||||
$checkArgs = @("node", "docker/check-updates.mjs")
|
||||
if ($Strict) { $checkArgs += "--strict" }
|
||||
@@ -59,11 +63,11 @@ if (-not $SkipDeps) {
|
||||
Write-Host "Let op: er zijn nieuwere versies beschikbaar (zie hierboven)." -ForegroundColor Yellow
|
||||
}
|
||||
} else {
|
||||
Write-Step "[1/4] Dependency-check overgeslagen (-SkipDeps)"
|
||||
Write-Step "[1/5] Dependency-check overgeslagen (-SkipDeps)"
|
||||
}
|
||||
|
||||
# ── 2. Backend-tests ──────────────────────────────────────────────────────
|
||||
Write-Step "[2/4] Backend-tests"
|
||||
Write-Step "[2/5] Backend-tests"
|
||||
docker build --target test -t $testImage $PSScriptRoot
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "Testimage bouwen mislukt"; exit 1 }
|
||||
|
||||
@@ -78,7 +82,7 @@ if ($Unit) {
|
||||
}
|
||||
|
||||
# ── 3. Productiebuild (inclusief Vite-build) ──────────────────────────────
|
||||
Write-Step "[3/4] Productie-image bouwen"
|
||||
Write-Step "[3/5] Productie-image bouwen"
|
||||
docker build --target production `
|
||||
--build-arg APP_VERSION=$appVersion `
|
||||
--build-arg VITE_APP_VERSION=$appVersion `
|
||||
@@ -87,13 +91,67 @@ docker build --target production `
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "Productiebuild mislukt"; exit 1 }
|
||||
Write-Host "Productie-image gebouwd: $prodImage" -ForegroundColor Green
|
||||
|
||||
# ── 4. Imagescan ──────────────────────────────────────────────────────────
|
||||
# Stap 1 kijkt naar de package.json's; deze kijkt naar wat er daadwerkelijk in
|
||||
# het image zit — de Alpine-pakketten en alles wat het base-image meelevert.
|
||||
# Dat onderscheid is niet theoretisch: bij de scan van 1.0.10 kwamen alle 39
|
||||
# bevindingen daarvandaan (OpenSSL in Alpine, en npm's eigen dependencies) en
|
||||
# geen enkele uit deze applicatie. Een dependency-check had er nul van gezien.
|
||||
#
|
||||
# --ignore-unfixed: alleen falen op iets waar een fix voor bestaat. Anders
|
||||
# blokkeert een CVE zonder patch je release zonder dat je er iets aan kunt doen.
|
||||
if (-not $SkipScan) {
|
||||
Write-Step "[4/5] Imagescan (Trivy)"
|
||||
|
||||
$trivyCache = Join-Path $env:USERPROFILE ".cache\trivy"
|
||||
New-Item -ItemType Directory -Force -Path $trivyCache | Out-Null
|
||||
|
||||
# Normaal blokkeren alleen CRITICAL en HIGH; -Strict trekt de lat naar alles.
|
||||
$ernst = if ($Strict) { "CRITICAL,HIGH,MEDIUM,LOW" } else { "CRITICAL,HIGH" }
|
||||
|
||||
# Uitvoer opvangen en alleen tonen als er iets is: Trivy print bij succes
|
||||
# een tabelregel voor élk schoon pakket, en dat is honderden regels ruis
|
||||
# waarin een echte melding juist wegvalt.
|
||||
$scanUit = docker run --rm `
|
||||
-v /var/run/docker.sock:/var/run/docker.sock `
|
||||
-v "${trivyCache}:/root/.cache" `
|
||||
aquasec/trivy:latest image `
|
||||
--scanners vuln --severity $ernst --ignore-unfixed --exit-code 1 --quiet `
|
||||
$prodImage 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
$scanUit | ForEach-Object { Write-Host $_ }
|
||||
Write-Error "Imagescan vond op te lossen kwetsbaarheden ($ernst)"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Imagescan schoon ($ernst)" -ForegroundColor Green
|
||||
|
||||
# Het postgres-image scannen we niet als gate: alle bevindingen daarin zitten
|
||||
# in /usr/local/bin/gosu, en die binary wordt nooit uitgevoerd omdat de
|
||||
# compose-bestanden de container al als `postgres` starten (zie BEHEER.md).
|
||||
# Wat wél misgaat is achterstand: het image stond vier maanden op 18.4 met
|
||||
# 143 bevindingen omdat niemand pullde. Daar waarschuwen we voor — falen zou
|
||||
# onterecht zijn, want een release van de app hangt er niet van af.
|
||||
$pgTag = "postgres:18-alpine"
|
||||
$lokaal = (docker image inspect $pgTag --format '{{index .RepoDigests 0}}' 2>$null)
|
||||
if ($lokaal) {
|
||||
$remote = (docker buildx imagetools inspect $pgTag --format '{{.Manifest.Digest}}' 2>$null)
|
||||
if ($remote -and $lokaal -notlike "*$remote*") {
|
||||
Write-Host "Let op: $pgTag is nieuwer in de registry — draai 'docker compose pull postgres'." -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "$pgTag is actueel" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Write-Step "[4/5] Imagescan overgeslagen (-SkipScan)"
|
||||
}
|
||||
|
||||
if ($NoSmoke) {
|
||||
Write-Host ""
|
||||
Write-Host "Smoketest overgeslagen (-NoSmoke) — klaar." -ForegroundColor Green
|
||||
exit 0
|
||||
}
|
||||
# ── 4. Smoketest op het productie-image + postgres ────────────────────────
|
||||
Write-Step "[4/4] Smoketest (app + postgres)"
|
||||
# ── 5. Smoketest op het productie-image + postgres ────────────────────────
|
||||
Write-Step "[5/5] Smoketest (app + postgres)"
|
||||
|
||||
function Remove-SmokeStack {
|
||||
docker rm -f $smokeApp $smokeDb 2>$null | Out-Null
|
||||
|
||||
Reference in New Issue
Block a user