diff --git a/client/src/App.tsx b/client/src/App.tsx index 9d83cf5..3d20637 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -7,12 +7,15 @@ import NotFound from "@/pages/NotFound"; import { Route, Switch } from "wouter"; import ErrorBoundary from "./components/ErrorBoundary"; import { ThemeProvider } from "./contexts/ThemeContext"; +import { ParametresProvider } from "./contexts/ParametresContext"; import Home from "./pages/Home"; +import Parametres from "./pages/Parametres"; function Router() { return ( + @@ -23,10 +26,12 @@ function App() { return ( - - - - + + + + + + ); diff --git a/client/src/contexts/ParametresContext.tsx b/client/src/contexts/ParametresContext.tsx new file mode 100644 index 0000000..017decf --- /dev/null +++ b/client/src/contexts/ParametresContext.tsx @@ -0,0 +1,80 @@ +// ParametresContext.tsx — Contexte global des paramètres de calcul +// Persiste les valeurs dans localStorage pour les conserver entre sessions + +import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'; + +export interface Parametres { + seuilFixesAns: number; + seuilPortablesAns: number; + coutFixe: number; + coutPortable: number; +} + +const DEFAULTS: Parametres = { + seuilFixesAns: 5, + seuilPortablesAns: 4, + coutFixe: 450, + coutPortable: 1000, +}; + +const STORAGE_KEY = 'itinova_budget_parametres'; + +function loadFromStorage(): Parametres { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return DEFAULTS; + const parsed = JSON.parse(raw) as Partial; + return { + seuilFixesAns: typeof parsed.seuilFixesAns === 'number' ? parsed.seuilFixesAns : DEFAULTS.seuilFixesAns, + seuilPortablesAns: typeof parsed.seuilPortablesAns === 'number' ? parsed.seuilPortablesAns : DEFAULTS.seuilPortablesAns, + coutFixe: typeof parsed.coutFixe === 'number' ? parsed.coutFixe : DEFAULTS.coutFixe, + coutPortable: typeof parsed.coutPortable === 'number' ? parsed.coutPortable : DEFAULTS.coutPortable, + }; + } catch { + return DEFAULTS; + } +} + +interface ParametresContextType { + parametres: Parametres; + setParametres: (p: Parametres) => void; + resetParametres: () => void; +} + +const ParametresContext = createContext(null); + +export function ParametresProvider({ children }: { children: ReactNode }) { + const [parametres, setParametresState] = useState(loadFromStorage); + + const setParametres = useCallback((p: Parametres) => { + setParametresState(p); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(p)); + } catch { + // ignore + } + }, []); + + const resetParametres = useCallback(() => { + setParametresState(DEFAULTS); + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + // ignore + } + }, []); + + return ( + + {children} + + ); +} + +export function useParametres() { + const ctx = useContext(ParametresContext); + if (!ctx) throw new Error('useParametres must be used within ParametresProvider'); + return ctx; +} + +export { DEFAULTS as PARAMETRES_DEFAULTS }; diff --git a/client/src/hooks/useBudgetData.ts b/client/src/hooks/useBudgetData.ts index 7b0de1c..791f894 100644 --- a/client/src/hooks/useBudgetData.ts +++ b/client/src/hooks/useBudgetData.ts @@ -1,20 +1,102 @@ -// Hook pour charger et gérer les données budget +// Hook pour charger et gérer les données budget avec recalcul dynamique // Design: Corporate Modernism — Itinova Budget SI 2027 import { useState, useMemo } from 'react'; import budgetRaw from '../data_budget.json'; -import type { BudgetData, Etablissement, SortField, SortOrder, FilterType } from '../types/budget'; +import type { BudgetData, Etablissement, EtablissementStats, SortField, SortOrder, FilterType } from '../types/budget'; +import { useParametres } from '../contexts/ParametresContext'; const budgetData = budgetRaw as BudgetData; +// Date de référence fixe : 01/01/2027 +const DATE_REFERENCE = new Date('2027-01-01'); + +/** + * Recalcule les stats d'un établissement selon les paramètres courants. + * On repart des données brutes (age_ans stocké dans chaque Materiel) + * et on applique les nouveaux seuils. + */ +function recalculerStats( + etab: Etablissement, + seuilFixesAns: number, + seuilPortablesAns: number, + coutFixe: number, + coutPortable: number +): EtablissementStats { + const fixes = etab.fixes ?? []; + const portables = etab.portables ?? []; + + const nbFixesTotal = fixes.length; + const nbPortablesTotal = portables.length; + + // Renouveler si age_ans connu ET >= seuil + const nbFixesRenouveler = fixes.filter( + (f) => f.age_ans !== null && f.age_ans >= seuilFixesAns + ).length; + const nbPortablesRenouveler = portables.filter( + (p) => p.age_ans !== null && p.age_ans >= seuilPortablesAns + ).length; + + const nbFixesSansDate = fixes.filter((f) => f.age_ans === null).length; + const nbPortablesSansDate = portables.filter((p) => p.age_ans === null).length; + + const budgetFixes = nbFixesRenouveler * coutFixe; + const budgetPortables = nbPortablesRenouveler * coutPortable; + const budgetTotal = budgetFixes + budgetPortables; + + return { + nb_fixes_total: nbFixesTotal, + nb_portables_total: nbPortablesTotal, + nb_fixes_renouveler: nbFixesRenouveler, + nb_portables_renouveler: nbPortablesRenouveler, + nb_fixes_sans_date: nbFixesSansDate, + nb_portables_sans_date: nbPortablesSansDate, + budget_fixes: budgetFixes, + budget_portables: budgetPortables, + budget_total: budgetTotal, + }; +} + export function useBudgetData() { + const { parametres } = useParametres(); const [search, setSearch] = useState(''); const [sortField, setSortField] = useState('budget_total'); const [sortOrder, setSortOrder] = useState('desc'); const [filterType, setFilterType] = useState('all'); + // Recalcul de tous les établissements selon les paramètres courants + const etablissementsRecalcules = useMemo(() => { + return budgetData.etablissements.map((etab) => ({ + ...etab, + stats: recalculerStats( + etab, + parametres.seuilFixesAns, + parametres.seuilPortablesAns, + parametres.coutFixe, + parametres.coutPortable + ), + })); + }, [parametres]); + + // Méta recalculée (totaux globaux) + const metaRecalculee = useMemo(() => { + const totalFixesR = etablissementsRecalcules.reduce((s, e) => s + e.stats.nb_fixes_renouveler, 0); + const totalPortablesR = etablissementsRecalcules.reduce((s, e) => s + e.stats.nb_portables_renouveler, 0); + const budgetGlobal = etablissementsRecalcules.reduce((s, e) => s + e.stats.budget_total, 0); + return { + ...budgetData.meta, + seuil_fixes_ans: parametres.seuilFixesAns, + seuil_portables_ans: parametres.seuilPortablesAns, + cout_fixe: parametres.coutFixe, + cout_portable: parametres.coutPortable, + total_fixes_renouveler: totalFixesR, + total_portables_renouveler: totalPortablesR, + budget_global: budgetGlobal, + }; + }, [etablissementsRecalcules, parametres]); + const filteredAndSorted = useMemo(() => { - let list: Etablissement[] = [...budgetData.etablissements]; + let list: Etablissement[] = [...etablissementsRecalcules]; // Filtre par recherche if (search.trim()) { @@ -76,7 +158,7 @@ export function useBudgetData() { }); return list; - }, [search, sortField, sortOrder, filterType]); + }, [etablissementsRecalcules, search, sortField, sortOrder, filterType]); const toggleSort = (field: SortField) => { if (sortField === field) { @@ -88,7 +170,7 @@ export function useBudgetData() { }; return { - meta: budgetData.meta, + meta: metaRecalculee, etablissements: filteredAndSorted, totalEtablissements: budgetData.etablissements.length, search, diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx index cbef1ec..1fb6d85 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -2,6 +2,7 @@ // Design: Corporate Modernism — Sidebar bleu marine + grille de cards + vue liste import { useState } from 'react'; +import { useLocation } from 'wouter'; import { Building2, Monitor, @@ -17,6 +18,7 @@ import { ChevronDown, LayoutGrid, List, + Settings, } from 'lucide-react'; import { useBudgetData } from '../hooks/useBudgetData'; import { formatEuros, formatNumber } from '../lib/format'; @@ -42,6 +44,7 @@ export default function Home() { setFilterType, } = useBudgetData(); + const [, navigate] = useLocation(); const [selectedEtab, setSelectedEtab] = useState(null); const [sidebarOpen, setSidebarOpen] = useState(true); const [viewMode, setViewMode] = useState('grid'); @@ -90,6 +93,13 @@ export default function Home() { {sidebarOpen && Établissements} + {/* Infos calcul */} diff --git a/client/src/pages/Parametres.tsx b/client/src/pages/Parametres.tsx new file mode 100644 index 0000000..263b45f --- /dev/null +++ b/client/src/pages/Parametres.tsx @@ -0,0 +1,406 @@ +// Parametres.tsx — Page de paramétrage du calcul de renouvellement +// Design: Corporate Modernism — Itinova Budget SI 2027 + +import { useState, useEffect } from 'react'; +import { useLocation } from 'wouter'; +import { + Settings, + Monitor, + Laptop, + Euro, + RotateCcw, + Save, + CheckCircle, + ChevronLeft, + Info, + BarChart3, + Building2, +} from 'lucide-react'; +import { useParametres, PARAMETRES_DEFAULTS } from '../contexts/ParametresContext'; +import { formatEuros } from '../lib/format'; +import { toast } from 'sonner'; + +interface FieldState { + seuilFixesAns: number; + seuilPortablesAns: number; + coutFixe: number; + coutPortable: number; +} + +function SliderInput({ + label, + icon: Icon, + value, + min, + max, + step, + unit, + description, + onChange, + accentClass, +}: { + label: string; + icon: React.ElementType; + value: number; + min: number; + max: number; + step: number; + unit: string; + description: string; + onChange: (v: number) => void; + accentClass: string; +}) { + return ( +
+
+
+ +
+
+

+ {label} +

+

{description}

+
+
+ + {value} + + {unit} +
+
+ + {/* Slider */} +
+ onChange(Number(e.target.value))} + className="w-full h-2 rounded-full appearance-none cursor-pointer accent-primary bg-muted" + /> +
+ {min} {unit} + {max} {unit} +
+
+ + {/* Input numérique direct */} +
+ + { + const v = Number(e.target.value); + if (!isNaN(v) && v >= min && v <= max) onChange(v); + }} + className="w-24 px-3 py-1.5 text-sm border border-border rounded-lg bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary tabular-nums" + /> + {unit} +
+
+ ); +} + +export default function Parametres() { + const [, navigate] = useLocation(); + const { parametres, setParametres, resetParametres } = useParametres(); + const [draft, setDraft] = useState({ ...parametres }); + const [saved, setSaved] = useState(false); + + // Sync si les paramètres changent depuis l'extérieur + useEffect(() => { + setDraft({ ...parametres }); + }, [parametres]); + + const hasChanges = + draft.seuilFixesAns !== parametres.seuilFixesAns || + draft.seuilPortablesAns !== parametres.seuilPortablesAns || + draft.coutFixe !== parametres.coutFixe || + draft.coutPortable !== parametres.coutPortable; + + const handleSave = () => { + setParametres(draft); + setSaved(true); + toast.success('Paramètres enregistrés', { + description: 'Les budgets ont été recalculés avec les nouveaux seuils.', + }); + setTimeout(() => setSaved(false), 2500); + }; + + const handleReset = () => { + setDraft({ ...PARAMETRES_DEFAULTS }); + resetParametres(); + toast.info('Paramètres réinitialisés', { + description: 'Les valeurs par défaut ont été restaurées.', + }); + }; + + const update = (key: keyof FieldState) => (v: number) => { + setDraft((d) => ({ ...d, [key]: v })); + }; + + return ( +
+ {/* Sidebar identique à Home */} + + + {/* Contenu principal */} +
+ {/* En-tête */} +
+
+
+ +
+

+ Paramètres de calcul +

+

+ Seuils de vétusté et coûts unitaires — Budget 2027 +

+
+
+
+ + +
+
+
+ + {/* Contenu */} +
+
+ + {/* Bandeau info */} +
+ +

+ Les modifications sont appliquées immédiatement après enregistrement. + Tous les budgets des 60 établissements sont recalculés automatiquement. + Les valeurs sont sauvegardées dans votre navigateur. +

+
+ + {/* Section vétusté */} +
+
+ +

+ Seuils de vétusté +

+
+

+ Un équipement est considéré à renouveler si son âge (calculé au 01/01/2027) est + supérieur ou égal au seuil défini ci-dessous. +

+ + + + +
+ + {/* Section coûts */} +
+
+ +

+ Coûts unitaires TTC +

+
+

+ Prix d'achat unitaire estimé utilisé pour le calcul du budget de renouvellement. +

+ + + + +
+ + {/* Aperçu de l'impact */} +
+
+ +

+ Paramètres en cours d'édition +

+
+
+ {[ + { label: 'Seuil PC Fixes', value: `${draft.seuilFixesAns} ans`, changed: draft.seuilFixesAns !== parametres.seuilFixesAns }, + { label: 'Seuil PC Portables', value: `${draft.seuilPortablesAns} ans`, changed: draft.seuilPortablesAns !== parametres.seuilPortablesAns }, + { label: 'Coût fixe unitaire', value: formatEuros(draft.coutFixe), changed: draft.coutFixe !== parametres.coutFixe }, + { label: 'Coût portable unitaire', value: formatEuros(draft.coutPortable), changed: draft.coutPortable !== parametres.coutPortable }, + ].map((item) => ( +
+

{item.label}

+

+ {item.value} + {item.changed && ( + + modifié + + )} +

+
+ ))} +
+ {hasChanges && ( +

+ + Des modifications non enregistrées sont en attente. Cliquez sur Enregistrer pour les appliquer. +

+ )} +
+ +
+
+
+
+ ); +}