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

597 lines
27 KiB
TypeScript

// Budget2027.tsx — Page de construction du budget CAPEX DSI 2027
// Design: Corporate Modernism — Itinova Budget SI 2027
// Chaque directeur sélectionne son établissement et remplit les champs optionnels.
// Le renouvellement 2027 est pré-rempli depuis les données de vétusté.
import { useState, useMemo, useEffect } from 'react';
import { useLocation } from 'wouter';
import {
Building2,
BarChart3,
Settings,
ChevronDown,
Monitor,
PlusCircle,
Bell,
Phone,
Wifi,
Camera,
Printer,
Video,
Package,
Info,
CheckCircle,
FileText,
RotateCcw,
TrendingUp,
AlertCircle,
} from 'lucide-react';
import bp2027Raw from '../data_bp2027.json';
import budgetRaw from '../data_budget.json';
import { useParametres } from '../contexts/ParametresContext';
import { formatEuros } from '../lib/format';
import type { BP2027Data, EtablissementBP, BudgetFormValues } from '../types/bp2027';
import type { BudgetData } from '../types/budget';
const bp2027Data = bp2027Raw as BP2027Data;
const budgetData = budgetRaw as BudgetData;
const STORAGE_KEY = 'itinova_budget2027_saisies';
// Icônes par clé de champ
const ICON_MAP: Record<string, React.ElementType> = {
renouvellement_2027: Monitor,
machines_supplementaires: PlusCircle,
appel_malade: Bell,
telephonie: Phone,
wifi: Wifi,
video_surveillance: Camera,
copieurs: Printer,
visio: Video,
autres: Package,
};
// Couleurs par champ
const COLOR_MAP: Record<string, string> = {
renouvellement_2027: 'blue',
machines_supplementaires: 'indigo',
appel_malade: 'red',
telephonie: 'green',
wifi: 'cyan',
video_surveillance: 'orange',
copieurs: 'purple',
visio: 'teal',
autres: 'slate',
};
const COLOR_CLASSES: Record<string, { bg: string; text: string; border: string; light: string }> = {
blue: { bg: 'bg-blue-600', text: 'text-blue-700', border: 'border-blue-200', light: 'bg-blue-50' },
indigo: { bg: 'bg-indigo-600', text: 'text-indigo-700', border: 'border-indigo-200', light: 'bg-indigo-50' },
red: { bg: 'bg-red-500', text: 'text-red-700', border: 'border-red-200', light: 'bg-red-50' },
green: { bg: 'bg-emerald-600',text: 'text-emerald-700',border: 'border-emerald-200',light: 'bg-emerald-50' },
cyan: { bg: 'bg-cyan-600', text: 'text-cyan-700', border: 'border-cyan-200', light: 'bg-cyan-50' },
orange: { bg: 'bg-orange-500', text: 'text-orange-700', border: 'border-orange-200', light: 'bg-orange-50' },
purple: { bg: 'bg-purple-600', text: 'text-purple-700', border: 'border-purple-200', light: 'bg-purple-50' },
teal: { bg: 'bg-teal-600', text: 'text-teal-700', border: 'border-teal-200', light: 'bg-teal-50' },
slate: { bg: 'bg-slate-500', text: 'text-slate-700', border: 'border-slate-200', light: 'bg-slate-50' },
};
const CHAMPS: { key: keyof BudgetFormValues; label: string; description: string }[] = [
{ key: 'renouvellement_2027', label: 'Renouvellement parc informatique', description: 'Pré-rempli depuis le calcul de vétusté — modifiable' },
{ key: 'machines_supplementaires', label: 'Acquisition machines supplémentaires', description: 'Nouveaux postes hors renouvellement' },
{ key: 'appel_malade', label: 'Système Appel Malade / Anti Fugue', description: 'Équipements d\'appel et de sécurité' },
{ key: 'telephonie', label: 'Installation téléphonique', description: 'Infrastructure téléphonique fixe et mobile' },
{ key: 'wifi', label: 'Réseau WiFi', description: 'Infrastructure réseau sans fil' },
{ key: 'video_surveillance', label: 'Vidéo Surveillance', description: 'Caméras et système de surveillance' },
{ key: 'copieurs', label: 'Copieurs', description: 'Imprimantes et copieurs multifonctions' },
{ key: 'visio', label: 'Équipement Visio Conférence', description: 'Matériel de visioconférence' },
{ key: 'autres', label: 'Autres investissements SI', description: 'Tout autre investissement informatique' },
];
function loadSaisies(): Record<string, Partial<BudgetFormValues>> {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : {};
} catch { return {}; }
}
function saveSaisies(data: Record<string, Partial<BudgetFormValues>>) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch {}
}
function getDefaultValues(etabCode: string, renouvellement2027: number): BudgetFormValues {
return {
renouvellement_2027: renouvellement2027,
machines_supplementaires: 0,
appel_malade: 0,
telephonie: 0,
wifi: 0,
video_surveillance: 0,
copieurs: 0,
visio: 0,
autres: 0,
commentaires: '',
};
}
// Calcule le budget de renouvellement 2027 depuis les données de vétusté
function getBudgetRenouvellement2027(code: string, parametres: { coutFixe: number; coutPortable: number; seuilFixesAns: number; seuilPortablesAns: number }): number {
const etab = budgetData.etablissements.find(e => e.code === code);
if (!etab) return 0;
const nbFixesR = (etab.fixes ?? []).filter(f => f.age_ans !== null && f.age_ans >= parametres.seuilFixesAns).length;
const nbPortablesR = (etab.portables ?? []).filter(p => p.age_ans !== null && p.age_ans >= parametres.seuilPortablesAns).length;
return nbFixesR * parametres.coutFixe + nbPortablesR * parametres.coutPortable;
}
function CurrencyInput({
value,
onChange,
placeholder = '0',
disabled = false,
}: {
value: number;
onChange: (v: number) => void;
placeholder?: string;
disabled?: boolean;
}) {
const [raw, setRaw] = useState(value === 0 ? '' : String(value));
useEffect(() => {
setRaw(value === 0 ? '' : String(value));
}, [value]);
return (
<div className="relative">
<input
type="number"
min={0}
step={100}
value={raw}
placeholder={placeholder}
disabled={disabled}
onChange={e => {
setRaw(e.target.value);
const v = parseFloat(e.target.value);
onChange(isNaN(v) || v < 0 ? 0 : Math.round(v));
}}
onBlur={() => setRaw(value === 0 ? '' : String(value))}
className={`w-full pl-8 pr-12 py-2.5 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary tabular-nums transition-colors ${
disabled ? 'bg-muted/50 text-muted-foreground cursor-not-allowed' : 'bg-background'
}`}
/>
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-sm"></span>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">TTC</span>
</div>
);
}
export default function Budget2027() {
const [, navigate] = useLocation();
const { parametres } = useParametres();
const [selectedCode, setSelectedCode] = useState<string>('');
const [saisies, setSaisies] = useState<Record<string, Partial<BudgetFormValues>>>(loadSaisies);
const [saved, setSaved] = useState(false);
const [searchEtab, setSearchEtab] = useState('');
const [dropdownOpen, setDropdownOpen] = useState(false);
// Établissement sélectionné (depuis BP2027)
const selectedBP = useMemo(
() => bp2027Data.etablissements.find(e => e.code === selectedCode) ?? null,
[selectedCode]
);
// Budget renouvellement 2027 calculé par vétusté
const renouvellement2027Calcule = useMemo(
() => selectedCode ? getBudgetRenouvellement2027(selectedCode, parametres) : 0,
[selectedCode, parametres]
);
// Valeurs du formulaire pour l'établissement sélectionné
const formValues = useMemo<BudgetFormValues>(() => {
const defaults = getDefaultValues(selectedCode, renouvellement2027Calcule);
const saved = saisies[selectedCode] ?? {};
return {
...defaults,
...saved,
// Si pas encore saisi, on prend le calculé
renouvellement_2027: saved.renouvellement_2027 !== undefined ? saved.renouvellement_2027 : renouvellement2027Calcule,
};
}, [selectedCode, saisies, renouvellement2027Calcule]);
// Total BP 2027
const totalBP2027 = useMemo(() => {
const v = formValues;
return (
v.renouvellement_2027 +
v.machines_supplementaires +
v.appel_malade +
v.telephonie +
v.wifi +
v.video_surveillance +
v.copieurs +
v.visio +
v.autres
);
}, [formValues]);
const updateField = (key: keyof BudgetFormValues, value: number | string) => {
setSaisies(prev => {
const next = {
...prev,
[selectedCode]: {
...(prev[selectedCode] ?? {}),
[key]: value,
},
};
saveSaisies(next);
return next;
});
setSaved(false);
};
const handleSave = () => {
saveSaisies(saisies);
setSaved(true);
setTimeout(() => setSaved(false), 2500);
};
const handleReset = () => {
setSaisies(prev => {
const next = { ...prev };
delete next[selectedCode];
saveSaisies(next);
return next;
});
};
// Établissements filtrés pour le dropdown
const etabsFiltres = useMemo(() => {
const q = searchEtab.toLowerCase();
return bp2027Data.etablissements.filter(
e => e.nom.toLowerCase().includes(q) || e.code.toLowerCase().includes(q)
);
}, [searchEtab]);
// Nb d'établissements avec saisie
const nbSaisies = Object.keys(saisies).length;
return (
<div className="min-h-screen flex bg-background">
{/* Sidebar */}
<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/60 hover:bg-white/10 hover:text-white transition-colors">
<Building2 className="w-4 h-4 flex-shrink-0" />
<span className="text-sm font-medium">Renouvellement PC</span>
</button>
<div className="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-white/10 text-white">
<FileText className="w-4 h-4 flex-shrink-0" />
<span className="text-sm font-medium">Construction BP 2027</span>
</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" />
<span className="text-sm font-medium">Paramètres</span>
</button>
</nav>
{/* Stats saisies */}
<div className="px-4 py-4 border-t border-white/10">
<p className="text-[10px] text-white/40 uppercase tracking-wider mb-2">Saisies enregistrées</p>
<div className="flex items-center gap-2">
<div className="flex-1 bg-white/10 rounded-full h-1.5">
<div
className="bg-emerald-400 h-1.5 rounded-full transition-all"
style={{ width: `${Math.min(100, (nbSaisies / bp2027Data.etablissements.length) * 100)}%` }}
/>
</div>
<span className="text-xs text-white font-medium">{nbSaisies}/{bp2027Data.etablissements.length}</span>
</div>
<p className="text-[10px] text-white/40 mt-1">établissements renseignés</p>
</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 gap-4">
<div>
<h1 className="text-xl font-bold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
Construction Budget 2027
</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Campagne budgétaire CAPEX DSI Prévisionnel 2027
</p>
</div>
{selectedCode && (
<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}
className={`flex items-center gap-2 px-4 py-2 text-sm rounded-lg font-medium transition-all ${
saved ? 'bg-emerald-600 text-white' : 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm'
}`}
>
{saved ? <><CheckCircle className="w-3.5 h-3.5" />Enregistré</> : <><CheckCircle className="w-3.5 h-3.5" />Enregistrer</>}
</button>
</div>
)}
</div>
</header>
<div className="flex-1 overflow-y-auto px-6 py-6 space-y-6">
{/* Sélection établissement */}
<section className="bg-card border border-border rounded-xl p-5">
<div className="flex items-center gap-2 mb-4">
<Building2 className="w-4 h-4 text-muted-foreground" />
<h2 className="font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
Sélection de l'établissement
</h2>
</div>
<div className="relative">
<button
onClick={() => setDropdownOpen(!dropdownOpen)}
className="w-full flex items-center justify-between px-4 py-3 border border-border rounded-lg bg-background hover:bg-muted/50 transition-colors text-left"
>
{selectedBP ? (
<div className="flex items-center gap-3">
<span className="font-mono text-xs bg-primary/10 text-primary px-2 py-0.5 rounded">{selectedBP.code}</span>
<span className="font-medium text-foreground">{selectedBP.nom}</span>
</div>
) : (
<span className="text-muted-foreground">Choisir un établissement...</span>
)}
<ChevronDown className={`w-4 h-4 text-muted-foreground transition-transform ${dropdownOpen ? 'rotate-180' : ''}`} />
</button>
{dropdownOpen && (
<div className="absolute z-50 top-full left-0 right-0 mt-1 bg-card border border-border rounded-xl shadow-lg overflow-hidden">
<div className="p-2 border-b border-border">
<input
type="text"
placeholder="Rechercher..."
value={searchEtab}
onChange={e => setSearchEtab(e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary/30"
autoFocus
/>
</div>
<div className="max-h-64 overflow-y-auto">
{etabsFiltres.length === 0 ? (
<p className="px-4 py-3 text-sm text-muted-foreground">Aucun résultat</p>
) : (
etabsFiltres.map(e => (
<button
key={e.code}
onClick={() => { setSelectedCode(e.code); setDropdownOpen(false); setSearchEtab(''); }}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left hover:bg-muted/50 transition-colors ${
selectedCode === e.code ? 'bg-primary/5' : ''
}`}
>
<span className="font-mono text-xs bg-muted text-muted-foreground px-1.5 py-0.5 rounded flex-shrink-0">{e.code}</span>
<span className="text-sm text-foreground">{e.nom}</span>
{saisies[e.code] && (
<CheckCircle className="w-3.5 h-3.5 text-emerald-500 ml-auto flex-shrink-0" />
)}
</button>
))
)}
</div>
</div>
)}
</div>
</section>
{/* Formulaire (visible uniquement si établissement sélectionné) */}
{!selectedCode && (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="w-16 h-16 rounded-2xl bg-muted flex items-center justify-center mb-4">
<FileText className="w-8 h-8 text-muted-foreground" />
</div>
<h3 className="font-semibold text-foreground mb-2" style={{ fontFamily: 'Sora, sans-serif' }}>
Sélectionnez un établissement
</h3>
<p className="text-sm text-muted-foreground max-w-sm">
Choisissez votre établissement dans la liste ci-dessus pour accéder au formulaire de construction budgétaire.
</p>
</div>
)}
{selectedCode && selectedBP && (
<>
{/* Données informatives 2025/2026 */}
<section className="bg-amber-50 border border-amber-200 rounded-xl p-5">
<div className="flex items-start gap-3 mb-4">
<Info className="w-4 h-4 text-amber-600 mt-0.5 flex-shrink-0" />
<div>
<h3 className="font-semibold text-amber-900" style={{ fontFamily: 'Sora, sans-serif' }}>
Données historiques — à titre informatif
</h3>
<p className="text-sm text-amber-700 mt-0.5">
Enveloppes de renouvellement allouées par le Siège Itinova (non modifiables)
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="bg-white/70 rounded-lg px-4 py-3 border border-amber-200">
<p className="text-xs text-amber-600 font-medium uppercase tracking-wide">Renouvellement 2025</p>
<p className="text-xl font-bold text-amber-900 mt-1 tabular-nums">
{selectedBP.renouvellement_2025 > 0 ? formatEuros(selectedBP.renouvellement_2025) : ''}
</p>
<p className="text-xs text-amber-600 mt-0.5">Enveloppe Siège</p>
</div>
<div className="bg-white/70 rounded-lg px-4 py-3 border border-amber-200">
<p className="text-xs text-amber-600 font-medium uppercase tracking-wide">Renouvellement 2026</p>
<p className="text-xl font-bold text-amber-900 mt-1 tabular-nums">
{selectedBP.renouvellement_2026 > 0 ? formatEuros(selectedBP.renouvellement_2026) : ''}
</p>
<p className="text-xs text-amber-600 mt-0.5">Enveloppe Siège</p>
</div>
</div>
</section>
{/* Formulaire de saisie */}
<section className="space-y-3">
<div className="flex items-center gap-2 pb-2 border-b border-border">
<TrendingUp className="w-4 h-4 text-muted-foreground" />
<h2 className="font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>
Budget prévisionnel 2027 — Saisie directeur
</h2>
<span className="text-xs text-muted-foreground ml-1">(tous les champs sont optionnels)</span>
</div>
{CHAMPS.map(champ => {
const Icon = ICON_MAP[champ.key] ?? Package;
const color = COLOR_MAP[champ.key] ?? 'slate';
const cls = COLOR_CLASSES[color];
const isRenouvellement = champ.key === 'renouvellement_2027';
const val = formValues[champ.key];
const numVal = typeof val === 'number' ? val : 0;
return (
<div
key={champ.key}
className={`bg-card border rounded-xl p-4 transition-colors ${
numVal > 0 ? `${cls.border} ${cls.light}` : 'border-border'
}`}
>
<div className="flex items-start gap-3">
<div className={`w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0 ${cls.light} border ${cls.border}`}>
<Icon className={`w-4 h-4 ${cls.text}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<p className="font-medium text-sm text-foreground">{champ.label}</p>
{isRenouvellement && (
<span className="text-[10px] bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded font-medium">
Pré-rempli
</span>
)}
</div>
<p className="text-xs text-muted-foreground mb-2">{champ.description}</p>
{isRenouvellement && renouvellement2027Calcule > 0 && (
<div className="flex items-center gap-1.5 mb-2">
<AlertCircle className="w-3 h-3 text-blue-500" />
<p className="text-xs text-blue-600">
Proposé par vétusté : <strong>{formatEuros(renouvellement2027Calcule)}</strong>
{formValues.renouvellement_2027 !== renouvellement2027Calcule && (
<span className="ml-1 text-orange-500">(modifié)</span>
)}
</p>
</div>
)}
<CurrencyInput
value={numVal}
onChange={v => updateField(champ.key, v)}
/>
</div>
{numVal > 0 && (
<div className="flex-shrink-0 text-right">
<span className={`text-sm font-bold ${cls.text} tabular-nums`}>
{formatEuros(numVal)}
</span>
</div>
)}
</div>
</div>
);
})}
{/* Commentaires */}
<div className="bg-card border border-border rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<FileText className="w-4 h-4 text-muted-foreground" />
<p className="font-medium text-sm text-foreground">Commentaires</p>
<span className="text-xs text-muted-foreground">(optionnel)</span>
</div>
<textarea
rows={3}
value={formValues.commentaires}
onChange={e => updateField('commentaires', e.target.value as unknown as number)}
placeholder="Précisions, justifications, contexte particulier..."
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary resize-none"
/>
</div>
</section>
{/* Récapitulatif total */}
<section className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl p-6 text-white">
<div className="flex items-center justify-between mb-5">
<div>
<h3 className="font-bold text-lg" style={{ fontFamily: 'Sora, sans-serif' }}>
Total BP 2027
</h3>
<p className="text-white/60 text-sm mt-0.5">{selectedBP.nom}</p>
</div>
<div className="text-right">
<p className="text-3xl font-bold tabular-nums text-white">
{formatEuros(totalBP2027)}
</p>
<p className="text-white/50 text-xs mt-0.5">TTC — exercice 2027</p>
</div>
</div>
{/* Détail des lignes non nulles */}
<div className="space-y-2 border-t border-white/10 pt-4">
{CHAMPS.map(champ => {
const val = formValues[champ.key];
const numVal = typeof val === 'number' ? val : 0;
if (numVal === 0) return null;
const color = COLOR_MAP[champ.key] ?? 'slate';
const cls = COLOR_CLASSES[color];
return (
<div key={champ.key} className="flex items-center justify-between text-sm">
<span className="text-white/70">{champ.label}</span>
<span className="font-medium tabular-nums text-white">{formatEuros(numVal)}</span>
</div>
);
})}
{totalBP2027 === 0 && (
<p className="text-white/40 text-sm text-center py-2">
Aucun montant saisi — remplissez les champs ci-dessus
</p>
)}
</div>
{/* Comparaison avec 2026 */}
{selectedBP.renouvellement_2026 > 0 && totalBP2027 > 0 && (
<div className="mt-4 pt-4 border-t border-white/10 flex items-center justify-between text-sm">
<span className="text-white/50">vs Enveloppe 2026</span>
<div className="flex items-center gap-2">
<span className="text-white/50">{formatEuros(selectedBP.renouvellement_2026)}</span>
<span className={`font-medium ${totalBP2027 > selectedBP.renouvellement_2026 ? 'text-red-400' : 'text-emerald-400'}`}>
{totalBP2027 > selectedBP.renouvellement_2026 ? '+' : ''}
{formatEuros(totalBP2027 - selectedBP.renouvellement_2026)}
</span>
</div>
</div>
)}
</section>
</>
)}
</div>
</main>
</div>
);
}