edtech / apps /admin /src /components /layouts /MainLayout.tsx
CognxSafeTrack
fix(clients): resolve file import dialog error and SSE 401
87dcd87
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, 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;
// 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 || !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') {
// 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, 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" /> },
// 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>
);
}