Initial commit

This commit is contained in:
Olivier CROGUENNEC
2026-06-13 14:57:15 +02:00
commit 48ed7fe65e
209 changed files with 49979 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
import { createContext, useContext, useEffect, useState } from 'react';
import { api } from '../api.js';
const AuthCtx = createContext(null);
export function AuthProvider({ children }) {
const [token, setToken] = useState(() => localStorage.getItem('cl_token'));
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!token) { setLoading(false); return; }
api.get('/auth/me')
.then(({ user }) => setUser(user))
.catch(() => logout())
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
const login = async (email, password) => {
const r = await api.post('/auth/login', { email, password });
localStorage.setItem('cl_token', r.token);
setToken(r.token);
setUser(r.user);
return r.user;
};
const register = async (email, password, displayName) => {
const r = await api.post('/auth/register', { email, password, displayName });
localStorage.setItem('cl_token', r.token);
setToken(r.token);
setUser(r.user);
return r.user;
};
const logout = () => {
localStorage.removeItem('cl_token');
localStorage.removeItem('cl_investisseur_id');
setToken(null);
setUser(null);
};
const updateUser = async (payload) => {
const r = await api.put('/auth/me', payload);
setUser(r.user);
// If backend issued a fresh token (email changed), persist it
if (r.token) {
localStorage.setItem('cl_token', r.token);
setToken(r.token);
}
return r.user;
};
const isAdmin = user?.role === 'admin';
return (
<AuthCtx.Provider value={{ token, user, loading, login, register, logout, updateUser, isAdmin }}>
{children}
</AuthCtx.Provider>
);
}
export const useAuth = () => useContext(AuthCtx);
@@ -0,0 +1,202 @@
import { createContext, useContext, useEffect, useState, useMemo, useCallback } from 'react';
import { api } from '../api.js';
import { useUi } from './UiContext.jsx';
const Ctx = createContext(null);
const MOIS = ['Jan','Fév','Mar','Avr','Mai','Jun','Jul','Aoû','Sep','Oct','Nov','Déc'];
const MOIS_LONG = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
export function InteretsChartProvider({ children, netMode, pfuRates, activeView, activeId }) {
const { chartInterets, chartCapital, chartCashback } = useUi();
const today = new Date();
const currentYear = today.getFullYear();
const currentMonth = today.getMonth() + 1;
// ── État partagé ────────────────────────────────────────────────
const [annee, setAnnee] = useState(currentYear);
const [inclureInterets, setInclureInterets] = useState(true);
const [inclureCapital, setInclureCapital] = useState(false);
const [inclureCashback, setInclureCashback] = useState(false);
const [selectedMonth, setSelectedMonth] = useState(null); // 0-11 | null
const [rawData, setRawData] = useState({ rembourses: [], projections: [], annees: [] });
// ── Mode global (TOUT) ──────────────────────────────────────────
const [modeGlobal, setModeGlobal] = useState(false);
const [selectedYear, setSelectedYear] = useState(null); // year number | null
const [rawDataGlobal, setRawDataGlobal] = useState({ rembourses: [], projections: [], annees: [] });
const toggleModeGlobal = useCallback(() => {
setModeGlobal(prev => {
if (!prev) setSelectedYear(null); // reset sélection en entrant dans le mode
return !prev;
});
}, []);
// Filtres reçu / projeté (force reçu si les deux seraient désactivés)
const [showActual, setShowActualRaw] = useState(true);
const [showProjected, setShowProjected] = useState(true);
const setShowActual = useCallback((val) => {
setShowActualRaw(prev => (val === false || val === prev ? (!prev && !showProjected ? true : val === false ? false : !prev) : val));
}, [showProjected]);
const toggleActual = useCallback(() => setShowActualRaw(prev => (!prev && !showProjected) ? true : !prev), [showProjected]);
const toggleProjected = useCallback(() => setShowProjected(prev => !prev), []);
const selectActualOnly = useCallback(() => { setShowActualRaw(true); setShowProjected(false); }, []);
const selectProjectedOnly = useCallback(() => { setShowActualRaw(false); setShowProjected(true); }, []);
const setActualProjected = useCallback((actual, projected) => { setShowActualRaw(actual); setShowProjected(projected); }, []);
// ── Fetch mensuel ────────────────────────────────────────────────
useEffect(() => {
const params = { annee, ...(activeView === 'all' ? { scope: 'all' } : {}) };
api.get('/dashboard/interets-mensuels', params)
.then(res => setRawData(res))
.catch(() => {});
}, [annee, activeView, activeId]);
// ── Fetch annuel (global) ────────────────────────────────────────
useEffect(() => {
const params = activeView === 'all' ? { scope: 'all' } : {};
api.get('/dashboard/interets-annuels', params)
.then(res => setRawDataGlobal(res))
.catch(() => {});
}, [activeView, activeId]);
// Réinitialiser la sélection au changement d'année
useEffect(() => { setSelectedMonth(null); }, [annee]);
// ── PFU helper ───────────────────────────────────────────────────
const getPfuReduction = useCallback((year) => {
if (!pfuRates?.length) return 0;
const r = pfuRates.find(r => r.annee === year)
?? pfuRates.reduce((best, r) => r.annee > best.annee ? r : best, pfuRates[0]);
return (r.prelev_sociaux + r.impot_revenu) / 100;
}, [pfuRates]);
// ── Calcul mensuel ───────────────────────────────────────────────
const months = useMemo(() => {
const reduction = getPfuReduction(annee);
return Array.from({ length: 12 }, (_, i) => {
const m = i + 1;
const moisStr = `${annee}-${String(m).padStart(2,'0')}`;
const isCurrent = annee === currentYear && m === currentMonth;
const isFuture = annee > currentYear || (annee === currentYear && m > currentMonth);
const rembData = rawData.rembourses?.find(r => r.mois === moisStr);
const projData = rawData.projections?.find(p => p.mois === moisStr);
const rawInterets = rembData ? (netMode ? (rembData.interets_nets||0) : (rembData.interets_bruts||0)) : 0;
const rawInteretsProj = projData && (isCurrent||isFuture)
? (netMode ? (projData.interets_prevus||0)*(1-reduction) : (projData.interets_prevus||0)) : 0;
const cashbackAmt = inclureCashback ? (rembData?.cashback||0) : 0;
const capitalAmt = inclureCapital ? (rembData?.capital||0) : 0;
const interetsAmt = inclureInterets ? rawInterets : 0;
const capitalProjAmt = inclureCapital && projData && (isCurrent||isFuture) ? (projData.capital_prevu||0) : 0;
const interetsProjAmt= inclureInterets ? rawInteretsProj : 0;
return {
m, idx: i,
label: MOIS[i], labelLong: MOIS_LONG[i],
actual: capitalAmt + cashbackAmt + interetsAmt,
projected: capitalProjAmt + interetsProjAmt,
total: capitalAmt + cashbackAmt + interetsAmt + capitalProjAmt + interetsProjAmt,
isCurrentMonth: isCurrent, isFuture,
cashbackAmt, capitalAmt, interetsAmt, capitalProjAmt, interetsProjAmt,
};
});
}, [rawData, netMode, getPfuReduction, annee, currentYear, currentMonth,
inclureInterets, inclureCapital, inclureCashback]);
const annualTotal = useMemo(() => months.reduce((s, m) => s + m.total, 0), [months]);
const availableYears = rawData.annees ?? [];
// ── Calcul annuel (mode global) ──────────────────────────────────
const years = useMemo(() => {
// Collecter toutes les années connues (rembourses + projections + annees)
const allKnown = new Set([
...(rawDataGlobal.annees ?? []),
...(rawDataGlobal.rembourses ?? []).map(r => r.annee),
...(rawDataGlobal.projections ?? []).map(p => p.annee),
]);
// Toujours inclure l'année courante
allKnown.add(currentYear);
const allSorted = [...allKnown].map(Number).sort((a, b) => a - b);
// Algorithme de fenêtrage
const pastYears = allSorted.filter(y => y < currentYear);
const futureYears = allSorted.filter(y => y > currentYear);
const pastCount = Math.min(pastYears.length, 8); // currentYear en position max 9
const pastSlice = pastYears.slice(-pastCount);
const usedSlots = pastCount + 1; // +1 for currentYear
const projCount = Math.max(3, 12 - usedSlots);
const futureSlice = futureYears.slice(0, projCount);
const visibleYears = [...pastSlice, currentYear, ...futureSlice];
return visibleYears.map(y => {
const isCurrent = y === currentYear;
const isFuture = y > currentYear;
const reduction = getPfuReduction(y);
const rembData = rawDataGlobal.rembourses?.find(r => Number(r.annee) === y);
const projData = rawDataGlobal.projections?.find(p => Number(p.annee) === y);
const rawInterets = rembData ? (netMode ? (rembData.interets_nets||0) : (rembData.interets_bruts||0)) : 0;
const rawInteretsProj = projData
? (netMode ? (projData.interets_prevus||0)*(1-reduction) : (projData.interets_prevus||0)) : 0;
const cashbackAmt = inclureCashback ? (rembData?.cashback||0) : 0;
const capitalAmt = inclureCapital ? (rembData?.capital||0) : 0;
const interetsAmt = inclureInterets ? rawInterets : 0;
const capitalProjAmt = inclureCapital ? (projData?.capital_prevu||0) : 0;
const interetsProjAmt = inclureInterets ? rawInteretsProj : 0;
return {
y,
label: String(y),
isCurrent, isFuture,
actual: capitalAmt + cashbackAmt + interetsAmt,
projected: capitalProjAmt + interetsProjAmt,
total: capitalAmt + cashbackAmt + interetsAmt + capitalProjAmt + interetsProjAmt,
cashbackAmt, capitalAmt, interetsAmt, capitalProjAmt, interetsProjAmt,
};
});
}, [rawDataGlobal, netMode, getPfuReduction, currentYear,
inclureInterets, inclureCapital, inclureCashback]);
const globalTotal = useMemo(() => years.reduce((s, y) => s + y.total, 0), [years]);
return (
<Ctx.Provider value={{
// Année
annee, setAnnee, availableYears,
// Toggles types
inclureInterets, setInclureInterets,
inclureCapital, setInclureCapital,
inclureCashback, setInclureCashback,
// Mois sélectionné (liaison bar ↔ donut)
selectedMonth, setSelectedMonth,
// Filtres reçu / projeté
showActual, toggleActual,
showProjected, toggleProjected,
selectActualOnly, selectProjectedOnly, setActualProjected,
// Mode global (TOUT)
modeGlobal, toggleModeGlobal,
selectedYear, setSelectedYear,
years, globalTotal,
// Données
months, annualTotal,
rawData, rawDataGlobal,
currentYear, currentMonth,
// Affichage
netMode,
// Couleurs palette
chartInterets, chartCapital, chartCashback,
}}>
{children}
</Ctx.Provider>
);
}
export const useInteretsChart = () => useContext(Ctx);
@@ -0,0 +1,72 @@
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
import { api } from '../api.js';
import { useAuth } from './AuthContext.jsx';
const InvCtx = createContext(null);
const KEY_ID = 'cl_investisseur_id';
const KEY_VIEW = 'cl_active_view'; // 'all' | '<id>'
export function InvestisseurProvider({ children }) {
const { token } = useAuth();
const [investisseurs, setInvestisseurs] = useState([]);
const [activeId, setActiveIdState] = useState(() => Number(localStorage.getItem(KEY_ID)) || null);
const [activeView, setActiveViewState] = useState(() => localStorage.getItem(KEY_VIEW) || 'all');
const [loading, setLoading] = useState(false);
const reload = useCallback(async () => {
if (!token) return;
setLoading(true);
try {
const list = await api.get('/investisseurs');
setInvestisseurs(list);
// S'assurer que activeId pointe sur un membre valide
if (list.length && (!activeId || !list.find(i => i.id === activeId))) {
const first = list[0].id;
setActiveIdState(first);
localStorage.setItem(KEY_ID, String(first));
}
} finally {
setLoading(false);
}
}, [token, activeId]);
useEffect(() => {
if (token) reload();
else setInvestisseurs([]);
}, [token, reload]);
/** Changer le membre actif pour les requêtes (topbar) */
const setActive = (id) => {
setActiveIdState(id);
localStorage.setItem(KEY_ID, String(id));
};
/** Changer la vue du profil : 'all' ou id numérique d'un investisseur */
const setActiveView = (v) => {
setActiveViewState(v);
localStorage.setItem(KEY_VIEW, String(v));
// Si on sélectionne un membre précis → l'activer aussi pour les requêtes
if (v !== 'all') {
setActiveIdState(Number(v));
localStorage.setItem(KEY_ID, String(v));
}
};
const active = investisseurs.find(i => i.id === activeId) || null;
const activeViewMember = activeView !== 'all'
? investisseurs.find(i => i.id === Number(activeView)) || null
: null;
return (
<InvCtx.Provider value={{
investisseurs, active, activeId, setActive,
activeView, activeViewMember, setActiveView,
reload, loading,
}}>
{children}
</InvCtx.Provider>
);
}
export const useInvestisseur = () => useContext(InvCtx);
+58
View File
@@ -0,0 +1,58 @@
import { createContext, useContext, useEffect, useMemo, useState, useCallback } from 'react';
/**
* ThemeContext
* - mode : 'light' | 'dark' | 'system' (préférence utilisateur, persistée)
* - resolved : 'light' | 'dark' (thème effectivement appliqué)
*
* Le thème effectif est appliqué via l'attribut data-theme sur <html>.
* En mode 'system', on écoute les changements du media query prefers-color-scheme.
*/
const ThemeCtx = createContext(null);
const STORAGE_KEY = 'cl_theme';
function getSystemTheme() {
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function applyTheme(theme) {
const root = document.documentElement;
root.setAttribute('data-theme', theme);
// Inform the browser (form controls, scrollbars) of the active scheme
root.style.colorScheme = theme;
}
export function ThemeProvider({ children }) {
const [mode, setMode] = useState(() => localStorage.getItem(STORAGE_KEY) || 'system');
const [systemTheme, setSystemTheme] = useState(getSystemTheme);
// Listen to OS preference changes when in 'system' mode
useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const onChange = (e) => setSystemTheme(e.matches ? 'dark' : 'light');
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}, []);
const resolved = mode === 'system' ? systemTheme : mode;
// Apply the resolved theme to <html>
useEffect(() => { applyTheme(resolved); }, [resolved]);
const setThemeMode = useCallback((m) => {
setMode(m);
localStorage.setItem(STORAGE_KEY, m);
}, []);
const value = useMemo(
() => ({ mode, resolved, setMode: setThemeMode }),
[mode, resolved, setThemeMode],
);
return <ThemeCtx.Provider value={value}>{children}</ThemeCtx.Provider>;
}
export const useTheme = () => useContext(ThemeCtx);
+165
View File
@@ -0,0 +1,165 @@
import { createContext, useContext, useEffect, useState, useCallback, useRef } from 'react';
import { api } from '../api.js';
/**
* UI state that persists across sessions:
* - sidebarCollapsed : sidebar visibility
* - fontScale : 'large' (max/accessibilité) | 'medium' | 'compact'
*/
const UiCtx = createContext(null);
const KEY_SIDEBAR = 'cl_ui_sidebar_collapsed';
const KEY_FONT = 'cl_fontscale';
const KEY_LANGUE = 'cl_langue';
const KEY_DEVISE = 'cl_devise';
const KEY_DISPLAY = 'cl_display_mode';
const KEY_CHART_INTERETS = 'cl_chart_interets';
const KEY_CHART_CAPITAL = 'cl_chart_capital';
const KEY_CHART_CASHBACK = 'cl_chart_cashback';
const KEY_PFO_ASSUJETTI = 'cl_pfo_assujetti';
const FONT_SIZES = ['large', 'medium', 'compact'];
const LANGUES = ['fr', 'en'];
const DEVISES = ['EUR', 'USD', 'GBP', 'CHF', 'CAD', 'SGD'];
const DEFAULT_CHART_INTERETS = '#2196f3'; // Blue 500
const DEFAULT_CHART_CAPITAL = '#4caf50'; // Green 500
const DEFAULT_CHART_CASHBACK = '#ffc107'; // Amber 500
function applyFontScale(scale) {
document.documentElement.setAttribute('data-fontsize', scale);
}
export function UiProvider({ children }) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(
() => localStorage.getItem(KEY_SIDEBAR) === '1'
);
const [fontScale, setFontScaleState] = useState(() => {
const saved = localStorage.getItem(KEY_FONT);
return FONT_SIZES.includes(saved) ? saved : 'large';
});
const [langue, setLangueState] = useState(() => {
const saved = localStorage.getItem(KEY_LANGUE);
return LANGUES.includes(saved) ? saved : 'fr';
});
const [devise, setDeviseState] = useState(() => {
const saved = localStorage.getItem(KEY_DEVISE);
return DEVISES.includes(saved) ? saved : 'EUR';
});
const [displayMode, setDisplayModeState] = useState(() => {
const saved = localStorage.getItem(KEY_DISPLAY);
return saved === 'brut' ? 'brut' : 'net';
});
const [chartInterets, setChartInteretsState] = useState(() =>
localStorage.getItem(KEY_CHART_INTERETS) || DEFAULT_CHART_INTERETS
);
const [chartCapital, setChartCapitalState] = useState(() =>
localStorage.getItem(KEY_CHART_CAPITAL) || DEFAULT_CHART_CAPITAL
);
const [chartCashback, setChartCashbackState] = useState(() =>
localStorage.getItem(KEY_CHART_CASHBACK) || DEFAULT_CHART_CASHBACK
);
const [pfoAssujetti, setPfoAssujettiState] = useState(() =>
localStorage.getItem(KEY_PFO_ASSUJETTI) === '1'
);
// ── Sync DB → localStorage au montage ──────────────────────────
// La DB fait foi : ses valeurs écrasent le localStorage si présentes.
useEffect(() => {
api.get('/preferences').then(prefs => {
if (prefs.chart_interets) {
localStorage.setItem(KEY_CHART_INTERETS, prefs.chart_interets);
setChartInteretsState(prefs.chart_interets);
}
if (prefs.chart_capital) {
localStorage.setItem(KEY_CHART_CAPITAL, prefs.chart_capital);
setChartCapitalState(prefs.chart_capital);
}
if (prefs.chart_cashback) {
localStorage.setItem(KEY_CHART_CASHBACK, prefs.chart_cashback);
setChartCashbackState(prefs.chart_cashback);
}
if (prefs.pfo_assujetti !== undefined) {
const val = prefs.pfo_assujetti === '1';
localStorage.setItem(KEY_PFO_ASSUJETTI, val ? '1' : '0');
setPfoAssujettiState(val);
}
}).catch(() => {
// Silencieux : token absent au premier rendu, on garde le localStorage
});
}, []);
// ── Helper : persist une pref en DB (silencieux en cas d'erreur) ─
const persistPref = useCallback((key, value) => {
api.patch('/preferences', { [key]: value }).catch(() => {});
}, []);
useEffect(() => {
localStorage.setItem(KEY_SIDEBAR, sidebarCollapsed ? '1' : '0');
}, [sidebarCollapsed]);
useEffect(() => {
applyFontScale(fontScale);
localStorage.setItem(KEY_FONT, fontScale);
}, [fontScale]);
// Apply on mount
useEffect(() => { applyFontScale(fontScale); }, []);
const toggleSidebar = useCallback(() => setSidebarCollapsed((v) => !v), []);
const setFontScale = useCallback((s) => {
if (FONT_SIZES.includes(s)) setFontScaleState(s);
}, []);
const setLangue = useCallback((l) => {
if (LANGUES.includes(l)) { setLangueState(l); localStorage.setItem(KEY_LANGUE, l); }
}, []);
const setDevise = useCallback((d) => {
if (DEVISES.includes(d)) { setDeviseState(d); localStorage.setItem(KEY_DEVISE, d); }
}, []);
const setDisplayMode = useCallback((m) => {
if (m === 'net' || m === 'brut') {
setDisplayModeState(m);
localStorage.setItem(KEY_DISPLAY, m);
}
}, []);
const setChartInterets = useCallback((hex) => {
setChartInteretsState(hex);
localStorage.setItem(KEY_CHART_INTERETS, hex);
persistPref('chart_interets', hex);
}, [persistPref]);
const setChartCapital = useCallback((hex) => {
setChartCapitalState(hex);
localStorage.setItem(KEY_CHART_CAPITAL, hex);
persistPref('chart_capital', hex);
}, [persistPref]);
const setChartCashback = useCallback((hex) => {
setChartCashbackState(hex);
localStorage.setItem(KEY_CHART_CASHBACK, hex);
persistPref('chart_cashback', hex);
}, [persistPref]);
const setPfoAssujetti = useCallback((val) => {
setPfoAssujettiState(val);
localStorage.setItem(KEY_PFO_ASSUJETTI, val ? '1' : '0');
persistPref('pfo_assujetti', val ? '1' : '0');
}, [persistPref]);
return (
<UiCtx.Provider value={{ sidebarCollapsed, setSidebarCollapsed, toggleSidebar, fontScale, setFontScale, langue, setLangue, devise, setDevise, displayMode, setDisplayMode, chartInterets, setChartInterets, chartCapital, setChartCapital, chartCashback, setChartCashback, pfoAssujetti, setPfoAssujetti }}>
{children}
</UiCtx.Provider>
);
}
export const useUi = () => useContext(UiCtx);