Checkpoint: Nouvelle navigation hiérarchique avec 4 menus principaux (Itinova, Santinova, Soins et Santé, St Exupéry), sous-menus dépliables (Établissements, DSI), sidebar partagée AppSidebar sur toutes les pages, et pages placeholder pour les sections à venir.
This commit is contained in:
252
client/src/components/AppSidebar.tsx
Normal file
252
client/src/components/AppSidebar.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
// AppSidebar.tsx — Sidebar partagée avec navigation hiérarchique
|
||||
// Structure :
|
||||
// ├── ITINOVA (expandable)
|
||||
// │ ├── Établissements (expandable)
|
||||
// │ │ ├── Renouvellement PC → /
|
||||
// │ │ └── Construction BP CAPEX → /budget2027
|
||||
// │ └── DSI (expandable)
|
||||
// │ ├── CAPEX (Investissement) → /dsi-capex
|
||||
// │ └── OPEX (Charges) → /dsi-opex
|
||||
// ├── SANTINOVA → /santinova
|
||||
// ├── SOINS ET SANTÉ → /soins-sante
|
||||
// ├── ST EXUPÉRY → /st-exupery
|
||||
// └── Paramètres → /parametres
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useLocation } from 'wouter';
|
||||
import {
|
||||
BarChart3,
|
||||
Building2,
|
||||
Settings,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Monitor,
|
||||
FileText,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Heart,
|
||||
GraduationCap,
|
||||
Stethoscope,
|
||||
Layers,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface NavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
path?: string;
|
||||
children?: NavItem[];
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
const NAV_STRUCTURE: NavItem[] = [
|
||||
{
|
||||
id: 'itinova',
|
||||
label: 'Itinova',
|
||||
icon: Building2,
|
||||
children: [
|
||||
{
|
||||
id: 'etablissements',
|
||||
label: 'Établissements',
|
||||
icon: Layers,
|
||||
children: [
|
||||
{ id: 'renouvellement-pc', label: 'Renouvellement PC', icon: Monitor, path: '/' },
|
||||
{ id: 'budget2027', label: 'Construction BP CAPEX', icon: FileText, path: '/budget2027' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dsi',
|
||||
label: 'DSI',
|
||||
icon: BarChart3,
|
||||
children: [
|
||||
{ id: 'dsi-capex', label: 'CAPEX (Investissement)', icon: TrendingUp, path: '/dsi-capex' },
|
||||
{ id: 'dsi-opex', label: 'OPEX (Charges)', icon: TrendingDown, path: '/dsi-opex' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'santinova',
|
||||
label: 'Santinova',
|
||||
icon: Heart,
|
||||
path: '/santinova',
|
||||
},
|
||||
{
|
||||
id: 'soins-sante',
|
||||
label: 'Soins et Santé',
|
||||
icon: Stethoscope,
|
||||
path: '/soins-sante',
|
||||
},
|
||||
{
|
||||
id: 'st-exupery',
|
||||
label: 'St Exupéry',
|
||||
icon: GraduationCap,
|
||||
path: '/st-exupery',
|
||||
},
|
||||
];
|
||||
|
||||
const SETTINGS_ITEM: NavItem = {
|
||||
id: 'parametres',
|
||||
label: 'Paramètres',
|
||||
icon: Settings,
|
||||
path: '/parametres',
|
||||
};
|
||||
|
||||
function isPathActive(path: string | undefined, currentPath: string): boolean {
|
||||
if (!path) return false;
|
||||
if (path === '/') return currentPath === '/';
|
||||
return currentPath.startsWith(path);
|
||||
}
|
||||
|
||||
function hasActiveChild(item: NavItem, currentPath: string): boolean {
|
||||
if (isPathActive(item.path, currentPath)) return true;
|
||||
if (item.children) {
|
||||
return item.children.some(child => hasActiveChild(child, currentPath));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Initialise les menus ouverts selon la route active
|
||||
function getInitialOpen(currentPath: string): Set<string> {
|
||||
const open = new Set<string>();
|
||||
// Toujours ouvrir itinova et son sous-menu actif
|
||||
NAV_STRUCTURE.forEach(item => {
|
||||
if (hasActiveChild(item, currentPath)) {
|
||||
open.add(item.id);
|
||||
item.children?.forEach(child => {
|
||||
if (hasActiveChild(child, currentPath)) {
|
||||
open.add(child.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return open;
|
||||
}
|
||||
|
||||
interface AppSidebarProps {
|
||||
collapsed?: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
export function AppSidebar({ collapsed = false, onToggle }: AppSidebarProps) {
|
||||
const [location, navigate] = useLocation();
|
||||
const [openMenus, setOpenMenus] = useState<Set<string>>(() => getInitialOpen(location));
|
||||
|
||||
const toggleMenu = (id: string) => {
|
||||
setOpenMenus(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const renderItem = (item: NavItem, depth: number = 0) => {
|
||||
const isActive = isPathActive(item.path, location);
|
||||
const hasChildren = !!item.children?.length;
|
||||
const isOpen = openMenus.has(item.id);
|
||||
const isChildActive = hasActiveChild(item, location);
|
||||
const Icon = item.icon;
|
||||
|
||||
const paddingLeft = depth === 0 ? 'pl-3' : depth === 1 ? 'pl-6' : 'pl-10';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// 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>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`flex-shrink-0 bg-[oklch(0.22_0.06_240)] text-white flex flex-col transition-all duration-300 ${
|
||||
collapsed ? 'w-14' : 'w-60'
|
||||
}`}
|
||||
style={{ minHeight: '100vh' }}
|
||||
>
|
||||
{/* Logo / Titre */}
|
||||
<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">
|
||||
<BarChart3 className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-bold text-sm leading-tight truncate" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||||
Budget SI 2027
|
||||
</p>
|
||||
<p className="text-[10px] text-white/40 mt-0.5">Itinova Group</p>
|
||||
</div>
|
||||
)}
|
||||
{onToggle && (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex-shrink-0 text-white/40 hover:text-white/80 transition-colors p-1 rounded"
|
||||
>
|
||||
{collapsed ? <PanelLeftOpen className="w-4 h-4" /> : <PanelLeftClose className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation principale */}
|
||||
<nav className="flex-1 px-2 py-3 space-y-0.5 overflow-y-auto">
|
||||
{NAV_STRUCTURE.map(item => renderItem(item, 0))}
|
||||
</nav>
|
||||
|
||||
{/* Séparateur + Paramètres */}
|
||||
<div className="px-2 py-3 border-t border-white/10">
|
||||
{renderItem(SETTINGS_ITEM, 0)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
28
client/src/components/ComingSoon.tsx
Normal file
28
client/src/components/ComingSoon.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
// ComingSoon.tsx — Page placeholder pour les sections en cours de développement
|
||||
import { LucideIcon, Construction } from 'lucide-react';
|
||||
|
||||
interface ComingSoonProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon?: LucideIcon;
|
||||
}
|
||||
|
||||
export function ComingSoon({ title, subtitle, icon: Icon = Construction }: ComingSoonProps) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center py-24 px-6 text-center">
|
||||
<div className="w-20 h-20 rounded-2xl bg-muted flex items-center justify-center mb-6">
|
||||
<Icon className="w-10 h-10 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-foreground mb-2" style={{ fontFamily: 'Sora, sans-serif' }}>
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="text-muted-foreground max-w-sm">{subtitle}</p>
|
||||
)}
|
||||
<div className="mt-6 inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 text-primary text-sm font-medium">
|
||||
<Construction className="w-3.5 h-3.5" />
|
||||
Section en cours de développement
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user