first commit

This commit is contained in:
2026-04-16 10:22:13 +02:00
commit 2b72f306ff
55 changed files with 10732 additions and 0 deletions

23
.env.example Normal file
View File

@@ -0,0 +1,23 @@
# ==============================================================================
# VibeFinance — Configuratie
# Kopieer dit bestand naar .env en vul de verplichte waarden in:
# cp .env.example .env
#
# Commit .env NOOIT naar Git — het staat al in .gitignore
# ==============================================================================
# ── Database ───────────────────────────────────────────────────────────────
POSTGRES_USER=vibefinance
POSTGRES_PASSWORD=verander_dit_wachtwoord
# POSTGRES_DB=vibefinance # optioneel — standaard vibefinance
# ── Backend ────────────────────────────────────────────────────────────────
# Genereer een veilige waarde met: openssl rand -hex 64
JWT_SECRET=verander_dit_naar_een_zeer_lange_willekeurige_string_minimaal_64_tekens
JWT_EXPIRES=7d
# ── URLs ───────────────────────────────────────────────────────────────────
CORS_ORIGIN=https://jouwdomein.nl
# ── Poorten ────────────────────────────────────────────────────────────────
FRONTEND_PORT=3300

21
.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
# Environment — commit NOOIT secrets
.env
.env.local
# Node
node_modules/
npm-debug.log*
# Vite build output
frontend/dist/
# OS
.DS_Store
Thumbs.db
# Editor
.vscode/
.idea/
# Claude
.claude/

162
BEHEER.md Normal file
View File

@@ -0,0 +1,162 @@
# 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
```
---
## 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
```
10.0.3.108:3000/vibe/vibefinance-backend
10.0.3.108: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 10.0.3.108:3000
```
---
## Update-notificaties
De app vergelijkt de draaiende versie met de laatste release op Gitea. Configuratie staat in `package.json`:
```json
"repository": "https://10.0.3.108:3000/vibe/VibeFinance",
"updateCheckUrl": "https://10.0.3.108:3000/api/v1/repos/vibe/VibeFinance/releases/latest"
```
Vite en de build-scripts lezen deze velden automatisch uit — geen handmatige build-args nodig.

52
Changelog.md Normal file
View File

@@ -0,0 +1,52 @@
# Changelog
## [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
README.md Normal file
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.0.3-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 binnen de container
│ ├── 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
- 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>

4
backend/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules
.env*
*.log
.DS_Store

41
backend/Dockerfile Normal file
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
backend/package.json Normal file
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"
}
}

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,
};

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
backend/src/db.js Normal file
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
backend/src/logger.js Normal file
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 } },
}),
});

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();
}

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
backend/src/routes/auth.js Normal file
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;

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;

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;

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
backend/src/server.js Normal file
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;

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))]
);
},
};

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
dev.ps1 Normal file
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 = "10.0.3.108:3000/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
dev.sh Normal file
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
docker-compose.dev.yml Normal file
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
docker-compose.yml Normal file
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:16-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/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U vibefinance"]
interval: 10s
timeout: 5s
retries: 5
networks:
- vibefinance_net
backend:
image: 10.0.3.108:3000/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: 10.0.3.108:3000/vibe/vibefinance-frontend:latest
container_name: vibefinance_frontend
restart: unless-stopped
ports:
- "${FRONTEND_PORT:-3300}:80"
depends_on:
backend:
condition: service_healthy
networks:
- vibefinance_net
volumes:
vibefinance_pgdata:
networks:
vibefinance_net:
driver: bridge

5
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
.env*
*.log
.DS_Store

42
frontend/Dockerfile Normal file
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
frontend/index.html Normal file
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=Inter: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>

22
frontend/nginx-spa.conf Normal file
View File

@@ -0,0 +1,22 @@
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;
}
# 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
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
frontend/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "vibefinance-frontend",
"version": "0.0.5-dev",
"updateCheckUrl": "https://10.0.3.108:3000/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"
}
}

21
frontend/src/App.css Normal file
View File

@@ -0,0 +1,21 @@
* {
box-sizing: border-box;
}
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); }
}

186
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,186 @@
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, PURPLE_LIGHT } from "./constants/index.js";
import ProfielPopup from "./components/ProfielPopup.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, loading, T, darkMode, setDarkMode, currentUser, avatar } = useApp();
const [profielOpen, setProfielOpen] = useState(false);
const idleTimer = useRef(null);
const IDLE_MS = 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]);
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: "'Inter', system-ui, sans-serif" }}>
<style>{`
* { box-sizing: border-box; }
input[type=number]::-webkit-inner-spin-button { opacity: 0.4; }
`}</style>
<NavBar />
{/* Hoofdinhoud */}
<div style={{ flex: 1, overflowY: "auto", maxHeight: "100vh" }}>
{/* Topbalk rechts */}
<div style={{ display: "flex", justifyContent: "flex-end", alignItems: "center", gap: 8, padding: "12px 20px 0" }}>
<div style={{ position: "relative" }}>
<button onClick={() => setProfielOpen((o) => !o)} style={{
display: "flex", alignItems: "center", gap: 6,
background: "transparent", border: "none", cursor: "pointer", padding: 0,
}}>
<div style={{
width: 44, height: 44, borderRadius: "50%", overflow: "hidden", flexShrink: 0,
background: avatar ? "transparent" : (profielOpen ? `linear-gradient(135deg, ${PURPLE}, #a855f7)` : `${PURPLE}33`),
border: `1px solid ${profielOpen ? PURPLE : PURPLE + "55"}`,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 13, fontWeight: 800, color: profielOpen ? "#fff" : PURPLE_LIGHT,
}}>
{avatar
? <img src={avatar} alt="avatar" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
: initials(currentUser?.naam || "")
}
</div>
<svg width="10" height="10" viewBox="0 0 10 10" fill="none"
style={{ color: T.muted, transition: "transform 0.2s", transform: profielOpen ? "rotate(180deg)" : "none", flexShrink: 0 }}>
<polyline points="1,3 5,7 9,3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
{profielOpen && <ProfielPopup onClose={() => setProfielOpen(false)} />}
</div>
<button onClick={() => setDarkMode((d) => !d)} style={{
width: 44, height: 44, borderRadius: 10,
background: T.card, border: `1px solid ${T.border}`,
display: "flex", alignItems: "center", justifyContent: "center",
cursor: "pointer", fontSize: 20,
}} title="Dark modus">
{darkMode ? "🌙" : "☀️"}
</button>
</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>
)}
{/* Gebruikersbeheer modal */}
{showUsers && <GebruikersBeheer onClose={() => setShowUsers(false)} />}
{/* Actieve pagina */}
<div style={{ maxWidth: 1200, margin: "0 auto", padding: "24px 16px" }}>
<ActivePage />
</div>
</div>
</div>
);
}
export default function App() {
return (
<AppProvider>
<AppInner />
</AppProvider>
);
}

View File

@@ -0,0 +1,355 @@
import { useState, useEffect, useRef } from "react";
import { PURPLE, PURPLE_LIGHT, RED, GREEN } from "../constants/index.js";
import { Overlay, ModalBox, ModalHeader } from "./ui/index.jsx";
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 ? PURPLE_LIGHT : T.text,
background: o === value ? `${PURPLE}22` : "transparent",
}}
onMouseEnter={(e) => e.currentTarget.style.background = `${PURPLE}18`}
onMouseLeave={(e) => e.currentTarget.style.background = o === value ? `${PURPLE}22` : "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 (
<Overlay onClose={onClose}>
<ModalBox maxWidth={700}>
<ModalHeader
icon="👥" iconBg={PURPLE}
title="Gebruikersbeheer"
subtitle={`${users.length} gebruiker${users.length !== 1 ? "s" : ""}`}
subtitleColor={PURPLE_LIGHT}
onClose={onClose}
headerBg={darkMode ? `${PURPLE}08` : "#f8f5ff"}
T={T}
/>
<div style={{ padding: "16px 22px" }}>
{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: `${PURPLE}33`, display: "flex",
alignItems: "center", justifyContent: "center",
fontSize: 12, fontWeight: 700, color: PURPLE_LIGHT, flexShrink: 0,
}}>
{initials}
</div>
<span style={{ fontWeight: 600 }}>{u.naam}</span>
{isSelf && (
<span style={{
fontSize: 10, fontWeight: 700, color: PURPLE_LIGHT,
background: `${PURPLE}22`, borderRadius: 99,
padding: "1px 7px", border: `1px solid ${PURPLE}44`,
}}>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: `${PURPLE}22`,
border: `1px solid ${PURPLE}44`, borderRadius: 6,
color: PURPLE_LIGHT, 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 ? `${PURPLE}06` : "#faf7ff",
border: `1px solid ${PURPLE}44`, borderRadius: 10, padding: "16px", marginBottom: 16,
}}>
<div style={{ fontSize: 11, color: PURPLE_LIGHT, 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: `linear-gradient(135deg, ${PURPLE}, #a855f7)`,
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 ? `${PURPLE}06` : "#faf7ff",
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: `linear-gradient(135deg, ${PURPLE}, #a855f7)`,
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: `linear-gradient(135deg, ${PURPLE}, #a855f7)`,
border: "none", borderRadius: 8, color: "#fff",
fontWeight: 700, fontSize: 13, cursor: "pointer",
}}>+ Nieuwe gebruiker</button>
)}
</div>
<div style={{ padding: "12px 22px", borderTop: `1px solid ${T.border}` }}>
<button onClick={onClose} style={{
width: "100%", padding: "10px", background: "transparent",
border: `1px solid ${T.border}`, borderRadius: 10,
color: T.muted, cursor: "pointer", fontWeight: 600, fontSize: 13,
}}>Sluiten</button>
</div>
</ModalBox>
</Overlay>
);
}

View File

@@ -0,0 +1,297 @@
import { useState } from "react";
import { PURPLE, PURPLE_LIGHT, RED } from "../constants/index.js";
export default function LoginPage({ onLogin }) {
const [mode, setMode] = useState("login");
const [naam, setNaam] = useState("");
const [email, setEmail] = useState("");
const [wachtwoord, setWachtwoord] = useState("");
const [showPass, setShowPass] = useState(false);
const [err, setErr] = useState("");
const [loading, setLoading] = useState(false);
const switchMode = (m) => { setMode(m); setNaam(""); setEmail(""); setWachtwoord(""); setErr(""); };
const handleSubmit = async () => {
setErr("");
if (mode === "register" && !naam.trim()) { setErr("Vul je naam in."); 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 iStyle = {
width: "100%",
background: "rgba(255,255,255,0.06)",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 8, color: "#f1f5f9",
padding: "11px 14px", fontSize: 13,
outline: "none", boxSizing: "border-box",
};
return (
<div style={{
minHeight: "100vh",
background: "radial-gradient(ellipse at 20% 50%, #1a1060 0%, #0a0b1a 50%, #0d0a2a 100%)",
display: "flex", fontFamily: "'Inter', system-ui, sans-serif",
color: "#f1f5f9", overflow: "hidden", position: "relative",
}}>
{/* Achtergrond gloeden */}
<div style={{ position: "fixed", width: 700, height: 700, borderRadius: "50%", background: "radial-gradient(circle, rgba(88,28,220,0.18) 0%, transparent 65%)", top: "-20%", left: "-10%", pointerEvents: "none" }} />
<div style={{ position: "fixed", width: 500, height: 500, borderRadius: "50%", background: "radial-gradient(circle, rgba(59,0,180,0.12) 0%, transparent 65%)", bottom: "-10%", left: "20%", pointerEvents: "none" }} />
{/* Links: formulier */}
<div style={{
width: 380, flexShrink: 0, minHeight: "100vh",
display: "flex", flexDirection: "column", justifyContent: "center",
padding: "48px 40px", boxSizing: "border-box", marginLeft: 80,
position: "relative", zIndex: 2,
}}>
{/* Logo */}
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 52 }}>
<div style={{
width: 42, height: 42, flexShrink: 0,
background: `linear-gradient(135deg, ${PURPLE}, #a855f7)`,
borderRadius: 10, display: "flex", alignItems: "center",
justifyContent: "center", boxShadow: `0 0 12px ${PURPLE}66`,
}}>
<svg width="26" height="26" viewBox="0 0 20 20" fill="none">
<polyline points="2,15 6,9 10,12 14,5 18,8" stroke="#fff" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round"/>
<line x1="2" y1="17" x2="18" y2="17" stroke="rgba(255,255,255,0.35)" strokeWidth="1.2"/>
</svg>
</div>
<span style={{
fontWeight: 700, fontSize: 17, whiteSpace: "nowrap",
background: `linear-gradient(90deg, ${PURPLE}, #a855f7)`,
WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent",
}}>VibeFinance</span>
</div>
{/* Heading */}
<div style={{ marginBottom: 28 }}>
<h1 style={{ fontSize: 26, fontWeight: 800, margin: "0 0 8px", color: "#fff" }}>
{mode === "login" ? "Welkom terug!" : "Account aanmaken"}
</h1>
<p style={{ fontSize: 13, color: "rgba(255,255,255,0.45)", margin: 0, lineHeight: 1.5 }}>
{mode === "login"
? "Voer je gegevens in om in te loggen op je dashboard."
: "Maak een account aan om je vermogen bij te houden."}
</p>
</div>
{/* Formulier */}
<div style={{ display: "flex", flexDirection: "column", gap: 10, marginBottom: 20 }}>
{mode === "register" && (
<div>
<input type="text" placeholder="Naam" value={naam}
onChange={(e) => { setNaam(e.target.value); setErr(""); }}
onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
style={iStyle} />
</div>
)}
<div>
<input type="email" placeholder="E-mailadres" value={email}
onChange={(e) => { setEmail(e.target.value); setErr(""); }}
onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
style={iStyle} />
</div>
<div>
<div style={{ position: "relative" }}>
<input type={showPass ? "text" : "password"} placeholder="Wachtwoord"
value={wachtwoord}
onChange={(e) => { setWachtwoord(e.target.value); setErr(""); }}
onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
style={{ ...iStyle, paddingRight: 42 }} />
<button onClick={() => setShowPass((s) => !s)} style={{
position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)",
background: "none", border: "none", cursor: "pointer",
color: "rgba(255,255,255,0.4)", fontSize: 14, padding: 0, lineHeight: 1,
}}>{showPass ? "🙈" : "👁️"}</button>
</div>
</div>
</div>
{err && (
<div style={{
background: "rgba(239,68,68,0.08)", border: "1px solid rgba(239,68,68,0.25)",
borderRadius: 8, padding: "9px 12px", marginBottom: 14, fontSize: 12, color: RED,
}}>{err}</div>
)}
{/* Submit knop */}
<button onClick={handleSubmit} disabled={loading} style={{
width: "100%", padding: "12px",
background: loading ? "rgba(99,60,220,0.5)" : "linear-gradient(135deg, #5b21b6, #7c3aed, #6d28d9)",
border: "none", borderRadius: 8, color: "#fff",
fontWeight: 700, fontSize: 14, cursor: loading ? "not-allowed" : "pointer",
boxShadow: loading ? "none" : "0 4px 20px rgba(109,40,217,0.5)",
marginBottom: 20,
}}>
{loading
? (mode === "login" ? "Inloggen..." : "Account aanmaken...")
: (mode === "login" ? "Inloggen" : "Account aanmaken")}
</button>
{/* Switch mode */}
<div style={{ marginTop: 20, textAlign: "center" }}>
{mode === "login" ? (
<span style={{ fontSize: 12, color: "rgba(255,255,255,0.4)" }}>
Nog geen account?{" "}
<button onClick={() => switchMode("register")} style={{ background: "none", border: "none", color: PURPLE_LIGHT, cursor: "pointer", fontSize: 12, fontWeight: 600, padding: 0 }}>Registreer</button>
</span>
) : (
<span style={{ fontSize: 12, color: "rgba(255,255,255,0.4)" }}>
Al een account?{" "}
<button onClick={() => switchMode("login")} style={{ background: "none", border: "none", color: PURPLE_LIGHT, cursor: "pointer", fontSize: 12, fontWeight: 600, padding: 0 }}>Inloggen</button>
</span>
)}
</div>
</div>
{/* Rechts: app mockup — flat, groot, licht zwevend */}
<div style={{
flex: 1, minHeight: "100vh", display: "flex", alignItems: "center",
justifyContent: "flex-start", paddingLeft: 260,
position: "relative", zIndex: 1, overflow: "hidden",
}}>
<div style={{
width: "calc(100% + 40px)", maxWidth: 900,
borderRadius: "12px 0 0 12px", overflow: "hidden",
boxShadow: "0 0 0 1px rgba(255,255,255,0.07), 0 24px 60px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.04)",
position: "relative",
maskImage: "linear-gradient(to right, black 40%, transparent 100%)",
WebkitMaskImage: "linear-gradient(to right, black 40%, transparent 100%)",
}}>
{/* Browser chrome */}
<div style={{ background: "#161927", padding: "9px 14px", display: "flex", alignItems: "center", gap: 5, borderBottom: "1px solid rgba(255,255,255,0.05)" }}>
<div style={{ width: 9, height: 9, borderRadius: "50%", background: "#ef4444", opacity: 0.8 }} />
<div style={{ width: 9, height: 9, borderRadius: "50%", background: "#f59e0b", opacity: 0.8 }} />
<div style={{ width: 9, height: 9, borderRadius: "50%", background: "#10b981", opacity: 0.8 }} />
<div style={{ flex: 1, margin: "0 10px", background: "rgba(255,255,255,0.05)", borderRadius: 5, padding: "3px 10px", fontSize: 10, color: "rgba(255,255,255,0.2)", textAlign: "center" }}>vibefinance.app</div>
</div>
{/* App */}
<div style={{ display: "flex", background: "#0f1117", height: 620 }}>
{/* Sidebar */}
<div style={{ width: 52, background: "#161927", borderRight: "1px solid rgba(255,255,255,0.05)", display: "flex", flexDirection: "column", alignItems: "center", padding: "14px 0 12px", gap: 4, flexShrink: 0 }}>
<div style={{ width: 24, height: 24, borderRadius: 6, border: "1px solid rgba(255,255,255,0.1)", display: "flex", alignItems: "center", justifyContent: "center", marginBottom: 10 }}>
<svg width="13" height="13" viewBox="0 0 16 16" fill="none"><rect x="1" y="1" width="14" height="14" rx="2.5" stroke="rgba(255,255,255,0.35)" strokeWidth="1.4"/><line x1="5" y1="1.7" x2="5" y2="14.3" stroke="rgba(255,255,255,0.35)" strokeWidth="1.4"/></svg>
</div>
{[
{ icon: <svg width="14" height="14" viewBox="0 0 16 16" fill="none"><rect x="1" y="1" width="6" height="6" rx="1.5" stroke={PURPLE_LIGHT} strokeWidth="1.5"/><rect x="9" y="1" width="6" height="6" rx="1.5" stroke={PURPLE_LIGHT} strokeWidth="1.5"/><rect x="1" y="9" width="6" height="6" rx="1.5" stroke={PURPLE_LIGHT} strokeWidth="1.5"/><rect x="9" y="9" width="6" height="6" rx="1.5" stroke={PURPLE_LIGHT} strokeWidth="1.5"/></svg>, active: true },
{ icon: <svg width="14" height="14" viewBox="0 0 16 16" fill="none"><polyline points="1,12 5,7 8,10 11,4 15,6" stroke="rgba(255,255,255,0.25)" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round"/></svg>, active: false },
{ icon: <svg width="14" height="14" viewBox="0 0 16 16" fill="none"><rect x="1" y="3" width="14" height="10" rx="2" stroke="rgba(255,255,255,0.25)" strokeWidth="1.5"/><line x1="1" y1="6.5" x2="15" y2="6.5" stroke="rgba(255,255,255,0.25)" strokeWidth="1.5"/></svg>, active: false },
{ icon: <svg width="14" height="14" viewBox="0 0 16 16" fill="none"><rect x="1.5" y="8" width="3" height="6" rx="1" fill="rgba(255,255,255,0.25)"/><rect x="6.5" y="5" width="3" height="9" rx="1" fill="rgba(255,255,255,0.25)"/><rect x="11.5" y="2" width="3" height="12" rx="1" fill="rgba(255,255,255,0.25)"/></svg>, active: false },
].map((item, i) => (
<div key={i} style={{ width: 36, height: 36, borderRadius: 8, display: "flex", alignItems: "center", justifyContent: "center", background: item.active ? `${PURPLE}22` : "transparent", marginBottom: 2 }}>
{item.icon}
</div>
))}
<div style={{ width: 28, height: 1, background: "rgba(255,255,255,0.06)", margin: "6px 0" }} />
<div style={{ width: 36, height: 36, borderRadius: 8, display: "flex", alignItems: "center", justifyContent: "center" }}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><circle cx="6" cy="5" r="2.5" stroke="rgba(255,255,255,0.25)" strokeWidth="1.5"/><path d="M1 13c0-2.5 2-4 5-4s5 1.5 5 4" stroke="rgba(255,255,255,0.25)" strokeWidth="1.5" strokeLinecap="round"/></svg>
</div>
<div style={{ flex: 1 }} />
<div style={{ width: 36, height: 36, borderRadius: 8, display: "flex", alignItems: "center", justifyContent: "center" }}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M6 2H3a1 1 0 00-1 1v10a1 1 0 001 1h3" stroke="rgba(255,255,255,0.2)" strokeWidth="1.5" strokeLinecap="round"/><polyline points="10,5 13,8 10,11" stroke="rgba(255,255,255,0.2)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/><line x1="13" y1="8" x2="6" y2="8" stroke="rgba(255,255,255,0.2)" strokeWidth="1.5" strokeLinecap="round"/></svg>
</div>
</div>
{/* Inhoud */}
<div style={{ flex: 1, padding: "18px 20px 14px", overflowY: "hidden" }}>
{/* Topbalk */}
<div style={{ display: "flex", justifyContent: "flex-end", alignItems: "center", gap: 6, marginBottom: 16 }}>
<div style={{ width: 28, height: 28, borderRadius: "50%", background: `${PURPLE}33`, border: `1px solid ${PURPLE}55`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 9, fontWeight: 800, color: PURPLE_LIGHT }}>JH</div>
<svg width="8" height="8" viewBox="0 0 10 10" fill="none"><polyline points="1,3 5,7 9,3" stroke="rgba(255,255,255,0.3)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
<div style={{ width: 28, height: 28, borderRadius: 7, background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.08)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 14 }}>🌙</div>
</div>
{/* Paginatitel */}
<div style={{ marginBottom: 14 }}>
<div style={{ fontSize: 16, fontWeight: 800, color: "#f1f5f9" }}>The Road naar 500.000</div>
<div style={{ fontSize: 10, color: "rgba(255,255,255,0.35)", marginTop: 2 }}>Persoonlijk vermogensdashboard</div>
</div>
{/* 6 KPI kaarten */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 8, marginBottom: 12 }}>
{[
{ label: "Start Eigen Vermogen", val: "€ 0", color: PURPLE },
{ label: "Extra Geïnv. Fiat", val: "€ 303.000", color: PURPLE },
{ label: "Totaal Geïnv. Fiat", val: "€ 303.000", color: PURPLE },
{ label: "Totaal Eigen Vermogen", val: "€ 445.500", color: "#10b981" },
{ label: "Winst op Investering", val: "€ 142.500", color: "#10b981" },
{ label: "ROI %", val: "+47,03%", color: "#10b981" },
].map((k) => (
<div key={k.label} style={{ background: "#161927", border: "1px solid rgba(255,255,255,0.06)", borderLeft: `2px solid ${k.color}55`, borderRadius: 8, padding: "9px 11px" }}>
<div style={{ fontSize: 7.5, color: "rgba(255,255,255,0.35)", fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 5 }}>{k.label}</div>
<div style={{ fontSize: 13, fontWeight: 700, color: k.color }}>{k.val}</div>
</div>
))}
</div>
{/* Progressie balk */}
<div style={{ background: "#161927", border: "1px solid rgba(255,255,255,0.06)", borderRadius: 8, padding: "10px 14px", marginBottom: 12 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ fontSize: 11 }}>🎯</span>
<span style={{ fontSize: 10, fontWeight: 600, color: "#f1f5f9" }}>Doel &amp; Bijbehorende Progressie</span>
<span style={{ fontSize: 9, background: `${PURPLE}22`, color: PURPLE_LIGHT, borderRadius: 99, padding: "1px 7px", fontWeight: 700 }}> 500.000</span>
</div>
<span style={{ fontSize: 9, color: "rgba(255,255,255,0.35)" }}>89,10% behaald</span>
</div>
<div style={{ height: 10, background: "rgba(255,255,255,0.06)", borderRadius: 99, overflow: "hidden", position: "relative" }}>
<div style={{ position: "absolute", inset: 0, width: "89%", background: `linear-gradient(90deg, ${PURPLE}, #a855f7)`, borderRadius: 99 }} />
{[25, 50, 75].map(p => <div key={p} style={{ position: "absolute", left: `${p}%`, top: 0, bottom: 0, width: 1, background: "rgba(0,0,0,0.3)" }} />)}
</div>
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 5 }}>
{["€ 0", "€ 125k", "€ 250k", "€ 375k", "€ 500k"].map(m => (
<span key={m} style={{ fontSize: 8, color: "rgba(255,255,255,0.2)" }}>{m}</span>
))}
</div>
</div>
{/* Grafiek */}
<div style={{ background: "#161927", border: "1px solid rgba(255,255,255,0.06)", borderRadius: 8, padding: "10px 14px" }}>
<div style={{ fontSize: 10, fontWeight: 700, color: "#f1f5f9", marginBottom: 8 }}>Ontwikkeling van Eigen Vermogen</div>
<svg viewBox="0 0 560 120" style={{ width: "100%", height: 120, display: "block" }}>
<defs>
<linearGradient id="gEVLogin" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={PURPLE_LIGHT} stopOpacity="0.3"/>
<stop offset="100%" stopColor={PURPLE_LIGHT} stopOpacity="0.02"/>
</linearGradient>
</defs>
{[0,1,2,3,4].map(i => <line key={i} x1="44" y1={i*24+6} x2="558" y2={i*24+6} stroke="rgba(255,255,255,0.04)" strokeWidth="1"/>)}
{["500k","375k","250k","125k","0"].map((l, i) => (
<text key={l} x="40" y={i*24+10} fill="rgba(255,255,255,0.18)" fontSize="7" textAnchor="end">{l}</text>
))}
<polygon points="44,112 114,102 184,90 254,74 324,56 394,38 464,22 534,10 558,7 558,118 44,118" fill="url(#gEVLogin)"/>
<polyline points="44,112 114,102 184,90 254,74 324,56 394,38 464,22 534,10 558,7" fill="none" stroke={PURPLE_LIGHT} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round"/>
{[[44,112],[114,102],[184,90],[254,74],[324,56],[394,38],[464,22],[534,10],[558,7]].map(([x,y],i) => (
<circle key={i} cx={x} cy={y} r="3" fill={PURPLE_LIGHT} stroke="#161927" strokeWidth="1.5"/>
))}
{["jan","feb","mrt","apr","mei","jun","jul","aug","sep"].map((m, i) => (
<text key={m} x={44 + i * 64} y="118" fill="rgba(255,255,255,0.18)" fontSize="7" textAnchor="middle">{m}</text>
))}
</svg>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,290 @@
import { useState, useEffect } from "react";
import { PURPLE, PURPLE_LIGHT, 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 DATA_ICON = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M8 1v8M5 6l3 3 3-3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M2 11v2a1 1 0 001 1h10a1 1 0 001-1v-2" 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>
);
function Tooltip({ label, collapsed }) {
if (!collapsed) return null;
return (
<div style={{
position: "absolute", left: "calc(100% + 10px)", top: "50%",
transform: "translateY(-50%)",
background: "#1e2130", border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 6, padding: "5px 10px",
fontSize: 12, fontWeight: 500, color: "#fff",
whiteSpace: "nowrap", pointerEvents: "none", zIndex: 200,
}}>{label}</div>
);
}
function SidebarItem({ active, onClick, icon, label, T, collapsed }) {
const [hovered, setHovered] = useState(false);
return (
<div
onClick={onClick}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
title={collapsed ? label : undefined}
style={{
position: "relative",
display: "flex", alignItems: "center",
gap: collapsed ? 0 : 10,
padding: "9px 12px", borderRadius: 8, cursor: "pointer", marginBottom: 6,
justifyContent: collapsed ? "center" : "flex-start",
background: active ? `${PURPLE}18` : "transparent",
color: active ? PURPLE_LIGHT : T.muted,
fontWeight: active ? 600 : 500,
fontSize: 13,
transition: "background 0.15s, color 0.15s",
}}>
<span style={{ flexShrink: 0, display: "flex" }}>{icon}</span>
{!collapsed && <span style={{ flex: 1 }}>{label}</span>}
{hovered && <Tooltip label={label} collapsed={collapsed} />}
</div>
);
}
export default function NavBar() {
const {
tab, setTab,
setShowUsers,
T,
logout,
doBackup, doRestore,
} = useApp();
const [nieuweVersie, setNieuweVersie] = useState(null);
const [dataOpen, setDataOpen] = useState(false);
const [collapsed, setCollapsed] = useState(false);
const W = collapsed ? 56 : 220;
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: W, minWidth: W, flexShrink: 0,
height: "100vh", position: "sticky", top: 0,
background: T.card, borderRight: `1px solid ${T.border}`,
display: "flex", flexDirection: "column",
zIndex: 100, transition: "width 0.2s ease, min-width 0.2s ease",
overflow: "hidden",
}}>
{/* Logo + collapse knop */}
{!collapsed ? (
<div style={{ padding: "20px 12px 16px", display: "flex", alignItems: "center", gap: 8, justifyContent: "space-between" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, overflow: "hidden" }}>
<div style={{
width: 42, height: 42, flexShrink: 0,
background: `linear-gradient(135deg, ${PURPLE}, #a855f7)`,
borderRadius: 10, display: "flex", alignItems: "center",
justifyContent: "center", boxShadow: `0 0 12px ${PURPLE}66`,
}}>
<svg width="26" height="26" viewBox="0 0 20 20" fill="none">
<polyline points="2,15 6,9 10,12 14,5 18,8" stroke="#fff"
strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
<line x1="2" y1="17" x2="18" y2="17"
stroke="rgba(255,255,255,0.35)" strokeWidth="1.2" />
</svg>
</div>
<span style={{
fontWeight: 700, fontSize: 17, whiteSpace: "nowrap",
background: `linear-gradient(90deg, ${PURPLE}, #a855f7)`,
WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent",
}}>VibeFinance</span>
</div>
<button onClick={() => setCollapsed(true)} style={{
background: "transparent", border: `1px solid ${T.border}`,
borderRadius: 6, cursor: "pointer", color: T.muted,
width: 28, height: 28, display: "flex", alignItems: "center", justifyContent: "center",
flexShrink: 0, padding: 0,
}}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="1" y="1" width="14" height="14" rx="2.5" stroke="currentColor" strokeWidth="1.4"/>
<line x1="5" y1="1.7" x2="5" y2="14.3" stroke="currentColor" strokeWidth="1.4"/>
</svg>
</button>
</div>
) : (
<div style={{ padding: "20px 12px 16px", display: "flex", justifyContent: "center" }}>
<button onClick={() => setCollapsed(false)} style={{
background: "transparent", border: `1px solid ${T.border}`,
borderRadius: 6, cursor: "pointer", color: T.muted,
width: 28, height: 28, display: "flex", alignItems: "center", justifyContent: "center",
padding: 0,
}}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="1" y="1" width="14" height="14" rx="2.5" stroke="currentColor" strokeWidth="1.4"/>
<line x1="5" y1="1.7" x2="5" y2="14.3" stroke="currentColor" strokeWidth="1.4"/>
</svg>
</button>
</div>
)}
{/* Navigatie */}
<nav style={{ flex: 1, padding: `16px ${collapsed ? 8 : 10}px`, overflowY: "auto" }}>
{TABS.map((t, i) => (
<SidebarItem
key={t} active={tab === i} onClick={() => setTab(i)}
icon={TAB_ICONS[i]} label={t} T={T} collapsed={collapsed}
/>
))}
<div style={{ height: 1, background: T.border, margin: "12px 2px" }} />
<SidebarItem
onClick={() => setShowUsers(true)}
icon={GEBRUIKERS_ICON}
label="Gebruikers"
T={T}
collapsed={collapsed}
/>
{/* Data */}
{collapsed ? (
<div style={{ position: "relative" }}>
<SidebarItem
onClick={() => setDataOpen((o) => !o)}
icon={DATA_ICON}
label="Data"
T={T}
collapsed={collapsed}
/>
{dataOpen && (
<div style={{
position: "absolute", left: "calc(100% + 10px)", top: 0,
background: T.card, border: `1px solid ${T.border}`,
borderRadius: 8, padding: "4px 0", minWidth: 160, zIndex: 200,
boxShadow: "0 4px 16px rgba(0,0,0,0.3)",
}}>
<div onClick={() => { doBackup(); setDataOpen(false); }} style={{ display: "flex", alignItems: "center", gap: 8, padding: "8px 14px", cursor: "pointer", color: T.muted, fontSize: 12 }}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M8 1v8M5 6l3 3 3-3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/><path d="M2 11v2a1 1 0 001 1h10a1 1 0 001-1v-2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>
Backup maken
</div>
<div onClick={() => { doRestore(); setDataOpen(false); }} style={{ display: "flex", alignItems: "center", gap: 8, padding: "8px 14px", cursor: "pointer", color: T.muted, fontSize: 12 }}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M2 8a6 6 0 106 -6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/><polyline points="2,4 2,8 6,8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
Backup herstellen
</div>
</div>
)}
</div>
) : (
<div>
<div onClick={() => setDataOpen((o) => !o)} style={{
display: "flex", alignItems: "center", gap: 10,
padding: "9px 12px", borderRadius: 8, cursor: "pointer", marginBottom: 6,
color: T.muted, fontSize: 13, fontWeight: 500,
}}>
{DATA_ICON}
<span style={{ flex: 1 }}>Data</span>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"
style={{ transform: dataOpen ? "rotate(180deg)" : "none", transition: "transform 0.2s", flexShrink: 0 }}>
<polyline points="2,4 6,8 10,4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
{dataOpen && (
<div style={{ paddingLeft: 14, marginBottom: 4 }}>
<div onClick={() => { doBackup(); setDataOpen(false); }} style={{ display: "flex", alignItems: "center", gap: 8, padding: "7px 12px", borderRadius: 8, cursor: "pointer", color: T.muted, fontSize: 12 }}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M8 1v8M5 6l3 3 3-3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/><path d="M2 11v2a1 1 0 001 1h10a1 1 0 001-1v-2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>
Backup maken
</div>
<div onClick={() => { doRestore(); setDataOpen(false); }} style={{ display: "flex", alignItems: "center", gap: 8, padding: "7px 12px", borderRadius: 8, cursor: "pointer", color: T.muted, fontSize: 12 }}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M2 8a6 6 0 106 -6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/><polyline points="2,4 2,8 6,8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
Backup herstellen
</div>
</div>
)}
</div>
)}
</nav>
{/* Onderste sectie */}
<div style={{ padding: `10px ${collapsed ? 8 : 10}px 24px`, borderTop: `1px solid ${T.border}` }}>
{/* Update melding */}
{nieuweVersie && !collapsed && (
<div style={{
margin: "8px 0", padding: "8px 12px", borderRadius: 8,
background: "rgba(249,115,22,0.1)", border: "1px solid rgba(249,115,22,0.25)",
}}>
<div style={{ fontSize: 11, color: "#f97316", fontWeight: 700, marginBottom: 2 }}>
Nieuwe versie: v{nieuweVersie}
</div>
<div style={{ fontSize: 11, color: T.muted }}>Huidig: v{version}</div>
</div>
)}
{nieuweVersie && collapsed && (
<div style={{ display: "flex", justifyContent: "center", marginBottom: 6 }}>
<div style={{ width: 8, height: 8, borderRadius: "50%", background: "#f97316" }} title={`v${nieuweVersie} beschikbaar`} />
</div>
)}
{/* Uitloggen */}
<SidebarItem
onClick={logout}
icon={<span style={{ color: T.muted, display: "flex" }}>{LOGOUT_ICON}</span>}
label="Log out"
T={T}
collapsed={collapsed}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,233 @@
import { useState } from "react";
import { useApp } from "../context/AppContext.jsx";
import { PURPLE, PURPLE_LIGHT, RED } from "../constants/index.js";
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();
}
export default function ProfielPopup({ onClose }) {
const { currentUser, updateProfile, changePassword, logout, T, avatar, setAvatar } = useApp();
const [activeTab, setActiveTab] = useState("profiel");
const [naam, setNaam] = useState(currentUser?.naam || "");
const [email, setEmail] = useState(currentUser?.email || "");
const [profielError, setProfielError] = useState("");
const [profielSuccess, setProfielSuccess] = useState(false);
const [profielLoading, setProfielLoading] = useState(false);
const [huidig, setHuidig] = useState("");
const [nieuw, setNieuw] = useState("");
const [bevestig, setBevestig] = useState("");
const [pwError, setPwError] = useState("");
const [pwSuccess, setPwSuccess] = useState(false);
const [pwLoading, setPwLoading] = useState(false);
const pIs = {
background: T.inputBg, border: `1px solid ${T.inputBorder}`,
borderRadius: 10, color: T.text, padding: "10px 12px",
fontSize: 13, outline: "none", width: "100%", boxSizing: "border-box",
};
const handleSaveProfiel = async () => {
setProfielError(""); setProfielSuccess(false);
if (!naam.trim() || !email.trim()) { setProfielError("Naam en e-mail zijn verplicht."); return; }
setProfielLoading(true);
try {
await updateProfile(naam.trim(), email.trim());
setProfielSuccess(true);
} catch (err) {
setProfielError(err.message || "Opslaan mislukt.");
} finally {
setProfielLoading(false);
}
};
const handleChangePassword = async () => {
setPwError(""); setPwSuccess(false);
if (!huidig || !nieuw || !bevestig) { setPwError("Vul alle velden in."); return; }
if (nieuw !== bevestig) { setPwError("Nieuwe wachtwoorden komen niet overeen."); return; }
if (nieuw.length < 8) { setPwError("Nieuw wachtwoord moet minimaal 8 tekens zijn."); return; }
setPwLoading(true);
try {
await changePassword(huidig, nieuw);
setPwSuccess(true);
setHuidig(""); setNieuw(""); setBevestig("");
} catch (err) {
setPwError(err.message || "Wachtwoord wijzigen mislukt.");
} finally {
setPwLoading(false);
}
};
return (
<>
{/* Backdrop */}
<div onClick={onClose} style={{ position: "fixed", inset: 0, zIndex: 198 }} />
{/* Popup */}
<div style={{
position: "fixed", top: 60, right: 16, zIndex: 199,
background: T.card, border: `1px solid ${T.border}`,
borderRadius: 14, width: 360,
boxShadow: "0 12px 40px rgba(0,0,0,0.4)",
overflow: "hidden",
}}>
{/* Header */}
<div style={{
display: "flex", alignItems: "center", gap: 12,
padding: "16px 18px", borderBottom: `1px solid ${T.border}`,
}}>
<div style={{ position: "relative", flexShrink: 0 }}>
<div style={{
width: 40, height: 40, borderRadius: "50%", overflow: "hidden",
background: avatar ? "transparent" : `linear-gradient(135deg, ${PURPLE}, #a855f7)`,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 14, fontWeight: 800, color: "#fff",
boxShadow: `0 0 10px ${PURPLE}66`,
}}>
{avatar
? <img src={avatar} alt="avatar" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
: initials(currentUser?.naam)
}
</div>
<label style={{
position: "absolute", bottom: -2, right: -2,
width: 16, height: 16, borderRadius: "50%",
background: PURPLE, cursor: "pointer",
display: "flex", alignItems: "center", justifyContent: "center",
border: `2px solid ${T.card}`,
}} title="Foto wijzigen">
<svg width="8" height="8" viewBox="0 0 10 10" fill="none">
<path d="M1 9l2.5-.5L9 3 7 1 1.5 6.5 1 9z" stroke="#fff" strokeWidth="1.2" strokeLinejoin="round"/>
</svg>
<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>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: T.text, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{currentUser?.naam}
</div>
<div style={{ fontSize: 12, color: T.muted, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{currentUser?.email}
</div>
</div>
<button onClick={onClose} style={{
background: "transparent", border: "none", color: T.muted,
fontSize: 18, cursor: "pointer", padding: 4, lineHeight: 1, flexShrink: 0,
}}>×</button>
</div>
{/* Tabs */}
<div style={{ display: "flex", borderBottom: `1px solid ${T.border}` }}>
{[
{ id: "profiel", label: "👤 Profiel" },
{ id: "wachtwoord", label: "🔑 Wachtwoord" },
].map(({ id, label }) => (
<button key={id} onClick={() => setActiveTab(id)} style={{
flex: 1, padding: "10px 0", background: "transparent", border: "none",
cursor: "pointer", fontSize: 13, fontWeight: 600,
color: activeTab === id ? PURPLE_LIGHT : T.muted,
borderBottom: activeTab === id ? `2px solid ${PURPLE}` : "2px solid transparent",
marginBottom: -1,
}}>{label}</button>
))}
</div>
{/* Profiel tab */}
{activeTab === "profiel" && (
<div style={{ padding: "18px 18px 20px", display: "flex", flexDirection: "column", gap: 12 }}>
{/* Avatar upload */}
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{
width: 56, height: 56, borderRadius: "50%", overflow: "hidden", flexShrink: 0,
background: avatar ? "transparent" : `linear-gradient(135deg, ${PURPLE}, #a855f7)`,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 18, fontWeight: 800, color: "#fff",
boxShadow: `0 0 10px ${PURPLE}44`,
}}>
{avatar
? <img src={avatar} alt="avatar" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
: initials(currentUser?.naam)
}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<label style={{
padding: "6px 12px", background: `${PURPLE}22`, border: `1px solid ${PURPLE}44`,
borderRadius: 8, color: PURPLE_LIGHT, fontSize: 12, fontWeight: 600, cursor: "pointer",
}}>
Foto uploaden
<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>
{avatar && (
<button onClick={() => setAvatar(null)} style={{
padding: "6px 12px", background: "transparent", border: `1px solid ${T.border}`,
borderRadius: 8, color: T.muted, fontSize: 12, fontWeight: 600, cursor: "pointer",
}}>Verwijderen</button>
)}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: T.muted, fontWeight: 700, display: "block", marginBottom: 6, textTransform: "uppercase" }}>Naam</label>
<input value={naam} onChange={(e) => { setNaam(e.target.value); setProfielSuccess(false); }} style={pIs} />
</div>
<div>
<label style={{ fontSize: 11, color: T.muted, fontWeight: 700, display: "block", marginBottom: 6, textTransform: "uppercase" }}>E-mail</label>
<input type="email" value={email} onChange={(e) => { setEmail(e.target.value); setProfielSuccess(false); }} style={pIs} />
</div>
{profielError && <div style={{ fontSize: 12, color: RED }}>{profielError}</div>}
{profielSuccess && <div style={{ fontSize: 12, color: "#10b981" }}>Profiel opgeslagen.</div>}
<button onClick={handleSaveProfiel} disabled={profielLoading} style={{
padding: "11px", background: `linear-gradient(135deg, ${PURPLE}, #a855f7)`,
border: "none", borderRadius: 10, color: "#fff", fontWeight: 700,
cursor: profielLoading ? "default" : "pointer", fontSize: 13, opacity: profielLoading ? 0.7 : 1,
}}>{profielLoading ? "Opslaan…" : "Opslaan"}</button>
<button onClick={() => { logout(); onClose(); }} style={{
padding: "11px", background: "rgba(180,30,30,0.85)",
border: "1px solid rgba(220,50,50,0.4)", borderRadius: 10,
color: "#fff", fontWeight: 700, cursor: "pointer", fontSize: 13,
}}>Uitloggen</button>
</div>
)}
{/* Wachtwoord tab */}
{activeTab === "wachtwoord" && (
<div style={{ padding: "18px 18px 20px", display: "flex", flexDirection: "column", gap: 12 }}>
{[
{ label: "Huidig wachtwoord", value: huidig, set: setHuidig },
{ label: "Nieuw wachtwoord", value: nieuw, set: setNieuw },
{ label: "Bevestig nieuw", value: bevestig, set: setBevestig },
].map(({ label, value, set }) => (
<div key={label}>
<label style={{ fontSize: 11, color: T.muted, fontWeight: 700, display: "block", marginBottom: 6, textTransform: "uppercase" }}>{label}</label>
<input type="password" value={value} onChange={(e) => { set(e.target.value); setPwSuccess(false); }} style={pIs} />
</div>
))}
{pwError && <div style={{ fontSize: 12, color: RED }}>{pwError}</div>}
{pwSuccess && <div style={{ fontSize: 12, color: "#10b981" }}>Wachtwoord gewijzigd.</div>}
<button onClick={handleChangePassword} disabled={pwLoading} style={{
padding: "11px", background: `linear-gradient(135deg, ${PURPLE}, #a855f7)`,
border: "none", borderRadius: 10, color: "#fff", fontWeight: 700,
cursor: pwLoading ? "default" : "pointer", fontSize: 13, opacity: pwLoading ? 0.7 : 1,
}}>{pwLoading ? "Opslaan…" : "Opslaan"}</button>
</div>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,458 @@
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", ...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 }) {
const r = size * 0.38, cx = size / 2, cy = size / 2;
const circ = 2 * Math.PI * r;
let off = 0;
const slices = segments.filter((s) => s.val > 0).map((s) => {
const dash = (s.val / total) * circ;
const sl = { ...s, dash, gap: circ - dash, offset: off };
off += dash;
return sl;
});
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<circle cx={cx} cy={cy} r={r} fill="none"
stroke="rgba(255,255,255,0.06)" strokeWidth={size * 0.18} />
{slices.map((s, i) => {
const mid = (s.offset + s.dash / 2) / r - Math.PI / 2;
const lx = cx + r * Math.cos(mid);
const ly = cy + r * Math.sin(mid);
return (
<g key={i}>
<circle cx={cx} cy={cy} r={r} fill="none"
stroke={s.color} strokeWidth={size * 0.18}
strokeDasharray={`${s.dash} ${s.gap}`}
strokeDashoffset={-s.offset + circ / 4} />
{s.dash > circ * 0.06 && (
<text x={lx} y={ly} fill="#fff" fontSize={size * 0.06}
fontWeight="600" textAnchor="middle" dominantBaseline="middle">
{(s.val / total * 100).toFixed(0)}%
</text>
)}
</g>
);
})}
</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>
);
}

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 },
]},
],
},
};

View File

@@ -0,0 +1,222 @@
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);
// ── 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,
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;
};

View File

@@ -0,0 +1,16 @@
/**
* 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 {
bg: darkMode ? "#0f1117" : "#f1f5f9",
card: darkMode ? "#1a1d27" : "#ffffff",
border: darkMode ? "rgba(255,255,255,0.07)" : "rgba(0,0,0,0.08)",
muted: darkMode ? "#6b7280" : "#94a3b8",
text: darkMode ? "#f1f5f9" : "#0f172a",
subtext: darkMode ? "#94a3b8" : "#64748b",
inputBg: darkMode ? "rgba(255,255,255,0.05)" : "#f8fafc",
inputBorder: darkMode ? "rgba(255,255,255,0.1)" : "#e2e8f0",
};
}

9
frontend/src/main.jsx Normal file
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>
);

View File

@@ -0,0 +1,209 @@
import { useApp } from "../context/AppContext.jsx";
import { fmt, pct, fmtDatum } from "../utils/format.js";
import { PURPLE, PURPLE_LIGHT, GREEN, RED } from "../constants/index.js";
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: PURPLE },
{ label: "Extra geïnvesteerde fiat", val: fmt(extraFiat), accent: PURPLE },
{ label: "Totaal geïnvesteerde fiat", val: fmt(totaalFiat), accent: PURPLE },
{ 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: 28, fontWeight: 800, color: T.text, marginBottom: 32 }}>
{dagdeel}{voornaam ? `, ${voornaam}` : ""} 👋
</div>
<h1 style={{ fontSize: 22, fontWeight: 800, margin: 0 }}>
The Road naar {fmt(doel)}
</h1>
<div style={{ color: T.muted, fontSize: 13, marginTop: 2 }}>
Persoonlijk vermogensdashboard
</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: T.card, border: `1px solid ${T.border}`,
borderLeft: `3px solid ${accent}44`,
}}>
<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={PURPLE} />
</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,${PURPLE},#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>
);
}
// ── Mini inline SVG grafiek ────────────────────────────────────────────────────
function MiniLineChart({ vData, darkMode }) {
const [hovered, setHovered] = useState(null);
const W = 700, H = 230, pl = 58, pr = 16, pt = 12, pb = 62;
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 line = xs.map((x, i) => `${x},${ys[i]}`).join(" ");
const area = `${xs[0]},${pt + iH} ${line} ${xs[xs.length - 1]},${pt + iH}`;
const gc = darkMode ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.06)";
const lc = darkMode ? "#6b7280" : "#94a3b8";
return (
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height: "auto", display: "block" }}>
<defs>
<linearGradient id="evDashFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={PURPLE} stopOpacity="0.35" />
<stop offset="100%" stopColor={PURPLE} stopOpacity="0.02" />
</linearGradient>
</defs>
{[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" />
<text x={pl - 5} y={gy + 4} fill={lc} fontSize="9" textAnchor="end">
{fmt(mn + f * rng)}
</text>
</g>
);
})}
<polygon points={area} fill="url(#evDashFill)" />
<polyline points={line} fill="none" stroke={PURPLE_LIGHT}
strokeWidth="2.5" strokeLinejoin="round" strokeLinecap="round" />
{xs.map((x, i) => {
const isHov = hovered === i;
const tipW = 90, tipH = 22;
const tipX = Math.min(Math.max(x - tipW / 2, pl), W - tipW - 4);
const tipY = ys[i] - tipH - 8 < pt ? ys[i] + 10 : ys[i] - tipH - 8;
return (
<g key={i}
onMouseEnter={() => setHovered(i)}
onMouseLeave={() => setHovered(null)}
style={{ cursor: "pointer" }}
>
{/* Vergroot klikgebied */}
<circle cx={x} cy={ys[i]} r="14" fill="transparent" />
<circle cx={x} cy={ys[i]} r={isHov ? 6 : 4}
fill={PURPLE_LIGHT}
stroke={darkMode ? "#1a1d27" : "#ffffff"}
strokeWidth="2" />
<text
x={x} y={H - pb + 16} fill={lc} fontSize="8"
textAnchor="end"
transform={`rotate(-45, ${x}, ${H - pb + 16})`}
>
{fmtDatum(vData[i].datum)}
</text>
{/* Tooltip */}
{isHov && (
<g>
<rect x={tipX} y={tipY} width={tipW} height={22}
rx="5" fill={darkMode ? "#1e2130" : "#fff"}
stroke={PURPLE} strokeWidth="1" />
<text x={tipX + tipW / 2} y={tipY + 15} fill={PURPLE_LIGHT} fontSize="8"
fontWeight="700" textAnchor="middle">
{fmt(vals[i])}
</text>
</g>
)}
</g>
);
})}
</svg>
);
}

View File

@@ -0,0 +1,656 @@
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}`,
borderTop: `3px solid ${GREEN}`, borderRadius: 12,
padding: "16px 18px", flex: 1, minWidth: 200,
display: "flex", flexDirection: "column", alignItems: "center",
}}>
<div style={{ fontWeight: 700, fontSize: 12, letterSpacing: "0.05em", color: T.text, marginBottom: 12, alignSelf: "flex-start" }}>
Donut verdeling
</div>
<DonutChart segments={catTotals.map((c) => ({ val: c.tot, color: c.color }))} total={totVerdeling} size={160} />
<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 });

View File

@@ -0,0 +1,455 @@
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}`,
borderTop: `3px solid ${RED}`, borderRadius: 12,
padding: "16px 18px", flex: 1, minWidth: 200,
display: "flex", flexDirection: "column", alignItems: "center",
}}>
<div style={{ fontWeight: 700, fontSize: 12, letterSpacing: "0.05em", color: T.text, marginBottom: 12, alignSelf: "flex-start" }}>
Verdeling Schuld
</div>
<DonutChart
segments={schuldCats.map((b) => ({ val: b.subtot, color: b.color }))}
total={donutTotal} size={160}
/>
<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 });

View File

@@ -0,0 +1,439 @@
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}`, borderTop: `3px solid ${PURPLE}`, display: "flex", flexDirection: "column", alignItems: "center" }}>
<div style={{ fontWeight: 700, fontSize: 12, letterSpacing: "0.05em", color: T.text, marginBottom: 12, alignSelf: "flex-start" }}>Verdeling Schuld & EV</div>
<DonutChart segments={[{ val: totEV, color: GREEN }, { val: totSchuld, color: RED }]} total={totEV + totSchuld || 1} size={160} />
<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>
);
}

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);
}

View File

@@ -0,0 +1,2 @@
export * from "./format.js";
export * from "./storage.js";

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;
}

View File

@@ -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

View File

@@ -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

41
frontend/vite.config.js Normal file
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
package.json Normal file
View File

@@ -0,0 +1,8 @@
{
"name": "vibefinance",
"version": "0.1.0",
"description": "VibeFinance — persoonlijk vermogensbeheer",
"private": true,
"repository": "https://10.0.3.108:3000/vibe/VibeFinance",
"updateCheckUrl": "https://10.0.3.108:3000/api/v1/repos/vibe/VibeFinance/releases/latest"
}

208
release.ps1 Normal file
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 = "10.0.3.108:3000/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
release.sh Normal file
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" \
"$@"