2026-09-04 09:19:14 +02:00
|
|
|
import "./App.css";
|
|
|
|
|
import { AppProvider, useApp } from "./context/AppContext.jsx";
|
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
|
|
|
import LoginPage from "./components/LoginPage.jsx";
|
|
|
|
|
import NavBar from "./components/NavBar.jsx";
|
|
|
|
|
import GebruikersBeheer from "./components/GebruikersBeheer.jsx";
|
|
|
|
|
import DashboardTab from "./pages/DashboardTab.jsx";
|
|
|
|
|
import EigenVermogenTab from "./pages/EigenVermogenTab.jsx";
|
|
|
|
|
import SchuldTab from "./pages/SchuldTab.jsx";
|
|
|
|
|
import VoortgangTab from "./pages/VoortgangTab.jsx";
|
|
|
|
|
import { RED, PURPLE_LIGHT } from "./constants/index.js";
|
|
|
|
|
import { fmt } from "./utils/format.js";
|
2026-09-09 12:43:11 +02:00
|
|
|
import { version } from "./version.js";
|
2026-09-04 09:19:23 +02:00
|
|
|
import { useUpdateCheck } from "./hooks/useUpdateCheck.js";
|
2026-09-04 09:19:14 +02:00
|
|
|
import ProfielPopup from "./components/ProfielPopup.jsx";
|
|
|
|
|
import InstellingenModal from "./components/InstellingenModal.jsx";
|
|
|
|
|
|
|
|
|
|
function initials(naam = "") {
|
|
|
|
|
const parts = naam.trim().split(/\s+/);
|
|
|
|
|
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
|
|
|
|
return naam.slice(0, 2).toUpperCase();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const PAGES = [DashboardTab, EigenVermogenTab, SchuldTab, VoortgangTab];
|
|
|
|
|
|
|
|
|
|
function AppInner() {
|
|
|
|
|
const { loggedIn, login, logout, locked, tab, showUsers, setShowUsers, showInstellingen, setShowInstellingen, loading, T, currentUser, avatar, idleMinutes, totEV } = useApp();
|
|
|
|
|
const [profielOpen, setProfielOpen] = useState(false);
|
|
|
|
|
const [searchOpen, setSearchOpen] = useState(false);
|
2026-09-04 09:19:23 +02:00
|
|
|
const nieuweVersie = useUpdateCheck(); // { latest, naam, notes, url } of null
|
2026-09-04 09:19:14 +02:00
|
|
|
const [bellOpen, setBellOpen] = useState(false);
|
|
|
|
|
const searchRef = useRef(null);
|
|
|
|
|
|
|
|
|
|
const idleTimer = useRef(null);
|
|
|
|
|
const IDLE_MS = (idleMinutes ?? 30) * 60 * 1000;
|
|
|
|
|
const [sessionVerlopen, setSessionVerlopen] = useState(false);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!loggedIn) return;
|
|
|
|
|
const reset = () => {
|
|
|
|
|
clearTimeout(idleTimer.current);
|
|
|
|
|
idleTimer.current = setTimeout(() => {
|
|
|
|
|
logout();
|
|
|
|
|
setSessionVerlopen(true);
|
|
|
|
|
}, IDLE_MS);
|
|
|
|
|
};
|
|
|
|
|
const events = ["mousemove", "mousedown", "keydown", "touchstart", "scroll"];
|
|
|
|
|
events.forEach((e) => window.addEventListener(e, reset, { passive: true }));
|
|
|
|
|
reset();
|
|
|
|
|
return () => {
|
|
|
|
|
events.forEach((e) => window.removeEventListener(e, reset));
|
|
|
|
|
clearTimeout(idleTimer.current);
|
|
|
|
|
};
|
|
|
|
|
}, [loggedIn]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handler = (e) => {
|
|
|
|
|
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setSearchOpen(true);
|
|
|
|
|
setTimeout(() => searchRef.current?.focus(), 50);
|
|
|
|
|
}
|
|
|
|
|
if (e.key === "Escape") setSearchOpen(false);
|
|
|
|
|
};
|
|
|
|
|
window.addEventListener("keydown", handler);
|
|
|
|
|
return () => window.removeEventListener("keydown", handler);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
if (!loggedIn) {
|
|
|
|
|
if (sessionVerlopen) {
|
|
|
|
|
return (
|
|
|
|
|
<div style={{
|
|
|
|
|
minHeight: "100vh", background: T.bg, color: T.text,
|
|
|
|
|
display: "flex", alignItems: "center", justifyContent: "center",
|
|
|
|
|
fontFamily: "'Inter', system-ui, sans-serif",
|
|
|
|
|
}}>
|
|
|
|
|
<div style={{
|
|
|
|
|
background: T.card, border: `1px solid ${T.border}`,
|
|
|
|
|
borderRadius: 20, padding: "40px 36px", maxWidth: 360, width: "100%",
|
|
|
|
|
textAlign: "center", boxShadow: "0 16px 48px rgba(0,0,0,0.4)",
|
|
|
|
|
}}>
|
|
|
|
|
<div style={{ fontSize: 52, marginBottom: 16 }}>⏱</div>
|
|
|
|
|
<div style={{ fontSize: 20, fontWeight: 800, marginBottom: 10 }}>Sessie verlopen</div>
|
|
|
|
|
<div style={{ fontSize: 14, color: T.muted, marginBottom: 28, lineHeight: 1.6 }}>
|
|
|
|
|
Je bent automatisch uitgelogd na 30 minuten inactiviteit. Klik hieronder om opnieuw in te loggen.
|
|
|
|
|
</div>
|
|
|
|
|
<button onClick={() => { setSessionVerlopen(false); }} style={{
|
|
|
|
|
width: "100%", padding: "14px",
|
|
|
|
|
background: "linear-gradient(135deg, #8b5cf6, #a855f7)",
|
|
|
|
|
border: "none", borderRadius: 12,
|
|
|
|
|
color: "#fff", fontWeight: 700, fontSize: 15, cursor: "pointer",
|
|
|
|
|
boxShadow: "0 4px 16px rgba(139,92,246,0.4)",
|
|
|
|
|
}}>
|
|
|
|
|
Opnieuw inloggen
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return <LoginPage onLogin={login} />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
return (
|
|
|
|
|
<div style={{
|
|
|
|
|
minHeight: "100vh", background: T.bg, color: T.text,
|
|
|
|
|
display: "flex", alignItems: "center", justifyContent: "center",
|
|
|
|
|
flexDirection: "column", gap: 16,
|
|
|
|
|
fontFamily: "'Inter', system-ui, sans-serif",
|
|
|
|
|
}}>
|
|
|
|
|
<div style={{
|
|
|
|
|
width: 40, height: 40, borderRadius: "50%",
|
|
|
|
|
border: `3px solid rgba(139,92,246,0.2)`,
|
|
|
|
|
borderTop: `3px solid ${PURPLE_LIGHT}`,
|
|
|
|
|
animation: "spin 0.8s linear infinite",
|
|
|
|
|
}} />
|
|
|
|
|
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
|
|
|
|
<div style={{ color: T.muted, fontSize: 14 }}>Data laden…</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ActivePage = PAGES[tab] ?? DashboardTab;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ display: "flex", minHeight: "100vh", background: T.bg, color: T.text, fontFamily: T.font }}>
|
|
|
|
|
<style>{`
|
|
|
|
|
* { box-sizing: border-box; }
|
|
|
|
|
input[type=number]::-webkit-inner-spin-button { opacity: 0.4; }
|
|
|
|
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
|
|
|
|
::-webkit-scrollbar-track { background: transparent; }
|
|
|
|
|
::-webkit-scrollbar-thumb { background: rgba(128,128,128,0.2); border-radius: 99px; }
|
|
|
|
|
::-webkit-scrollbar-thumb:hover { background: rgba(128,128,128,0.35); }
|
|
|
|
|
`}</style>
|
|
|
|
|
|
|
|
|
|
<NavBar />
|
|
|
|
|
|
|
|
|
|
{/* Hoofdinhoud */}
|
|
|
|
|
<div style={{ flex: 1, overflowY: "auto", maxHeight: "100vh", background: T.contentBg ?? T.bg }}>
|
|
|
|
|
{/* Topbalk */}
|
|
|
|
|
<div style={{
|
|
|
|
|
display: "flex", alignItems: "center", gap: 12,
|
|
|
|
|
padding: "10px 24px",
|
|
|
|
|
background: T.bg,
|
|
|
|
|
position: "sticky", top: 0, zIndex: 50,
|
|
|
|
|
}}>
|
|
|
|
|
<div style={{ flex: 1 }} />
|
|
|
|
|
|
|
|
|
|
{/* Notificatiebel */}
|
|
|
|
|
<div style={{ position: "relative" }}>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => nieuweVersie && setBellOpen(o => !o)}
|
|
|
|
|
style={{
|
|
|
|
|
width: 38, height: 38, borderRadius: "50%",
|
|
|
|
|
background: T.inputBg, border: `1px solid ${T.border}`,
|
|
|
|
|
display: "flex", alignItems: "center", justifyContent: "center",
|
|
|
|
|
cursor: nieuweVersie ? "pointer" : "default", color: T.subtext, flexShrink: 0,
|
|
|
|
|
}}>
|
|
|
|
|
<svg width="17" height="17" viewBox="0 0 16 16" fill="none">
|
|
|
|
|
<path d="M8 1.5A4.5 4.5 0 003.5 6v2.5L2 10h12l-1.5-1.5V6A4.5 4.5 0 008 1.5z" stroke="currentColor" strokeWidth="1.4"/>
|
|
|
|
|
<path d="M6.5 12.5a1.5 1.5 0 003 0" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/>
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{/* Badge */}
|
|
|
|
|
{nieuweVersie && (
|
|
|
|
|
<div style={{
|
|
|
|
|
position: "absolute", top: 1, right: 1,
|
|
|
|
|
width: 13, height: 13, borderRadius: "50%",
|
|
|
|
|
background: "#f97316", border: `2px solid ${T.sidebar ?? T.card}`,
|
|
|
|
|
display: "flex", alignItems: "center", justifyContent: "center",
|
|
|
|
|
fontSize: 7, fontWeight: 700, color: "#fff",
|
|
|
|
|
}}>1</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Popup */}
|
|
|
|
|
{bellOpen && nieuweVersie && (
|
|
|
|
|
<>
|
|
|
|
|
<div onClick={() => setBellOpen(false)} style={{ position: "fixed", inset: 0, zIndex: 190 }} />
|
|
|
|
|
<div style={{
|
|
|
|
|
position: "absolute", right: 0, top: "calc(100% + 10px)",
|
|
|
|
|
zIndex: 200, width: 280,
|
2026-09-04 09:19:23 +02:00
|
|
|
background: T.popupBg, border: `1px solid ${T.border}`,
|
2026-09-04 09:19:14 +02:00
|
|
|
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 }}>
|
2026-09-04 09:19:23 +02:00
|
|
|
v{nieuweVersie.latest} is uitgebracht. Je gebruikt momenteel v{version}.
|
2026-09-04 09:19:14 +02:00
|
|
|
</div>
|
2026-09-04 09:19:23 +02:00
|
|
|
{nieuweVersie.notes && (
|
|
|
|
|
<div style={{
|
|
|
|
|
fontSize: 11, color: T.subtext, marginTop: 6, lineHeight: 1.5,
|
|
|
|
|
maxHeight: 130, overflowY: "auto", whiteSpace: "pre-wrap",
|
|
|
|
|
}}>
|
|
|
|
|
{nieuweVersie.notes}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{nieuweVersie.url && (
|
|
|
|
|
<a href={nieuweVersie.url} target="_blank" rel="noreferrer" style={{
|
|
|
|
|
display: "inline-block", marginTop: 8,
|
|
|
|
|
fontSize: 11, fontWeight: 700, color: "#f97316", textDecoration: "none",
|
|
|
|
|
}}>
|
|
|
|
|
Release bekijken →
|
|
|
|
|
</a>
|
|
|
|
|
)}
|
2026-09-04 09:19:14 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{/* Gebruikersprofiel */}
|
|
|
|
|
<div style={{ position: "relative", flexShrink: 0 }}>
|
|
|
|
|
<button onClick={() => setProfielOpen((o) => !o)} style={{
|
|
|
|
|
display: "flex", alignItems: "center", gap: 10,
|
|
|
|
|
background: "transparent", border: "none",
|
|
|
|
|
borderRadius: 12, cursor: "pointer", padding: "4px 8px 4px 4px",
|
|
|
|
|
transition: "background 0.15s",
|
|
|
|
|
}}
|
|
|
|
|
onMouseEnter={e => e.currentTarget.style.background = T.inputBg}
|
|
|
|
|
onMouseLeave={e => e.currentTarget.style.background = "transparent"}
|
|
|
|
|
>
|
|
|
|
|
<div style={{
|
|
|
|
|
width: 36, height: 36, borderRadius: "50%", overflow: "hidden", flexShrink: 0,
|
|
|
|
|
background: avatar ? "transparent" : "linear-gradient(135deg, #7c3aed, #a855f7)",
|
|
|
|
|
display: "flex", alignItems: "center", justifyContent: "center",
|
|
|
|
|
fontSize: 12, fontWeight: 800, color: "#fff",
|
|
|
|
|
border: `2px solid ${T.border}`,
|
|
|
|
|
}}>
|
|
|
|
|
{avatar
|
|
|
|
|
? <img src={avatar} alt="avatar" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
|
|
|
|
: initials(currentUser?.naam || "")
|
|
|
|
|
}
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ textAlign: "left" }}>
|
|
|
|
|
<div style={{ fontSize: 13, fontWeight: 600, color: T.text, whiteSpace: "nowrap", lineHeight: 1.3 }}>
|
|
|
|
|
{currentUser?.naam || "Gebruiker"}
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ fontSize: 11, color: T.muted, whiteSpace: "nowrap", lineHeight: 1.3 }}>
|
|
|
|
|
{currentUser?.email || ""}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<svg width="12" height="12" viewBox="0 0 10 10" fill="none" style={{ flexShrink: 0, color: T.muted }}>
|
|
|
|
|
<polyline points="1,3 5,7 9,3" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
{profielOpen && <ProfielPopup onClose={() => setProfielOpen(false)} />}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Vergrendeld banner */}
|
|
|
|
|
{locked && (
|
|
|
|
|
<div style={{
|
|
|
|
|
position: "sticky", top: 16, zIndex: 150,
|
|
|
|
|
margin: "16px auto 0", maxWidth: 400,
|
|
|
|
|
background: "rgba(239,68,68,0.15)",
|
|
|
|
|
border: "1px solid rgba(239,68,68,0.4)",
|
|
|
|
|
borderRadius: 99, padding: "8px 20px",
|
|
|
|
|
display: "flex", alignItems: "center", gap: 8,
|
|
|
|
|
backdropFilter: "blur(8px)",
|
|
|
|
|
}}>
|
|
|
|
|
<span style={{ fontSize: 14 }}>🔒</span>
|
|
|
|
|
<span style={{ fontSize: 13, color: RED, fontWeight: 600 }}>
|
|
|
|
|
Vergrendeld — ga naar Gebruikers om te ontgrendelen
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Modals */}
|
|
|
|
|
{showUsers && <GebruikersBeheer onClose={() => setShowUsers(false)} />}
|
|
|
|
|
{showInstellingen && <InstellingenModal onClose={() => setShowInstellingen(false)} />}
|
|
|
|
|
|
|
|
|
|
{/* Actieve pagina */}
|
|
|
|
|
<div style={{ maxWidth: 1200, margin: "0 auto", padding: "28px 24px" }}>
|
|
|
|
|
<ActivePage />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function App() {
|
|
|
|
|
return (
|
|
|
|
|
<AppProvider>
|
|
|
|
|
<AppInner />
|
|
|
|
|
</AppProvider>
|
|
|
|
|
);
|
|
|
|
|
}
|