Checkpoint: Sidebar refondée : application renommée Budget SI, menus accordéon avec couleurs distinctes (bleu Itinova, vert Santinova, violet Soins et Santé, orange St Exupéry), CAPEX-Synthèse déplacé dans Établissements, Construction BP 2027 renommé CAPEX-Construction.

This commit is contained in:
Manus
2026-06-03 09:41:47 +00:00
parent 1cdfbd1b12
commit 858f0dc4d9
2 changed files with 284 additions and 129 deletions

View File

@@ -6,7 +6,7 @@
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1" /> content="width=device-width, initial-scale=1.0, maximum-scale=1" />
<title>Budget Renouvellement SI 2027 — Itinova</title> <title>Budget SI — Itinova Group</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@400;600;700;800&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Sora:wght@400;600;700;800&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />

View File

@@ -1,15 +1,15 @@
// AppSidebar.tsx — Sidebar partagée avec navigation hiérarchique // AppSidebar.tsx — Sidebar partagée avec navigation hiérarchique en accordéon
// Structure : // Structure mise à jour :
// ├── ITINOVA (expandable) // ├── ITINOVA (bleu)
// │ ├── Établissements (expandable) // │ ├── Établissements (bleu foncé)
// │ │ ├── Renouvellement PC → / // │ │ ├── Renouvellement PC → /
// │ │ ── Construction BP CAPEX → /budget2027 // │ │ ── CAPEX - Construction → /budget2027
// │ └── DSI (expandable) // │ │ └── CAPEX - Synthèse → /dsi-capex
// │ ├── CAPEX (Investissement) → /dsi-capex // │ └── DSI (bleu foncé)
// │ └── OPEX (Charges) → /dsi-opex // │ └── OPEX (Charges) → /dsi-opex
// ├── SANTINOVA → /santinova // ├── SANTINOVA (vert)
// ├── SOINS ET SANTÉ → /soins-sante // ├── SOINS ET SANTÉ (violet)
// ├── ST EXUPÉRY → /st-exupery // ├── ST EXUPÉRY (orange)
// └── Paramètres → /parametres // └── Paramètres → /parametres
import { useState } from 'react'; import { useState } from 'react';
@@ -19,32 +19,109 @@ import {
Building2, Building2,
Settings, Settings,
ChevronDown, ChevronDown,
ChevronRight,
Monitor, Monitor,
FileText, FileText,
TrendingUp, TrendingUp,
TrendingDown, TrendingDown,
Heart, Heart,
GraduationCap,
Stethoscope, Stethoscope,
Layers, Layers,
BookOpen,
PanelLeftClose, PanelLeftClose,
PanelLeftOpen, PanelLeftOpen,
TableProperties,
} from 'lucide-react'; } from 'lucide-react';
interface NavItem { // Palette de couleurs par section principale
// Chaque section a : bg (fond menu principal), sub (fond sous-menu), leaf (fond item feuille actif), text, border
const SECTION_COLORS: Record<string, {
menuBg: string;
menuText: string;
menuActiveBg: string;
subBg: string;
subText: string;
subActiveBg: string;
leafActiveBg: string;
leafActiveText: string;
border: string;
dot: string;
}> = {
itinova: {
menuBg: 'hover:bg-blue-700/40',
menuText: 'text-blue-100',
menuActiveBg: 'bg-blue-700/50',
subBg: 'hover:bg-blue-900/50',
subText: 'text-blue-200',
subActiveBg: 'bg-blue-900/60',
leafActiveBg: 'bg-blue-500',
leafActiveText:'text-white',
border: 'border-blue-400/30',
dot: 'bg-blue-300',
},
santinova: {
menuBg: 'hover:bg-emerald-700/40',
menuText: 'text-emerald-100',
menuActiveBg: 'bg-emerald-700/50',
subBg: 'hover:bg-emerald-900/50',
subText: 'text-emerald-200',
subActiveBg: 'bg-emerald-900/60',
leafActiveBg: 'bg-emerald-500',
leafActiveText:'text-white',
border: 'border-emerald-400/30',
dot: 'bg-emerald-300',
},
'soins-sante': {
menuBg: 'hover:bg-violet-700/40',
menuText: 'text-violet-100',
menuActiveBg: 'bg-violet-700/50',
subBg: 'hover:bg-violet-900/50',
subText: 'text-violet-200',
subActiveBg: 'bg-violet-900/60',
leafActiveBg: 'bg-violet-500',
leafActiveText:'text-white',
border: 'border-violet-400/30',
dot: 'bg-violet-300',
},
'st-exupery': {
menuBg: 'hover:bg-orange-700/40',
menuText: 'text-orange-100',
menuActiveBg: 'bg-orange-700/50',
subBg: 'hover:bg-orange-900/50',
subText: 'text-orange-200',
subActiveBg: 'bg-orange-900/60',
leafActiveBg: 'bg-orange-500',
leafActiveText:'text-white',
border: 'border-orange-400/30',
dot: 'bg-orange-300',
},
};
interface NavLeaf {
id: string; id: string;
label: string; label: string;
icon: React.ElementType; icon: React.ElementType;
path?: string; path: string;
children?: NavItem[];
badge?: string;
} }
const NAV_STRUCTURE: NavItem[] = [ interface NavSubMenu {
id: string;
label: string;
icon: React.ElementType;
children: NavLeaf[];
}
interface NavSection {
id: string;
label: string;
icon: React.ElementType;
path?: string; // si section sans sous-menu (feuille directe)
children?: NavSubMenu[]; // sous-menus
}
const NAV_SECTIONS: NavSection[] = [
{ {
id: 'itinova', id: 'itinova',
label: 'Itinova', label: 'ITINOVA',
icon: Building2, icon: Building2,
children: [ children: [
{ {
@@ -53,7 +130,8 @@ const NAV_STRUCTURE: NavItem[] = [
icon: Layers, icon: Layers,
children: [ children: [
{ id: 'renouvellement-pc', label: 'Renouvellement PC', icon: Monitor, path: '/' }, { id: 'renouvellement-pc', label: 'Renouvellement PC', icon: Monitor, path: '/' },
{ id: 'budget2027', label: 'Construction BP CAPEX', icon: FileText, path: '/budget2027' }, { id: 'budget2027', label: 'CAPEX - Construction', icon: FileText, path: '/budget2027' },
{ id: 'dsi-capex', label: 'CAPEX - Synthèse', icon: TableProperties, path: '/dsi-capex' },
], ],
}, },
{ {
@@ -61,7 +139,6 @@ const NAV_STRUCTURE: NavItem[] = [
label: 'DSI', label: 'DSI',
icon: BarChart3, icon: BarChart3,
children: [ children: [
{ id: 'dsi-capex', label: 'CAPEX (Investissement)', icon: TrendingUp, path: '/dsi-capex' },
{ id: 'dsi-opex', label: 'OPEX (Charges)', icon: TrendingDown, path: '/dsi-opex' }, { id: 'dsi-opex', label: 'OPEX (Charges)', icon: TrendingDown, path: '/dsi-opex' },
], ],
}, },
@@ -69,59 +146,52 @@ const NAV_STRUCTURE: NavItem[] = [
}, },
{ {
id: 'santinova', id: 'santinova',
label: 'Santinova', label: 'SANTINOVA',
icon: Heart, icon: Heart,
path: '/santinova', path: '/santinova',
}, },
{ {
id: 'soins-sante', id: 'soins-sante',
label: 'Soins et Santé', label: 'SOINS ET SANTÉ',
icon: Stethoscope, icon: Stethoscope,
path: '/soins-sante', path: '/soins-sante',
}, },
{ {
id: 'st-exupery', id: 'st-exupery',
label: 'St Exupéry', label: 'ST EXUPÉRY',
icon: GraduationCap, icon: BookOpen,
path: '/st-exupery', path: '/st-exupery',
}, },
]; ];
const SETTINGS_ITEM: NavItem = { const SETTINGS_ITEM = { id: 'parametres', label: 'Paramètres', icon: Settings, path: '/parametres' };
id: 'parametres',
label: 'Paramètres',
icon: Settings,
path: '/parametres',
};
function isPathActive(path: string | undefined, currentPath: string): boolean { function isLeafActive(path: string, currentPath: string): boolean {
if (!path) return false;
if (path === '/') return currentPath === '/'; if (path === '/') return currentPath === '/';
return currentPath.startsWith(path); return currentPath.startsWith(path);
} }
function hasActiveChild(item: NavItem, currentPath: string): boolean { function sectionHasActive(section: NavSection, currentPath: string): boolean {
if (isPathActive(item.path, currentPath)) return true; if (section.path) return isLeafActive(section.path, currentPath);
if (item.children) { return section.children?.some(sub =>
return item.children.some(child => hasActiveChild(child, currentPath)); sub.children.some(leaf => isLeafActive(leaf.path, currentPath))
} ) ?? false;
return false;
} }
// Initialise les menus ouverts selon la route active function subMenuHasActive(sub: NavSubMenu, currentPath: string): boolean {
function getInitialOpen(currentPath: string): Set<string> { return sub.children.some(leaf => isLeafActive(leaf.path, currentPath));
}
function getInitialOpenSections(currentPath: string): Set<string> {
const open = new Set<string>(); const open = new Set<string>();
// Toujours ouvrir itinova et son sous-menu actif for (const section of NAV_SECTIONS) {
NAV_STRUCTURE.forEach(item => { if (sectionHasActive(section, currentPath)) {
if (hasActiveChild(item, currentPath)) { open.add(section.id);
open.add(item.id); section.children?.forEach(sub => {
item.children?.forEach(child => { if (subMenuHasActive(sub, currentPath)) open.add(sub.id);
if (hasActiveChild(child, currentPath)) {
open.add(child.id);
}
}); });
} }
}); }
return open; return open;
} }
@@ -132,98 +202,53 @@ interface AppSidebarProps {
export function AppSidebar({ collapsed = false, onToggle }: AppSidebarProps) { export function AppSidebar({ collapsed = false, onToggle }: AppSidebarProps) {
const [location, navigate] = useLocation(); const [location, navigate] = useLocation();
const [openMenus, setOpenMenus] = useState<Set<string>>(() => getInitialOpen(location)); const [openMenus, setOpenMenus] = useState<Set<string>>(() => getInitialOpenSections(location));
const toggleMenu = (id: string) => { // Mode accordéon : ferme les autres sections du même niveau lors de l'ouverture
const toggleSection = (id: string) => {
setOpenMenus(prev => { setOpenMenus(prev => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(id)) next.delete(id); if (next.has(id)) {
else next.add(id); next.delete(id);
} else {
// Fermer les autres sections de niveau 0
NAV_SECTIONS.forEach(s => next.delete(s.id));
next.add(id);
}
return next; return next;
}); });
}; };
const renderItem = (item: NavItem, depth: number = 0) => { const toggleSubMenu = (id: string) => {
const isActive = isPathActive(item.path, location); setOpenMenus(prev => {
const hasChildren = !!item.children?.length; const next = new Set(prev);
const isOpen = openMenus.has(item.id); if (next.has(id)) {
const isChildActive = hasActiveChild(item, location); next.delete(id);
const Icon = item.icon; } else {
// Fermer les autres sous-menus du même parent
const paddingLeft = depth === 0 ? 'pl-3' : depth === 1 ? 'pl-6' : 'pl-10'; NAV_SECTIONS.forEach(s => s.children?.forEach(sub => next.delete(sub.id)));
next.add(id);
if (hasChildren) {
return (
<div key={item.id}>
<button
onClick={() => toggleMenu(item.id)}
className={`w-full flex items-center gap-2.5 pr-3 py-2 rounded-lg transition-all duration-150 text-left group ${paddingLeft} ${
isChildActive
? 'bg-white/15 text-white'
: 'text-white/60 hover:bg-white/8 hover:text-white/90'
}`}
>
<Icon className={`flex-shrink-0 transition-colors ${depth === 0 ? 'w-4 h-4' : 'w-3.5 h-3.5'} ${isChildActive ? 'text-white' : 'text-white/50 group-hover:text-white/80'}`} />
{!collapsed && (
<>
<span className={`flex-1 min-w-0 truncate ${depth === 0 ? 'text-sm font-semibold tracking-wide uppercase text-[11px]' : 'text-sm font-medium'}`}>
{item.label}
</span>
<span className="flex-shrink-0 transition-transform duration-200" style={{ transform: isOpen ? 'rotate(0deg)' : 'rotate(-90deg)' }}>
<ChevronDown className="w-3.5 h-3.5 text-white/40" />
</span>
</>
)}
</button>
{!collapsed && isOpen && (
<div className={`mt-0.5 space-y-0.5 ${depth === 0 ? 'border-l border-white/10 ml-5 pl-2' : ''}`}>
{item.children!.map(child => renderItem(child, depth + 1))}
</div>
)}
</div>
);
} }
return next;
// Feuille (lien direct) });
return (
<button
key={item.id}
onClick={() => navigate(item.path!)}
className={`w-full flex items-center gap-2.5 pr-3 py-2 rounded-lg transition-all duration-150 text-left group ${paddingLeft} ${
isActive
? 'bg-primary text-white shadow-sm'
: 'text-white/60 hover:bg-white/8 hover:text-white/90'
}`}
>
<Icon className={`flex-shrink-0 ${depth === 0 ? 'w-4 h-4' : 'w-3.5 h-3.5'} ${isActive ? 'text-white' : 'text-white/50 group-hover:text-white/80'}`} />
{!collapsed && (
<span className={`flex-1 min-w-0 truncate ${depth === 0 ? 'text-sm font-semibold' : 'text-sm'}`}>
{item.label}
</span>
)}
{!collapsed && isActive && (
<span className="w-1.5 h-1.5 rounded-full bg-white/80 flex-shrink-0" />
)}
</button>
);
}; };
const colors = (sectionId: string) => SECTION_COLORS[sectionId] ?? SECTION_COLORS['itinova'];
return ( return (
<aside <aside
className={`flex-shrink-0 bg-[oklch(0.22_0.06_240)] text-white flex flex-col transition-all duration-300 ${ className={`flex-shrink-0 text-white flex flex-col transition-all duration-300 ${collapsed ? 'w-14' : 'w-64'}`}
collapsed ? 'w-14' : 'w-60' style={{ minHeight: '100vh', background: 'oklch(0.20 0.055 245)' }}
}`}
style={{ minHeight: '100vh' }}
> >
{/* Logo / Titre */} {/* Logo / Titre */}
<div className="px-3 py-4 border-b border-white/10 flex items-center gap-3"> <div className="px-3 py-4 border-b border-white/10 flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center flex-shrink-0"> <div className="w-8 h-8 rounded-lg bg-blue-500 flex items-center justify-center flex-shrink-0 shadow-md">
<BarChart3 className="w-4 h-4 text-white" /> <BarChart3 className="w-4 h-4 text-white" />
</div> </div>
{!collapsed && ( {!collapsed && (
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="font-bold text-sm leading-tight truncate" style={{ fontFamily: 'Sora, sans-serif' }}> <p className="font-bold text-sm leading-tight truncate text-white" style={{ fontFamily: 'Sora, sans-serif' }}>
Budget SI 2027 Budget SI
</p> </p>
<p className="text-[10px] text-white/40 mt-0.5">Itinova Group</p> <p className="text-[10px] text-white/40 mt-0.5">Itinova Group</p>
</div> </div>
@@ -239,13 +264,143 @@ export function AppSidebar({ collapsed = false, onToggle }: AppSidebarProps) {
</div> </div>
{/* Navigation principale */} {/* Navigation principale */}
<nav className="flex-1 px-2 py-3 space-y-0.5 overflow-y-auto"> <nav className="flex-1 py-2 overflow-y-auto space-y-0.5">
{NAV_STRUCTURE.map(item => renderItem(item, 0))} {NAV_SECTIONS.map(section => {
const c = colors(section.id);
const isActive = sectionHasActive(section, location);
const isOpen = openMenus.has(section.id);
const SectionIcon = section.icon;
// Section sans sous-menu (feuille directe)
if (!section.children) {
return (
<div key={section.id} className="px-2">
<button
onClick={() => navigate(section.path!)}
className={`w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg transition-all duration-150 text-left group ${
isActive
? `${c.leafActiveBg} ${c.leafActiveText} shadow-sm`
: `${c.menuText} ${c.menuBg}`
}`}
>
<SectionIcon className="w-4 h-4 flex-shrink-0" />
{!collapsed && (
<span className="flex-1 text-[11px] font-bold tracking-widest uppercase truncate">
{section.label}
</span>
)}
{!collapsed && isActive && (
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${c.dot}`} />
)}
</button>
</div>
);
}
// Section avec sous-menus (accordéon)
return (
<div key={section.id} className="px-2">
{/* Bouton section principale */}
<button
onClick={() => toggleSection(section.id)}
className={`w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg transition-all duration-150 text-left group ${
isActive || isOpen
? `${c.menuActiveBg} ${c.menuText}`
: `text-white/50 hover:bg-white/8 hover:text-white/80`
}`}
>
<SectionIcon className={`w-4 h-4 flex-shrink-0 ${isActive || isOpen ? c.menuText : 'text-white/40'}`} />
{!collapsed && (
<>
<span className="flex-1 text-[11px] font-bold tracking-widest uppercase truncate">
{section.label}
</span>
<ChevronDown
className={`w-3.5 h-3.5 flex-shrink-0 transition-transform duration-200 ${
isOpen ? 'rotate-0' : '-rotate-90'
} ${isActive || isOpen ? c.menuText : 'text-white/30'}`}
/>
</>
)}
</button>
{/* Sous-menus — accordéon */}
{!collapsed && isOpen && (
<div className={`mt-1 ml-2 border-l-2 ${c.border} pl-2 space-y-0.5`}>
{section.children.map(sub => {
const subActive = subMenuHasActive(sub, location);
const subOpen = openMenus.has(sub.id);
const SubIcon = sub.icon;
return (
<div key={sub.id}>
{/* Bouton sous-menu */}
<button
onClick={() => toggleSubMenu(sub.id)}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-all duration-150 text-left group ${
subActive || subOpen
? `${c.subActiveBg} ${c.subText}`
: `${c.subText}/60 ${c.subBg}`
}`}
>
<SubIcon className={`w-3.5 h-3.5 flex-shrink-0 ${subActive || subOpen ? c.subText : 'opacity-50'}`} />
<span className="flex-1 text-xs font-semibold truncate">{sub.label}</span>
<ChevronDown
className={`w-3 h-3 flex-shrink-0 transition-transform duration-200 ${
subOpen ? 'rotate-0' : '-rotate-90'
} opacity-60`}
/>
</button>
{/* Items feuilles */}
{subOpen && (
<div className={`mt-0.5 ml-2 border-l ${c.border} pl-2 space-y-0.5`}>
{sub.children.map(leaf => {
const leafActive = isLeafActive(leaf.path, location);
const LeafIcon = leaf.icon;
return (
<button
key={leaf.id}
onClick={() => navigate(leaf.path)}
className={`w-full flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all duration-150 text-left group ${
leafActive
? `${c.leafActiveBg} ${c.leafActiveText} shadow-sm`
: `${c.subText}/60 hover:bg-white/10 hover:${c.subText}`
}`}
>
<LeafIcon className={`w-3 h-3 flex-shrink-0 ${leafActive ? 'text-white' : 'opacity-50'}`} />
<span className="flex-1 text-xs truncate">{leaf.label}</span>
{leafActive && <span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${c.dot}`} />}
</button>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
})}
</nav> </nav>
{/* Séparateur + Paramètres */} {/* Paramètres en bas */}
<div className="px-2 py-3 border-t border-white/10"> <div className="px-2 py-3 border-t border-white/10">
{renderItem(SETTINGS_ITEM, 0)} <button
onClick={() => navigate(SETTINGS_ITEM.path)}
className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-lg transition-all duration-150 text-left ${
isLeafActive(SETTINGS_ITEM.path, location)
? 'bg-white/20 text-white'
: 'text-white/50 hover:bg-white/10 hover:text-white/80'
}`}
>
<Settings className="w-4 h-4 flex-shrink-0" />
{!collapsed && (
<span className="flex-1 text-xs font-medium truncate">{SETTINGS_ITEM.label}</span>
)}
</button>
</div> </div>
</aside> </aside>
); );