Files
itinova-budget-si/client/src/pages/Parametres.tsx

396 lines
15 KiB
TypeScript

// 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,
Calendar,
} from 'lucide-react';
import { ANNEES_DISPONIBLES } from '../contexts/AnneeContext';
import { AppSidebar } from '../components/AppSidebar';
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;
anneeDefaut: 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 ||
draft.anneeDefaut !== parametres.anneeDefaut;
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">
<AppSidebar />
{/* 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>
{/* Section année par défaut */}
<section className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-border">
<Calendar className="w-4 h-4 text-muted-foreground" />
<h2 className="font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
Année d'ouverture par défaut
</h2>
</div>
<p className="text-sm text-muted-foreground">
L'année sélectionnée au démarrage de l'application lorsqu'aucune session précédente n'est mémorisée.
</p>
<div className="bg-card border border-border rounded-xl p-5">
<div className="flex items-center gap-4 flex-wrap">
<div className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 bg-indigo-100 text-indigo-700">
<Calendar className="w-5 h-5" />
</div>
<div className="flex-1">
<p className="font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>Année par défaut</p>
<p className="text-sm text-muted-foreground mt-0.5">Exercice ouvert au premier lancement</p>
</div>
<div className="flex items-center gap-2 flex-wrap">
{ANNEES_DISPONIBLES.map(a => (
<button
key={a}
onClick={() => setDraft(d => ({ ...d, anneeDefaut: a }))}
className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all ${
draft.anneeDefaut === a
? 'bg-indigo-600 text-white shadow-sm'
: 'bg-muted text-muted-foreground hover:bg-muted/70'
}`}
>
{a}
</button>
))}
</div>
</div>
</div>
</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 },
{ label: 'Année par défaut', value: `${draft.anneeDefaut}`, changed: draft.anneeDefaut !== parametres.anneeDefaut },
].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>
);
}