aa3e62d0ff
The mobile drawer rendered the nav at full height below a fixed header, so lower items (and the admin section) overflowed off-screen with no way to scroll. Lay the drawer out as a flex column with a fixed header, a scrollable nav area (min-h-0 + overflow-y-auto) and a pinned user footer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
325 lines
13 KiB
TypeScript
325 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState, type ReactNode } from 'react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { useAuthStore } from '@/lib/store';
|
|
import api from '@/lib/api';
|
|
import { Link } from '@/i18n/Link';
|
|
import { useLocalizedRouter, usePathname } from '@/i18n/navigation';
|
|
import { useT } from '@/i18n/I18nProvider';
|
|
import { LanguageSwitcher } from '@/i18n/LanguageSwitcher';
|
|
import type { Dictionary } from '@/i18n/dictionaries/fa';
|
|
import { DeploymentProgressManager } from '@/components/deployment-progress-manager';
|
|
import { useDeployProgressStore } from '@/lib/deploy-progress-store';
|
|
import {
|
|
LayoutDashboard,
|
|
Package,
|
|
Rocket,
|
|
Ticket,
|
|
Users,
|
|
Server,
|
|
Scale,
|
|
ClipboardList,
|
|
Wrench,
|
|
Briefcase,
|
|
Cloud,
|
|
Menu,
|
|
X,
|
|
LogOut,
|
|
Boxes,
|
|
Wallet,
|
|
CreditCard,
|
|
ScrollText,
|
|
FileText,
|
|
Database,
|
|
} from 'lucide-react';
|
|
|
|
type NavKey = keyof Dictionary['nav'];
|
|
type NavItem = { href: string; labelKey: NavKey; icon: ReactNode };
|
|
|
|
const userNavItems: NavItem[] = [
|
|
{ href: '/dashboard', labelKey: 'dashboard', icon: <LayoutDashboard className="w-4 h-4" /> },
|
|
{ href: '/dashboard/apps', labelKey: 'applications', icon: <Package className="w-4 h-4" /> },
|
|
{ href: '/dashboard/services', labelKey: 'services', icon: <Database className="w-4 h-4" /> },
|
|
{ href: '/dashboard/logs', labelKey: 'logs', icon: <ScrollText className="w-4 h-4" /> },
|
|
{ href: '/dashboard/deploy', labelKey: 'newDeploy', icon: <Rocket className="w-4 h-4" /> },
|
|
{ href: '/dashboard/wallet', labelKey: 'wallet', icon: <Wallet className="w-4 h-4" /> },
|
|
{ href: '/dashboard/invoices', labelKey: 'invoices', icon: <FileText className="w-4 h-4" /> },
|
|
{ href: '/dashboard/tickets', labelKey: 'tickets', icon: <Ticket className="w-4 h-4" /> },
|
|
];
|
|
|
|
const adminNavItems: NavItem[] = [
|
|
{ href: '/dashboard/admin/users', labelKey: 'users', icon: <Users className="w-4 h-4" /> },
|
|
{ href: '/dashboard/admin/apps', labelKey: 'allApplications', icon: <Boxes className="w-4 h-4" /> },
|
|
{ href: '/dashboard/admin/billing', labelKey: 'billingPlans', icon: <CreditCard className="w-4 h-4" /> },
|
|
{ href: '/dashboard/admin/invoices', labelKey: 'invoices', icon: <FileText className="w-4 h-4" /> },
|
|
{ href: '/dashboard/admin/clusters', labelKey: 'clusters', icon: <Server className="w-4 h-4" /> },
|
|
{ href: '/dashboard/admin/pools', labelKey: 'clusterPools', icon: <Scale className="w-4 h-4" /> },
|
|
{ href: '/dashboard/admin/tickets', labelKey: 'allTickets', icon: <ClipboardList className="w-4 h-4" /> },
|
|
];
|
|
|
|
const technicalNavItems: NavItem[] = [
|
|
{ href: '/dashboard/admin/users', labelKey: 'users', icon: <Users className="w-4 h-4" /> },
|
|
{ href: '/dashboard/admin/apps', labelKey: 'allApplications', icon: <Boxes className="w-4 h-4" /> },
|
|
{ href: '/dashboard/staff/tickets', labelKey: 'technicalTickets', icon: <Wrench className="w-4 h-4" /> },
|
|
];
|
|
|
|
const salesNavItems: NavItem[] = [
|
|
{ href: '/dashboard/admin/users', labelKey: 'users', icon: <Users className="w-4 h-4" /> },
|
|
{ href: '/dashboard/staff/tickets', labelKey: 'salesTickets', icon: <Briefcase className="w-4 h-4" /> },
|
|
];
|
|
|
|
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
|
const t = useT();
|
|
const { user, isAuthenticated, isLoading, logout } = useAuthStore();
|
|
const router = useLocalizedRouter();
|
|
const pathname = usePathname();
|
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
const deployBarMinimized = useDeployProgressStore((s) => s.minimized);
|
|
|
|
useEffect(() => {
|
|
if (!isLoading && !isAuthenticated) {
|
|
router.push('/login');
|
|
}
|
|
}, [isLoading, isAuthenticated, router]);
|
|
|
|
// Close sidebar on route change (mobile)
|
|
useEffect(() => {
|
|
setSidebarOpen(false);
|
|
}, [pathname]);
|
|
|
|
// Fetch unanswered ticket counts for staff/admin roles
|
|
// Must be called before any early returns to respect React's rules of hooks
|
|
const isStaffOrAdmin = user?.role === 'admin' || user?.role === 'technical' || user?.role === 'sales';
|
|
const { data: unansweredCounts } = useQuery<{ technical: number; sales: number; total: number }>({
|
|
queryKey: ['unanswered-counts'],
|
|
queryFn: () => api.get('/tickets/unanswered-counts').then((r) => r.data),
|
|
enabled: isStaffOrAdmin && isAuthenticated,
|
|
refetchInterval: 30000,
|
|
});
|
|
|
|
// Fetch wallet balance for all authenticated users (shown in header)
|
|
const { data: walletData } = useQuery<{ balance: number }>({
|
|
queryKey: ['wallet-balance'],
|
|
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
|
enabled: isAuthenticated,
|
|
refetchInterval: 60000,
|
|
});
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
|
<div className="flex flex-col items-center gap-3">
|
|
<div className="animate-spin rounded-full h-10 w-10 border-2 border-primary-600 border-t-transparent" />
|
|
<span className="text-sm text-gray-500">{t.common.loading}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!isAuthenticated) return null;
|
|
|
|
const NavLink = ({ item, badge }: { item: NavItem; badge?: number }) => {
|
|
const isActive = pathname === item.href || (item.href !== '/dashboard' && pathname.startsWith(item.href + '/'));
|
|
return (
|
|
<Link
|
|
href={item.href}
|
|
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-150 ${
|
|
isActive
|
|
? 'bg-primary-50 text-primary-700 shadow-sm'
|
|
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
|
|
}`}
|
|
>
|
|
{item.icon}
|
|
<span className="flex-1">{t.nav[item.labelKey]}</span>
|
|
{badge !== undefined && badge > 0 && (
|
|
<span className="min-w-[20px] h-5 flex items-center justify-center px-1.5 text-xs font-bold rounded-full bg-red-500 text-white">
|
|
{badge}
|
|
</span>
|
|
)}
|
|
</Link>
|
|
);
|
|
};
|
|
|
|
const getBadge = (href: string): number | undefined => {
|
|
if (!unansweredCounts) return undefined;
|
|
if (href === '/dashboard/staff/tickets') {
|
|
// Staff ticket page: show count for their department
|
|
if (user?.role === 'technical') return unansweredCounts.technical;
|
|
if (user?.role === 'sales') return unansweredCounts.sales;
|
|
}
|
|
if (href === '/dashboard/admin/tickets') {
|
|
return unansweredCounts.total;
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const SidebarContent = () => (
|
|
<div className="flex flex-col h-full">
|
|
<div className="space-y-1 flex-1 min-h-0 overflow-y-auto">
|
|
{userNavItems.map((item) => (
|
|
<NavLink key={item.href} item={item} badge={getBadge(item.href)} />
|
|
))}
|
|
|
|
{user?.role === 'admin' && (
|
|
<>
|
|
<div className="pt-5 pb-2">
|
|
<p className="px-3 text-[11px] font-bold text-gray-400 uppercase tracking-widest">
|
|
{t.nav.sectionAdmin}
|
|
</p>
|
|
</div>
|
|
{adminNavItems.map((item) => (
|
|
<NavLink key={item.href} item={item} badge={getBadge(item.href)} />
|
|
))}
|
|
</>
|
|
)}
|
|
|
|
{user?.role === 'technical' && (
|
|
<>
|
|
<div className="pt-5 pb-2">
|
|
<p className="px-3 text-[11px] font-bold text-gray-400 uppercase tracking-widest">
|
|
{t.nav.sectionTechnical}
|
|
</p>
|
|
</div>
|
|
{technicalNavItems.map((item) => (
|
|
<NavLink key={item.href} item={item} badge={getBadge(item.href)} />
|
|
))}
|
|
</>
|
|
)}
|
|
|
|
{user?.role === 'sales' && (
|
|
<>
|
|
<div className="pt-5 pb-2">
|
|
<p className="px-3 text-[11px] font-bold text-gray-400 uppercase tracking-widest">
|
|
{t.nav.sectionSales}
|
|
</p>
|
|
</div>
|
|
{salesNavItems.map((item) => (
|
|
<NavLink key={item.href} item={item} badge={getBadge(item.href)} />
|
|
))}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Sidebar footer */}
|
|
<div className="pt-4 mt-4 border-t border-gray-200 shrink-0">
|
|
<div className="px-3 py-2">
|
|
<p className="text-xs font-semibold text-gray-700 truncate">
|
|
{user?.firstName} {user?.lastName}
|
|
</p>
|
|
<p className="text-xs text-gray-400 truncate">{user?.email}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50">
|
|
<DeploymentProgressManager />
|
|
{/* Mobile overlay */}
|
|
{sidebarOpen && (
|
|
<div
|
|
className="fixed inset-0 bg-black/30 z-40 lg:hidden"
|
|
onClick={() => setSidebarOpen(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* Mobile sidebar */}
|
|
<aside
|
|
className={`fixed inset-y-0 left-0 rtl:left-auto rtl:right-0 z-50 w-64 bg-white border-r rtl:border-r-0 rtl:border-l border-gray-200 p-4 flex flex-col transform transition-transform duration-200 ease-in-out lg:hidden ${
|
|
sidebarOpen ? 'translate-x-0' : '-translate-x-full rtl:translate-x-full'
|
|
}`}
|
|
>
|
|
<div className="flex items-center justify-between mb-6 shrink-0">
|
|
<Link href="/dashboard" className="text-lg font-bold text-primary-600">
|
|
<Cloud className="w-5 h-5 inline mr-1 rtl:mr-0 rtl:ml-1" /> {t.common.appName}
|
|
</Link>
|
|
<button onClick={() => setSidebarOpen(false)} className="btn-icon">
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
<div className="flex-1 min-h-0">
|
|
<SidebarContent />
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Header */}
|
|
<header
|
|
className={`bg-white/80 backdrop-blur-lg border-b border-gray-200/80 sticky z-30 ${
|
|
deployBarMinimized ? 'top-11' : 'top-0'
|
|
}`}
|
|
>
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
<div className="flex justify-between items-center h-16">
|
|
<div className="flex items-center gap-3">
|
|
{/* Mobile hamburger */}
|
|
<button
|
|
onClick={() => setSidebarOpen(true)}
|
|
className="btn-icon lg:hidden"
|
|
>
|
|
<Menu className="w-5 h-5" />
|
|
</button>
|
|
<Link href="/dashboard" className="text-xl font-bold text-primary-600 flex items-center gap-2">
|
|
<Cloud className="w-6 h-6" />
|
|
<span className="hidden sm:inline">{t.common.appName}</span>
|
|
</Link>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{/* Wallet balance */}
|
|
<Link
|
|
href="/dashboard/wallet"
|
|
className="hidden sm:flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-gray-100 hover:bg-gray-200 transition-colors text-sm"
|
|
title={t.nav.walletBalanceTitle}
|
|
>
|
|
<Wallet className="w-3.5 h-3.5 text-primary-600" />
|
|
<span className="font-bold text-gray-800">
|
|
{walletData ? Number(walletData.balance).toLocaleString('en-US') : '...'}
|
|
</span>
|
|
<span className="text-gray-500 text-xs">{t.common.currencyShort}</span>
|
|
</Link>
|
|
|
|
<LanguageSwitcher className="text-gray-600 hover:bg-gray-100 hover:text-gray-900" />
|
|
|
|
<div className="hidden sm:flex items-center gap-2 text-sm">
|
|
<span className="text-gray-600 font-medium">
|
|
{user?.firstName} {user?.lastName}
|
|
</span>
|
|
{user?.role === 'admin' && (
|
|
<span className="badge-purple">{t.roles.admin}</span>
|
|
)}
|
|
{user?.role === 'technical' && (
|
|
<span className="px-2 py-0.5 text-xs font-semibold rounded-full bg-blue-100 text-blue-700">{t.roles.technical}</span>
|
|
)}
|
|
{user?.role === 'sales' && (
|
|
<span className="px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-700">{t.roles.sales}</span>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={() => { logout(); router.push('/login'); }}
|
|
className="btn-ghost text-gray-500 hover:text-red-600"
|
|
>
|
|
<LogOut className="w-4 h-4 sm:hidden" />
|
|
<span className="hidden sm:inline">{t.common.signOut}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 lg:py-8">
|
|
<div className="flex gap-8">
|
|
{/* Desktop sidebar */}
|
|
<nav className="hidden lg:block w-56 flex-shrink-0">
|
|
<div className="sticky top-24">
|
|
<SidebarContent />
|
|
</div>
|
|
</nav>
|
|
|
|
{/* Main content */}
|
|
<main className="flex-1 min-w-0 animate-fade-in">{children}</main>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|