Checkpoint: Ajout de la page Paramètres : seuils de vétusté (PC fixes et portables) et coûts unitaires configurables via sliders + inputs numériques. Recalcul automatique de tous les budgets à l'enregistrement. Persistance dans localStorage. Navigation sidebar Établissements ↔ Paramètres.
This commit is contained in:
@@ -7,12 +7,15 @@ import NotFound from "@/pages/NotFound";
|
|||||||
import { Route, Switch } from "wouter";
|
import { Route, Switch } from "wouter";
|
||||||
import ErrorBoundary from "./components/ErrorBoundary";
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||||
|
import { ParametresProvider } from "./contexts/ParametresContext";
|
||||||
import Home from "./pages/Home";
|
import Home from "./pages/Home";
|
||||||
|
import Parametres from "./pages/Parametres";
|
||||||
|
|
||||||
function Router() {
|
function Router() {
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path={"/"} component={Home} />
|
<Route path={"/"} component={Home} />
|
||||||
|
<Route path={"/parametres"} component={Parametres} />
|
||||||
<Route path={"/404"} component={NotFound} />
|
<Route path={"/404"} component={NotFound} />
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
</Switch>
|
</Switch>
|
||||||
@@ -23,10 +26,12 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<ThemeProvider defaultTheme="light">
|
<ThemeProvider defaultTheme="light">
|
||||||
|
<ParametresProvider>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<Router />
|
<Router />
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
</ParametresProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|||||||
80
client/src/contexts/ParametresContext.tsx
Normal file
80
client/src/contexts/ParametresContext.tsx
Normal file
@@ -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<Parametres>;
|
||||||
|
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<ParametresContextType | null>(null);
|
||||||
|
|
||||||
|
export function ParametresProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [parametres, setParametresState] = useState<Parametres>(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 (
|
||||||
|
<ParametresContext.Provider value={{ parametres, setParametres, resetParametres }}>
|
||||||
|
{children}
|
||||||
|
</ParametresContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
@@ -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
|
// Design: Corporate Modernism — Itinova Budget SI 2027
|
||||||
|
|
||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import budgetRaw from '../data_budget.json';
|
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;
|
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() {
|
export function useBudgetData() {
|
||||||
|
const { parametres } = useParametres();
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [sortField, setSortField] = useState<SortField>('budget_total');
|
const [sortField, setSortField] = useState<SortField>('budget_total');
|
||||||
const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
|
const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
|
||||||
const [filterType, setFilterType] = useState<FilterType>('all');
|
const [filterType, setFilterType] = useState<FilterType>('all');
|
||||||
|
|
||||||
|
// Recalcul de tous les établissements selon les paramètres courants
|
||||||
|
const etablissementsRecalcules = useMemo<Etablissement[]>(() => {
|
||||||
|
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(() => {
|
const filteredAndSorted = useMemo(() => {
|
||||||
let list: Etablissement[] = [...budgetData.etablissements];
|
let list: Etablissement[] = [...etablissementsRecalcules];
|
||||||
|
|
||||||
// Filtre par recherche
|
// Filtre par recherche
|
||||||
if (search.trim()) {
|
if (search.trim()) {
|
||||||
@@ -76,7 +158,7 @@ export function useBudgetData() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return list;
|
return list;
|
||||||
}, [search, sortField, sortOrder, filterType]);
|
}, [etablissementsRecalcules, search, sortField, sortOrder, filterType]);
|
||||||
|
|
||||||
const toggleSort = (field: SortField) => {
|
const toggleSort = (field: SortField) => {
|
||||||
if (sortField === field) {
|
if (sortField === field) {
|
||||||
@@ -88,7 +170,7 @@ export function useBudgetData() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
meta: budgetData.meta,
|
meta: metaRecalculee,
|
||||||
etablissements: filteredAndSorted,
|
etablissements: filteredAndSorted,
|
||||||
totalEtablissements: budgetData.etablissements.length,
|
totalEtablissements: budgetData.etablissements.length,
|
||||||
search,
|
search,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// Design: Corporate Modernism — Sidebar bleu marine + grille de cards + vue liste
|
// Design: Corporate Modernism — Sidebar bleu marine + grille de cards + vue liste
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useLocation } from 'wouter';
|
||||||
import {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
Monitor,
|
Monitor,
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
List,
|
List,
|
||||||
|
Settings,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useBudgetData } from '../hooks/useBudgetData';
|
import { useBudgetData } from '../hooks/useBudgetData';
|
||||||
import { formatEuros, formatNumber } from '../lib/format';
|
import { formatEuros, formatNumber } from '../lib/format';
|
||||||
@@ -42,6 +44,7 @@ export default function Home() {
|
|||||||
setFilterType,
|
setFilterType,
|
||||||
} = useBudgetData();
|
} = useBudgetData();
|
||||||
|
|
||||||
|
const [, navigate] = useLocation();
|
||||||
const [selectedEtab, setSelectedEtab] = useState<Etablissement | null>(null);
|
const [selectedEtab, setSelectedEtab] = useState<Etablissement | null>(null);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||||
@@ -90,6 +93,13 @@ export default function Home() {
|
|||||||
<Building2 className="w-4 h-4 flex-shrink-0" />
|
<Building2 className="w-4 h-4 flex-shrink-0" />
|
||||||
{sidebarOpen && <span className="text-sm font-medium">Établissements</span>}
|
{sidebarOpen && <span className="text-sm font-medium">Établissements</span>}
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/parametres')}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-white/60 hover:bg-white/10 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4 flex-shrink-0" />
|
||||||
|
{sidebarOpen && <span className="text-sm font-medium">Paramètres</span>}
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Infos calcul */}
|
{/* Infos calcul */}
|
||||||
|
|||||||
406
client/src/pages/Parametres.tsx
Normal file
406
client/src/pages/Parametres.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="bg-card border border-border rounded-xl p-5 space-y-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${accentClass}`}>
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">{description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0 text-right">
|
||||||
|
<span className={`text-2xl font-bold tabular-nums ${accentClass.replace('bg-', 'text-').replace('/10', '')}`}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground ml-1">{unit}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Slider */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
className="w-full h-2 rounded-full appearance-none cursor-pointer accent-primary bg-muted"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>{min} {unit}</span>
|
||||||
|
<span>{max} {unit}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input numérique direct */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-sm text-muted-foreground flex-shrink-0">Valeur exacte :</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">{unit}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Parametres() {
|
||||||
|
const [, navigate] = useLocation();
|
||||||
|
const { parametres, setParametres, resetParametres } = useParametres();
|
||||||
|
const [draft, setDraft] = useState<FieldState>({ ...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 (
|
||||||
|
<div className="min-h-screen flex bg-background">
|
||||||
|
{/* Sidebar identique à Home */}
|
||||||
|
<aside
|
||||||
|
className="w-64 flex-shrink-0 bg-[oklch(0.22_0.06_240)] text-white flex flex-col"
|
||||||
|
style={{ minHeight: '100vh' }}
|
||||||
|
>
|
||||||
|
<div className="px-4 py-5 border-b border-white/10">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center flex-shrink-0">
|
||||||
|
<BarChart3 className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-bold text-sm leading-tight" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||||||
|
Budget SI 2027
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-white/50 mt-0.5">Itinova — Renouvellement</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 px-3 py-4 space-y-1">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-white/70 hover:bg-white/10 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<Building2 className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span className="text-sm font-medium">Établissements</span>
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-white/10 text-white">
|
||||||
|
<Settings className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span className="text-sm font-medium">Paramètres</span>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Résumé des paramètres actifs */}
|
||||||
|
<div className="px-4 py-4 border-t border-white/10">
|
||||||
|
<p className="text-[10px] text-white/40 uppercase tracking-wider mb-2">Paramètres actifs</p>
|
||||||
|
<div className="space-y-1.5 text-xs text-white/70">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Seuil fixes</span>
|
||||||
|
<span className="text-white font-medium">> {parametres.seuilFixesAns} ans</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Seuil portables</span>
|
||||||
|
<span className="text-white font-medium">> {parametres.seuilPortablesAns} ans</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Coût fixe</span>
|
||||||
|
<span className="text-white font-medium">{formatEuros(parametres.coutFixe)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Coût portable</span>
|
||||||
|
<span className="text-white font-medium">{formatEuros(parametres.coutPortable)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Contenu principal */}
|
||||||
|
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||||
|
{/* En-tête */}
|
||||||
|
<header className="bg-card border-b border-border px-6 py-4 flex-shrink-0">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="p-2 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||||
|
title="Retour aux établissements"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||||||
|
Paramètres de calcul
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
|
Seuils de vétusté et coûts unitaires — Budget 2027
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-sm rounded-lg border border-border bg-card hover:bg-muted transition-colors text-muted-foreground"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
Réinitialiser
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!hasChanges}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 text-sm rounded-lg font-medium transition-all ${
|
||||||
|
saved
|
||||||
|
? 'bg-emerald-600 text-white'
|
||||||
|
: hasChanges
|
||||||
|
? 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm'
|
||||||
|
: 'bg-muted text-muted-foreground cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{saved ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="w-3.5 h-3.5" />
|
||||||
|
Enregistré
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="w-3.5 h-3.5" />
|
||||||
|
Enregistrer
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Contenu */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-6">
|
||||||
|
<div className="max-w-2xl space-y-8">
|
||||||
|
|
||||||
|
{/* Bandeau info */}
|
||||||
|
<div className="flex items-start gap-3 bg-blue-50 border border-blue-200 rounded-xl px-4 py-3 text-sm text-blue-800">
|
||||||
|
<Info className="w-4 h-4 mt-0.5 flex-shrink-0 text-blue-500" />
|
||||||
|
<p>
|
||||||
|
Les modifications sont appliquées <strong>immédiatement</strong> après enregistrement.
|
||||||
|
Tous les budgets des 60 établissements sont recalculés automatiquement.
|
||||||
|
Les valeurs sont sauvegardées dans votre navigateur.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section vétusté */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 pb-2 border-b border-border">
|
||||||
|
<Settings className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<h2 className="font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||||||
|
Seuils de vétusté
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Un équipement est considéré à renouveler si son âge (calculé au 01/01/2027) est
|
||||||
|
<strong> supérieur ou égal</strong> au seuil défini ci-dessous.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<SliderInput
|
||||||
|
label="PC Fixes"
|
||||||
|
icon={Monitor}
|
||||||
|
value={draft.seuilFixesAns}
|
||||||
|
min={1}
|
||||||
|
max={15}
|
||||||
|
step={1}
|
||||||
|
unit="ans"
|
||||||
|
description="Âge minimum pour renouveler un PC de bureau"
|
||||||
|
onChange={update('seuilFixesAns')}
|
||||||
|
accentClass="bg-blue-100 text-blue-700"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SliderInput
|
||||||
|
label="PC Portables"
|
||||||
|
icon={Laptop}
|
||||||
|
value={draft.seuilPortablesAns}
|
||||||
|
min={1}
|
||||||
|
max={15}
|
||||||
|
step={1}
|
||||||
|
unit="ans"
|
||||||
|
description="Âge minimum pour renouveler un PC portable"
|
||||||
|
onChange={update('seuilPortablesAns')}
|
||||||
|
accentClass="bg-orange-100 text-orange-700"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section coûts */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 pb-2 border-b border-border">
|
||||||
|
<Euro className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<h2 className="font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||||||
|
Coûts unitaires TTC
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Prix d'achat unitaire estimé utilisé pour le calcul du budget de renouvellement.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<SliderInput
|
||||||
|
label="Coût d'un PC Fixe"
|
||||||
|
icon={Monitor}
|
||||||
|
value={draft.coutFixe}
|
||||||
|
min={100}
|
||||||
|
max={2000}
|
||||||
|
step={50}
|
||||||
|
unit="€ TTC"
|
||||||
|
description="Prix unitaire estimé pour un PC de bureau neuf"
|
||||||
|
onChange={update('coutFixe')}
|
||||||
|
accentClass="bg-blue-100 text-blue-700"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SliderInput
|
||||||
|
label="Coût d'un PC Portable"
|
||||||
|
icon={Laptop}
|
||||||
|
value={draft.coutPortable}
|
||||||
|
min={100}
|
||||||
|
max={3000}
|
||||||
|
step={50}
|
||||||
|
unit="€ TTC"
|
||||||
|
description="Prix unitaire estimé pour un PC portable neuf"
|
||||||
|
onChange={update('coutPortable')}
|
||||||
|
accentClass="bg-orange-100 text-orange-700"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Aperçu de l'impact */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 pb-2 border-b border-border">
|
||||||
|
<BarChart3 className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<h2 className="font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||||||
|
Paramètres en cours d'édition
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{[
|
||||||
|
{ 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) => (
|
||||||
|
<div
|
||||||
|
key={item.label}
|
||||||
|
className={`rounded-lg border px-4 py-3 transition-colors ${
|
||||||
|
item.changed
|
||||||
|
? 'border-orange-300 bg-orange-50'
|
||||||
|
: 'border-border bg-card'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="text-xs text-muted-foreground">{item.label}</p>
|
||||||
|
<p className={`font-bold mt-0.5 ${item.changed ? 'text-orange-700' : 'text-foreground'}`}>
|
||||||
|
{item.value}
|
||||||
|
{item.changed && (
|
||||||
|
<span className="ml-2 text-[10px] font-normal text-orange-500 uppercase tracking-wide">
|
||||||
|
modifié
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{hasChanges && (
|
||||||
|
<p className="text-sm text-orange-600 flex items-center gap-1.5">
|
||||||
|
<Info className="w-3.5 h-3.5" />
|
||||||
|
Des modifications non enregistrées sont en attente. Cliquez sur <strong>Enregistrer</strong> pour les appliquer.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user