first commit
This commit is contained in:
23
.env.example
Normal file
23
.env.example
Normal 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
21
.gitignore
vendored
Normal 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
162
BEHEER.md
Normal 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
52
Changelog.md
Normal 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
191
README.md
Normal file
@@ -0,0 +1,191 @@
|
||||
<div align="center">
|
||||
|
||||
# 💜 VibeFinance
|
||||
|
||||
**Persoonlijk vermogensdashboard**
|
||||
|
||||
Eigen vermogen · Schulden · Voortgang · Gebruikersbeheer
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
</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
4
backend/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
.env*
|
||||
*.log
|
||||
.DS_Store
|
||||
41
backend/Dockerfile
Normal file
41
backend/Dockerfile
Normal 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
27
backend/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "vibefinance-backend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"dev": "node --watch src/server.js",
|
||||
"lint": "eslint src --ext .js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"express-rate-limit": "^7.4.0",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"pg": "^8.13.3",
|
||||
"pino": "^9.0.0",
|
||||
"pino-http": "^10.0.0",
|
||||
"pino-pretty": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.57.0"
|
||||
}
|
||||
}
|
||||
11
backend/src/config/index.js
Normal file
11
backend/src/config/index.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import "dotenv/config";
|
||||
|
||||
export const config = {
|
||||
NODE_ENV: process.env.NODE_ENV || "development",
|
||||
PORT: parseInt(process.env.PORT || "3001", 10),
|
||||
CORS_ORIGIN: process.env.CORS_ORIGIN || "http://localhost",
|
||||
JWT_SECRET: process.env.JWT_SECRET || "change-me-in-production",
|
||||
JWT_EXPIRES: process.env.JWT_EXPIRES || "7d",
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
REDIS_URL: process.env.REDIS_URL,
|
||||
};
|
||||
30
backend/src/constants/initialData.js
Normal file
30
backend/src/constants/initialData.js
Normal 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
33
backend/src/db.js
Normal 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
9
backend/src/logger.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import pino from "pino";
|
||||
import { config } from "./config/index.js";
|
||||
|
||||
export const logger = pino({
|
||||
level: config.NODE_ENV === "production" ? "info" : "debug",
|
||||
...(config.NODE_ENV !== "production" && {
|
||||
transport: { target: "pino-pretty", options: { colorize: true } },
|
||||
}),
|
||||
});
|
||||
22
backend/src/middleware/auth.js
Normal file
22
backend/src/middleware/auth.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
import { config } from "../config/index.js";
|
||||
|
||||
export function authenticate(req, res, next) {
|
||||
const header = req.headers.authorization;
|
||||
if (!header?.startsWith("Bearer ")) {
|
||||
return res.status(401).json({ error: "Niet ingelogd." });
|
||||
}
|
||||
try {
|
||||
req.user = jwt.verify(header.slice(7), config.JWT_SECRET);
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ error: "Sessie verlopen. Log opnieuw in." });
|
||||
}
|
||||
}
|
||||
|
||||
export function requireAdmin(req, res, next) {
|
||||
if (req.user?.rol !== "Admin") {
|
||||
return res.status(403).json({ error: "Geen toegang." });
|
||||
}
|
||||
next();
|
||||
}
|
||||
6
backend/src/middleware/errorHandler.js
Normal file
6
backend/src/middleware/errorHandler.js
Normal 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
135
backend/src/routes/auth.js
Normal file
@@ -0,0 +1,135 @@
|
||||
import { Router } from "express";
|
||||
import jwt from "jsonwebtoken";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { config } from "../config/index.js";
|
||||
import { authenticate } from "../middleware/auth.js";
|
||||
import { userStore } from "../stores/userStore.js";
|
||||
import { db } from "../db.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
/** POST /api/auth/register */
|
||||
router.post("/register", async (req, res, next) => {
|
||||
try {
|
||||
const { naam, email, wachtwoord } = req.body;
|
||||
if (!naam || !email || !wachtwoord)
|
||||
return res.status(400).json({ error: "Naam, e-mail en wachtwoord zijn verplicht." });
|
||||
if (wachtwoord.length < 8)
|
||||
return res.status(400).json({ error: "Wachtwoord moet minimaal 8 tekens zijn." });
|
||||
|
||||
const existing = await userStore.findByEmail(email.toLowerCase().trim());
|
||||
if (existing)
|
||||
return res.status(409).json({ error: "E-mailadres is al in gebruik." });
|
||||
|
||||
const { rows: countRows } = await db.query("SELECT COUNT(*) FROM users");
|
||||
const isFirst = parseInt(countRows[0].count) === 0;
|
||||
|
||||
const hash = await bcrypt.hash(wachtwoord, 12);
|
||||
const user = await userStore.create({
|
||||
naam: naam.trim(),
|
||||
email: email.toLowerCase().trim(),
|
||||
wachtwoord: hash,
|
||||
rol: isFirst ? "Admin" : "Viewer",
|
||||
actief: true,
|
||||
});
|
||||
|
||||
const token = jwt.sign(
|
||||
{ sub: user.id, naam: user.naam, email: user.email, rol: user.rol },
|
||||
config.JWT_SECRET,
|
||||
{ expiresIn: config.JWT_EXPIRES }
|
||||
);
|
||||
|
||||
res.status(201).json({ token, user: { id: user.id, naam: user.naam, email: user.email, rol: user.rol, avatar: null } });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
/** POST /api/auth/login */
|
||||
router.post("/login", async (req, res, next) => {
|
||||
try {
|
||||
const { email, wachtwoord } = req.body;
|
||||
if (!email || !wachtwoord)
|
||||
return res.status(400).json({ error: "E-mail en wachtwoord zijn verplicht." });
|
||||
|
||||
const user = await userStore.findByEmail(email.toLowerCase().trim());
|
||||
if (!user || !await bcrypt.compare(wachtwoord, user.wachtwoord))
|
||||
return res.status(401).json({ error: "Ongeldig e-mailadres of wachtwoord." });
|
||||
|
||||
if (!user.actief)
|
||||
return res.status(403).json({ error: "Account is inactief." });
|
||||
|
||||
const token = jwt.sign(
|
||||
{ sub: user.id, naam: user.naam, email: user.email, rol: user.rol },
|
||||
config.JWT_SECRET,
|
||||
{ expiresIn: config.JWT_EXPIRES }
|
||||
);
|
||||
|
||||
const fullUser = await userStore.findById(user.id);
|
||||
res.json({ token, user: { id: user.id, naam: user.naam, email: user.email, rol: user.rol, avatar: fullUser?.avatar ?? null } });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
/** GET /api/auth/me */
|
||||
router.get("/me", authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const user = await userStore.findById(req.user.sub);
|
||||
res.json({ user: { id: user.id, naam: user.naam, email: user.email, rol: user.rol, avatar: user?.avatar ?? null } });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
/** PATCH /api/auth/me — update naam / email */
|
||||
router.patch("/me", authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const { naam, email } = req.body;
|
||||
if (!naam?.trim() || !email?.trim())
|
||||
return res.status(400).json({ error: "Naam en e-mail zijn verplicht." });
|
||||
|
||||
const conflict = await userStore.findByEmail(email.toLowerCase().trim());
|
||||
if (conflict && conflict.id !== req.user.sub)
|
||||
return res.status(409).json({ error: "E-mailadres is al in gebruik." });
|
||||
|
||||
const updated = await userStore.update(req.user.sub, { naam: naam.trim(), email: email.toLowerCase().trim() });
|
||||
const token = jwt.sign(
|
||||
{ sub: updated.id, naam: updated.naam, email: updated.email, rol: updated.rol },
|
||||
config.JWT_SECRET,
|
||||
{ expiresIn: config.JWT_EXPIRES }
|
||||
);
|
||||
res.json({ token, user: { id: updated.id, naam: updated.naam, email: updated.email, rol: updated.rol, avatar: updated.avatar ?? null } });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
/** POST /api/auth/me/password — wijzig wachtwoord */
|
||||
router.post("/me/password", authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const { huidig, nieuw: nieuwWw } = req.body;
|
||||
if (!huidig || !nieuwWw)
|
||||
return res.status(400).json({ error: "Huidig en nieuw wachtwoord zijn verplicht." });
|
||||
if (nieuwWw.length < 8)
|
||||
return res.status(400).json({ error: "Nieuw wachtwoord moet minimaal 8 tekens zijn." });
|
||||
|
||||
const user = await userStore.findById(req.user.sub);
|
||||
if (!user || !await bcrypt.compare(huidig, user.wachtwoord))
|
||||
return res.status(401).json({ error: "Huidig wachtwoord is onjuist." });
|
||||
|
||||
const hash = await bcrypt.hash(nieuwWw, 12);
|
||||
await userStore.update(req.user.sub, { wachtwoord: hash });
|
||||
res.json({ message: "Wachtwoord gewijzigd." });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
/** PATCH /api/auth/me/avatar — sla avatar op als base64 */
|
||||
router.patch("/me/avatar", authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const { avatar } = req.body;
|
||||
// avatar mag null zijn (verwijderen) of een base64 data-URL
|
||||
if (avatar !== null && avatar !== undefined && typeof avatar !== "string")
|
||||
return res.status(400).json({ error: "Ongeldig avatar formaat." });
|
||||
|
||||
await userStore.update(req.user.sub, { avatar: avatar ?? null });
|
||||
res.json({ avatar: avatar ?? null });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
/** POST /api/auth/logout */
|
||||
router.post("/logout", authenticate, (_req, res) => res.json({ message: "Uitgelogd." }));
|
||||
|
||||
export default router;
|
||||
12
backend/src/routes/data.js
Normal file
12
backend/src/routes/data.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Router } from "express";
|
||||
import { authenticate } from "../middleware/auth.js";
|
||||
import { dataStore } from "../stores/dataStore.js";
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate);
|
||||
|
||||
router.get("/", async (req, res, next) => { try { res.json(await dataStore.load(req.user.sub)); } catch (e) { next(e); } });
|
||||
router.put("/", async (req, res, next) => { try { res.json(await dataStore.save(req.user.sub, req.body)); } catch (e) { next(e); } });
|
||||
router.delete("/", async (req, res, next) => { try { await dataStore.reset(req.user.sub); res.json({ message: "Reset." }); } catch (e) { next(e); } });
|
||||
|
||||
export default router;
|
||||
7
backend/src/routes/health.js
Normal file
7
backend/src/routes/health.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Router } from "express";
|
||||
const router = Router();
|
||||
const START = Date.now();
|
||||
router.get("/", (_req, res) => {
|
||||
res.json({ status: "ok", uptime: Math.floor((Date.now() - START) / 1000) });
|
||||
});
|
||||
export default router;
|
||||
42
backend/src/routes/users.js
Normal file
42
backend/src/routes/users.js
Normal 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
61
backend/src/server.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import helmet from "helmet";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import cookieParser from "cookie-parser";
|
||||
import pino from "pino";
|
||||
import pinoHttp from "pino-http";
|
||||
import { config } from "./config/index.js";
|
||||
import authRouter from "./routes/auth.js";
|
||||
import dataRouter from "./routes/data.js";
|
||||
import usersRouter from "./routes/users.js";
|
||||
import healthRouter from "./routes/health.js";
|
||||
import { errorHandler } from "./middleware/errorHandler.js";
|
||||
|
||||
export const logger = pino({
|
||||
level: config.NODE_ENV === "production" ? "info" : "debug",
|
||||
...(config.NODE_ENV !== "production" && {
|
||||
transport: { target: "pino-pretty", options: { colorize: true } },
|
||||
}),
|
||||
});
|
||||
|
||||
const app = express();
|
||||
|
||||
// ── Security ──────────────────────────────────────────────────────────────────
|
||||
app.use(helmet());
|
||||
app.use(cors({ origin: config.CORS_ORIGIN, credentials: true }));
|
||||
app.set("trust proxy", 1);
|
||||
|
||||
// ── Rate limiting ─────────────────────────────────────────────────────────────
|
||||
app.use("/api/auth", rateLimit({ windowMs: 15 * 60 * 1000, max: 20, standardHeaders: true, legacyHeaders: false }));
|
||||
app.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 200, standardHeaders: true, legacyHeaders: false }));
|
||||
|
||||
// ── Parsing ───────────────────────────────────────────────────────────────────
|
||||
app.use(express.json({ limit: "2mb" }));
|
||||
app.use(cookieParser());
|
||||
|
||||
// ── Logging ───────────────────────────────────────────────────────────────────
|
||||
if (config.NODE_ENV !== "test") {
|
||||
app.use(pinoHttp({ logger }));
|
||||
}
|
||||
|
||||
// ── Routes ────────────────────────────────────────────────────────────────────
|
||||
app.use("/api/health", healthRouter);
|
||||
app.use("/api/auth", authRouter);
|
||||
app.use("/api/data", dataRouter);
|
||||
app.use("/api/users", usersRouter);
|
||||
|
||||
// ── 404 voor overige /api/* ───────────────────────────────────────────────────
|
||||
app.use("/api/*", (_req, res) => res.status(404).json({ error: "Endpoint niet gevonden." }));
|
||||
|
||||
// ── Error handler ─────────────────────────────────────────────────────────────
|
||||
app.use(errorHandler);
|
||||
|
||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||
if (config.NODE_ENV !== "test") {
|
||||
app.listen(config.PORT, () => {
|
||||
logger.info(`VibeFinance API gestart op http://localhost:${config.PORT} [${config.NODE_ENV}]`);
|
||||
});
|
||||
}
|
||||
|
||||
export default app;
|
||||
22
backend/src/stores/dataStore.js
Normal file
22
backend/src/stores/dataStore.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { db } from "../db.js";
|
||||
import { INITIAL_DATA } from "../constants/initialData.js";
|
||||
|
||||
export const dataStore = {
|
||||
async load(userId) {
|
||||
const { rows } = await db.query("SELECT data FROM user_data WHERE user_id = $1", [userId]);
|
||||
return rows[0] ? JSON.parse(rows[0].data) : structuredClone(INITIAL_DATA);
|
||||
},
|
||||
async save(userId, payload) {
|
||||
await db.query(
|
||||
"INSERT INTO user_data (user_id, data) VALUES ($1, $2) ON CONFLICT (user_id) DO UPDATE SET data = $2",
|
||||
[userId, JSON.stringify(payload)]
|
||||
);
|
||||
return payload;
|
||||
},
|
||||
async reset(userId) {
|
||||
await db.query(
|
||||
"INSERT INTO user_data (user_id, data) VALUES ($1, $2) ON CONFLICT (user_id) DO UPDATE SET data = $2",
|
||||
[userId, JSON.stringify(structuredClone(INITIAL_DATA))]
|
||||
);
|
||||
},
|
||||
};
|
||||
43
backend/src/stores/userStore.js
Normal file
43
backend/src/stores/userStore.js
Normal 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
176
dev.ps1
Normal 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
39
dev.sh
Normal 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
88
docker-compose.dev.yml
Normal 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
66
docker-compose.yml
Normal 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
5
frontend/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.env*
|
||||
*.log
|
||||
.DS_Store
|
||||
42
frontend/Dockerfile
Normal file
42
frontend/Dockerfile
Normal 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
17
frontend/index.html
Normal 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
22
frontend/nginx-spa.conf
Normal 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
4934
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
frontend/package.json
Normal file
24
frontend/package.json
Normal 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
21
frontend/src/App.css
Normal 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
186
frontend/src/App.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
355
frontend/src/components/GebruikersBeheer.jsx
Normal file
355
frontend/src/components/GebruikersBeheer.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
297
frontend/src/components/LoginPage.jsx
Normal file
297
frontend/src/components/LoginPage.jsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
290
frontend/src/components/NavBar.jsx
Normal file
290
frontend/src/components/NavBar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
233
frontend/src/components/ProfielPopup.jsx
Normal file
233
frontend/src/components/ProfielPopup.jsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
458
frontend/src/components/ui/index.jsx
Normal file
458
frontend/src/components/ui/index.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
frontend/src/constants/index.js
Normal file
76
frontend/src/constants/index.js
Normal 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 },
|
||||
]},
|
||||
],
|
||||
},
|
||||
};
|
||||
222
frontend/src/context/AppContext.jsx
Normal file
222
frontend/src/context/AppContext.jsx
Normal 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;
|
||||
};
|
||||
16
frontend/src/hooks/useTheme.js
Normal file
16
frontend/src/hooks/useTheme.js
Normal 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
9
frontend/src/main.jsx
Normal 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>
|
||||
);
|
||||
209
frontend/src/pages/DashboardTab.jsx
Normal file
209
frontend/src/pages/DashboardTab.jsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
656
frontend/src/pages/EigenVermogenTab.jsx
Normal file
656
frontend/src/pages/EigenVermogenTab.jsx
Normal 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 });
|
||||
455
frontend/src/pages/SchuldTab.jsx
Normal file
455
frontend/src/pages/SchuldTab.jsx
Normal 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 });
|
||||
439
frontend/src/pages/VoortgangTab.jsx
Normal file
439
frontend/src/pages/VoortgangTab.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
frontend/src/utils/format.js
Normal file
33
frontend/src/utils/format.js
Normal file
@@ -0,0 +1,33 @@
|
||||
/** Euro formatter (nl-NL, geen decimalen) */
|
||||
export const fmt = (n) =>
|
||||
new Intl.NumberFormat("nl-NL", {
|
||||
style: "currency", currency: "EUR", maximumFractionDigits: 0,
|
||||
}).format(n || 0);
|
||||
|
||||
/** Percentage formatter */
|
||||
export const pct = (n, d = 1) => `${((n || 0) * 100).toFixed(d)}%`;
|
||||
|
||||
/** ISO datum (YYYY-MM-DD) → Nederlandse notatie (DD-MM-YYYY) */
|
||||
export const fmtDatum = (s) => {
|
||||
if (!s) return s;
|
||||
const [y, m, d] = s.split("-");
|
||||
return `${d}-${m}-${y}`;
|
||||
};
|
||||
|
||||
/** Crypto-bedrag formatter (4 decimalen standaard) */
|
||||
export const fmtCrypto = (n, d = 4) => (parseFloat(n) || 0).toFixed(d);
|
||||
|
||||
/**
|
||||
* Bedrag in USD of EUR opmaken afhankelijk van geselecteerde valuta.
|
||||
* @param {number} amount
|
||||
* @param {"USD"|"EUR"} currency
|
||||
* @param {number} usdEurRate
|
||||
*/
|
||||
export function fmtMoney(amount, currency = "EUR", usdEurRate = 0.92) {
|
||||
const v = currency === "EUR" ? amount * usdEurRate : amount;
|
||||
return new Intl.NumberFormat("nl-NL", {
|
||||
style: "currency",
|
||||
currency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(v || 0);
|
||||
}
|
||||
2
frontend/src/utils/index.js
Normal file
2
frontend/src/utils/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./format.js";
|
||||
export * from "./storage.js";
|
||||
75
frontend/src/utils/storage.js
Normal file
75
frontend/src/utils/storage.js
Normal 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;
|
||||
}
|
||||
26
frontend/vibefinance-logo-wordmark.svg
Normal file
26
frontend/vibefinance-logo-wordmark.svg
Normal 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 |
15
frontend/vibefinance-logo.svg
Normal file
15
frontend/vibefinance-logo.svg
Normal 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
41
frontend/vite.config.js
Normal 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
8
package.json
Normal 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
208
release.ps1
Normal 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
35
release.sh
Normal 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" \
|
||||
"$@"
|
||||
Reference in New Issue
Block a user