const { useEffect, useMemo, useState } = React; const API_BASE = "/api"; const monthLabels = [ "Jan", "Fev", "Mar", "Avr", "Mai", "Juin", "Juil", "Aout", "Sep", "Oct", "Nov", "Dec" ]; function fetchJson(path) { return fetch(path).then(async (r) => { if (!r.ok) { throw new Error(`Erreur chargement ${path}`); } return r.json(); }); } function apiFetch(path, token, options = {}) { const headers = Object.assign( { "Content-Type": "application/json" }, options.headers || {} ); if (token) headers.Authorization = `Bearer ${token}`; return fetch(`${API_BASE}${path}`, { ...options, headers }).then(async (r) => { if (!r.ok) { const msg = await r.text(); throw new Error(msg || "Erreur API"); } return r.json(); }); } function formatMoney(n) { return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0, }).format(n || 0); } function formatInt(n) { return new Intl.NumberFormat("fr-FR").format(n || 0); } function App() { const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [token, setToken] = useState(localStorage.getItem("sf_token") || ""); const [authUser, setAuthUser] = useState(null); const [centers, setCenters] = useState([]); const [kpis, setKpis] = useState([]); const [budgets, setBudgets] = useState([]); const [loginId, setLoginId] = useState(""); const [loginPwd, setLoginPwd] = useState(""); const [loginError, setLoginError] = useState(""); const [demoRoles, setDemoRoles] = useState([]); const [demoUsers, setDemoUsers] = useState([]); const [demoRole, setDemoRole] = useState(""); const [demoUserId, setDemoUserId] = useState(""); const [demoError, setDemoError] = useState(""); const [selectedYear, setSelectedYear] = useState(2025); const [selectedCenter, setSelectedCenter] = useState("ALL"); const [showPwdModal, setShowPwdModal] = useState(false); const [pwdOld, setPwdOld] = useState(""); const [pwdNew, setPwdNew] = useState(""); const [pwdError, setPwdError] = useState(""); useEffect(() => { if (!token) { setLoading(false); return; } apiFetch("/me", token) .then((u) => { setAuthUser(u); if (u.center_id && u.center_id !== "*") { setSelectedCenter(u.center_id); } if (u.must_change_password) { setShowPwdModal(true); } }) .catch(() => { setToken(""); localStorage.removeItem("sf_token"); }) .finally(() => setLoading(false)); }, [token]); useEffect(() => { Promise.all([fetchJson("/config/roles.json"), fetchJson("/config/users.json")]) .then(([rolesJson, usersJson]) => { setDemoRoles(rolesJson); setDemoUsers(usersJson); const firstRole = rolesJson.find((r) => usersJson.some((u) => u.role_id === r.role_id) ); const fallbackUser = usersJson[0]; if (firstRole) { setDemoRole(firstRole.role_id); const firstUserForRole = usersJson.find((u) => u.role_id === firstRole.role_id); setDemoUserId(firstUserForRole ? firstUserForRole.user_id : ""); } else if (fallbackUser) { setDemoRole(fallbackUser.role_id); setDemoUserId(fallbackUser.user_id); } setDemoError(""); }) .catch(() => { setDemoError("Mode demo indisponible (configuration non chargee)"); }); }, []); useEffect(() => { if (!token) return; apiFetch("/centers", token) .then(setCenters) .catch((e) => setError(e.message || "Erreur chargement centres")); }, [token]); useEffect(() => { if (!token) return; setLoading(true); Promise.all([ apiFetch(`/kpi?year=${selectedYear}¢er=${selectedCenter}`, token), apiFetch(`/budgets?year=${selectedYear}¢er=${selectedCenter}`, token), ]) .then(([k, b]) => { const parsedKpis = k.map((r) => ({ ...r, revenue: Number(r.revenue), expenses: Number(r.expenses), margin: Number(r.margin), jobs_leak_detection: Number(r.jobs_leak_detection), jobs_networks: Number(r.jobs_networks), incident_rate: Number(r.incident_rate), sla_compliance: Number(r.sla_compliance), year: Number(r.date.slice(0, 4)), month: Number(r.date.slice(5, 7)), })); setKpis(parsedKpis); setBudgets( b.map((r) => ({ ...r, year: Number(r.year), budget_revenue: Number(r.budget_revenue), budget_expenses: Number(r.budget_expenses), target_margin_pct: Number(r.target_margin_pct), })) ); setError(""); }) .catch((e) => setError(e.message || "Erreur chargement donnees")) .finally(() => setLoading(false)); }, [token, selectedYear, selectedCenter]); const years = useMemo(() => { return [2023, 2024, 2025]; }, []); const demoRoleOptions = useMemo(() => { if (!demoRoles.length || !demoUsers.length) return []; return demoRoles.filter((r) => demoUsers.some((u) => u.role_id === r.role_id)); }, [demoRoles, demoUsers]); const demoUsersForRole = useMemo(() => { if (!demoUsers.length) return []; return demoUsers .filter((u) => !demoRole || u.role_id === demoRole) .sort((a, b) => a.user_id.localeCompare(b.user_id)); }, [demoUsers, demoRole]); useEffect(() => { if (!demoUsersForRole.length) { setDemoUserId(""); return; } if (!demoUsersForRole.some((u) => u.user_id === demoUserId)) { setDemoUserId(demoUsersForRole[0].user_id); } }, [demoUsersForRole, demoUserId]); const monthly = useMemo(() => { const map = new Map(); for (let m = 1; m <= 12; m++) { map.set(m, { month: m, revenue: 0, expenses: 0, margin: 0, jobs: 0, sla_sum: 0, sla_count: 0, }); } kpis.forEach((k) => { if (k.year !== selectedYear) return; const bucket = map.get(k.month); bucket.revenue += k.revenue; bucket.expenses += k.expenses; bucket.margin += k.margin; bucket.jobs += k.jobs_leak_detection + k.jobs_networks; bucket.sla_sum += k.sla_compliance; bucket.sla_count += 1; }); return Array.from(map.values()).map((b) => ({ ...b, sla: b.sla_count ? b.sla_sum / b.sla_count : 0, })); }, [kpis, selectedYear]); const totals = useMemo(() => { const t = monthly.reduce( (acc, m) => { acc.revenue += m.revenue; acc.expenses += m.expenses; acc.margin += m.margin; acc.jobs += m.jobs; acc.sla_sum += m.sla; acc.sla_count += 1; return acc; }, { revenue: 0, expenses: 0, margin: 0, jobs: 0, sla_sum: 0, sla_count: 0 } ); return { ...t, margin_pct: t.revenue ? (t.margin / t.revenue) * 100 : 0, sla: t.sla_count ? t.sla_sum / t.sla_count : 0, }; }, [monthly]); const budget = useMemo(() => { if (!budgets.length) return { revenue: 0, expenses: 0, target_margin_pct: 0 }; const total = budgets.reduce( (acc, b) => { acc.revenue += b.budget_revenue; acc.expenses += b.budget_expenses; acc.target_margin_pct += b.target_margin_pct; return acc; }, { revenue: 0, expenses: 0, target_margin_pct: 0 } ); return { revenue: total.revenue, expenses: total.expenses, target_margin_pct: total.target_margin_pct / budgets.length, }; }, [budgets]); const alerts = useMemo(() => { const lowMarginMonths = monthly.filter((m) => m.margin < 0).length; const lowSlaMonths = monthly.filter((m) => m.sla < 90).length; return { lowMarginMonths, lowSlaMonths }; }, [monthly]); const maxMargin = useMemo(() => { return Math.max(...monthly.map((m) => Math.abs(m.margin)), 1); }, [monthly]); const loginWithCredentials = async (userId, password) => { try { const res = await apiFetch("/auth/login", "", { method: "POST", body: JSON.stringify({ user_id: userId, password }), }); localStorage.setItem("sf_token", res.token); setToken(res.token); setAuthUser(res.user); setLoginError(""); if (res.user.center_id && res.user.center_id !== "*") { setSelectedCenter(res.user.center_id); } else { setSelectedCenter("ALL"); } if (res.user.must_change_password) { setShowPwdModal(true); } } catch (e) { setLoginError("Identifiants invalides"); } }; const handleLogin = async (e) => { e.preventDefault(); await loginWithCredentials(loginId, loginPwd); }; const handleDemoLogin = async () => { const selectedDemoUser = demoUsers.find((u) => u.user_id === demoUserId); if (!selectedDemoUser) { setLoginError("Profil demo introuvable"); return; } setLoginId(selectedDemoUser.user_id); setLoginPwd(selectedDemoUser.password); await loginWithCredentials(selectedDemoUser.user_id, selectedDemoUser.password); }; const handleChangePassword = async (e) => { e.preventDefault(); setPwdError(""); try { await apiFetch("/auth/change-password", token, { method: "POST", body: JSON.stringify({ old_password: pwdOld, new_password: pwdNew }), }); setShowPwdModal(false); setPwdOld(""); setPwdNew(""); } catch (e) { setPwdError("Impossible de changer le mot de passe"); } }; const handleLogout = () => { setAuthUser(null); setToken(""); localStorage.removeItem("sf_token"); }; if (loading && !authUser) { return (
Chargement...
); } if (!authUser) { return (
Suivi financier

Application de pilotage pour 10 centres de profits

{loginError &&
{loginError}
}
Mode demo: test des roles
{demoError &&
{demoError}
}
Version securisee avec mot de passe modifiable.
); } return (
Suivi financier
Centre de profits | Detection fuites & reseaux
{authUser.user_id}
{authUser.role_id}
Annee
Centre
Periode {selectedYear}-01 a {selectedYear}-12
{error && (
{error}
)}
Chiffre d'affaires
{formatMoney(totals.revenue)}
Budget: {formatMoney(budget.revenue)}
Charges
{formatMoney(totals.expenses)}
Budget: {formatMoney(budget.expenses)}
Marge
{formatMoney(totals.margin)}
{totals.margin_pct.toFixed(1)}% | Cible {budget.target_margin_pct.toFixed(1)}%
Interventions
{formatInt(totals.jobs)}
Fuites + Reseaux
SLA moyen
{totals.sla.toFixed(1)}%
Seuil interne: 92%
Alertes
{alerts.lowMarginMonths + alerts.lowSlaMonths}
{alerts.lowMarginMonths} mois marge negative, {alerts.lowSlaMonths} mois SLA < 90%
Marge mensuelle
{monthly.map((m, idx) => { const height = Math.round((Math.abs(m.margin) / maxMargin) * 100); const negative = m.margin < 0; return (
{monthLabels[idx]}
); })}
Detail mensuel
Mois
CA
Charges
Marge
Interventions
SLA
{monthly.map((m, idx) => (
{monthLabels[idx]}
{formatMoney(m.revenue)}
{formatMoney(m.expenses)}
{formatMoney(m.margin)}
{formatInt(m.jobs)}
{m.sla.toFixed(1)}%
))}
{showPwdModal && (
Changer le mot de passe
{pwdError &&
{pwdError}
}
)}
); } ReactDOM.createRoot(document.getElementById("root")).render();