// 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 = { 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 = { 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 = { 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> { try { const raw = localStorage.getItem(STORAGE_KEY); return raw ? JSON.parse(raw) : {}; } catch { return {}; } } function saveSaisies(data: Record>) { 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 (
{ 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' }`} /> TTC
); } export default function Budget2027() { const [, navigate] = useLocation(); const { parametres } = useParametres(); const [selectedCode, setSelectedCode] = useState(''); const [saisies, setSaisies] = useState>>(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(() => { 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 (
{/* Sidebar */} {/* Contenu principal */}
{/* En-tête */}

Construction Budget 2027

Campagne budgétaire CAPEX DSI — Prévisionnel 2027

{selectedCode && (
)}
{/* Sélection établissement */}

Sélection de l'établissement

{dropdownOpen && (
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 />
{etabsFiltres.length === 0 ? (

Aucun résultat

) : ( etabsFiltres.map(e => ( )) )}
)}
{/* Formulaire (visible uniquement si établissement sélectionné) */} {!selectedCode && (

Sélectionnez un établissement

Choisissez votre établissement dans la liste ci-dessus pour accéder au formulaire de construction budgétaire.

)} {selectedCode && selectedBP && ( <> {/* Données informatives 2025/2026 */}

Données historiques — à titre informatif

Enveloppes de renouvellement allouées par le Siège Itinova (non modifiables)

Renouvellement 2025

{selectedBP.renouvellement_2025 > 0 ? formatEuros(selectedBP.renouvellement_2025) : '—'}

Enveloppe Siège

Renouvellement 2026

{selectedBP.renouvellement_2026 > 0 ? formatEuros(selectedBP.renouvellement_2026) : '—'}

Enveloppe Siège

{/* Formulaire de saisie */}

Budget prévisionnel 2027 — Saisie directeur

(tous les champs sont optionnels)
{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 (
0 ? `${cls.border} ${cls.light}` : 'border-border' }`} >

{champ.label}

{isRenouvellement && ( Pré-rempli )}

{champ.description}

{isRenouvellement && renouvellement2027Calcule > 0 && (

Proposé par vétusté : {formatEuros(renouvellement2027Calcule)} {formValues.renouvellement_2027 !== renouvellement2027Calcule && ( (modifié) )}

)} updateField(champ.key, v)} />
{numVal > 0 && (
{formatEuros(numVal)}
)}
); })} {/* Commentaires */}

Commentaires

(optionnel)