| import React, { useState, useEffect, useRef } from 'react'; |
| import { NavLink, useLocation } from 'react-router-dom'; |
| import { useTranslation } from 'react-i18next'; |
| import { useAuth } from '@/lib/auth'; |
| import { useTenant } from '@/lib/tenant'; |
| import { |
| BarChart2, TrendingUp, Users, BookOpen, Mic, Building2, Activity, |
| Lightbulb, Database, Megaphone, LogOut, LayoutTemplate, MessageSquare, |
| Bot, Menu, X, CreditCard |
| } from 'lucide-react'; |
| import RoleGuard from '@/components/RoleGuard'; |
| import LanguageSwitcher from '@/components/LanguageSwitcher'; |
| import AdminChat, { type AdminChatPage } from '@/components/AdminChat'; |
|
|
| interface MainLayoutProps { |
| children: React.ReactNode; |
| isSuperAdmin: boolean; |
| orgs: any[]; |
| } |
|
|
| function useAdminChatPage(): AdminChatPage | null { |
| const { pathname } = useLocation(); |
| if (pathname === '/billing') return null; |
| if (pathname.startsWith('/settings')) return 'settings'; |
| if (pathname.startsWith('/whatsapp-templates')) return 'templates'; |
| if (pathname.startsWith('/ai-setup') || pathname.startsWith('/kb')) return 'agent'; |
| if (pathname.startsWith('/clients/new') || pathname.startsWith('/onboarding')) return 'onboarding'; |
| return 'general'; |
| } |
|
|
| export default function MainLayout({ children, isSuperAdmin, orgs }: MainLayoutProps) { |
| const { t } = useTranslation(); |
| const { logout, user, token } = useAuth(); |
| const { selectedOrgId, setSelectedOrgId, currentOrg } = useTenant(); |
| const [sidebarOpen, setSidebarOpen] = useState(false); |
| const [unreadCount, setUnreadCount] = useState(0); |
| const chatPage = useAdminChatPage(); |
| const { pathname } = useLocation(); |
| const esRef = useRef<EventSource | null>(null); |
|
|
| const isCrmActive = !!currentOrg?.isCrmActive; |
| const isEdTechActive = !!currentOrg?.isEdTechActive; |
|
|
| |
| useEffect(() => { |
| if (pathname.startsWith('/conversations') || pathname.startsWith('/crm')) { |
| setUnreadCount(0); |
| } |
| }, [pathname]); |
|
|
| |
| useEffect(() => { |
| if (!selectedOrgId || !token) return; |
| const apiBase = import.meta.env.VITE_API_URL || ''; |
| const es = new EventSource(`${apiBase}/v1/organizations/${selectedOrgId}/stream?token=${encodeURIComponent(token)}`); |
| esRef.current = es; |
| es.addEventListener('message', (event) => { |
| try { |
| const payload = JSON.parse(event.data); |
| if (payload.type === 'new-message') { |
| |
| const isViewingConversations = window.location.pathname.startsWith('/conversations') || window.location.pathname.startsWith('/crm'); |
| if (!isViewingConversations) { |
| setUnreadCount(n => n + 1); |
| } |
| } |
| } catch { } |
| }); |
| es.onerror = () => es.close(); |
| return () => { es.close(); esRef.current = null; }; |
| }, [selectedOrgId, token]); |
|
|
| const navItems = [ |
| { to: '/', label: t('nav.home'), icon: <BarChart2 className="w-4 h-4" />, end: true }, |
| { to: '/analytics', label: t('common.analytics'), icon: <TrendingUp className="w-4 h-4 text-amber-500" /> }, |
| { to: '/conversations', label: t('nav.conversations'), icon: <MessageSquare className="w-4 h-4 text-sky-400" /> }, |
| |
| { to: '/contacts', label: t('common.clients'), icon: <Users className="w-4 h-4 text-blue-400" />, show: isCrmActive }, |
| { to: '/campaign-history', label: t('nav.campaigns'), icon: <Megaphone className="w-4 h-4 text-amber-500" />, show: isCrmActive }, |
| { to: '/whatsapp-templates',label: t('nav.templates'), icon: <LayoutTemplate className="w-4 h-4 text-indigo-400" />, show: isCrmActive }, |
| |
| { to: '/content', label: t('nav.content'), icon: <BookOpen className="w-4 h-4" />, show: isEdTechActive }, |
| { to: '/live-feed', label: t('nav.moderation'), icon: <Mic className="w-4 h-4 text-emerald-500" />, show: isEdTechActive }, |
| { to: '/users', label: t('nav.users'), icon: <Users className="w-4 h-4" />, show: isEdTechActive }, |
| |
| { to: '/kb', label: t('nav.kb'), icon: <Database className="w-4 h-4 text-violet-400" /> }, |
| { to: '/ai-setup', label: t('nav.ai_setup'), icon: <Bot className="w-4 h-4 text-pink-400" /> }, |
| |
| { to: '/clients', label: t('nav.b2b'), icon: <Building2 className="w-4 h-4 text-indigo-400" />, show: isSuperAdmin }, |
| { to: '/training', label: t('nav.training'), icon: <Activity className="w-4 h-4 text-purple-400" />, show: isSuperAdmin }, |
| |
| { to: '/billing', label: t('nav.billing'), icon: <CreditCard className="w-4 h-4 text-emerald-400" /> }, |
| { to: '/settings', label: t('common.settings'), icon: <Lightbulb className="w-4 h-4" /> }, |
| ].filter(item => item.show !== false); |
|
|
| const linkClass = ({ isActive }: { isActive: boolean }) => |
| `flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${ |
| isActive |
| ? 'bg-white/15 text-white' |
| : 'text-slate-400 hover:text-white hover:bg-white/10' |
| }`; |
|
|
| const avatarInitial = (user?.name?.trim()?.[0] ?? user?.email?.[0] ?? 'U').toUpperCase(); |
| const displayName = user?.name || user?.email || '—'; |
| const displayRole = (user?.role ?? '').toLowerCase().replace(/_/g, ' '); |
|
|
| const sidebarInner = ( |
| <> |
| {/* Logo / org name */} |
| <div className="text-xl font-bold mb-8 flex items-center gap-3 shrink-0"> |
| {currentOrg?.brandingData?.logoUrl ? ( |
| <img src={currentOrg.brandingData.logoUrl} className="h-8 w-8 object-contain rounded" alt="Logo" /> |
| ) : ( |
| <span className="text-2xl">🎓</span> |
| )} |
| <span className="truncate text-base">{currentOrg?.name || 'Admin'}</span> |
| </div> |
| |
| {/* Super-admin org selector */} |
| <RoleGuard requireSuperAdmin> |
| <div className="mb-6 shrink-0"> |
| <label className="block text-[10px] uppercase font-bold text-slate-500 tracking-wider mb-2"> |
| Multi-Tenant |
| </label> |
| <select |
| value={selectedOrgId || ''} |
| onChange={e => setSelectedOrgId(e.target.value)} |
| className="w-full bg-slate-800 text-slate-200 text-xs px-3 py-2.5 rounded-xl outline-none focus:ring-1 focus:ring-slate-600 appearance-none cursor-pointer" |
| > |
| <option value="">Sélectionner une école...</option> |
| {orgs.map(o => ( |
| <option key={o.id} value={o.id}>{o.name}</option> |
| ))} |
| </select> |
| </div> |
| </RoleGuard> |
| |
| {/* Nav — scrollable so it never overflows */} |
| <nav className="space-y-0.5 flex-1 overflow-y-auto min-h-0 -mx-1 px-1"> |
| {navItems.map(n => { |
| const showBadge = n.to === '/conversations' && unreadCount > 0; |
| return ( |
| <NavLink |
| key={n.to} |
| to={n.to} |
| end={n.end} |
| className={linkClass} |
| onClick={() => { setSidebarOpen(false); if (n.to === '/conversations') setUnreadCount(0); }} |
| > |
| {n.icon} |
| <span className="truncate flex-1">{n.label}</span> |
| {showBadge && ( |
| <span className="ml-auto bg-red-500 text-white text-[10px] font-bold rounded-full min-w-[18px] h-[18px] flex items-center justify-center px-1 shrink-0"> |
| {unreadCount > 99 ? '99+' : unreadCount} |
| </span> |
| )} |
| </NavLink> |
| ); |
| })} |
| </nav> |
| |
| {/* Footer */} |
| <div className="pt-4 mt-4 border-t border-slate-800 shrink-0"> |
| <div className="mb-4"> |
| <label className="block text-[10px] uppercase font-bold text-slate-500 tracking-wider mb-2"> |
| Interface |
| </label> |
| <LanguageSwitcher /> |
| </div> |
| |
| <div className="flex items-center gap-3 px-1 mb-3"> |
| <div className="w-8 h-8 rounded-full bg-indigo-500 flex items-center justify-center font-bold text-xs shrink-0"> |
| {avatarInitial} |
| </div> |
| <div className="flex-1 min-w-0"> |
| <p className="text-sm font-bold truncate">{displayName}</p> |
| <p className="text-[10px] text-slate-500 truncate capitalize">{displayRole}</p> |
| </div> |
| </div> |
| |
| <button |
| onClick={logout} |
| className="w-full flex items-center gap-3 px-3 py-2 text-xs text-slate-500 hover:text-white transition group rounded-xl hover:bg-white/5" |
| > |
| <LogOut className="w-3.5 h-3.5 group-hover:text-red-400 transition" /> |
| {t('common.logout')} |
| </button> |
| </div> |
| </> |
| ); |
|
|
| return ( |
| <div className="min-h-screen bg-gray-50 flex"> |
| |
| {/* ── Desktop sidebar (always visible ≥ lg) ── */} |
| <aside className="hidden lg:flex w-64 bg-slate-900 text-white p-5 flex-col shrink-0 h-screen sticky top-0"> |
| {sidebarInner} |
| </aside> |
| |
| {/* ── Mobile: backdrop overlay ── */} |
| {sidebarOpen && ( |
| <div |
| className="fixed inset-0 bg-black/50 backdrop-blur-sm z-30 lg:hidden" |
| onClick={() => setSidebarOpen(false)} |
| /> |
| )} |
| |
| {/* ── Mobile: sliding sidebar ── */} |
| <aside |
| className={`fixed top-0 left-0 h-full w-64 bg-slate-900 text-white p-5 flex flex-col z-40 transition-transform duration-200 ease-in-out lg:hidden ${ |
| sidebarOpen ? 'translate-x-0' : '-translate-x-full' |
| }`} |
| > |
| <button |
| onClick={() => setSidebarOpen(false)} |
| className="absolute top-4 right-4 text-slate-400 hover:text-white transition" |
| aria-label="Fermer le menu" |
| > |
| <X className="w-5 h-5" /> |
| </button> |
| {sidebarInner} |
| </aside> |
| |
| {/* ── Main content area ── */} |
| <div className="flex-1 flex flex-col min-w-0"> |
| |
| {/* Mobile top bar */} |
| <header className="lg:hidden flex items-center gap-3 px-4 py-3 bg-slate-900 text-white sticky top-0 z-20 shrink-0"> |
| <button |
| onClick={() => setSidebarOpen(true)} |
| className="text-slate-400 hover:text-white transition" |
| aria-label="Ouvrir le menu" |
| > |
| <Menu className="w-5 h-5" /> |
| </button> |
| <span className="font-bold text-sm truncate">{currentOrg?.name || 'Admin'}</span> |
| </header> |
| |
| <main className="flex-1 overflow-auto"> |
| {children} |
| </main> |
| </div> |
| |
| {/* Global AI assistant — hidden on /billing (has its own inline chat) */} |
| {chatPage && selectedOrgId && <AdminChat page={chatPage} />} |
| </div> |
| ); |
| } |
|
|