Initiële commit: VibeFinance 3-container opzet

Basislijn van de bestaande applicatie (Vite/React frontend, Express
backend, PostgreSQL) zoals die in de index stond, plus dev-, release-
en documentatiescripts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jordy Hoogeveen
2026-09-04 09:19:14 +02:00
co-authored by Claude Opus 5
commit e45d5b73fb
54 changed files with 11434 additions and 0 deletions
+194
View File
@@ -0,0 +1,194 @@
# VibeFinance — Beheer
---
## Ontwikkeling
```powershell
.\dev.ps1 # build + start (standaard)
.\dev.ps1 build # alleen bouwen
.\dev.ps1 build -Target backend # alleen backend bouwen
.\dev.ps1 build -Target frontend # alleen frontend bouwen
.\dev.ps1 up -Target backend # alleen backend herbouwen + herstarten
.\dev.ps1 up -Target frontend # alleen frontend herbouwen + herstarten
.\dev.ps1 push # dev-images naar registry pushen
.\dev.ps1 push -Target backend # alleen backend pushen
.\dev.ps1 push -Target frontend # alleen frontend pushen
```
> Op macOS/Linux: gebruik `./dev.sh` met dezelfde argumenten.
> ⚠️ `.\dev.ps1 up` (beide services) en `.\dev.ps1 down` voeren intern `docker compose down -v` uit — dit wist de development-database. Gebruik voor een herstart zonder dataverlies:
```bash
docker compose -f docker-compose.dev.yml down
docker compose -f docker-compose.dev.yml up -d --build
```
---
## Release workflow
```powershell
# 1. Versienummer in package.json van "x.x.x-dev" naar "x.x.x"
# 2.
.\release.ps1 # bouw, push naar registry, maak Git-tag aan
.\release.ps1 -DryRun # droogloop — toont alle stappen zonder uit te voeren
.\release.ps1 -NoBuild # sla docker build over, push bestaande images
# 3. Versienummer in package.json naar "x.x.(x+1)-dev" voor volgende cyclus
```
---
## Docker
```bash
# Update via pre-built images
docker compose pull && docker compose up -d
# Reset inclusief database ⚠️ wist alle data
docker compose down -v && docker compose up -d
```
### PostgreSQL versie-upgrade
Bij een upgrade van PostgreSQL (bijv. 16 → 18) is de bestaande data niet compatibel. Stappen:
```bash
# 1. Backup maken
docker exec vibefinance_postgres pg_dump -U vibefinance vibefinance > backup-$(date +%F).sql
# 2. Oude containers en volumes verwijderen
docker compose down -v
# 3. Nieuwe versie starten
docker compose up -d
# 4. Backup terugzetten
docker exec -i vibefinance_postgres psql -U vibefinance -d vibefinance < backup-YYYY-MM-DD.sql
```
---
## Architectuur — nginx proxy
De frontend-container (nginx) serveert de React-app en proxyt `/api/`-verzoeken intern door naar de backend-container op poort 3001. Dit is geconfigureerd in `frontend/nginx-spa.conf`:
```nginx
location /api/ {
proxy_pass http://backend:3001;
}
```
> Ontbreekt dit blok, dan krijgen gebruikers "Kan geen verbinding maken met de server" bij het inloggen — ook al draaien alle containers.
---
## Logs en diagnose
```bash
# Live logs (dev)
docker compose -f docker-compose.dev.yml logs -f
# Live logs (productie)
docker compose logs -f
# Status containers
docker compose ps
# Database inspecteren (dev)
docker exec -it vibefinance-postgres-dev psql -U vibefinance -d vibefinance
# Database inspecteren (productie)
docker exec -it vibefinance_postgres psql -U vibefinance -d vibefinance
```
---
## Data persistentie
De backend slaat alle data op in **PostgreSQL**.
| Omgeving | Volume | Mount pad |
|---|---|---|
| Productie | `vibefinance_pgdata` | `/var/lib/postgresql` |
| Development | `postgres_dev_data` | `/var/lib/postgresql` |
> PostgreSQL 18 gebruikt `PGDATA=/var/lib/postgresql/18/docker`. Het volume is gemount op `/var/lib/postgresql` zodat data bewaard blijft na een container restart.
**Backup maken:**
```bash
# Dev
docker exec vibefinance-postgres-dev pg_dump -U vibefinance vibefinance > backup-$(date +%F).sql
# Productie
docker exec vibefinance_postgres pg_dump -U vibefinance vibefinance > backup-$(date +%F).sql
```
**Backup terugzetten:**
```bash
# Dev
docker exec -i vibefinance-postgres-dev psql -U vibefinance -d vibefinance < backup-2026-04-08.sql
# Productie
docker exec -i vibefinance_postgres psql -U vibefinance -d vibefinance < backup-2026-04-08.sql
```
---
## Gebruikersbeheer
De eerste geregistreerde gebruiker krijgt automatisch de **Admin**-rol. Verdere accounts kunnen via de app aangemaakt worden (Gebruikersbeheer, Admin vereist) of via de API.
Rollen:
| Rol | Rechten |
|---|---|
| `Admin` | Alles, inclusief gebruikersbeheer |
| `Bewerker` | Data lezen en bewerken |
| `Viewer` | Alleen lezen |
---
## Productie checklist
- [ ] `POSTGRES_PASSWORD` instellen op een sterk wachtwoord
- [ ] `JWT_SECRET` aanpassen (genereer met `openssl rand -hex 64`)
- [ ] `CORS_ORIGIN` instellen op je domeinnaam
- [ ] `FRONTEND_PORT` aanpassen indien poort 3300 al in gebruik is
- [ ] TLS afhandelen via een externe reverse proxy (bijv. Nginx Proxy Manager, Caddy, Traefik)
---
## Registry
```
vibehoogie.duckdns.org:3000/vibe/vibefinance-backend
vibehoogie.duckdns.org:3000/vibe/vibefinance-frontend
Tags:
latest productie — meest recente release
x.x.x productie — specifieke versie
latest-dev development — meest recente dev build
x.x.x-dev development — specifieke dev versie
```
Inloggen op registry:
```bash
docker login vibehoogie.duckdns.org:3000
```
---
## Update-notificaties
De app vergelijkt de draaiende versie met de laatste release op Gitea. Configuratie staat in `package.json`:
```json
"repository": "https://vibehoogie.duckdns.org:3000/vibe/VibeFinance",
"updateCheckUrl": "https://vibehoogie.duckdns.org:3000/api/v1/repos/vibe/VibeFinance/releases/latest"
```
Vite en de build-scripts lezen deze velden automatisch uit — geen handmatige build-args nodig.
+67
View File
@@ -0,0 +1,67 @@
# Changelog
## [0.1.3] - 2026-04-16
### Toegevoegd
- Versienummer met logo zichtbaar boven de logout-knop in de sidebar
### Gewijzigd
- Registry verplaatst van intern IP (`10.0.3.108:3000`) naar publieke DuckDNS-hostnaam (`vibehoogie.duckdns.org`) — toegankelijk via Nginx Proxy Manager
- PostgreSQL productie geüpgraded van versie 16 naar 18 (volume mount aangepast naar `/var/lib/postgresql`)
- Witte rand rondom de app verwijderd (globale CSS reset toegevoegd, `App.css` gekoppeld aan `App.jsx`)
### Opgelost
- Login gaf "Kan geen verbinding maken met de server": `nginx-spa.conf` miste een `/api/` proxy-blok naar de backend-container — alle API-calls kwamen niet aan
---
## [0.0.4] - 2026-04-08
### Toegevoegd
- _Nog in ontwikkeling_
---
## [0.0.3] - 2026-04-08
### Toegevoegd
- Gebruikersbeheer haalt accounts op uit de backend API (was: localStorage)
- Gebruikers bewerken: naam, e-mailadres en wachtwoord aanpassen via ✏️ knop
- Nieuwe gebruiker aanmaken vereist nu ook een wachtwoord
- Eerste geregistreerde gebruiker krijgt automatisch de Admin-rol
- `aangemaakt` kolom toegevoegd aan de `users` tabel (zichtbaar in gebruikersbeheer)
- Custom dropdown component voor rolselectie (past donker thema)
- Voortgang overzicht gebruikt eigen `evVoortgangHistory` — los van geïnvesteerde fiat
### Opgelost
- Data ging verloren na container restart: PostgreSQL 18 gebruikt een ander `PGDATA` pad (`/var/lib/postgresql/18/docker`). Volume nu gemount op `/var/lib/postgresql`
- Geïnvesteerde fiat invullen vulde automatisch ook het voortgangsoverzicht in
---
## [0.0.2] - 2026-04-01
### Toegevoegd
- PostgreSQL als persistente database (vervangt in-memory opslag)
- JWT authenticatie met instelbare vervaltijd
- Backup en restore via ZIP-bestand
- Rate limiting op auth-endpoints
- Helmet security headers
- Docker multi-stage builds (dev / productie)
- Dev script (`dev.ps1` / `dev.sh`) voor bouwen, starten en pushen
### Gewijzigd
- Gebruikersdata per account opgeslagen (niet meer gedeeld)
---
## [0.0.1] - 2026-03-01
### Toegevoegd
- Initiële release van VibeFinance
- Dashboard met overzicht van eigen vermogen, schulden en netto vermogen
- Eigen vermogen beheer met categorieën en historiek
- Schulden beheer met blokken en afbetalingsoverzicht
- Voortgangspagina met grafieken (EV en schuld over tijd)
- Gebruikersbeheer en authenticatie
- Dark mode ondersteuning
+191
View File
@@ -0,0 +1,191 @@
<div align="center">
# 💜 VibeFinance
**Persoonlijk vermogensdashboard**
Eigen vermogen · Schulden · Voortgang · Gebruikersbeheer
![React](https://img.shields.io/badge/React_18-20232A?style=flat&logo=react&logoColor=61DAFB)
![Node.js](https://img.shields.io/badge/Node.js_22-339933?style=flat&logo=node.js&logoColor=white)
![PostgreSQL](https://img.shields.io/badge/PostgreSQL_18-4169E1?style=flat&logo=postgresql&logoColor=white)
![Docker](https://img.shields.io/badge/Docker_Compose-2496ED?style=flat&logo=docker&logoColor=white)
![Version](https://img.shields.io/badge/versie-0.1.4-8b5cf6?style=flat)
![Built with Claude](https://img.shields.io/badge/Gebouwd_met-Claude.ai-8b5cf6?style=flat&logo=anthropic&logoColor=white)
</div>
---
## 🤖 Gebouwd met Claude.ai
Deze applicatie is volledig ontworpen en ontwikkeld met behulp van [Claude.ai](https://claude.ai) — de AI-assistent van Anthropic. Van architectuur tot gebruikersinterface: alles is stap voor stap tot stand gekomen in samenwerking met Claude.
---
## ⚡ Quick Start
> Vereisten: Docker Engine 24+ en Docker Compose v2+
**`.env`** — kopieer `.env.example` en vul in:
```env
POSTGRES_USER=vibefinance
POSTGRES_PASSWORD=kies_een_sterk_wachtwoord
JWT_SECRET=minimaal_64_willekeurige_tekens
FRONTEND_PORT=3300
```
```bash
# Genereer een veilige JWT_SECRET
openssl rand -hex 64
```
```bash
docker compose pull && docker compose up -d
```
Navigeer naar `http://<server-ip>:3300` en registreer je account. De **eerste** geregistreerde gebruiker krijgt automatisch de Admin-rol.
### Development (hot reload)
```powershell
.\dev.ps1
```
Frontend bereikbaar op **http://localhost** (poort 80 → Vite dev server op 5173).
> **Let op:** `.\dev.ps1 up` en `.\dev.ps1 down` wissen de development-database (volumes). Gebruik voor een gewone herstart `docker compose -f docker-compose.dev.yml down` gevolgd door `docker compose -f docker-compose.dev.yml up -d --build`.
---
## 🗂️ Functionaliteit
| Scherm | Wat het doet |
|---|---|
| 📊 **Dashboard** | Overzicht van totaal eigen vermogen, schulden en netto vermogen |
| 💰 **Eigen vermogen** | Vermogensposten per categorie beheren + geïnvesteerde fiat historiek |
| 📉 **Schulden** | Schulden bijhouden per blok met afbetalingsoverzicht |
| 📈 **Voortgang** | Grafieken en trends van EV en schuld over tijd (apart bij te houden) |
| 👥 **Gebruikers** | Accounts aanmaken, bewerken en beheren (Admin-rol vereist) |
---
## 🏗️ Architectuur
```
vibefinance/
├── docker-compose.yml # Productie orchestratie (pre-built images)
├── docker-compose.dev.yml # Standalone dev stack (hot reload)
├── .env.example # Kopieer naar .env en vul in
├── package.json # Versienummer — single source of truth
├── dev.ps1 # Dev script (bouwen, starten, pushen)
│
├── frontend/ # React / Vite SPA
│ ├── Dockerfile # Multi-stage: dev → build → nginx
│ ├── nginx-spa.conf # SPA fallback + /api proxy naar backend
│ ├── vite.config.js
│ └── src/
│ ├── App.jsx # Root – wiring + idle-logout
│ ├── main.jsx
│ ├── constants/ # Kleuren, tabs, begindata
│ ├── utils/ # Formatters, helpers
│ ├── hooks/ # useTheme
│ ├── context/ # AppContext (global state + API sync)
│ ├── components/
│ │ ├── ui/ # Primitieve UI (VCard, DonutChart…)
│ │ ├── LoginPage.jsx
│ │ ├── NavBar.jsx
│ │ ├── ProfielPopup.jsx
│ │ └── GebruikersBeheer.jsx
│ └── pages/
│ ├── DashboardTab.jsx
│ ├── EigenVermogenTab.jsx
│ ├── SchuldTab.jsx
│ └── VoortgangTab.jsx
│
└── backend/ # Express REST API
├── Dockerfile # Multi-stage: dev → production (non-root)
└── src/
├── server.js # Entry point
├── db.js # PostgreSQL connectie + schema migraties
├── config/ # Omgevingsvariabelen
├── constants/ # Begindata (initialData.js)
├── middleware/ # auth.js, errorHandler.js
├── routes/ # auth, data, users, health
└── stores/ # userStore, dataStore
```
**Stack:** React 18 · Vite · Node.js 22 · Express 4 · PostgreSQL 18 · JWT · bcrypt · Docker Compose v2
---
## 🔌 API
Beveiligde endpoints vereisen `Authorization: Bearer <token>`. Rate limiting: 20 req/15 min op auth-routes, 200 req/15 min globaal.
| Route | Methoden | Omschrijving |
|---|---|---|
| `/api/auth/register` | POST | Account aanmaken (eerste user = Admin) |
| `/api/auth/login` | POST | Inloggen, JWT token terug |
| `/api/auth/me` | GET, PATCH | Profiel ophalen en bijwerken |
| `/api/auth/me/password` | POST | Wachtwoord wijzigen |
| `/api/data` | GET, PUT, DELETE | Financiële data per gebruiker |
| `/api/users` | GET, POST, PATCH, DELETE | Gebruikersbeheer (Admin only) |
| `/api/health` | GET | Status + uptime |
---
## 🗄️ Database
| Tabel | Omschrijving |
|---|---|
| `users` | Accounts met bcrypt hash, rol, actief-vlag en aanmaakdatum |
| `user_data` | Financiële data per gebruiker (JSON) |
| Omgeving | Volume | Mount pad |
|---|---|---|
| Productie | `vibefinance_pgdata` | `/var/lib/postgresql` |
| Development | `postgres_dev_data` | `/var/lib/postgresql` |
> PostgreSQL 18 gebruikt `PGDATA=/var/lib/postgresql/18/docker` — het volume is daarom gemount op `/var/lib/postgresql` (niet `/data`).
---
## ⚙️ Omgevingsvariabelen
| Variabele | Standaard | Omschrijving |
|---|---|---|
| `POSTGRES_PASSWORD` | — | **Verplicht** |
| `JWT_SECRET` | — | **Verplicht** — minimaal 64 tekens |
| `POSTGRES_USER` | `vibefinance` | Database gebruikersnaam |
| `POSTGRES_DB` | `vibefinance` | Database naam |
| `JWT_EXPIRES` | `7d` | Geldigheidsduur token |
| `CORS_ORIGIN` | — | Externe domeinnaam |
| `FRONTEND_PORT` | `3300` | Externe poort frontend |
---
## 🔐 Beveiliging
- Wachtwoorden gehasht met bcrypt (cost factor 12)
- JWT authenticatie met instelbare vervaltijd
- Backend uitsluitend bereikbaar via nginx proxy (`/api/` in `nginx-spa.conf`)
- Database bereikbaar alleen binnen het interne Docker netwerk
- Containers draaien als non-root
- Helmet security headers
- Rate limiting op alle endpoints
---
## 🛠️ Beheer
Zie `BEHEER.md` voor commando's voor lokale ontwikkeling, releases, logs en database-toegang.
---
<div align="center">
Privé gebruik · gebouwd met [Claude.ai](https://claude.ai)
</div>
+41
View File
@@ -0,0 +1,41 @@
# ╔══════════════════════════════════════════════╗
# ║ Backend Dockerfile – multi-stage ║
# ╚══════════════════════════════════════════════╝
# ── Stage 1: deps (incl. native build tools) ─────
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install && npm cache clean --force
# ── Stage 2: development (hot reload via --watch) ─
FROM deps AS development
COPY . .
EXPOSE 3001
CMD ["node", "--watch", "src/server.js"]
# ── Stage 3: productie-deps (apart gebouwd) ───────
FROM deps AS prod-deps
RUN npm prune --omit=dev
# ── Stage 4: production (minimale image) ──────────
FROM node:22-alpine AS production
LABEL maintainer="VibeFinance"
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=prod-deps /app/node_modules ./node_modules
COPY package.json ./
COPY src/ ./src/
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:3001/api/health || exit 1
CMD ["node", "src/server.js"]
+27
View File
@@ -0,0 +1,27 @@
{
"name": "vibefinance-backend",
"private": true,
"type": "module",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js",
"lint": "eslint src --ext .js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-rate-limit": "^7.4.0",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"pg": "^8.13.3",
"pino": "^9.0.0",
"pino-http": "^10.0.0",
"pino-pretty": "^13.0.0"
},
"devDependencies": {
"eslint": "^8.57.0"
}
}
+11
View File
@@ -0,0 +1,11 @@
import "dotenv/config";
export const config = {
NODE_ENV: process.env.NODE_ENV || "development",
PORT: parseInt(process.env.PORT || "3001", 10),
CORS_ORIGIN: process.env.CORS_ORIGIN || "http://localhost",
JWT_SECRET: process.env.JWT_SECRET || "change-me-in-production",
JWT_EXPIRES: process.env.JWT_EXPIRES || "7d",
DATABASE_URL: process.env.DATABASE_URL,
REDIS_URL: process.env.REDIS_URL,
};
+30
View File
@@ -0,0 +1,30 @@
export const INITIAL_DATA = {
eigenVermogen: {
doel: 500000,
categories: [
{ id: "c1", naam: "Aandelen", color: "#3b82f6", items: [] },
{ id: "c2", naam: "Crypto", color: "#8b5cf6", items: [] },
{ id: "c3", naam: "Commodities", color: "#f59e0b", items: [] },
{ id: "c4", naam: "Vastgoed", color: "#10b981", items: [] },
{ id: "c5", naam: "Crowdfunding", color: "#06b6d4", items: [] },
{ id: "c6", naam: "Te Besteden", color: "#64748b", items: [] },
],
},
schuld: {
initieleSchuld: 303000,
schuldHistory: [],
blokken: [
{ id: "s1", naam: "Hypotheek", color: "#ef4444", items: [
{ id: "s1i1", naam: "Leningdeel 1", waarde: 0 },
{ id: "s1i2", naam: "Leningdeel 2", waarde: 0 },
{ id: "s1i3", naam: "Leningdeel 3", waarde: 0 },
{ id: "s1i4", naam: "Leningdeel 4", waarde: 0 },
]},
{ id: "s2", naam: "Belastingdienst", color: "#f97316", items: [
{ id: "s2i1", naam: "Kinderopvangtoeslag", waarde: 0 },
{ id: "s2i2", naam: "Sub-categorie 2", waarde: 0 },
]},
],
},
evVoortgang: [],
};
+33
View File
@@ -0,0 +1,33 @@
import pg from "pg";
import { config } from "./config/index.js";
const { Pool } = pg;
export const db = new Pool({
connectionString: config.DATABASE_URL,
});
await db.query(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
naam TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
wachtwoord TEXT NOT NULL,
rol TEXT NOT NULL DEFAULT 'Viewer',
actief BOOLEAN NOT NULL DEFAULT true,
aangemaakt TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS user_data (
user_id TEXT PRIMARY KEY,
data TEXT NOT NULL
);
`);
await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS aangemaakt TIMESTAMPTZ NOT NULL DEFAULT NOW();
`);
await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar TEXT;
`);
+9
View File
@@ -0,0 +1,9 @@
import pino from "pino";
import { config } from "./config/index.js";
export const logger = pino({
level: config.NODE_ENV === "production" ? "info" : "debug",
...(config.NODE_ENV !== "production" && {
transport: { target: "pino-pretty", options: { colorize: true } },
}),
});
+22
View File
@@ -0,0 +1,22 @@
import jwt from "jsonwebtoken";
import { config } from "../config/index.js";
export function authenticate(req, res, next) {
const header = req.headers.authorization;
if (!header?.startsWith("Bearer ")) {
return res.status(401).json({ error: "Niet ingelogd." });
}
try {
req.user = jwt.verify(header.slice(7), config.JWT_SECRET);
next();
} catch {
res.status(401).json({ error: "Sessie verlopen. Log opnieuw in." });
}
}
export function requireAdmin(req, res, next) {
if (req.user?.rol !== "Admin") {
return res.status(403).json({ error: "Geen toegang." });
}
next();
}
+6
View File
@@ -0,0 +1,6 @@
export function errorHandler(err, _req, res, _next) {
const status = err.status || err.statusCode || 500;
const message = status < 500 ? err.message : "Er is een interne fout opgetreden.";
if (status >= 500) console.error("[ERROR]", err);
res.status(status).json({ error: message });
}
+135
View File
@@ -0,0 +1,135 @@
import { Router } from "express";
import jwt from "jsonwebtoken";
import bcrypt from "bcryptjs";
import { config } from "../config/index.js";
import { authenticate } from "../middleware/auth.js";
import { userStore } from "../stores/userStore.js";
import { db } from "../db.js";
const router = Router();
/** POST /api/auth/register */
router.post("/register", async (req, res, next) => {
try {
const { naam, email, wachtwoord } = req.body;
if (!naam || !email || !wachtwoord)
return res.status(400).json({ error: "Naam, e-mail en wachtwoord zijn verplicht." });
if (wachtwoord.length < 8)
return res.status(400).json({ error: "Wachtwoord moet minimaal 8 tekens zijn." });
const existing = await userStore.findByEmail(email.toLowerCase().trim());
if (existing)
return res.status(409).json({ error: "E-mailadres is al in gebruik." });
const { rows: countRows } = await db.query("SELECT COUNT(*) FROM users");
const isFirst = parseInt(countRows[0].count) === 0;
const hash = await bcrypt.hash(wachtwoord, 12);
const user = await userStore.create({
naam: naam.trim(),
email: email.toLowerCase().trim(),
wachtwoord: hash,
rol: isFirst ? "Admin" : "Viewer",
actief: true,
});
const token = jwt.sign(
{ sub: user.id, naam: user.naam, email: user.email, rol: user.rol },
config.JWT_SECRET,
{ expiresIn: config.JWT_EXPIRES }
);
res.status(201).json({ token, user: { id: user.id, naam: user.naam, email: user.email, rol: user.rol, avatar: null } });
} catch (err) { next(err); }
});
/** POST /api/auth/login */
router.post("/login", async (req, res, next) => {
try {
const { email, wachtwoord } = req.body;
if (!email || !wachtwoord)
return res.status(400).json({ error: "E-mail en wachtwoord zijn verplicht." });
const user = await userStore.findByEmail(email.toLowerCase().trim());
if (!user || !await bcrypt.compare(wachtwoord, user.wachtwoord))
return res.status(401).json({ error: "Ongeldig e-mailadres of wachtwoord." });
if (!user.actief)
return res.status(403).json({ error: "Account is inactief." });
const token = jwt.sign(
{ sub: user.id, naam: user.naam, email: user.email, rol: user.rol },
config.JWT_SECRET,
{ expiresIn: config.JWT_EXPIRES }
);
const fullUser = await userStore.findById(user.id);
res.json({ token, user: { id: user.id, naam: user.naam, email: user.email, rol: user.rol, avatar: fullUser?.avatar ?? null } });
} catch (err) { next(err); }
});
/** GET /api/auth/me */
router.get("/me", authenticate, async (req, res, next) => {
try {
const user = await userStore.findById(req.user.sub);
res.json({ user: { id: user.id, naam: user.naam, email: user.email, rol: user.rol, avatar: user?.avatar ?? null } });
} catch (err) { next(err); }
});
/** PATCH /api/auth/me — update naam / email */
router.patch("/me", authenticate, async (req, res, next) => {
try {
const { naam, email } = req.body;
if (!naam?.trim() || !email?.trim())
return res.status(400).json({ error: "Naam en e-mail zijn verplicht." });
const conflict = await userStore.findByEmail(email.toLowerCase().trim());
if (conflict && conflict.id !== req.user.sub)
return res.status(409).json({ error: "E-mailadres is al in gebruik." });
const updated = await userStore.update(req.user.sub, { naam: naam.trim(), email: email.toLowerCase().trim() });
const token = jwt.sign(
{ sub: updated.id, naam: updated.naam, email: updated.email, rol: updated.rol },
config.JWT_SECRET,
{ expiresIn: config.JWT_EXPIRES }
);
res.json({ token, user: { id: updated.id, naam: updated.naam, email: updated.email, rol: updated.rol, avatar: updated.avatar ?? null } });
} catch (err) { next(err); }
});
/** POST /api/auth/me/password — wijzig wachtwoord */
router.post("/me/password", authenticate, async (req, res, next) => {
try {
const { huidig, nieuw: nieuwWw } = req.body;
if (!huidig || !nieuwWw)
return res.status(400).json({ error: "Huidig en nieuw wachtwoord zijn verplicht." });
if (nieuwWw.length < 8)
return res.status(400).json({ error: "Nieuw wachtwoord moet minimaal 8 tekens zijn." });
const user = await userStore.findById(req.user.sub);
if (!user || !await bcrypt.compare(huidig, user.wachtwoord))
return res.status(401).json({ error: "Huidig wachtwoord is onjuist." });
const hash = await bcrypt.hash(nieuwWw, 12);
await userStore.update(req.user.sub, { wachtwoord: hash });
res.json({ message: "Wachtwoord gewijzigd." });
} catch (err) { next(err); }
});
/** PATCH /api/auth/me/avatar — sla avatar op als base64 */
router.patch("/me/avatar", authenticate, async (req, res, next) => {
try {
const { avatar } = req.body;
// avatar mag null zijn (verwijderen) of een base64 data-URL
if (avatar !== null && avatar !== undefined && typeof avatar !== "string")
return res.status(400).json({ error: "Ongeldig avatar formaat." });
await userStore.update(req.user.sub, { avatar: avatar ?? null });
res.json({ avatar: avatar ?? null });
} catch (err) { next(err); }
});
/** POST /api/auth/logout */
router.post("/logout", authenticate, (_req, res) => res.json({ message: "Uitgelogd." }));
export default router;
+12
View File
@@ -0,0 +1,12 @@
import { Router } from "express";
import { authenticate } from "../middleware/auth.js";
import { dataStore } from "../stores/dataStore.js";
const router = Router();
router.use(authenticate);
router.get("/", async (req, res, next) => { try { res.json(await dataStore.load(req.user.sub)); } catch (e) { next(e); } });
router.put("/", async (req, res, next) => { try { res.json(await dataStore.save(req.user.sub, req.body)); } catch (e) { next(e); } });
router.delete("/", async (req, res, next) => { try { await dataStore.reset(req.user.sub); res.json({ message: "Reset." }); } catch (e) { next(e); } });
export default router;
+7
View File
@@ -0,0 +1,7 @@
import { Router } from "express";
const router = Router();
const START = Date.now();
router.get("/", (_req, res) => {
res.json({ status: "ok", uptime: Math.floor((Date.now() - START) / 1000) });
});
export default router;
+42
View File
@@ -0,0 +1,42 @@
import { Router } from "express";
import bcrypt from "bcryptjs";
import { authenticate, requireAdmin } from "../middleware/auth.js";
import { userStore } from "../stores/userStore.js";
const router = Router();
router.use(authenticate);
const safe = ({ wachtwoord, ...u }) => ({ ...u, aangemaakt: u.aangemaakt ?? null });
router.get("/", requireAdmin, async (_req, res, next) => {
try { res.json((await userStore.list()).map(safe)); } catch (e) { next(e); }
});
router.post("/", requireAdmin, async (req, res, next) => {
try {
const { naam, email, wachtwoord, rol = "Viewer" } = req.body;
if (!naam || !email || !wachtwoord) return res.status(400).json({ error: "Naam, e-mail en wachtwoord zijn verplicht." });
const hash = await bcrypt.hash(wachtwoord, 12);
const u = await userStore.create({ naam, email: email.toLowerCase(), wachtwoord: hash, rol, actief: true });
res.status(201).json(safe(u));
} catch (e) { next(e); }
});
router.patch("/:id", requireAdmin, async (req, res, next) => {
try {
const patch = { ...req.body };
if (patch.wachtwoord) {
patch.wachtwoord = await bcrypt.hash(patch.wachtwoord, 12);
}
const u = await userStore.update(req.params.id, patch);
if (!u) return res.status(404).json({ error: "Niet gevonden." });
res.json(safe(u));
} catch (e) { next(e); }
});
router.delete("/:id", requireAdmin, async (req, res, next) => {
try { await userStore.remove(req.params.id); res.json({ message: "Verwijderd." }); }
catch (e) { next(e); }
});
export default router;
+61
View File
@@ -0,0 +1,61 @@
import express from "express";
import cors from "cors";
import helmet from "helmet";
import rateLimit from "express-rate-limit";
import cookieParser from "cookie-parser";
import pino from "pino";
import pinoHttp from "pino-http";
import { config } from "./config/index.js";
import authRouter from "./routes/auth.js";
import dataRouter from "./routes/data.js";
import usersRouter from "./routes/users.js";
import healthRouter from "./routes/health.js";
import { errorHandler } from "./middleware/errorHandler.js";
export const logger = pino({
level: config.NODE_ENV === "production" ? "info" : "debug",
...(config.NODE_ENV !== "production" && {
transport: { target: "pino-pretty", options: { colorize: true } },
}),
});
const app = express();
// ── Security ──────────────────────────────────────────────────────────────────
app.use(helmet());
app.use(cors({ origin: config.CORS_ORIGIN, credentials: true }));
app.set("trust proxy", 1);
// ── Rate limiting ─────────────────────────────────────────────────────────────
app.use("/api/auth", rateLimit({ windowMs: 15 * 60 * 1000, max: 20, standardHeaders: true, legacyHeaders: false }));
app.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 200, standardHeaders: true, legacyHeaders: false }));
// ── Parsing ───────────────────────────────────────────────────────────────────
app.use(express.json({ limit: "2mb" }));
app.use(cookieParser());
// ── Logging ───────────────────────────────────────────────────────────────────
if (config.NODE_ENV !== "test") {
app.use(pinoHttp({ logger }));
}
// ── Routes ────────────────────────────────────────────────────────────────────
app.use("/api/health", healthRouter);
app.use("/api/auth", authRouter);
app.use("/api/data", dataRouter);
app.use("/api/users", usersRouter);
// ── 404 voor overige /api/* ───────────────────────────────────────────────────
app.use("/api/*", (_req, res) => res.status(404).json({ error: "Endpoint niet gevonden." }));
// ── Error handler ─────────────────────────────────────────────────────────────
app.use(errorHandler);
// ── Start ─────────────────────────────────────────────────────────────────────
if (config.NODE_ENV !== "test") {
app.listen(config.PORT, () => {
logger.info(`VibeFinance API gestart op http://localhost:${config.PORT} [${config.NODE_ENV}]`);
});
}
export default app;
+22
View File
@@ -0,0 +1,22 @@
import { db } from "../db.js";
import { INITIAL_DATA } from "../constants/initialData.js";
export const dataStore = {
async load(userId) {
const { rows } = await db.query("SELECT data FROM user_data WHERE user_id = $1", [userId]);
return rows[0] ? JSON.parse(rows[0].data) : structuredClone(INITIAL_DATA);
},
async save(userId, payload) {
await db.query(
"INSERT INTO user_data (user_id, data) VALUES ($1, $2) ON CONFLICT (user_id) DO UPDATE SET data = $2",
[userId, JSON.stringify(payload)]
);
return payload;
},
async reset(userId) {
await db.query(
"INSERT INTO user_data (user_id, data) VALUES ($1, $2) ON CONFLICT (user_id) DO UPDATE SET data = $2",
[userId, JSON.stringify(structuredClone(INITIAL_DATA))]
);
},
};
+43
View File
@@ -0,0 +1,43 @@
/**
* PostgreSQL-backed user store.
*/
import { db } from "../db.js";
const toUser = (row) => row ? { ...row } : null;
export const userStore = {
async list() {
const { rows } = await db.query("SELECT * FROM users");
return rows.map(toUser);
},
async findByEmail(email) {
const { rows } = await db.query("SELECT * FROM users WHERE email = $1", [email]);
return toUser(rows[0]);
},
async findById(id) {
const { rows } = await db.query("SELECT * FROM users WHERE id = $1", [id]);
return toUser(rows[0]);
},
async create(data) {
const u = { id: `u${Date.now()}`, ...data };
await db.query(
"INSERT INTO users (id, naam, email, wachtwoord, rol, actief) VALUES ($1, $2, $3, $4, $5, $6)",
[u.id, u.naam, u.email, u.wachtwoord, u.rol ?? "Viewer", u.actief ?? true]
);
return u;
},
async update(id, patch) {
const { rows } = await db.query("SELECT * FROM users WHERE id = $1", [id]);
const user = toUser(rows[0]);
if (!user) return null;
const u = { ...user, ...patch };
await db.query(
"UPDATE users SET naam=$1, email=$2, wachtwoord=$3, rol=$4, actief=$5, avatar=$6 WHERE id=$7",
[u.naam, u.email, u.wachtwoord, u.rol, u.actief, u.avatar ?? null, id]
);
return u;
},
async remove(id) {
await db.query("DELETE FROM users WHERE id = $1", [id]);
},
};
+176
View File
@@ -0,0 +1,176 @@
# VibeFinance dev script (PowerShell)
# Lokale development cyclus: bouwen, starten, stoppen en optioneel pushen
# naar de dev-registry. Gebruikt altijd de -dev images en tags.
#
# Gebruik:
# .\dev.ps1 # build + herstart containers
# .\dev.ps1 build # alleen lokaal bouwen
# .\dev.ps1 build -Target backend # alleen backend bouwen
# .\dev.ps1 build -Target frontend # alleen frontend bouwen
# .\dev.ps1 up # build + herstart containers
# .\dev.ps1 up -Target backend # alleen backend bouwen + herstart
# .\dev.ps1 up -Target frontend # alleen frontend bouwen + herstart
# .\dev.ps1 down # containers stoppen en volumes verwijderen
# .\dev.ps1 push # multi-platform build + push naar registry
# .\dev.ps1 push -Target backend # alleen backend multi-platform pushen
# .\dev.ps1 push -Target frontend # alleen frontend multi-platform pushen
#
# Vereisten:
# - Docker Desktop actief
# - Voor 'push': ingelogd op registry via: docker login <registry>
# - Voor 'push': docker buildx builder actief (docker buildx create --use)
# - root package.json bevat het versienummer (bijv. "1.0.0-dev")
param(
[ValidateSet("build","up","down","push")]
[string]$Action = "up",
[ValidateSet("both","frontend","backend")]
[string]$Target = "both"
)
$ErrorActionPreference = "Stop"
# =============================================================================
# PROJECTCONFIGURATIE - pas alleen dit blok aan voor een nieuw project
# =============================================================================
$appName = "VibeFinance"
$registry = "vibehoogie.duckdns.org/vibe"
$backendImage = "vibefinance-backend"
$frontendImage = "vibefinance-frontend"
$apiUrl = "/api"
# =============================================================================
# Versie uit root package.json
$rootPkg = Get-Content (Join-Path $PSScriptRoot "package.json") -Raw | ConvertFrom-Json
$appVersion = $rootPkg.version
$repoUrl = if ($rootPkg.repository) { $rootPkg.repository } else { "" }
$updateCheckUrl = if ($rootPkg.updateCheckUrl) { $rootPkg.updateCheckUrl } else { "" }
if (-not $appVersion) {
Write-Error "Kon versienummer niet lezen uit package.json"
exit 1
}
$env:VERSION = $appVersion
$env:VITE_APP_VERSION = $appVersion
if (-not $env:POSTGRES_PASSWORD) { $env:POSTGRES_PASSWORD = "devpassword" }
# Versie synchroniseren naar frontend/package.json
$frontendPkgPath = Join-Path $PSScriptRoot "frontend/package.json"
$frontendPkg = Get-Content $frontendPkgPath -Raw | ConvertFrom-Json
$updateUrl = if ($rootPkg.updateCheckUrl) { $rootPkg.updateCheckUrl } else { "" }
if ($frontendPkg.version -ne $appVersion -or $frontendPkg.updateCheckUrl -ne $updateUrl) {
$frontendPkg.version = $appVersion
$frontendPkg.updateCheckUrl = $updateUrl
[System.IO.File]::WriteAllText($frontendPkgPath, ($frontendPkg | ConvertTo-Json -Depth 10))
Write-Host "Versie gesynchroniseerd naar frontend: $appVersion" -ForegroundColor Green
}
# Registry tags
$backendBase = "$registry/$backendImage"
$frontendBase = "$registry/$frontendImage"
$backendLatestDev = "$backendBase`:latest-dev"
$frontendLatestDev = "$frontendBase`:latest-dev"
$backendVersionDev = "$backendBase`:$appVersion"
$frontendVersionDev= "$frontendBase`:$appVersion"
# Pad naar compose file
$devCompose = Join-Path $PSScriptRoot "docker-compose.dev.yml"
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " $appName Dev" -ForegroundColor Cyan
Write-Host " Versie : $appVersion" -ForegroundColor Green
Write-Host " Actie : $Action" -ForegroundColor Green
Write-Host " Target : $Target" -ForegroundColor Green
Write-Host " Registry: $registry" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# Functies
function Invoke-BackendPush {
Write-Host "Backend bouwen en pushen..." -ForegroundColor White
docker buildx build `
--platform linux/amd64,linux/arm64 `
--push `
-t $backendLatestDev `
-t $backendVersionDev `
--build-arg APP_VERSION=$appVersion `
(Join-Path $PSScriptRoot "backend")
if ($LASTEXITCODE -ne 0) { throw "Backend build mislukt" }
Write-Host "Gepusht: $backendVersionDev" -ForegroundColor Green
}
function Invoke-FrontendPush {
Write-Host "Frontend bouwen en pushen..." -ForegroundColor White
docker buildx build `
--platform linux/amd64,linux/arm64 `
--push `
-t $frontendLatestDev `
-t $frontendVersionDev `
--build-arg APP_VERSION=$appVersion `
--build-arg VITE_API_URL=$apiUrl `
--build-arg VITE_REPO_URL=$repoUrl `
--build-arg VITE_UPDATE_CHECK_URL=$updateCheckUrl `
(Join-Path $PSScriptRoot "frontend")
if ($LASTEXITCODE -ne 0) { throw "Frontend build mislukt" }
Write-Host "Gepusht: $frontendVersionDev" -ForegroundColor Green
}
function Invoke-Push {
Write-Host "-- Multi-platform bouwen en pushen -------" -ForegroundColor Cyan
if ($Target -eq "both" -or $Target -eq "backend") { Invoke-BackendPush }
if ($Target -eq "both" -or $Target -eq "frontend") { Invoke-FrontendPush }
}
# Acties
switch ($Action) {
"build" {
Write-Host "-- Lokaal bouwen -------------------------" -ForegroundColor Cyan
$services = @(if ($Target -ne "both") { $Target })
docker compose --progress plain -f $devCompose build @services
if ($LASTEXITCODE -ne 0) { throw "Build mislukt" }
Write-Host ""
Write-Host "Build klaar - $appVersion [$Target]" -ForegroundColor Green
}
"up" {
if ($Target -eq "both") {
docker compose -f $devCompose down -v
docker compose --progress plain -f $devCompose up --build -d
} else {
docker compose -f $devCompose stop $Target
docker compose --progress plain -f $devCompose up --build -d $Target
}
if ($LASTEXITCODE -ne 0) { throw "Opstarten mislukt" }
Write-Host ""
Write-Host "$appName gestart - $appVersion [$Target]" -ForegroundColor Green
}
"down" {
docker compose -f $devCompose down -v
Write-Host ""
Write-Host "$appName gestopt" -ForegroundColor Yellow
}
"push" {
Invoke-Push
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Dev images gepusht: $appVersion [$Target]" -ForegroundColor Green
if ($Target -eq "both" -or $Target -eq "backend") {
Write-Host " $backendVersionDev" -ForegroundColor White
Write-Host " $backendLatestDev" -ForegroundColor White
}
if ($Target -eq "both" -or $Target -eq "frontend") {
Write-Host " $frontendVersionDev" -ForegroundColor White
Write-Host " $frontendLatestDev" -ForegroundColor White
}
Write-Host "========================================" -ForegroundColor Cyan
}
}
Write-Host ""
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# ── VibeFinance dev wrapper (macOS/Linux) ────────────────────────────────
# Roept dev.ps1 aan via PowerShell Core (pwsh).
#
# Gebruik:
# ./dev.sh # build + herstart containers
# ./dev.sh build # alleen lokaal bouwen
# ./dev.sh build -Target backend # alleen backend bouwen
# ./dev.sh build -Target frontend # alleen frontend bouwen
# ./dev.sh up # build lokaal + herstart containers
# ./dev.sh up -Target backend # alleen backend bouwen + herstart
# ./dev.sh up -Target frontend # alleen frontend bouwen + herstart
# ./dev.sh down # containers stoppen
# ./dev.sh push # multi-platform build + push
# ./dev.sh push -Target backend # alleen backend pushen
# ./dev.sh push -Target frontend # alleen frontend pushen
#
# Vereisten:
# - pwsh (PowerShell Core) geïnstalleerd: brew install powershell
# - Docker Desktop actief
# - Voor 'push': docker buildx builder actief: docker buildx create --use
# - Voor 'push': ingelogd op registry via: docker login 10.0.3.108:3000
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if ! command -v pwsh &>/dev/null; then
echo "Fout: pwsh niet gevonden. Installeer via: brew install powershell" >&2
exit 1
fi
ACTION="${1:-up}"
shift || true
pwsh -NoProfile -ExecutionPolicy Bypass \
-File "$SCRIPT_DIR/dev.ps1" \
-Action "$ACTION" \
"$@"
+88
View File
@@ -0,0 +1,88 @@
##
## Standalone dev stack — lokaal bouwen en testen
##
## docker compose -f docker-compose.dev.yml up --build
##
services:
postgres:
image: postgres:18-alpine
container_name: vibefinance-postgres-dev
environment:
POSTGRES_DB: ${POSTGRES_DB:-vibefinance}
POSTGRES_USER: ${POSTGRES_USER:-vibefinance}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-devpassword}
volumes:
- postgres_dev_data:/var/lib/postgresql
networks:
- dev
healthcheck:
test: ["CMD-SHELL", "pg_isready -U vibefinance"]
interval: 10s
timeout: 5s
retries: 5
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
target: development
args:
VITE_APP_VERSION: ${VITE_APP_VERSION:-dev}
image: vibefinance/frontend:${VERSION:-latest-dev}
container_name: vibefinance-frontend-dev
ports:
- "80:5173"
volumes:
- ./frontend:/app
- /app/node_modules
networks:
- dev
depends_on:
backend:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:5173/"]
interval: 15s
timeout: 5s
retries: 5
start_period: 20s
backend:
build:
context: ./backend
dockerfile: Dockerfile
target: development
image: vibefinance/backend:${VERSION:-latest-dev}
container_name: vibefinance-backend-dev
expose:
- "3001"
volumes:
- ./backend:/app
- /app/node_modules
environment:
NODE_ENV: development
PORT: 3001
JWT_SECRET: dev-secret-change-in-production
JWT_EXPIRES: 7d
CORS_ORIGIN: http://localhost
DATABASE_URL: postgresql://${POSTGRES_USER:-vibefinance}:${POSTGRES_PASSWORD:-devpassword}@postgres:5432/${POSTGRES_DB:-vibefinance}
networks:
- dev
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3001/api/health"]
interval: 15s
timeout: 5s
retries: 5
start_period: 15s
volumes:
postgres_dev_data:
networks:
dev:
driver: bridge
+66
View File
@@ -0,0 +1,66 @@
# VibeFinance — docker-compose voor deployment via pre-built images
#
# Zie README.md § Quick Start voor volledige instructies.
#
# Stappen:
# 1. cp .env.example .env (vul JWT_SECRET, CORS_ORIGIN en DB_PASSWORD in)
# 2. docker compose pull
# 3. docker compose up -d
services:
postgres:
image: postgres:18-alpine
container_name: vibefinance_postgres
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-vibefinance}
POSTGRES_USER: ${POSTGRES_USER:-vibefinance}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- vibefinance_pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U vibefinance"]
interval: 10s
timeout: 5s
retries: 5
networks:
- vibefinance_net
backend:
image: vibehoogie.duckdns.org/vibe/vibefinance-backend:latest
container_name: vibefinance_backend
restart: unless-stopped
env_file: .env
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-vibefinance}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-vibefinance}
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/api/health | grep -q status || exit 1"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
depends_on:
postgres:
condition: service_healthy
networks:
- vibefinance_net
frontend:
image: vibehoogie.duckdns.org/vibe/vibefinance-frontend:latest
container_name: vibefinance_frontend
restart: unless-stopped
ports:
- "${FRONTEND_PORT:-3400}:80"
depends_on:
backend:
condition: service_healthy
networks:
- vibefinance_net
volumes:
vibefinance_pgdata:
networks:
vibefinance_net:
driver: bridge
+42
View File
@@ -0,0 +1,42 @@
# ╔══════════════════════════════════════════════╗
# ║ Frontend Dockerfile – multi-stage ║
# ╚══════════════════════════════════════════════╝
# ── Stage 1: deps ────────────────────────────────
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install && npm cache clean --force
# ── Stage 2: development (hot reload) ────────────
FROM deps AS development
ARG VITE_APP_VERSION=dev
ENV VITE_APP_VERSION=$VITE_APP_VERSION
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
# ── Stage 3: build ───────────────────────────────
FROM deps AS builder
ARG VITE_API_URL=/api
ARG VITE_APP_VERSION=dev
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_APP_VERSION=$VITE_APP_VERSION
COPY . .
RUN npm run build
# ── Stage 4: production (nginx static) ───────────
FROM nginx:1.27-alpine AS production
LABEL maintainer="VibeFinance"
# Kopieer de Vite build output
COPY --from=builder /app/dist /usr/share/nginx/html
# Nginx config voor SPA (history-mode fallback)
COPY nginx-spa.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost/ || exit 1
CMD ["nginx", "-g", "daemon off;"]
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="VibeFinance – Persoonlijk vermogensdashboard" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>VibeFinance</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# Gzip voor statische assets
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
# Cache-control: assets met hash in naam → lang; index.html → nooit
location ~* \.(js|css|woff2?|ttf|eot|svg|png|jpg|ico|webp)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# Proxy API-calls naar de backend
location /api/ {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# SPA fallback – stuur alle routes naar index.html
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
}
+4934
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "vibefinance-frontend",
"version": "0.1.5-dev",
"updateCheckUrl": "https://vibehoogie.duckdns.org/api/v1/repos/vibe/VibeFinance/releases/latest",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint src --ext .js,.jsx"
},
"dependencies": {
"jszip": "^3.10.1",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.0",
"vite": "^5.4.0"
}
}
+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0eddd4"/>
<stop offset="100%" stop-color="#0891b2"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="8" fill="url(#bg)"/>
<polyline
points="4,24 10,15 16,19 22,9 28,13"
fill="none" stroke="#ffffff" stroke-width="3"
stroke-linejoin="round" stroke-linecap="round"/>
<line x1="4" y1="27" x2="28" y2="27"
stroke="rgba(255,255,255,0.35)" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 578 B

@@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 40">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#6d28d9"/>
<stop offset="100%" stop-color="#a855f7"/>
</linearGradient>
<linearGradient id="textGrad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#6d28d9"/>
<stop offset="100%" stop-color="#a855f7"/>
</linearGradient>
</defs>
<!-- Icoon -->
<rect width="36" height="36" x="0" y="2" rx="8" fill="url(#bg)"/>
<polyline
points="5,30 11,20 18,25 25,13 31,19"
fill="none" stroke="#fff" stroke-width="2.8"
stroke-linejoin="round" stroke-linecap="round"/>
<line x1="5" y1="33" x2="31" y2="33"
stroke="rgba(255,255,255,0.35)" stroke-width="1.8"/>
<!-- Tekst -->
<text x="46" y="27"
font-family="'Segoe UI', system-ui, sans-serif"
font-size="18" font-weight="600" fill="url(#textGrad)" letter-spacing="-0.3">
VibeFinance
</text>
</svg>

After

Width:  |  Height:  |  Size: 980 B

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#6d28d9"/>
<stop offset="100%" stop-color="#a855f7"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="url(#bg)"/>
<polyline
points="8,48 20,28 32,36 44,16 56,26"
fill="none" stroke="#fff" stroke-width="5"
stroke-linejoin="round" stroke-linecap="round"/>
<line x1="8" y1="54" x2="56" y2="54"
stroke="rgba(255,255,255,0.35)" stroke-width="3.5"/>
</svg>

After

Width:  |  Height:  |  Size: 569 B

+28
View File
@@ -0,0 +1,28 @@
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body, #root {
height: 100%;
width: 100%;
}
input[type="number"]::-webkit-inner-spin-button {
opacity: 0.4;
}
.nav-tabs {
display: flex;
gap: 2px;
overflow-x: auto;
}
.nav-tabs::-webkit-scrollbar {
display: none;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
+306
View File
@@ -0,0 +1,306 @@
import "./App.css";
import { AppProvider, useApp } from "./context/AppContext.jsx";
import { useEffect, useRef, useState } from "react";
import LoginPage from "./components/LoginPage.jsx";
import NavBar from "./components/NavBar.jsx";
import GebruikersBeheer from "./components/GebruikersBeheer.jsx";
import DashboardTab from "./pages/DashboardTab.jsx";
import EigenVermogenTab from "./pages/EigenVermogenTab.jsx";
import SchuldTab from "./pages/SchuldTab.jsx";
import VoortgangTab from "./pages/VoortgangTab.jsx";
import { RED, PURPLE_LIGHT } from "./constants/index.js";
import { fmt } from "./utils/format.js";
import { version, updateCheckUrl } from "../package.json";
import ProfielPopup from "./components/ProfielPopup.jsx";
import InstellingenModal from "./components/InstellingenModal.jsx";
function initials(naam = "") {
const parts = naam.trim().split(/\s+/);
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
return naam.slice(0, 2).toUpperCase();
}
const PAGES = [DashboardTab, EigenVermogenTab, SchuldTab, VoortgangTab];
function AppInner() {
const { loggedIn, login, logout, locked, tab, showUsers, setShowUsers, showInstellingen, setShowInstellingen, loading, T, currentUser, avatar, idleMinutes, totEV } = useApp();
const [profielOpen, setProfielOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
const [nieuweVersie, setNieuweVersie] = useState(null);
const [bellOpen, setBellOpen] = useState(false);
const searchRef = useRef(null);
useEffect(() => {
if (!updateCheckUrl) return;
fetch(updateCheckUrl)
.then(r => r.json())
.then(data => {
const latest = data?.tag_name?.replace(/^v/, "");
if (latest && latest !== version) setNieuweVersie(latest);
})
.catch(() => {});
}, []);
const idleTimer = useRef(null);
const IDLE_MS = (idleMinutes ?? 30) * 60 * 1000;
const [sessionVerlopen, setSessionVerlopen] = useState(false);
useEffect(() => {
if (!loggedIn) return;
const reset = () => {
clearTimeout(idleTimer.current);
idleTimer.current = setTimeout(() => {
logout();
setSessionVerlopen(true);
}, IDLE_MS);
};
const events = ["mousemove", "mousedown", "keydown", "touchstart", "scroll"];
events.forEach((e) => window.addEventListener(e, reset, { passive: true }));
reset();
return () => {
events.forEach((e) => window.removeEventListener(e, reset));
clearTimeout(idleTimer.current);
};
}, [loggedIn]);
useEffect(() => {
const handler = (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setSearchOpen(true);
setTimeout(() => searchRef.current?.focus(), 50);
}
if (e.key === "Escape") setSearchOpen(false);
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
if (!loggedIn) {
if (sessionVerlopen) {
return (
<div style={{
minHeight: "100vh", background: T.bg, color: T.text,
display: "flex", alignItems: "center", justifyContent: "center",
fontFamily: "'Inter', system-ui, sans-serif",
}}>
<div style={{
background: T.card, border: `1px solid ${T.border}`,
borderRadius: 20, padding: "40px 36px", maxWidth: 360, width: "100%",
textAlign: "center", boxShadow: "0 16px 48px rgba(0,0,0,0.4)",
}}>
<div style={{ fontSize: 52, marginBottom: 16 }}>⏱</div>
<div style={{ fontSize: 20, fontWeight: 800, marginBottom: 10 }}>Sessie verlopen</div>
<div style={{ fontSize: 14, color: T.muted, marginBottom: 28, lineHeight: 1.6 }}>
Je bent automatisch uitgelogd na 30 minuten inactiviteit. Klik hieronder om opnieuw in te loggen.
</div>
<button onClick={() => { setSessionVerlopen(false); }} style={{
width: "100%", padding: "14px",
background: "linear-gradient(135deg, #8b5cf6, #a855f7)",
border: "none", borderRadius: 12,
color: "#fff", fontWeight: 700, fontSize: 15, cursor: "pointer",
boxShadow: "0 4px 16px rgba(139,92,246,0.4)",
}}>
Opnieuw inloggen
</button>
</div>
</div>
);
}
return <LoginPage onLogin={login} />;
}
if (loading) {
return (
<div style={{
minHeight: "100vh", background: T.bg, color: T.text,
display: "flex", alignItems: "center", justifyContent: "center",
flexDirection: "column", gap: 16,
fontFamily: "'Inter', system-ui, sans-serif",
}}>
<div style={{
width: 40, height: 40, borderRadius: "50%",
border: `3px solid rgba(139,92,246,0.2)`,
borderTop: `3px solid ${PURPLE_LIGHT}`,
animation: "spin 0.8s linear infinite",
}} />
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
<div style={{ color: T.muted, fontSize: 14 }}>Data laden…</div>
</div>
);
}
const ActivePage = PAGES[tab] ?? DashboardTab;
return (
<div style={{ display: "flex", minHeight: "100vh", background: T.bg, color: T.text, fontFamily: T.font }}>
<style>{`
* { box-sizing: border-box; }
input[type=number]::-webkit-inner-spin-button { opacity: 0.4; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(128,128,128,0.2); border-radius: 99px; }
::-webkit-scrollbar-thumb:hover { background: rgba(128,128,128,0.35); }
`}</style>
<NavBar />
{/* Hoofdinhoud */}
<div style={{ flex: 1, overflowY: "auto", maxHeight: "100vh", background: T.contentBg ?? T.bg }}>
{/* Topbalk */}
<div style={{
display: "flex", alignItems: "center", gap: 12,
padding: "10px 24px",
background: T.bg,
position: "sticky", top: 0, zIndex: 50,
}}>
<div style={{ flex: 1 }} />
{/* Notificatiebel */}
<div style={{ position: "relative" }}>
<button
onClick={() => nieuweVersie && setBellOpen(o => !o)}
style={{
width: 38, height: 38, borderRadius: "50%",
background: T.inputBg, border: `1px solid ${T.border}`,
display: "flex", alignItems: "center", justifyContent: "center",
cursor: nieuweVersie ? "pointer" : "default", color: T.subtext, flexShrink: 0,
}}>
<svg width="17" height="17" viewBox="0 0 16 16" fill="none">
<path d="M8 1.5A4.5 4.5 0 003.5 6v2.5L2 10h12l-1.5-1.5V6A4.5 4.5 0 008 1.5z" stroke="currentColor" strokeWidth="1.4"/>
<path d="M6.5 12.5a1.5 1.5 0 003 0" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/>
</svg>
</button>
{/* Badge */}
{nieuweVersie && (
<div style={{
position: "absolute", top: 1, right: 1,
width: 13, height: 13, borderRadius: "50%",
background: "#f97316", border: `2px solid ${T.sidebar ?? T.card}`,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 7, fontWeight: 700, color: "#fff",
}}>1</div>
)}
{/* Popup */}
{bellOpen && nieuweVersie && (
<>
<div onClick={() => setBellOpen(false)} style={{ position: "fixed", inset: 0, zIndex: 190 }} />
<div style={{
position: "absolute", right: 0, top: "calc(100% + 10px)",
zIndex: 200, width: 280,
background: T.card, border: `1px solid ${T.border}`,
borderRadius: 14, padding: "16px 18px",
boxShadow: T.shadow,
}}>
<div style={{ fontSize: 13, fontWeight: 700, color: T.text, marginBottom: 10 }}>
Meldingen
</div>
<div style={{
display: "flex", gap: 12, alignItems: "flex-start",
padding: "10px 12px", borderRadius: 10,
background: "rgba(249,115,22,0.08)", border: "1px solid rgba(249,115,22,0.2)",
}}>
<div style={{
width: 32, height: 32, borderRadius: "50%", flexShrink: 0,
background: "rgba(249,115,22,0.15)",
display: "flex", alignItems: "center", justifyContent: "center",
}}>
<svg width="15" height="15" viewBox="0 0 16 16" fill="none">
<path d="M8 1.5A4.5 4.5 0 003.5 6v2.5L2 10h12l-1.5-1.5V6A4.5 4.5 0 008 1.5z" stroke="#f97316" strokeWidth="1.4"/>
<path d="M6.5 12.5a1.5 1.5 0 003 0" stroke="#f97316" strokeWidth="1.4" strokeLinecap="round"/>
</svg>
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: T.text, marginBottom: 2 }}>
Nieuwe versie beschikbaar
</div>
<div style={{ fontSize: 12, color: T.muted, lineHeight: 1.5 }}>
v{nieuweVersie} is uitgebracht. Je gebruikt momenteel v{version}.
</div>
</div>
</div>
</div>
</>
)}
</div>
{/* Gebruikersprofiel */}
<div style={{ position: "relative", flexShrink: 0 }}>
<button onClick={() => setProfielOpen((o) => !o)} style={{
display: "flex", alignItems: "center", gap: 10,
background: "transparent", border: "none",
borderRadius: 12, cursor: "pointer", padding: "4px 8px 4px 4px",
transition: "background 0.15s",
}}
onMouseEnter={e => e.currentTarget.style.background = T.inputBg}
onMouseLeave={e => e.currentTarget.style.background = "transparent"}
>
<div style={{
width: 36, height: 36, borderRadius: "50%", overflow: "hidden", flexShrink: 0,
background: avatar ? "transparent" : "linear-gradient(135deg, #7c3aed, #a855f7)",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 12, fontWeight: 800, color: "#fff",
border: `2px solid ${T.border}`,
}}>
{avatar
? <img src={avatar} alt="avatar" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
: initials(currentUser?.naam || "")
}
</div>
<div style={{ textAlign: "left" }}>
<div style={{ fontSize: 13, fontWeight: 600, color: T.text, whiteSpace: "nowrap", lineHeight: 1.3 }}>
{currentUser?.naam || "Gebruiker"}
</div>
<div style={{ fontSize: 11, color: T.muted, whiteSpace: "nowrap", lineHeight: 1.3 }}>
{currentUser?.email || ""}
</div>
</div>
<svg width="12" height="12" viewBox="0 0 10 10" fill="none" style={{ flexShrink: 0, color: T.muted }}>
<polyline points="1,3 5,7 9,3" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
{profielOpen && <ProfielPopup onClose={() => setProfielOpen(false)} />}
</div>
</div>
{/* Vergrendeld banner */}
{locked && (
<div style={{
position: "sticky", top: 16, zIndex: 150,
margin: "16px auto 0", maxWidth: 400,
background: "rgba(239,68,68,0.15)",
border: "1px solid rgba(239,68,68,0.4)",
borderRadius: 99, padding: "8px 20px",
display: "flex", alignItems: "center", gap: 8,
backdropFilter: "blur(8px)",
}}>
<span style={{ fontSize: 14 }}>🔒</span>
<span style={{ fontSize: 13, color: RED, fontWeight: 600 }}>
Vergrendeld — ga naar Gebruikers om te ontgrendelen
</span>
</div>
)}
{/* Modals */}
{showUsers && <GebruikersBeheer onClose={() => setShowUsers(false)} />}
{showInstellingen && <InstellingenModal onClose={() => setShowInstellingen(false)} />}
{/* Actieve pagina */}
<div style={{ maxWidth: 1200, margin: "0 auto", padding: "28px 24px" }}>
<ActivePage />
</div>
</div>
</div>
);
}
export default function App() {
return (
<AppProvider>
<AppInner />
</AppProvider>
);
}
@@ -0,0 +1,377 @@
import { useState, useEffect, useRef } from "react";
import { RED, GREEN } from "../constants/index.js";
const TEAL = "linear-gradient(135deg, #7c3aed, #a855f7)";
const TEAL_COLOR = "#a855f7";
const TEAL_BG = "rgba(124,58,237,0.15)";
const TEAL_BORDER = "rgba(124,58,237,0.35)";
import { useApp } from "../context/AppContext.jsx";
const getToken = () => localStorage.getItem("vf_token");
const authHdrs = () => ({ "Content-Type": "application/json", Authorization: `Bearer ${getToken()}` });
async function apiFetch(path, options = {}) {
const res = await fetch(`/api${path}`, { ...options, headers: { ...authHdrs(), ...(options.headers || {}) } });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `HTTP ${res.status}`);
}
return res.json();
}
function fmtDatum(iso) {
if (!iso) return "—";
const d = new Date(iso);
return `${d.getDate()}-${d.getMonth() + 1}-${d.getFullYear()}`;
}
function RolSelect({ value, onChange, opties, T, darkMode }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
return (
<div ref={ref} style={{ position: "relative", width: "100%" }}>
<button type="button" onClick={() => setOpen((o) => !o)} style={{
width: "100%", background: T.inputBg, border: `1px solid ${T.inputBorder}`,
borderRadius: 8, color: T.text, padding: "8px 10px", fontSize: 13,
cursor: "pointer", display: "flex", justifyContent: "space-between", alignItems: "center",
boxSizing: "border-box",
}}>
<span>{value}</span>
<span style={{ fontSize: 10, color: T.muted }}>▼</span>
</button>
{open && (
<div style={{
position: "absolute", top: "calc(100% + 4px)", left: 0, right: 0, zIndex: 999,
background: T.card, border: `1px solid ${T.inputBorder}`,
borderRadius: 8, overflow: "hidden",
boxShadow: darkMode ? "0 8px 24px rgba(0,0,0,0.5)" : "0 8px 24px rgba(0,0,0,0.15)",
}}>
{opties.map((o) => (
<div key={o} onClick={() => { onChange(o); setOpen(false); }} style={{
padding: "9px 12px", fontSize: 13, cursor: "pointer",
color: o === value ? TEAL_COLOR : T.text,
background: o === value ? TEAL_BG : "transparent",
}}
onMouseEnter={(e) => e.currentTarget.style.background = "rgba(14,221,212,0.10)"}
onMouseLeave={(e) => e.currentTarget.style.background = o === value ? TEAL_BG : "transparent"}
>
{o}
</div>
))}
</div>
)}
</div>
);
}
export default function GebruikersBeheer({ onClose }) {
const { currentUser, darkMode, T } = useApp();
const [users, setUsers] = useState([]);
const [loadErr, setLoadErr] = useState("");
const [showAdd, setShowAdd] = useState(false);
const [editUser, setEditUser] = useState(null); // user object dat bewerkt wordt
const [nieuwNaam, setNieuwNaam] = useState("");
const [nieuwEmail, setNieuwEmail] = useState("");
const [nieuwWw, setNieuwWw] = useState("");
const [nieuwRol, setNieuwRol] = useState("Viewer");
const [editNaam, setEditNaam] = useState("");
const [editEmail, setEditEmail] = useState("");
const [editWw, setEditWw] = useState("");
const [err, setErr] = useState("");
const [saving, setSaving] = useState(false);
const rollen = ["Admin", "Bewerker", "Viewer"];
useEffect(() => {
apiFetch("/users")
.then(setUsers)
.catch((e) => setLoadErr(e.message));
}, []);
const is = {
background: T.inputBg, border: `1px solid ${T.inputBorder}`,
borderRadius: 8, color: T.text, padding: "8px 10px",
fontSize: 13, outline: "none", boxSizing: "border-box", width: "100%",
};
const addUser = async () => {
if (!nieuwNaam.trim() || !nieuwEmail.trim() || !nieuwWw.trim()) {
setErr("Naam, e-mail en wachtwoord zijn verplicht.");
return;
}
setSaving(true);
try {
const u = await apiFetch("/users", {
method: "POST",
body: JSON.stringify({ naam: nieuwNaam, email: nieuwEmail, wachtwoord: nieuwWw, rol: nieuwRol }),
});
setUsers((prev) => [...prev, u]);
setNieuwNaam(""); setNieuwEmail(""); setNieuwWw(""); setErr("");
setShowAdd(false);
} catch (e) {
setErr(e.message);
} finally {
setSaving(false);
}
};
const openEdit = (u) => {
setEditUser(u);
setEditNaam(u.naam);
setEditEmail(u.email);
setEditWw("");
setErr("");
};
const saveEdit = async () => {
if (!editNaam.trim() || !editEmail.trim()) { setErr("Naam en e-mail zijn verplicht."); return; }
setSaving(true);
try {
const patch = { naam: editNaam.trim(), email: editEmail.trim() };
if (editWw) patch.wachtwoord = editWw;
const updated = await apiFetch(`/users/${editUser.id}`, {
method: "PATCH",
body: JSON.stringify(patch),
});
setUsers((prev) => prev.map((x) => x.id === editUser.id ? { ...x, ...updated } : x));
setEditUser(null); setErr("");
} catch (e) { setErr(e.message); }
finally { setSaving(false); }
};
const toggleActief = async (u) => {
try {
const updated = await apiFetch(`/users/${u.id}`, {
method: "PATCH",
body: JSON.stringify({ actief: !u.actief }),
});
setUsers((prev) => prev.map((x) => x.id === u.id ? { ...x, ...updated } : x));
} catch (e) { setErr(e.message); }
};
const removeUser = async (id) => {
try {
await apiFetch(`/users/${id}`, { method: "DELETE" });
setUsers((prev) => prev.filter((u) => u.id !== id));
} catch (e) { setErr(e.message); }
};
const th = {
padding: "8px 12px", fontSize: 11, color: T.muted, fontWeight: 600,
textAlign: "left", borderBottom: `1px solid ${T.border}`,
background: T.card, textTransform: "uppercase", letterSpacing: "0.05em",
};
const td = { padding: "10px 12px", fontSize: 13, color: T.text, borderBottom: `1px solid ${T.border}` };
return (
<div
onClick={onClose}
style={{
position: "fixed", inset: 0, zIndex: 400,
background: "rgba(0,0,0,0.55)", backdropFilter: "blur(4px)",
display: "flex", alignItems: "center", justifyContent: "center",
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
background: T.card, border: `1px solid ${T.border}`,
borderRadius: 16, width: "100%", maxWidth: 700,
maxHeight: "90vh", display: "flex", flexDirection: "column",
boxShadow: "0 16px 48px rgba(0,0,0,0.4)",
overflow: "hidden",
}}
>
{/* Header */}
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "18px 22px", borderBottom: `1px solid ${T.border}`, flexShrink: 0,
}}>
<div>
<span style={{ fontWeight: 700, fontSize: 15, color: T.text }}>Gebruikersbeheer</span>
<span style={{ marginLeft: 10, fontSize: 11, fontWeight: 600, color: TEAL_COLOR,
background: TEAL_BG, borderRadius: 99, padding: "2px 8px", border: `1px solid ${TEAL_BORDER}`,
}}>
{users.length} gebruiker{users.length !== 1 ? "s" : ""}
</span>
</div>
<button onClick={onClose} style={{
background: "transparent", border: "none", cursor: "pointer",
color: T.muted, fontSize: 20, lineHeight: 1, padding: 4,
}}>×</button>
</div>
<div style={{ padding: "16px 22px", overflowY: "auto", flex: 1 }}>
{loadErr && (
<div style={{ color: RED, fontSize: 13, marginBottom: 12 }}>{loadErr}</div>
)}
{/* Tabel */}
<div style={{ overflowX: "auto", marginBottom: 16 }}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
<th style={th}>Naam</th>
<th style={th}>E-mail</th>
<th style={th}>Aangemaakt</th>
<th style={th}>Admin</th>
<th style={th}></th>
</tr>
</thead>
<tbody>
{users.map((u) => {
const initials = u.naam.split(" ").map((w) => w[0]).join("").toUpperCase().slice(0, 2);
const isSelf = u.id === currentUser?.id || u.email === currentUser?.email;
return (
<tr key={u.id} style={{ opacity: u.actief ? 1 : 0.5 }}>
<td style={td}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{
width: 32, height: 32, borderRadius: "50%",
background: TEAL_BG, display: "flex",
alignItems: "center", justifyContent: "center",
fontSize: 12, fontWeight: 700, color: TEAL_COLOR, flexShrink: 0,
}}>
{initials}
</div>
<span style={{ fontWeight: 600 }}>{u.naam}</span>
{isSelf && (
<span style={{
fontSize: 10, fontWeight: 700, color: TEAL_COLOR,
background: TEAL_BG, borderRadius: 99,
padding: "1px 7px", border: `1px solid ${TEAL_BORDER}`,
}}>jij</span>
)}
</div>
</td>
<td style={{ ...td, color: T.muted }}>{u.email}</td>
<td style={{ ...td, color: T.muted }}>{fmtDatum(u.aangemaakt)}</td>
<td style={td}>
{u.rol === "Admin"
? <span style={{ fontSize: 18 }}>⭐</span>
: <span style={{ fontSize: 13, color: T.muted }}>—</span>
}
</td>
<td style={{ ...td, whiteSpace: "nowrap" }}>
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
<button onClick={() => openEdit(u)} style={{
padding: "3px 8px", background: TEAL_BG,
border: `1px solid ${TEAL_BORDER}`, borderRadius: 6,
color: TEAL_COLOR, cursor: "pointer", fontSize: 12,
}}>✏️</button>
{!isSelf && (
<>
<button onClick={() => toggleActief(u)} style={{
padding: "3px 8px", borderRadius: 6, cursor: "pointer",
fontSize: 11, fontWeight: 700,
background: u.actief ? "rgba(16,185,129,0.12)" : "rgba(239,68,68,0.1)",
border: `1px solid ${u.actief ? "rgba(16,185,129,0.4)" : "rgba(239,68,68,0.3)"}`,
color: u.actief ? GREEN : RED,
}}>
{u.actief ? "Actief" : "Inactief"}
</button>
<button onClick={() => removeUser(u.id)} style={{
padding: "3px 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,
}}>×</button>
</>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{err && (
<div style={{ color: RED, fontSize: 12, marginBottom: 12, padding: "6px 10px", background: "rgba(239,68,68,0.1)", borderRadius: 8, border: "1px solid rgba(239,68,68,0.25)" }}>
{err}
</div>
)}
{/* Bewerken modal */}
{editUser && (
<div style={{
background: darkMode ? "rgba(124,58,237,0.06)" : "#f5f0ff",
border: `1px solid ${TEAL_BORDER}`, borderRadius: 10, padding: "16px", marginBottom: 16,
}}>
<div style={{ fontSize: 11, color: TEAL_COLOR, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 12 }}>
{editUser.naam} bewerken
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 10 }}>
<input placeholder="Naam" value={editNaam} onChange={(e) => setEditNaam(e.target.value)} style={is} />
<input placeholder="E-mail" value={editEmail} onChange={(e) => setEditEmail(e.target.value)} style={is} />
<input placeholder="Nieuw wachtwoord (optioneel)" type="password" value={editWw} onChange={(e) => setEditWw(e.target.value)} style={{ ...is, gridColumn: "1 / -1" }} />
</div>
<div style={{ display: "flex", gap: 8 }}>
<button onClick={() => { setEditUser(null); setErr(""); }} style={{
flex: 1, padding: "8px", background: "transparent",
border: `1px solid ${T.border}`, borderRadius: 8,
color: T.muted, cursor: "pointer", fontSize: 13, fontWeight: 600,
}}>Annuleren</button>
<button onClick={saveEdit} disabled={saving} style={{
flex: 2, padding: "8px",
background: TEAL,
border: "none", borderRadius: 8, color: "#fff",
fontWeight: 700, fontSize: 13, cursor: saving ? "not-allowed" : "pointer",
}}>{saving ? "Opslaan..." : "Opslaan"}</button>
</div>
</div>
)}
{/* Nieuwe gebruiker */}
{showAdd ? (
<div style={{
background: darkMode ? "rgba(124,58,237,0.06)" : "#f5f0ff",
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 }}>
Nieuwe gebruiker
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 10 }}>
<input placeholder="Naam" value={nieuwNaam} onChange={(e) => setNieuwNaam(e.target.value)} style={is} />
<input placeholder="E-mail" value={nieuwEmail} onChange={(e) => setNieuwEmail(e.target.value)} style={is} />
<input placeholder="Wachtwoord" type="password" value={nieuwWw} onChange={(e) => setNieuwWw(e.target.value)} style={is} />
<RolSelect value={nieuwRol} onChange={setNieuwRol} opties={rollen} T={T} darkMode={darkMode} />
</div>
<div style={{ display: "flex", gap: 8 }}>
<button onClick={() => { setShowAdd(false); setErr(""); }} style={{
flex: 1, padding: "8px", background: "transparent",
border: `1px solid ${T.border}`, borderRadius: 8,
color: T.muted, cursor: "pointer", fontSize: 13, fontWeight: 600,
}}>Annuleren</button>
<button onClick={addUser} disabled={saving} style={{
flex: 2, padding: "8px",
background: TEAL,
border: "none", borderRadius: 8, color: "#fff",
fontWeight: 700, fontSize: 13, cursor: saving ? "not-allowed" : "pointer",
}}>
{saving ? "Opslaan..." : "Toevoegen"}
</button>
</div>
</div>
) : (
<button onClick={() => setShowAdd(true)} style={{
padding: "8px 18px",
background: TEAL,
border: "none", borderRadius: 8, color: "#fff",
fontWeight: 700, fontSize: 13, cursor: "pointer",
}}>+ Nieuwe gebruiker</button>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,112 @@
import { useApp } from "../context/AppContext.jsx";
const TEAL = "linear-gradient(135deg, #7c3aed, #a855f7)";
const BACKUP_ICON = (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M12 15V5M8 11l4-4 4 4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M4 17v2a1 1 0 001 1h14a1 1 0 001-1v-2" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"/>
</svg>
);
const RESTORE_ICON = (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M4 8a8 8 0 1 1 0 8" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"/>
<polyline points="4,4 4,8 8,8" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
function ActionRow({ icon, title, description, onClick }) {
return (
<div
onClick={onClick}
style={{
display: "flex", alignItems: "center", gap: 14,
padding: "12px 0", cursor: "pointer",
}}
>
<div style={{ color: "#94a3b8", flexShrink: 0 }}>{icon}</div>
<div>
<div style={{ fontWeight: 600, fontSize: 14, color: "inherit", marginBottom: 2 }}>{title}</div>
<div style={{ fontSize: 12, color: "#64748b" }}>{description}</div>
</div>
</div>
);
}
export default function InstellingenModal({ onClose }) {
const { T, darkMode, setDarkMode, doBackup, doRestore } = useApp();
return (
<div
onClick={onClose}
style={{
position: "fixed", inset: 0, zIndex: 400,
background: "rgba(0,0,0,0.55)", backdropFilter: "blur(4px)",
display: "flex", alignItems: "center", justifyContent: "center",
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
background: T.card, border: `1px solid ${T.border}`,
borderRadius: 16, padding: "22px 22px 8px",
width: 360, boxShadow: "0 16px 48px rgba(0,0,0,0.4)",
color: T.text,
}}
>
{/* Header */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 20 }}>
<span style={{ fontWeight: 700, fontSize: 16 }}>Instellingen</span>
<button onClick={onClose} style={{
background: "transparent", border: "none", cursor: "pointer",
color: T.muted, fontSize: 20, lineHeight: 1, padding: "0 4px",
}}>×</button>
</div>
{/* Dark mode rij */}
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "4px 0 16px",
}}>
<span style={{ fontWeight: 600, fontSize: 14 }}>Dark mode</span>
<div
onClick={() => setDarkMode((d) => !d)}
style={{
width: 44, height: 24, borderRadius: 12, cursor: "pointer",
background: darkMode ? TEAL : "#334155",
position: "relative", transition: "background 0.2s", flexShrink: 0,
}}
>
<div style={{
position: "absolute", top: 3, borderRadius: "50%",
width: 18, height: 18, background: "#fff",
left: darkMode ? 23 : 3,
transition: "left 0.2s",
boxShadow: "0 1px 4px rgba(0,0,0,0.3)",
}} />
</div>
</div>
<div style={{ height: 1, background: T.border, margin: "0 -22px" }} />
{/* Acties */}
<div style={{ padding: "4px 0 10px" }}>
<ActionRow
icon={BACKUP_ICON}
title="Backup maken"
description="Download een volledige backup als ZIP"
onClick={() => { doBackup(); onClose(); }}
/>
<div style={{ height: 1, background: T.border }} />
<ActionRow
icon={RESTORE_ICON}
title="Backup herstellen"
description="Herstel data vanuit een backup-bestand"
onClick={() => { doRestore(); onClose(); }}
/>
</div>
</div>
</div>
);
}
+220
View File
@@ -0,0 +1,220 @@
/**
* Merkeigen achtergrond voor de loginpagina.
*
* Vertelt in één beeld waar de app over gaat: het eigen vermogen (paars/groen)
* stijgt, de schuld (oranje) daalt en de twee lijnen kruisen elkaar. Daarnaast
* de categorie-donut, maandstaafjes en zwevende munten — allemaal in de
* huisstijl-paars. Volledig SVG, dus scherp op elk formaat en zonder asset.
*/
// Eigen Vermogen: stijgend. Schuld: dalend. Ze kruisen rond x = 320.
const EV_LINE = "M 30 612 C 110 596, 150 560, 215 528 S 300 470, 340 430 S 430 336, 500 286 S 560 250, 600 232";
const EV_AREA = `${EV_LINE} L 600 760 L 30 760 Z`;
const DEBT_LINE = "M 30 286 C 110 306, 150 340, 215 372 S 300 430, 340 466 S 430 542, 500 578 S 560 602, 600 616";
const EV_DOTS = [[215, 528], [340, 430], [500, 286]];
const BARS = [26, 38, 34, 52, 47, 68, 62, 84, 96];
export default function LoginBackground() {
return (
<svg
viewBox="0 0 600 800"
preserveAspectRatio="xMidYMid slice"
aria-hidden="true"
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", display: "block" }}
>
<defs>
{/* Basis: diep paars met licht verloop naar de rechterbovenhoek */}
<linearGradient id="vfbg-base" x1="0" y1="1" x2="1" y2="0">
<stop offset="0%" stopColor="#12061f" />
<stop offset="45%" stopColor="#2a0f4d" />
<stop offset="100%" stopColor="#4c1d95" />
</linearGradient>
{/* Zachte lichtbollen */}
<radialGradient id="vfbg-orb-purple">
<stop offset="0%" stopColor="#a855f7" stopOpacity="0.55" />
<stop offset="100%" stopColor="#a855f7" stopOpacity="0" />
</radialGradient>
<radialGradient id="vfbg-orb-violet">
<stop offset="0%" stopColor="#7c3aed" stopOpacity="0.5" />
<stop offset="100%" stopColor="#7c3aed" stopOpacity="0" />
</radialGradient>
<radialGradient id="vfbg-orb-teal">
<stop offset="0%" stopColor="#10b981" stopOpacity="0.28" />
<stop offset="100%" stopColor="#10b981" stopOpacity="0" />
</radialGradient>
{/* Vlak onder de EV-lijn */}
<linearGradient id="vfbg-ev-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#c084fc" stopOpacity="0.34" />
<stop offset="55%" stopColor="#8b5cf6" stopOpacity="0.12" />
<stop offset="100%" stopColor="#7c3aed" stopOpacity="0" />
</linearGradient>
{/* Lijnkleuren */}
<linearGradient id="vfbg-ev-stroke" x1="0" y1="1" x2="1" y2="0">
<stop offset="0%" stopColor="#7c3aed" />
<stop offset="55%" stopColor="#c084fc" />
<stop offset="100%" stopColor="#34d399" />
</linearGradient>
<linearGradient id="vfbg-debt-stroke" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#fb923c" stopOpacity="0.85" />
<stop offset="100%" stopColor="#f97316" stopOpacity="0.25" />
</linearGradient>
<linearGradient id="vfbg-bar" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stopColor="#7c3aed" stopOpacity="0.15" />
<stop offset="100%" stopColor="#c4b5fd" stopOpacity="0.42" />
</linearGradient>
{/* Ruit-raster */}
<pattern id="vfbg-grid" width="48" height="48" patternUnits="userSpaceOnUse">
<path d="M 48 0 L 0 0 0 48" fill="none" stroke="#ffffff" strokeOpacity="0.055" strokeWidth="1" />
</pattern>
{/* Vignet zodat kaartjes en tekst leesbaar blijven */}
<radialGradient id="vfbg-vignette" cx="0.5" cy="0.42" r="0.78">
<stop offset="55%" stopColor="#09090f" stopOpacity="0" />
<stop offset="100%" stopColor="#09090f" stopOpacity="0.62" />
</radialGradient>
<linearGradient id="vfbg-floor" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#09090f" stopOpacity="0" />
<stop offset="100%" stopColor="#09090f" stopOpacity="0.7" />
</linearGradient>
<filter id="vfbg-glow" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="7" result="b" />
<feMerge>
<feMergeNode in="b" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="vfbg-soft" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="18" />
</filter>
</defs>
<style>{`
@keyframes vfbgDrift {
0%,100% { transform: translate(0,0) scale(1); }
50% { transform: translate(26px,-34px) scale(1.1); }
}
@keyframes vfbgDriftAlt {
0%,100% { transform: translate(0,0) scale(1.05); }
50% { transform: translate(-30px,26px) scale(0.94); }
}
@keyframes vfbgFloat {
0%,100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-16px) rotate(6deg); }
}
@keyframes vfbgDraw { to { stroke-dashoffset: 0; } }
@keyframes vfbgSpin { to { transform: rotate(360deg); } }
@keyframes vfbgPulse {
0%,100% { opacity: 0.35; transform: scale(1); }
50% { opacity: 0.9; transform: scale(1.5); }
}
.vfbg-orb-a { animation: vfbgDrift 26s ease-in-out infinite; }
.vfbg-orb-b { animation: vfbgDriftAlt 32s ease-in-out infinite; }
.vfbg-orb-c { animation: vfbgDrift 38s ease-in-out infinite reverse; }
.vfbg-coin { animation: vfbgFloat 11s ease-in-out infinite; transform-origin: center; }
.vfbg-coin-2 { animation-duration: 14s; animation-delay: -3s; }
.vfbg-coin-3 { animation-duration: 9s; animation-delay: -6s; }
.vfbg-line { stroke-dasharray: 1400; stroke-dashoffset: 1400; animation: vfbgDraw 2.4s ease-out 0.15s forwards; }
.vfbg-line-slow { animation-duration: 2.9s; animation-delay: 0.35s; }
.vfbg-ring { animation: vfbgSpin 90s linear infinite; transform-origin: 452px 168px; }
.vfbg-dot { transform-box: fill-box; transform-origin: center; animation: vfbgPulse 4.5s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.vfbg-orb-a, .vfbg-orb-b, .vfbg-orb-c,
.vfbg-coin, .vfbg-ring, .vfbg-dot { animation: none; }
.vfbg-line { stroke-dashoffset: 0; animation: none; }
}
`}</style>
{/* ── Basis + raster ─────────────────────────────────────────────────── */}
<rect width="600" height="800" fill="url(#vfbg-base)" />
<rect width="600" height="800" fill="url(#vfbg-grid)" />
{/* ── Lichtbollen ────────────────────────────────────────────────────── */}
<g style={{ mixBlendMode: "screen" }}>
<circle className="vfbg-orb-a" cx="470" cy="150" r="210" fill="url(#vfbg-orb-purple)" />
<circle className="vfbg-orb-b" cx="110" cy="560" r="240" fill="url(#vfbg-orb-violet)" />
<circle className="vfbg-orb-c" cx="540" cy="640" r="180" fill="url(#vfbg-orb-teal)" />
</g>
{/* ── Categorie-donut (Aandelen / Crypto / Commodities / Vastgoed) ───── */}
<g opacity="0.5">
<circle cx="452" cy="168" r="86" fill="none" stroke="#ffffff" strokeOpacity="0.07" strokeWidth="14" />
<g className="vfbg-ring" fill="none" strokeWidth="14" strokeLinecap="round">
<circle cx="452" cy="168" r="86" stroke="#3b82f6" strokeOpacity="0.75" strokeDasharray="150 390" />
<circle cx="452" cy="168" r="86" stroke="#8b5cf6" strokeOpacity="0.8" strokeDasharray="110 430" strokeDashoffset="-158" />
<circle cx="452" cy="168" r="86" stroke="#f59e0b" strokeOpacity="0.6" strokeDasharray="66 474" strokeDashoffset="-276" />
<circle cx="452" cy="168" r="86" stroke="#10b981" strokeOpacity="0.65" strokeDasharray="94 446" strokeDashoffset="-350" />
</g>
</g>
{/* ── Maandstaafjes ──────────────────────────────────────────────────── */}
<g opacity="0.55">
{BARS.map((h, i) => (
<rect
key={i}
x={38 + i * 62} y={700 - h} width="30" height={h}
rx="7" fill="url(#vfbg-bar)"
/>
))}
<line x1="30" y1="701" x2="570" y2="701" stroke="#ffffff" strokeOpacity="0.12" strokeWidth="1.5" />
</g>
{/* ── Schuldlijn: dalend ─────────────────────────────────────────────── */}
<path
className="vfbg-line vfbg-line-slow" d={DEBT_LINE}
fill="none" stroke="url(#vfbg-debt-stroke)" strokeWidth="3"
strokeLinecap="round" strokeDasharray="10 9" opacity="0.75"
/>
{/* ── Eigen vermogen: stijgend, met vlak eronder ─────────────────────── */}
<path d={EV_AREA} fill="url(#vfbg-ev-fill)" />
<path
className="vfbg-line" d={EV_LINE}
fill="none" stroke="url(#vfbg-ev-stroke)" strokeWidth="4.5"
strokeLinecap="round" strokeLinejoin="round" filter="url(#vfbg-glow)"
/>
{EV_DOTS.map(([cx, cy], i) => (
<g key={cx}>
<circle className="vfbg-dot" cx={cx} cy={cy} r="9" fill="#c4b5fd" opacity="0.4"
style={{ animationDelay: `${i * -1.4}s` }} />
<circle cx={cx} cy={cy} r="4.5" fill="#f5f3ff" />
</g>
))}
{/* Doellijn: The Road naar € 500.000 */}
<g opacity="0.5">
<line x1="30" y1="232" x2="600" y2="232" stroke="#34d399" strokeOpacity="0.45"
strokeWidth="1.5" strokeDasharray="6 8" />
<text x="34" y="222" fill="#6ee7b7" fillOpacity="0.75" fontSize="13" fontWeight="600"
fontFamily="'Poppins', system-ui, sans-serif" letterSpacing="0.08em">DOEL € 500.000</text>
</g>
{/* ── Zwevende munten ───────────────────────────────────────────────── */}
<g fontFamily="'Poppins', system-ui, sans-serif" fontWeight="700" textAnchor="middle">
<g className="vfbg-coin" opacity="0.6">
<circle cx="120" cy="190" r="26" fill="#a855f7" fillOpacity="0.16" stroke="#c4b5fd" strokeOpacity="0.4" strokeWidth="1.5" />
<text x="120" y="199" fill="#e9d5ff" fillOpacity="0.85" fontSize="22">€</text>
</g>
<g className="vfbg-coin vfbg-coin-2" opacity="0.45">
<circle cx="252" cy="112" r="18" fill="#10b981" fillOpacity="0.14" stroke="#6ee7b7" strokeOpacity="0.35" strokeWidth="1.4" />
<text x="252" y="119" fill="#a7f3d0" fillOpacity="0.8" fontSize="16">€</text>
</g>
<g className="vfbg-coin vfbg-coin-3" opacity="0.4">
<circle cx="536" cy="392" r="21" fill="#7c3aed" fillOpacity="0.18" stroke="#c4b5fd" strokeOpacity="0.32" strokeWidth="1.4" />
<text x="536" y="400" fill="#ddd6fe" fillOpacity="0.8" fontSize="18">€</text>
</g>
</g>
{/* ── Diepte + leesbaarheid ─────────────────────────────────────────── */}
<ellipse cx="300" cy="770" rx="300" ry="90" fill="#7c3aed" fillOpacity="0.2" filter="url(#vfbg-soft)" />
<rect width="600" height="800" fill="url(#vfbg-vignette)" />
<rect y="480" width="600" height="320" fill="url(#vfbg-floor)" />
</svg>
);
}
+378
View File
@@ -0,0 +1,378 @@
import { useState } from "react";
import { RED } from "../constants/index.js";
const MailIcon = () => (
<svg width="15" height="15" viewBox="0 0 16 16" fill="none">
<rect x="1" y="3" width="14" height="10" rx="2" stroke="currentColor" strokeWidth="1.5"/>
<polyline points="1,5 8,9.5 15,5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
const LockIcon = () => (
<svg width="15" height="15" viewBox="0 0 16 16" fill="none">
<rect x="3" y="7" width="10" height="8" rx="2" stroke="currentColor" strokeWidth="1.5"/>
<path d="M5 7V5a3 3 0 016 0v2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
);
const PersonIcon = () => (
<svg width="15" height="15" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="5" r="3" stroke="currentColor" strokeWidth="1.5"/>
<path d="M2 14c0-3 2.5-5 6-5s6 2 6 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
);
function EyeBtn({ show, onToggle }) {
return (
<button onClick={onToggle} type="button" style={{
background: "none", border: "none", cursor: "pointer",
color: "rgba(255,255,255,0.35)", padding: 0, lineHeight: 1, display: "flex", flexShrink: 0,
}}>
{show
? <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M1 8s2.5-5 7-5 7 5 7 5-2.5 5-7 5-7-5-7-5z" stroke="currentColor" strokeWidth="1.4"/><circle cx="8" cy="8" r="2" stroke="currentColor" strokeWidth="1.4"/><line x1="2" y1="2" x2="14" y2="14" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/></svg>
: <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M1 8s2.5-5 7-5 7 5 7 5-2.5 5-7 5-7-5-7-5z" stroke="currentColor" strokeWidth="1.4"/><circle cx="8" cy="8" r="2" stroke="currentColor" strokeWidth="1.4"/></svg>
}
</button>
);
}
function FloatingInput({ label, type = "text", value, onChange, onKeyDown, icon, rightElement }) {
const [focused, setFocused] = useState(false);
return (
<div style={{ position: "relative", marginTop: 10 }}>
<div style={{
position: "relative",
border: `1px solid ${focused ? "rgba(124,58,237,0.7)" : "rgba(255,255,255,0.1)"}`,
borderRadius: 10, background: "rgba(255,255,255,0.03)",
transition: "border-color 0.15s",
}}>
<label style={{
position: "absolute", top: -8, left: 12,
fontSize: 10, fontWeight: 700,
color: focused ? "#a855f7" : "rgba(255,255,255,0.35)",
background: "#1a1b22", padding: "0 5px",
letterSpacing: "0.06em", textTransform: "uppercase",
pointerEvents: "none", transition: "color 0.15s",
}}>{label}</label>
<div style={{ display: "flex", alignItems: "center", padding: "13px 14px", gap: 10 }}>
{icon && (
<span style={{ color: focused ? "rgba(168,85,247,0.7)" : "rgba(255,255,255,0.25)", flexShrink: 0, display: "flex", transition: "color 0.15s" }}>
{icon}
</span>
)}
<input
type={type} value={value} onChange={onChange} onKeyDown={onKeyDown}
onFocus={() => setFocused(true)} onBlur={() => setFocused(false)}
style={{
flex: 1, background: "transparent", border: "none", outline: "none",
color: "#f1f5f9", fontSize: 14,
fontFamily: "'Poppins', system-ui, sans-serif",
}}
/>
{rightElement}
</div>
</div>
</div>
);
}
function MiniBarChart() {
const bars = [38, 52, 45, 68, 74, 61, 89];
const months = ["nov", "dec", "jan", "feb", "mrt", "apr", "mei"];
return (
<div style={{ display: "flex", alignItems: "flex-end", gap: 6, height: 48, marginTop: 4 }}>
{bars.map((h, i) => (
<div key={i} style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 4 }}>
<div style={{
width: "100%", borderRadius: 4,
height: `${h}%`,
background: i === bars.length - 1
? "linear-gradient(180deg, #a855f7, #7c3aed)"
: "rgba(255,255,255,0.12)",
}} />
<span style={{ fontSize: 8, color: "rgba(255,255,255,0.35)", whiteSpace: "nowrap" }}>{months[i]}</span>
</div>
))}
</div>
);
}
export default function LoginPage({ onLogin }) {
const [mode, setMode] = useState("login");
const [naam, setNaam] = useState("");
const [email, setEmail] = useState("");
const [wachtwoord, setWachtwoord] = useState("");
const [herhaalWw, setHerhaalWw] = useState("");
const [showPass, setShowPass] = useState(false);
const [showPass2, setShowPass2] = useState(false);
const [err, setErr] = useState("");
const [loading, setLoading] = useState(false);
const switchMode = (m) => { setMode(m); setNaam(""); setEmail(""); setWachtwoord(""); setHerhaalWw(""); setErr(""); };
const handleSubmit = async () => {
setErr("");
if (mode === "register") {
if (!naam.trim()) { setErr("Vul je naam in."); return; }
if (wachtwoord.length < 8) { setErr("Wachtwoord moet minimaal 8 tekens zijn."); return; }
if (!/[A-Z]/.test(wachtwoord)) { setErr("Wachtwoord moet minimaal één hoofdletter bevatten."); return; }
if (!/[0-9]/.test(wachtwoord)) { setErr("Wachtwoord moet minimaal één cijfer bevatten."); return; }
if (wachtwoord !== herhaalWw) { setErr("Wachtwoorden komen niet overeen."); return; }
}
if (!email || !wachtwoord) { setErr("Vul e-mail en wachtwoord in."); return; }
setLoading(true);
try {
const url = mode === "login" ? "/api/auth/login" : "/api/auth/register";
const body = mode === "login" ? { email, wachtwoord } : { naam, email, wachtwoord };
const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
const data = await res.json();
if (!res.ok) { setErr(data.error || "Er is een fout opgetreden."); return; }
localStorage.setItem("vf_token", data.token);
onLogin(data.user);
} catch {
setErr("Kan geen verbinding maken met de server.");
} finally {
setLoading(false);
}
};
const onEnter = (e) => e.key === "Enter" && handleSubmit();
return (
<div style={{
minHeight: "100vh",
background: "#09090f",
display: "flex", alignItems: "center", justifyContent: "center",
fontFamily: "'Poppins', system-ui, sans-serif",
color: "#f1f5f9",
padding: "40px 24px", boxSizing: "border-box",
}}>
{/* Floating card — larger */}
<div style={{
display: "flex", width: "100%", maxWidth: 1080,
minHeight: 620,
borderRadius: 24, overflow: "hidden",
boxShadow: "0 40px 100px rgba(0,0,0,0.75), 0 0 0 1px rgba(255,255,255,0.06)",
}}>
{/* LEFT: Form panel */}
<div style={{
width: 460, flexShrink: 0,
background: "#1a1b22",
padding: "52px 48px", boxSizing: "border-box",
display: "flex", flexDirection: "column",
}}>
{/* Logo */}
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 40 }}>
<div style={{
width: 36, height: 36, flexShrink: 0, borderRadius: 10,
background: "rgba(124,58,237,0.15)", border: "1px solid rgba(124,58,237,0.3)",
display: "flex", alignItems: "center", justifyContent: "center",
}}>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<polyline points="2,15 6,9 10,12 14,5 18,8" stroke="#a855f7" strokeWidth="2.2" strokeLinejoin="round" strokeLinecap="round"/>
<line x1="2" y1="17" x2="18" y2="17" stroke="rgba(124,58,237,0.35)" strokeWidth="1.4"/>
</svg>
</div>
<span style={{ fontSize: 19, fontWeight: 700, color: "#fff", letterSpacing: "-0.3px" }}>VibeFinance</span>
</div>
{/* Heading */}
<div style={{ marginBottom: 32 }}>
<h1 style={{ fontSize: 26, fontWeight: 800, margin: "0 0 10px", color: "#fff", letterSpacing: "-0.4px" }}>
Welkom terug!
</h1>
<p style={{ fontSize: 14, color: "rgba(255,255,255,0.38)", margin: 0, lineHeight: 1.6 }}>
{mode === "login"
? "Log in om je budgetoverzicht te bekijken."
: "Maak een account aan om je vermogen bij te houden."}
</p>
</div>
{/* Tab switcher */}
<div style={{
display: "flex", background: "rgba(255,255,255,0.05)",
borderRadius: 11, padding: 4, marginBottom: 32,
border: "1px solid rgba(255,255,255,0.07)",
}}>
{[["login", "Inloggen"], ["register", "Registreren"]].map(([m, lbl]) => (
<button key={m} onClick={() => switchMode(m)} style={{
flex: 1, padding: "10px 0", borderRadius: 8,
border: "none", cursor: "pointer",
fontFamily: "'Poppins', system-ui, sans-serif",
fontWeight: 600, fontSize: 13,
transition: "all 0.15s",
background: mode === m ? "linear-gradient(135deg, #7c3aed, #a855f7)" : "transparent",
color: mode === m ? "#fff" : "rgba(255,255,255,0.35)",
boxShadow: mode === m ? "0 4px 14px rgba(124,58,237,0.4)" : "none",
}}>{lbl}</button>
))}
</div>
{/* Form fields */}
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
{mode === "register" && (
<FloatingInput
label="Naam" value={naam} icon={<PersonIcon />}
onChange={(e) => { setNaam(e.target.value); setErr(""); }}
onKeyDown={onEnter}
/>
)}
<FloatingInput
label="E-mailadres" type="email" value={email} icon={<MailIcon />}
onChange={(e) => { setEmail(e.target.value); setErr(""); }}
onKeyDown={onEnter}
/>
<FloatingInput
label="Wachtwoord" type={showPass ? "text" : "password"} value={wachtwoord} icon={<LockIcon />}
onChange={(e) => { setWachtwoord(e.target.value); setErr(""); }}
onKeyDown={onEnter}
rightElement={<EyeBtn show={showPass} onToggle={() => setShowPass(s => !s)} />}
/>
{mode === "register" && (
<>
<FloatingInput
label="Herhaal wachtwoord" type={showPass2 ? "text" : "password"} value={herhaalWw} icon={<LockIcon />}
onChange={(e) => { setHerhaalWw(e.target.value); setErr(""); }}
onKeyDown={onEnter}
rightElement={<EyeBtn show={showPass2} onToggle={() => setShowPass2(s => !s)} />}
/>
<div style={{ fontSize: 11, color: "rgba(255,255,255,0.25)", marginTop: 6, paddingLeft: 2 }}>
Min. 8 tekens · één hoofdletter · één cijfer
</div>
</>
)}
</div>
{err && (
<div style={{
background: "rgba(239,68,68,0.08)", border: "1px solid rgba(239,68,68,0.22)",
borderRadius: 8, padding: "9px 12px", marginTop: 16, fontSize: 12, color: RED,
}}>{err}</div>
)}
{/* Submit */}
<button onClick={handleSubmit} disabled={loading} style={{
width: "100%", padding: "14px",
background: loading ? "rgba(124,58,237,0.3)" : "linear-gradient(135deg, #7c3aed, #a855f7)",
border: "none", borderRadius: 11, color: "#fff",
fontFamily: "'Poppins', system-ui, sans-serif",
fontWeight: 700, fontSize: 15, cursor: loading ? "not-allowed" : "pointer",
boxShadow: loading ? "none" : "0 6px 24px rgba(124,58,237,0.45)",
marginTop: 28, opacity: loading ? 0.7 : 1, transition: "opacity 0.15s",
}}>
{loading
? (mode === "login" ? "Inloggen..." : "Account aanmaken...")
: (mode === "login" ? "Inloggen" : "Account aanmaken")}
</button>
</div>
{/* RIGHT: Purple mockup panel */}
<div style={{
flex: 1,
background: "linear-gradient(145deg, #3b0764 0%, #5b21b6 40%, #7c3aed 80%, #9333ea 100%)",
position: "relative", overflow: "hidden",
display: "flex", flexDirection: "column",
alignItems: "center", justifyContent: "center",
padding: "48px 40px",
}}>
{/* Decorative circles */}
<div style={{ position: "absolute", width: 320, height: 320, borderRadius: "50%", background: "rgba(168,85,247,0.2)", top: -100, right: -80, pointerEvents: "none" }} />
<div style={{ position: "absolute", width: 220, height: 220, borderRadius: "50%", background: "rgba(168,85,247,0.15)", bottom: -40, left: -60, pointerEvents: "none" }} />
<div style={{ position: "absolute", width: 120, height: 120, borderRadius: "50%", background: "rgba(168,85,247,0.12)", top: "42%", left: "8%", pointerEvents: "none" }} />
<div style={{ position: "absolute", width: 60, height: 60, borderRadius: "50%", background: "rgba(255,255,255,0.06)", top: "18%", right: "22%", pointerEvents: "none" }} />
{/* Top badge */}
<div style={{
background: "rgba(255,255,255,0.13)", backdropFilter: "blur(8px)",
borderRadius: 99, padding: "7px 16px", fontSize: 12, fontWeight: 600,
color: "rgba(255,255,255,0.92)", marginBottom: 32,
border: "1px solid rgba(255,255,255,0.18)",
display: "flex", alignItems: "center", gap: 7,
position: "relative", zIndex: 2,
}}>
<span style={{ color: "#fbbf24", fontSize: 14 }}>✦</span>
Jouw vermogen in één oogopslag
</div>
{/* Main glassmorphism card */}
<div style={{
background: "rgba(255,255,255,0.11)", backdropFilter: "blur(16px)",
border: "1px solid rgba(255,255,255,0.18)",
borderRadius: 18, padding: "22px 24px",
width: "100%", maxWidth: 320,
boxShadow: "0 12px 40px rgba(0,0,0,0.3)",
position: "relative", zIndex: 2, marginBottom: 16,
}}>
{/* Card header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 18 }}>
<div>
<div style={{ fontSize: 13, fontWeight: 700, color: "#fff" }}>Vermogensoverzicht</div>
<div style={{ fontSize: 10, color: "rgba(255,255,255,0.5)", marginTop: 2 }}>The Road naar € 500.000</div>
</div>
<div style={{ fontSize: 10, color: "rgba(255,255,255,0.5)", background: "rgba(255,255,255,0.1)", borderRadius: 6, padding: "3px 8px" }}>Mei 2026</div>
</div>
{/* KPI rows */}
{[
{ label: "Eigen Vermogen", val: "€ 445.500", color: "#10b981", icon: "↑" },
{ label: "Schuld", val: "€ 54.500", color: "#f97316", icon: "↓" },
{ label: "Winst", val: "€ 142.500", color: "#a855f7", icon: "↑" },
].map(({ label, val, color, icon }) => (
<div key={label} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{ width: 26, height: 26, borderRadius: 7, background: `${color}22`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 11, color, fontWeight: 700 }}>{icon}</div>
<span style={{ fontSize: 12, color: "rgba(255,255,255,0.7)" }}>{label}</span>
</div>
<span style={{ fontSize: 13, fontWeight: 700, color: "#fff" }}>{val}</span>
</div>
))}
{/* Progress toward goal */}
<div style={{ marginTop: 16, padding: "12px 14px", background: "rgba(0,0,0,0.2)", borderRadius: 10 }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 8 }}>
<span style={{ fontSize: 11, color: "rgba(255,255,255,0.6)" }}>Voortgang naar doel</span>
<span style={{ fontSize: 11, fontWeight: 700, color: "#fff" }}>89%</span>
</div>
<div style={{ height: 6, background: "rgba(255,255,255,0.12)", borderRadius: 99, overflow: "hidden" }}>
<div style={{ width: "89%", height: "100%", background: "linear-gradient(90deg, #a855f7, #c084fc)", borderRadius: 99 }} />
</div>
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 6 }}>
<span style={{ fontSize: 9, color: "rgba(255,255,255,0.35)" }}>€ 0</span>
<span style={{ fontSize: 9, color: "rgba(255,255,255,0.35)" }}>€ 500.000</span>
</div>
</div>
{/* Mini bar chart */}
<div style={{ marginTop: 14 }}>
<div style={{ fontSize: 10, color: "rgba(255,255,255,0.45)", marginBottom: 6 }}>Groei per maand</div>
<MiniBarChart />
</div>
</div>
{/* Floating badges */}
<div style={{ display: "flex", gap: 12, position: "relative", zIndex: 2 }}>
<div style={{
background: "rgba(255,255,255,0.95)", borderRadius: 14, padding: "12px 16px",
boxShadow: "0 8px 24px rgba(0,0,0,0.25)",
}}>
<div style={{ fontSize: 10, color: "#64748b", fontWeight: 600, marginBottom: 3 }}>ROI</div>
<div style={{ fontSize: 18, fontWeight: 800, color: "#10b981" }}>+47,03%</div>
</div>
<div style={{
background: "rgba(255,255,255,0.95)", borderRadius: 14, padding: "12px 16px",
boxShadow: "0 8px 24px rgba(0,0,0,0.25)",
}}>
<div style={{ fontSize: 10, color: "#64748b", fontWeight: 600, marginBottom: 3 }}>Doel</div>
<div style={{ fontSize: 18, fontWeight: 800, color: "#7c3aed" }}>€ 500.000</div>
</div>
</div>
{/* Bottom tagline */}
<div style={{ position: "absolute", bottom: 24, left: 40, right: 40, fontSize: 12, color: "rgba(255,255,255,0.4)", lineHeight: 1.6, textAlign: "center" }}>
Beheer je vermogen, volg je groei en bereik je financiële doelen — allemaal op één plek.
</div>
</div>
</div>
</div>
);
}
+286
View File
@@ -0,0 +1,286 @@
import { useState, useEffect } from "react";
import { TABS } from "../constants/index.js";
import { version, updateCheckUrl } from "../../package.json";
import { useApp } from "../context/AppContext.jsx";
const TAB_ICONS = [
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="1" y="1" width="6" height="6" rx="1.5" stroke="currentColor" strokeWidth="1.5"/>
<rect x="9" y="1" width="6" height="6" rx="1.5" stroke="currentColor" strokeWidth="1.5"/>
<rect x="1" y="9" width="6" height="6" rx="1.5" stroke="currentColor" strokeWidth="1.5"/>
<rect x="9" y="9" width="6" height="6" rx="1.5" stroke="currentColor" strokeWidth="1.5"/>
</svg>,
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<polyline points="1,12 5,7 8,10 11,4 15,6" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round"/>
<polyline points="11,4 15,4 15,8" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round"/>
</svg>,
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="1" y="3" width="14" height="10" rx="2" stroke="currentColor" strokeWidth="1.5"/>
<line x1="1" y1="6.5" x2="15" y2="6.5" stroke="currentColor" strokeWidth="1.5"/>
<line x1="3" y1="10" x2="6" y2="10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>,
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="1.5" y="8" width="3" height="6" rx="1" fill="currentColor"/>
<rect x="6.5" y="5" width="3" height="9" rx="1" fill="currentColor"/>
<rect x="11.5" y="2" width="3" height="12" rx="1" fill="currentColor"/>
</svg>,
];
const GEBRUIKERS_ICON = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="6" cy="5" r="2.5" stroke="currentColor" strokeWidth="1.5"/>
<path d="M1 13c0-2.5 2-4 5-4s5 1.5 5 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
<circle cx="12.5" cy="5" r="2" stroke="currentColor" strokeWidth="1.4"/>
<path d="M14.5 13c0-1.5-.8-2.7-2-3.3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/>
</svg>
);
const INSTELLINGEN_ICON = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path fillRule="evenodd" clipRule="evenodd" d="M6.5 1a.75.75 0 0 0-.743.648l-.16 1.112a5.07 5.07 0 0 0-.88.51l-1.054-.424a.75.75 0 0 0-.9.31L1.263 4.95a.75.75 0 0 0 .165.96l.888.717a5.14 5.14 0 0 0 0 1.018l-.888.717a.75.75 0 0 0-.165.96l1.5 2.598a.75.75 0 0 0 .9.31l1.053-.424c.276.19.572.36.881.51l.16 1.112A.75.75 0 0 0 6.5 14h3a.75.75 0 0 0 .743-.648l.16-1.112c.309-.15.605-.32.88-.51l1.054.424a.75.75 0 0 0 .9-.31l1.5-2.598a.75.75 0 0 0-.165-.96l-.888-.717a5.14 5.14 0 0 0 0-1.018l.888-.717a.75.75 0 0 0 .165-.96l-1.5-2.598a.75.75 0 0 0-.9-.31l-1.053.424a5.07 5.07 0 0 0-.881-.51L9.743 1.648A.75.75 0 0 0 9.5 1h-3Zm1.5 4.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5Z"/>
</svg>
);
const LOGOUT_ICON = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M6 2H3a1 1 0 00-1 1v10a1 1 0 001 1h3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
<polyline points="10,5 13,8 10,11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<line x1="13" y1="8" x2="6" y2="8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
);
const INFO_ICON = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="6.5" stroke="currentColor" strokeWidth="1.5"/>
<line x1="8" y1="7" x2="8" y2="11.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
<circle cx="8" cy="4.5" r="0.8" fill="currentColor"/>
</svg>
);
const MOON_ICON = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M13.5 9.5A6 6 0 016.5 2.5a6 6 0 100 11 6 6 0 007-4z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
function initials(naam = "") {
const parts = naam.trim().split(/\s+/);
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
return naam.slice(0, 2).toUpperCase();
}
function SectionLabel({ children }) {
return (
<div style={{
fontSize: 11, fontWeight: 700, letterSpacing: "0.12em",
textTransform: "uppercase", color: "#3d4966",
padding: "0 12px", marginBottom: 2, marginTop: 20,
}}>
{children}
</div>
);
}
const ACTIVE_BG = "#7c3aed";
const ACTIVE_SHADOW = "0 4px 14px rgba(124,58,237,0.4)";
function NavItem({ active, onClick, icon, label, T, danger }) {
const [hovered, setHovered] = useState(false);
const isLit = active || hovered;
return (
<div
onClick={onClick}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: "flex", alignItems: "center", gap: 10,
padding: "11px 12px", borderRadius: 9, cursor: "pointer", marginBottom: 4,
background: active
? ACTIVE_BG
: hovered ? "rgba(139,92,246,0.08)" : "transparent",
color: active ? "#fff" : hovered ? T.text : (danger ? "#ef4444" : T.muted),
fontWeight: active ? 600 : 400,
fontSize: 15,
boxShadow: active ? ACTIVE_SHADOW : "none",
transition: "all 0.15s",
}}
>
<span style={{ flexShrink: 0, display: "flex", color: "inherit", opacity: active ? 1 : (isLit ? 0.85 : 0.55) }}>{icon}</span>
<span style={{ flex: 1 }}>{label}</span>
</div>
);
}
function OverPopup({ onClose, nieuweVersie, T }) {
return (
<>
{/* Overlay */}
<div onClick={onClose} style={{
position: "fixed", inset: 0, zIndex: 490,
background: "rgba(0,0,0,0.55)", backdropFilter: "blur(4px)",
display: "flex", alignItems: "center", justifyContent: "center",
}}>
{/* Modal */}
<div onClick={(e) => e.stopPropagation()} style={{
background: T.card,
border: `1px solid ${T.border}`,
borderRadius: 16, padding: "28px 28px 24px", width: 300,
boxShadow: "0 24px 60px rgba(0,0,0,0.5)",
position: "relative",
}}>
{/* Sluitknop */}
<button onClick={onClose} style={{
position: "absolute", top: 14, right: 14,
background: "transparent", border: "none", cursor: "pointer",
color: T.muted, fontSize: 20, lineHeight: 1, padding: 4,
}}>×</button>
{/* Logo + naam */}
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 20 }}>
<div style={{
width: 44, height: 44, borderRadius: 12, flexShrink: 0,
background: "rgba(124,58,237,0.15)", border: "1px solid rgba(124,58,237,0.3)",
display: "flex", alignItems: "center", justifyContent: "center",
}}>
<svg width="22" height="22" viewBox="0 0 20 20" fill="none">
<polyline points="2,15 6,9 10,12 14,5 18,8" stroke="#a855f7" strokeWidth="2.2" strokeLinejoin="round" strokeLinecap="round"/>
<line x1="2" y1="17" x2="18" y2="17" stroke="rgba(168,85,247,0.35)" strokeWidth="1.4"/>
</svg>
</div>
<div>
<div style={{ fontSize: 15, fontWeight: 700, color: T.text }}>VibeFinance</div>
</div>
</div>
<div style={{ height: 1, background: T.border, marginBottom: 16 }} />
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
<span style={{ fontSize: 13, color: T.muted }}>Versie</span>
<span style={{ fontSize: 12, fontWeight: 700, color: "#a855f7", background: "rgba(124,58,237,0.15)", borderRadius: 6, padding: "3px 10px" }}>
v{version}
</span>
</div>
{nieuweVersie && (
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
<span style={{ fontSize: 13, color: T.muted }}>Nieuw beschikbaar</span>
<span style={{ fontSize: 12, fontWeight: 700, color: "#f97316", background: "rgba(249,115,22,0.12)", borderRadius: 6, padding: "3px 10px" }}>
v{nieuweVersie}
</span>
</div>
)}
<div style={{ height: 1, background: T.border, margin: "12px 0" }} />
<div style={{ fontSize: 12, color: T.muted, textAlign: "center" }}>© {new Date().getFullYear()} VibeFinance</div>
</div>
</div>
</>
);
}
function DarkModeToggle({ darkMode, setDarkMode, T }) {
return (
<div
onClick={() => setDarkMode(!darkMode)}
style={{
display: "flex", alignItems: "center", gap: 10,
padding: "11px 12px", borderRadius: 9, cursor: "pointer", marginBottom: 4,
color: T.muted, fontSize: 13.5, fontWeight: 500,
transition: "background 0.12s",
}}
onMouseEnter={e => e.currentTarget.style.background = "rgba(255,255,255,0.04)"}
onMouseLeave={e => e.currentTarget.style.background = "transparent"}
>
<span style={{ flexShrink: 0, display: "flex", opacity: 0.6 }}>{MOON_ICON}</span>
<span style={{ flex: 1 }}>Dark Mode</span>
{/* Toggle pill */}
<div style={{
width: 36, height: 20, borderRadius: 99, flexShrink: 0,
background: darkMode ? "#7c3aed" : "rgba(255,255,255,0.15)",
position: "relative", transition: "background 0.2s",
}}>
<div style={{
position: "absolute", top: 3, left: darkMode ? 19 : 3,
width: 14, height: 14, borderRadius: "50%",
background: "#fff", transition: "left 0.2s",
boxShadow: "0 1px 4px rgba(0,0,0,0.3)",
}} />
</div>
</div>
);
}
export default function NavBar() {
const { tab, setTab, setShowUsers, setShowInstellingen, T, logout, darkMode, setDarkMode, currentUser, avatar } = useApp();
const [nieuweVersie, setNieuweVersie] = useState(null);
const [overOpen, setOverOpen] = useState(false);
useEffect(() => {
if (!updateCheckUrl) return;
fetch(updateCheckUrl)
.then(r => r.json())
.then(data => {
const latest = data?.tag_name?.replace(/^v/, "");
if (latest && latest !== version) setNieuweVersie(latest);
})
.catch(() => {});
}, []);
return (
<div style={{
width: 260, minWidth: 260, flexShrink: 0,
height: "100vh", position: "sticky", top: 0,
background: T.sidebar ?? T.card,
borderRight: `1px solid ${T.border}`,
display: "flex", flexDirection: "column",
zIndex: 100,
}}>
{/* Logo */}
<div style={{ padding: "22px 16px 28px", display: "flex", alignItems: "center", gap: 10 }}>
<div style={{
width: 32, height: 32, flexShrink: 0, borderRadius: 9,
background: "rgba(124,58,237,0.15)",
border: "1px solid rgba(124,58,237,0.3)",
display: "flex", alignItems: "center", justifyContent: "center",
}}>
<svg width="18" height="18" viewBox="0 0 20 20" fill="none">
<polyline points="2,15 6,9 10,12 14,5 18,8"
stroke="#a855f7" strokeWidth="2.2"
strokeLinejoin="round" strokeLinecap="round"/>
<line x1="2" y1="17" x2="18" y2="17"
stroke="rgba(168,85,247,0.35)" strokeWidth="1.4"/>
</svg>
</div>
<span style={{ fontSize: 17, fontWeight: 700, color: T.text, letterSpacing: "-0.3px" }}>VibeFinance</span>
</div>
{/* Nav */}
<nav style={{ flex: 1, padding: "0 8px", overflowY: "auto" }}>
{TABS.map((t, i) => (
<NavItem key={t} active={tab === i} onClick={() => setTab(i)} icon={TAB_ICONS[i]} label={t} T={T} />
))}
<NavItem onClick={() => setShowUsers(true)} icon={GEBRUIKERS_ICON} label="Gebruikers" T={T} />
<NavItem onClick={() => setShowInstellingen(true)} icon={INSTELLINGEN_ICON} label="Instellingen" T={T} />
<div style={{ position: "relative" }}>
<NavItem onClick={() => setOverOpen(o => !o)} icon={INFO_ICON} label="Over VibeFinance" active={false} T={T} />
{overOpen && <OverPopup onClose={() => setOverOpen(false)} nieuweVersie={nieuweVersie} T={T} />}
{nieuweVersie && !overOpen && (
<div style={{ position: "absolute", top: 10, right: 26, width: 6, height: 6, borderRadius: "50%", background: "#f97316", pointerEvents: "none" }} />
)}
</div>
</nav>
{/* Uitloggen */}
<div style={{ padding: "8px 8px 12px", borderTop: `1px solid ${T.border}` }}>
<NavItem onClick={logout} icon={LOGOUT_ICON} label="Log Out" T={T} />
</div>
</div>
);
}
+265
View File
@@ -0,0 +1,265 @@
import { useState } from "react";
import { useApp } from "../context/AppContext.jsx";
import { RED } from "../constants/index.js";
const TEAL = "linear-gradient(135deg, #7c3aed, #a855f7)";
const TEAL_SHADOW = "0 4px 14px rgba(124,58,237,0.4)";
function initials(naam = "") {
const parts = naam.trim().split(/\s+/);
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
return naam.slice(0, 2).toUpperCase();
}
const LOCK_ICON = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="3" y="7" width="10" height="8" rx="2" stroke="currentColor" strokeWidth="1.5"/>
<path d="M5 7V5a3 3 0 016 0v2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
);
const LOGOUT_ICON = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M6 2H3a1 1 0 00-1 1v10a1 1 0 001 1h3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
<polyline points="10,5 13,8 10,11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<line x1="13" y1="8" x2="6" y2="8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
);
const CHEVRON = (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<polyline points="5,3 9,7 5,11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
const BACK_ICON = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<polyline points="10,3 6,8 10,13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
function InputField({ label, type = "text", value, onChange, T }) {
return (
<div>
<label style={{ fontSize: 11, color: T.muted, fontWeight: 600, display: "block", marginBottom: 5, textTransform: "uppercase", letterSpacing: "0.06em" }}>{label}</label>
<input type={type} value={value} onChange={onChange} style={{
width: "100%", padding: "10px 12px", borderRadius: 10, fontSize: 13,
background: T.bg, border: `1px solid ${T.border}`, color: T.text, outline: "none",
}} />
</div>
);
}
export default function ProfielPopup({ onClose }) {
const { currentUser, updateProfile, changePassword, logout, T, avatar, setAvatar } = useApp();
const [view, setView] = useState("main");
const [naam, setNaam] = useState(currentUser?.naam || "");
const [email, setEmail] = useState(currentUser?.email || "");
const [profielErr, setProfielErr] = useState("");
const [profielOk, setProfielOk] = useState(false);
const [profielBusy, setProfielBusy] = useState(false);
const [huidig, setHuidig] = useState("");
const [nieuw, setNieuw] = useState("");
const [bevestig, setBevestig] = useState("");
const [pwErr, setPwErr] = useState("");
const [pwOk, setPwOk] = useState(false);
const [pwBusy, setPwBusy] = useState(false);
const saveProfiel = async () => {
setProfielErr(""); setProfielOk(false);
if (!naam.trim() || !email.trim()) { setProfielErr("Naam en e-mail zijn verplicht."); return; }
setProfielBusy(true);
try { await updateProfile(naam.trim(), email.trim()); setProfielOk(true); }
catch (err) { setProfielErr(err.message || "Opslaan mislukt."); }
finally { setProfielBusy(false); }
};
const saveWachtwoord = async () => {
setPwErr(""); setPwOk(false);
if (!huidig || !nieuw || !bevestig) { setPwErr("Vul alle velden in."); return; }
if (nieuw !== bevestig) { setPwErr("Nieuwe wachtwoorden komen niet overeen."); return; }
if (nieuw.length < 8) { setPwErr("Minimaal 8 tekens."); return; }
setPwBusy(true);
try { await changePassword(huidig, nieuw); setPwOk(true); setHuidig(""); setNieuw(""); setBevestig(""); }
catch (err) { setPwErr(err.message || "Wijzigen mislukt."); }
finally { setPwBusy(false); }
};
const popupStyle = {
position: "fixed", top: 64, right: 16, zIndex: 199,
background: T.card, border: `1px solid ${T.border}`,
borderRadius: 16, width: 340,
boxShadow: "0 16px 48px rgba(0,0,0,0.45)",
overflow: "hidden",
};
const headerStyle = {
display: "flex", alignItems: "center", justifyContent: "center",
padding: "16px 16px 14px", position: "relative",
borderBottom: `1px solid ${T.border}`,
};
const closeBtn = (
<button onClick={onClose} style={{
position: "absolute", right: 14, top: "50%", transform: "translateY(-50%)",
background: "transparent", border: "none", cursor: "pointer",
color: T.muted, fontSize: 20, lineHeight: 1, padding: 4,
}}>×</button>
);
const backBtn = (label) => (
<button onClick={() => setView("main")} style={{
position: "absolute", left: 12, top: "50%", transform: "translateY(-50%)",
background: "transparent", border: "none", cursor: "pointer",
color: T.muted, display: "flex", alignItems: "center", padding: 4,
}}>{BACK_ICON}</button>
);
const actionRow = (icon, label, onClick, color) => (
<div onClick={onClick} style={{
display: "flex", alignItems: "center", gap: 12,
padding: "14px 18px", cursor: "pointer",
color: color || T.text,
}}>
<span style={{ color: color || T.muted, display: "flex" }}>{icon}</span>
<span style={{ flex: 1, fontWeight: 600, fontSize: 14 }}>{label}</span>
{!color && <span style={{ color: T.muted, display: "flex" }}>{CHEVRON}</span>}
</div>
);
return (
<>
<div onClick={onClose} style={{ position: "fixed", inset: 0, zIndex: 198 }} />
<div style={popupStyle}>
{/* ── Hoofdscherm ── */}
{view === "main" && (
<>
<div style={headerStyle}>
<span style={{ fontWeight: 700, fontSize: 15, color: T.text }}>Mijn account</span>
{closeBtn}
</div>
{/* Avatar + naam + email */}
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", padding: "24px 18px 20px", gap: 6 }}>
<div style={{
width: 72, height: 72, borderRadius: "50%", overflow: "hidden",
background: avatar ? "transparent" : TEAL,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 24, fontWeight: 800, color: "#fff",
boxShadow: "0 0 20px rgba(124,58,237,0.4)", marginBottom: 4,
}}>
{avatar
? <img src={avatar} alt="avatar" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
: initials(currentUser?.naam)
}
</div>
<div style={{ fontWeight: 700, fontSize: 16, color: T.text }}>{currentUser?.naam}</div>
<div style={{ fontSize: 13, color: T.muted }}>{currentUser?.email}</div>
<button onClick={() => setView("profiel")} style={{
marginTop: 8, padding: "9px 24px",
background: TEAL,
border: "none", borderRadius: 10, color: "#fff",
fontWeight: 700, fontSize: 13, cursor: "pointer",
boxShadow: TEAL_SHADOW,
}}>Profiel bewerken</button>
</div>
<div style={{ height: 1, background: T.border }} />
{actionRow(LOCK_ICON, "Wachtwoord wijzigen", () => setView("wachtwoord"))}
</>
)}
{/* ── Profiel bewerken ── */}
{view === "profiel" && (
<>
<div style={headerStyle}>
{backBtn()}
<span style={{ fontWeight: 700, fontSize: 15, color: T.text }}>Profiel bewerken</span>
{closeBtn}
</div>
<div style={{ padding: "20px 18px", display: "flex", flexDirection: "column", gap: 14 }}>
{/* Avatar — klikbaar om foto te uploaden */}
<div style={{ display: "flex", justifyContent: "center", marginBottom: 4 }}>
<label style={{ cursor: "pointer", position: "relative", display: "block" }} title="Klik om foto te uploaden">
<div style={{
width: 64, height: 64, borderRadius: "50%", overflow: "hidden",
background: avatar ? "transparent" : TEAL,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 20, fontWeight: 800, color: "#fff",
}}>
{avatar
? <img src={avatar} alt="avatar" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
: initials(currentUser?.naam)
}
</div>
<div style={{
position: "absolute", inset: 0, borderRadius: "50%",
background: "rgba(0,0,0,0.35)", display: "flex", alignItems: "center", justifyContent: "center",
opacity: 0, transition: "opacity 0.15s",
}} className="avatar-overlay">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M13 3H7L5 6H2a1 1 0 00-1 1v9a1 1 0 001 1h16a1 1 0 001-1V7a1 1 0 00-1-1h-3L13 3z" stroke="#fff" strokeWidth="1.5" strokeLinejoin="round"/>
<circle cx="10" cy="11" r="2.5" stroke="#fff" strokeWidth="1.5"/>
</svg>
</div>
<input type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => {
const file = e.target.files?.[0]; if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => setAvatar(ev.target.result);
reader.readAsDataURL(file);
}} />
</label>
</div>
<style>{`.avatar-overlay { opacity: 0 !important; } label:hover .avatar-overlay { opacity: 1 !important; }`}</style>
{avatar && (
<button onClick={() => setAvatar(null)} style={{
alignSelf: "center", padding: "4px 12px", background: "transparent",
border: `1px solid ${T.border}`, borderRadius: 8,
color: T.muted, fontSize: 12, fontWeight: 600, cursor: "pointer",
}}>Foto verwijderen</button>
)}
<InputField label="Naam" value={naam} onChange={(e) => { setNaam(e.target.value); setProfielOk(false); }} T={T} />
<InputField label="E-mail" type="email" value={email} onChange={(e) => { setEmail(e.target.value); setProfielOk(false); }} T={T} />
{profielErr && <div style={{ fontSize: 12, color: RED }}>{profielErr}</div>}
{profielOk && <div style={{ fontSize: 12, color: "#10b981" }}>Profiel opgeslagen.</div>}
<button onClick={saveProfiel} disabled={profielBusy} style={{
padding: "11px", background: TEAL,
border: "none", borderRadius: 10, color: "#fff", fontWeight: 700,
fontSize: 13, cursor: profielBusy ? "default" : "pointer", opacity: profielBusy ? 0.7 : 1,
}}>{profielBusy ? "Opslaan…" : "Opslaan"}</button>
</div>
</>
)}
{/* ── Wachtwoord wijzigen ── */}
{view === "wachtwoord" && (
<>
<div style={headerStyle}>
{backBtn()}
<span style={{ fontWeight: 700, fontSize: 15, color: T.text }}>Wachtwoord wijzigen</span>
{closeBtn}
</div>
<div style={{ padding: "20px 18px", display: "flex", flexDirection: "column", gap: 14 }}>
<InputField label="Huidig wachtwoord" type="password" value={huidig} onChange={(e) => { setHuidig(e.target.value); setPwOk(false); }} T={T} />
<InputField label="Nieuw wachtwoord" type="password" value={nieuw} onChange={(e) => { setNieuw(e.target.value); setPwOk(false); }} T={T} />
<InputField label="Bevestig nieuw" type="password" value={bevestig} onChange={(e) => { setBevestig(e.target.value); setPwOk(false); }} T={T} />
{pwErr && <div style={{ fontSize: 12, color: RED }}>{pwErr}</div>}
{pwOk && <div style={{ fontSize: 12, color: "#10b981" }}>Wachtwoord gewijzigd.</div>}
<button onClick={saveWachtwoord} disabled={pwBusy} style={{
padding: "11px", background: TEAL,
border: "none", borderRadius: 10, color: "#fff", fontWeight: 700,
fontSize: 13, cursor: pwBusy ? "default" : "pointer", opacity: pwBusy ? 0.7 : 1,
}}>{pwBusy ? "Opslaan…" : "Opslaan"}</button>
</div>
</>
)}
</div>
</>
);
}
+498
View File
@@ -0,0 +1,498 @@
import { useRef, useState, useCallback, useContext, createContext } from "react";
import { PURPLE, PURPLE_LIGHT, EMOJI_OPTIONS } from "../../constants/index.js";
const DragContext = createContext(null);
// ── VCard ─────────────────────────────────────────────────────────────────────
export function VCard({ children, style = {} }) {
return (
<div style={{
borderRadius: 12, padding: "16px 18px",
boxShadow: "none",
...style,
}}>
{children}
</div>
);
}
// ── VBadge ────────────────────────────────────────────────────────────────────
export function VBadge({ label, color = PURPLE }) {
return (
<span style={{
background: `${color}22`, color,
border: `1px solid ${color}44`,
borderRadius: 99, padding: "2px 10px",
fontSize: 11, fontWeight: 700,
}}>
{label}
</span>
);
}
// ── TabBtn ────────────────────────────────────────────────────────────────────
export function TabBtn({ active, onClick, children, darkMode = true }) {
return (
<button
onClick={onClick}
style={{
padding: "8px 16px", border: "none", borderRadius: 8, cursor: "pointer",
fontSize: 13, fontWeight: active ? 700 : 500,
background: active ? `${PURPLE}22` : "transparent",
color: active ? PURPLE_LIGHT : (darkMode ? "#94a3b8" : "#64748b"),
borderBottom: active ? `2px solid ${PURPLE_LIGHT}` : "2px solid transparent",
transition: "all 0.15s",
}}
>
{children}
</button>
);
}
// ── VProgressBar ──────────────────────────────────────────────────────────────
export function VProgressBar({ value, max, color = PURPLE }) {
const p = Math.min((value / (max || 1)) * 100, 100);
return (
<div style={{ height: 8, background: "rgba(255,255,255,0.07)", borderRadius: 99, overflow: "hidden" }}>
<div style={{
height: "100%", width: `${p}%`, background: color,
borderRadius: 99, transition: "width 0.5s ease",
}} />
</div>
);
}
// ── DonutChart ────────────────────────────────────────────────────────────────
export function DonutChart({ segments, total, size = 120, label, centerVal }) {
const strokeW = size * 0.155;
const r = (size - strokeW) / 2 - 1;
const cx = size / 2, cy = size / 2;
const circ = 2 * Math.PI * r;
const active = segments.filter((s) => s.val > 0);
const GAP = active.length > 1 ? circ * 0.022 : 0;
let off = 0;
const slices = active.map((s) => {
const raw = (s.val / total) * circ;
const dash = Math.max(raw - GAP, 2);
const sl = { ...s, dash, offset: off };
off += raw;
return sl;
});
const glowId = `dgl${size}`;
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ overflow: "visible" }}>
<defs>
<filter id={glowId} x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation={size * 0.022} />
</filter>
</defs>
{/* Dark inner fill */}
<circle cx={cx} cy={cy} r={r - strokeW / 2 + 1} fill="rgba(0,0,0,0.55)" />
{/* Track */}
<circle cx={cx} cy={cy} r={r} fill="none"
stroke="rgba(255,255,255,0.06)" strokeWidth={strokeW} />
{/* Glow pass */}
{slices.map((s, i) => (
<circle key={`g${i}`} cx={cx} cy={cy} r={r} fill="none"
stroke={s.color} strokeWidth={strokeW * 1.5}
strokeDasharray={`${s.dash} ${circ - s.dash}`}
strokeDashoffset={-s.offset + circ / 4}
strokeLinecap="round"
opacity="0.18"
filter={`url(#${glowId})`}
/>
))}
{/* Crisp segments */}
{slices.map((s, i) => (
<circle key={`c${i}`} cx={cx} cy={cy} r={r} fill="none"
stroke={s.color} strokeWidth={strokeW}
strokeDasharray={`${s.dash} ${circ - s.dash}`}
strokeDashoffset={-s.offset + circ / 4}
strokeLinecap="round"
/>
))}
{/* Center label */}
{label && (
<text x={cx} y={cy - size * 0.1} fill="rgba(255,255,255,0.4)"
fontSize={size * 0.09} textAnchor="middle" dominantBaseline="middle"
fontFamily="inherit">
{label}
</text>
)}
{/* Center value */}
{centerVal && (
<text x={cx} y={cy + size * 0.09} fill="#fff"
fontSize={size * 0.155} fontWeight="800" textAnchor="middle" dominantBaseline="middle"
fontFamily="inherit">
{centerVal}
</text>
)}
</svg>
);
}
// ── SvgLineChart ──────────────────────────────────────────────────────────────
export function SvgLineChart({ pts, lines, T, H = 180 }) {
if (pts.length < 2) {
return (
<div style={{ color: T.muted, fontSize: 12, textAlign: "center", padding: "24px 0" }}>
Minimaal 2 datapunten nodig
</div>
);
}
const W = 580, pl = 64, pr = 14, pt2 = 16, pb = 30;
const iW = W - pl - pr, iH = H - pt2 - pb;
const allVals = lines.flatMap((l) => pts.map((p) => p[l.key]));
const mn = Math.min(0, ...allVals);
const mx = Math.max(...allVals) * 1.08 || 1;
const rng = mx - mn || 1;
const xp = (i) => pl + (i / (pts.length - 1)) * iW;
const yp = (v) => pt2 + (1 - (v - mn) / rng) * iH;
const gc = T.bg === "#0f1117" ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.05)";
return (
<div style={{ overflowX: "auto" }}>
<svg viewBox={`0 0 ${W} ${H}`}
style={{ width: "100%", minWidth: 340, height: "auto", display: "block" }}>
<defs>
{lines.map((l) => (
<linearGradient key={l.key} id={`g_${l.key}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={l.color} stopOpacity="0.25" />
<stop offset="100%" stopColor={l.color} stopOpacity="0.02" />
</linearGradient>
))}
</defs>
{[0, 0.25, 0.5, 0.75, 1].map((f) => {
const gy = pt2 + (1 - f) * iH;
const v = mn + f * rng;
return (
<g key={f}>
<line x1={pl} y1={gy} x2={W - pr} y2={gy} stroke={gc} strokeWidth="1" />
<text x={pl - 4} y={gy + 4} fill={T.muted} fontSize="8" textAnchor="end">
{new Intl.NumberFormat("nl-NL", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(v)}
</text>
</g>
);
})}
{lines.map((l) => {
const xs = pts.map((_, i) => xp(i));
const ys = pts.map((p) => yp(p[l.key]));
const ls = xs.map((x, i) => `${x},${ys[i]}`).join(" ");
const ar = `${xs[0]},${pt2 + iH} ${ls} ${xs[xs.length - 1]},${pt2 + iH}`;
return (
<g key={l.key}>
<polygon points={ar} fill={`url(#g_${l.key})`} />
<polyline points={ls} fill="none" stroke={l.color} strokeWidth="2.5"
strokeLinejoin="round" strokeLinecap="round" />
{xs.map((x, i) => (
<circle key={i} cx={x} cy={ys[i]} r="4"
fill={l.color} stroke={T.card} strokeWidth="2" />
))}
</g>
);
})}
{pts.map((p, i) => {
const [y, m, d] = (p.datum || "").split("-");
const label = d ? `${d}-${m}-${y}` : p.datum;
return (
<text key={i} x={xp(i)} y={H - 8} fill={T.muted} fontSize="8" textAnchor="middle">
{label}
</text>
);
})}
</svg>
<div style={{ display: "flex", gap: 20, justifyContent: "center", marginTop: 10 }}>
{lines.map((l) => (
<div key={l.key} style={{ display: "flex", alignItems: "center", gap: 6 }}>
<div style={{ width: 18, height: 3, background: l.color, borderRadius: 2 }} />
<div style={{ width: 8, height: 8, borderRadius: "50%", background: l.color }} />
<span style={{ fontSize: 12, color: T.muted }}>{l.label}</span>
</div>
))}
</div>
</div>
);
}
// ── EthIcon ───────────────────────────────────────────────────────────────────
export function EthIcon({ size = 18, color = "#3b82f6" }) {
const w = size * 0.6, h = size;
return (
<svg width={w} height={h} viewBox="0 0 60 100" xmlns="http://www.w3.org/2000/svg"
style={{ display: "inline-block", verticalAlign: "middle" }}>
<polygon points="30,2 58,50 30,38" fill={color} opacity="1" />
<polygon points="2,50 30,2 30,38" fill={color} opacity="0.6" />
<polygon points="30,62 58,50 30,38" fill={color} opacity="0.6" />
<polygon points="2,50 30,38 30,62" fill={color} opacity="0.3" />
<polygon points="30,98 58,50 30,62" fill={color} opacity="0.6" />
<polygon points="2,50 30,62 30,98" fill={color} opacity="1" />
</svg>
);
}
// ── BtcIcon ───────────────────────────────────────────────────────────────────
export function BtcIcon({ size = 18, color = "#f59e0b" }) {
return (
<svg width={size} height={size} viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"
style={{ display: "inline-block", verticalAlign: "middle" }}>
<circle cx="32" cy="32" r="30" fill={color} />
<text x="32" y="44" textAnchor="middle" fontSize="36" fontWeight="bold"
fontFamily="Arial, sans-serif" fill="#fff">₿</text>
</svg>
);
}
// ── GoldIcon ──────────────────────────────────────────────────────────────────
export function GoldIcon({ size = 24 }) {
return (
<svg width={size} height={size * 0.72} viewBox="0 0 100 72" xmlns="http://www.w3.org/2000/svg"
style={{ display: "inline-block", verticalAlign: "middle" }}>
{/* Bovenkant */}
<polygon points="18,4 82,4 100,20 0,20" fill="#fcd34d" />
{/* Voorkant */}
<rect x="0" y="20" width="100" height="40" fill="#f59e0b" />
{/* Rechterkant */}
<polygon points="100,20 100,60 82,72 82,36" fill="#d97706" />
{/* Linkeronderkant */}
<polygon points="0,60 0,20 18,4 18,44" fill="#fbbf24" />
{/* Onderkant schaduw */}
<polygon points="0,60 82,60 100,72 18,72" fill="#b45309" />
{/* Streep highlight bovenkant */}
<polygon points="28,8 72,8 88,18 12,18" fill="#fde68a" opacity="0.5" />
{/* GOLD tekst */}
<text x="50" y="46" textAnchor="middle" fontSize="18" fontWeight="800"
fontFamily="Arial, sans-serif" fill="#92400e" letterSpacing="2">GOLD</text>
</svg>
);
}
// ── Modal primitieven ─────────────────────────────────────────────────────────
export function Overlay({ children, onClose, zIndex = 400 }) {
return (
<div
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
style={{
position: "fixed", inset: 0,
background: "rgba(0,0,0,0.75)",
zIndex,
}}
>
{children}
</div>
);
}
export function ModalBox({ children, maxWidth = 480 }) {
const ref = useRef(null);
const [pos, setPos] = useState(null);
const dragState = useRef(null);
const startDrag = useCallback((e) => {
if (e.button !== 0) return;
e.preventDefault();
const rect = ref.current.getBoundingClientRect();
dragState.current = {
startX: e.clientX,
startY: e.clientY,
startLeft: rect.left,
startTop: rect.top,
};
const onMove = (e) => {
if (!dragState.current) return;
setPos({
x: dragState.current.startLeft + (e.clientX - dragState.current.startX),
y: dragState.current.startTop + (e.clientY - dragState.current.startY),
});
};
const onUp = () => {
dragState.current = null;
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
}, []);
const posStyle = pos
? { left: pos.x, top: pos.y, transform: "none" }
: { left: "50%", top: "50%", transform: "translate(-50%, -50%)" };
return (
<DragContext.Provider value={startDrag}>
<div ref={ref} style={{
position: "fixed",
...posStyle,
background: "#1a1d27",
border: "1px solid rgba(255,255,255,0.07)",
borderRadius: 18, width: "100%", maxWidth,
boxShadow: "0 24px 64px rgba(0,0,0,0.5)",
overflow: "hidden",
}}>
{children}
</div>
</DragContext.Provider>
);
}
export function ModalHeader({ icon, iconBg, title, subtitle, subtitleColor, onClose, headerBg, T }) {
const startDrag = useContext(DragContext);
return (
<div
onMouseDown={startDrag}
style={{
display: "flex", alignItems: "center",
justifyContent: "space-between",
padding: "18px 22px",
borderBottom: `1px solid ${T.border}`,
background: headerBg,
cursor: "grab",
userSelect: "none",
}}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{
width: 32, height: 32, borderRadius: 8,
background: `${iconBg}33`,
display: "flex", alignItems: "center",
justifyContent: "center", fontSize: 16,
}}>
{icon}
</div>
<div>
<div style={{ fontWeight: 700, fontSize: 15, color: T.text }}>{title}</div>
<div style={{ fontSize: 11, color: subtitleColor, fontWeight: 600 }}>{subtitle}</div>
</div>
</div>
<button onClick={onClose} onMouseDown={(e) => e.stopPropagation()} style={{
background: "transparent", border: "none",
color: T.muted, cursor: "pointer",
fontSize: 22, lineHeight: 1, padding: 4,
}}>×</button>
</div>
);
}
export function ModalFooter({ children, T }) {
return (
<div style={{
display: "flex", gap: 10,
padding: "14px 22px",
borderTop: `1px solid ${T.border}`,
}}>
{children}
</div>
);
}
// ── AppIcon — rendert emoji-string of speciale SVG-iconen ────────────────────
export function AppIcon({ value, fallback = "🏷", size = 20 }) {
if (value === "__BTC__") return <BtcIcon size={size} color="#f59e0b" />;
if (value === "__ETH__") return <EthIcon size={size} color="#627eea" />;
if (value === "__GOLD__") return <GoldIcon size={size * 1.4} />;
if (value) return <span style={{ fontSize: size, lineHeight: 1 }}>{value}</span>;
return <span style={{ fontSize: size, lineHeight: 1 }}>{fallback}</span>;
}
// ── EmojiPicker ───────────────────────────────────────────────────────────────
const SPECIAL_ICONS = [
{ key: "__BTC__", node: <BtcIcon size={24} color="#f59e0b" /> },
{ key: "__ETH__", node: <EthIcon size={18} color="#627eea" /> },
{ key: "__GOLD__", node: <GoldIcon size={28} /> },
];
export function EmojiPicker({ selected, T, accentColor, onSelect, onClear }) {
const ac = accentColor || "#f59e0b";
const btnStyle = (active) => ({
width: 34, height: 34, borderRadius: 8,
border: `2px solid ${active ? ac : T.border}`,
background: active ? `${ac}22` : "transparent",
cursor: "pointer", fontSize: 18,
display: "flex", alignItems: "center", justifyContent: "center",
});
return (
<div style={{
background: T.inputBg, border: `1px solid ${T.border}`,
borderRadius: 12, padding: 10, marginTop: 8,
}}>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, maxHeight: 200, overflowY: "auto" }}>
{selected && (
<button onClick={onClear} style={btnStyle(false)}>
<span style={{ fontSize: 13, color: T.muted }}>✕</span>
</button>
)}
{SPECIAL_ICONS.map(({ key, node }) => (
<button key={key} onClick={() => onSelect(key)} style={btnStyle(selected === key)}>
{node}
</button>
))}
{EMOJI_OPTIONS.map((e) => (
<button key={e} onClick={() => onSelect(e)} style={btnStyle(selected === e)}>
{e}
</button>
))}
</div>
</div>
);
}
// ── ConfirmDeleteDialog ───────────────────────────────────────────────────────
export function ConfirmDeleteDialog({ naam, T, onCancel, onConfirm }) {
const RED = "#ef4444";
return (
<div style={{
background: "rgba(239,68,68,0.08)",
border: "1px solid rgba(239,68,68,0.25)",
borderRadius: 10, padding: "12px 14px",
}}>
<div style={{ fontSize: 13, color: RED, fontWeight: 600, marginBottom: 10 }}>
Weet je zeker dat je <strong>{naam}</strong> wilt verwijderen?
</div>
<div style={{ display: "flex", gap: 8 }}>
<button onClick={onCancel} style={{
flex: 1, padding: "8px",
background: "transparent",
border: `1px solid ${T.border}`,
borderRadius: 8, color: T.muted,
cursor: "pointer", fontWeight: 600, fontSize: 13,
}}>Annuleren</button>
<button onClick={onConfirm} style={{
flex: 1, padding: "8px",
background: "rgba(239,68,68,0.15)",
border: "1px solid rgba(239,68,68,0.4)",
borderRadius: 8, color: RED,
cursor: "pointer", fontWeight: 700, fontSize: 13,
}}>Ja, verwijderen</button>
</div>
</div>
);
}
// ── EmptyState ────────────────────────────────────────────────────────────────
export function EmptyState({ icon, text, hint, onClick, locked, T }) {
return (
<div
onClick={!locked ? onClick : undefined}
style={{
textAlign: "center", padding: "36px 0",
color: T.muted,
cursor: locked ? "default" : "pointer",
border: `2px dashed ${T.border}`,
borderRadius: 12,
}}
>
<div style={{ fontSize: 32, marginBottom: 10 }}>{icon}</div>
<div style={{ fontSize: 13, marginBottom: 4 }}>{text}</div>
{!locked && hint && (
<div style={{ fontSize: 12, color: PURPLE_LIGHT }}>{hint}</div>
)}
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
// ── Kleuren ──────────────────────────────────────────────────────────────────
export const PURPLE = "#7c3aed";
export const PURPLE_LIGHT = "#8b5cf6";
export const GREEN = "#10b981";
export const RED = "#ef4444";
export const GOLD = "#f59e0b";
export const BLUE = "#3b82f6";
export const CAT_COLORS = [
"#3b82f6", "#8b5cf6", "#f59e0b", "#10b981",
"#06b6d4", "#f97316", "#ec4899", "#84cc16",
];
// ── Emoji picker opties ───────────────────────────────────────────────────────
export const EMOJI_OPTIONS = [
"📈","📉","💰","💵","🏠","🏡","🏦","💎","🪙","🥇","🥈","🥉",
"📊","💹","🌍","🚀","⚡","🔋","🛢","🌾","☕","🍷","🎯","🎲","🎪",
"🏋","🏄","🚗","✈️","🛳","🎸","🎓","💡","🔑","🌱",
"🌊","🔥","❄️","🌈","⭐","🦁","🐉","🦋","🐝","🍀","🌻","💫","✨",
"💸","💝","🌐","🔗","🏅","🪙","💼","📌",
];
// ── Navigatie tabs ────────────────────────────────────────────────────────────
export const TABS = [
"Dashboard",
"Eigen Vermogen",
"Schuld",
"Voortgang Schuld & EV",
];
// ── Categorie-sleutels (EV voortgang tabel) ───────────────────────────────────
export const CAT_KEYS = [
{ key: "aandelen", label: "Aandelen", color: "#3b82f6" },
{ key: "crypto", label: "Crypto", color: "#8b5cf6" },
{ key: "commodities", label: "Commodities", color: "#f59e0b" },
{ key: "vastgoed", label: "Vastgoed", color: "#10b981" },
{ key: "crowdfunding", label: "Crowdfunding", color: "#06b6d4" },
{ key: "teBesteden", label: "Te Besteden", color: "#64748b" },
];
// ── Demo gebruikers (alleen frontend – backend heeft eigen store) ─────────────
export const DEMO_USERS = [
{ id: "u1", email: "admin@vibefinance.nl", wachtwoord: "Admin123!", naam: "Beheerder", rol: "Admin" },
{ id: "u2", email: "gebruiker@vibefinance.nl", wachtwoord: "Vibe2024!", naam: "Gebruiker", rol: "Viewer" },
];
// ── Initiële app-data (fallback als backend niet bereikbaar is) ───────────────
export const INITIAL_DATA = {
eigenVermogen: {
doel: 500000,
categories: [
{ id: "c1", naam: "Aandelen", color: "#3b82f6", items: [] },
{ id: "c2", naam: "Crypto", color: "#8b5cf6", items: [] },
{ id: "c3", naam: "Commodities", color: "#f59e0b", items: [] },
{ id: "c4", naam: "Vastgoed", color: "#10b981", items: [] },
{ id: "c5", naam: "Crowdfunding", color: "#06b6d4", items: [] },
{ id: "c6", naam: "Te Besteden", color: "#64748b", items: [] },
],
},
schuld: {
initieleSchuld: 303000,
schuldHistory: [],
blokken: [
{ id: "s1", naam: "Hypotheek", color: "#ef4444", items: [
{ id: "s1i1", naam: "Leningdeel 1", waarde: 0 },
{ id: "s1i2", naam: "Leningdeel 2", waarde: 0 },
{ id: "s1i3", naam: "Leningdeel 3", waarde: 0 },
{ id: "s1i4", naam: "Leningdeel 4", waarde: 0 },
]},
{ id: "s2", naam: "Belastingdienst", color: "#f97316", items: [
{ id: "s2i1", naam: "Kinderopvangtoeslag", waarde: 0 },
{ id: "s2i2", naam: "Sub-categorie 2", waarde: 0 },
]},
],
},
};
+226
View File
@@ -0,0 +1,226 @@
import { createContext, useContext, useState, useMemo, useRef } from "react";
import JSZip from "jszip";
import { INITIAL_DATA } from "../constants/index.js";
import { migrateData } from "../utils/storage.js";
import { useTheme } from "../hooks/useTheme.js";
const AppContext = createContext(null);
// ── API helpers ───────────────────────────────────────────────────────────────
const getToken = () => localStorage.getItem("vf_token");
const authHdrs = () => ({ "Content-Type": "application/json", Authorization: `Bearer ${getToken()}` });
async function apiFetch(path, options = {}) {
const res = await fetch(`/api${path}`, { ...options, headers: { ...authHdrs(), ...(options.headers || {}) } });
if (!res.ok) throw new Error(`API ${path} → ${res.status}`);
return res.json();
}
// ── Provider ──────────────────────────────────────────────────────────────────
export function AppProvider({ children }) {
// ── Auth ──────────────────────────────────────────────────────
const [loggedIn, setLoggedIn] = useState(false);
const [currentUser, setCurrentUser] = useState(null);
const [loading, setLoading] = useState(false);
const [avatar, setAvatarRaw] = useState(null);
const setAvatar = async (base64) => {
setAvatarRaw(base64);
try {
await apiFetch("/auth/me/avatar", { method: "PATCH", body: JSON.stringify({ avatar: base64 ?? null }) });
} catch (err) {
console.error("Avatar opslaan mislukt:", err);
}
};
// ── UI ────────────────────────────────────────────────────────
const [darkMode, setDarkMode] = useState(true);
const [locked, setLocked] = useState(false);
const [tab, setTab] = useState(0);
const [menuOpen, setMenuOpen] = useState(false);
const [showUsers, setShowUsers] = useState(false);
const [showInstellingen, setShowInstellingen] = useState(false);
const [idleMinutes, setIdleMinutes] = useState(30);
// ── Financiële data ───────────────────────────────────────────
const [data, setDataRaw] = useState(INITIAL_DATA);
const [evRows, setEvRowsRaw] = useState([]);
const [evVoortgangHistory, setEvVoortgangHistoryRaw] = useState([]);
const saveTimer = useRef(null);
const saveToApi = (newData, newEvRows, newEvVH) => {
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(async () => {
try {
await apiFetch("/data", {
method: "PUT",
body: JSON.stringify({ ...newData, evVoortgang: newEvRows, evVoortgangHistory: newEvVH }),
});
} catch (err) {
console.error("Opslaan mislukt:", err);
}
}, 800);
};
const setData = (next) => {
const d = typeof next === "function" ? next(data) : next;
setDataRaw(d);
saveToApi(d, evRows, evVoortgangHistory);
};
const setEvRows = (rows) => {
const r = typeof rows === "function" ? rows(evRows) : rows;
setEvRowsRaw(r);
saveToApi(data, r, evVoortgangHistory);
};
const setEvVoortgangHistory = (history) => {
const h = typeof history === "function" ? history(evVoortgangHistory) : history;
setEvVoortgangHistoryRaw(h);
saveToApi(data, evRows, h);
};
// ── Gebruikers ────────────────────────────────────────────────
const [users, setUsersRaw] = useState(() => {
try { return JSON.parse(localStorage.getItem("wbjw_users")) || []; } catch { return []; }
});
const setUsers = (u) => { localStorage.setItem("wbjw_users", JSON.stringify(u)); setUsersRaw(u); };
// ── Thema ─────────────────────────────────────────────────────
const T = useTheme(darkMode);
// ── Afgeleide waarden ─────────────────────────────────────────
const ev = data.eigenVermogen;
const schuld = data.schuld;
const cats = ev?.categories || [];
const schuldBlokken = schuld?.blokken || [];
const schuldHistory = schuld?.schuldHistory || [];
const totEV = useMemo(
() => cats.reduce((a, c) => a + c.items.reduce((b, i) => b + (i.waarde || 0), 0), 0),
[cats]
);
const totSchuld = useMemo(
() => schuldBlokken.reduce((a, b) => a + b.items.reduce((c, i) => c + (i.waarde || 0), 0), 0),
[schuldBlokken]
);
const catTotals = useMemo(
() => cats.map((c) => ({ ...c, tot: c.items.reduce((a, i) => a + (i.waarde || 0), 0) })),
[cats]
);
// ── Auth handlers ─────────────────────────────────────────────
const login = async (user) => {
setCurrentUser(user);
setLoggedIn(true);
setAvatarRaw(user.avatar ?? null);
setLoading(true);
try {
const apiData = await apiFetch("/data");
const migrated = migrateData({
eigenVermogen: apiData.eigenVermogen,
schuld: apiData.schuld,
});
setDataRaw(migrated);
setEvRowsRaw(apiData.evVoortgang || []);
setEvVoortgangHistoryRaw(apiData.evVoortgangHistory || []);
} catch (err) {
console.error("Data laden mislukt:", err);
} finally {
setLoading(false);
}
};
const updateProfile = async (naam, email) => {
const res = await apiFetch("/auth/me", { method: "PATCH", body: JSON.stringify({ naam, email }) });
localStorage.setItem("vf_token", res.token);
setCurrentUser(res.user);
};
const changePassword = async (huidig, nieuw) => {
await apiFetch("/auth/me/password", { method: "POST", body: JSON.stringify({ huidig, nieuw }) });
};
const logout = () => {
setCurrentUser(null);
setAvatarRaw(null);
setLoggedIn(false);
setDataRaw(INITIAL_DATA);
setEvRowsRaw([]);
setEvVoortgangHistoryRaw([]);
localStorage.removeItem("vf_token");
if (saveTimer.current) clearTimeout(saveTimer.current);
};
// ── Backup / restore ──────────────────────────────────────────
const doBackup = async () => {
const zip = new JSZip();
const datum = new Date().toISOString().slice(0, 10);
zip.file("data.json", JSON.stringify({ ...data, evVoortgang: evRows, evVoortgangHistory }, null, 2));
zip.file("meta.json", JSON.stringify({ versie: 1, datum, app: "vibefinance", gebruiker: currentUser?.email }, null, 2));
const blob = await zip.generateAsync({ type: "blob", compression: "DEFLATE" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `vibefinance_backup_${datum}.zip`;
a.click();
URL.revokeObjectURL(url);
};
const doRestore = () => {
const input = document.createElement("input");
input.type = "file";
input.accept = ".zip";
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const zip = await JSZip.loadAsync(file);
const entry = zip.file("data.json");
if (!entry) throw new Error("Geen data.json gevonden in backup.");
const parsed = JSON.parse(await entry.async("string"));
const migrated = migrateData({ eigenVermogen: parsed.eigenVermogen, schuld: parsed.schuld });
const newEvRows = parsed.evVoortgang || [];
const newEvVH = parsed.evVoortgangHistory || [];
setDataRaw(migrated);
setEvRowsRaw(newEvRows);
setEvVoortgangHistoryRaw(newEvVH);
await apiFetch("/data", {
method: "PUT",
body: JSON.stringify({ ...migrated, evVoortgang: newEvRows, evVoortgangHistory: newEvVH }),
});
} catch (err) { alert("Ongeldig of beschadigd backup-bestand: " + err.message); }
};
input.click();
};
return (
<AppContext.Provider value={{
loggedIn, currentUser, login, logout, loading, updateProfile, changePassword,
avatar, setAvatar,
darkMode, setDarkMode,
locked, setLocked,
tab, setTab,
menuOpen, setMenuOpen,
showUsers, setShowUsers,
showInstellingen, setShowInstellingen,
idleMinutes, setIdleMinutes,
T,
data, setData,
ev, schuld, cats, schuldBlokken, schuldHistory,
totEV, totSchuld, catTotals,
evRows, setEvRows,
evVoortgangHistory, setEvVoortgangHistory,
users, setUsers,
doBackup, doRestore,
}}>
{children}
</AppContext.Provider>
);
}
export const useApp = () => {
const ctx = useContext(AppContext);
if (!ctx) throw new Error("useApp must be used inside <AppProvider>");
return ctx;
};
+23
View File
@@ -0,0 +1,23 @@
/**
* Geeft een thema-object (T) terug op basis van darkMode.
* Wordt gebruikt door alle componenten om consistent kleuren te krijgen.
*/
export function useTheme(darkMode) {
return {
// Exacte Finex kleuren (dark) / licht alternatief
bg: darkMode ? "#241A39" : "#eef1f8",
card: darkMode ? "rgba(22,23,27,0.30)" : "#ffffff",
sidebar: darkMode ? "#16171B" : "#f8faff",
border: darkMode ? "rgba(139,92,246,0.15)" : "rgba(0,0,0,0.07)",
muted: darkMode ? "#8b8fa8" : "#94a3b8",
text: darkMode ? "#ffffff" : "#0f172a",
subtext: darkMode ? "#a0aec0" : "#64748b",
inputBg: darkMode ? "rgba(139,92,246,0.08)" : "#f1f5ff",
inputBorder:darkMode ? "rgba(139,92,246,0.2)" : "#e2e8f0",
contentBg: darkMode ? "#241A39" : "#eef1f8",
shadow: darkMode ? "0 4px 24px rgba(124,58,237,0.25)" : "0 4px 20px rgba(0,0,0,0.07)",
cardShadow: darkMode ? "0 4px 20px rgba(124,58,237,0.2)" : "0 2px 8px rgba(0,0,0,0.06)",
radius: "14px",
font: "'Poppins', system-ui, sans-serif",
};
}
+9
View File
@@ -0,0 +1,9 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
createRoot(document.getElementById("root")).render(
<StrictMode>
<App />
</StrictMode>
);
+258
View File
@@ -0,0 +1,258 @@
import { useState } from "react";
import { useApp } from "../context/AppContext.jsx";
import { fmt, pct, fmtDatum } from "../utils/format.js";
import { GREEN, RED } from "../constants/index.js";
const TEAL = "#a855f7";
import { VCard, VBadge } from "../components/ui/index.jsx";
export default function DashboardTab() {
const { ev, totEV, evRows, cats, T, darkMode, currentUser } = useApp();
const hour = new Date().getHours();
const dagdeel = hour < 12 ? "Goedemorgen" : hour < 18 ? "Goedemiddag" : "Goedenavond";
const rawNaam = currentUser?.naam?.trim().split(/\s+/)[0] || "";
const voornaam = rawNaam ? rawNaam.charAt(0).toUpperCase() + rawNaam.slice(1) : "";
const catSum = (row) =>
cats.reduce((a, c) => a + (row[c.id] || 0), 0);
const startEV = evRows.length > 0 ? (evRows[evRows.length - 1]?.nettoWaarde || 0) : 0;
const extraFiat = evRows.length > 1
? evRows.slice(0, -1).reduce((acc, row) => acc + catSum(row), 0) : 0;
const totaalFiat = startEV + extraFiat;
const winst = totEV - totaalFiat;
const roi = totaalFiat > 0 ? winst / totaalFiat : 0;
const doel = ev.doel || 1;
const progressPct = Math.min(totEV / doel, 1);
const milestones = [0, 0.25, 0.5, 0.75, 1].map((f) => f * doel);
const kpis = [
{ label: "Start eigen vermogen", val: fmt(startEV), accent: TEAL},
{ label: "Extra geïnvesteerde fiat", val: fmt(extraFiat), accent: TEAL},
{ label: "Totaal geïnvesteerde fiat", val: fmt(totaalFiat), accent: TEAL},
{ label: "Totaal eigen vermogen", val: fmt(totEV), accent: GREEN },
{ label: "Winst op investering", val: fmt(winst), accent: winst >= 0 ? GREEN : RED },
{ label: "ROI %", val: totaalFiat > 0 ? pct(roi, 2) : "—", accent: roi >= 0 ? GREEN : RED },
];
// Minigrafiek data
const vData = [...evRows].sort((a, b) => a.datum.localeCompare(b.datum));
return (
<div>
<div style={{ marginBottom: 24 }}>
<div style={{ fontSize: 14, color: T.muted, marginBottom: 6 }}>
Hi{voornaam ? ` ${voornaam},` : ","}
</div>
<h1 style={{ fontSize: 32, fontWeight: 800, margin: "0 0 32px", color: T.text, letterSpacing: "-0.5px", lineHeight: 1.15 }}>
{dagdeel} 👋
</h1>
<div style={{ fontSize: 32, fontWeight: 800, color: T.text, letterSpacing: "-0.5px", lineHeight: 1.15 }}>
The Road naar {fmt(doel)}
</div>
</div>
{/* KPI grid */}
<div style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 12, marginBottom: 20,
}}>
{kpis.map(({ label, val, accent }) => (
<VCard key={label} style={{
background: `linear-gradient(to right, ${accent}cc 0%, ${accent}44 50%, transparent 80%) bottom / 100% 3px no-repeat, ${T.card}`,
border: `1px solid ${T.border}`,
}}>
<div style={{
fontSize: 11, color: T.muted, marginBottom: 6,
textTransform: "uppercase", letterSpacing: "0.05em",
}}>{label}</div>
<div style={{ fontSize: 20, fontWeight: 700, color: accent }}>{val}</div>
</VCard>
))}
</div>
{/* Doel progressie */}
<VCard style={{ background: T.card, border: `1px solid ${T.border}`, marginBottom: 16 }}>
<div style={{
display: "flex", alignItems: "center",
justifyContent: "space-between", marginBottom: 12,
}}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ fontSize: 16 }}>🎯</span>
<span style={{ fontWeight: 600 }}>Doel &amp; Bijbehorende Progressie</span>
<VBadge label={fmt(doel)} color={TEAL} />
</div>
<span style={{ color: T.muted, fontSize: 13 }}>{pct(progressPct)} behaald</span>
</div>
<div style={{
height: 14, background: "rgba(255,255,255,0.07)",
borderRadius: 99, overflow: "hidden",
position: "relative", marginBottom: 8,
}}>
<div style={{
position: "absolute", inset: 0,
width: `${progressPct * 100}%`,
background: "linear-gradient(90deg, #7c3aed, #a855f7)",
borderRadius: 99, transition: "width 0.7s ease",
}} />
{[0.25, 0.5, 0.75].map((f) => (
<div key={f} style={{
position: "absolute", left: `${f * 100}%`,
top: 0, bottom: 0, width: 1,
background: "rgba(0,0,0,0.3)",
}} />
))}
</div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
{milestones.map((m) => (
<span key={m} style={{ fontSize: 11, color: T.muted }}>{fmt(m)}</span>
))}
</div>
</VCard>
{/* EV Grafiek */}
<VCard style={{ background: T.card, border: `1px solid ${T.border}`, marginBottom: 20 }}>
<div style={{ fontWeight: 600, marginBottom: 16 }}>Ontwikkeling van Eigen Vermogen</div>
{vData.length < 2 ? (
<div style={{ textAlign: "center", padding: "32px 0", color: T.muted }}>
<div style={{ fontSize: 28, marginBottom: 8 }}>📈</div>
<div style={{ fontSize: 13 }}>
Leg minimaal 2 voortgangsmomenten vast in het Eigen Vermogen tabblad om de grafiek te tonen.
</div>
</div>
) : (
<MiniLineChart vData={vData} darkMode={darkMode} />
)}
</VCard>
</div>
);
}
// ── 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 ────────────────────────────────────────────────────
function MiniLineChart({ vData, darkMode }) {
const [hovered, setHovered] = useState(null);
const W = 700, H = 230, pl = 62, pr = 16, pt = 16, pb = 52;
const vals = vData.map((d) => d.nettoWaarde || 0);
const mn = Math.min(...vals) * 0.95;
const mx = Math.max(...vals) * 1.05;
const rng = mx - mn || 1;
const iW = W - pl - pr, iH = H - pt - pb;
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 linePath = crPath(xs, ys);
const areaPath = `${linePath} L ${xs[xs.length-1]},${pt+iH} L ${xs[0]},${pt+iH} Z`;
const gc = darkMode ? "rgba(255,255,255,0.07)" : "rgba(0,0,0,0.06)";
const lc = darkMode ? "#6b7280" : "#94a3b8";
const handleMouseMove = (e) => {
const rect = e.currentTarget.getBoundingClientRect();
const svgX = ((e.clientX - rect.left) / rect.width) * W;
const dists = xs.map((x) => Math.abs(x - svgX));
setHovered(dists.indexOf(Math.min(...dists)));
};
const h = hovered;
const tipW = 114, tipH = 46;
const tipX = h !== null ? Math.min(Math.max(xs[h] - tipW / 2, pl), W - tipW - 4) : 0;
const tipY = h !== null ? (ys[h] - tipH - 14 < pt ? ys[h] + 14 : ys[h] - tipH - 14) : 0;
return (
<svg
viewBox={`0 0 ${W} ${H}`}
style={{ width: "100%", height: "auto", display: "block", cursor: "crosshair" }}
onMouseMove={handleMouseMove}
onMouseLeave={() => setHovered(null)}
>
<defs>
<linearGradient id="chartLineGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#0eddd4" />
<stop offset="100%" stopColor="#8b5cf6" />
</linearGradient>
<linearGradient id="chartFadeMask" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="white" stopOpacity="1" />
<stop offset="100%" stopColor="white" stopOpacity="0" />
</linearGradient>
<mask id="chartAreaMask">
<rect x={pl} y={pt} width={iW} height={iH} fill="url(#chartFadeMask)" />
</mask>
</defs>
{/* Gestippelde gridlijnen */}
{[0, 0.25, 0.5, 0.75, 1].map((f) => {
const gy = pt + (1 - f) * iH;
return (
<g key={f}>
<line x1={pl} y1={gy} x2={W - pr} y2={gy}
stroke={gc} strokeWidth="1" strokeDasharray="4,4" />
<text x={pl - 6} y={gy + 4} fill={lc} fontSize="9" textAnchor="end">
{fmt(mn + f * rng)}
</text>
</g>
);
})}
{/* Vlakkleur met gradient + fade */}
<path d={areaPath} fill="url(#chartLineGrad)" mask="url(#chartAreaMask)" opacity="0.4" />
{/* Lijn */}
<path d={linePath} fill="none" stroke="url(#chartLineGrad)"
strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
{/* Verticale hover-lijn */}
{h !== null && (
<line x1={xs[h]} y1={pt} x2={xs[h]} y2={pt + iH}
stroke="rgba(255,255,255,0.25)" strokeWidth="1" strokeDasharray="4,3" />
)}
{/* Hover punt */}
{h !== null && (
<circle cx={xs[h]} cy={ys[h]} r="5.5"
fill="#0eddd4" stroke={darkMode ? "#151c2e" : "#fff"} strokeWidth="2.5" />
)}
{/* Tooltip */}
{h !== null && (
<g>
<rect x={tipX} y={tipY} width={tipW} height={tipH} rx="7"
fill={darkMode ? "#1a2035" : "#fff"}
stroke="rgba(14,221,212,0.35)" strokeWidth="1" />
<text x={tipX + tipW / 2} y={tipY + 16} fill={lc} fontSize="9" textAnchor="middle">
{fmtDatum(vData[h].datum)}
</text>
<text x={tipX + tipW / 2} y={tipY + 34} fill="#0eddd4" fontSize="11"
fontWeight="700" textAnchor="middle">
{fmt(vals[h])}
</text>
</g>
)}
{/* X-as labels */}
{xs.map((x, i) => (
<text key={i} x={x} y={H - pb + 16} fill={lc} fontSize="8"
textAnchor="end" transform={`rotate(-45, ${x}, ${H - pb + 16})`}>
{fmtDatum(vData[i].datum)}
</text>
))}
</svg>
);
}
+653
View File
@@ -0,0 +1,653 @@
import { useState } from "react";
import { useApp } from "../context/AppContext.jsx";
import { fmt, fmtDatum } from "../utils/format.js";
import { PURPLE, PURPLE_LIGHT, GREEN, RED, CAT_COLORS } from "../constants/index.js";
import {
VCard, DonutChart, Overlay, ModalBox, ModalHeader, ModalFooter,
EmojiPicker, AppIcon, ConfirmDeleteDialog, EmptyState,
} from "../components/ui/index.jsx";
// ── AanpassenPopup (EV categorie) ─────────────────────────────────────────────
function AanpassenPopup({ cat, onClose }) {
const { data, setData, ev, cats, darkMode, T } = useApp();
const [items, setItems] = useState(cat.items.map((i) => ({ ...i })));
const [catNaam, setCatNaam] = useState(cat.naam);
const [catColor, setCatColor] = useState(cat.color);
const [catIcon, setCatIcon] = useState(cat.icon || "");
const [showEmoji, setShowEmoji] = useState(false);
const [nieuwNaam, setNieuwNaam] = useState("");
const [nieuwVal, setNieuwVal] = useState("");
const [err, setErr] = useState("");
const [confirmDel, setConfirmDel] = useState(false);
const is = inputStyle(T);
const updateItem = (id, field, val) =>
setItems((p) => p.map((i) => i.id !== id ? i : { ...i, [field]: val }));
const removeItemLocal = (id) => {
if (items.length <= 1) return;
setItems((p) => p.filter((i) => i.id !== id));
};
const addNieuw = () => {
if (!nieuwNaam.trim()) { setErr("Naam is verplicht."); return; }
setItems((p) => [...p, { id: `i${Date.now()}`, naam: nieuwNaam.trim(), waarde: parseFloat(nieuwVal) || 0 }]);
setNieuwNaam(""); setNieuwVal(""); setErr("");
};
const handleSave = () => {
const updated = {
...data,
eigenVermogen: {
...ev,
categories: cats.map((c) =>
c.id !== cat.id ? c : {
...c,
naam: catNaam.trim() || c.naam,
color: catColor,
icon: catIcon,
items: items.map((i) => ({ ...i, waarde: parseFloat(i.waarde) || 0 })),
}
),
},
};
setData(updated); onClose();
};
const handleDelete = () => {
setData({ ...data, eigenVermogen: { ...ev, categories: cats.filter((c) => c.id !== cat.id) } });
onClose();
};
return (
<Overlay onClose={onClose}>
<ModalBox>
<ModalHeader
icon="✏️" iconBg={cat.color}
title="Aanpassen" subtitle={cat.naam} subtitleColor={cat.color}
onClose={onClose}
headerBg={darkMode ? `${cat.color}11` : `${cat.color}09`}
T={T}
/>
<div style={{ padding: "20px 22px", overflowY: "auto", maxHeight: "65vh" }}>
{/* Naam + emoji */}
<FieldLabel label="Categorie naam" T={T} />
<div style={{ marginBottom: 16 }}>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<button onClick={() => setShowEmoji((p) => !p)} style={{
width: 38, height: 38, borderRadius: 8,
background: `${cat.color}22`,
border: `2px solid ${showEmoji ? cat.color : cat.color + "44"}`,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 20, flexShrink: 0, cursor: "pointer",
}}>
<AppIcon value={catIcon} fallback="🏷" size={20} />
</button>
<input value={catNaam} onChange={(e) => setCatNaam(e.target.value)}
style={{ ...is, flex: 1, borderLeft: `3px solid ${cat.color}` }}
placeholder="Naam categorie" />
</div>
{showEmoji && (
<EmojiPicker
selected={catIcon} T={T} accentColor={cat.color}
onSelect={(e) => { setCatIcon(e); setShowEmoji(false); }}
onClear={() => { setCatIcon(""); setShowEmoji(false); }}
/>
)}
</div>
{/* Kleur */}
<FieldLabel label="Kleur" T={T} />
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 16 }}>
{CAT_COLORS.map((c) => (
<button key={c} onClick={() => setCatColor(c)} style={{
width: 28, height: 28, borderRadius: "50%", background: c,
border: `3px solid ${catColor === c ? "#fff" : "transparent"}`,
cursor: "pointer", outline: catColor === c ? `2px solid ${c}` : "none", outlineOffset: 1,
}} />
))}
</div>
{/* Bestaande items */}
<FieldLabel label="Items bewerken" T={T} />
{items.map((item) => (
<div key={item.id} style={{
display: "flex", alignItems: "center", gap: 8, marginBottom: 10,
background: darkMode ? "rgba(255,255,255,0.03)" : "#f8fafc",
border: `1px solid ${T.border}`, borderLeft: `3px solid ${cat.color}`,
borderRadius: 10, padding: "10px 12px",
}}>
<input value={item.naam} onChange={(e) => updateItem(item.id, "naam", e.target.value)}
style={{ ...is, flex: 1 }} placeholder="Naam" />
<EuroInput value={item.waarde} onChange={(v) => updateItem(item.id, "waarde", v)} is={is} T={T} />
{items.length > 1 && (
<button onClick={() => removeItemLocal(item.id)} style={deleteSmBtn}>×</button>
)}
</div>
))}
{/* Nieuw item */}
<div style={{ marginTop: 18, paddingTop: 16, borderTop: `1px solid ${T.border}` }}>
<FieldLabel label="Nieuw item toevoegen" T={T} />
<div style={{
display: "flex", alignItems: "center", gap: 8,
background: darkMode ? "rgba(255,255,255,0.03)" : "#f8fafc",
border: `1px solid ${T.border}`, borderLeft: `3px solid ${PURPLE}`,
borderRadius: 10, padding: "10px 12px",
}}>
<input value={nieuwNaam}
onChange={(e) => { setNieuwNaam(e.target.value); setErr(""); }}
placeholder="Naam" style={{ ...is, flex: 1 }} />
<EuroInput value={nieuwVal} onChange={setNieuwVal} is={is} T={T} />
<button onClick={addNieuw} style={addBtn}>+ Voeg toe</button>
</div>
{err && <ErrMsg msg={err} />}
</div>
</div>
<ModalFooter T={T}>
{confirmDel ? (
<ConfirmDeleteDialog
naam={cat.naam} T={T}
onCancel={() => setConfirmDel(false)}
onConfirm={handleDelete}
/>
) : (
<>
<button onClick={() => setConfirmDel(true)} style={trashBtn}>🗑 Verwijderen</button>
<button onClick={onClose} style={cancelBtn(T)}>Annuleren</button>
<button onClick={handleSave} style={{
flex: 2, padding: "10px",
background: `linear-gradient(135deg, ${cat.color}, ${cat.color}cc)`,
border: "none", borderRadius: 10, color: "#fff",
fontWeight: 700, cursor: "pointer", fontSize: 13,
}}>Opslaan</button>
</>
)}
</ModalFooter>
</ModalBox>
</Overlay>
);
}
// ── CatKaart ──────────────────────────────────────────────────────────────────
function CatKaart({ cat }) {
const { locked, T } = useApp();
const [showAanpassen, setShowAanpassen] = useState(false);
return (
<VCard style={{ background: T.card, border: `1px solid ${T.border}`, borderTop: `3px solid ${cat.color}` }}>
{showAanpassen && <AanpassenPopup cat={cat} onClose={() => setShowAanpassen(false)} />}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
<span style={{ fontWeight: 700, fontSize: 14, color: cat.color }}>
{cat.icon && <span style={{ marginRight: 6 }}><AppIcon value={cat.icon} size={16} /></span>}
{cat.naam}
</span>
{!locked && (
<button onClick={() => setShowAanpassen(true)} style={{
padding: "4px 10px", background: `${cat.color}22`,
border: `1px solid ${cat.color}44`, borderRadius: 7,
color: cat.color, cursor: "pointer", fontSize: 11, fontWeight: 700,
}}>Aanpassen</button>
)}
</div>
{cat.items.map((item) => (
<div key={item.id} style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 8 }}>
<span style={{ fontSize: 13, color: T.muted, flex: 1, minWidth: 0, wordBreak: "break-word" }}>
{item.naam}
</span>
<span style={{ fontSize: 14, fontWeight: 700, color: T.text, whiteSpace: "nowrap" }}>
{fmt(item.waarde)}
</span>
</div>
))}
<div style={{
marginTop: 10, paddingTop: 10,
borderTop: `1px solid ${T.border}`,
display: "flex", justifyContent: "flex-end",
}}>
<span style={{ fontSize: 14, fontWeight: 700, color: cat.color }}>
{fmt(cat.items.reduce((a, i) => a + (i.waarde || 0), 0))}
</span>
</div>
</VCard>
);
}
// ── EVVoortgangTabel ──────────────────────────────────────────────────────────
function EVVoortgangTabel() {
const { evRows, setEvRows, cats, locked, darkMode, T } = useApp();
const empty = () => ({
datum: "", nettoWaarde: "", opmerkingen: "",
...cats.reduce((a, c) => ({ ...a, [c.id]: "" }), {}),
});
const [showModal, setShowModal] = useState(false);
const [input, setInput] = useState(empty);
const [editDatum, setEditDatum] = useState(null);
const [err, setErr] = useState("");
const is = inputStyle(T);
const openModal = () => { setInput(empty()); setEditDatum(null); setErr(""); setShowModal(true); };
const openEdit = (row) => {
setInput({ ...row, opmerkingen: row.opmerkingen || "" });
setEditDatum(row.datum); setErr(""); setShowModal(true);
};
const closeModal = () => { setShowModal(false); setErr(""); setEditDatum(null); };
const addRow = () => {
if (!input.datum) { setErr("Datum is verplicht."); return; }
const parsedCats = cats.reduce((a, c) => ({ ...a, [c.id]: parseFloat(input[c.id]) || 0 }), {});
const hasValue = cats.some((c) => parsedCats[c.id] > 0);
if (!hasValue) { setErr("Vul minimaal één bedrag in."); return; }
const row = {
...input,
...parsedCats,
nettoWaarde: parseFloat(input.nettoWaarde) || 0,
};
const updated = [...evRows.filter((r) => r.datum !== row.datum), row]
.sort((a, b) => b.datum.localeCompare(a.datum));
setEvRows(updated); closeModal();
};
const removeRow = (datum) => setEvRows(evRows.filter((r) => r.datum !== datum));
const th = { padding: "8px 10px", fontSize: 11, color: T.muted, fontWeight: 600, textAlign: "left", whiteSpace: "nowrap", background: T.card, position: "sticky", top: 0, zIndex: 1, borderBottom: `1px solid ${T.border}` };
const td = { padding: "7px 10px", fontSize: 12, color: T.text, borderBottom: `1px solid ${T.border}` };
return (
<div>
{/* Modal */}
{showModal && (
<Overlay onClose={closeModal}>
<ModalBox maxWidth={560}>
<ModalHeader
icon="📊" iconBg={PURPLE}
title={editDatum ? "Rij bewerken" : "Nieuw geïnvesteerde fiat"}
subtitle="Vul de waarden in voor dit moment"
subtitleColor={T.muted}
onClose={closeModal}
headerBg={darkMode ? `${PURPLE}08` : "#f8f5ff"}
T={T}
/>
<div style={{ padding: "20px 22px", overflowY: "auto", maxHeight: "70vh" }}>
<FieldLabel label="Datum *" T={T} />
<input type="date" value={input.datum}
onChange={(e) => setInput((s) => ({ ...s, datum: e.target.value }))}
style={{ ...is, width: "100%", marginBottom: 16 }} />
<FieldLabel label="Bedragen per categorie" T={T} />
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, marginBottom: 16 }}>
{cats.map((c) => (
<div key={c.id} style={{
display: "flex", alignItems: "center", gap: 12,
background: darkMode ? "rgba(255,255,255,0.03)" : "#f8fafc",
border: `1px solid ${T.border}`, borderLeft: `3px solid ${c.color}`,
borderRadius: 10, padding: "8px 12px",
}}>
<span style={{ fontSize: 12, color: c.color, fontWeight: 700, minWidth: 90, flexShrink: 0 }}>{c.naam}</span>
<EuroInput
value={input[c.id]}
onChange={(v) => setInput((s) => ({ ...s, [c.id]: v }))}
is={is} T={T}
/>
</div>
))}
</div>
<FieldLabel label="Netto Waarde *" T={T} />
<EuroInput
value={input.nettoWaarde}
onChange={(v) => setInput((s) => ({ ...s, nettoWaarde: v }))}
is={is} T={T}
/>
<div style={{ marginBottom: 16 }} />
<FieldLabel label="Opmerkingen" T={T} />
<input type="text" value={input.opmerkingen}
onChange={(e) => setInput((s) => ({ ...s, opmerkingen: e.target.value }))}
placeholder="Optionele notitie..." style={{ ...is, width: "100%" }} />
{err && <ErrMsg msg={err} />}
</div>
<ModalFooter T={T}>
<button onClick={closeModal} style={cancelBtn(T)}>Annuleren</button>
<button onClick={addRow} style={primaryBtn}>{editDatum ? "Opslaan" : "Toevoegen"}</button>
</ModalFooter>
</ModalBox>
</Overlay>
)}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: T.text }}>Geïnvesteerde Fiat</div>
{!locked && (
<button onClick={openModal} style={primaryBtn}>+ Toevoegen</button>
)}
</div>
{evRows.length > 0 ? (
<div style={{ overflowX: "auto" }}>
<div style={{ maxHeight: 340, overflowY: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse", minWidth: 600 }}>
<thead>
<tr>
<th style={th}>Datum</th>
{cats.map((c) => <th key={c.id} style={{ ...th, color: c.color }}>{c.naam}</th>)}
<th style={th}>Netto Waarde</th>
<th style={th}>Verschil</th>
<th style={th}>Opmerkingen</th>
{!locked && <th style={th}></th>}
</tr>
</thead>
<tbody>
{evRows.map((row, i) => {
const vorigeNW = i < evRows.length - 1 ? (evRows[i + 1].nettoWaarde || 0) : null;
const verschil = vorigeNW !== null ? (row.nettoWaarde || 0) - vorigeNW : null;
const vColor = verschil === null ? T.muted : verschil > 0 ? GREEN : verschil < 0 ? RED : T.muted;
const vLabel = verschil === null ? "—" : `${verschil >= 0 ? "+" : ""}${fmt(verschil)}`;
return (
<tr key={i}>
<td style={td}>{fmtDatum(row.datum)}</td>
{cats.map((c) => <td key={c.id} style={{ ...td, color: c.color }}>{fmt(row[c.id] || 0)}</td>)}
<td style={{ ...td, fontWeight: 700, color: PURPLE_LIGHT }}>{fmt(row.nettoWaarde || 0)}</td>
<td style={{ ...td, fontWeight: 700, color: vColor }}>{vLabel}</td>
<td style={{ ...td, color: T.muted }}>{row.opmerkingen || "—"}</td>
{!locked && (
<td style={td}>
<div style={{ display: "flex", gap: 6 }}>
<button onClick={() => openEdit(row)} style={editBtn}>✏️</button>
<button onClick={() => removeRow(row.datum)} style={deleteSmBtn}>×</button>
</div>
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
) : (
<EmptyState
icon="📊" text="Nog geen datapunten"
hint="Klik hier om je eerste datapunt toe te voegen"
locked={locked} onClick={openModal} T={T}
/>
)}
</div>
);
}
// ── DoelProgressie ────────────────────────────────────────────────────────────
function DoelProgressie({ ev, data, setData, totVerdeling, locked, T }) {
const [editing, setEditing] = useState(false);
const [val, setVal] = useState("");
const doel = ev.doel || 1;
const progressPct = Math.min(totVerdeling / doel, 1);
const openEdit = () => { setVal(String(doel)); setEditing(true); };
const save = () => {
const n = parseFloat(val);
if (n > 0) setData({ ...data, eigenVermogen: { ...ev, doel: n } });
setEditing(false);
};
return (
<VCard style={{ background: T.card, border: `1px solid ${T.border}`, marginBottom: 20 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 14 }}>🎯</span>
<span style={{ fontWeight: 600, fontSize: 14 }}>Gewenst doel EV</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<span style={{ fontWeight: 700, fontSize: 15, color: PURPLE_LIGHT }}>{fmt(totVerdeling)}</span>
{editing ? (
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ color: T.muted, fontSize: 13 }}>/</span>
<input
autoFocus type="number" value={val}
onChange={(e) => setVal(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") save(); if (e.key === "Escape") setEditing(false); }}
style={{
width: 130, padding: "4px 8px", borderRadius: 8, fontSize: 13,
background: T.inputBg, border: `1px solid ${PURPLE}`, color: T.text, outline: "none",
}}
/>
<button onClick={save} style={{ padding: "4px 10px", background: PURPLE, border: "none", borderRadius: 7, color: "#fff", cursor: "pointer", fontSize: 12, fontWeight: 700 }}>OK</button>
</div>
) : (
<>
<span style={{ color: T.muted, fontSize: 13 }}>/ {fmt(doel)}</span>
{!locked && (
<button onClick={openEdit} style={{ background: "transparent", border: "none", cursor: "pointer", color: T.muted, fontSize: 14, padding: 2 }}>✏️</button>
)}
</>
)}
<span style={{ color: PURPLE_LIGHT, fontWeight: 700, fontSize: 13 }}>{Math.round(progressPct * 100)}%</span>
</div>
</div>
<div style={{ height: 12, background: "rgba(255,255,255,0.07)", borderRadius: 99, overflow: "hidden" }}>
<div style={{
height: "100%", width: `${progressPct * 100}%`,
background: `linear-gradient(90deg,${PURPLE},#a855f7)`,
borderRadius: 99, transition: "width 0.7s ease",
}} />
</div>
</VCard>
);
}
// ── EigenVermogenTab (pagina) ─────────────────────────────────────────────────
export default function EigenVermogenTab() {
const { cats, catTotals, ev, data, setData, locked, darkMode, T, evRows } = useApp();
const [showAddCat, setShowAddCat] = useState(false);
const [newNaam, setNewNaam] = useState("");
const [newColor, setNewColor] = useState(CAT_COLORS[0]);
const totVerdeling = catTotals.reduce((a, c) => a + c.tot, 0);
const sumCat = (id) => evRows.reduce((a, r) => a + (r[id] || 0), 0);
const totGeinv = cats.reduce((a, c) => a + sumCat(c.id), 0);
const iStyle2 = inputStyle(T);
const addCat = () => {
if (!newNaam.trim()) return;
setData({
...data,
eigenVermogen: {
...ev,
categories: [...cats, {
id: `c${Date.now()}`, naam: newNaam, color: newColor,
items: [{ id: `i${Date.now()}`, naam: "Item 1", waarde: 0 }],
}],
},
});
setNewNaam(""); setShowAddCat(false);
};
const summaryBlocks = [
{
title: "Verdeling Eigen Vermogen", accent: GREEN,
rows: catTotals.map((c) => ({ label: c.naam, value: fmt(c.tot), color: c.color })),
total: fmt(totVerdeling), totalColor: GREEN,
},
{
title: "Geïnvesteerde Fiat", accent: PURPLE_LIGHT,
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,
},
{
title: "Rendement", accent: "#f59e0b",
rows: cats.map((c) => {
const ev2 = catTotals.find((ct) => ct.id === c.id)?.tot ?? 0;
const g = sumCat(c.id);
const r = g > 0 ? (ev2 - g) / g : null;
return { label: c.naam, value: r === null ? "—" : `${(r * 100).toFixed(1)}%`, color: r === null ? T.muted : r >= 0 ? GREEN : RED };
}),
total: (() => {
const et = totVerdeling;
const gt = totGeinv;
return gt > 0 ? `${(((et - gt) / gt) * 100).toFixed(1)}%` : "—";
})(),
totalColor: (() => {
return totGeinv > 0 ? (totVerdeling >= totGeinv ? GREEN : RED) : T.muted;
})(),
},
];
return (
<div>
<h1 style={{ fontSize: 20, fontWeight: 800, margin: "0 0 20px 0" }}>Eigen Vermogen</h1>
{/* Doel progressie */}
<DoelProgressie ev={ev} data={data} setData={setData} totVerdeling={totVerdeling} locked={locked} T={T} />
{/* Summary blokken */}
<div style={{ display: "flex", gap: 16, marginBottom: 20, flexWrap: "wrap" }}>
{summaryBlocks.map((block) => (
<div key={block.title} style={{
background: T.card, border: `1px solid ${T.border}`,
borderTop: `3px solid ${block.accent}`, borderRadius: 12,
padding: "16px 18px", flex: 1, minWidth: 200,
}}>
<div style={{
fontWeight: 700, fontSize: 12, letterSpacing: "0.1em",
textTransform: "uppercase", color: block.accent, marginBottom: 12,
}}>{block.title}</div>
{block.rows.map(({ label, value, color }) => (
<div key={label} style={{
display: "flex", justifyContent: "space-between",
padding: "6px 0", borderBottom: `1px solid ${T.border}`,
}}>
<span style={{ fontSize: 13, color: T.muted }}>{label}</span>
<span style={{ fontSize: 13, fontWeight: 600, color }}>{value}</span>
</div>
))}
<div style={{
display: "flex", justifyContent: "space-between",
alignItems: "center", marginTop: 10, paddingTop: 8,
}}>
<span style={{ fontSize: 12, fontWeight: 700, color: T.text }}>Totaal</span>
<span style={{ fontSize: 15, fontWeight: 800, color: block.totalColor }}>{block.total}</span>
</div>
</div>
))}
{totVerdeling > 0 && (
<div style={{
background: T.card, border: `1px solid ${T.border}`,
borderRadius: 16, padding: "20px",
flex: 1, minWidth: 200,
display: "flex", flexDirection: "column", alignItems: "center",
}}>
<DonutChart segments={catTotals.map((c) => ({ val: c.tot, color: c.color }))} total={totVerdeling} size={200} label="Verdeling" centerVal={fmt(totVerdeling)} />
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px 14px", marginTop: 12, justifyContent: "center" }}>
{catTotals.map((c) => (
<div key={c.id} style={{ display: "flex", alignItems: "center", gap: 5 }}>
<div style={{ width: 7, height: 7, borderRadius: "50%", background: c.color }} />
<span style={{ fontSize: 11, color: T.subtext }}>{c.naam}</span>
</div>
))}
</div>
</div>
)}
</div>
{/* Nieuwe categorie */}
{!locked && (
<div style={{ marginBottom: 16 }}>
<button onClick={() => setShowAddCat(true)} style={{
padding: "10px 20px", background: `${PURPLE}22`,
border: `1px solid ${PURPLE}44`, borderRadius: 10,
color: PURPLE_LIGHT, cursor: "pointer", fontWeight: 700, fontSize: 13,
}}>+ Nieuwe categorie</button>
</div>
)}
{showAddCat && (
<Overlay onClose={() => setShowAddCat(false)}>
<ModalBox maxWidth={400}>
<ModalHeader icon="➕" iconBg={PURPLE} title="Nieuwe categorie" subtitle="Voeg een EV-categorie toe" subtitleColor={PURPLE_LIGHT} onClose={() => setShowAddCat(false)} headerBg={darkMode ? `${PURPLE}08` : "#f8f5ff"} T={T} />
<div style={{ padding: "20px 22px" }}>
<div style={{ fontSize: 11, color: T.muted, fontWeight: 700, textTransform: "uppercase", marginBottom: 6 }}>Naam *</div>
<input autoFocus placeholder="Bijv. Aandelen" value={newNaam}
onChange={(e) => setNewNaam(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addCat()}
style={{ ...iStyle2, marginBottom: 16, width: "100%", boxSizing: "border-box" }} />
<div style={{ fontSize: 11, color: T.muted, fontWeight: 700, textTransform: "uppercase", marginBottom: 8 }}>Kleur</div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{CAT_COLORS.map((c) => (
<button key={c} onClick={() => setNewColor(c)} style={{
width: 28, height: 28, borderRadius: "50%", background: c,
border: `3px solid ${newColor === c ? "#fff" : "transparent"}`,
cursor: "pointer", outline: newColor === c ? `2px solid ${c}` : "none", outlineOffset: 1,
}} />
))}
</div>
</div>
<ModalFooter T={T}>
<button onClick={() => setShowAddCat(false)} style={{ flex: 1, padding: "10px", background: "transparent", border: `1px solid ${T.border}`, borderRadius: 10, color: T.muted, cursor: "pointer", fontWeight: 600, fontSize: 13 }}>Annuleren</button>
<button onClick={addCat} style={{ flex: 2, padding: "10px", background: `linear-gradient(135deg, ${PURPLE}, #a855f7)`, border: "none", borderRadius: 10, color: "#fff", fontWeight: 700, cursor: "pointer", fontSize: 13 }}>Aanmaken</button>
</ModalFooter>
</ModalBox>
</Overlay>
)}
{/* Categorie kaarten */}
<div style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))",
gap: 16, marginBottom: 20,
}}>
{cats.map((cat) => <CatKaart key={cat.id} cat={cat} />)}
</div>
{/* Voortgang tabel */}
<VCard style={{ background: T.card, border: `1px solid ${T.border}` }}>
<EVVoortgangTabel />
</VCard>
</div>
);
}
// ── Gedeelde kleine helpers ────────────────────────────────────────────────────
function inputStyle(T) {
return {
background: T.inputBg, border: `1px solid ${T.inputBorder}`,
borderRadius: 8, color: T.text, padding: "8px 10px",
fontSize: 13, outline: "none", boxSizing: "border-box",
};
}
function FieldLabel({ label, T }) {
return (
<div style={{
fontSize: 11, color: T.muted, fontWeight: 700,
textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 8,
}}>{label}</div>
);
}
function EuroInput({ value, onChange, is, T }) {
return (
<div style={{ position: "relative", width: 110, flexShrink: 0 }}>
<span style={{
position: "absolute", left: 8, top: "50%", transform: "translateY(-50%)",
fontSize: 12, color: T.muted, pointerEvents: "none",
}}>€</span>
<input type="number" value={value} onChange={(e) => onChange(e.target.value)}
placeholder="0" style={{ ...is, paddingLeft: 22, width: "100%" }} />
</div>
);
}
function ErrMsg({ msg }) {
return (
<div style={{
color: RED, fontSize: 12, marginTop: 8,
padding: "6px 10px", background: "rgba(239,68,68,0.1)",
borderRadius: 8, border: "1px solid rgba(239,68,68,0.25)",
}}>{msg}</div>
);
}
const primaryBtn = { padding: "10px 20px", background: PURPLE, border: "none", borderRadius: 8, color: "#fff", fontWeight: 700, cursor: "pointer", fontSize: 13 };
const editBtn = { padding: "2px 10px", background: `${PURPLE}22`, border: `1px solid ${PURPLE}44`, borderRadius: 6, color: PURPLE_LIGHT, cursor: "pointer", fontSize: 12, fontWeight: 600 };
const deleteSmBtn = { padding: "2px 8px", background: "rgba(239,68,68,0.1)", border: "1px solid rgba(239,68,68,0.25)", borderRadius: 6, color: RED, cursor: "pointer", fontSize: 12 };
const trashBtn = { padding: "10px 14px", background: "rgba(239,68,68,0.08)", border: "1px solid rgba(239,68,68,0.25)", borderRadius: 10, color: RED, cursor: "pointer", fontWeight: 600, fontSize: 13 };
const addBtn = { padding: "6px 14px", background: `${PURPLE}22`, border: `1px solid ${PURPLE}44`, borderRadius: 8, color: PURPLE_LIGHT, cursor: "pointer", fontSize: 12, fontWeight: 700, flexShrink: 0 };
const cancelBtn = (T) => ({ padding: "10px", background: "transparent", border: `1px solid ${T.border}`, borderRadius: 10, color: T.muted, cursor: "pointer", fontWeight: 600, fontSize: 13 });
+452
View File
@@ -0,0 +1,452 @@
import { useState } from "react";
import { useApp } from "../context/AppContext.jsx";
import { fmt, pct } from "../utils/format.js";
import { PURPLE, PURPLE_LIGHT, RED, GREEN } from "../constants/index.js";
import { VCard, DonutChart, Overlay, ModalBox, ModalHeader, EmojiPicker, AppIcon, ConfirmDeleteDialog } from "../components/ui/index.jsx";
// ── SchuldAanpassenPopup ──────────────────────────────────────────────────────
function SchuldAanpassenPopup({ blok, onClose }) {
const { data, setData, schuld, schuldBlokken, darkMode, T } = useApp();
const [items, setItems] = useState(blok.items.map((i) => ({ ...i })));
const [blokNaam, setBlokNaam] = useState(blok.naam);
const [blokIcon, setBlokIcon] = useState(blok.icon || "");
const [showEmoji, setShowEmoji] = useState(false);
const [nieuwNaam, setNieuwNaam] = useState("");
const [nieuwVal, setNieuwVal] = useState("");
const [nieuwErr, setNieuwErr] = useState("");
const [confirmDel, setConfirmDel] = useState(false);
const is = inputStyle(T);
const updateItem = (id, field, val) =>
setItems((p) => p.map((i) => i.id !== id ? i : { ...i, [field]: val }));
const removeItemLocal = (id) => {
if (items.length <= 1) return;
setItems((p) => p.filter((i) => i.id !== id));
};
const addNieuw = () => {
if (!nieuwNaam.trim()) { setNieuwErr("Naam is verplicht."); setTimeout(() => setNieuwErr(""), 3000); return; }
setItems((p) => [...p, { id: `si${Date.now()}`, naam: nieuwNaam.trim(), waarde: parseFloat(nieuwVal) || 0 }]);
setNieuwNaam(""); setNieuwVal(""); setNieuwErr("");
};
const handleSave = () => {
setData({
...data,
schuld: {
...schuld,
blokken: schuldBlokken.map((b) =>
b.id !== blok.id ? b : {
...b, naam: blokNaam.trim() || b.naam, icon: blokIcon,
items: items.map((i) => ({ ...i, waarde: parseFloat(i.waarde) || 0 })),
}
),
},
});
onClose();
};
const handleDelete = () => {
setData({ ...data, schuld: { ...schuld, blokken: schuldBlokken.filter((b) => b.id !== blok.id) } });
onClose();
};
return (
<Overlay onClose={onClose}>
<ModalBox>
<ModalHeader
icon="✏️" iconBg={blok.color}
title="Aanpassen" subtitle={blok.naam} subtitleColor={blok.color}
onClose={onClose}
headerBg={darkMode ? `${blok.color}11` : `${blok.color}09`}
T={T}
/>
<div style={{ padding: "20px 22px", overflowY: "auto", maxHeight: "65vh" }}>
{/* Naam + emoji */}
<FieldLabel label="Categorie naam" T={T} />
<div style={{ marginBottom: 16 }}>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<button onClick={() => setShowEmoji((p) => !p)} style={{
width: 38, height: 38, borderRadius: 8,
background: `${blok.color}22`,
border: `2px solid ${showEmoji ? blok.color : blok.color + "44"}`,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 20, flexShrink: 0, cursor: "pointer",
}}><AppIcon value={blokIcon} fallback="🏷" size={20} /></button>
<input value={blokNaam} onChange={(e) => setBlokNaam(e.target.value)}
style={{ ...is, flex: 1, borderLeft: `3px solid ${blok.color}` }}
placeholder="Naam categorie" />
</div>
{showEmoji && (
<EmojiPicker
selected={blokIcon} T={T} accentColor={blok.color}
onSelect={(e) => { setBlokIcon(e); setShowEmoji(false); }}
onClear={() => { setBlokIcon(""); setShowEmoji(false); }}
/>
)}
</div>
{/* Items */}
<FieldLabel label="Items bewerken" T={T} />
{items.map((item) => (
<div key={item.id} style={{
display: "flex", alignItems: "center", gap: 8, marginBottom: 10,
background: darkMode ? "rgba(255,255,255,0.03)" : "#f8fafc",
border: `1px solid ${T.border}`, borderLeft: `3px solid ${blok.color}`,
borderRadius: 10, padding: "10px 12px",
}}>
<input value={item.naam} onChange={(e) => updateItem(item.id, "naam", e.target.value)}
style={{ ...is, flex: 1 }} placeholder="Naam" />
<EuroInput value={item.waarde} onChange={(v) => updateItem(item.id, "waarde", v)} is={is} T={T} />
{items.length > 1 && (
<button onClick={() => removeItemLocal(item.id)} style={deleteSmBtn}>×</button>
)}
</div>
))}
{/* Nieuw item */}
<div style={{ marginTop: 18, paddingTop: 16, borderTop: `1px solid ${T.border}` }}>
<FieldLabel label="Nieuw item toevoegen" T={T} />
<div style={{
display: "flex", alignItems: "center", gap: 8,
background: darkMode ? "rgba(255,255,255,0.03)" : "#f8fafc",
border: `1px solid ${T.border}`, borderLeft: `3px solid ${PURPLE}`,
borderRadius: 10, padding: "10px 12px",
}}>
<input value={nieuwNaam}
onChange={(e) => { setNieuwNaam(e.target.value); setNieuwErr(""); }}
placeholder="Naam" style={{ ...is, flex: 1 }} />
<EuroInput value={nieuwVal} onChange={setNieuwVal} is={is} T={T} />
<button onClick={addNieuw} style={addBtn}>+ Voeg toe</button>
</div>
{nieuwErr && <ErrMsg msg={nieuwErr} />}
</div>
</div>
{/* Footer */}
<div style={{ padding: "14px 22px", borderTop: `1px solid ${T.border}` }}>
{confirmDel ? (
<ConfirmDeleteDialog
naam={blok.naam} T={T}
onCancel={() => setConfirmDel(false)}
onConfirm={handleDelete}
/>
) : (
<div style={{ display: "flex", gap: 10 }}>
<button onClick={() => setConfirmDel(true)} style={trashBtn}>🗑 Verwijderen</button>
<button onClick={onClose} style={cancelBtn(T)}>Annuleren</button>
<button onClick={handleSave} style={{
flex: 2, padding: "10px",
background: `linear-gradient(135deg, ${blok.color}, ${blok.color}cc)`,
border: "none", borderRadius: 10, color: "#fff",
fontWeight: 700, cursor: "pointer", fontSize: 13,
}}>Opslaan</button>
</div>
)}
</div>
</ModalBox>
</Overlay>
);
}
// ── SchuldKaart ───────────────────────────────────────────────────────────────
function SchuldKaart({ blok }) {
const { locked, T } = useApp();
const [showAanpassen, setShowAanpassen] = useState(false);
const subtot = blok.items.reduce((a, i) => a + (i.waarde || 0), 0);
return (
<VCard style={{ background: T.card, border: `1px solid ${T.border}`, borderTop: `3px solid ${blok.color}` }}>
{showAanpassen && <SchuldAanpassenPopup blok={blok} onClose={() => setShowAanpassen(false)} />}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
<span style={{ fontWeight: 700, fontSize: 14, color: blok.color }}>
{blok.icon && <span style={{ marginRight: 6 }}><AppIcon value={blok.icon} size={16} /></span>}
{blok.naam}
</span>
{!locked && (
<button onClick={() => setShowAanpassen(true)} style={{
padding: "4px 10px", background: `${blok.color}22`,
border: `1px solid ${blok.color}44`, borderRadius: 7,
color: blok.color, cursor: "pointer", fontSize: 11, fontWeight: 700,
}}>Aanpassen</button>
)}
</div>
{blok.items.map((item) => (
<div key={item.id} style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 8 }}>
<span style={{ fontSize: 13, color: T.muted, flex: 1, minWidth: 0, wordBreak: "break-word" }}>
{item.naam}
</span>
<span style={{ fontSize: 13, fontWeight: 600, color: blok.color, whiteSpace: "nowrap" }}>
{fmt(item.waarde)}
</span>
</div>
))}
<div style={{
marginTop: 8, paddingTop: 8,
borderTop: `1px solid ${T.border}`,
display: "flex", justifyContent: "flex-end",
}}>
<span style={{ fontSize: 14, fontWeight: 700, color: blok.color }}>{fmt(subtot)}</span>
</div>
</VCard>
);
}
// ── AflossingProgressie ───────────────────────────────────────────────────────
function AflossingProgressie() {
const { data, setData, schuld, totSchuld, locked, darkMode, T } = useApp();
const init = schuld.initieleSchuld || 0;
const betaald = Math.min(Math.max(init - totSchuld, 0), init);
const aflossingPct = init > 0 ? betaald / init : 0;
const [editOpen, setEditOpen] = useState(false);
const [editVal, setEditVal] = useState(schuld.initieleSchuld || "");
const saveInitiele = () => {
setData({ ...data, schuld: { ...schuld, initieleSchuld: parseFloat(editVal) || 0 } });
setEditOpen(false);
};
return (
<VCard style={{ background: T.card, border: `1px solid ${T.border}`, marginTop: 4 }}>
{editOpen && (
<Overlay onClose={() => setEditOpen(false)}>
<ModalBox maxWidth={380}>
<ModalHeader
icon="✏️" iconBg={RED}
title="Initiële schuld aanpassen"
subtitle="Startbedrag voor aflossing progressie"
subtitleColor={RED}
onClose={() => setEditOpen(false)}
headerBg={darkMode ? `${RED}11` : `${RED}09`}
T={T}
/>
<div style={{ padding: "20px 22px" }}>
<div style={{ fontSize: 11, color: T.muted, fontWeight: 700, display: "block", marginBottom: 6, textTransform: "uppercase" }}>Bedrag (€)</div>
<div style={{ position: "relative" }}>
<span style={{ position: "absolute", left: 12, top: "50%", transform: "translateY(-50%)", fontSize: 13, color: T.muted }}>€</span>
<input type="number" value={editVal} onChange={(e) => setEditVal(e.target.value)} autoFocus
style={{ background: T.inputBg, border: `1px solid ${T.inputBorder}`, borderRadius: 10, color: T.text, padding: "10px 12px 10px 28px", fontSize: 14, outline: "none", width: "100%", boxSizing: "border-box" }} />
</div>
</div>
<div style={{ display: "flex", gap: 10, padding: "14px 22px", borderTop: `1px solid ${T.border}` }}>
<button onClick={() => setEditOpen(false)} style={cancelBtn(T)}>Annuleren</button>
<button onClick={saveInitiele} style={{
flex: 2, padding: "10px",
background: `linear-gradient(135deg, ${RED}, #f97316)`,
border: "none", borderRadius: 10, color: "#fff",
fontWeight: 700, cursor: "pointer", fontSize: 13,
}}>Opslaan</button>
</div>
</ModalBox>
</Overlay>
)}
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 8, alignItems: "center", flexWrap: "wrap", gap: 8 }}>
<span style={{ fontWeight: 600 }}>Aflossing progressie</span>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 12, color: T.muted }}>Initiële schuld:</span>
<span style={{ fontSize: 13, fontWeight: 700, color: RED }}>{fmt(schuld.initieleSchuld)}</span>
{!locked && (
<button onClick={() => { setEditVal(schuld.initieleSchuld || ""); setEditOpen(true); }} style={{
padding: "4px 10px", background: `${RED}22`, border: `1px solid ${RED}44`,
borderRadius: 7, color: RED, cursor: "pointer", fontSize: 11, fontWeight: 700,
}}>Aanpassen</button>
)}
</div>
</div>
<div style={{ height: 8, background: "rgba(255,255,255,0.07)", borderRadius: 99, overflow: "hidden" }}>
<div style={{
height: "100%",
width: `${Math.min(aflossingPct * 100, 100)}%`,
background: GREEN,
borderRadius: 99,
transition: "width 0.5s ease",
marginLeft: 0,
}} />
</div>
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 6, fontSize: 11, color: T.muted }}>
<span>€0</span>
<span style={{ color: GREEN }}>{pct(aflossingPct)} afgelost</span>
<span>{fmt(init)}</span>
</div>
</VCard>
);
}
// ── NieuweCategoriepopup ──────────────────────────────────────────────────────
function NieuweCategoriepopup({ onSave, onClose, T, darkMode }) {
const is = { background: T.inputBg, border: `1px solid ${T.inputBorder}`, borderRadius: 8, color: T.text, padding: "10px 12px", fontSize: 13, outline: "none", boxSizing: "border-box", width: "100%" };
const COLORS = ["#ef4444","#f97316","#eab308","#a855f7","#06b6d4","#10b981","#3b82f6","#ec4899","#6b7280"];
const [naam, setNaam] = useState("");
const [color, setColor] = useState(COLORS[0]);
const save = () => {
if (!naam.trim()) return;
onSave({ naam: naam.trim(), color });
onClose();
};
return (
<Overlay onClose={onClose}>
<ModalBox maxWidth={400}>
<ModalHeader icon="➕" iconBg={RED} title="Nieuwe categorie" subtitle="Voeg een schuldcategorie toe" subtitleColor={RED} onClose={onClose} headerBg={darkMode ? `${RED}11` : `${RED}09`} T={T} />
<div style={{ padding: "20px 22px" }}>
<div style={{ fontSize: 11, color: T.muted, fontWeight: 700, textTransform: "uppercase", marginBottom: 6 }}>Naam *</div>
<input autoFocus value={naam} onChange={(e) => setNaam(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && save()}
placeholder="Bijv. Hypotheek" style={{ ...is, marginBottom: 16 }} />
<div style={{ fontSize: 11, color: T.muted, fontWeight: 700, textTransform: "uppercase", marginBottom: 8 }}>Kleur</div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{COLORS.map((c) => (
<button key={c} onClick={() => setColor(c)} style={{
width: 28, height: 28, borderRadius: "50%", background: c, border: `3px solid ${color === c ? "#fff" : "transparent"}`,
cursor: "pointer", outline: color === c ? `2px solid ${c}` : "none", outlineOffset: 1,
}} />
))}
</div>
</div>
<div style={{ display: "flex", gap: 10, padding: "14px 22px", borderTop: `1px solid ${T.border}` }}>
<button onClick={onClose} 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={save} style={{ flex: 2, padding: "10px", background: `linear-gradient(135deg, ${RED}, #f97316)`, border: "none", borderRadius: 10, color: "#fff", fontWeight: 700, cursor: "pointer", fontSize: 13 }}>Aanmaken</button>
</div>
</ModalBox>
</Overlay>
);
}
// ── SchuldTab (pagina) ────────────────────────────────────────────────────────
export default function SchuldTab() {
const { data, setData, schuld, schuldBlokken, locked, darkMode, T } = useApp();
const [showNieuweCategorie, setShowNieuweCategorie] = useState(false);
const schuldCats = schuldBlokken.map((b) => ({
...b, subtot: b.items.reduce((a, i) => a + (i.waarde || 0), 0),
}));
const donutTotal = schuldCats.reduce((a, b) => a + b.subtot, 0);
const addSchuldBlok = ({ naam, color }) => {
setData({
...data,
schuld: {
...schuld,
blokken: [...schuldBlokken, {
id: `s${Date.now()}`, naam, color,
items: [{ id: `si${Date.now()}`, naam: "Item 1", waarde: 0 }],
}],
},
});
};
return (
<div>
<h1 style={{ fontSize: 20, fontWeight: 800, margin: "0 0 20px 0" }}>Schuld</h1>
<AflossingProgressie />
<div style={{ marginBottom: 20 }} />
{schuldCats.length > 0 && (
<div style={{ display: "flex", gap: 16, marginBottom: 20, flexWrap: "wrap" }}>
{/* Verdeling lijst */}
<div style={{
background: T.card, border: `1px solid ${T.border}`,
borderTop: `3px solid ${RED}`, borderRadius: 12,
padding: "16px 18px", flex: 1, minWidth: 200,
}}>
<div style={{ fontWeight: 700, fontSize: 12, letterSpacing: "0.1em", textTransform: "uppercase", color: RED, marginBottom: 12 }}>
Verdeling Schuld
</div>
{schuldCats.map((b) => (
<div key={b.id} style={{ display: "flex", justifyContent: "space-between", padding: "6px 0", borderBottom: `1px solid ${T.border}` }}>
<span style={{ fontSize: 13, color: T.muted }}>{b.naam}</span>
<span style={{ fontSize: 13, fontWeight: 600, color: b.color }}>{fmt(b.subtot)}</span>
</div>
))}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 10, paddingTop: 8 }}>
<span style={{ fontSize: 12, fontWeight: 700, color: T.text }}>Totaal</span>
<span style={{ fontSize: 15, fontWeight: 800, color: RED }}>{fmt(donutTotal)}</span>
</div>
</div>
{/* Donut */}
<div style={{
background: T.card, border: `1px solid ${T.border}`,
borderRadius: 16, padding: "20px",
flex: 1, minWidth: 200,
display: "flex", flexDirection: "column", alignItems: "center",
}}>
<DonutChart
segments={schuldCats.map((b) => ({ val: b.subtot, color: b.color }))}
total={donutTotal} size={200} label="Totaal schuld" centerVal={fmt(donutTotal)}
/>
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px 14px", marginTop: 12, justifyContent: "center" }}>
{schuldCats.map((b) => (
<div key={b.id} style={{ display: "flex", alignItems: "center", gap: 5 }}>
<div style={{ width: 7, height: 7, borderRadius: "50%", background: b.color }} />
<span style={{ fontSize: 11, color: T.subtext }}>{b.naam}</span>
</div>
))}
</div>
</div>
</div>
)}
{!locked && (
<button onClick={() => setShowNieuweCategorie(true)} style={{
padding: "10px 20px", background: `${RED}22`,
border: `1px solid ${RED}44`, borderRadius: 10,
color: RED, cursor: "pointer", fontWeight: 700,
fontSize: 13, marginBottom: 16, display: "inline-block",
}}>+ Nieuwe categorie</button>
)}
{showNieuweCategorie && (
<NieuweCategoriepopup
onSave={addSchuldBlok}
onClose={() => setShowNieuweCategorie(false)}
T={T}
darkMode={darkMode}
/>
)}
<div style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))",
gap: 16, marginBottom: 20,
}}>
{schuldCats.map((blok) => <SchuldKaart key={blok.id} blok={blok} />)}
</div>
</div>
);
}
// ── kleine helpers ────────────────────────────────────────────────────────────
function inputStyle(T) {
return {
background: T.inputBg, border: `1px solid ${T.inputBorder}`,
borderRadius: 8, color: T.text, padding: "8px 10px",
fontSize: 13, outline: "none", boxSizing: "border-box",
};
}
function FieldLabel({ label, T }) {
return (
<div style={{ fontSize: 11, color: T.muted, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 8 }}>
{label}
</div>
);
}
function EuroInput({ value, onChange, is, T }) {
return (
<div style={{ position: "relative", width: 110, flexShrink: 0 }}>
<span style={{ position: "absolute", left: 8, top: "50%", transform: "translateY(-50%)", fontSize: 12, color: T.muted, pointerEvents: "none" }}>€</span>
<input type="number" value={value} onChange={(e) => onChange(e.target.value)}
placeholder="0" style={{ ...is, paddingLeft: 22, width: "100%" }} />
</div>
);
}
function ErrMsg({ msg }) {
return <div style={{ color: RED, fontSize: 12, marginTop: 6, padding: "6px 10px", background: "rgba(239,68,68,0.1)", borderRadius: 8, border: "1px solid rgba(239,68,68,0.25)" }}>{msg}</div>;
}
const deleteSmBtn = { padding: "4px 8px", background: "rgba(239,68,68,0.1)", border: "1px solid rgba(239,68,68,0.25)", borderRadius: 6, color: RED, cursor: "pointer", fontSize: 13, flexShrink: 0 };
const trashBtn = { padding: "10px 14px", background: "rgba(239,68,68,0.08)", border: "1px solid rgba(239,68,68,0.25)", borderRadius: 10, color: RED, cursor: "pointer", fontWeight: 600, fontSize: 13 };
const addBtn = { padding: "6px 14px", background: `${PURPLE}22`, border: `1px solid ${PURPLE}44`, borderRadius: 8, color: PURPLE_LIGHT, cursor: "pointer", fontSize: 12, fontWeight: 700, flexShrink: 0 };
const cancelBtn = (T) => ({ padding: "10px", background: "transparent", border: `1px solid ${T.border}`, borderRadius: 10, color: T.muted, cursor: "pointer", fontWeight: 600, fontSize: 13 });
+438
View File
@@ -0,0 +1,438 @@
import { useState } from "react";
import { useApp } from "../context/AppContext.jsx";
import { fmt, fmtDatum } from "../utils/format.js";
import { PURPLE, PURPLE_LIGHT, RED, GREEN } from "../constants/index.js";
import { VCard, DonutChart, Overlay, ModalBox, ModalHeader, ModalFooter } from "../components/ui/index.jsx";
// ── Inline SVG line chart ─────────────────────────────────────────────────────
function SvgLineChart({ pts, lines, T, H = 200 }) {
const [hovered, setHovered] = useState(null); // { lineKey, ptIdx }
if (pts.length < 2) {
return (
<div style={{ color: T.muted, fontSize: 12, textAlign: "center", padding: "24px 0" }}>
Minimaal 2 datapunten nodig
</div>
);
}
const W = 700, pl = 64, pr = 16, pt2 = 16, pb = 62;
const iW = W - pl - pr, iH = H - pt2 - pb;
const allVals = lines.flatMap((l) => pts.map((p) => p[l.key] ?? 0));
const dataMin = Math.min(...allVals);
const dataMax = Math.max(...allVals);
const padding = (dataMax - dataMin) * 0.08 || 1;
const mn = dataMin - padding;
const mx = dataMax + padding;
const rng = mx - mn || 1;
const xp = (i) => pl + (i / (pts.length - 1)) * iW;
const yp = (v) => pt2 + (1 - ((v ?? 0) - mn) / rng) * iH;
const gc = T.bg === "#0f1117" ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.05)";
const darkMode = T.bg === "#0f1117";
return (
<div style={{ overflowX: "auto" }}>
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", minWidth: 340, height: "auto", display: "block" }}>
<defs>
{lines.map((l) => (
<linearGradient key={l.key} id={`g_${l.key}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={l.color} stopOpacity="0.25" />
<stop offset="100%" stopColor={l.color} stopOpacity="0.02" />
</linearGradient>
))}
</defs>
{[0, 0.25, 0.5, 0.75, 1].map((f) => {
const gy = pt2 + (1 - f) * iH;
return (
<g key={f}>
<line x1={pl} y1={gy} x2={W - pr} y2={gy} stroke={gc} strokeWidth="1" />
<text x={pl - 4} y={gy + 4} fill={T.muted} fontSize="8" textAnchor="end">
{fmt(mn + f * rng)}
</text>
</g>
);
})}
{lines.map((l) => {
const validPts = pts.map((p, i) => ({ ...p, _i: i })).filter((p) => p[l.key] !== null);
if (validPts.length < 2) return null;
const xs = validPts.map((p) => xp(p._i));
const ys = validPts.map((p) => yp(p[l.key]));
const ls = xs.map((x, i) => `${x},${ys[i]}`).join(" ");
const ar = `${xs[0]},${pt2 + iH} ${ls} ${xs[xs.length - 1]},${pt2 + iH}`;
return (
<g key={l.key}>
<polygon points={ar} fill={`url(#g_${l.key})`} />
<polyline points={ls} fill="none" stroke={l.color} strokeWidth="2.5"
strokeLinejoin="round" strokeLinecap="round" />
{validPts.map((p, i) => {
const x = xs[i], y = ys[i];
const isHov = hovered?.lineKey === l.key && hovered?.ptIdx === i;
const tipW = 90, tipH = 22;
const tipX = Math.min(Math.max(x - tipW / 2, pl), W - tipW - 4);
const tipY = y - tipH - 8 < pt2 ? y + 10 : y - tipH - 8;
return (
<g key={i}
onMouseEnter={() => setHovered({ lineKey: l.key, ptIdx: i })}
onMouseLeave={() => setHovered(null)}
style={{ cursor: "pointer" }}
>
<circle cx={x} cy={y} r="14" fill="transparent" />
<circle cx={x} cy={y} r={isHov ? 6 : 4}
fill={l.color} stroke={T.card} strokeWidth="2" />
{isHov && (
<g>
<rect x={tipX} y={tipY} width={tipW} height={22}
rx="5" fill={darkMode ? "#1e2130" : "#fff"}
stroke={l.color} strokeWidth="1" />
<text x={tipX + tipW / 2} y={tipY + 15} fill={l.color} fontSize="8"
fontWeight="700" textAnchor="middle">
{fmt(p[l.key])}
</text>
</g>
)}
</g>
);
})}
</g>
);
})}
{pts.map((p, i) => (
<text key={i}
x={xp(i)} y={H - pb + 16}
fill={T.muted} fontSize="8"
textAnchor="end"
transform={`rotate(-45, ${xp(i)}, ${H - pb + 16})`}>
{fmtDatum(p.datum)}
</text>
))}
</svg>
<div style={{ display: "flex", gap: 20, justifyContent: "center", marginTop: 10 }}>
{lines.map((l) => (
<div key={l.key} style={{ display: "flex", alignItems: "center", gap: 6 }}>
<div style={{ width: 18, height: 3, background: l.color, borderRadius: 2 }} />
<div style={{ width: 8, height: 8, borderRadius: "50%", background: l.color }} />
<span style={{ fontSize: 12, color: T.muted }}>{l.label}</span>
</div>
))}
</div>
</div>
);
}
// ── VoortgangModal ────────────────────────────────────────────────────────────
function VoortgangModal({ onSave, onClose, T, darkMode, initialData }) {
const is = { background: T.inputBg, border: `1px solid ${T.inputBorder}`, borderRadius: 8, color: T.text, padding: "8px 10px", fontSize: 13, outline: "none", boxSizing: "border-box" };
const isEdit = !!initialData;
const [datum, setDatum] = useState(initialData?.datum || "");
const [ev, setEv] = useState(initialData?.ev != null ? String(initialData.ev) : "");
const [schuld, setSchuld] = useState(initialData?.schuld != null ? String(initialData.schuld) : "");
const [opmerkingen, setOpmerkingen] = useState(initialData?.opmerkingen || "");
const [err, setErr] = useState("");
const save = () => {
if (!datum) { setErr("Vul een datum in."); setTimeout(() => setErr(""), 3000); return; }
if (!ev && !schuld) { setErr("Vul minimaal Eigen Vermogen of Schuld in."); setTimeout(() => setErr(""), 3000); return; }
onSave({
datum,
ev: ev ? parseFloat(ev) : null,
schuld: schuld ? parseFloat(schuld) : null,
opmerkingen,
});
onClose();
};
return (
<Overlay onClose={onClose}>
<ModalBox maxWidth={480}>
<ModalHeader
icon="📊" iconBg={PURPLE}
title={isEdit ? "Voortgang bewerken" : "Voortgang toevoegen"}
subtitle="Vul de waarden in voor dit moment"
subtitleColor={T.muted}
onClose={onClose}
headerBg={darkMode ? `${PURPLE}08` : "#f8f5ff"}
T={T}
/>
<div style={{ padding: "20px 22px", overflowY: "auto", maxHeight: "70vh" }}>
<VLabel label="Datum *" T={T} />
<input type="date" value={datum}
onChange={(e) => setDatum(e.target.value)}
style={{ ...is, width: "100%", marginBottom: 16 }} />
<VLabel label="Eigen Vermogen (€)" T={T} />
<div style={{ position: "relative", marginBottom: 16 }}>
<span style={{ position: "absolute", left: 8, top: "50%", transform: "translateY(-50%)", fontSize: 12, color: T.muted, pointerEvents: "none" }}>€</span>
<input type="number" value={ev} onChange={(e) => setEv(e.target.value)}
placeholder="0" style={{ ...is, paddingLeft: 22, width: "100%" }} />
</div>
<VLabel label="Schuld (€)" T={T} />
<div style={{ position: "relative", marginBottom: 16 }}>
<span style={{ position: "absolute", left: 8, top: "50%", transform: "translateY(-50%)", fontSize: 12, color: T.muted, pointerEvents: "none" }}>€</span>
<input type="number" value={schuld} onChange={(e) => setSchuld(e.target.value)}
placeholder="0" style={{ ...is, paddingLeft: 22, width: "100%" }} />
</div>
<VLabel label="Opmerkingen" T={T} />
<input type="text" value={opmerkingen}
onChange={(e) => setOpmerkingen(e.target.value)}
placeholder="Optionele notitie..."
style={{ ...is, width: "100%" }} />
{err && (
<div style={{ color: RED, fontSize: 12, marginTop: 12, padding: "6px 10px", background: "rgba(239,68,68,0.1)", borderRadius: 8, border: "1px solid rgba(239,68,68,0.25)" }}>
{err}
</div>
)}
</div>
<ModalFooter T={T}>
<button onClick={onClose} style={{ padding: "10px", background: "transparent", border: `1px solid ${T.border}`, borderRadius: 10, color: T.muted, cursor: "pointer", fontWeight: 600, fontSize: 13 }}>
Annuleren
</button>
<button onClick={save} style={{ flex: 2, padding: "10px", background: `linear-gradient(135deg, ${PURPLE}, ${PURPLE}cc)`, border: "none", borderRadius: 10, color: "#fff", fontWeight: 700, cursor: "pointer", fontSize: 13 }}>
{isEdit ? "Opslaan" : "Toevoegen"}
</button>
</ModalFooter>
</ModalBox>
</Overlay>
);
}
function VLabel({ label, T }) {
return (
<div style={{ fontSize: 11, color: T.muted, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 8 }}>
{label}
</div>
);
}
// ── VoortgangTab (pagina) ─────────────────────────────────────────────────────
export default function VoortgangTab() {
const [showAdd, setShowAdd] = useState(false);
const [editRow, setEditRow] = useState(null);
const {
totEV, totSchuld, catTotals, schuldBlokken, schuldHistory,
evVoortgangHistory, setEvVoortgangHistory,
data, setData, schuld, locked, darkMode, T,
} = useApp();
const nettoWaarde = totEV - totSchuld;
const schuldCatsDash = schuldBlokken.map((b) => ({
naam: b.naam, color: b.color,
subtot: b.items.reduce((a, i) => a + (i.waarde || 0), 0),
}));
const combined = [...new Set([
...evVoortgangHistory.map((r) => r.datum),
...schuldHistory.map((s) => s.datum),
])].sort((a, b) => a.localeCompare(b)).map((d) => {
const evRow = evVoortgangHistory.find((r) => r.datum === d);
const schRow = schuldHistory.find((s) => s.datum === d);
const evVal = evRow ? (evRow.ev || 0) : null;
const schVal = schRow ? schRow.schuld : null;
return {
datum: d,
ev: evVal,
schuld: schVal,
netto: evVal !== null && schVal !== null ? evVal - schVal : null,
opmerkingen: evRow?.opmerkingen || "",
};
});
const timeline = combined;
const voortgangRows = [...combined].reverse();
const addVoortgang = ({ datum, ev: evVal, schuld: schuldVal, opmerkingen }) => {
if (evVal !== null) {
const newRow = { datum, ev: evVal, opmerkingen: opmerkingen || "" };
const updated = [...evVoortgangHistory.filter((r) => r.datum !== datum), newRow]
.sort((a, b) => b.datum.localeCompare(a.datum));
setEvVoortgangHistory(updated);
}
if (schuldVal !== null) {
const updated = [...schuldHistory.filter((s) => s.datum !== datum), { datum, schuld: schuldVal }]
.sort((a, b) => b.datum.localeCompare(a.datum));
setData({ ...data, schuld: { ...schuld, schuldHistory: updated } });
}
};
const removeVoortgang = (datum) => {
setEvVoortgangHistory(evVoortgangHistory.filter((r) => r.datum !== datum));
setData({ ...data, schuld: { ...schuld, schuldHistory: schuldHistory.filter((s) => s.datum !== datum) } });
};
const th = { padding: "8px 10px", fontSize: 11, color: T.muted, fontWeight: 600, textAlign: "left", whiteSpace: "nowrap", background: T.card, position: "sticky", top: 0, zIndex: 1, borderBottom: `1px solid ${T.border}` };
const td = { padding: "7px 10px", fontSize: 12, color: T.text, borderBottom: `1px solid ${T.border}` };
return (
<div>
<div style={{ marginBottom: 24 }}>
<h1 style={{ fontSize: 20, fontWeight: 800, margin: 0 }}>Voortgang Schuld & EV</h1>
</div>
{/* Hero netto */}
<VCard style={{
background: `linear-gradient(135deg, ${PURPLE}22, rgba(16,185,129,0.08))`,
border: `1px solid ${T.border}`, borderLeft: `4px solid ${PURPLE}`,
marginBottom: 16,
}}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 12 }}>
<div>
<div style={{ fontSize: 11, color: T.muted, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.08em", marginBottom: 6 }}>Mijn Nettowaarde</div>
<div style={{ fontSize: 32, fontWeight: 800, color: nettoWaarde >= 0 ? GREEN : RED }}>{fmt(nettoWaarde)}</div>
</div>
<div style={{ display: "flex", gap: 24, flexWrap: "wrap" }}>
<div style={{ textAlign: "right" }}>
<div style={{ fontSize: 11, color: T.muted, marginBottom: 4, textTransform: "uppercase" }}>Eigen Vermogen</div>
<div style={{ fontSize: 20, fontWeight: 700, color: GREEN }}>{fmt(totEV)}</div>
</div>
<div style={{ textAlign: "right" }}>
<div style={{ fontSize: 11, color: T.muted, marginBottom: 4, textTransform: "uppercase" }}>Totale Schuld</div>
<div style={{ fontSize: 20, fontWeight: 700, color: RED }}>{fmt(totSchuld)}</div>
</div>
</div>
</div>
</VCard>
{/* KPI kaarten */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill,minmax(260px,1fr))", gap: 14, marginBottom: 20 }}>
{/* Schuld verdeling */}
<VCard style={{ background: T.card, border: `1px solid ${T.border}`, borderTop: `3px solid ${RED}` }}>
<div style={{ fontWeight: 700, fontSize: 12, letterSpacing: "0.08em", textTransform: "uppercase", color: RED, marginBottom: 12 }}>Verdeling Schuld</div>
{schuldCatsDash.map((b) => (
<div key={b.naam} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "6px 0", borderBottom: `1px solid ${T.border}` }}>
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
<div style={{ width: 7, height: 7, borderRadius: "50%", background: b.color }} />
<span style={{ fontSize: 13, color: T.muted }}>{b.naam}</span>
</div>
<span style={{ fontSize: 13, fontWeight: 600, color: b.color }}>{fmt(b.subtot)}</span>
</div>
))}
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 10, paddingTop: 8 }}>
<span style={{ fontSize: 12, fontWeight: 700, color: T.text }}>Totaal schuld</span>
<span style={{ fontSize: 15, fontWeight: 800, color: RED }}>{fmt(totSchuld)}</span>
</div>
</VCard>
{/* EV verdeling */}
<VCard style={{ background: T.card, border: `1px solid ${T.border}`, borderTop: `3px solid ${GREEN}` }}>
<div style={{ fontWeight: 700, fontSize: 12, letterSpacing: "0.08em", textTransform: "uppercase", color: GREEN, marginBottom: 12 }}>Verdeling Eigen Vermogen</div>
{catTotals.map((c) => (
<div key={c.id} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "6px 0", borderBottom: `1px solid ${T.border}` }}>
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
<div style={{ width: 7, height: 7, borderRadius: "50%", background: c.color }} />
<span style={{ fontSize: 13, color: T.muted }}>{c.naam}</span>
</div>
<span style={{ fontSize: 13, fontWeight: 600, color: c.color }}>{fmt(c.tot)}</span>
</div>
))}
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 10, paddingTop: 8 }}>
<span style={{ fontSize: 12, fontWeight: 700, color: T.text }}>Totaal EV</span>
<span style={{ fontSize: 15, fontWeight: 800, color: GREEN }}>{fmt(totEV)}</span>
</div>
</VCard>
{/* Rendement */}
<VCard style={{ background: T.card, border: `1px solid ${T.border}`, borderTop: `3px solid #f59e0b` }}>
<div style={{ fontWeight: 700, fontSize: 12, letterSpacing: "0.08em", textTransform: "uppercase", color: "#f59e0b", marginBottom: 12 }}>Rendement Eigen Vermogen</div>
<div style={{ padding: "10px 0", borderBottom: `1px solid ${T.border}` }}>
<div style={{ fontSize: 11, color: T.muted, marginBottom: 4 }}>Bedrag</div>
<div style={{ fontSize: 15, fontWeight: 700, color: T.muted }}>—</div>
</div>
<div style={{ padding: "10px 0" }}>
<div style={{ fontSize: 11, color: T.muted, marginBottom: 4 }}>Percentage</div>
<div style={{ fontSize: 15, fontWeight: 700, color: T.muted }}>—</div>
</div>
</VCard>
{/* Donut */}
<VCard style={{ background: T.card, border: `1px solid ${T.border}`, borderRadius: 16, display: "flex", flexDirection: "column", alignItems: "center" }}>
<DonutChart segments={[{ val: totEV, color: GREEN }, { val: totSchuld, color: RED }]} total={totEV + totSchuld || 1} size={200} label="Netto waarde" centerVal={fmt(totEV - totSchuld)} />
<div style={{ display: "flex", gap: 20, marginTop: 12, justifyContent: "center" }}>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<div style={{ width: 10, height: 10, borderRadius: "50%", background: GREEN }} />
<span style={{ fontSize: 12, color: T.subtext }}>Eigen Vermogen</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<div style={{ width: 10, height: 10, borderRadius: "50%", background: RED }} />
<span style={{ fontSize: 12, color: T.subtext }}>Schuld</span>
</div>
</div>
</VCard>
</div>
{/* Grafieken */}
<VCard style={{ background: T.card, border: `1px solid ${T.border}`, marginBottom: 14 }}>
<div style={{ fontWeight: 700, fontSize: 14, marginBottom: 12 }}>Ontwikkeling Schuld en EV</div>
<SvgLineChart pts={timeline} lines={[
{ key: "ev", color: GREEN, label: "Eigen Vermogen" },
{ key: "schuld", color: RED, label: "Schuld" },
]} T={T} H={200} />
</VCard>
<VCard style={{ background: T.card, border: `1px solid ${T.border}`, marginBottom: 20 }}>
<div style={{ fontWeight: 700, fontSize: 14, marginBottom: 12 }}>Ontwikkeling Mijn Nettowaarde</div>
<SvgLineChart pts={timeline} lines={[
{ key: "netto", color: GREEN, label: "Netto Vermogen" },
]} T={T} H={200} />
</VCard>
{/* Voortgang tabel */}
<VCard style={{ background: T.card, border: `1px solid ${T.border}`, marginBottom: 14 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
<div style={{ fontWeight: 700, fontSize: 14 }}>Voortgang overzicht</div>
{!locked && (
<button onClick={() => setShowAdd(true)} style={{
padding: "6px 14px", background: PURPLE, border: `1px solid ${PURPLE}`,
borderRadius: 8, color: "#fff", fontWeight: 700, fontSize: 12, cursor: "pointer",
}}>+ Toevoegen</button>
)}
</div>
{showAdd && !locked && (
<VoortgangModal onSave={addVoortgang} onClose={() => setShowAdd(false)} T={T} darkMode={darkMode} />
)}
{editRow && !locked && (
<VoortgangModal onSave={addVoortgang} onClose={() => setEditRow(null)} T={T} darkMode={darkMode} initialData={editRow} />
)}
{voortgangRows.length > 0 ? (
<div style={{ overflowX: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse", minWidth: 560 }}>
<thead>
<tr>
<th style={th}>Datum</th>
<th style={{ ...th, color: GREEN }}>Eigen Vermogen</th>
<th style={{ ...th, color: RED }}>Schuld</th>
<th style={{ ...th, color: PURPLE_LIGHT }}>Nettowaarde</th>
<th style={th}>Opmerkingen</th>
{!locked && <th style={th}></th>}
</tr>
</thead>
<tbody>
{voortgangRows.map((row, i) => (
<tr key={i}>
<td style={td}>{fmtDatum(row.datum)}</td>
<td style={{ ...td, color: GREEN, fontWeight: 600 }}>{row.ev !== null ? fmt(row.ev) : "—"}</td>
<td style={{ ...td, color: RED, fontWeight: 600 }}>{row.schuld !== null ? fmt(row.schuld) : "—"}</td>
<td style={{ ...td, fontWeight: 700, color: row.netto !== null ? (row.netto >= 0 ? GREEN : RED) : T.muted }}>{row.netto !== null ? fmt(row.netto) : "—"}</td>
<td style={{ ...td, color: T.muted }}>{row.opmerkingen || "—"}</td>
{!locked && (
<td style={{ ...td, whiteSpace: "nowrap" }}>
<button onClick={() => setEditRow(row)} style={{ padding: "2px 8px", background: `${PURPLE}22`, border: `1px solid ${PURPLE}44`, borderRadius: 6, color: PURPLE_LIGHT, cursor: "pointer", fontSize: 12, marginRight: 4 }}>✏️</button>
<button onClick={() => 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>
)}
</tr>
))}
</tbody>
</table>
</div>
) : (
<div style={{ color: T.muted, fontSize: 13, textAlign: "center", padding: "24px 0" }}>
Nog geen gecombineerde datapunten. Voeg schuld- en EV-momenten toe.
</div>
)}
</VCard>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
/** Euro formatter (nl-NL, geen decimalen) */
export const fmt = (n) =>
new Intl.NumberFormat("nl-NL", {
style: "currency", currency: "EUR", maximumFractionDigits: 0,
}).format(n || 0);
/** Percentage formatter */
export const pct = (n, d = 1) => `${((n || 0) * 100).toFixed(d)}%`;
/** ISO datum (YYYY-MM-DD) → Nederlandse notatie (DD-MM-YYYY) */
export const fmtDatum = (s) => {
if (!s) return s;
const [y, m, d] = s.split("-");
return `${d}-${m}-${y}`;
};
/** Crypto-bedrag formatter (4 decimalen standaard) */
export const fmtCrypto = (n, d = 4) => (parseFloat(n) || 0).toFixed(d);
/**
* Bedrag in USD of EUR opmaken afhankelijk van geselecteerde valuta.
* @param {number} amount
* @param {"USD"|"EUR"} currency
* @param {number} usdEurRate
*/
export function fmtMoney(amount, currency = "EUR", usdEurRate = 0.92) {
const v = currency === "EUR" ? amount * usdEurRate : amount;
return new Intl.NumberFormat("nl-NL", {
style: "currency",
currency,
maximumFractionDigits: 0,
}).format(v || 0);
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./format.js";
export * from "./storage.js";
+75
View File
@@ -0,0 +1,75 @@
const KEYS = {
data: "wbjw_data",
evRows: "ev_voortgang_rows",
users: "wbjw_users",
exitplan: "exitplan_data",
};
export function loadFromStorage(key) {
try {
const raw = localStorage.getItem(KEYS[key] ?? key);
return raw ? JSON.parse(raw) : null;
} catch { return null; }
}
export function saveToStorage(key, value) {
try { localStorage.setItem(KEYS[key] ?? key, JSON.stringify(value)); } catch {}
}
export function clearStorage(key) {
try { localStorage.removeItem(KEYS[key] ?? key); } catch {}
}
/** Exporteer alle data als JSON-backup blob */
export function buildBackup() {
return {
wbjw_data: localStorage.getItem(KEYS.data),
ev_voortgang_rows: localStorage.getItem(KEYS.evRows),
wbjw_users: localStorage.getItem(KEYS.users),
exitplan_data: localStorage.getItem(KEYS.exitplan),
datum: new Date().toISOString(),
versie: "2.0",
};
}
/** Herstel een backup-object naar localStorage */
export function restoreBackup(backup) {
if (backup.wbjw_data) localStorage.setItem(KEYS.data, backup.wbjw_data);
if (backup.ev_voortgang_rows) localStorage.setItem(KEYS.evRows, backup.ev_voortgang_rows);
if (backup.wbjw_users) localStorage.setItem(KEYS.users, backup.wbjw_users);
if (backup.exitplan_data) localStorage.setItem(KEYS.exitplan, backup.exitplan_data);
}
/** Migreer oud schuld-formaat naar blokken-formaat */
export function migrateData(parsed) {
if (parsed.schuld && !parsed.schuld.blokken) {
const h = parsed.schuld.hypotheek || {};
const b = parsed.schuld.belastingdienst || {};
const ex = parsed.schuld.extra || [];
parsed.schuld.blokken = [
{
id: "s1", naam: "Hypotheek", color: "#ef4444",
items: Object.entries({
deel1: "Leningdeel 1", deel2: "Leningdeel 2",
deel3: "Leningdeel 3", deel4: "Leningdeel 4",
deel5: "Sub-categorie 5",
}).map(([k, n], i) => ({ id: `s1i${i + 1}`, naam: n, waarde: h[k] || 0 })),
},
{
id: "s2", naam: "Belastingdienst", color: "#f97316",
items: Object.entries({
sub1: "Kinderopvangtoeslag", sub2: "Sub-categorie 2",
sub3: "Sub-categorie 3", sub4: "Sub-categorie 4",
sub5: "Sub-categorie 5",
}).map(([k, n], i) => ({ id: `s2i${i + 1}`, naam: n, waarde: b[k] || 0 })),
},
...ex.map((cat, ci) => ({ ...cat, id: `s${ci + 3}` })),
];
delete parsed.schuld.hypotheek;
delete parsed.schuld.belastingdienst;
delete parsed.schuld.extra;
}
if (!parsed.schuld.schuldHistory) parsed.schuld.schuldHistory = [];
if (!parsed.schuld.initieleSchuld) parsed.schuld.initieleSchuld = 303000;
return parsed;
}
+41
View File
@@ -0,0 +1,41 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { readFileSync } from "fs";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf-8"));
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
// In dev: proxy /api naar de backend zodat je geen CORS-issues hebt
proxy: {
"/api": {
target: "http://backend:3001",
changeOrigin: true,
secure: false,
},
},
},
build: {
outDir: "dist",
sourcemap: false, // zet op true voor staged deploys
chunkSizeWarningLimit: 800,
rollupOptions: {
output: {
// Splits vendor chunks voor betere caching
manualChunks: {
react: ["react", "react-dom"],
},
},
},
},
define: {
// VITE_API_URL wordt ingebakken tijdens build
__API_URL__: JSON.stringify(process.env.VITE_API_URL ?? "/api"),
__APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION ?? pkg.version),
},
});
+8
View File
@@ -0,0 +1,8 @@
{
"name": "vibefinance",
"version": "0.1.5-dev",
"description": "VibeFinance — persoonlijk vermogensbeheer",
"private": true,
"repository": "https://vibehoogie.duckdns.org/vibe/VibeFinance",
"updateCheckUrl": "https://vibehoogie.duckdns.org/api/v1/repos/vibe/VibeFinance/releases/latest"
}
+208
View File
@@ -0,0 +1,208 @@
# ── VibeFinance release script (PowerShell) ──────────────────────────────
# Productie release: bouwt multi-platform, pusht naar registry,
# maakt een Git-tag aan en pusht naar origin.
#
# Gebruik:
# .\release.ps1 # bouw + push images + Git-tag
# .\release.ps1 -NoBuild # sla docker build over, alleen Git-tag
# .\release.ps1 -DryRun # toon alle commando's zonder iets uit te voeren
# .\release.ps1 -Force # sla conflictcontrole over, overschrijf bestaande tags
#
# Vereisten:
# - Docker Desktop actief
# - Ingelogd op de registry: docker login <registry>
# - docker buildx builder actief: docker buildx create --use
# - Git geïnstalleerd en working directory is de repo root
# - root package.json bevat een schoon versienummer ZONDER -dev suffix
param(
[switch]$NoBuild,
[switch]$DryRun,
[switch]$Force
)
$ErrorActionPreference = "Stop"
# ════════════════════════════════════════════════════════════════════════════
# ── PROJECTCONFIGURATIE — pas alleen dit blok aan voor een nieuw project ──
# ════════════════════════════════════════════════════════════════════════════
$appName = "VibeFinance"
$registry = "vibehoogie.duckdns.org/vibe"
$backendImage = "vibefinance-backend"
$frontendImage = "vibefinance-frontend"
$apiUrl = "/api"
# ════════════════════════════════════════════════════════════════════════════
# ── Versie uit root package.json ───────────────────────────────────────────
$rootPkg = Get-Content (Join-Path $PSScriptRoot "package.json") -Raw | ConvertFrom-Json
$appVersion = $rootPkg.version
if (-not $appVersion) {
Write-Error "Kon versienummer niet lezen uit package.json"
exit 1
}
if ($appVersion -match "-dev$") {
Write-Error "Versienummer '$appVersion' bevat '-dev' suffix. Verwijder de suffix voor een productie-release."
exit 1
}
# ── Versie synchroniseren naar frontend/package.json ──────────────────────
$frontendPkgPath = Join-Path $PSScriptRoot "frontend/package.json"
$frontendPkg = Get-Content $frontendPkgPath -Raw | ConvertFrom-Json
if ($frontendPkg.version -ne $appVersion) {
$frontendPkg.version = $appVersion
[System.IO.File]::WriteAllText($frontendPkgPath, ($frontendPkg | ConvertTo-Json -Depth 10))
Write-Host "Versie gesynchroniseerd naar frontend: $appVersion" -ForegroundColor Green
}
$gitTag = "$appVersion"
# ── Registry tags ──────────────────────────────────────────────────────────
$backendBase = "$registry/$backendImage"
$frontendBase = "$registry/$frontendImage"
$backendVersionTag = "$backendBase`:$appVersion"
$frontendVersionTag = "$frontendBase`:$appVersion"
$backendLatestTag = "$backendBase`:latest"
$frontendLatestTag = "$frontendBase`:latest"
# ── Helpers ────────────────────────────────────────────────────────────────
function Invoke-Cmd {
param([string[]]$Cmd)
$display = $Cmd -join " "
if ($DryRun) {
Write-Host "[DRY-RUN] $display" -ForegroundColor Yellow
return ""
}
Write-Host "> $display" -ForegroundColor DarkGray
$output = & $Cmd[0] $Cmd[1..($Cmd.Length - 1)]
if ($LASTEXITCODE -ne 0) { throw "Commando mislukt: $display" }
return $output
}
function Write-Step {
param([string]$Label)
Write-Host ""
Write-Host "── $Label" -ForegroundColor Cyan
}
# ── Samenvatting ───────────────────────────────────────────────────────────
Write-Host ""
Write-Host "════════════════════════════════════════" -ForegroundColor Cyan
Write-Host " $appName Release" -ForegroundColor Cyan
Write-Host " Versie : $appVersion" -ForegroundColor Green
Write-Host " Git-tag : $gitTag" -ForegroundColor Green
Write-Host " Registry: $registry" -ForegroundColor Green
if ($DryRun) { Write-Host " Mode : DRY-RUN (geen wijzigingen)" -ForegroundColor Yellow }
if ($NoBuild) { Write-Host " Mode : SKIP BUILD" -ForegroundColor Yellow }
if ($Force) { Write-Host " Mode : FORCE (conflictcontrole overgeslagen)" -ForegroundColor Red }
Write-Host "════════════════════════════════════════" -ForegroundColor Cyan
# ── Stap 1: Conflictcontrole ──────────────────────────────────────────────
Write-Step "[1/3] Conflictcontrole"
if (-not $DryRun -and -not $Force) {
$localTag = git tag --list $gitTag
if ($localTag) {
Write-Error "Git-tag '$gitTag' bestaat al lokaal. Verhoog het versienummer in package.json of gebruik -Force."
exit 1
}
$remoteTags = git ls-remote --tags origin "refs/tags/$gitTag"
if ($remoteTags) {
Write-Error "Git-tag '$gitTag' bestaat al op origin. Verhoog het versienummer in package.json of gebruik -Force."
exit 1
}
Write-Host "Controleer registry: $backendVersionTag" -ForegroundColor DarkGray
$ErrorActionPreference = "SilentlyContinue"
docker manifest inspect $backendVersionTag 2>$null
$backendExists = $LASTEXITCODE -eq 0
$ErrorActionPreference = "Stop"
if ($backendExists) {
Write-Error "Registry-tag '$backendVersionTag' bestaat al. Verhoog het versienummer in package.json of gebruik -Force."
exit 1
}
Write-Host "Controleer registry: $frontendVersionTag" -ForegroundColor DarkGray
$ErrorActionPreference = "SilentlyContinue"
docker manifest inspect $frontendVersionTag 2>$null
$frontendExists = $LASTEXITCODE -eq 0
$ErrorActionPreference = "Stop"
if ($frontendExists) {
Write-Error "Registry-tag '$frontendVersionTag' bestaat al. Verhoog het versienummer in package.json of gebruik -Force."
exit 1
}
Write-Host "Geen conflicten gevonden." -ForegroundColor Green
} elseif ($Force) {
Write-Host "Conflictcontrole overgeslagen (-Force)" -ForegroundColor Yellow
} else {
Write-Host "[DRY-RUN] Conflictcontrole overgeslagen" -ForegroundColor Yellow
}
# ── Stap 2: Bouwen en pushen ──────────────────────────────────────────────
if (-not $NoBuild) {
Write-Step "[2/3] Multi-platform bouwen en pushen"
Write-Host "Backend bouwen en pushen..." -ForegroundColor White
Invoke-Cmd @(
"docker", "buildx", "build",
"--platform", "linux/amd64,linux/arm64",
"--push",
"-t", $backendVersionTag,
"-t", $backendLatestTag,
"--build-arg", "APP_VERSION=$appVersion",
(Join-Path $PSScriptRoot "backend")
)
Write-Host "Frontend bouwen en pushen..." -ForegroundColor White
Invoke-Cmd @(
"docker", "buildx", "build",
"--platform", "linux/amd64,linux/arm64",
"--push",
"-t", $frontendVersionTag,
"-t", $frontendLatestTag,
"--build-arg", "APP_VERSION=$appVersion",
"--build-arg", "VITE_API_URL=$apiUrl",
(Join-Path $PSScriptRoot "frontend")
)
} else {
Write-Step "[2/3] Bouwen overgeslagen (-NoBuild)"
}
# ── Stap 3: Git-tag aanmaken en pushen ────────────────────────────────────
Write-Step "[3/3] Git-tag aanmaken en pushen"
if ($Force) {
Write-Host "Bestaande lokale tag verwijderen (indien aanwezig)..." -ForegroundColor Yellow
$ErrorActionPreference = "SilentlyContinue"
git tag -d $gitTag 2>$null | Out-Null
$ErrorActionPreference = "Stop"
Write-Host "Bestaande remote tag verwijderen (indien aanwezig)..." -ForegroundColor Yellow
$ErrorActionPreference = "SilentlyContinue"
git push origin ":refs/tags/$gitTag" 2>$null | Out-Null
$ErrorActionPreference = "Stop"
}
Write-Host "Git-tag aanmaken: $gitTag" -ForegroundColor White
Invoke-Cmd @("git", "tag", "-a", $gitTag, "-m", "Release $gitTag")
Write-Host "Git-tag pushen naar origin" -ForegroundColor White
Invoke-Cmd @("git", "push", "origin", "refs/tags/$gitTag")
# ── Klaar ─────────────────────────────────────────────────────────────────
Write-Host ""
Write-Host "════════════════════════════════════════" -ForegroundColor Cyan
Write-Host " Gepubliceerd: $appVersion" -ForegroundColor Green
Write-Host ""
Write-Host " Registry images:" -ForegroundColor Green
Write-Host " $backendVersionTag" -ForegroundColor White
Write-Host " $backendLatestTag" -ForegroundColor White
Write-Host " $frontendVersionTag" -ForegroundColor White
Write-Host " $frontendLatestTag" -ForegroundColor White
Write-Host ""
Write-Host " Git-tag: $gitTag -> origin" -ForegroundColor Green
Write-Host "════════════════════════════════════════" -ForegroundColor Cyan
Write-Host ""
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# ── VibeFinance release wrapper (macOS/Linux) ────────────────────────────
# Roept release.ps1 aan via PowerShell Core (pwsh).
#
# Gebruik:
# ./release.sh # bouw + push images + Git-tag
# ./release.sh -NoBuild # sla docker build over, alleen Git-tag
# ./release.sh -DryRun # toon alle commando's zonder iets uit te voeren
# ./release.sh -Force # sla conflictcontrole over, overschrijf bestaande tags
#
# Combinaties:
# ./release.sh -Force -NoBuild
# ./release.sh -DryRun -Force
#
# Vereisten:
# - pwsh (PowerShell Core) geïnstalleerd: brew install powershell
# - Docker Desktop actief
# - docker buildx builder actief: docker buildx create --use
# - Ingelogd op de registry: docker login vibetea.vldn.net
# - Git geïnstalleerd
# - root package.json bevat versienummer ZONDER -dev suffix
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if ! command -v pwsh &>/dev/null; then
echo "Fout: pwsh niet gevonden. Installeer via: brew install powershell" >&2
exit 1
fi
# Geef alle argumenten door aan het .ps1 script
pwsh -NoProfile -ExecutionPolicy Bypass \
-File "$SCRIPT_DIR/release.ps1" \
"$@"