Compare commits
12
Commits
v1.0.6
..
539b42dde7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
539b42dde7 | ||
|
|
ad45e372e6 | ||
|
|
73ce30c5b0 | ||
|
|
0b67f346e1 | ||
|
|
4915a090a3 | ||
|
|
750dc69e56 | ||
|
|
6d1b114b78 | ||
|
|
a9b6203dec | ||
|
|
36f49b216f | ||
|
|
796245fefe | ||
|
|
0bbb166b32 | ||
|
|
fe6b94aa7f |
@@ -32,6 +32,32 @@ Vite proxyt `/api` naar `localhost:3001` binnen dezelfde container.
|
|||||||
> ⚠️ Alleen `.\dev.ps1 down` wist de development-database (`docker compose down -v`).
|
> ⚠️ Alleen `.\dev.ps1 down` wist de development-database (`docker compose down -v`).
|
||||||
> `.\dev.ps1 up` doet een gewone `down` en laat het volume staan.
|
> `.\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
|
### Welke versie de dev-app toont
|
||||||
|
|
||||||
De versie in "Over VibeFinance" komt uit `VITE_APP_VERSION`. `dev.ps1` leest die
|
De versie in "Over VibeFinance" komt uit `VITE_APP_VERSION`. `dev.ps1` leest die
|
||||||
|
|||||||
@@ -2,6 +2,105 @@
|
|||||||
|
|
||||||
## [Niet vrijgegeven]
|
## [Niet vrijgegeven]
|
||||||
|
|
||||||
|
### 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
|
### Toegevoegd
|
||||||
- De grafiek "Ontwikkeling van Eigen Vermogen" op het dashboard heeft een tweede
|
- De grafiek "Ontwikkeling van Eigen Vermogen" op het dashboard heeft een tweede
|
||||||
lijn: de **cumulatief geïnvesteerde fiat**. Die is opgebouwd uit dezelfde
|
lijn: de **cumulatief geïnvesteerde fiat**. Die is opgebouwd uit dezelfde
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Eigen vermogen · Schulden · Voortgang · Gebruikersbeheer
|
|||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
<img src="docs/screenshot-dashboard.png" alt="VibeFinance dashboard" width="820">
|
<img src="docs/screenshot-dashboard.png" alt="VibeFinance dashboard" width="820">
|
||||||
@@ -171,7 +171,7 @@ build — anders zou de release met een placeholder als notes online komen.
|
|||||||
| 📉 **Schuld** | Schulden bijhouden per blok met afbetalingsoverzicht |
|
| 📉 **Schuld** | Schulden bijhouden per blok met afbetalingsoverzicht |
|
||||||
| 📈 **Voortgang Schuld & EV** | Grafieken en trends van EV en schuld over tijd (apart bij te houden) |
|
| 📈 **Voortgang Schuld & EV** | Grafieken en trends van EV en schuld over tijd (apart bij te houden) |
|
||||||
| 👥 **Gebruikers** | Accounts aanmaken, bewerken en beheren (Admin-rol vereist) |
|
| 👥 **Gebruikers** | Accounts aanmaken, bewerken en beheren (Admin-rol vereist) |
|
||||||
| ⚙️ **Instellingen** | Licht/donker thema, backup downloaden en backup terugzetten |
|
| ⚙️ **Instellingen** | Kleurthema, licht/donker, backup downloaden en backup terugzetten |
|
||||||
|
|
||||||
De grafiek "Ontwikkeling van Eigen Vermogen" op het dashboard tekent twee lijnen
|
De grafiek "Ontwikkeling van Eigen Vermogen" op het dashboard tekent twee lijnen
|
||||||
op één as: je netto waarde per voortgangsmoment, en daaronder de cumulatief
|
op één as: je netto waarde per voortgangsmoment, en daaronder de cumulatief
|
||||||
@@ -181,6 +181,15 @@ inleglijn is per definitie gelijk aan de KPI "Totaal geïnvesteerde fiat" — be
|
|||||||
rekenen het oudste voortgangsmoment als startinleg en tellen daar de stortingen
|
rekenen het oudste voortgangsmoment als startinleg en tellen daar de stortingen
|
||||||
per rij bij op.
|
per rij bij op.
|
||||||
|
|
||||||
|
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 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
|
de rechterrand van een kolomkop. De breedtes worden per tabel in de browser
|
||||||
bewaard; dubbelklik op de greep voor de standaardbreedte.
|
bewaard; dubbelklik op de greep voor de standaardbreedte.
|
||||||
@@ -223,7 +232,7 @@ vibefinance/
|
|||||||
│ ├── App.css # Reset + losse klassen (o.a. .kolom-grip)
|
│ ├── App.css # Reset + losse klassen (o.a. .kolom-grip)
|
||||||
│ ├── main.jsx
|
│ ├── main.jsx
|
||||||
│ ├── version.js # Draaiende versie (__APP_VERSION__ uit de build)
|
│ ├── version.js # Draaiende versie (__APP_VERSION__ uit de build)
|
||||||
│ ├── constants/ # Kleuren, tabs, begindata
|
│ ├── constants/ # index.js (kleuren, tabs, begindata), themas.js
|
||||||
│ ├── utils/ # format.js (bedragen/datums), storage.js
|
│ ├── utils/ # format.js (bedragen/datums), storage.js
|
||||||
│ ├── hooks/ # useTheme, useUpdateCheck, useKolomBreedtes
|
│ ├── hooks/ # useTheme, useUpdateCheck, useKolomBreedtes
|
||||||
│ ├── context/ # AppContext (global state + API sync + backup)
|
│ ├── context/ # AppContext (global state + API sync + backup)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "vibefinance-frontend",
|
"name": "vibefinance-frontend",
|
||||||
"version": "1.0.5",
|
"version": "1.0.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import DashboardTab from "./pages/DashboardTab.jsx";
|
|||||||
import EigenVermogenTab from "./pages/EigenVermogenTab.jsx";
|
import EigenVermogenTab from "./pages/EigenVermogenTab.jsx";
|
||||||
import SchuldTab from "./pages/SchuldTab.jsx";
|
import SchuldTab from "./pages/SchuldTab.jsx";
|
||||||
import VoortgangTab from "./pages/VoortgangTab.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 { fmt } from "./utils/format.js";
|
||||||
import { version } from "./version.js";
|
import { version } from "./version.js";
|
||||||
import { useUpdateCheck } from "./hooks/useUpdateCheck.js";
|
import { useUpdateCheck } from "./hooks/useUpdateCheck.js";
|
||||||
@@ -86,10 +86,10 @@ function AppInner() {
|
|||||||
</div>
|
</div>
|
||||||
<button onClick={() => { setSessionVerlopen(false); }} style={{
|
<button onClick={() => { setSessionVerlopen(false); }} style={{
|
||||||
width: "100%", padding: "14px",
|
width: "100%", padding: "14px",
|
||||||
background: "linear-gradient(135deg, #8b5cf6, #a855f7)",
|
background: T.accentGradient,
|
||||||
border: "none", borderRadius: 12,
|
border: "none", borderRadius: 12,
|
||||||
color: "#fff", fontWeight: 700, fontSize: 15, cursor: "pointer",
|
color: "#fff", fontWeight: 700, fontSize: 15, cursor: "pointer",
|
||||||
boxShadow: "0 4px 16px rgba(139,92,246,0.4)",
|
boxShadow: T.cardShadow,
|
||||||
}}>
|
}}>
|
||||||
Opnieuw inloggen
|
Opnieuw inloggen
|
||||||
</button>
|
</button>
|
||||||
@@ -110,8 +110,8 @@ function AppInner() {
|
|||||||
}}>
|
}}>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 40, height: 40, borderRadius: "50%",
|
width: 40, height: 40, borderRadius: "50%",
|
||||||
border: `3px solid rgba(139,92,246,0.2)`,
|
border: `3px solid ${T.border}`,
|
||||||
borderTop: `3px solid ${PURPLE_LIGHT}`,
|
borderTop: `3px solid ${T.accentTekst}`,
|
||||||
animation: "spin 0.8s linear infinite",
|
animation: "spin 0.8s linear infinite",
|
||||||
}} />
|
}} />
|
||||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||||
@@ -242,7 +242,7 @@ function AppInner() {
|
|||||||
>
|
>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 36, height: 36, borderRadius: "50%", overflow: "hidden", flexShrink: 0,
|
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",
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
fontSize: 12, fontWeight: 800, color: "#fff",
|
fontSize: 12, fontWeight: 800, color: "#fff",
|
||||||
border: `2px solid ${T.border}`,
|
border: `2px solid ${T.border}`,
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { RED, GREEN } from "../constants/index.js";
|
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";
|
import { useApp } from "../context/AppContext.jsx";
|
||||||
|
|
||||||
const getToken = () => localStorage.getItem("vf_token");
|
const getToken = () => localStorage.getItem("vf_token");
|
||||||
@@ -56,11 +52,11 @@ function RolSelect({ value, onChange, opties, T, darkMode }) {
|
|||||||
{opties.map((o) => (
|
{opties.map((o) => (
|
||||||
<div key={o} onClick={() => { onChange(o); setOpen(false); }} style={{
|
<div key={o} onClick={() => { onChange(o); setOpen(false); }} style={{
|
||||||
padding: "9px 12px", fontSize: 13, cursor: "pointer",
|
padding: "9px 12px", fontSize: 13, cursor: "pointer",
|
||||||
color: o === value ? TEAL_COLOR : T.text,
|
color: o === value ? T.accentTekst : T.text,
|
||||||
background: o === value ? TEAL_BG : "transparent",
|
background: o === value ? T.inputBg : "transparent",
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => e.currentTarget.style.background = "rgba(14,221,212,0.10)"}
|
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}
|
{o}
|
||||||
</div>
|
</div>
|
||||||
@@ -197,8 +193,8 @@ export default function GebruikersBeheer({ onClose }) {
|
|||||||
}}>
|
}}>
|
||||||
<div>
|
<div>
|
||||||
<span style={{ fontWeight: 700, fontSize: 15, color: T.text }}>Gebruikersbeheer</span>
|
<span style={{ fontWeight: 700, fontSize: 15, color: T.text }}>Gebruikersbeheer</span>
|
||||||
<span style={{ marginLeft: 10, fontSize: 11, fontWeight: 600, color: TEAL_COLOR,
|
<span style={{ marginLeft: 10, fontSize: 11, fontWeight: 600, color: T.accentTekst,
|
||||||
background: TEAL_BG, borderRadius: 99, padding: "2px 8px", border: `1px solid ${TEAL_BORDER}`,
|
background: T.inputBg, borderRadius: 99, padding: "2px 8px", border: `1px solid ${T.inputBorder}`,
|
||||||
}}>
|
}}>
|
||||||
{users.length} gebruiker{users.length !== 1 ? "s" : ""}
|
{users.length} gebruiker{users.length !== 1 ? "s" : ""}
|
||||||
</span>
|
</span>
|
||||||
@@ -236,18 +232,18 @@ export default function GebruikersBeheer({ onClose }) {
|
|||||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 32, height: 32, borderRadius: "50%",
|
width: 32, height: 32, borderRadius: "50%",
|
||||||
background: TEAL_BG, display: "flex",
|
background: T.inputBg, display: "flex",
|
||||||
alignItems: "center", justifyContent: "center",
|
alignItems: "center", justifyContent: "center",
|
||||||
fontSize: 12, fontWeight: 700, color: TEAL_COLOR, flexShrink: 0,
|
fontSize: 12, fontWeight: 700, color: T.accentTekst, flexShrink: 0,
|
||||||
}}>
|
}}>
|
||||||
{initials}
|
{initials}
|
||||||
</div>
|
</div>
|
||||||
<span style={{ fontWeight: 600 }}>{u.naam}</span>
|
<span style={{ fontWeight: 600 }}>{u.naam}</span>
|
||||||
{isSelf && (
|
{isSelf && (
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: 10, fontWeight: 700, color: TEAL_COLOR,
|
fontSize: 10, fontWeight: 700, color: T.accentTekst,
|
||||||
background: TEAL_BG, borderRadius: 99,
|
background: T.inputBg, borderRadius: 99,
|
||||||
padding: "1px 7px", border: `1px solid ${TEAL_BORDER}`,
|
padding: "1px 7px", border: `1px solid ${T.inputBorder}`,
|
||||||
}}>jij</span>
|
}}>jij</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -263,9 +259,9 @@ export default function GebruikersBeheer({ onClose }) {
|
|||||||
<td style={{ ...td, whiteSpace: "nowrap" }}>
|
<td style={{ ...td, whiteSpace: "nowrap" }}>
|
||||||
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
||||||
<button onClick={() => openEdit(u)} style={{
|
<button onClick={() => openEdit(u)} style={{
|
||||||
padding: "3px 8px", background: TEAL_BG,
|
padding: "3px 8px", background: T.inputBg,
|
||||||
border: `1px solid ${TEAL_BORDER}`, borderRadius: 6,
|
border: `1px solid ${T.inputBorder}`, borderRadius: 6,
|
||||||
color: TEAL_COLOR, cursor: "pointer", fontSize: 12,
|
color: T.accentTekst, cursor: "pointer", fontSize: 12,
|
||||||
}}>✏️</button>
|
}}>✏️</button>
|
||||||
{!isSelf && (
|
{!isSelf && (
|
||||||
<>
|
<>
|
||||||
@@ -303,10 +299,10 @@ export default function GebruikersBeheer({ onClose }) {
|
|||||||
{/* Bewerken modal */}
|
{/* Bewerken modal */}
|
||||||
{editUser && (
|
{editUser && (
|
||||||
<div style={{
|
<div style={{
|
||||||
background: darkMode ? "rgba(124,58,237,0.06)" : "#f5f0ff",
|
background: darkMode ? "rgba(124,58,237,0.06)" : T.inputBg,
|
||||||
border: `1px solid ${TEAL_BORDER}`, borderRadius: 10, padding: "16px", marginBottom: 16,
|
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
|
{editUser.naam} bewerken
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 10 }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 10 }}>
|
||||||
@@ -322,7 +318,7 @@ export default function GebruikersBeheer({ onClose }) {
|
|||||||
}}>Annuleren</button>
|
}}>Annuleren</button>
|
||||||
<button onClick={saveEdit} disabled={saving} style={{
|
<button onClick={saveEdit} disabled={saving} style={{
|
||||||
flex: 2, padding: "8px",
|
flex: 2, padding: "8px",
|
||||||
background: TEAL,
|
background: T.accentGradient,
|
||||||
border: "none", borderRadius: 8, color: "#fff",
|
border: "none", borderRadius: 8, color: "#fff",
|
||||||
fontWeight: 700, fontSize: 13, cursor: saving ? "not-allowed" : "pointer",
|
fontWeight: 700, fontSize: 13, cursor: saving ? "not-allowed" : "pointer",
|
||||||
}}>{saving ? "Opslaan..." : "Opslaan"}</button>
|
}}>{saving ? "Opslaan..." : "Opslaan"}</button>
|
||||||
@@ -333,7 +329,7 @@ export default function GebruikersBeheer({ onClose }) {
|
|||||||
{/* Nieuwe gebruiker */}
|
{/* Nieuwe gebruiker */}
|
||||||
{showAdd ? (
|
{showAdd ? (
|
||||||
<div style={{
|
<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",
|
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 }}>
|
<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>
|
}}>Annuleren</button>
|
||||||
<button onClick={addUser} disabled={saving} style={{
|
<button onClick={addUser} disabled={saving} style={{
|
||||||
flex: 2, padding: "8px",
|
flex: 2, padding: "8px",
|
||||||
background: TEAL,
|
background: T.accentGradient,
|
||||||
border: "none", borderRadius: 8, color: "#fff",
|
border: "none", borderRadius: 8, color: "#fff",
|
||||||
fontWeight: 700, fontSize: 13, cursor: saving ? "not-allowed" : "pointer",
|
fontWeight: 700, fontSize: 13, cursor: saving ? "not-allowed" : "pointer",
|
||||||
}}>
|
}}>
|
||||||
@@ -364,7 +360,7 @@ export default function GebruikersBeheer({ onClose }) {
|
|||||||
) : (
|
) : (
|
||||||
<button onClick={() => setShowAdd(true)} style={{
|
<button onClick={() => setShowAdd(true)} style={{
|
||||||
padding: "8px 18px",
|
padding: "8px 18px",
|
||||||
background: TEAL,
|
background: T.accentGradient,
|
||||||
border: "none", borderRadius: 8, color: "#fff",
|
border: "none", borderRadius: 8, color: "#fff",
|
||||||
fontWeight: 700, fontSize: 13, cursor: "pointer",
|
fontWeight: 700, fontSize: 13, cursor: "pointer",
|
||||||
}}>+ Nieuwe gebruiker</button>
|
}}>+ Nieuwe gebruiker</button>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useApp } from "../context/AppContext.jsx";
|
import { useApp } from "../context/AppContext.jsx";
|
||||||
|
import { THEMAS } from "../constants/themas.js";
|
||||||
const TEAL = "linear-gradient(135deg, #7c3aed, #a855f7)";
|
|
||||||
|
|
||||||
const BACKUP_ICON = (
|
const BACKUP_ICON = (
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||||
@@ -35,7 +34,7 @@ function ActionRow({ icon, title, description, onClick }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function InstellingenModal({ onClose }) {
|
export default function InstellingenModal({ onClose }) {
|
||||||
const { T, darkMode, setDarkMode, doBackup, doRestore } = useApp();
|
const { T, darkMode, setDarkMode, thema, setThema, doBackup, doRestore } = useApp();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -70,21 +69,63 @@ export default function InstellingenModal({ onClose }) {
|
|||||||
padding: "4px 0 16px",
|
padding: "4px 0 16px",
|
||||||
}}>
|
}}>
|
||||||
<span style={{ fontWeight: 600, fontSize: 14 }}>Dark mode</span>
|
<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)}
|
onClick={() => setDarkMode((d) => !d)}
|
||||||
style={{
|
style={{
|
||||||
width: 44, height: 24, borderRadius: 12, cursor: "pointer",
|
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,
|
position: "relative", transition: "background 0.2s", flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{
|
<span style={{
|
||||||
position: "absolute", top: 3, borderRadius: "50%",
|
position: "absolute", top: 3, borderRadius: "50%",
|
||||||
width: 18, height: 18, background: "#fff",
|
width: 18, height: 18, background: "#fff",
|
||||||
left: darkMode ? 23 : 3,
|
left: darkMode ? 23 : 3,
|
||||||
transition: "left 0.2s",
|
transition: "left 0.2s",
|
||||||
boxShadow: "0 1px 4px rgba(0,0,0,0.3)",
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { TABS } from "../constants/index.js";
|
import { TABS, RED } from "../constants/index.js";
|
||||||
import { version } from "../version.js";
|
import { version } from "../version.js";
|
||||||
import { useUpdateCheck } from "../hooks/useUpdateCheck.js";
|
import { useUpdateCheck } from "../hooks/useUpdateCheck.js";
|
||||||
import { useApp } from "../context/AppContext.jsx";
|
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 }) {
|
function NavItem({ active, onClick, icon, label, T, danger }) {
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
const isLit = active || hovered;
|
const isLit = active || hovered;
|
||||||
@@ -98,12 +95,12 @@ function NavItem({ active, onClick, icon, label, T, danger }) {
|
|||||||
display: "flex", alignItems: "center", gap: 10,
|
display: "flex", alignItems: "center", gap: 10,
|
||||||
padding: "11px 12px", borderRadius: 9, cursor: "pointer", marginBottom: 4,
|
padding: "11px 12px", borderRadius: 9, cursor: "pointer", marginBottom: 4,
|
||||||
background: active
|
background: active
|
||||||
? ACTIVE_BG
|
? T.accent
|
||||||
: hovered ? "rgba(139,92,246,0.08)" : "transparent",
|
: hovered ? T.inputBg : "transparent",
|
||||||
color: active ? "#fff" : hovered ? T.text : (danger ? "#ef4444" : T.muted),
|
color: active ? "#fff" : hovered ? T.text : (danger ? RED : T.muted),
|
||||||
fontWeight: active ? 600 : 400,
|
fontWeight: active ? 600 : 400,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
boxShadow: active ? ACTIVE_SHADOW : "none",
|
boxShadow: active ? T.cardShadow : "none",
|
||||||
transition: "all 0.15s",
|
transition: "all 0.15s",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -141,12 +138,12 @@ function OverPopup({ onClose, nieuweVersie, T }) {
|
|||||||
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 20 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 20 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 44, height: 44, borderRadius: 12, flexShrink: 0,
|
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",
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
}}>
|
}}>
|
||||||
<svg width="22" height="22" viewBox="0 0 20 20" fill="none">
|
<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"/>
|
<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="rgba(168,85,247,0.35)" strokeWidth="1.4"/>
|
<line x1="2" y1="17" x2="18" y2="17" stroke={T.accentTekst} strokeOpacity="0.35" strokeWidth="1.4"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -158,7 +155,7 @@ function OverPopup({ onClose, nieuweVersie, T }) {
|
|||||||
|
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
|
||||||
<span style={{ fontSize: 13, color: T.muted }}>Versie</span>
|
<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}
|
v{version}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -198,7 +195,7 @@ function DarkModeToggle({ darkMode, setDarkMode, T }) {
|
|||||||
{/* Toggle pill */}
|
{/* Toggle pill */}
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 36, height: 20, borderRadius: 99, flexShrink: 0,
|
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",
|
position: "relative", transition: "background 0.2s",
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -231,16 +228,16 @@ export default function NavBar() {
|
|||||||
<div style={{ padding: "22px 16px 28px", display: "flex", alignItems: "center", gap: 10 }}>
|
<div style={{ padding: "22px 16px 28px", display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 32, height: 32, flexShrink: 0, borderRadius: 9,
|
width: 32, height: 32, flexShrink: 0, borderRadius: 9,
|
||||||
background: "rgba(124,58,237,0.15)",
|
background: T.inputBg,
|
||||||
border: "1px solid rgba(124,58,237,0.3)",
|
border: `1px solid ${T.inputBorder}`,
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
}}>
|
}}>
|
||||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none">
|
<svg width="18" height="18" viewBox="0 0 20 20" fill="none">
|
||||||
<polyline points="2,15 6,9 10,12 14,5 18,8"
|
<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"/>
|
strokeLinejoin="round" strokeLinecap="round"/>
|
||||||
<line x1="2" y1="17" x2="18" y2="17"
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span style={{ fontSize: 17, fontWeight: 700, color: T.text, letterSpacing: "-0.3px" }}>VibeFinance</span>
|
<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 { useApp } from "../context/AppContext.jsx";
|
||||||
import { RED } from "../constants/index.js";
|
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 = "") {
|
function initials(naam = "") {
|
||||||
const parts = naam.trim().split(/\s+/);
|
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={{ display: "flex", flexDirection: "column", alignItems: "center", padding: "24px 18px 20px", gap: 6 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 72, height: 72, borderRadius: "50%", overflow: "hidden",
|
width: 72, height: 72, borderRadius: "50%", overflow: "hidden",
|
||||||
background: avatar ? "transparent" : TEAL,
|
background: avatar ? "transparent" : T.accentGradient,
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
fontSize: 24, fontWeight: 800, color: "#fff",
|
fontSize: 24, fontWeight: 800, color: "#fff",
|
||||||
boxShadow: "0 0 20px rgba(124,58,237,0.4)", marginBottom: 4,
|
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>
|
<div style={{ fontSize: 13, color: T.muted }}>{currentUser?.email}</div>
|
||||||
<button onClick={() => setView("profiel")} style={{
|
<button onClick={() => setView("profiel")} style={{
|
||||||
marginTop: 8, padding: "9px 24px",
|
marginTop: 8, padding: "9px 24px",
|
||||||
background: TEAL,
|
background: T.accentGradient,
|
||||||
border: "none", borderRadius: 10, color: "#fff",
|
border: "none", borderRadius: 10, color: "#fff",
|
||||||
fontWeight: 700, fontSize: 13, cursor: "pointer",
|
fontWeight: 700, fontSize: 13, cursor: "pointer",
|
||||||
boxShadow: TEAL_SHADOW,
|
boxShadow: T.cardShadow,
|
||||||
}}>Profiel bewerken</button>
|
}}>Profiel bewerken</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -189,7 +187,7 @@ export default function ProfielPopup({ onClose }) {
|
|||||||
<label style={{ cursor: "pointer", position: "relative", display: "block" }} title="Klik om foto te uploaden">
|
<label style={{ cursor: "pointer", position: "relative", display: "block" }} title="Klik om foto te uploaden">
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 64, height: 64, borderRadius: "50%", overflow: "hidden",
|
width: 64, height: 64, borderRadius: "50%", overflow: "hidden",
|
||||||
background: avatar ? "transparent" : TEAL,
|
background: avatar ? "transparent" : T.accentGradient,
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
fontSize: 20, fontWeight: 800, color: "#fff",
|
fontSize: 20, fontWeight: 800, color: "#fff",
|
||||||
}}>
|
}}>
|
||||||
@@ -229,7 +227,7 @@ export default function ProfielPopup({ onClose }) {
|
|||||||
{profielErr && <div style={{ fontSize: 12, color: RED }}>{profielErr}</div>}
|
{profielErr && <div style={{ fontSize: 12, color: RED }}>{profielErr}</div>}
|
||||||
{profielOk && <div style={{ fontSize: 12, color: "#10b981" }}>Profiel opgeslagen.</div>}
|
{profielOk && <div style={{ fontSize: 12, color: "#10b981" }}>Profiel opgeslagen.</div>}
|
||||||
<button onClick={saveProfiel} disabled={profielBusy} style={{
|
<button onClick={saveProfiel} disabled={profielBusy} style={{
|
||||||
padding: "11px", background: TEAL,
|
padding: "11px", background: T.accentGradient,
|
||||||
border: "none", borderRadius: 10, color: "#fff", fontWeight: 700,
|
border: "none", borderRadius: 10, color: "#fff", fontWeight: 700,
|
||||||
fontSize: 13, cursor: profielBusy ? "default" : "pointer", opacity: profielBusy ? 0.7 : 1,
|
fontSize: 13, cursor: profielBusy ? "default" : "pointer", opacity: profielBusy ? 0.7 : 1,
|
||||||
}}>{profielBusy ? "Opslaan…" : "Opslaan"}</button>
|
}}>{profielBusy ? "Opslaan…" : "Opslaan"}</button>
|
||||||
@@ -252,7 +250,7 @@ export default function ProfielPopup({ onClose }) {
|
|||||||
{pwErr && <div style={{ fontSize: 12, color: RED }}>{pwErr}</div>}
|
{pwErr && <div style={{ fontSize: 12, color: RED }}>{pwErr}</div>}
|
||||||
{pwOk && <div style={{ fontSize: 12, color: "#10b981" }}>Wachtwoord gewijzigd.</div>}
|
{pwOk && <div style={{ fontSize: 12, color: "#10b981" }}>Wachtwoord gewijzigd.</div>}
|
||||||
<button onClick={saveWachtwoord} disabled={pwBusy} style={{
|
<button onClick={saveWachtwoord} disabled={pwBusy} style={{
|
||||||
padding: "11px", background: TEAL,
|
padding: "11px", background: T.accentGradient,
|
||||||
border: "none", borderRadius: 10, color: "#fff", fontWeight: 700,
|
border: "none", borderRadius: 10, color: "#fff", fontWeight: 700,
|
||||||
fontSize: 13, cursor: pwBusy ? "default" : "pointer", opacity: pwBusy ? 0.7 : 1,
|
fontSize: 13, cursor: pwBusy ? "default" : "pointer", opacity: pwBusy ? 0.7 : 1,
|
||||||
}}>{pwBusy ? "Opslaan…" : "Opslaan"}</button>
|
}}>{pwBusy ? "Opslaan…" : "Opslaan"}</button>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useRef, useState, useCallback, useContext, createContext } from "react";
|
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";
|
import { useApp } from "../../context/AppContext.jsx";
|
||||||
|
|
||||||
const DragContext = createContext(null);
|
const DragContext = createContext(null);
|
||||||
@@ -18,11 +18,13 @@ export function VCard({ children, style = {} }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── VBadge ────────────────────────────────────────────────────────────────────
|
// ── VBadge ────────────────────────────────────────────────────────────────────
|
||||||
export function VBadge({ label, color = PURPLE }) {
|
export function VBadge({ label, color }) {
|
||||||
|
const { T } = useApp();
|
||||||
|
const kleur = color ?? T.accentTekst;
|
||||||
return (
|
return (
|
||||||
<span style={{
|
<span style={{
|
||||||
background: `${color}22`, color,
|
background: `${kleur}22`, color: kleur,
|
||||||
border: `1px solid ${color}44`,
|
border: `1px solid ${kleur}44`,
|
||||||
borderRadius: 99, padding: "2px 10px",
|
borderRadius: 99, padding: "2px 10px",
|
||||||
fontSize: 11, fontWeight: 700,
|
fontSize: 11, fontWeight: 700,
|
||||||
}}>
|
}}>
|
||||||
@@ -32,16 +34,17 @@ export function VBadge({ label, color = PURPLE }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── TabBtn ────────────────────────────────────────────────────────────────────
|
// ── TabBtn ────────────────────────────────────────────────────────────────────
|
||||||
export function TabBtn({ active, onClick, children, darkMode = true }) {
|
export function TabBtn({ active, onClick, children }) {
|
||||||
|
const { T } = useApp();
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
style={{
|
style={{
|
||||||
padding: "8px 16px", border: "none", borderRadius: 8, cursor: "pointer",
|
padding: "8px 16px", border: "none", borderRadius: 8, cursor: "pointer",
|
||||||
fontSize: 13, fontWeight: active ? 700 : 500,
|
fontSize: 13, fontWeight: active ? 700 : 500,
|
||||||
background: active ? `${PURPLE}22` : "transparent",
|
background: active ? `${T.accent}22` : "transparent",
|
||||||
color: active ? PURPLE_LIGHT : (darkMode ? "#94a3b8" : "#64748b"),
|
color: active ? T.accentTekst : T.muted,
|
||||||
borderBottom: active ? `2px solid ${PURPLE_LIGHT}` : "2px solid transparent",
|
borderBottom: active ? `2px solid ${T.accentTekst}` : "2px solid transparent",
|
||||||
transition: "all 0.15s",
|
transition: "all 0.15s",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -51,12 +54,14 @@ export function TabBtn({ active, onClick, children, darkMode = true }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── VProgressBar ──────────────────────────────────────────────────────────────
|
// ── 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);
|
const p = Math.min((value / (max || 1)) * 100, 100);
|
||||||
return (
|
return (
|
||||||
<div style={{ height: 8, background: "rgba(255,255,255,0.07)", borderRadius: 99, overflow: "hidden" }}>
|
<div style={{ height: 8, background: "rgba(255,255,255,0.07)", borderRadius: 99, overflow: "hidden" }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
height: "100%", width: `${p}%`, background: color,
|
height: "100%", width: `${p}%`, background: kleur,
|
||||||
borderRadius: 99, transition: "width 0.5s ease",
|
borderRadius: 99, transition: "width 0.5s ease",
|
||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
@@ -527,7 +532,7 @@ export function EmptyState({ icon, text, hint, onClick, locked, T }) {
|
|||||||
<div style={{ fontSize: 32, marginBottom: 10 }}>{icon}</div>
|
<div style={{ fontSize: 32, marginBottom: 10 }}>{icon}</div>
|
||||||
<div style={{ fontSize: 13, marginBottom: 4 }}>{text}</div>
|
<div style={{ fontSize: 13, marginBottom: 4 }}>{text}</div>
|
||||||
{!locked && hint && (
|
{!locked && hint && (
|
||||||
<div style={{ fontSize: 12, color: PURPLE_LIGHT }}>{hint}</div>
|
<div style={{ fontSize: 12, color: T.accentTekst }}>{hint}</div>
|
||||||
)}
|
)}
|
||||||
</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,8 @@
|
|||||||
import { createContext, useContext, useState, useMemo, useRef } from "react";
|
import { createContext, useContext, useState, useMemo, useRef } from "react";
|
||||||
import JSZip from "jszip";
|
import JSZip from "jszip";
|
||||||
import { INITIAL_DATA } from "../constants/index.js";
|
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 { useTheme } from "../hooks/useTheme.js";
|
import { useTheme } from "../hooks/useTheme.js";
|
||||||
|
|
||||||
const AppContext = createContext(null);
|
const AppContext = createContext(null);
|
||||||
@@ -34,7 +35,22 @@ export function AppProvider({ children }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── UI ────────────────────────────────────────────────────────
|
// ── 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 [locked, setLocked] = useState(false);
|
||||||
const [tab, setTab] = useState(0);
|
const [tab, setTab] = useState(0);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
@@ -87,7 +103,7 @@ export function AppProvider({ children }) {
|
|||||||
const setUsers = (u) => { localStorage.setItem("wbjw_users", JSON.stringify(u)); setUsersRaw(u); };
|
const setUsers = (u) => { localStorage.setItem("wbjw_users", JSON.stringify(u)); setUsersRaw(u); };
|
||||||
|
|
||||||
// ── Thema ─────────────────────────────────────────────────────
|
// ── Thema ─────────────────────────────────────────────────────
|
||||||
const T = useTheme(darkMode);
|
const T = useTheme(thema, darkMode);
|
||||||
|
|
||||||
// ── Afgeleide waarden ─────────────────────────────────────────
|
// ── Afgeleide waarden ─────────────────────────────────────────
|
||||||
const ev = data.eigenVermogen;
|
const ev = data.eigenVermogen;
|
||||||
@@ -199,6 +215,7 @@ export function AppProvider({ children }) {
|
|||||||
loggedIn, currentUser, login, logout, loading, updateProfile, changePassword,
|
loggedIn, currentUser, login, logout, loading, updateProfile, changePassword,
|
||||||
avatar, setAvatar,
|
avatar, setAvatar,
|
||||||
darkMode, setDarkMode,
|
darkMode, setDarkMode,
|
||||||
|
thema, setThema,
|
||||||
locked, setLocked,
|
locked, setLocked,
|
||||||
tab, setTab,
|
tab, setTab,
|
||||||
menuOpen, setMenuOpen,
|
menuOpen, setMenuOpen,
|
||||||
|
|||||||
@@ -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.
|
* Geeft een thema-object (T) terug op basis van het gekozen kleurthema en
|
||||||
* Wordt gebruikt door alle componenten om consistent kleuren te krijgen.
|
* 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) {
|
export function useTheme(themaId, darkMode) {
|
||||||
const bg = darkMode ? "#241A39" : "#eef1f8";
|
const thema = zoekThema(themaId);
|
||||||
const card = darkMode ? "rgba(22,23,27,0.30)" : "#ffffff";
|
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 {
|
return {
|
||||||
// Exacte Finex kleuren (dark) / licht alternatief
|
bg: p.bg,
|
||||||
bg,
|
card: p.card,
|
||||||
card,
|
|
||||||
// Ondoorzichtige achtergrond voor popups/modals: kaartkleur over de pagina-bg.
|
// Ondoorzichtige achtergrond voor popups/modals: kaartkleur over de pagina-bg.
|
||||||
popupBg: `linear-gradient(${card}, ${card}), ${bg}`,
|
popupBg: `linear-gradient(${p.card}, ${p.card}), ${p.bg}`,
|
||||||
sidebar: darkMode ? "#16171B" : "#f8faff",
|
sidebar: p.diep,
|
||||||
border: darkMode ? "rgba(139,92,246,0.15)" : "rgba(0,0,0,0.07)",
|
border: darkMode ? metAlpha(thema.licht, 0.15) : "rgba(0,0,0,0.07)",
|
||||||
muted: darkMode ? "#8b8fa8" : "#94a3b8",
|
muted: p.muted,
|
||||||
text: darkMode ? "#ffffff" : "#0f172a",
|
text: p.tekst,
|
||||||
subtext: darkMode ? "#a0aec0" : "#64748b",
|
subtext: p.sub,
|
||||||
inputBg: darkMode ? "rgba(139,92,246,0.08)" : "#f1f5ff",
|
inputBg: darkMode ? metAlpha(thema.licht, 0.08) : metAlpha(thema.hoofd, 0.05),
|
||||||
inputBorder:darkMode ? "rgba(139,92,246,0.2)" : "#e2e8f0",
|
inputBorder: darkMode ? metAlpha(thema.licht, 0.2) : "#e2e8f0",
|
||||||
contentBg: darkMode ? "#241A39" : "#eef1f8",
|
contentBg: p.bg,
|
||||||
shadow: darkMode ? "0 4px 24px rgba(124,58,237,0.25)" : "0 4px 20px rgba(0,0,0,0.07)",
|
shadow: darkMode ? `0 4px 24px ${metAlpha(thema.hoofd, 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)",
|
cardShadow: darkMode ? `0 4px 20px ${metAlpha(thema.hoofd, 0.2)}` : "0 2px 8px rgba(0,0,0,0.06)",
|
||||||
radius: "14px",
|
radius: "14px",
|
||||||
font: "'Poppins', system-ui, sans-serif",
|
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 { useState } from "react";
|
||||||
import { useApp } from "../context/AppContext.jsx";
|
import { useApp } from "../context/AppContext.jsx";
|
||||||
import { fmt, pct, fmtDatum } from "../utils/format.js";
|
import { fmt, pct, fmtDatum, fmtMaand } from "../utils/format.js";
|
||||||
|
import { crPath } from "../utils/grafiek.js";
|
||||||
import { GREEN, RED, GOLD } from "../constants/index.js";
|
import { GREEN, RED, GOLD } from "../constants/index.js";
|
||||||
|
|
||||||
const TEAL = "#a855f7";
|
|
||||||
import { VCard, VBadge } from "../components/ui/index.jsx";
|
import { VCard, VBadge } from "../components/ui/index.jsx";
|
||||||
|
|
||||||
export default function DashboardTab() {
|
export default function DashboardTab() {
|
||||||
@@ -27,9 +28,9 @@ export default function DashboardTab() {
|
|||||||
const progressPct = Math.min(totEV / doel, 1);
|
const progressPct = Math.min(totEV / doel, 1);
|
||||||
const milestones = [0, 0.25, 0.5, 0.75, 1].map((f) => f * doel);
|
const milestones = [0, 0.25, 0.5, 0.75, 1].map((f) => f * doel);
|
||||||
const kpis = [
|
const kpis = [
|
||||||
{ label: "Start eigen vermogen", val: fmt(startEV), accent: TEAL},
|
{ label: "Start eigen vermogen", val: fmt(startEV), accent: T.accentTekst},
|
||||||
{ label: "Extra geïnvesteerde fiat", val: fmt(extraFiat), accent: TEAL},
|
{ label: "Extra geïnvesteerde fiat", val: fmt(extraFiat), accent: T.accentTekst},
|
||||||
{ label: "Totaal geïnvesteerde fiat", val: fmt(totaalFiat), accent: TEAL},
|
{ label: "Totaal geïnvesteerde fiat", val: fmt(totaalFiat), accent: T.accentTekst},
|
||||||
{ label: "Totaal eigen vermogen", val: fmt(totEV), accent: GREEN },
|
{ label: "Totaal eigen vermogen", val: fmt(totEV), accent: GREEN },
|
||||||
{ label: "Winst op investering", val: fmt(winst), accent: winst >= 0 ? GREEN : RED },
|
{ 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 },
|
{ label: "ROI %", val: totaalFiat > 0 ? pct(roi, 2) : "—", accent: roi >= 0 ? GREEN : RED },
|
||||||
@@ -91,19 +92,19 @@ export default function DashboardTab() {
|
|||||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
<span style={{ fontSize: 16 }}>🎯</span>
|
<span style={{ fontSize: 16 }}>🎯</span>
|
||||||
<span style={{ fontWeight: 600 }}>Doel & Bijbehorende Progressie</span>
|
<span style={{ fontWeight: 600 }}>Doel & Bijbehorende Progressie</span>
|
||||||
<VBadge label={fmt(doel)} color={TEAL} />
|
<VBadge label={fmt(doel)} color={T.accentTekst} />
|
||||||
</div>
|
</div>
|
||||||
<span style={{ color: T.muted, fontSize: 13 }}>{pct(progressPct)} behaald</span>
|
<span style={{ color: T.muted, fontSize: 13 }}>{pct(progressPct)} behaald</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div style={{
|
||||||
height: 14, background: "rgba(255,255,255,0.07)",
|
height: 14, background: T.inputBg,
|
||||||
borderRadius: 99, overflow: "hidden",
|
borderRadius: 99, overflow: "hidden",
|
||||||
position: "relative", marginBottom: 8,
|
position: "relative", marginBottom: 8,
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
<div style={{
|
||||||
position: "absolute", inset: 0,
|
position: "absolute", inset: 0,
|
||||||
width: `${progressPct * 100}%`,
|
width: `${progressPct * 100}%`,
|
||||||
background: "linear-gradient(90deg, #7c3aed, #a855f7)",
|
background: `linear-gradient(90deg, ${T.accent}, ${T.accentLicht})`,
|
||||||
borderRadius: 99, transition: "width 0.7s ease",
|
borderRadius: 99, transition: "width 0.7s ease",
|
||||||
}} />
|
}} />
|
||||||
{[0.25, 0.5, 0.75].map((f) => (
|
{[0.25, 0.5, 0.75].map((f) => (
|
||||||
@@ -130,7 +131,7 @@ export default function DashboardTab() {
|
|||||||
<div style={{ fontWeight: 600 }}>Ontwikkeling van Eigen Vermogen</div>
|
<div style={{ fontWeight: 600 }}>Ontwikkeling van Eigen Vermogen</div>
|
||||||
{vData.length >= 2 && (
|
{vData.length >= 2 && (
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 14, fontSize: 11, color: T.muted }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 14, fontSize: 11, color: T.muted }}>
|
||||||
<Legenda kleur="linear-gradient(90deg, #0eddd4, #8b5cf6)" label="Eigen vermogen" />
|
<Legenda kleur={`linear-gradient(90deg, ${T.chartVan}, ${T.chartTot})`} label="Eigen vermogen" />
|
||||||
<Legenda
|
<Legenda
|
||||||
kleur={`repeating-linear-gradient(90deg, ${GOLD} 0 5px, transparent 5px 9px)`}
|
kleur={`repeating-linear-gradient(90deg, ${GOLD} 0 5px, transparent 5px 9px)`}
|
||||||
label="Geïnvesteerde fiat"
|
label="Geïnvesteerde fiat"
|
||||||
@@ -146,7 +147,7 @@ export default function DashboardTab() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<MiniLineChart vData={vData} fiatData={fiatData} darkMode={darkMode} />
|
<MiniLineChart vData={vData} fiatData={fiatData} darkMode={darkMode} T={T} />
|
||||||
)}
|
)}
|
||||||
</VCard>
|
</VCard>
|
||||||
|
|
||||||
@@ -164,28 +165,12 @@ function Legenda({ kleur, label }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Mini inline SVG grafiek ────────────────────────────────────────────────────
|
// ── Mini inline SVG grafiek ────────────────────────────────────────────────────
|
||||||
function MiniLineChart({ vData, fiatData, darkMode }) {
|
function MiniLineChart({ vData, fiatData, darkMode, T }) {
|
||||||
const [hovered, setHovered] = useState(null);
|
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 vals = vData.map((d) => d.nettoWaarde || 0);
|
||||||
const fVals = fiatData;
|
const fVals = fiatData;
|
||||||
// Beide reeksen staan in euro's op dezelfde as, dus het bereik moet ze allebei
|
// Beide reeksen staan in euro's op dezelfde as, dus het bereik moet ze allebei
|
||||||
@@ -194,6 +179,9 @@ function MiniLineChart({ vData, fiatData, darkMode }) {
|
|||||||
const mx = Math.max(...vals, ...fVals) * 1.05;
|
const mx = Math.max(...vals, ...fVals) * 1.05;
|
||||||
const rng = mx - mn || 1;
|
const rng = mx - mn || 1;
|
||||||
const iW = W - pl - pr, iH = H - pt - pb;
|
const iW = W - pl - pr, iH = H - pt - pb;
|
||||||
|
// Eén label "sep '26" is bij deze lettergrootte ongeveer 46px breed. Zoveel
|
||||||
|
// passen er dus naast elkaar; de rest wordt overgeslagen.
|
||||||
|
const labelStap = Math.max(1, Math.ceil(vData.length / Math.max(1, Math.floor(iW / 46))));
|
||||||
const xs = vData.map((_, i) => pl + (i / Math.max(vData.length - 1, 1)) * iW);
|
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 ys = vals.map((v) => pt + (1 - (v - mn) / rng) * iH);
|
||||||
const fYs = fVals.map((v) => pt + (1 - (v - mn) / rng) * iH);
|
const fYs = fVals.map((v) => pt + (1 - (v - mn) / rng) * iH);
|
||||||
@@ -203,7 +191,7 @@ function MiniLineChart({ vData, fiatData, darkMode }) {
|
|||||||
// afronding zou tussen twee stortingen een dip tekenen die er niet is.
|
// 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 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 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 handleMouseMove = (e) => {
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
@@ -214,11 +202,17 @@ function MiniLineChart({ vData, fiatData, darkMode }) {
|
|||||||
|
|
||||||
const h = hovered;
|
const h = hovered;
|
||||||
const tipW = 150, tipH = 76;
|
const tipW = 150, tipH = 76;
|
||||||
const tipX = h !== null ? Math.min(Math.max(xs[h] - tipW / 2, pl), W - tipW - 4) : 0;
|
// Náást de hoverlijn, niet erboven of eronder. Zodra de twee punten ver uit
|
||||||
// Ankeren op de hoogste van de twee punten, zodat de tooltip nooit over de
|
// elkaar liggen is er verticaal geen ruimte voor een kaart van 76px: dan
|
||||||
// andere lijn heen valt.
|
// klapte hij naar onderen tot buiten de tekening, over de datumlabels heen en
|
||||||
const tipAnker = h !== null ? Math.min(ys[h], fYs[h]) : 0;
|
// half afgekapt. Zijwaarts past hij altijd; de klemmen houden hem binnen het
|
||||||
const tipY = h !== null ? (tipAnker - tipH - 14 < pt ? Math.max(ys[h], fYs[h]) + 14 : tipAnker - tipH - 14) : 0;
|
// 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;
|
const resultaat = h !== null ? vals[h] - fVals[h] : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -230,8 +224,8 @@ function MiniLineChart({ vData, fiatData, darkMode }) {
|
|||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="chartLineGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
<linearGradient id="chartLineGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
<stop offset="0%" stopColor="#0eddd4" />
|
<stop offset="0%" stopColor={T.chartVan} />
|
||||||
<stop offset="100%" stopColor="#8b5cf6" />
|
<stop offset="100%" stopColor={T.chartTot} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="chartFadeMask" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="chartFadeMask" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="0%" stopColor="white" stopOpacity="1" />
|
<stop offset="0%" stopColor="white" stopOpacity="1" />
|
||||||
@@ -271,16 +265,16 @@ function MiniLineChart({ vData, fiatData, darkMode }) {
|
|||||||
{/* Verticale hover-lijn */}
|
{/* Verticale hover-lijn */}
|
||||||
{h !== null && (
|
{h !== null && (
|
||||||
<line x1={xs[h]} y1={pt} x2={xs[h]} y2={pt + iH}
|
<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 punten */}
|
{/* Hover punten */}
|
||||||
{h !== null && (
|
{h !== null && (
|
||||||
<>
|
<>
|
||||||
<circle cx={xs[h]} cy={fYs[h]} r="4.5"
|
<circle cx={xs[h]} cy={fYs[h]} r="4.5"
|
||||||
fill={GOLD} stroke={darkMode ? "#151c2e" : "#fff"} strokeWidth="2.5" />
|
fill={GOLD} stroke={T.card} strokeWidth="2.5" />
|
||||||
<circle cx={xs[h]} cy={ys[h]} r="5.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" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -288,13 +282,13 @@ function MiniLineChart({ vData, fiatData, darkMode }) {
|
|||||||
{h !== null && (
|
{h !== null && (
|
||||||
<g>
|
<g>
|
||||||
<rect x={tipX} y={tipY} width={tipW} height={tipH} rx="7"
|
<rect x={tipX} y={tipY} width={tipW} height={tipH} rx="7"
|
||||||
fill={darkMode ? "#1a2035" : "#fff"}
|
fill={T.card}
|
||||||
stroke="rgba(14,221,212,0.35)" strokeWidth="1" />
|
stroke={T.border} strokeWidth="1" />
|
||||||
<text x={tipX + tipW / 2} y={tipY + 16} fill={lc} fontSize="9" textAnchor="middle">
|
<text x={tipX + tipW / 2} y={tipY + 16} fill={lc} fontSize="9" textAnchor="middle">
|
||||||
{fmtDatum(vData[h].datum)}
|
{fmtDatum(vData[h].datum)}
|
||||||
</text>
|
</text>
|
||||||
<text x={tipX + 10} y={tipY + 33} fill={lc} fontSize="9">Vermogen</text>
|
<text x={tipX + 10} y={tipY + 33} fill={lc} fontSize="9">Vermogen</text>
|
||||||
<text x={tipX + tipW - 10} y={tipY + 33} fill="#0eddd4" fontSize="10"
|
<text x={tipX + tipW - 10} y={tipY + 33} fill={T.chartVan} fontSize="10"
|
||||||
fontWeight="700" textAnchor="end">
|
fontWeight="700" textAnchor="end">
|
||||||
{fmt(vals[h])}
|
{fmt(vals[h])}
|
||||||
</text>
|
</text>
|
||||||
@@ -314,13 +308,26 @@ function MiniLineChart({ vData, fiatData, darkMode }) {
|
|||||||
</g>
|
</g>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* X-as labels */}
|
{/* X-as: korte maandlabels, rechtop. Elk punt een volledige datum geven
|
||||||
{xs.map((x, i) => (
|
liep bij een lange reeks over in een onleesbare band — tien tekens
|
||||||
<text key={i} x={x} y={H - pb + 16} fill={lc} fontSize="8"
|
gedraaid kost ~45px en er passen er maar een stuk of veertien. De
|
||||||
textAnchor="end" transform={`rotate(-45, ${x}, ${H - pb + 16})`}>
|
grafiek rekent nu uit hoeveel er passen en toont elke n-de; het
|
||||||
{fmtDatum(vData[i].datum)}
|
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;
|
||||||
|
if (i % labelStap !== 0 && !laatste) 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>
|
</text>
|
||||||
))}
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState } from "react";
|
|||||||
import { useApp } from "../context/AppContext.jsx";
|
import { useApp } from "../context/AppContext.jsx";
|
||||||
import { fmt, fmtDatum } from "../utils/format.js";
|
import { fmt, fmtDatum } from "../utils/format.js";
|
||||||
import { useKolomBreedtes } from "../hooks/useKolomBreedtes.js";
|
import { useKolomBreedtes } from "../hooks/useKolomBreedtes.js";
|
||||||
import { PURPLE, PURPLE_LIGHT, GREEN, RED, CAT_COLORS } from "../constants/index.js";
|
import { GREEN, RED, CAT_COLORS } from "../constants/index.js";
|
||||||
import {
|
import {
|
||||||
VCard, DonutChart, Overlay, ModalBox, ModalHeader, ModalFooter,
|
VCard, DonutChart, Overlay, ModalBox, ModalHeader, ModalFooter,
|
||||||
EmojiPicker, AppIcon, ConfirmDeleteDialog, EmptyState,
|
EmojiPicker, AppIcon, ConfirmDeleteDialog, EmptyState,
|
||||||
@@ -130,14 +130,14 @@ function AanpassenPopup({ cat, onClose }) {
|
|||||||
<div style={{
|
<div style={{
|
||||||
display: "flex", alignItems: "center", gap: 8,
|
display: "flex", alignItems: "center", gap: 8,
|
||||||
background: darkMode ? "rgba(255,255,255,0.03)" : "#f8fafc",
|
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",
|
borderRadius: 10, padding: "10px 12px",
|
||||||
}}>
|
}}>
|
||||||
<input value={nieuwNaam}
|
<input value={nieuwNaam}
|
||||||
onChange={(e) => { setNieuwNaam(e.target.value); setErr(""); }}
|
onChange={(e) => { setNieuwNaam(e.target.value); setErr(""); }}
|
||||||
placeholder="Naam" style={{ ...is, flex: 1 }} />
|
placeholder="Naam" style={{ ...is, flex: 1 }} />
|
||||||
<EuroInput value={nieuwVal} onChange={setNieuwVal} is={is} T={T} />
|
<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>
|
</div>
|
||||||
{err && <ErrMsg msg={err} />}
|
{err && <ErrMsg msg={err} />}
|
||||||
</div>
|
</div>
|
||||||
@@ -275,12 +275,12 @@ function EVVoortgangTabel() {
|
|||||||
<Overlay onClose={closeModal}>
|
<Overlay onClose={closeModal}>
|
||||||
<ModalBox maxWidth={560}>
|
<ModalBox maxWidth={560}>
|
||||||
<ModalHeader
|
<ModalHeader
|
||||||
icon="📊" iconBg={PURPLE}
|
icon="📊" iconBg={T.accent}
|
||||||
title={editDatum ? "Rij bewerken" : "Nieuw geïnvesteerde fiat"}
|
title={editDatum ? "Rij bewerken" : "Nieuw geïnvesteerde fiat"}
|
||||||
subtitle="Vul de waarden in voor dit moment"
|
subtitle="Vul de waarden in voor dit moment"
|
||||||
subtitleColor={T.muted}
|
subtitleColor={T.muted}
|
||||||
onClose={closeModal}
|
onClose={closeModal}
|
||||||
headerBg={darkMode ? `${PURPLE}08` : "#f8f5ff"}
|
headerBg={darkMode ? `${T.accent}08` : T.inputBg}
|
||||||
T={T}
|
T={T}
|
||||||
/>
|
/>
|
||||||
<div style={{ padding: "20px 22px", overflowY: "auto", maxHeight: "70vh" }}>
|
<div style={{ padding: "20px 22px", overflowY: "auto", maxHeight: "70vh" }}>
|
||||||
@@ -325,7 +325,7 @@ function EVVoortgangTabel() {
|
|||||||
</div>
|
</div>
|
||||||
<ModalFooter T={T}>
|
<ModalFooter T={T}>
|
||||||
<button onClick={closeModal} style={cancelBtn(T)}>Annuleren</button>
|
<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>
|
</ModalFooter>
|
||||||
</ModalBox>
|
</ModalBox>
|
||||||
</Overlay>
|
</Overlay>
|
||||||
@@ -334,7 +334,7 @@ function EVVoortgangTabel() {
|
|||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}>
|
||||||
<div style={{ fontWeight: 700, fontSize: 14, color: T.text }}>Geïnvesteerde Fiat</div>
|
<div style={{ fontWeight: 700, fontSize: 14, color: T.text }}>Geïnvesteerde Fiat</div>
|
||||||
{!locked && (
|
{!locked && (
|
||||||
<button onClick={openModal} style={primaryBtn}>+ Toevoegen</button>
|
<button onClick={openModal} style={primaryBtn(T)}>+ Toevoegen</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -349,7 +349,9 @@ function EVVoortgangTabel() {
|
|||||||
<tr>
|
<tr>
|
||||||
<th style={th}>Datum<span {...grip("datum")} /></th>
|
<th style={th}>Datum<span {...grip("datum")} /></th>
|
||||||
{cats.map((c) => (
|
{cats.map((c) => (
|
||||||
<th key={c.id} style={{ ...th, color: c.color }}>
|
// 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)} />
|
{c.naam}<span {...grip(c.id)} />
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
@@ -369,14 +371,14 @@ function EVVoortgangTabel() {
|
|||||||
<tr key={i}>
|
<tr key={i}>
|
||||||
<td style={td}>{fmtDatum(row.datum)}</td>
|
<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>)}
|
{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, fontWeight: 700, color: vColor }}>{vLabel}</td>
|
||||||
{/* title: bij een smalle kolom is de tekst afgekapt */}
|
{/* title: bij een smalle kolom is de tekst afgekapt */}
|
||||||
<td style={{ ...td, color: T.muted }} title={row.opmerkingen || ""}>{row.opmerkingen || "—"}</td>
|
<td style={{ ...td, color: T.muted }} title={row.opmerkingen || ""}>{row.opmerkingen || "—"}</td>
|
||||||
{!locked && (
|
{!locked && (
|
||||||
<td style={td}>
|
<td style={td}>
|
||||||
<div style={{ display: "flex", gap: 6 }}>
|
<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>
|
<button onClick={() => removeRow(row.datum)} style={deleteSmBtn}>×</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -422,7 +424,7 @@ function DoelProgressie({ ev, data, setData, totVerdeling, locked, T }) {
|
|||||||
<span style={{ fontWeight: 600, fontSize: 14 }}>Gewenst doel EV</span>
|
<span style={{ fontWeight: 600, fontSize: 14 }}>Gewenst doel EV</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
<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 ? (
|
{editing ? (
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
<span style={{ color: T.muted, fontSize: 13 }}>/</span>
|
<span style={{ color: T.muted, fontSize: 13 }}>/</span>
|
||||||
@@ -432,10 +434,10 @@ function DoelProgressie({ ev, data, setData, totVerdeling, locked, T }) {
|
|||||||
onKeyDown={(e) => { if (e.key === "Enter") save(); if (e.key === "Escape") setEditing(false); }}
|
onKeyDown={(e) => { if (e.key === "Enter") save(); if (e.key === "Escape") setEditing(false); }}
|
||||||
style={{
|
style={{
|
||||||
width: 130, padding: "4px 8px", borderRadius: 8, fontSize: 13,
|
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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -445,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>
|
</div>
|
||||||
<div style={{ height: 12, background: "rgba(255,255,255,0.07)", borderRadius: 99, overflow: "hidden" }}>
|
<div style={{ height: 12, background: "rgba(255,255,255,0.07)", borderRadius: 99, overflow: "hidden" }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
height: "100%", width: `${progressPct * 100}%`,
|
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",
|
borderRadius: 99, transition: "width 0.7s ease",
|
||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
@@ -494,9 +496,9 @@ export default function EigenVermogenTab() {
|
|||||||
total: fmt(totVerdeling), totalColor: GREEN,
|
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 })),
|
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",
|
title: "Rendement", accent: "#f59e0b",
|
||||||
@@ -579,16 +581,16 @@ export default function EigenVermogenTab() {
|
|||||||
{!locked && (
|
{!locked && (
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<button onClick={() => setShowAddCat(true)} style={{
|
<button onClick={() => setShowAddCat(true)} style={{
|
||||||
padding: "10px 20px", background: `${PURPLE}22`,
|
padding: "10px 20px", background: `${T.accent}22`,
|
||||||
border: `1px solid ${PURPLE}44`, borderRadius: 10,
|
border: `1px solid ${T.accent}44`, borderRadius: 10,
|
||||||
color: PURPLE_LIGHT, cursor: "pointer", fontWeight: 700, fontSize: 13,
|
color: T.accentTekst, cursor: "pointer", fontWeight: 700, fontSize: 13,
|
||||||
}}>+ Nieuwe categorie</button>
|
}}>+ Nieuwe categorie</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showAddCat && (
|
{showAddCat && (
|
||||||
<Overlay onClose={() => setShowAddCat(false)}>
|
<Overlay onClose={() => setShowAddCat(false)}>
|
||||||
<ModalBox maxWidth={400}>
|
<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={{ padding: "20px 22px" }}>
|
||||||
<div style={{ fontSize: 11, color: T.muted, fontWeight: 700, textTransform: "uppercase", marginBottom: 6 }}>Naam *</div>
|
<div style={{ fontSize: 11, color: T.muted, fontWeight: 700, textTransform: "uppercase", marginBottom: 6 }}>Naam *</div>
|
||||||
<input autoFocus placeholder="Bijv. Aandelen" value={newNaam}
|
<input autoFocus placeholder="Bijv. Aandelen" value={newNaam}
|
||||||
@@ -608,7 +610,7 @@ export default function EigenVermogenTab() {
|
|||||||
</div>
|
</div>
|
||||||
<ModalFooter T={T}>
|
<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={() => 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>
|
</ModalFooter>
|
||||||
</ModalBox>
|
</ModalBox>
|
||||||
</Overlay>
|
</Overlay>
|
||||||
@@ -668,9 +670,9 @@ function ErrMsg({ msg }) {
|
|||||||
}}>{msg}</div>
|
}}>{msg}</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const primaryBtn = { padding: "10px 20px", background: PURPLE, border: "none", borderRadius: 8, color: "#fff", fontWeight: 700, cursor: "pointer", fontSize: 13 };
|
const primaryBtn = (T) => ({ padding: "10px 20px", background: T.accent, 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 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 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 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 });
|
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 { useState } from "react";
|
||||||
import { useApp } from "../context/AppContext.jsx";
|
import { useApp } from "../context/AppContext.jsx";
|
||||||
import { fmt, pct } from "../utils/format.js";
|
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";
|
import { VCard, DonutChart, Overlay, ModalBox, ModalHeader, EmojiPicker, AppIcon, ConfirmDeleteDialog } from "../components/ui/index.jsx";
|
||||||
|
|
||||||
// ── SchuldAanpassenPopup ──────────────────────────────────────────────────────
|
// ── SchuldAanpassenPopup ──────────────────────────────────────────────────────
|
||||||
@@ -108,14 +108,14 @@ function SchuldAanpassenPopup({ blok, onClose }) {
|
|||||||
<div style={{
|
<div style={{
|
||||||
display: "flex", alignItems: "center", gap: 8,
|
display: "flex", alignItems: "center", gap: 8,
|
||||||
background: darkMode ? "rgba(255,255,255,0.03)" : "#f8fafc",
|
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",
|
borderRadius: 10, padding: "10px 12px",
|
||||||
}}>
|
}}>
|
||||||
<input value={nieuwNaam}
|
<input value={nieuwNaam}
|
||||||
onChange={(e) => { setNieuwNaam(e.target.value); setNieuwErr(""); }}
|
onChange={(e) => { setNieuwNaam(e.target.value); setNieuwErr(""); }}
|
||||||
placeholder="Naam" style={{ ...is, flex: 1 }} />
|
placeholder="Naam" style={{ ...is, flex: 1 }} />
|
||||||
<EuroInput value={nieuwVal} onChange={setNieuwVal} is={is} T={T} />
|
<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>
|
</div>
|
||||||
{nieuwErr && <ErrMsg msg={nieuwErr} />}
|
{nieuwErr && <ErrMsg msg={nieuwErr} />}
|
||||||
</div>
|
</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 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 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 });
|
const cancelBtn = (T) => ({ padding: "10px", background: "transparent", border: `1px solid ${T.border}`, borderRadius: 10, color: T.muted, cursor: "pointer", fontWeight: 600, fontSize: 13 });
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useApp } from "../context/AppContext.jsx";
|
import { useApp } from "../context/AppContext.jsx";
|
||||||
import { fmt, fmtDatum } from "../utils/format.js";
|
import { fmt, fmtDatum, fmtMaand } from "../utils/format.js";
|
||||||
|
import { crPath, vlakPad } from "../utils/grafiek.js";
|
||||||
import { useKolomBreedtes } from "../hooks/useKolomBreedtes.js";
|
import { useKolomBreedtes } from "../hooks/useKolomBreedtes.js";
|
||||||
import { PURPLE, PURPLE_LIGHT, RED, GREEN } from "../constants/index.js";
|
import { RED, GREEN } from "../constants/index.js";
|
||||||
import { VCard, DonutChart, Overlay, ModalBox, ModalHeader, ModalFooter } from "../components/ui/index.jsx";
|
import { VCard, DonutChart, Overlay, ModalBox, ModalHeader, ModalFooter } from "../components/ui/index.jsx";
|
||||||
|
|
||||||
// ── Inline SVG line chart ─────────────────────────────────────────────────────
|
// ── Inline SVG line chart ─────────────────────────────────────────────────────
|
||||||
@@ -16,8 +17,11 @@ function SvgLineChart({ pts, lines, T, H = 200 }) {
|
|||||||
</div>
|
</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;
|
const iW = W - pl - pr, iH = H - pt2 - pb;
|
||||||
|
// Eén label "sep '26" is ongeveer 46px breed; zoveel passen er naast elkaar.
|
||||||
|
const labelStap = Math.max(1, Math.ceil(pts.length / Math.max(1, Math.floor(iW / 46))));
|
||||||
const allVals = lines.flatMap((l) => pts.map((p) => p[l.key] ?? 0));
|
const allVals = lines.flatMap((l) => pts.map((p) => p[l.key] ?? 0));
|
||||||
const dataMin = Math.min(...allVals);
|
const dataMin = Math.min(...allVals);
|
||||||
const dataMax = Math.max(...allVals);
|
const dataMax = Math.max(...allVals);
|
||||||
@@ -27,8 +31,11 @@ function SvgLineChart({ pts, lines, T, H = 200 }) {
|
|||||||
const rng = mx - mn || 1;
|
const rng = mx - mn || 1;
|
||||||
const xp = (i) => pl + (i / (pts.length - 1)) * iW;
|
const xp = (i) => pl + (i / (pts.length - 1)) * iW;
|
||||||
const yp = (v) => pt2 + (1 - ((v ?? 0) - mn) / rng) * iH;
|
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)";
|
// Was een vergelijking met een vaste hex (`T.bg === "#0f1117"`). Die kleur is
|
||||||
const darkMode = T.bg === "#0f1117";
|
// 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 (
|
return (
|
||||||
<div style={{ overflowX: "auto" }}>
|
<div style={{ overflowX: "auto" }}>
|
||||||
@@ -57,17 +64,21 @@ function SvgLineChart({ pts, lines, T, H = 200 }) {
|
|||||||
if (validPts.length < 2) return null;
|
if (validPts.length < 2) return null;
|
||||||
const xs = validPts.map((p) => xp(p._i));
|
const xs = validPts.map((p) => xp(p._i));
|
||||||
const ys = validPts.map((p) => yp(p[l.key]));
|
const ys = validPts.map((p) => yp(p[l.key]));
|
||||||
const ls = xs.map((x, i) => `${x},${ys[i]}`).join(" ");
|
// Dezelfde Catmull-Rom afronding als de dashboardgrafiek, in plaats
|
||||||
const ar = `${xs[0]},${pt2 + iH} ${ls} ${xs[xs.length - 1]},${pt2 + iH}`;
|
// van rechte segmenten tussen de punten.
|
||||||
|
const lijn = crPath(xs, ys);
|
||||||
|
const vlak = vlakPad(lijn, xs[0], xs[xs.length - 1], pt2 + iH);
|
||||||
return (
|
return (
|
||||||
<g key={l.key}>
|
<g key={l.key}>
|
||||||
<polygon points={ar} fill={`url(#g_${l.key})`} />
|
<path d={vlak} fill={`url(#g_${l.key})`} />
|
||||||
<polyline points={ls} fill="none" stroke={l.color} strokeWidth="2.5"
|
<path d={lijn} fill="none" stroke={l.color} strokeWidth="2.5"
|
||||||
strokeLinejoin="round" strokeLinecap="round" />
|
strokeLinejoin="round" strokeLinecap="round" />
|
||||||
{validPts.map((p, i) => {
|
{validPts.map((p, i) => {
|
||||||
const x = xs[i], y = ys[i];
|
const x = xs[i], y = ys[i];
|
||||||
const isHov = hovered?.lineKey === l.key && hovered?.ptIdx === 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 tipX = Math.min(Math.max(x - tipW / 2, pl), W - tipW - 4);
|
||||||
const tipY = y - tipH - 8 < pt2 ? y + 10 : y - tipH - 8;
|
const tipY = y - tipH - 8 < pt2 ? y + 10 : y - tipH - 8;
|
||||||
return (
|
return (
|
||||||
@@ -81,10 +92,14 @@ function SvgLineChart({ pts, lines, T, H = 200 }) {
|
|||||||
fill={l.color} stroke={T.card} strokeWidth="2" />
|
fill={l.color} stroke={T.card} strokeWidth="2" />
|
||||||
{isHov && (
|
{isHov && (
|
||||||
<g>
|
<g>
|
||||||
<rect x={tipX} y={tipY} width={tipW} height={22}
|
<rect x={tipX} y={tipY} width={tipW} height={tipH}
|
||||||
rx="5" fill={darkMode ? "#1e2130" : "#fff"}
|
rx="5" fill={T.card}
|
||||||
stroke={l.color} strokeWidth="1" />
|
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">
|
fontWeight="700" textAnchor="middle">
|
||||||
{fmt(p[l.key])}
|
{fmt(p[l.key])}
|
||||||
</text>
|
</text>
|
||||||
@@ -96,15 +111,23 @@ function SvgLineChart({ pts, lines, T, H = 200 }) {
|
|||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{pts.map((p, i) => (
|
{/* X-as: korte maandlabels, rechtop en uitgedund op wat er past. Elk
|
||||||
<text key={i}
|
punt een volledige datum geven liep bij een lange reeks over in een
|
||||||
x={xp(i)} y={H - pb + 16}
|
onleesbare band. De exacte dag staat in de tooltip op elk punt. */}
|
||||||
fill={T.muted} fontSize="8"
|
{pts.map((p, i) => {
|
||||||
textAnchor="end"
|
const laatste = i === pts.length - 1;
|
||||||
transform={`rotate(-45, ${xp(i)}, ${H - pb + 16})`}>
|
if (i % labelStap !== 0 && !laatste) return null;
|
||||||
{fmtDatum(p.datum)}
|
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>
|
</text>
|
||||||
))}
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</svg>
|
</svg>
|
||||||
<div style={{ display: "flex", gap: 20, justifyContent: "center", marginTop: 10 }}>
|
<div style={{ display: "flex", gap: 20, justifyContent: "center", marginTop: 10 }}>
|
||||||
{lines.map((l) => (
|
{lines.map((l) => (
|
||||||
@@ -145,12 +168,12 @@ function VoortgangModal({ onSave, onClose, T, darkMode, initialData }) {
|
|||||||
<Overlay onClose={onClose}>
|
<Overlay onClose={onClose}>
|
||||||
<ModalBox maxWidth={480}>
|
<ModalBox maxWidth={480}>
|
||||||
<ModalHeader
|
<ModalHeader
|
||||||
icon="📊" iconBg={PURPLE}
|
icon="📊" iconBg={T.accent}
|
||||||
title={isEdit ? "Voortgang bewerken" : "Voortgang toevoegen"}
|
title={isEdit ? "Voortgang bewerken" : "Voortgang toevoegen"}
|
||||||
subtitle="Vul de waarden in voor dit moment"
|
subtitle="Vul de waarden in voor dit moment"
|
||||||
subtitleColor={T.muted}
|
subtitleColor={T.muted}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
headerBg={darkMode ? `${PURPLE}08` : "#f8f5ff"}
|
headerBg={darkMode ? `${T.accent}08` : T.inputBg}
|
||||||
T={T}
|
T={T}
|
||||||
/>
|
/>
|
||||||
<div style={{ padding: "20px 22px", overflowY: "auto", maxHeight: "70vh" }}>
|
<div style={{ padding: "20px 22px", overflowY: "auto", maxHeight: "70vh" }}>
|
||||||
@@ -189,7 +212,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 }}>
|
<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
|
Annuleren
|
||||||
</button>
|
</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"}
|
{isEdit ? "Opslaan" : "Toevoegen"}
|
||||||
</button>
|
</button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
@@ -286,8 +309,8 @@ export default function VoortgangTab() {
|
|||||||
|
|
||||||
{/* Hero netto */}
|
{/* Hero netto */}
|
||||||
<VCard style={{
|
<VCard style={{
|
||||||
background: `linear-gradient(135deg, ${PURPLE}22, rgba(16,185,129,0.08))`,
|
background: `linear-gradient(135deg, ${T.accent}22, rgba(16,185,129,0.08))`,
|
||||||
border: `1px solid ${T.border}`, borderLeft: `4px solid ${PURPLE}`,
|
border: `1px solid ${T.border}`, borderLeft: `4px solid ${T.accent}`,
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 12 }}>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 12 }}>
|
||||||
@@ -397,7 +420,7 @@ export default function VoortgangTab() {
|
|||||||
<div style={{ fontWeight: 700, fontSize: 14 }}>Voortgang overzicht</div>
|
<div style={{ fontWeight: 700, fontSize: 14 }}>Voortgang overzicht</div>
|
||||||
{!locked && (
|
{!locked && (
|
||||||
<button onClick={() => setShowAdd(true)} style={{
|
<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",
|
borderRadius: 8, color: "#fff", fontWeight: 700, fontSize: 12, cursor: "pointer",
|
||||||
}}>+ Toevoegen</button>
|
}}>+ Toevoegen</button>
|
||||||
)}
|
)}
|
||||||
@@ -419,7 +442,7 @@ export default function VoortgangTab() {
|
|||||||
<th style={th}>Datum<span {...grip("datum")} /></th>
|
<th style={th}>Datum<span {...grip("datum")} /></th>
|
||||||
<th style={{ ...th, color: GREEN }}>Eigen Vermogen<span {...grip("ev")} /></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: RED }}>Schuld<span {...grip("schuld")} /></th>
|
||||||
<th style={{ ...th, color: PURPLE_LIGHT }}>Nettowaarde<span {...grip("netto")} /></th>
|
<th style={{ ...th, color: T.accentTekst }}>Nettowaarde<span {...grip("netto")} /></th>
|
||||||
<th style={th}>Opmerkingen<span {...grip("opmerkingen")} /></th>
|
<th style={th}>Opmerkingen<span {...grip("opmerkingen")} /></th>
|
||||||
{!locked && <th style={th}><span {...grip("acties")} /></th>}
|
{!locked && <th style={th}><span {...grip("acties")} /></th>}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -435,7 +458,7 @@ export default function VoortgangTab() {
|
|||||||
<td style={{ ...td, color: T.muted }} title={row.opmerkingen || ""}>{row.opmerkingen || "—"}</td>
|
<td style={{ ...td, color: T.muted }} title={row.opmerkingen || ""}>{row.opmerkingen || "—"}</td>
|
||||||
{!locked && (
|
{!locked && (
|
||||||
<td style={{ ...td, whiteSpace: "nowrap" }}>
|
<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>
|
<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>
|
</td>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,6 +14,21 @@ export const fmtDatum = (s) => {
|
|||||||
return `${d}-${m}-${y}`;
|
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) */
|
/** Crypto-bedrag formatter (4 decimalen standaard) */
|
||||||
export const fmtCrypto = (n, d = 4) => (parseFloat(n) || 0).toFixed(d);
|
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`;
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from "./format.js";
|
export * from "./format.js";
|
||||||
export * from "./storage.js";
|
export * from "./storage.js";
|
||||||
|
export * from "./grafiek.js";
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ const KEYS = {
|
|||||||
evRows: "ev_voortgang_rows",
|
evRows: "ev_voortgang_rows",
|
||||||
users: "wbjw_users",
|
users: "wbjw_users",
|
||||||
exitplan: "exitplan_data",
|
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) {
|
export function loadFromStorage(key) {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "vibefinance",
|
"name": "vibefinance",
|
||||||
"version": "1.0.6",
|
"version": "1.0.9-dev",
|
||||||
"description": "VibeFinance — persoonlijk vermogensbeheer",
|
"description": "VibeFinance — persoonlijk vermogensbeheer",
|
||||||
"private": true,
|
"private": true,
|
||||||
"repository": "https://vibehoogie.duckdns.org/vibe/VibeFinance",
|
"repository": "https://vibehoogie.duckdns.org/vibe/VibeFinance",
|
||||||
|
|||||||
Reference in New Issue
Block a user