Compare commits
2 Commits
4e064d86d2
...
74a0e951a9
| Author | SHA1 | Date | |
|---|---|---|---|
| 74a0e951a9 | |||
| 720403e6c2 |
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
<title>VibeFinance</title>
|
<title>VibeFinance</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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" />
|
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "vibefinance-frontend",
|
"name": "vibefinance-frontend",
|
||||||
"version": "0.1.4",
|
"version": "0.1.5-dev",
|
||||||
"updateCheckUrl": "https://vibehoogie.duckdns.org/api/v1/repos/vibe/VibeFinance/releases/latest",
|
"updateCheckUrl": "https://vibehoogie.duckdns.org/api/v1/repos/vibe/VibeFinance/releases/latest",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#0eddd4"/>
|
||||||
|
<stop offset="100%" stop-color="#0891b2"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="32" height="32" rx="8" fill="url(#bg)"/>
|
||||||
|
<polyline
|
||||||
|
points="4,24 10,15 16,19 22,9 28,13"
|
||||||
|
fill="none" stroke="#ffffff" stroke-width="3"
|
||||||
|
stroke-linejoin="round" stroke-linecap="round"/>
|
||||||
|
<line x1="4" y1="27" x2="28" y2="27"
|
||||||
|
stroke="rgba(255,255,255,0.35)" stroke-width="1.5"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 578 B |
|
Before Width: | Height: | Size: 980 B After Width: | Height: | Size: 980 B |
|
Before Width: | Height: | Size: 569 B After Width: | Height: | Size: 569 B |
+150
-31
@@ -8,8 +8,11 @@ import DashboardTab from "./pages/DashboardTab.jsx";
|
|||||||
import EigenVermogenTab from "./pages/EigenVermogenTab.jsx";
|
import EigenVermogenTab from "./pages/EigenVermogenTab.jsx";
|
||||||
import SchuldTab from "./pages/SchuldTab.jsx";
|
import SchuldTab from "./pages/SchuldTab.jsx";
|
||||||
import VoortgangTab from "./pages/VoortgangTab.jsx";
|
import VoortgangTab from "./pages/VoortgangTab.jsx";
|
||||||
import { RED, PURPLE, PURPLE_LIGHT } from "./constants/index.js";
|
import { RED, PURPLE_LIGHT } from "./constants/index.js";
|
||||||
|
import { fmt } from "./utils/format.js";
|
||||||
|
import { version, updateCheckUrl } from "../package.json";
|
||||||
import ProfielPopup from "./components/ProfielPopup.jsx";
|
import ProfielPopup from "./components/ProfielPopup.jsx";
|
||||||
|
import InstellingenModal from "./components/InstellingenModal.jsx";
|
||||||
|
|
||||||
function initials(naam = "") {
|
function initials(naam = "") {
|
||||||
const parts = naam.trim().split(/\s+/);
|
const parts = naam.trim().split(/\s+/);
|
||||||
@@ -20,11 +23,26 @@ function initials(naam = "") {
|
|||||||
const PAGES = [DashboardTab, EigenVermogenTab, SchuldTab, VoortgangTab];
|
const PAGES = [DashboardTab, EigenVermogenTab, SchuldTab, VoortgangTab];
|
||||||
|
|
||||||
function AppInner() {
|
function AppInner() {
|
||||||
const { loggedIn, login, logout, locked, tab, showUsers, setShowUsers, loading, T, darkMode, setDarkMode, currentUser, avatar } = useApp();
|
const { loggedIn, login, logout, locked, tab, showUsers, setShowUsers, showInstellingen, setShowInstellingen, loading, T, currentUser, avatar, idleMinutes, totEV } = useApp();
|
||||||
const [profielOpen, setProfielOpen] = useState(false);
|
const [profielOpen, setProfielOpen] = useState(false);
|
||||||
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
const [nieuweVersie, setNieuweVersie] = useState(null);
|
||||||
|
const [bellOpen, setBellOpen] = useState(false);
|
||||||
|
const searchRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!updateCheckUrl) return;
|
||||||
|
fetch(updateCheckUrl)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
const latest = data?.tag_name?.replace(/^v/, "");
|
||||||
|
if (latest && latest !== version) setNieuweVersie(latest);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const idleTimer = useRef(null);
|
const idleTimer = useRef(null);
|
||||||
const IDLE_MS = 30 * 60 * 1000;
|
const IDLE_MS = (idleMinutes ?? 30) * 60 * 1000;
|
||||||
const [sessionVerlopen, setSessionVerlopen] = useState(false);
|
const [sessionVerlopen, setSessionVerlopen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -45,6 +63,19 @@ function AppInner() {
|
|||||||
};
|
};
|
||||||
}, [loggedIn]);
|
}, [loggedIn]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSearchOpen(true);
|
||||||
|
setTimeout(() => searchRef.current?.focus(), 50);
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") setSearchOpen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!loggedIn) {
|
if (!loggedIn) {
|
||||||
if (sessionVerlopen) {
|
if (sessionVerlopen) {
|
||||||
return (
|
return (
|
||||||
@@ -102,50 +133,137 @@ function AppInner() {
|
|||||||
const ActivePage = PAGES[tab] ?? DashboardTab;
|
const ActivePage = PAGES[tab] ?? DashboardTab;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", minHeight: "100vh", background: T.bg, color: T.text, fontFamily: "'Inter', system-ui, sans-serif" }}>
|
<div style={{ display: "flex", minHeight: "100vh", background: T.bg, color: T.text, fontFamily: T.font }}>
|
||||||
<style>{`
|
<style>{`
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
input[type=number]::-webkit-inner-spin-button { opacity: 0.4; }
|
input[type=number]::-webkit-inner-spin-button { opacity: 0.4; }
|
||||||
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: rgba(128,128,128,0.2); border-radius: 99px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: rgba(128,128,128,0.35); }
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
<NavBar />
|
<NavBar />
|
||||||
|
|
||||||
{/* Hoofdinhoud */}
|
{/* Hoofdinhoud */}
|
||||||
<div style={{ flex: 1, overflowY: "auto", maxHeight: "100vh" }}>
|
<div style={{ flex: 1, overflowY: "auto", maxHeight: "100vh", background: T.contentBg ?? T.bg }}>
|
||||||
{/* Topbalk rechts */}
|
{/* Topbalk */}
|
||||||
<div style={{ display: "flex", justifyContent: "flex-end", alignItems: "center", gap: 8, padding: "12px 20px 0" }}>
|
<div style={{
|
||||||
|
display: "flex", alignItems: "center", gap: 12,
|
||||||
|
padding: "10px 24px",
|
||||||
|
background: T.bg,
|
||||||
|
position: "sticky", top: 0, zIndex: 50,
|
||||||
|
}}>
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
|
{/* Notificatiebel */}
|
||||||
<div style={{ position: "relative" }}>
|
<div style={{ position: "relative" }}>
|
||||||
<button onClick={() => setProfielOpen((o) => !o)} style={{
|
<button
|
||||||
display: "flex", alignItems: "center", gap: 6,
|
onClick={() => nieuweVersie && setBellOpen(o => !o)}
|
||||||
background: "transparent", border: "none", cursor: "pointer", padding: 0,
|
style={{
|
||||||
}}>
|
width: 38, height: 38, borderRadius: "50%",
|
||||||
<div style={{
|
background: T.inputBg, border: `1px solid ${T.border}`,
|
||||||
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",
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
fontSize: 13, fontWeight: 800, color: profielOpen ? "#fff" : PURPLE_LIGHT,
|
cursor: nieuweVersie ? "pointer" : "default", color: T.subtext, flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<svg width="17" height="17" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path d="M8 1.5A4.5 4.5 0 003.5 6v2.5L2 10h12l-1.5-1.5V6A4.5 4.5 0 008 1.5z" stroke="currentColor" strokeWidth="1.4"/>
|
||||||
|
<path d="M6.5 12.5a1.5 1.5 0 003 0" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Badge */}
|
||||||
|
{nieuweVersie && (
|
||||||
|
<div style={{
|
||||||
|
position: "absolute", top: 1, right: 1,
|
||||||
|
width: 13, height: 13, borderRadius: "50%",
|
||||||
|
background: "#f97316", border: `2px solid ${T.sidebar ?? T.card}`,
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
fontSize: 7, fontWeight: 700, color: "#fff",
|
||||||
|
}}>1</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Popup */}
|
||||||
|
{bellOpen && nieuweVersie && (
|
||||||
|
<>
|
||||||
|
<div onClick={() => setBellOpen(false)} style={{ position: "fixed", inset: 0, zIndex: 190 }} />
|
||||||
|
<div style={{
|
||||||
|
position: "absolute", right: 0, top: "calc(100% + 10px)",
|
||||||
|
zIndex: 200, width: 280,
|
||||||
|
background: T.card, border: `1px solid ${T.border}`,
|
||||||
|
borderRadius: 14, padding: "16px 18px",
|
||||||
|
boxShadow: T.shadow,
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: T.text, marginBottom: 10 }}>
|
||||||
|
Meldingen
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
display: "flex", gap: 12, alignItems: "flex-start",
|
||||||
|
padding: "10px 12px", borderRadius: 10,
|
||||||
|
background: "rgba(249,115,22,0.08)", border: "1px solid rgba(249,115,22,0.2)",
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 32, height: 32, borderRadius: "50%", flexShrink: 0,
|
||||||
|
background: "rgba(249,115,22,0.15)",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
}}>
|
||||||
|
<svg width="15" height="15" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path d="M8 1.5A4.5 4.5 0 003.5 6v2.5L2 10h12l-1.5-1.5V6A4.5 4.5 0 008 1.5z" stroke="#f97316" strokeWidth="1.4"/>
|
||||||
|
<path d="M6.5 12.5a1.5 1.5 0 003 0" stroke="#f97316" strokeWidth="1.4" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: T.text, marginBottom: 2 }}>
|
||||||
|
Nieuwe versie beschikbaar
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: T.muted, lineHeight: 1.5 }}>
|
||||||
|
v{nieuweVersie} is uitgebracht. Je gebruikt momenteel v{version}.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{/* Gebruikersprofiel */}
|
||||||
|
<div style={{ position: "relative", flexShrink: 0 }}>
|
||||||
|
<button onClick={() => setProfielOpen((o) => !o)} style={{
|
||||||
|
display: "flex", alignItems: "center", gap: 10,
|
||||||
|
background: "transparent", border: "none",
|
||||||
|
borderRadius: 12, cursor: "pointer", padding: "4px 8px 4px 4px",
|
||||||
|
transition: "background 0.15s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = T.inputBg}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = "transparent"}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
width: 36, height: 36, borderRadius: "50%", overflow: "hidden", flexShrink: 0,
|
||||||
|
background: avatar ? "transparent" : "linear-gradient(135deg, #7c3aed, #a855f7)",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
fontSize: 12, fontWeight: 800, color: "#fff",
|
||||||
|
border: `2px solid ${T.border}`,
|
||||||
}}>
|
}}>
|
||||||
{avatar
|
{avatar
|
||||||
? <img src={avatar} alt="avatar" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
? <img src={avatar} alt="avatar" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
||||||
: initials(currentUser?.naam || "")
|
: initials(currentUser?.naam || "")
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none"
|
<div style={{ textAlign: "left" }}>
|
||||||
style={{ color: T.muted, transition: "transform 0.2s", transform: profielOpen ? "rotate(180deg)" : "none", flexShrink: 0 }}>
|
<div style={{ fontSize: 13, fontWeight: 600, color: T.text, whiteSpace: "nowrap", lineHeight: 1.3 }}>
|
||||||
<polyline points="1,3 5,7 9,3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
{currentUser?.naam || "Gebruiker"}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: T.muted, whiteSpace: "nowrap", lineHeight: 1.3 }}>
|
||||||
|
{currentUser?.email || ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 10 10" fill="none" style={{ flexShrink: 0, color: T.muted }}>
|
||||||
|
<polyline points="1,3 5,7 9,3" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{profielOpen && <ProfielPopup onClose={() => setProfielOpen(false)} />}
|
{profielOpen && <ProfielPopup onClose={() => setProfielOpen(false)} />}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Vergrendeld banner */}
|
{/* Vergrendeld banner */}
|
||||||
@@ -166,11 +284,12 @@ function AppInner() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Gebruikersbeheer modal */}
|
{/* Modals */}
|
||||||
{showUsers && <GebruikersBeheer onClose={() => setShowUsers(false)} />}
|
{showUsers && <GebruikersBeheer onClose={() => setShowUsers(false)} />}
|
||||||
|
{showInstellingen && <InstellingenModal onClose={() => setShowInstellingen(false)} />}
|
||||||
|
|
||||||
{/* Actieve pagina */}
|
{/* Actieve pagina */}
|
||||||
<div style={{ maxWidth: 1200, margin: "0 auto", padding: "24px 16px" }}>
|
<div style={{ maxWidth: 1200, margin: "0 auto", padding: "28px 24px" }}>
|
||||||
<ActivePage />
|
<ActivePage />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { PURPLE, PURPLE_LIGHT, RED, GREEN } from "../constants/index.js";
|
import { RED, GREEN } from "../constants/index.js";
|
||||||
import { Overlay, ModalBox, ModalHeader } from "./ui/index.jsx";
|
|
||||||
|
const TEAL = "linear-gradient(135deg, #7c3aed, #a855f7)";
|
||||||
|
const TEAL_COLOR = "#a855f7";
|
||||||
|
const TEAL_BG = "rgba(124,58,237,0.15)";
|
||||||
|
const TEAL_BORDER = "rgba(124,58,237,0.35)";
|
||||||
import { useApp } from "../context/AppContext.jsx";
|
import { useApp } from "../context/AppContext.jsx";
|
||||||
|
|
||||||
const getToken = () => localStorage.getItem("vf_token");
|
const getToken = () => localStorage.getItem("vf_token");
|
||||||
@@ -52,11 +56,11 @@ function RolSelect({ value, onChange, opties, T, darkMode }) {
|
|||||||
{opties.map((o) => (
|
{opties.map((o) => (
|
||||||
<div key={o} onClick={() => { onChange(o); setOpen(false); }} style={{
|
<div key={o} onClick={() => { onChange(o); setOpen(false); }} style={{
|
||||||
padding: "9px 12px", fontSize: 13, cursor: "pointer",
|
padding: "9px 12px", fontSize: 13, cursor: "pointer",
|
||||||
color: o === value ? PURPLE_LIGHT : T.text,
|
color: o === value ? TEAL_COLOR : T.text,
|
||||||
background: o === value ? `${PURPLE}22` : "transparent",
|
background: o === value ? TEAL_BG : "transparent",
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => e.currentTarget.style.background = `${PURPLE}18`}
|
onMouseEnter={(e) => e.currentTarget.style.background = "rgba(14,221,212,0.10)"}
|
||||||
onMouseLeave={(e) => e.currentTarget.style.background = o === value ? `${PURPLE}22` : "transparent"}
|
onMouseLeave={(e) => e.currentTarget.style.background = o === value ? TEAL_BG : "transparent"}
|
||||||
>
|
>
|
||||||
{o}
|
{o}
|
||||||
</div>
|
</div>
|
||||||
@@ -168,19 +172,44 @@ export default function GebruikersBeheer({ onClose }) {
|
|||||||
const td = { padding: "10px 12px", fontSize: 13, color: T.text, borderBottom: `1px solid ${T.border}` };
|
const td = { padding: "10px 12px", fontSize: 13, color: T.text, borderBottom: `1px solid ${T.border}` };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Overlay onClose={onClose}>
|
<div
|
||||||
<ModalBox maxWidth={700}>
|
onClick={onClose}
|
||||||
<ModalHeader
|
style={{
|
||||||
icon="👥" iconBg={PURPLE}
|
position: "fixed", inset: 0, zIndex: 400,
|
||||||
title="Gebruikersbeheer"
|
background: "rgba(0,0,0,0.55)", backdropFilter: "blur(4px)",
|
||||||
subtitle={`${users.length} gebruiker${users.length !== 1 ? "s" : ""}`}
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
subtitleColor={PURPLE_LIGHT}
|
}}
|
||||||
onClose={onClose}
|
>
|
||||||
headerBg={darkMode ? `${PURPLE}08` : "#f8f5ff"}
|
<div
|
||||||
T={T}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
style={{
|
||||||
|
background: T.card, border: `1px solid ${T.border}`,
|
||||||
|
borderRadius: 16, width: "100%", maxWidth: 700,
|
||||||
|
maxHeight: "90vh", display: "flex", flexDirection: "column",
|
||||||
|
boxShadow: "0 16px 48px rgba(0,0,0,0.4)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||||
|
padding: "18px 22px", borderBottom: `1px solid ${T.border}`, flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<span style={{ fontWeight: 700, fontSize: 15, color: T.text }}>Gebruikersbeheer</span>
|
||||||
|
<span style={{ marginLeft: 10, fontSize: 11, fontWeight: 600, color: TEAL_COLOR,
|
||||||
|
background: TEAL_BG, borderRadius: 99, padding: "2px 8px", border: `1px solid ${TEAL_BORDER}`,
|
||||||
|
}}>
|
||||||
|
{users.length} gebruiker{users.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} style={{
|
||||||
|
background: "transparent", border: "none", cursor: "pointer",
|
||||||
|
color: T.muted, fontSize: 20, lineHeight: 1, padding: 4,
|
||||||
|
}}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ padding: "16px 22px" }}>
|
<div style={{ padding: "16px 22px", overflowY: "auto", flex: 1 }}>
|
||||||
{loadErr && (
|
{loadErr && (
|
||||||
<div style={{ color: RED, fontSize: 13, marginBottom: 12 }}>{loadErr}</div>
|
<div style={{ color: RED, fontSize: 13, marginBottom: 12 }}>{loadErr}</div>
|
||||||
)}
|
)}
|
||||||
@@ -207,18 +236,18 @@ export default function GebruikersBeheer({ onClose }) {
|
|||||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 32, height: 32, borderRadius: "50%",
|
width: 32, height: 32, borderRadius: "50%",
|
||||||
background: `${PURPLE}33`, display: "flex",
|
background: TEAL_BG, display: "flex",
|
||||||
alignItems: "center", justifyContent: "center",
|
alignItems: "center", justifyContent: "center",
|
||||||
fontSize: 12, fontWeight: 700, color: PURPLE_LIGHT, flexShrink: 0,
|
fontSize: 12, fontWeight: 700, color: TEAL_COLOR, flexShrink: 0,
|
||||||
}}>
|
}}>
|
||||||
{initials}
|
{initials}
|
||||||
</div>
|
</div>
|
||||||
<span style={{ fontWeight: 600 }}>{u.naam}</span>
|
<span style={{ fontWeight: 600 }}>{u.naam}</span>
|
||||||
{isSelf && (
|
{isSelf && (
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: 10, fontWeight: 700, color: PURPLE_LIGHT,
|
fontSize: 10, fontWeight: 700, color: TEAL_COLOR,
|
||||||
background: `${PURPLE}22`, borderRadius: 99,
|
background: TEAL_BG, borderRadius: 99,
|
||||||
padding: "1px 7px", border: `1px solid ${PURPLE}44`,
|
padding: "1px 7px", border: `1px solid ${TEAL_BORDER}`,
|
||||||
}}>jij</span>
|
}}>jij</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -234,9 +263,9 @@ export default function GebruikersBeheer({ onClose }) {
|
|||||||
<td style={{ ...td, whiteSpace: "nowrap" }}>
|
<td style={{ ...td, whiteSpace: "nowrap" }}>
|
||||||
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
||||||
<button onClick={() => openEdit(u)} style={{
|
<button onClick={() => openEdit(u)} style={{
|
||||||
padding: "3px 8px", background: `${PURPLE}22`,
|
padding: "3px 8px", background: TEAL_BG,
|
||||||
border: `1px solid ${PURPLE}44`, borderRadius: 6,
|
border: `1px solid ${TEAL_BORDER}`, borderRadius: 6,
|
||||||
color: PURPLE_LIGHT, cursor: "pointer", fontSize: 12,
|
color: TEAL_COLOR, cursor: "pointer", fontSize: 12,
|
||||||
}}>✏️</button>
|
}}>✏️</button>
|
||||||
{!isSelf && (
|
{!isSelf && (
|
||||||
<>
|
<>
|
||||||
@@ -274,10 +303,10 @@ export default function GebruikersBeheer({ onClose }) {
|
|||||||
{/* Bewerken modal */}
|
{/* Bewerken modal */}
|
||||||
{editUser && (
|
{editUser && (
|
||||||
<div style={{
|
<div style={{
|
||||||
background: darkMode ? `${PURPLE}06` : "#faf7ff",
|
background: darkMode ? "rgba(124,58,237,0.06)" : "#f5f0ff",
|
||||||
border: `1px solid ${PURPLE}44`, borderRadius: 10, padding: "16px", marginBottom: 16,
|
border: `1px solid ${TEAL_BORDER}`, borderRadius: 10, padding: "16px", marginBottom: 16,
|
||||||
}}>
|
}}>
|
||||||
<div style={{ fontSize: 11, color: PURPLE_LIGHT, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 12 }}>
|
<div style={{ fontSize: 11, color: TEAL_COLOR, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 12 }}>
|
||||||
{editUser.naam} bewerken
|
{editUser.naam} bewerken
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 10 }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 10 }}>
|
||||||
@@ -293,7 +322,7 @@ export default function GebruikersBeheer({ onClose }) {
|
|||||||
}}>Annuleren</button>
|
}}>Annuleren</button>
|
||||||
<button onClick={saveEdit} disabled={saving} style={{
|
<button onClick={saveEdit} disabled={saving} style={{
|
||||||
flex: 2, padding: "8px",
|
flex: 2, padding: "8px",
|
||||||
background: `linear-gradient(135deg, ${PURPLE}, #a855f7)`,
|
background: TEAL,
|
||||||
border: "none", borderRadius: 8, color: "#fff",
|
border: "none", borderRadius: 8, color: "#fff",
|
||||||
fontWeight: 700, fontSize: 13, cursor: saving ? "not-allowed" : "pointer",
|
fontWeight: 700, fontSize: 13, cursor: saving ? "not-allowed" : "pointer",
|
||||||
}}>{saving ? "Opslaan..." : "Opslaan"}</button>
|
}}>{saving ? "Opslaan..." : "Opslaan"}</button>
|
||||||
@@ -304,7 +333,7 @@ export default function GebruikersBeheer({ onClose }) {
|
|||||||
{/* Nieuwe gebruiker */}
|
{/* Nieuwe gebruiker */}
|
||||||
{showAdd ? (
|
{showAdd ? (
|
||||||
<div style={{
|
<div style={{
|
||||||
background: darkMode ? `${PURPLE}06` : "#faf7ff",
|
background: darkMode ? "rgba(124,58,237,0.06)" : "#f5f0ff",
|
||||||
border: `1px solid ${T.border}`, borderRadius: 10, padding: "16px",
|
border: `1px solid ${T.border}`, borderRadius: 10, padding: "16px",
|
||||||
}}>
|
}}>
|
||||||
<div style={{ fontSize: 11, color: T.muted, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 12 }}>
|
<div style={{ fontSize: 11, color: T.muted, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 12 }}>
|
||||||
@@ -324,7 +353,7 @@ export default function GebruikersBeheer({ onClose }) {
|
|||||||
}}>Annuleren</button>
|
}}>Annuleren</button>
|
||||||
<button onClick={addUser} disabled={saving} style={{
|
<button onClick={addUser} disabled={saving} style={{
|
||||||
flex: 2, padding: "8px",
|
flex: 2, padding: "8px",
|
||||||
background: `linear-gradient(135deg, ${PURPLE}, #a855f7)`,
|
background: TEAL,
|
||||||
border: "none", borderRadius: 8, color: "#fff",
|
border: "none", borderRadius: 8, color: "#fff",
|
||||||
fontWeight: 700, fontSize: 13, cursor: saving ? "not-allowed" : "pointer",
|
fontWeight: 700, fontSize: 13, cursor: saving ? "not-allowed" : "pointer",
|
||||||
}}>
|
}}>
|
||||||
@@ -335,21 +364,14 @@ export default function GebruikersBeheer({ onClose }) {
|
|||||||
) : (
|
) : (
|
||||||
<button onClick={() => setShowAdd(true)} style={{
|
<button onClick={() => setShowAdd(true)} style={{
|
||||||
padding: "8px 18px",
|
padding: "8px 18px",
|
||||||
background: `linear-gradient(135deg, ${PURPLE}, #a855f7)`,
|
background: TEAL,
|
||||||
border: "none", borderRadius: 8, color: "#fff",
|
border: "none", borderRadius: 8, color: "#fff",
|
||||||
fontWeight: 700, fontSize: 13, cursor: "pointer",
|
fontWeight: 700, fontSize: 13, cursor: "pointer",
|
||||||
}}>+ Nieuwe gebruiker</button>
|
}}>+ Nieuwe gebruiker</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ padding: "12px 22px", borderTop: `1px solid ${T.border}` }}>
|
</div>
|
||||||
<button onClick={onClose} style={{
|
</div>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { useApp } from "../context/AppContext.jsx";
|
||||||
|
|
||||||
|
const TEAL = "linear-gradient(135deg, #7c3aed, #a855f7)";
|
||||||
|
|
||||||
|
const BACKUP_ICON = (
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M12 15V5M8 11l4-4 4 4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<path d="M4 17v2a1 1 0 001 1h14a1 1 0 001-1v-2" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const RESTORE_ICON = (
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M4 8a8 8 0 1 1 0 8" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"/>
|
||||||
|
<polyline points="4,4 4,8 8,8" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
function ActionRow({ icon, title, description, onClick }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
display: "flex", alignItems: "center", gap: 14,
|
||||||
|
padding: "12px 0", cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ color: "#94a3b8", flexShrink: 0 }}>{icon}</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 14, color: "inherit", marginBottom: 2 }}>{title}</div>
|
||||||
|
<div style={{ fontSize: 12, color: "#64748b" }}>{description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InstellingenModal({ onClose }) {
|
||||||
|
const { T, darkMode, setDarkMode, doBackup, doRestore } = useApp();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
position: "fixed", inset: 0, zIndex: 400,
|
||||||
|
background: "rgba(0,0,0,0.55)", backdropFilter: "blur(4px)",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
background: T.card, border: `1px solid ${T.border}`,
|
||||||
|
borderRadius: 16, padding: "22px 22px 8px",
|
||||||
|
width: 360, boxShadow: "0 16px 48px rgba(0,0,0,0.4)",
|
||||||
|
color: T.text,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 20 }}>
|
||||||
|
<span style={{ fontWeight: 700, fontSize: 16 }}>Instellingen</span>
|
||||||
|
<button onClick={onClose} style={{
|
||||||
|
background: "transparent", border: "none", cursor: "pointer",
|
||||||
|
color: T.muted, fontSize: 20, lineHeight: 1, padding: "0 4px",
|
||||||
|
}}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dark mode rij */}
|
||||||
|
<div style={{
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||||
|
padding: "4px 0 16px",
|
||||||
|
}}>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 14 }}>Dark mode</span>
|
||||||
|
<div
|
||||||
|
onClick={() => setDarkMode((d) => !d)}
|
||||||
|
style={{
|
||||||
|
width: 44, height: 24, borderRadius: 12, cursor: "pointer",
|
||||||
|
background: darkMode ? TEAL : "#334155",
|
||||||
|
position: "relative", transition: "background 0.2s", flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
position: "absolute", top: 3, borderRadius: "50%",
|
||||||
|
width: 18, height: 18, background: "#fff",
|
||||||
|
left: darkMode ? 23 : 3,
|
||||||
|
transition: "left 0.2s",
|
||||||
|
boxShadow: "0 1px 4px rgba(0,0,0,0.3)",
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ height: 1, background: T.border, margin: "0 -22px" }} />
|
||||||
|
|
||||||
|
{/* Acties */}
|
||||||
|
<div style={{ padding: "4px 0 10px" }}>
|
||||||
|
<ActionRow
|
||||||
|
icon={BACKUP_ICON}
|
||||||
|
title="Backup maken"
|
||||||
|
description="Download een volledige backup als ZIP"
|
||||||
|
onClick={() => { doBackup(); onClose(); }}
|
||||||
|
/>
|
||||||
|
<div style={{ height: 1, background: T.border }} />
|
||||||
|
<ActionRow
|
||||||
|
icon={RESTORE_ICON}
|
||||||
|
title="Backup herstellen"
|
||||||
|
description="Herstel data vanuit een backup-bestand"
|
||||||
|
onClick={() => { doRestore(); onClose(); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,20 +1,122 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { PURPLE, PURPLE_LIGHT, RED } from "../constants/index.js";
|
import { RED } from "../constants/index.js";
|
||||||
|
|
||||||
|
const MailIcon = () => (
|
||||||
|
<svg width="15" height="15" viewBox="0 0 16 16" fill="none">
|
||||||
|
<rect x="1" y="3" width="14" height="10" rx="2" stroke="currentColor" strokeWidth="1.5"/>
|
||||||
|
<polyline points="1,5 8,9.5 15,5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const LockIcon = () => (
|
||||||
|
<svg width="15" height="15" viewBox="0 0 16 16" fill="none">
|
||||||
|
<rect x="3" y="7" width="10" height="8" rx="2" stroke="currentColor" strokeWidth="1.5"/>
|
||||||
|
<path d="M5 7V5a3 3 0 016 0v2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const PersonIcon = () => (
|
||||||
|
<svg width="15" height="15" viewBox="0 0 16 16" fill="none">
|
||||||
|
<circle cx="8" cy="5" r="3" stroke="currentColor" strokeWidth="1.5"/>
|
||||||
|
<path d="M2 14c0-3 2.5-5 6-5s6 2 6 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
function EyeBtn({ show, onToggle }) {
|
||||||
|
return (
|
||||||
|
<button onClick={onToggle} type="button" style={{
|
||||||
|
background: "none", border: "none", cursor: "pointer",
|
||||||
|
color: "rgba(255,255,255,0.35)", padding: 0, lineHeight: 1, display: "flex", flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{show
|
||||||
|
? <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M1 8s2.5-5 7-5 7 5 7 5-2.5 5-7 5-7-5-7-5z" stroke="currentColor" strokeWidth="1.4"/><circle cx="8" cy="8" r="2" stroke="currentColor" strokeWidth="1.4"/><line x1="2" y1="2" x2="14" y2="14" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/></svg>
|
||||||
|
: <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M1 8s2.5-5 7-5 7 5 7 5-2.5 5-7 5-7-5-7-5z" stroke="currentColor" strokeWidth="1.4"/><circle cx="8" cy="8" r="2" stroke="currentColor" strokeWidth="1.4"/></svg>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FloatingInput({ label, type = "text", value, onChange, onKeyDown, icon, rightElement }) {
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
return (
|
||||||
|
<div style={{ position: "relative", marginTop: 10 }}>
|
||||||
|
<div style={{
|
||||||
|
position: "relative",
|
||||||
|
border: `1px solid ${focused ? "rgba(124,58,237,0.7)" : "rgba(255,255,255,0.1)"}`,
|
||||||
|
borderRadius: 10, background: "rgba(255,255,255,0.03)",
|
||||||
|
transition: "border-color 0.15s",
|
||||||
|
}}>
|
||||||
|
<label style={{
|
||||||
|
position: "absolute", top: -8, left: 12,
|
||||||
|
fontSize: 10, fontWeight: 700,
|
||||||
|
color: focused ? "#a855f7" : "rgba(255,255,255,0.35)",
|
||||||
|
background: "#1a1b22", padding: "0 5px",
|
||||||
|
letterSpacing: "0.06em", textTransform: "uppercase",
|
||||||
|
pointerEvents: "none", transition: "color 0.15s",
|
||||||
|
}}>{label}</label>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", padding: "13px 14px", gap: 10 }}>
|
||||||
|
{icon && (
|
||||||
|
<span style={{ color: focused ? "rgba(168,85,247,0.7)" : "rgba(255,255,255,0.25)", flexShrink: 0, display: "flex", transition: "color 0.15s" }}>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type={type} value={value} onChange={onChange} onKeyDown={onKeyDown}
|
||||||
|
onFocus={() => setFocused(true)} onBlur={() => setFocused(false)}
|
||||||
|
style={{
|
||||||
|
flex: 1, background: "transparent", border: "none", outline: "none",
|
||||||
|
color: "#f1f5f9", fontSize: 14,
|
||||||
|
fontFamily: "'Poppins', system-ui, sans-serif",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{rightElement}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniBarChart() {
|
||||||
|
const bars = [38, 52, 45, 68, 74, 61, 89];
|
||||||
|
const months = ["nov", "dec", "jan", "feb", "mrt", "apr", "mei"];
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", alignItems: "flex-end", gap: 6, height: 48, marginTop: 4 }}>
|
||||||
|
{bars.map((h, i) => (
|
||||||
|
<div key={i} style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 4 }}>
|
||||||
|
<div style={{
|
||||||
|
width: "100%", borderRadius: 4,
|
||||||
|
height: `${h}%`,
|
||||||
|
background: i === bars.length - 1
|
||||||
|
? "linear-gradient(180deg, #a855f7, #7c3aed)"
|
||||||
|
: "rgba(255,255,255,0.12)",
|
||||||
|
}} />
|
||||||
|
<span style={{ fontSize: 8, color: "rgba(255,255,255,0.35)", whiteSpace: "nowrap" }}>{months[i]}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function LoginPage({ onLogin }) {
|
export default function LoginPage({ onLogin }) {
|
||||||
const [mode, setMode] = useState("login");
|
const [mode, setMode] = useState("login");
|
||||||
const [naam, setNaam] = useState("");
|
const [naam, setNaam] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [wachtwoord, setWachtwoord] = useState("");
|
const [wachtwoord, setWachtwoord] = useState("");
|
||||||
|
const [herhaalWw, setHerhaalWw] = useState("");
|
||||||
const [showPass, setShowPass] = useState(false);
|
const [showPass, setShowPass] = useState(false);
|
||||||
|
const [showPass2, setShowPass2] = useState(false);
|
||||||
const [err, setErr] = useState("");
|
const [err, setErr] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const switchMode = (m) => { setMode(m); setNaam(""); setEmail(""); setWachtwoord(""); setErr(""); };
|
const switchMode = (m) => { setMode(m); setNaam(""); setEmail(""); setWachtwoord(""); setHerhaalWw(""); setErr(""); };
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
setErr("");
|
setErr("");
|
||||||
if (mode === "register" && !naam.trim()) { setErr("Vul je naam in."); return; }
|
if (mode === "register") {
|
||||||
|
if (!naam.trim()) { setErr("Vul je naam in."); return; }
|
||||||
|
if (wachtwoord.length < 8) { setErr("Wachtwoord moet minimaal 8 tekens zijn."); return; }
|
||||||
|
if (!/[A-Z]/.test(wachtwoord)) { setErr("Wachtwoord moet minimaal één hoofdletter bevatten."); return; }
|
||||||
|
if (!/[0-9]/.test(wachtwoord)) { setErr("Wachtwoord moet minimaal één cijfer bevatten."); return; }
|
||||||
|
if (wachtwoord !== herhaalWw) { setErr("Wachtwoorden komen niet overeen."); return; }
|
||||||
|
}
|
||||||
if (!email || !wachtwoord) { setErr("Vul e-mail en wachtwoord in."); return; }
|
if (!email || !wachtwoord) { setErr("Vul e-mail en wachtwoord in."); return; }
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -32,265 +134,244 @@ export default function LoginPage({ onLogin }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const iStyle = {
|
const onEnter = (e) => e.key === "Enter" && handleSubmit();
|
||||||
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 (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
minHeight: "100vh",
|
minHeight: "100vh",
|
||||||
background: "radial-gradient(ellipse at 20% 50%, #1a1060 0%, #0a0b1a 50%, #0d0a2a 100%)",
|
background: "#09090f",
|
||||||
display: "flex", fontFamily: "'Inter', system-ui, sans-serif",
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
color: "#f1f5f9", overflow: "hidden", position: "relative",
|
fontFamily: "'Poppins', system-ui, sans-serif",
|
||||||
|
color: "#f1f5f9",
|
||||||
|
padding: "40px 24px", boxSizing: "border-box",
|
||||||
}}>
|
}}>
|
||||||
{/* Achtergrond gloeden */}
|
{/* Floating card — larger */}
|
||||||
<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={{
|
<div style={{
|
||||||
width: 380, flexShrink: 0, minHeight: "100vh",
|
display: "flex", width: "100%", maxWidth: 1080,
|
||||||
display: "flex", flexDirection: "column", justifyContent: "center",
|
minHeight: 620,
|
||||||
padding: "48px 40px", boxSizing: "border-box", marginLeft: 80,
|
borderRadius: 24, overflow: "hidden",
|
||||||
position: "relative", zIndex: 2,
|
boxShadow: "0 40px 100px rgba(0,0,0,0.75), 0 0 0 1px rgba(255,255,255,0.06)",
|
||||||
}}>
|
}}>
|
||||||
{/* 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 */}
|
{/* LEFT: Form panel */}
|
||||||
<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={{
|
<div style={{
|
||||||
width: "calc(100% + 40px)", maxWidth: 900,
|
width: 460, flexShrink: 0,
|
||||||
borderRadius: "12px 0 0 12px", overflow: "hidden",
|
background: "#1a1b22",
|
||||||
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)",
|
padding: "52px 48px", boxSizing: "border-box",
|
||||||
position: "relative",
|
display: "flex", flexDirection: "column",
|
||||||
maskImage: "linear-gradient(to right, black 40%, transparent 100%)",
|
|
||||||
WebkitMaskImage: "linear-gradient(to right, black 40%, transparent 100%)",
|
|
||||||
}}>
|
}}>
|
||||||
{/* Browser chrome */}
|
{/* Logo */}
|
||||||
<div style={{ background: "#161927", padding: "9px 14px", display: "flex", alignItems: "center", gap: 5, borderBottom: "1px solid rgba(255,255,255,0.05)" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 40 }}>
|
||||||
<div style={{ width: 9, height: 9, borderRadius: "50%", background: "#ef4444", opacity: 0.8 }} />
|
<div style={{
|
||||||
<div style={{ width: 9, height: 9, borderRadius: "50%", background: "#f59e0b", opacity: 0.8 }} />
|
width: 36, height: 36, flexShrink: 0, borderRadius: 10,
|
||||||
<div style={{ width: 9, height: 9, borderRadius: "50%", background: "#10b981", opacity: 0.8 }} />
|
background: "rgba(124,58,237,0.15)", border: "1px solid rgba(124,58,237,0.3)",
|
||||||
<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>
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
}}>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
|
<polyline points="2,15 6,9 10,12 14,5 18,8" stroke="#a855f7" strokeWidth="2.2" strokeLinejoin="round" strokeLinecap="round"/>
|
||||||
|
<line x1="2" y1="17" x2="18" y2="17" stroke="rgba(124,58,237,0.35)" strokeWidth="1.4"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 19, fontWeight: 700, color: "#fff", letterSpacing: "-0.3px" }}>VibeFinance</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* App */}
|
{/* Heading */}
|
||||||
<div style={{ display: "flex", background: "#0f1117", height: 620 }}>
|
<div style={{ marginBottom: 32 }}>
|
||||||
|
<h1 style={{ fontSize: 26, fontWeight: 800, margin: "0 0 10px", color: "#fff", letterSpacing: "-0.4px" }}>
|
||||||
|
Welkom terug!
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: 14, color: "rgba(255,255,255,0.38)", margin: 0, lineHeight: 1.6 }}>
|
||||||
|
{mode === "login"
|
||||||
|
? "Log in om je budgetoverzicht te bekijken."
|
||||||
|
: "Maak een account aan om je vermogen bij te houden."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* Tab switcher */}
|
||||||
<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={{
|
||||||
<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 }}>
|
display: "flex", background: "rgba(255,255,255,0.05)",
|
||||||
<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>
|
borderRadius: 11, padding: 4, marginBottom: 32,
|
||||||
</div>
|
border: "1px solid rgba(255,255,255,0.07)",
|
||||||
{[
|
}}>
|
||||||
{ 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 },
|
{[["login", "Inloggen"], ["register", "Registreren"]].map(([m, lbl]) => (
|
||||||
{ 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 },
|
<button key={m} onClick={() => switchMode(m)} style={{
|
||||||
{ 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 },
|
flex: 1, padding: "10px 0", borderRadius: 8,
|
||||||
{ 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 },
|
border: "none", cursor: "pointer",
|
||||||
].map((item, i) => (
|
fontFamily: "'Poppins', system-ui, sans-serif",
|
||||||
<div key={i} style={{ width: 36, height: 36, borderRadius: 8, display: "flex", alignItems: "center", justifyContent: "center", background: item.active ? `${PURPLE}22` : "transparent", marginBottom: 2 }}>
|
fontWeight: 600, fontSize: 13,
|
||||||
{item.icon}
|
transition: "all 0.15s",
|
||||||
|
background: mode === m ? "linear-gradient(135deg, #7c3aed, #a855f7)" : "transparent",
|
||||||
|
color: mode === m ? "#fff" : "rgba(255,255,255,0.35)",
|
||||||
|
boxShadow: mode === m ? "0 4px 14px rgba(124,58,237,0.4)" : "none",
|
||||||
|
}}>{lbl}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form fields */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
|
{mode === "register" && (
|
||||||
|
<FloatingInput
|
||||||
|
label="Naam" value={naam} icon={<PersonIcon />}
|
||||||
|
onChange={(e) => { setNaam(e.target.value); setErr(""); }}
|
||||||
|
onKeyDown={onEnter}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<FloatingInput
|
||||||
|
label="E-mailadres" type="email" value={email} icon={<MailIcon />}
|
||||||
|
onChange={(e) => { setEmail(e.target.value); setErr(""); }}
|
||||||
|
onKeyDown={onEnter}
|
||||||
|
/>
|
||||||
|
<FloatingInput
|
||||||
|
label="Wachtwoord" type={showPass ? "text" : "password"} value={wachtwoord} icon={<LockIcon />}
|
||||||
|
onChange={(e) => { setWachtwoord(e.target.value); setErr(""); }}
|
||||||
|
onKeyDown={onEnter}
|
||||||
|
rightElement={<EyeBtn show={showPass} onToggle={() => setShowPass(s => !s)} />}
|
||||||
|
/>
|
||||||
|
{mode === "register" && (
|
||||||
|
<>
|
||||||
|
<FloatingInput
|
||||||
|
label="Herhaal wachtwoord" type={showPass2 ? "text" : "password"} value={herhaalWw} icon={<LockIcon />}
|
||||||
|
onChange={(e) => { setHerhaalWw(e.target.value); setErr(""); }}
|
||||||
|
onKeyDown={onEnter}
|
||||||
|
rightElement={<EyeBtn show={showPass2} onToggle={() => setShowPass2(s => !s)} />}
|
||||||
|
/>
|
||||||
|
<div style={{ fontSize: 11, color: "rgba(255,255,255,0.25)", marginTop: 6, paddingLeft: 2 }}>
|
||||||
|
Min. 8 tekens · één hoofdletter · één cijfer
|
||||||
</div>
|
</div>
|
||||||
))}
|
</>
|
||||||
<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" }}>
|
</div>
|
||||||
<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>
|
|
||||||
|
{err && (
|
||||||
|
<div style={{
|
||||||
|
background: "rgba(239,68,68,0.08)", border: "1px solid rgba(239,68,68,0.22)",
|
||||||
|
borderRadius: 8, padding: "9px 12px", marginTop: 16, fontSize: 12, color: RED,
|
||||||
|
}}>{err}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button onClick={handleSubmit} disabled={loading} style={{
|
||||||
|
width: "100%", padding: "14px",
|
||||||
|
background: loading ? "rgba(124,58,237,0.3)" : "linear-gradient(135deg, #7c3aed, #a855f7)",
|
||||||
|
border: "none", borderRadius: 11, color: "#fff",
|
||||||
|
fontFamily: "'Poppins', system-ui, sans-serif",
|
||||||
|
fontWeight: 700, fontSize: 15, cursor: loading ? "not-allowed" : "pointer",
|
||||||
|
boxShadow: loading ? "none" : "0 6px 24px rgba(124,58,237,0.45)",
|
||||||
|
marginTop: 28, opacity: loading ? 0.7 : 1, transition: "opacity 0.15s",
|
||||||
|
}}>
|
||||||
|
{loading
|
||||||
|
? (mode === "login" ? "Inloggen..." : "Account aanmaken...")
|
||||||
|
: (mode === "login" ? "Inloggen" : "Account aanmaken")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT: Purple mockup panel */}
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
background: "linear-gradient(145deg, #3b0764 0%, #5b21b6 40%, #7c3aed 80%, #9333ea 100%)",
|
||||||
|
position: "relative", overflow: "hidden",
|
||||||
|
display: "flex", flexDirection: "column",
|
||||||
|
alignItems: "center", justifyContent: "center",
|
||||||
|
padding: "48px 40px",
|
||||||
|
}}>
|
||||||
|
{/* Decorative circles */}
|
||||||
|
<div style={{ position: "absolute", width: 320, height: 320, borderRadius: "50%", background: "rgba(168,85,247,0.2)", top: -100, right: -80, pointerEvents: "none" }} />
|
||||||
|
<div style={{ position: "absolute", width: 220, height: 220, borderRadius: "50%", background: "rgba(168,85,247,0.15)", bottom: -40, left: -60, pointerEvents: "none" }} />
|
||||||
|
<div style={{ position: "absolute", width: 120, height: 120, borderRadius: "50%", background: "rgba(168,85,247,0.12)", top: "42%", left: "8%", pointerEvents: "none" }} />
|
||||||
|
<div style={{ position: "absolute", width: 60, height: 60, borderRadius: "50%", background: "rgba(255,255,255,0.06)", top: "18%", right: "22%", pointerEvents: "none" }} />
|
||||||
|
|
||||||
|
{/* Top badge */}
|
||||||
|
<div style={{
|
||||||
|
background: "rgba(255,255,255,0.13)", backdropFilter: "blur(8px)",
|
||||||
|
borderRadius: 99, padding: "7px 16px", fontSize: 12, fontWeight: 600,
|
||||||
|
color: "rgba(255,255,255,0.92)", marginBottom: 32,
|
||||||
|
border: "1px solid rgba(255,255,255,0.18)",
|
||||||
|
display: "flex", alignItems: "center", gap: 7,
|
||||||
|
position: "relative", zIndex: 2,
|
||||||
|
}}>
|
||||||
|
<span style={{ color: "#fbbf24", fontSize: 14 }}>✦</span>
|
||||||
|
Jouw vermogen in één oogopslag
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main glassmorphism card */}
|
||||||
|
<div style={{
|
||||||
|
background: "rgba(255,255,255,0.11)", backdropFilter: "blur(16px)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.18)",
|
||||||
|
borderRadius: 18, padding: "22px 24px",
|
||||||
|
width: "100%", maxWidth: 320,
|
||||||
|
boxShadow: "0 12px 40px rgba(0,0,0,0.3)",
|
||||||
|
position: "relative", zIndex: 2, marginBottom: 16,
|
||||||
|
}}>
|
||||||
|
{/* Card header */}
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 18 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: "#fff" }}>Vermogensoverzicht</div>
|
||||||
|
<div style={{ fontSize: 10, color: "rgba(255,255,255,0.5)", marginTop: 2 }}>The Road naar € 500.000</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1 }} />
|
<div style={{ fontSize: 10, color: "rgba(255,255,255,0.5)", background: "rgba(255,255,255,0.1)", borderRadius: 6, padding: "3px 8px" }}>Mei 2026</div>
|
||||||
<div style={{ width: 36, height: 36, borderRadius: 8, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
</div>
|
||||||
<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>
|
|
||||||
|
{/* KPI rows */}
|
||||||
|
{[
|
||||||
|
{ label: "Eigen Vermogen", val: "€ 445.500", color: "#10b981", icon: "↑" },
|
||||||
|
{ label: "Schuld", val: "€ 54.500", color: "#f97316", icon: "↓" },
|
||||||
|
{ label: "Winst", val: "€ 142.500", color: "#a855f7", icon: "↑" },
|
||||||
|
].map(({ label, val, color, icon }) => (
|
||||||
|
<div key={label} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<div style={{ width: 26, height: 26, borderRadius: 7, background: `${color}22`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 11, color, fontWeight: 700 }}>{icon}</div>
|
||||||
|
<span style={{ fontSize: 12, color: "rgba(255,255,255,0.7)" }}>{label}</span>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700, color: "#fff" }}>{val}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Progress toward goal */}
|
||||||
|
<div style={{ marginTop: 16, padding: "12px 14px", background: "rgba(0,0,0,0.2)", borderRadius: 10 }}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 8 }}>
|
||||||
|
<span style={{ fontSize: 11, color: "rgba(255,255,255,0.6)" }}>Voortgang naar doel</span>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, color: "#fff" }}>89%</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 6, background: "rgba(255,255,255,0.12)", borderRadius: 99, overflow: "hidden" }}>
|
||||||
|
<div style={{ width: "89%", height: "100%", background: "linear-gradient(90deg, #a855f7, #c084fc)", borderRadius: 99 }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 6 }}>
|
||||||
|
<span style={{ fontSize: 9, color: "rgba(255,255,255,0.35)" }}>€ 0</span>
|
||||||
|
<span style={{ fontSize: 9, color: "rgba(255,255,255,0.35)" }}>€ 500.000</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Inhoud */}
|
{/* Mini bar chart */}
|
||||||
<div style={{ flex: 1, padding: "18px 20px 14px", overflowY: "hidden" }}>
|
<div style={{ marginTop: 14 }}>
|
||||||
{/* Topbalk */}
|
<div style={{ fontSize: 10, color: "rgba(255,255,255,0.45)", marginBottom: 6 }}>Groei per maand</div>
|
||||||
<div style={{ display: "flex", justifyContent: "flex-end", alignItems: "center", gap: 6, marginBottom: 16 }}>
|
<MiniBarChart />
|
||||||
<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>
|
||||||
|
|
||||||
|
{/* Floating badges */}
|
||||||
|
<div style={{ display: "flex", gap: 12, position: "relative", zIndex: 2 }}>
|
||||||
|
<div style={{
|
||||||
|
background: "rgba(255,255,255,0.95)", borderRadius: 14, padding: "12px 16px",
|
||||||
|
boxShadow: "0 8px 24px rgba(0,0,0,0.25)",
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 10, color: "#64748b", fontWeight: 600, marginBottom: 3 }}>ROI</div>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 800, color: "#10b981" }}>+47,03%</div>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
background: "rgba(255,255,255,0.95)", borderRadius: 14, padding: "12px 16px",
|
||||||
|
boxShadow: "0 8px 24px rgba(0,0,0,0.25)",
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 10, color: "#64748b", fontWeight: 600, marginBottom: 3 }}>Doel</div>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 800, color: "#7c3aed" }}>€ 500.000</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom tagline */}
|
||||||
|
<div style={{ position: "absolute", bottom: 24, left: 40, right: 40, fontSize: 12, color: "rgba(255,255,255,0.4)", lineHeight: 1.6, textAlign: "center" }}>
|
||||||
|
Beheer je vermogen, volg je groei en bereik je financiële doelen — allemaal op één plek.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+192
-217
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { PURPLE, PURPLE_LIGHT, TABS } from "../constants/index.js";
|
import { TABS } from "../constants/index.js";
|
||||||
import { version, updateCheckUrl } from "../../package.json";
|
import { version, updateCheckUrl } from "../../package.json";
|
||||||
import { useApp } from "../context/AppContext.jsx";
|
import { useApp } from "../context/AppContext.jsx";
|
||||||
|
|
||||||
@@ -35,10 +35,9 @@ const GEBRUIKERS_ICON = (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
const DATA_ICON = (
|
const INSTELLINGEN_ICON = (
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
<path d="M8 1v8M5 6l3 3 3-3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
<path fillRule="evenodd" clipRule="evenodd" d="M6.5 1a.75.75 0 0 0-.743.648l-.16 1.112a5.07 5.07 0 0 0-.88.51l-1.054-.424a.75.75 0 0 0-.9.31L1.263 4.95a.75.75 0 0 0 .165.96l.888.717a5.14 5.14 0 0 0 0 1.018l-.888.717a.75.75 0 0 0-.165.96l1.5 2.598a.75.75 0 0 0 .9.31l1.053-.424c.276.19.572.36.881.51l.16 1.112A.75.75 0 0 0 6.5 14h3a.75.75 0 0 0 .743-.648l.16-1.112c.309-.15.605-.32.88-.51l1.054.424a.75.75 0 0 0 .9-.31l1.5-2.598a.75.75 0 0 0-.165-.96l-.888-.717a5.14 5.14 0 0 0 0-1.018l.888-.717a.75.75 0 0 0 .165-.96l-1.5-2.598a.75.75 0 0 0-.9-.31l-1.053.424a5.07 5.07 0 0 0-.881-.51L9.743 1.648A.75.75 0 0 0 9.5 1h-3Zm1.5 4.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5Z"/>
|
||||||
<path d="M2 11v2a1 1 0 001 1h10a1 1 0 001-1v-2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -50,67 +49,178 @@ const LOGOUT_ICON = (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
function Tooltip({ label, collapsed }) {
|
const INFO_ICON = (
|
||||||
if (!collapsed) return null;
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<circle cx="8" cy="8" r="6.5" stroke="currentColor" strokeWidth="1.5"/>
|
||||||
|
<line x1="8" y1="7" x2="8" y2="11.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
|
||||||
|
<circle cx="8" cy="4.5" r="0.8" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MOON_ICON = (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path d="M13.5 9.5A6 6 0 016.5 2.5a6 6 0 100 11 6 6 0 007-4z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
function initials(naam = "") {
|
||||||
|
const parts = naam.trim().split(/\s+/);
|
||||||
|
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||||
|
return naam.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionLabel({ children }) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: "absolute", left: "calc(100% + 10px)", top: "50%",
|
fontSize: 11, fontWeight: 700, letterSpacing: "0.12em",
|
||||||
transform: "translateY(-50%)",
|
textTransform: "uppercase", color: "#3d4966",
|
||||||
background: "#1e2130", border: "1px solid rgba(255,255,255,0.1)",
|
padding: "0 12px", marginBottom: 2, marginTop: 20,
|
||||||
borderRadius: 6, padding: "5px 10px",
|
}}>
|
||||||
fontSize: 12, fontWeight: 500, color: "#fff",
|
{children}
|
||||||
whiteSpace: "nowrap", pointerEvents: "none", zIndex: 200,
|
</div>
|
||||||
}}>{label}</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarItem({ active, onClick, icon, label, T, collapsed }) {
|
const ACTIVE_BG = "#7c3aed";
|
||||||
|
const ACTIVE_SHADOW = "0 4px 14px rgba(124,58,237,0.4)";
|
||||||
|
|
||||||
|
function NavItem({ active, onClick, icon, label, T, danger }) {
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const isLit = active || hovered;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onMouseEnter={() => setHovered(true)}
|
onMouseEnter={() => setHovered(true)}
|
||||||
onMouseLeave={() => setHovered(false)}
|
onMouseLeave={() => setHovered(false)}
|
||||||
title={collapsed ? label : undefined}
|
|
||||||
style={{
|
style={{
|
||||||
position: "relative",
|
display: "flex", alignItems: "center", gap: 10,
|
||||||
display: "flex", alignItems: "center",
|
padding: "11px 12px", borderRadius: 9, cursor: "pointer", marginBottom: 4,
|
||||||
gap: collapsed ? 0 : 10,
|
background: active
|
||||||
padding: "9px 12px", borderRadius: 8, cursor: "pointer", marginBottom: 6,
|
? ACTIVE_BG
|
||||||
justifyContent: collapsed ? "center" : "flex-start",
|
: hovered ? "rgba(139,92,246,0.08)" : "transparent",
|
||||||
background: active ? `${PURPLE}18` : "transparent",
|
color: active ? "#fff" : hovered ? T.text : (danger ? "#ef4444" : T.muted),
|
||||||
color: active ? PURPLE_LIGHT : T.muted,
|
fontWeight: active ? 600 : 400,
|
||||||
fontWeight: active ? 600 : 500,
|
fontSize: 15,
|
||||||
fontSize: 13,
|
boxShadow: active ? ACTIVE_SHADOW : "none",
|
||||||
transition: "background 0.15s, color 0.15s",
|
transition: "all 0.15s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ flexShrink: 0, display: "flex", color: "inherit", opacity: active ? 1 : (isLit ? 0.85 : 0.55) }}>{icon}</span>
|
||||||
|
<span style={{ flex: 1 }}>{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverPopup({ onClose, nieuweVersie, T }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Overlay */}
|
||||||
|
<div onClick={onClose} style={{
|
||||||
|
position: "fixed", inset: 0, zIndex: 490,
|
||||||
|
background: "rgba(0,0,0,0.55)", backdropFilter: "blur(4px)",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
}}>
|
}}>
|
||||||
<span style={{ flexShrink: 0, display: "flex" }}>{icon}</span>
|
{/* Modal */}
|
||||||
{!collapsed && <span style={{ flex: 1 }}>{label}</span>}
|
<div onClick={(e) => e.stopPropagation()} style={{
|
||||||
{hovered && <Tooltip label={label} collapsed={collapsed} />}
|
background: T.card,
|
||||||
|
border: `1px solid ${T.border}`,
|
||||||
|
borderRadius: 16, padding: "28px 28px 24px", width: 300,
|
||||||
|
boxShadow: "0 24px 60px rgba(0,0,0,0.5)",
|
||||||
|
position: "relative",
|
||||||
|
}}>
|
||||||
|
{/* Sluitknop */}
|
||||||
|
<button onClick={onClose} style={{
|
||||||
|
position: "absolute", top: 14, right: 14,
|
||||||
|
background: "transparent", border: "none", cursor: "pointer",
|
||||||
|
color: T.muted, fontSize: 20, lineHeight: 1, padding: 4,
|
||||||
|
}}>×</button>
|
||||||
|
|
||||||
|
{/* Logo + naam */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 20 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 44, height: 44, borderRadius: 12, flexShrink: 0,
|
||||||
|
background: "rgba(124,58,237,0.15)", border: "1px solid rgba(124,58,237,0.3)",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
}}>
|
||||||
|
<svg width="22" height="22" viewBox="0 0 20 20" fill="none">
|
||||||
|
<polyline points="2,15 6,9 10,12 14,5 18,8" stroke="#a855f7" strokeWidth="2.2" strokeLinejoin="round" strokeLinecap="round"/>
|
||||||
|
<line x1="2" y1="17" x2="18" y2="17" stroke="rgba(168,85,247,0.35)" strokeWidth="1.4"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 700, color: T.text }}>VibeFinance</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ height: 1, background: T.border, marginBottom: 16 }} />
|
||||||
|
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
|
||||||
|
<span style={{ fontSize: 13, color: T.muted }}>Versie</span>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 700, color: "#a855f7", background: "rgba(124,58,237,0.15)", borderRadius: 6, padding: "3px 10px" }}>
|
||||||
|
v{version}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{nieuweVersie && (
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
|
||||||
|
<span style={{ fontSize: 13, color: T.muted }}>Nieuw beschikbaar</span>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 700, color: "#f97316", background: "rgba(249,115,22,0.12)", borderRadius: 6, padding: "3px 10px" }}>
|
||||||
|
v{nieuweVersie}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ height: 1, background: T.border, margin: "12px 0" }} />
|
||||||
|
<div style={{ fontSize: 12, color: T.muted, textAlign: "center" }}>© {new Date().getFullYear()} VibeFinance</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DarkModeToggle({ darkMode, setDarkMode, T }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={() => setDarkMode(!darkMode)}
|
||||||
|
style={{
|
||||||
|
display: "flex", alignItems: "center", gap: 10,
|
||||||
|
padding: "11px 12px", borderRadius: 9, cursor: "pointer", marginBottom: 4,
|
||||||
|
color: T.muted, fontSize: 13.5, fontWeight: 500,
|
||||||
|
transition: "background 0.12s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = "rgba(255,255,255,0.04)"}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = "transparent"}
|
||||||
|
>
|
||||||
|
<span style={{ flexShrink: 0, display: "flex", opacity: 0.6 }}>{MOON_ICON}</span>
|
||||||
|
<span style={{ flex: 1 }}>Dark Mode</span>
|
||||||
|
{/* Toggle pill */}
|
||||||
|
<div style={{
|
||||||
|
width: 36, height: 20, borderRadius: 99, flexShrink: 0,
|
||||||
|
background: darkMode ? "#7c3aed" : "rgba(255,255,255,0.15)",
|
||||||
|
position: "relative", transition: "background 0.2s",
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
position: "absolute", top: 3, left: darkMode ? 19 : 3,
|
||||||
|
width: 14, height: 14, borderRadius: "50%",
|
||||||
|
background: "#fff", transition: "left 0.2s",
|
||||||
|
boxShadow: "0 1px 4px rgba(0,0,0,0.3)",
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NavBar() {
|
export default function NavBar() {
|
||||||
const {
|
const { tab, setTab, setShowUsers, setShowInstellingen, T, logout, darkMode, setDarkMode, currentUser, avatar } = useApp();
|
||||||
tab, setTab,
|
|
||||||
setShowUsers,
|
|
||||||
T,
|
|
||||||
logout,
|
|
||||||
doBackup, doRestore,
|
|
||||||
} = useApp();
|
|
||||||
|
|
||||||
const [nieuweVersie, setNieuweVersie] = useState(null);
|
const [nieuweVersie, setNieuweVersie] = useState(null);
|
||||||
const [dataOpen, setDataOpen] = useState(false);
|
const [overOpen, setOverOpen] = useState(false);
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
|
||||||
|
|
||||||
const W = collapsed ? 56 : 220;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!updateCheckUrl) return;
|
if (!updateCheckUrl) return;
|
||||||
fetch(updateCheckUrl)
|
fetch(updateCheckUrl)
|
||||||
.then((r) => r.json())
|
.then(r => r.json())
|
||||||
.then((data) => {
|
.then(data => {
|
||||||
const latest = data?.tag_name?.replace(/^v/, "");
|
const latest = data?.tag_name?.replace(/^v/, "");
|
||||||
if (latest && latest !== version) setNieuweVersie(latest);
|
if (latest && latest !== version) setNieuweVersie(latest);
|
||||||
})
|
})
|
||||||
@@ -119,192 +229,57 @@ export default function NavBar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
width: W, minWidth: W, flexShrink: 0,
|
width: 260, minWidth: 260, flexShrink: 0,
|
||||||
height: "100vh", position: "sticky", top: 0,
|
height: "100vh", position: "sticky", top: 0,
|
||||||
background: T.card, borderRight: `1px solid ${T.border}`,
|
background: T.sidebar ?? T.card,
|
||||||
|
borderRight: `1px solid ${T.border}`,
|
||||||
display: "flex", flexDirection: "column",
|
display: "flex", flexDirection: "column",
|
||||||
zIndex: 100, transition: "width 0.2s ease, min-width 0.2s ease",
|
zIndex: 100,
|
||||||
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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
{/* Logo */}
|
||||||
|
<div style={{ padding: "22px 16px 28px", display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 32, height: 32, flexShrink: 0, borderRadius: 9,
|
||||||
|
background: "rgba(124,58,237,0.15)",
|
||||||
|
border: "1px solid rgba(124,58,237,0.3)",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
}}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 20 20" fill="none">
|
||||||
|
<polyline points="2,15 6,9 10,12 14,5 18,8"
|
||||||
|
stroke="#a855f7" strokeWidth="2.2"
|
||||||
|
strokeLinejoin="round" strokeLinecap="round"/>
|
||||||
|
<line x1="2" y1="17" x2="18" y2="17"
|
||||||
|
stroke="rgba(168,85,247,0.35)" strokeWidth="1.4"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 17, fontWeight: 700, color: T.text, letterSpacing: "-0.3px" }}>VibeFinance</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav */}
|
||||||
|
<nav style={{ flex: 1, padding: "0 8px", overflowY: "auto" }}>
|
||||||
|
|
||||||
{/* Navigatie */}
|
|
||||||
<nav style={{ flex: 1, padding: `16px ${collapsed ? 8 : 10}px`, overflowY: "auto" }}>
|
|
||||||
{TABS.map((t, i) => (
|
{TABS.map((t, i) => (
|
||||||
<SidebarItem
|
<NavItem key={t} active={tab === i} onClick={() => setTab(i)} icon={TAB_ICONS[i]} label={t} T={T} />
|
||||||
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
|
<NavItem onClick={() => setShowUsers(true)} icon={GEBRUIKERS_ICON} label="Gebruikers" T={T} />
|
||||||
onClick={() => setShowUsers(true)}
|
<NavItem onClick={() => setShowInstellingen(true)} icon={INSTELLINGEN_ICON} label="Instellingen" T={T} />
|
||||||
icon={GEBRUIKERS_ICON}
|
|
||||||
label="Gebruikers"
|
|
||||||
T={T}
|
|
||||||
collapsed={collapsed}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Data */}
|
|
||||||
{collapsed ? (
|
<div style={{ position: "relative" }}>
|
||||||
<div style={{ position: "relative" }}>
|
<NavItem onClick={() => setOverOpen(o => !o)} icon={INFO_ICON} label="Over VibeFinance" active={false} T={T} />
|
||||||
<SidebarItem
|
{overOpen && <OverPopup onClose={() => setOverOpen(false)} nieuweVersie={nieuweVersie} T={T} />}
|
||||||
onClick={() => setDataOpen((o) => !o)}
|
{nieuweVersie && !overOpen && (
|
||||||
icon={DATA_ICON}
|
<div style={{ position: "absolute", top: 10, right: 26, width: 6, height: 6, borderRadius: "50%", background: "#f97316", pointerEvents: "none" }} />
|
||||||
label="Data"
|
)}
|
||||||
T={T}
|
</div>
|
||||||
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>
|
</nav>
|
||||||
|
|
||||||
{/* Onderste sectie */}
|
{/* Uitloggen */}
|
||||||
<div style={{ padding: `10px ${collapsed ? 8 : 10}px 24px`, borderTop: `1px solid ${T.border}` }}>
|
<div style={{ padding: "8px 8px 12px", borderTop: `1px solid ${T.border}` }}>
|
||||||
|
<NavItem onClick={logout} icon={LOGOUT_ICON} label="Log Out" T={T} />
|
||||||
{/* 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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* App naam + versienummer */}
|
|
||||||
{!collapsed && (
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "8px 12px", marginBottom: 4 }}>
|
|
||||||
<div style={{
|
|
||||||
width: 32, height: 32, flexShrink: 0,
|
|
||||||
background: `linear-gradient(135deg, ${PURPLE}, #a855f7)`,
|
|
||||||
borderRadius: 8, display: "flex", alignItems: "center", justifyContent: "center",
|
|
||||||
boxShadow: `0 0 8px ${PURPLE}44`,
|
|
||||||
}}>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none">
|
|
||||||
<polyline points="2,15 6,9 10,12 14,5 18,8" stroke="#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>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 700, color: T.text }}>VibeFinance</div>
|
|
||||||
<div style={{ fontSize: 11, color: T.muted }}>{version}</div>
|
|
||||||
</div>
|
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useApp } from "../context/AppContext.jsx";
|
import { useApp } from "../context/AppContext.jsx";
|
||||||
import { PURPLE, PURPLE_LIGHT, RED } from "../constants/index.js";
|
import { RED } from "../constants/index.js";
|
||||||
|
|
||||||
|
const TEAL = "linear-gradient(135deg, #7c3aed, #a855f7)";
|
||||||
|
const TEAL_SHADOW = "0 4px 14px rgba(124,58,237,0.4)";
|
||||||
|
|
||||||
function initials(naam = "") {
|
function initials(naam = "") {
|
||||||
const parts = naam.trim().split(/\s+/);
|
const parts = naam.trim().split(/\s+/);
|
||||||
@@ -8,225 +11,254 @@ function initials(naam = "") {
|
|||||||
return naam.slice(0, 2).toUpperCase();
|
return naam.slice(0, 2).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LOCK_ICON = (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<rect x="3" y="7" width="10" height="8" rx="2" stroke="currentColor" strokeWidth="1.5"/>
|
||||||
|
<path d="M5 7V5a3 3 0 016 0v2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const LOGOUT_ICON = (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path d="M6 2H3a1 1 0 00-1 1v10a1 1 0 001 1h3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
|
||||||
|
<polyline points="10,5 13,8 10,11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<line x1="13" y1="8" x2="6" y2="8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CHEVRON = (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||||
|
<polyline points="5,3 9,7 5,11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const BACK_ICON = (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<polyline points="10,3 6,8 10,13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
function InputField({ label, type = "text", value, onChange, T }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 11, color: T.muted, fontWeight: 600, display: "block", marginBottom: 5, textTransform: "uppercase", letterSpacing: "0.06em" }}>{label}</label>
|
||||||
|
<input type={type} value={value} onChange={onChange} style={{
|
||||||
|
width: "100%", padding: "10px 12px", borderRadius: 10, fontSize: 13,
|
||||||
|
background: T.bg, border: `1px solid ${T.border}`, color: T.text, outline: "none",
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProfielPopup({ onClose }) {
|
export default function ProfielPopup({ onClose }) {
|
||||||
const { currentUser, updateProfile, changePassword, logout, T, avatar, setAvatar } = useApp();
|
const { currentUser, updateProfile, changePassword, logout, T, avatar, setAvatar } = useApp();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState("profiel");
|
const [view, setView] = useState("main");
|
||||||
|
|
||||||
const [naam, setNaam] = useState(currentUser?.naam || "");
|
const [naam, setNaam] = useState(currentUser?.naam || "");
|
||||||
const [email, setEmail] = useState(currentUser?.email || "");
|
const [email, setEmail] = useState(currentUser?.email || "");
|
||||||
const [profielError, setProfielError] = useState("");
|
const [profielErr, setProfielErr] = useState("");
|
||||||
const [profielSuccess, setProfielSuccess] = useState(false);
|
const [profielOk, setProfielOk] = useState(false);
|
||||||
const [profielLoading, setProfielLoading] = useState(false);
|
const [profielBusy, setProfielBusy] = useState(false);
|
||||||
|
|
||||||
const [huidig, setHuidig] = useState("");
|
const [huidig, setHuidig] = useState("");
|
||||||
const [nieuw, setNieuw] = useState("");
|
const [nieuw, setNieuw] = useState("");
|
||||||
const [bevestig, setBevestig] = useState("");
|
const [bevestig, setBevestig] = useState("");
|
||||||
const [pwError, setPwError] = useState("");
|
const [pwErr, setPwErr] = useState("");
|
||||||
const [pwSuccess, setPwSuccess] = useState(false);
|
const [pwOk, setPwOk] = useState(false);
|
||||||
const [pwLoading, setPwLoading] = useState(false);
|
const [pwBusy, setPwBusy] = useState(false);
|
||||||
|
|
||||||
const pIs = {
|
const saveProfiel = async () => {
|
||||||
background: T.inputBg, border: `1px solid ${T.inputBorder}`,
|
setProfielErr(""); setProfielOk(false);
|
||||||
borderRadius: 10, color: T.text, padding: "10px 12px",
|
if (!naam.trim() || !email.trim()) { setProfielErr("Naam en e-mail zijn verplicht."); return; }
|
||||||
fontSize: 13, outline: "none", width: "100%", boxSizing: "border-box",
|
setProfielBusy(true);
|
||||||
|
try { await updateProfile(naam.trim(), email.trim()); setProfielOk(true); }
|
||||||
|
catch (err) { setProfielErr(err.message || "Opslaan mislukt."); }
|
||||||
|
finally { setProfielBusy(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveProfiel = async () => {
|
const saveWachtwoord = async () => {
|
||||||
setProfielError(""); setProfielSuccess(false);
|
setPwErr(""); setPwOk(false);
|
||||||
if (!naam.trim() || !email.trim()) { setProfielError("Naam en e-mail zijn verplicht."); return; }
|
if (!huidig || !nieuw || !bevestig) { setPwErr("Vul alle velden in."); return; }
|
||||||
setProfielLoading(true);
|
if (nieuw !== bevestig) { setPwErr("Nieuwe wachtwoorden komen niet overeen."); return; }
|
||||||
try {
|
if (nieuw.length < 8) { setPwErr("Minimaal 8 tekens."); return; }
|
||||||
await updateProfile(naam.trim(), email.trim());
|
setPwBusy(true);
|
||||||
setProfielSuccess(true);
|
try { await changePassword(huidig, nieuw); setPwOk(true); setHuidig(""); setNieuw(""); setBevestig(""); }
|
||||||
} catch (err) {
|
catch (err) { setPwErr(err.message || "Wijzigen mislukt."); }
|
||||||
setProfielError(err.message || "Opslaan mislukt.");
|
finally { setPwBusy(false); }
|
||||||
} finally {
|
|
||||||
setProfielLoading(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChangePassword = async () => {
|
const popupStyle = {
|
||||||
setPwError(""); setPwSuccess(false);
|
position: "fixed", top: 64, right: 16, zIndex: 199,
|
||||||
if (!huidig || !nieuw || !bevestig) { setPwError("Vul alle velden in."); return; }
|
background: T.card, border: `1px solid ${T.border}`,
|
||||||
if (nieuw !== bevestig) { setPwError("Nieuwe wachtwoorden komen niet overeen."); return; }
|
borderRadius: 16, width: 340,
|
||||||
if (nieuw.length < 8) { setPwError("Nieuw wachtwoord moet minimaal 8 tekens zijn."); return; }
|
boxShadow: "0 16px 48px rgba(0,0,0,0.45)",
|
||||||
setPwLoading(true);
|
overflow: "hidden",
|
||||||
try {
|
|
||||||
await changePassword(huidig, nieuw);
|
|
||||||
setPwSuccess(true);
|
|
||||||
setHuidig(""); setNieuw(""); setBevestig("");
|
|
||||||
} catch (err) {
|
|
||||||
setPwError(err.message || "Wachtwoord wijzigen mislukt.");
|
|
||||||
} finally {
|
|
||||||
setPwLoading(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const headerStyle = {
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
padding: "16px 16px 14px", position: "relative",
|
||||||
|
borderBottom: `1px solid ${T.border}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeBtn = (
|
||||||
|
<button onClick={onClose} style={{
|
||||||
|
position: "absolute", right: 14, top: "50%", transform: "translateY(-50%)",
|
||||||
|
background: "transparent", border: "none", cursor: "pointer",
|
||||||
|
color: T.muted, fontSize: 20, lineHeight: 1, padding: 4,
|
||||||
|
}}>×</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const backBtn = (label) => (
|
||||||
|
<button onClick={() => setView("main")} style={{
|
||||||
|
position: "absolute", left: 12, top: "50%", transform: "translateY(-50%)",
|
||||||
|
background: "transparent", border: "none", cursor: "pointer",
|
||||||
|
color: T.muted, display: "flex", alignItems: "center", padding: 4,
|
||||||
|
}}>{BACK_ICON}</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const actionRow = (icon, label, onClick, color) => (
|
||||||
|
<div onClick={onClick} style={{
|
||||||
|
display: "flex", alignItems: "center", gap: 12,
|
||||||
|
padding: "14px 18px", cursor: "pointer",
|
||||||
|
color: color || T.text,
|
||||||
|
}}>
|
||||||
|
<span style={{ color: color || T.muted, display: "flex" }}>{icon}</span>
|
||||||
|
<span style={{ flex: 1, fontWeight: 600, fontSize: 14 }}>{label}</span>
|
||||||
|
{!color && <span style={{ color: T.muted, display: "flex" }}>{CHEVRON}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Backdrop */}
|
|
||||||
<div onClick={onClose} style={{ position: "fixed", inset: 0, zIndex: 198 }} />
|
<div onClick={onClose} style={{ position: "fixed", inset: 0, zIndex: 198 }} />
|
||||||
|
|
||||||
{/* Popup */}
|
<div style={popupStyle}>
|
||||||
<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 */}
|
{/* ── Hoofdscherm ── */}
|
||||||
<div style={{ display: "flex", borderBottom: `1px solid ${T.border}` }}>
|
{view === "main" && (
|
||||||
{[
|
<>
|
||||||
{ id: "profiel", label: "👤 Profiel" },
|
<div style={headerStyle}>
|
||||||
{ id: "wachtwoord", label: "🔑 Wachtwoord" },
|
<span style={{ fontWeight: 700, fontSize: 15, color: T.text }}>Mijn account</span>
|
||||||
].map(({ id, label }) => (
|
{closeBtn}
|
||||||
<button key={id} onClick={() => setActiveTab(id)} style={{
|
</div>
|
||||||
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 */}
|
{/* Avatar + naam + email */}
|
||||||
{activeTab === "profiel" && (
|
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", padding: "24px 18px 20px", gap: 6 }}>
|
||||||
<div style={{ padding: "18px 18px 20px", display: "flex", flexDirection: "column", gap: 12 }}>
|
|
||||||
{/* Avatar upload */}
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 56, height: 56, borderRadius: "50%", overflow: "hidden", flexShrink: 0,
|
width: 72, height: 72, borderRadius: "50%", overflow: "hidden",
|
||||||
background: avatar ? "transparent" : `linear-gradient(135deg, ${PURPLE}, #a855f7)`,
|
background: avatar ? "transparent" : TEAL,
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
fontSize: 18, fontWeight: 800, color: "#fff",
|
fontSize: 24, fontWeight: 800, color: "#fff",
|
||||||
boxShadow: `0 0 10px ${PURPLE}44`,
|
boxShadow: "0 0 20px rgba(124,58,237,0.4)", marginBottom: 4,
|
||||||
}}>
|
}}>
|
||||||
{avatar
|
{avatar
|
||||||
? <img src={avatar} alt="avatar" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
? <img src={avatar} alt="avatar" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
||||||
: initials(currentUser?.naam)
|
: initials(currentUser?.naam)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
<div style={{ fontWeight: 700, fontSize: 16, color: T.text }}>{currentUser?.naam}</div>
|
||||||
<label style={{
|
<div style={{ fontSize: 13, color: T.muted }}>{currentUser?.email}</div>
|
||||||
padding: "6px 12px", background: `${PURPLE}22`, border: `1px solid ${PURPLE}44`,
|
<button onClick={() => setView("profiel")} style={{
|
||||||
borderRadius: 8, color: PURPLE_LIGHT, fontSize: 12, fontWeight: 600, cursor: "pointer",
|
marginTop: 8, padding: "9px 24px",
|
||||||
}}>
|
background: TEAL,
|
||||||
Foto uploaden
|
border: "none", borderRadius: 10, color: "#fff",
|
||||||
|
fontWeight: 700, fontSize: 13, cursor: "pointer",
|
||||||
|
boxShadow: TEAL_SHADOW,
|
||||||
|
}}>Profiel bewerken</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ height: 1, background: T.border }} />
|
||||||
|
{actionRow(LOCK_ICON, "Wachtwoord wijzigen", () => setView("wachtwoord"))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Profiel bewerken ── */}
|
||||||
|
{view === "profiel" && (
|
||||||
|
<>
|
||||||
|
<div style={headerStyle}>
|
||||||
|
{backBtn()}
|
||||||
|
<span style={{ fontWeight: 700, fontSize: 15, color: T.text }}>Profiel bewerken</span>
|
||||||
|
{closeBtn}
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: "20px 18px", display: "flex", flexDirection: "column", gap: 14 }}>
|
||||||
|
{/* Avatar — klikbaar om foto te uploaden */}
|
||||||
|
<div style={{ display: "flex", justifyContent: "center", marginBottom: 4 }}>
|
||||||
|
<label style={{ cursor: "pointer", position: "relative", display: "block" }} title="Klik om foto te uploaden">
|
||||||
|
<div style={{
|
||||||
|
width: 64, height: 64, borderRadius: "50%", overflow: "hidden",
|
||||||
|
background: avatar ? "transparent" : TEAL,
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
fontSize: 20, fontWeight: 800, color: "#fff",
|
||||||
|
}}>
|
||||||
|
{avatar
|
||||||
|
? <img src={avatar} alt="avatar" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
||||||
|
: initials(currentUser?.naam)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
position: "absolute", inset: 0, borderRadius: "50%",
|
||||||
|
background: "rgba(0,0,0,0.35)", display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
opacity: 0, transition: "opacity 0.15s",
|
||||||
|
}} className="avatar-overlay">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
|
<path d="M13 3H7L5 6H2a1 1 0 00-1 1v9a1 1 0 001 1h16a1 1 0 001-1V7a1 1 0 00-1-1h-3L13 3z" stroke="#fff" strokeWidth="1.5" strokeLinejoin="round"/>
|
||||||
|
<circle cx="10" cy="11" r="2.5" stroke="#fff" strokeWidth="1.5"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
<input type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => {
|
<input type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0]; if (!file) return;
|
||||||
if (!file) return;
|
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (ev) => setAvatar(ev.target.result);
|
reader.onload = (ev) => setAvatar(ev.target.result);
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
}} />
|
}} />
|
||||||
</label>
|
</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>
|
||||||
|
<style>{`.avatar-overlay { opacity: 0 !important; } label:hover .avatar-overlay { opacity: 1 !important; }`}</style>
|
||||||
|
{avatar && (
|
||||||
|
<button onClick={() => setAvatar(null)} style={{
|
||||||
|
alignSelf: "center", padding: "4px 12px", background: "transparent",
|
||||||
|
border: `1px solid ${T.border}`, borderRadius: 8,
|
||||||
|
color: T.muted, fontSize: 12, fontWeight: 600, cursor: "pointer",
|
||||||
|
}}>Foto verwijderen</button>
|
||||||
|
)}
|
||||||
|
<InputField label="Naam" value={naam} onChange={(e) => { setNaam(e.target.value); setProfielOk(false); }} T={T} />
|
||||||
|
<InputField label="E-mail" type="email" value={email} onChange={(e) => { setEmail(e.target.value); setProfielOk(false); }} T={T} />
|
||||||
|
{profielErr && <div style={{ fontSize: 12, color: RED }}>{profielErr}</div>}
|
||||||
|
{profielOk && <div style={{ fontSize: 12, color: "#10b981" }}>Profiel opgeslagen.</div>}
|
||||||
|
<button onClick={saveProfiel} disabled={profielBusy} style={{
|
||||||
|
padding: "11px", background: TEAL,
|
||||||
|
border: "none", borderRadius: 10, color: "#fff", fontWeight: 700,
|
||||||
|
fontSize: 13, cursor: profielBusy ? "default" : "pointer", opacity: profielBusy ? 0.7 : 1,
|
||||||
|
}}>{profielBusy ? "Opslaan…" : "Opslaan"}</button>
|
||||||
</div>
|
</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 */}
|
{/* ── Wachtwoord wijzigen ── */}
|
||||||
{activeTab === "wachtwoord" && (
|
{view === "wachtwoord" && (
|
||||||
<div style={{ padding: "18px 18px 20px", display: "flex", flexDirection: "column", gap: 12 }}>
|
<>
|
||||||
{[
|
<div style={headerStyle}>
|
||||||
{ label: "Huidig wachtwoord", value: huidig, set: setHuidig },
|
{backBtn()}
|
||||||
{ label: "Nieuw wachtwoord", value: nieuw, set: setNieuw },
|
<span style={{ fontWeight: 700, fontSize: 15, color: T.text }}>Wachtwoord wijzigen</span>
|
||||||
{ label: "Bevestig nieuw", value: bevestig, set: setBevestig },
|
{closeBtn}
|
||||||
].map(({ label, value, set }) => (
|
</div>
|
||||||
<div key={label}>
|
<div style={{ padding: "20px 18px", display: "flex", flexDirection: "column", gap: 14 }}>
|
||||||
<label style={{ fontSize: 11, color: T.muted, fontWeight: 700, display: "block", marginBottom: 6, textTransform: "uppercase" }}>{label}</label>
|
<InputField label="Huidig wachtwoord" type="password" value={huidig} onChange={(e) => { setHuidig(e.target.value); setPwOk(false); }} T={T} />
|
||||||
<input type="password" value={value} onChange={(e) => { set(e.target.value); setPwSuccess(false); }} style={pIs} />
|
<InputField label="Nieuw wachtwoord" type="password" value={nieuw} onChange={(e) => { setNieuw(e.target.value); setPwOk(false); }} T={T} />
|
||||||
</div>
|
<InputField label="Bevestig nieuw" type="password" value={bevestig} onChange={(e) => { setBevestig(e.target.value); setPwOk(false); }} T={T} />
|
||||||
))}
|
{pwErr && <div style={{ fontSize: 12, color: RED }}>{pwErr}</div>}
|
||||||
{pwError && <div style={{ fontSize: 12, color: RED }}>{pwError}</div>}
|
{pwOk && <div style={{ fontSize: 12, color: "#10b981" }}>Wachtwoord gewijzigd.</div>}
|
||||||
{pwSuccess && <div style={{ fontSize: 12, color: "#10b981" }}>Wachtwoord gewijzigd.</div>}
|
<button onClick={saveWachtwoord} disabled={pwBusy} style={{
|
||||||
<button onClick={handleChangePassword} disabled={pwLoading} style={{
|
padding: "11px", background: TEAL,
|
||||||
padding: "11px", background: `linear-gradient(135deg, ${PURPLE}, #a855f7)`,
|
border: "none", borderRadius: 10, color: "#fff", fontWeight: 700,
|
||||||
border: "none", borderRadius: 10, color: "#fff", fontWeight: 700,
|
fontSize: 13, cursor: pwBusy ? "default" : "pointer", opacity: pwBusy ? 0.7 : 1,
|
||||||
cursor: pwLoading ? "default" : "pointer", fontSize: 13, opacity: pwLoading ? 0.7 : 1,
|
}}>{pwBusy ? "Opslaan…" : "Opslaan"}</button>
|
||||||
}}>{pwLoading ? "Opslaan…" : "Opslaan"}</button>
|
</div>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ const DragContext = createContext(null);
|
|||||||
// ── VCard ─────────────────────────────────────────────────────────────────────
|
// ── VCard ─────────────────────────────────────────────────────────────────────
|
||||||
export function VCard({ children, style = {} }) {
|
export function VCard({ children, style = {} }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ borderRadius: 12, padding: "16px 18px", ...style }}>
|
<div style={{
|
||||||
|
borderRadius: 12, padding: "16px 18px",
|
||||||
|
boxShadow: "none",
|
||||||
|
...style,
|
||||||
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -59,39 +63,75 @@ export function VProgressBar({ value, max, color = PURPLE }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── DonutChart ────────────────────────────────────────────────────────────────
|
// ── DonutChart ────────────────────────────────────────────────────────────────
|
||||||
export function DonutChart({ segments, total, size = 120 }) {
|
export function DonutChart({ segments, total, size = 120, label, centerVal }) {
|
||||||
const r = size * 0.38, cx = size / 2, cy = size / 2;
|
const strokeW = size * 0.155;
|
||||||
|
const r = (size - strokeW) / 2 - 1;
|
||||||
|
const cx = size / 2, cy = size / 2;
|
||||||
const circ = 2 * Math.PI * r;
|
const circ = 2 * Math.PI * r;
|
||||||
|
const active = segments.filter((s) => s.val > 0);
|
||||||
|
const GAP = active.length > 1 ? circ * 0.022 : 0;
|
||||||
let off = 0;
|
let off = 0;
|
||||||
const slices = segments.filter((s) => s.val > 0).map((s) => {
|
const slices = active.map((s) => {
|
||||||
const dash = (s.val / total) * circ;
|
const raw = (s.val / total) * circ;
|
||||||
const sl = { ...s, dash, gap: circ - dash, offset: off };
|
const dash = Math.max(raw - GAP, 2);
|
||||||
off += dash;
|
const sl = { ...s, dash, offset: off };
|
||||||
|
off += raw;
|
||||||
return sl;
|
return sl;
|
||||||
});
|
});
|
||||||
|
const glowId = `dgl${size}`;
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ overflow: "visible" }}>
|
||||||
|
<defs>
|
||||||
|
<filter id={glowId} x="-40%" y="-40%" width="180%" height="180%">
|
||||||
|
<feGaussianBlur stdDeviation={size * 0.022} />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Dark inner fill */}
|
||||||
|
<circle cx={cx} cy={cy} r={r - strokeW / 2 + 1} fill="rgba(0,0,0,0.55)" />
|
||||||
|
|
||||||
|
{/* Track */}
|
||||||
<circle cx={cx} cy={cy} r={r} fill="none"
|
<circle cx={cx} cy={cy} r={r} fill="none"
|
||||||
stroke="rgba(255,255,255,0.06)" strokeWidth={size * 0.18} />
|
stroke="rgba(255,255,255,0.06)" strokeWidth={strokeW} />
|
||||||
{slices.map((s, i) => {
|
|
||||||
const mid = (s.offset + s.dash / 2) / r - Math.PI / 2;
|
{/* Glow pass */}
|
||||||
const lx = cx + r * Math.cos(mid);
|
{slices.map((s, i) => (
|
||||||
const ly = cy + r * Math.sin(mid);
|
<circle key={`g${i}`} cx={cx} cy={cy} r={r} fill="none"
|
||||||
return (
|
stroke={s.color} strokeWidth={strokeW * 1.5}
|
||||||
<g key={i}>
|
strokeDasharray={`${s.dash} ${circ - s.dash}`}
|
||||||
<circle cx={cx} cy={cy} r={r} fill="none"
|
strokeDashoffset={-s.offset + circ / 4}
|
||||||
stroke={s.color} strokeWidth={size * 0.18}
|
strokeLinecap="round"
|
||||||
strokeDasharray={`${s.dash} ${s.gap}`}
|
opacity="0.18"
|
||||||
strokeDashoffset={-s.offset + circ / 4} />
|
filter={`url(#${glowId})`}
|
||||||
{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)}%
|
{/* Crisp segments */}
|
||||||
</text>
|
{slices.map((s, i) => (
|
||||||
)}
|
<circle key={`c${i}`} cx={cx} cy={cy} r={r} fill="none"
|
||||||
</g>
|
stroke={s.color} strokeWidth={strokeW}
|
||||||
);
|
strokeDasharray={`${s.dash} ${circ - s.dash}`}
|
||||||
})}
|
strokeDashoffset={-s.offset + circ / 4}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Center label */}
|
||||||
|
{label && (
|
||||||
|
<text x={cx} y={cy - size * 0.1} fill="rgba(255,255,255,0.4)"
|
||||||
|
fontSize={size * 0.09} textAnchor="middle" dominantBaseline="middle"
|
||||||
|
fontFamily="inherit">
|
||||||
|
{label}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
{/* Center value */}
|
||||||
|
{centerVal && (
|
||||||
|
<text x={cx} y={cy + size * 0.09} fill="#fff"
|
||||||
|
fontSize={size * 0.155} fontWeight="800" textAnchor="middle" dominantBaseline="middle"
|
||||||
|
fontFamily="inherit">
|
||||||
|
{centerVal}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,11 +34,13 @@ export function AppProvider({ children }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── UI ────────────────────────────────────────────────────────
|
// ── UI ────────────────────────────────────────────────────────
|
||||||
const [darkMode, setDarkMode] = useState(true);
|
const [darkMode, setDarkMode] = useState(true);
|
||||||
const [locked, setLocked] = useState(false);
|
const [locked, setLocked] = useState(false);
|
||||||
const [tab, setTab] = useState(0);
|
const [tab, setTab] = useState(0);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [showUsers, setShowUsers] = useState(false);
|
const [showUsers, setShowUsers] = useState(false);
|
||||||
|
const [showInstellingen, setShowInstellingen] = useState(false);
|
||||||
|
const [idleMinutes, setIdleMinutes] = useState(30);
|
||||||
|
|
||||||
// ── Financiële data ───────────────────────────────────────────
|
// ── Financiële data ───────────────────────────────────────────
|
||||||
const [data, setDataRaw] = useState(INITIAL_DATA);
|
const [data, setDataRaw] = useState(INITIAL_DATA);
|
||||||
@@ -201,6 +203,8 @@ export function AppProvider({ children }) {
|
|||||||
tab, setTab,
|
tab, setTab,
|
||||||
menuOpen, setMenuOpen,
|
menuOpen, setMenuOpen,
|
||||||
showUsers, setShowUsers,
|
showUsers, setShowUsers,
|
||||||
|
showInstellingen, setShowInstellingen,
|
||||||
|
idleMinutes, setIdleMinutes,
|
||||||
T,
|
T,
|
||||||
data, setData,
|
data, setData,
|
||||||
ev, schuld, cats, schuldBlokken, schuldHistory,
|
ev, schuld, cats, schuldBlokken, schuldHistory,
|
||||||
|
|||||||
@@ -4,13 +4,20 @@
|
|||||||
*/
|
*/
|
||||||
export function useTheme(darkMode) {
|
export function useTheme(darkMode) {
|
||||||
return {
|
return {
|
||||||
bg: darkMode ? "#0f1117" : "#f1f5f9",
|
// Exacte Finex kleuren (dark) / licht alternatief
|
||||||
card: darkMode ? "#1a1d27" : "#ffffff",
|
bg: darkMode ? "#241A39" : "#eef1f8",
|
||||||
border: darkMode ? "rgba(255,255,255,0.07)" : "rgba(0,0,0,0.08)",
|
card: darkMode ? "rgba(22,23,27,0.30)" : "#ffffff",
|
||||||
muted: darkMode ? "#6b7280" : "#94a3b8",
|
sidebar: darkMode ? "#16171B" : "#f8faff",
|
||||||
text: darkMode ? "#f1f5f9" : "#0f172a",
|
border: darkMode ? "rgba(139,92,246,0.15)" : "rgba(0,0,0,0.07)",
|
||||||
subtext: darkMode ? "#94a3b8" : "#64748b",
|
muted: darkMode ? "#8b8fa8" : "#94a3b8",
|
||||||
inputBg: darkMode ? "rgba(255,255,255,0.05)" : "#f8fafc",
|
text: darkMode ? "#ffffff" : "#0f172a",
|
||||||
inputBorder: darkMode ? "rgba(255,255,255,0.1)" : "#e2e8f0",
|
subtext: darkMode ? "#a0aec0" : "#64748b",
|
||||||
|
inputBg: darkMode ? "rgba(139,92,246,0.08)" : "#f1f5ff",
|
||||||
|
inputBorder:darkMode ? "rgba(139,92,246,0.2)" : "#e2e8f0",
|
||||||
|
contentBg: darkMode ? "#241A39" : "#eef1f8",
|
||||||
|
shadow: darkMode ? "0 4px 24px rgba(124,58,237,0.25)" : "0 4px 20px rgba(0,0,0,0.07)",
|
||||||
|
cardShadow: darkMode ? "0 4px 20px rgba(124,58,237,0.2)" : "0 2px 8px rgba(0,0,0,0.06)",
|
||||||
|
radius: "14px",
|
||||||
|
font: "'Poppins', system-ui, sans-serif",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { useApp } from "../context/AppContext.jsx";
|
import { useApp } from "../context/AppContext.jsx";
|
||||||
import { fmt, pct, fmtDatum } from "../utils/format.js";
|
import { fmt, pct, fmtDatum } from "../utils/format.js";
|
||||||
import { PURPLE, PURPLE_LIGHT, GREEN, RED } from "../constants/index.js";
|
import { GREEN, RED } from "../constants/index.js";
|
||||||
|
|
||||||
|
const TEAL = "#a855f7";
|
||||||
import { VCard, VBadge } from "../components/ui/index.jsx";
|
import { VCard, VBadge } from "../components/ui/index.jsx";
|
||||||
|
|
||||||
export default function DashboardTab() {
|
export default function DashboardTab() {
|
||||||
@@ -24,9 +27,9 @@ export default function DashboardTab() {
|
|||||||
const progressPct = Math.min(totEV / doel, 1);
|
const progressPct = Math.min(totEV / doel, 1);
|
||||||
const milestones = [0, 0.25, 0.5, 0.75, 1].map((f) => f * doel);
|
const milestones = [0, 0.25, 0.5, 0.75, 1].map((f) => f * doel);
|
||||||
const kpis = [
|
const kpis = [
|
||||||
{ label: "Start eigen vermogen", val: fmt(startEV), accent: PURPLE },
|
{ label: "Start eigen vermogen", val: fmt(startEV), accent: TEAL},
|
||||||
{ label: "Extra geïnvesteerde fiat", val: fmt(extraFiat), accent: PURPLE },
|
{ label: "Extra geïnvesteerde fiat", val: fmt(extraFiat), accent: TEAL},
|
||||||
{ label: "Totaal geïnvesteerde fiat", val: fmt(totaalFiat), accent: PURPLE },
|
{ label: "Totaal geïnvesteerde fiat", val: fmt(totaalFiat), accent: TEAL},
|
||||||
{ label: "Totaal eigen vermogen", val: fmt(totEV), accent: GREEN },
|
{ label: "Totaal eigen vermogen", val: fmt(totEV), accent: GREEN },
|
||||||
{ label: "Winst op investering", val: fmt(winst), accent: winst >= 0 ? GREEN : RED },
|
{ label: "Winst op investering", val: fmt(winst), accent: winst >= 0 ? GREEN : RED },
|
||||||
{ label: "ROI %", val: totaalFiat > 0 ? pct(roi, 2) : "—", accent: roi >= 0 ? GREEN : RED },
|
{ label: "ROI %", val: totaalFiat > 0 ? pct(roi, 2) : "—", accent: roi >= 0 ? GREEN : RED },
|
||||||
@@ -38,14 +41,14 @@ export default function DashboardTab() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ marginBottom: 24 }}>
|
<div style={{ marginBottom: 24 }}>
|
||||||
<div style={{ fontSize: 28, fontWeight: 800, color: T.text, marginBottom: 32 }}>
|
<div style={{ fontSize: 14, color: T.muted, marginBottom: 6 }}>
|
||||||
{dagdeel}{voornaam ? `, ${voornaam}` : ""} 👋
|
Hi{voornaam ? ` ${voornaam},` : ","}
|
||||||
</div>
|
</div>
|
||||||
<h1 style={{ fontSize: 22, fontWeight: 800, margin: 0 }}>
|
<h1 style={{ fontSize: 32, fontWeight: 800, margin: "0 0 32px", color: T.text, letterSpacing: "-0.5px", lineHeight: 1.15 }}>
|
||||||
The Road naar {fmt(doel)}
|
{dagdeel} 👋
|
||||||
</h1>
|
</h1>
|
||||||
<div style={{ color: T.muted, fontSize: 13, marginTop: 2 }}>
|
<div style={{ fontSize: 32, fontWeight: 800, color: T.text, letterSpacing: "-0.5px", lineHeight: 1.15 }}>
|
||||||
Persoonlijk vermogensdashboard
|
The Road naar {fmt(doel)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -57,8 +60,8 @@ export default function DashboardTab() {
|
|||||||
}}>
|
}}>
|
||||||
{kpis.map(({ label, val, accent }) => (
|
{kpis.map(({ label, val, accent }) => (
|
||||||
<VCard key={label} style={{
|
<VCard key={label} style={{
|
||||||
background: T.card, border: `1px solid ${T.border}`,
|
background: `linear-gradient(to right, ${accent}cc 0%, ${accent}44 50%, transparent 80%) bottom / 100% 3px no-repeat, ${T.card}`,
|
||||||
borderLeft: `3px solid ${accent}44`,
|
border: `1px solid ${T.border}`,
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: 11, color: T.muted, marginBottom: 6,
|
fontSize: 11, color: T.muted, marginBottom: 6,
|
||||||
@@ -78,7 +81,7 @@ export default function DashboardTab() {
|
|||||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
<span style={{ fontSize: 16 }}>🎯</span>
|
<span style={{ fontSize: 16 }}>🎯</span>
|
||||||
<span style={{ fontWeight: 600 }}>Doel & Bijbehorende Progressie</span>
|
<span style={{ fontWeight: 600 }}>Doel & Bijbehorende Progressie</span>
|
||||||
<VBadge label={fmt(doel)} color={PURPLE} />
|
<VBadge label={fmt(doel)} color={TEAL} />
|
||||||
</div>
|
</div>
|
||||||
<span style={{ color: T.muted, fontSize: 13 }}>{pct(progressPct)} behaald</span>
|
<span style={{ color: T.muted, fontSize: 13 }}>{pct(progressPct)} behaald</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,7 +93,7 @@ export default function DashboardTab() {
|
|||||||
<div style={{
|
<div style={{
|
||||||
position: "absolute", inset: 0,
|
position: "absolute", inset: 0,
|
||||||
width: `${progressPct * 100}%`,
|
width: `${progressPct * 100}%`,
|
||||||
background: `linear-gradient(90deg,${PURPLE},#a855f7)`,
|
background: "linear-gradient(90deg, #7c3aed, #a855f7)",
|
||||||
borderRadius: 99, transition: "width 0.7s ease",
|
borderRadius: 99, transition: "width 0.7s ease",
|
||||||
}} />
|
}} />
|
||||||
{[0.25, 0.5, 0.75].map((f) => (
|
{[0.25, 0.5, 0.75].map((f) => (
|
||||||
@@ -127,83 +130,129 @@ export default function DashboardTab() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Smooth Catmull-Rom bezier path ──────────────────────────────────────────
|
||||||
|
function crPath(xs, ys, t = 0.4) {
|
||||||
|
if (xs.length < 2) return "";
|
||||||
|
let d = `M ${xs[0]},${ys[0]}`;
|
||||||
|
for (let i = 0; i < xs.length - 1; i++) {
|
||||||
|
const p0 = i > 0 ? [xs[i-1], ys[i-1]] : [xs[i], ys[i]];
|
||||||
|
const p1 = [xs[i], ys[i]];
|
||||||
|
const p2 = [xs[i+1], ys[i+1]];
|
||||||
|
const p3 = i < xs.length - 2 ? [xs[i+2], ys[i+2]] : [xs[i+1], ys[i+1]];
|
||||||
|
const cp1x = (p1[0] + (p2[0] - p0[0]) * t).toFixed(2);
|
||||||
|
const cp1y = (p1[1] + (p2[1] - p0[1]) * t).toFixed(2);
|
||||||
|
const cp2x = (p2[0] - (p3[0] - p1[0]) * t).toFixed(2);
|
||||||
|
const cp2y = (p2[1] - (p3[1] - p1[1]) * t).toFixed(2);
|
||||||
|
d += ` C ${cp1x},${cp1y} ${cp2x},${cp2y} ${p2[0]},${p2[1]}`;
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Mini inline SVG grafiek ────────────────────────────────────────────────────
|
// ── Mini inline SVG grafiek ────────────────────────────────────────────────────
|
||||||
function MiniLineChart({ vData, darkMode }) {
|
function MiniLineChart({ vData, darkMode }) {
|
||||||
const [hovered, setHovered] = useState(null);
|
const [hovered, setHovered] = useState(null);
|
||||||
const W = 700, H = 230, pl = 58, pr = 16, pt = 12, pb = 62;
|
const W = 700, H = 230, pl = 62, pr = 16, pt = 16, pb = 52;
|
||||||
const vals = vData.map((d) => d.nettoWaarde || 0);
|
const vals = vData.map((d) => d.nettoWaarde || 0);
|
||||||
const mn = Math.min(...vals) * 0.95;
|
const mn = Math.min(...vals) * 0.95;
|
||||||
const mx = Math.max(...vals) * 1.05;
|
const mx = Math.max(...vals) * 1.05;
|
||||||
const rng = mx - mn || 1;
|
const rng = mx - mn || 1;
|
||||||
const iW = W - pl - pr, iH = H - pt - pb;
|
const iW = W - pl - pr, iH = H - pt - pb;
|
||||||
const xs = vData.map((_, i) => pl + (i / Math.max(vData.length - 1, 1)) * iW);
|
const xs = vData.map((_, i) => pl + (i / Math.max(vData.length - 1, 1)) * iW);
|
||||||
const ys = vals.map((v) => pt + (1 - (v - mn) / rng) * iH);
|
const ys = vals.map((v) => pt + (1 - (v - mn) / rng) * iH);
|
||||||
const line = xs.map((x, i) => `${x},${ys[i]}`).join(" ");
|
const linePath = crPath(xs, ys);
|
||||||
const area = `${xs[0]},${pt + iH} ${line} ${xs[xs.length - 1]},${pt + iH}`;
|
const areaPath = `${linePath} L ${xs[xs.length-1]},${pt+iH} L ${xs[0]},${pt+iH} Z`;
|
||||||
const gc = darkMode ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.06)";
|
const gc = darkMode ? "rgba(255,255,255,0.07)" : "rgba(0,0,0,0.06)";
|
||||||
const lc = darkMode ? "#6b7280" : "#94a3b8";
|
const lc = darkMode ? "#6b7280" : "#94a3b8";
|
||||||
|
|
||||||
|
const handleMouseMove = (e) => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const svgX = ((e.clientX - rect.left) / rect.width) * W;
|
||||||
|
const dists = xs.map((x) => Math.abs(x - svgX));
|
||||||
|
setHovered(dists.indexOf(Math.min(...dists)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const h = hovered;
|
||||||
|
const tipW = 114, tipH = 46;
|
||||||
|
const tipX = h !== null ? Math.min(Math.max(xs[h] - tipW / 2, pl), W - tipW - 4) : 0;
|
||||||
|
const tipY = h !== null ? (ys[h] - tipH - 14 < pt ? ys[h] + 14 : ys[h] - tipH - 14) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height: "auto", display: "block" }}>
|
<svg
|
||||||
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
|
style={{ width: "100%", height: "auto", display: "block", cursor: "crosshair" }}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseLeave={() => setHovered(null)}
|
||||||
|
>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="evDashFill" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="chartLineGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
<stop offset="0%" stopColor={PURPLE} stopOpacity="0.35" />
|
<stop offset="0%" stopColor="#0eddd4" />
|
||||||
<stop offset="100%" stopColor={PURPLE} stopOpacity="0.02" />
|
<stop offset="100%" stopColor="#8b5cf6" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
<linearGradient id="chartFadeMask" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="white" stopOpacity="1" />
|
||||||
|
<stop offset="100%" stopColor="white" stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
<mask id="chartAreaMask">
|
||||||
|
<rect x={pl} y={pt} width={iW} height={iH} fill="url(#chartFadeMask)" />
|
||||||
|
</mask>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
|
{/* Gestippelde gridlijnen */}
|
||||||
{[0, 0.25, 0.5, 0.75, 1].map((f) => {
|
{[0, 0.25, 0.5, 0.75, 1].map((f) => {
|
||||||
const gy = pt + (1 - f) * iH;
|
const gy = pt + (1 - f) * iH;
|
||||||
return (
|
return (
|
||||||
<g key={f}>
|
<g key={f}>
|
||||||
<line x1={pl} y1={gy} x2={W - pr} y2={gy} stroke={gc} strokeWidth="1" />
|
<line x1={pl} y1={gy} x2={W - pr} y2={gy}
|
||||||
<text x={pl - 5} y={gy + 4} fill={lc} fontSize="9" textAnchor="end">
|
stroke={gc} strokeWidth="1" strokeDasharray="4,4" />
|
||||||
|
<text x={pl - 6} y={gy + 4} fill={lc} fontSize="9" textAnchor="end">
|
||||||
{fmt(mn + f * rng)}
|
{fmt(mn + f * rng)}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<polygon points={area} fill="url(#evDashFill)" />
|
|
||||||
<polyline points={line} fill="none" stroke={PURPLE_LIGHT}
|
{/* Vlakkleur met gradient + fade */}
|
||||||
strokeWidth="2.5" strokeLinejoin="round" strokeLinecap="round" />
|
<path d={areaPath} fill="url(#chartLineGrad)" mask="url(#chartAreaMask)" opacity="0.4" />
|
||||||
{xs.map((x, i) => {
|
|
||||||
const isHov = hovered === i;
|
{/* Lijn */}
|
||||||
const tipW = 90, tipH = 22;
|
<path d={linePath} fill="none" stroke="url(#chartLineGrad)"
|
||||||
const tipX = Math.min(Math.max(x - tipW / 2, pl), W - tipW - 4);
|
strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
const tipY = ys[i] - tipH - 8 < pt ? ys[i] + 10 : ys[i] - tipH - 8;
|
|
||||||
return (
|
{/* Verticale hover-lijn */}
|
||||||
<g key={i}
|
{h !== null && (
|
||||||
onMouseEnter={() => setHovered(i)}
|
<line x1={xs[h]} y1={pt} x2={xs[h]} y2={pt + iH}
|
||||||
onMouseLeave={() => setHovered(null)}
|
stroke="rgba(255,255,255,0.25)" strokeWidth="1" strokeDasharray="4,3" />
|
||||||
style={{ cursor: "pointer" }}
|
)}
|
||||||
>
|
|
||||||
{/* Vergroot klikgebied */}
|
{/* Hover punt */}
|
||||||
<circle cx={x} cy={ys[i]} r="14" fill="transparent" />
|
{h !== null && (
|
||||||
<circle cx={x} cy={ys[i]} r={isHov ? 6 : 4}
|
<circle cx={xs[h]} cy={ys[h]} r="5.5"
|
||||||
fill={PURPLE_LIGHT}
|
fill="#0eddd4" stroke={darkMode ? "#151c2e" : "#fff"} strokeWidth="2.5" />
|
||||||
stroke={darkMode ? "#1a1d27" : "#ffffff"}
|
)}
|
||||||
strokeWidth="2" />
|
|
||||||
<text
|
{/* Tooltip */}
|
||||||
x={x} y={H - pb + 16} fill={lc} fontSize="8"
|
{h !== null && (
|
||||||
textAnchor="end"
|
<g>
|
||||||
transform={`rotate(-45, ${x}, ${H - pb + 16})`}
|
<rect x={tipX} y={tipY} width={tipW} height={tipH} rx="7"
|
||||||
>
|
fill={darkMode ? "#1a2035" : "#fff"}
|
||||||
{fmtDatum(vData[i].datum)}
|
stroke="rgba(14,221,212,0.35)" strokeWidth="1" />
|
||||||
</text>
|
<text x={tipX + tipW / 2} y={tipY + 16} fill={lc} fontSize="9" textAnchor="middle">
|
||||||
{/* Tooltip */}
|
{fmtDatum(vData[h].datum)}
|
||||||
{isHov && (
|
</text>
|
||||||
<g>
|
<text x={tipX + tipW / 2} y={tipY + 34} fill="#0eddd4" fontSize="11"
|
||||||
<rect x={tipX} y={tipY} width={tipW} height={22}
|
fontWeight="700" textAnchor="middle">
|
||||||
rx="5" fill={darkMode ? "#1e2130" : "#fff"}
|
{fmt(vals[h])}
|
||||||
stroke={PURPLE} strokeWidth="1" />
|
</text>
|
||||||
<text x={tipX + tipW / 2} y={tipY + 15} fill={PURPLE_LIGHT} fontSize="8"
|
</g>
|
||||||
fontWeight="700" textAnchor="middle">
|
)}
|
||||||
{fmt(vals[i])}
|
|
||||||
</text>
|
{/* X-as labels */}
|
||||||
</g>
|
{xs.map((x, i) => (
|
||||||
)}
|
<text key={i} x={x} y={H - pb + 16} fill={lc} fontSize="8"
|
||||||
</g>
|
textAnchor="end" transform={`rotate(-45, ${x}, ${H - pb + 16})`}>
|
||||||
);
|
{fmtDatum(vData[i].datum)}
|
||||||
})}
|
</text>
|
||||||
|
))}
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -535,14 +535,11 @@ export default function EigenVermogenTab() {
|
|||||||
{totVerdeling > 0 && (
|
{totVerdeling > 0 && (
|
||||||
<div style={{
|
<div style={{
|
||||||
background: T.card, border: `1px solid ${T.border}`,
|
background: T.card, border: `1px solid ${T.border}`,
|
||||||
borderTop: `3px solid ${GREEN}`, borderRadius: 12,
|
borderRadius: 16, padding: "20px",
|
||||||
padding: "16px 18px", flex: 1, minWidth: 200,
|
flex: 1, minWidth: 200,
|
||||||
display: "flex", flexDirection: "column", alignItems: "center",
|
display: "flex", flexDirection: "column", alignItems: "center",
|
||||||
}}>
|
}}>
|
||||||
<div style={{ fontWeight: 700, fontSize: 12, letterSpacing: "0.05em", color: T.text, marginBottom: 12, alignSelf: "flex-start" }}>
|
<DonutChart segments={catTotals.map((c) => ({ val: c.tot, color: c.color }))} total={totVerdeling} size={200} label="Verdeling" centerVal={fmt(totVerdeling)} />
|
||||||
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" }}>
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px 14px", marginTop: 12, justifyContent: "center" }}>
|
||||||
{catTotals.map((c) => (
|
{catTotals.map((c) => (
|
||||||
<div key={c.id} style={{ display: "flex", alignItems: "center", gap: 5 }}>
|
<div key={c.id} style={{ display: "flex", alignItems: "center", gap: 5 }}>
|
||||||
|
|||||||
@@ -370,16 +370,13 @@ export default function SchuldTab() {
|
|||||||
{/* Donut */}
|
{/* Donut */}
|
||||||
<div style={{
|
<div style={{
|
||||||
background: T.card, border: `1px solid ${T.border}`,
|
background: T.card, border: `1px solid ${T.border}`,
|
||||||
borderTop: `3px solid ${RED}`, borderRadius: 12,
|
borderRadius: 16, padding: "20px",
|
||||||
padding: "16px 18px", flex: 1, minWidth: 200,
|
flex: 1, minWidth: 200,
|
||||||
display: "flex", flexDirection: "column", alignItems: "center",
|
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
|
<DonutChart
|
||||||
segments={schuldCats.map((b) => ({ val: b.subtot, color: b.color }))}
|
segments={schuldCats.map((b) => ({ val: b.subtot, color: b.color }))}
|
||||||
total={donutTotal} size={160}
|
total={donutTotal} size={200} label="Totaal schuld" centerVal={fmt(donutTotal)}
|
||||||
/>
|
/>
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px 14px", marginTop: 12, justifyContent: "center" }}>
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px 14px", marginTop: 12, justifyContent: "center" }}>
|
||||||
{schuldCats.map((b) => (
|
{schuldCats.map((b) => (
|
||||||
|
|||||||
@@ -346,9 +346,8 @@ export default function VoortgangTab() {
|
|||||||
</VCard>
|
</VCard>
|
||||||
|
|
||||||
{/* Donut */}
|
{/* Donut */}
|
||||||
<VCard style={{ background: T.card, border: `1px solid ${T.border}`, borderTop: `3px solid ${PURPLE}`, display: "flex", flexDirection: "column", alignItems: "center" }}>
|
<VCard style={{ background: T.card, border: `1px solid ${T.border}`, borderRadius: 16, 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={200} label="Netto waarde" centerVal={fmt(totEV - totSchuld)} />
|
||||||
<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", gap: 20, marginTop: 12, justifyContent: "center" }}>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
<div style={{ width: 10, height: 10, borderRadius: "50%", background: GREEN }} />
|
<div style={{ width: 10, height: 10, borderRadius: "50%", background: GREEN }} />
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "vibefinance",
|
"name": "vibefinance",
|
||||||
"version": "0.1.4",
|
"version": "0.1.5-dev",
|
||||||
"description": "VibeFinance — persoonlijk vermogensbeheer",
|
"description": "VibeFinance — persoonlijk vermogensbeheer",
|
||||||
"private": true,
|
"private": true,
|
||||||
"repository": "https://vibehoogie.duckdns.org/vibe/VibeFinance",
|
"repository": "https://vibehoogie.duckdns.org/vibe/VibeFinance",
|
||||||
|
|||||||
Reference in New Issue
Block a user