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