first commit

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

View File

@@ -0,0 +1,222 @@
import { createContext, useContext, useState, useMemo, useRef } from "react";
import JSZip from "jszip";
import { INITIAL_DATA } from "../constants/index.js";
import { migrateData } from "../utils/storage.js";
import { useTheme } from "../hooks/useTheme.js";
const AppContext = createContext(null);
// ── API helpers ───────────────────────────────────────────────────────────────
const getToken = () => localStorage.getItem("vf_token");
const authHdrs = () => ({ "Content-Type": "application/json", Authorization: `Bearer ${getToken()}` });
async function apiFetch(path, options = {}) {
const res = await fetch(`/api${path}`, { ...options, headers: { ...authHdrs(), ...(options.headers || {}) } });
if (!res.ok) throw new Error(`API ${path}${res.status}`);
return res.json();
}
// ── Provider ──────────────────────────────────────────────────────────────────
export function AppProvider({ children }) {
// ── Auth ──────────────────────────────────────────────────────
const [loggedIn, setLoggedIn] = useState(false);
const [currentUser, setCurrentUser] = useState(null);
const [loading, setLoading] = useState(false);
const [avatar, setAvatarRaw] = useState(null);
const setAvatar = async (base64) => {
setAvatarRaw(base64);
try {
await apiFetch("/auth/me/avatar", { method: "PATCH", body: JSON.stringify({ avatar: base64 ?? null }) });
} catch (err) {
console.error("Avatar opslaan mislukt:", err);
}
};
// ── UI ────────────────────────────────────────────────────────
const [darkMode, setDarkMode] = useState(true);
const [locked, setLocked] = useState(false);
const [tab, setTab] = useState(0);
const [menuOpen, setMenuOpen] = useState(false);
const [showUsers, setShowUsers] = useState(false);
// ── Financiële data ───────────────────────────────────────────
const [data, setDataRaw] = useState(INITIAL_DATA);
const [evRows, setEvRowsRaw] = useState([]);
const [evVoortgangHistory, setEvVoortgangHistoryRaw] = useState([]);
const saveTimer = useRef(null);
const saveToApi = (newData, newEvRows, newEvVH) => {
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(async () => {
try {
await apiFetch("/data", {
method: "PUT",
body: JSON.stringify({ ...newData, evVoortgang: newEvRows, evVoortgangHistory: newEvVH }),
});
} catch (err) {
console.error("Opslaan mislukt:", err);
}
}, 800);
};
const setData = (next) => {
const d = typeof next === "function" ? next(data) : next;
setDataRaw(d);
saveToApi(d, evRows, evVoortgangHistory);
};
const setEvRows = (rows) => {
const r = typeof rows === "function" ? rows(evRows) : rows;
setEvRowsRaw(r);
saveToApi(data, r, evVoortgangHistory);
};
const setEvVoortgangHistory = (history) => {
const h = typeof history === "function" ? history(evVoortgangHistory) : history;
setEvVoortgangHistoryRaw(h);
saveToApi(data, evRows, h);
};
// ── Gebruikers ────────────────────────────────────────────────
const [users, setUsersRaw] = useState(() => {
try { return JSON.parse(localStorage.getItem("wbjw_users")) || []; } catch { return []; }
});
const setUsers = (u) => { localStorage.setItem("wbjw_users", JSON.stringify(u)); setUsersRaw(u); };
// ── Thema ─────────────────────────────────────────────────────
const T = useTheme(darkMode);
// ── Afgeleide waarden ─────────────────────────────────────────
const ev = data.eigenVermogen;
const schuld = data.schuld;
const cats = ev?.categories || [];
const schuldBlokken = schuld?.blokken || [];
const schuldHistory = schuld?.schuldHistory || [];
const totEV = useMemo(
() => cats.reduce((a, c) => a + c.items.reduce((b, i) => b + (i.waarde || 0), 0), 0),
[cats]
);
const totSchuld = useMemo(
() => schuldBlokken.reduce((a, b) => a + b.items.reduce((c, i) => c + (i.waarde || 0), 0), 0),
[schuldBlokken]
);
const catTotals = useMemo(
() => cats.map((c) => ({ ...c, tot: c.items.reduce((a, i) => a + (i.waarde || 0), 0) })),
[cats]
);
// ── Auth handlers ─────────────────────────────────────────────
const login = async (user) => {
setCurrentUser(user);
setLoggedIn(true);
setAvatarRaw(user.avatar ?? null);
setLoading(true);
try {
const apiData = await apiFetch("/data");
const migrated = migrateData({
eigenVermogen: apiData.eigenVermogen,
schuld: apiData.schuld,
});
setDataRaw(migrated);
setEvRowsRaw(apiData.evVoortgang || []);
setEvVoortgangHistoryRaw(apiData.evVoortgangHistory || []);
} catch (err) {
console.error("Data laden mislukt:", err);
} finally {
setLoading(false);
}
};
const updateProfile = async (naam, email) => {
const res = await apiFetch("/auth/me", { method: "PATCH", body: JSON.stringify({ naam, email }) });
localStorage.setItem("vf_token", res.token);
setCurrentUser(res.user);
};
const changePassword = async (huidig, nieuw) => {
await apiFetch("/auth/me/password", { method: "POST", body: JSON.stringify({ huidig, nieuw }) });
};
const logout = () => {
setCurrentUser(null);
setAvatarRaw(null);
setLoggedIn(false);
setDataRaw(INITIAL_DATA);
setEvRowsRaw([]);
setEvVoortgangHistoryRaw([]);
localStorage.removeItem("vf_token");
if (saveTimer.current) clearTimeout(saveTimer.current);
};
// ── Backup / restore ──────────────────────────────────────────
const doBackup = async () => {
const zip = new JSZip();
const datum = new Date().toISOString().slice(0, 10);
zip.file("data.json", JSON.stringify({ ...data, evVoortgang: evRows, evVoortgangHistory }, null, 2));
zip.file("meta.json", JSON.stringify({ versie: 1, datum, app: "vibefinance", gebruiker: currentUser?.email }, null, 2));
const blob = await zip.generateAsync({ type: "blob", compression: "DEFLATE" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `vibefinance_backup_${datum}.zip`;
a.click();
URL.revokeObjectURL(url);
};
const doRestore = () => {
const input = document.createElement("input");
input.type = "file";
input.accept = ".zip";
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const zip = await JSZip.loadAsync(file);
const entry = zip.file("data.json");
if (!entry) throw new Error("Geen data.json gevonden in backup.");
const parsed = JSON.parse(await entry.async("string"));
const migrated = migrateData({ eigenVermogen: parsed.eigenVermogen, schuld: parsed.schuld });
const newEvRows = parsed.evVoortgang || [];
const newEvVH = parsed.evVoortgangHistory || [];
setDataRaw(migrated);
setEvRowsRaw(newEvRows);
setEvVoortgangHistoryRaw(newEvVH);
await apiFetch("/data", {
method: "PUT",
body: JSON.stringify({ ...migrated, evVoortgang: newEvRows, evVoortgangHistory: newEvVH }),
});
} catch (err) { alert("Ongeldig of beschadigd backup-bestand: " + err.message); }
};
input.click();
};
return (
<AppContext.Provider value={{
loggedIn, currentUser, login, logout, loading, updateProfile, changePassword,
avatar, setAvatar,
darkMode, setDarkMode,
locked, setLocked,
tab, setTab,
menuOpen, setMenuOpen,
showUsers, setShowUsers,
T,
data, setData,
ev, schuld, cats, schuldBlokken, schuldHistory,
totEV, totSchuld, catTotals,
evRows, setEvRows,
evVoortgangHistory, setEvVoortgangHistory,
users, setUsers,
doBackup, doRestore,
}}>
{children}
</AppContext.Provider>
);
}
export const useApp = () => {
const ctx = useContext(AppContext);
if (!ctx) throw new Error("useApp must be used inside <AppProvider>");
return ctx;
};