597 lines
27 KiB
TypeScript
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>
|
|
);
|
|
}
|