CognxSafeTrack
feat: agentic platform — text-to-sql, pedagogy advisor, security hardening & performance
5b8761d | 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; // billing has its own inline chat | |
| 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 } = 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; | |
| // Reset unread count when user is on the conversations page | |
| useEffect(() => { | |
| if (pathname.startsWith('/conversations') || pathname.startsWith('/crm')) { | |
| setUnreadCount(0); | |
| } | |
| }, [pathname]); | |
| // SSE — track new inbound messages for the notification badge | |
| useEffect(() => { | |
| if (!selectedOrgId) return; | |
| const apiBase = import.meta.env.VITE_API_URL || ''; | |
| const es = new EventSource(`${apiBase}/v1/organizations/${selectedOrgId}/stream`); | |
| esRef.current = es; | |
| es.addEventListener('message', (event) => { | |
| try { | |
| const payload = JSON.parse(event.data); | |
| if (payload.type === 'new-message') { | |
| // Only increment if not currently viewing conversations | |
| const isViewingConversations = window.location.pathname.startsWith('/conversations') || window.location.pathname.startsWith('/crm'); | |
| if (!isViewingConversations) { | |
| setUnreadCount(n => n + 1); | |
| } | |
| } | |
| } catch { /* ignore */ } | |
| }); | |
| es.onerror = () => es.close(); | |
| return () => { es.close(); esRef.current = null; }; | |
| }, [selectedOrgId]); | |
| 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" /> }, | |
| // CRM items | |
| { 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 }, | |
| // EdTech items | |
| { 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 }, | |
| // Shared — accessible to all org members | |
| { 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" /> }, | |
| // Super admin only | |
| { 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 }, | |
| // Always last | |
| { 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> | |
| ); | |
| } | |