Files
saas_frontend/src/components/layout/AppSidebar.tsx
T
2026-08-31 20:40:20 -04:00

486 lines
16 KiB
TypeScript

import { useCallback, useMemo, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
LayoutGrid,
Package,
UserCog,
Building2,
UsersRound,
X,
LogOut,
PanelLeft,
Palette,
KeyRound,
List,
Paperclip,
LogIn,
Mail,
Network,
Settings,
Webhook,
ChevronDown,
ChevronRight,
CreditCard,
FileText,
Activity,
} from "lucide-react";
import { useSidebar } from "../../context/SidebarContext";
import { usePermission } from "../../lib/usePermission";
import { useAuth } from "../../context/AuthContext";
import { authApi } from "../../application/authentication/AuthApi";
import { clearAuthCookies } from "../../lib/authCookies";
interface SubMenuItem {
icon: React.ReactNode;
name: string;
path: string;
access?: string;
}
interface NavItem {
icon: React.ReactNode;
name: string;
path: string;
access?: string;
superadminOnly?: boolean;
submenu?: SubMenuItem[];
}
const navItems: NavItem[] = [
{
icon: <LayoutGrid size={22} />,
name: "Dashboard",
path: "/dashboard",
},
{
icon: <Building2 size={22} />,
name: "Tenants",
path: "/tenants",
access: "superadmin.tenant.read",
},
{
icon: <CreditCard size={22} />,
name: "Subscriptions",
path: "/subscriptions",
access: "superadmin.plan.read",
},
{
icon: <UserCog size={22} />,
name: "Roles",
path: "/roles",
access: "admin.role.read",
},
{
icon: <UsersRound size={22} />,
name: "Users",
path: "/users",
access: "admin.user.read",
},
{
icon: <Palette size={22} />,
name: "Themes",
path: "/theme",
access: "superadmin.palette.read",
},
{
icon: <Package size={22} />,
name: "Modules",
path: "/admin/modules",
access: "modules.view",
},
{
icon: <FileText size={22} />,
name: "Logs",
path: "/logs",
access: "admin.logs.read",
},
{
icon: <Activity size={22} />,
name: "Operations",
path: "/operations",
superadminOnly: true,
},
{
icon: <Paperclip size={22} />,
name: "Documents",
path: "/documents",
access: "admin.document.read",
},
{
icon: <List size={22} />,
name: "Reference data",
path: "/settings/reference",
access: "admin.lookup.manage",
},
{
icon: <Mail size={22} />,
name: "Email",
path: "/settings/email",
access: "admin.email.manage",
},
{
icon: <Network size={22} />,
name: "Organisation",
path: "/settings/organisation",
access: "admin.user.read",
},
{
icon: <LogIn size={22} />,
name: "Sign-in",
path: "/settings/sign-in",
access: "admin.sso.read",
},
{
icon: <KeyRound size={22} />,
name: "API keys",
path: "/settings/api-keys",
access: "admin.api_key.manage",
},
{
icon: <Webhook size={22} />,
name: "Webhooks",
path: "/settings/webhooks",
access: "admin.webhook.manage",
},
{
icon: <Settings size={22} />,
name: "Settings",
path: "/settings",
},
];
const AppSidebar: React.FC = () => {
const { t } = useTranslation(['common', 'modules', 'dashboard']);
const navigate = useNavigate();
const {
isExpanded,
isMobileOpen,
isMobile,
toggleSidebar,
closeMobileSidebar,
} = useSidebar();
const location = useLocation();
const { can } = usePermission();
const { user, isLoading } = useAuth();
const getNavTranslation = (name: string): string => {
const keyMap: Record<string, string> = {
'Dashboard': 'dashboard',
'Orders': 'orders',
'Tenants': 'tenants',
'Users': 'users',
'Roles': 'roles',
'Themes': 'themes',
'Configuration': 'configuration',
'Locations': 'locations',
'Packaging': 'packaging',
'Dropdowns': 'dropdowns',
'Settings': 'settings',
'Modules': 'modules',
'Logs': 'logs',
'Operations': 'operations',
'Documents': 'documents',
'Reference data': 'reference',
'Email': 'email',
'Organisation': 'organisation',
'Sign-in': 'signIn',
'API keys': 'apiKeys',
'Webhooks': 'webhooks',
'Subscriptions': 'subscriptions',
};
const key = keyMap[name];
if (key) {
return t(`nav.${key}`, name);
}
return t([
`modules:${name}`,
`dashboard:${name}`,
`common:nav.${name}`,
name
]);
};
const visibleNavItems = useMemo(
() => navItems.filter((item) => {
if (item.superadminOnly) return Boolean(user?.is_superadmin);
if (!item.access) return true;
if (item.submenu && item.submenu.length > 0) {
return item.submenu.some(subItem => !subItem.access || can(subItem.access));
}
return can(item.access);
}),
[can, user]
);
const isActive = useCallback(
(path: string) => location.pathname === path || location.pathname.startsWith(path + "/"),
[location.pathname]
);
const groupedNavItems = useMemo(() => {
const groups: { label: string; items: NavItem[] }[] = [];
const addedPaths = new Set<string>();
const getItems = (names: string[]) => {
const filtered = visibleNavItems.filter(item =>
names.includes(item.name) && !addedPaths.has(item.path)
);
filtered.forEach(item => addedPaths.add(item.path));
return filtered;
};
const dashboardItems = getItems(["Dashboard"]);
if (dashboardItems.length > 0) {
groups.push({ label: t('nav.groups.overview', 'Overview'), items: dashboardItems });
}
const managementItems = getItems(["Tenants", "Subscriptions", "Roles", "Users",
"Documents"]);
if (managementItems.length > 0) {
groups.push({ label: t('nav.groups.management', 'Management'), items: managementItems });
}
const systemItems = getItems(["Themes", "Modules", "Logs", "Operations",
"Organisation", "Sign-in", "Email", "Reference data", "API keys",
"Webhooks", "Settings"]);
if (systemItems.length > 0) {
groups.push({ label: t('nav.groups.system', 'System'), items: systemItems });
}
const remaining = visibleNavItems.filter(item => !addedPaths.has(item.path));
if (remaining.length > 0) {
groups.push({ label: t('nav.groups.other', 'Other'), items: remaining });
}
return groups;
}, [visibleNavItems, t]);
const [openSubmenus, setOpenSubmenus] = useState<Record<string, boolean>>({});
const toggleSubmenu = (name: string) => {
if (!isExpanded && !isMobile) {
toggleSidebar();
}
setOpenSubmenus(prev => ({ ...prev, [name]: !prev[name] }));
};
const handleLogout = async () => {
try {
await authApi.logout();
} catch {
}
clearAuthCookies();
navigate("/signin");
};
if (isLoading || !user) {
return null;
}
const sidebarWidth = isExpanded ? "w-[260px]" : "w-[80px]";
const mobileClasses = isMobileOpen ? "translate-x-0" : "-translate-x-full";
return (
<>
<aside
className={`
fixed top-0 left-0 z-50 h-screen bg-[var(--sidebar-bg)] text-[var(--sidebar-text)] flex flex-col
transition-all duration-300 ease-in-out border-r border-[var(--sidebar-border)]
lg:translate-x-0 overflow-x-hidden
${isMobile ? "w-[280px]" : sidebarWidth}
${isMobile ? mobileClasses : ""}
sidebar
`}
>
<div className={`h-[70px] flex items-center px-4 border-b border-white/5 ${!isExpanded && !isMobile ? 'justify-center' : 'justify-between'}`}>
{(isExpanded || isMobile) && (
<Link to="/dashboard" className="flex items-center gap-3 overflow-hidden ml-1" onClick={isMobile ? closeMobileSidebar : undefined}>
{user?.role?.accesses?.includes("superadmin.tenant.read") ? (
<img
src="https://maskantech.netlify.app/maskan_footer1.png"
alt="Maskan Tech"
className="h-8 w-auto object-contain"
/>
) : user?.tenant_logo_url ? (
<img
src={user.tenant_logo_url}
alt={user.tenant_name || "Logo"}
className="h-8 w-auto object-contain"
/>
) : (
<div className="flex items-center gap-2">
<div className="shrink-0 w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center text-white font-bold">S</div>
<span className="text-xl font-bold text-white whitespace-nowrap">SaaS</span>
</div>
)}
</Link>
)}
{!isMobile && (
<button
onClick={toggleSidebar}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-white/10 transition-colors"
aria-label="Toggle Sidebar"
>
<PanelLeft size={20} className={!isExpanded ? "rotate-180" : ""} />
</button>
)}
{isMobile && (
<button
onClick={closeMobileSidebar}
className="p-1 rounded-md text-slate-400 hover:text-white hover:bg-white/10"
>
<X size={20} />
</button>
)}
</div>
<nav className="flex-1 overflow-y-auto overflow-x-hidden py-4 px-3 custom-scrollbar">
<div className="flex flex-col gap-6">
{groupedNavItems.map((group, idx) => (
<div key={idx} className="space-y-1.5">
{(isExpanded || isMobile) && (
<p className="px-3 text-[10px] font-bold text-slate-500 uppercase tracking-[0.15em] mb-2 mt-2">
{group.label}
</p>
)}
<ul className="space-y-1.5">
{group.items.map((nav) => {
const active = isActive(nav.path);
const hasSubmenu = nav.submenu && nav.submenu.length > 0;
const isMenuOpen = openSubmenus[nav.name];
if (hasSubmenu) {
return (
<li key={nav.name}>
<button
onClick={() => toggleSubmenu(nav.name)}
className={`
w-full relative flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-200 group
${isMenuOpen
? "bg-white/5 text-white"
: "text-slate-400 hover:bg-white/5 hover:text-white"
}
`}
>
<div className="flex-shrink-0">
{nav.icon}
</div>
{(isExpanded || isMobile) && (
<>
<span className="flex-1 text-left whitespace-nowrap font-medium text-sm">
{getNavTranslation(nav.name)}
</span>
<span className="flex-shrink-0 transition-transform duration-200">
{isMenuOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
</>
)}
</button>
<div className={`overflow-hidden transition-all duration-300 ${isMenuOpen && (isExpanded || isMobile) ? "max-h-[800px] opacity-100 mt-1" : "max-h-0 opacity-0"}`}>
<ul className="space-y-1 pl-4 mt-1 border-l border-white/5 ml-4">
{nav.submenu?.filter(subItem => !subItem.access || can(subItem.access)).map((subItem) => {
const subActive = isActive(subItem.path || "");
return (
<li key={subItem.name}>
<Link
to={subItem.path || "#"}
onClick={isMobile ? closeMobileSidebar : undefined}
className={`
flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors block
${subActive
? "text-white font-bold"
: "text-slate-500 hover:text-slate-300 hover:bg-white/5"
}
`}
>
{subItem.icon && (
<div className="flex-shrink-0 scale-75">
{subItem.icon}
</div>
)}
<span className="truncate">{getNavTranslation(subItem.name)}</span>
</Link>
</li>
);
})}
</ul>
</div>
</li>
);
}
return (
<li key={nav.name}>
<Link
to={nav.path}
onClick={isMobile ? closeMobileSidebar : undefined}
className={`
relative flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-200 group
${active
? "bg-white text-slate-900"
: "text-slate-400 hover:bg-white/5 hover:text-white"
}
`}
>
<div className="flex-shrink-0">
{nav.icon}
</div>
<span
className={`
whitespace-nowrap font-semibold text-sm transition-all duration-300
${(isExpanded || isMobile) ? "opacity-100 w-auto" : "opacity-0 w-0 hidden"}
`}
>
{getNavTranslation(nav.name)}
</span>
{!isExpanded && !isMobile && (
<div className="absolute left-full ml-4 px-2 py-1 bg-slate-800 text-white text-xs rounded shadow-lg opacity-0 group-hover:opacity-100 transition-opacity z-50 whitespace-nowrap pointer-events-none">
{getNavTranslation(nav.name)}
</div>
)}
</Link>
</li>
);
})}
</ul>
</div>
))}
</div>
</nav>
<div className="p-4 border-t border-white/5">
<button
onClick={handleLogout}
className={`group flex items-center gap-3 w-full px-3 py-2.5 text-slate-400 hover:text-white hover:bg-white/5 rounded-xl transition-all overflow-hidden ${(isExpanded || isMobile) ? "justify-start" : "justify-center"}`}
title="Logout"
>
<div className="flex-shrink-0 transition-colors group-hover:text-white">
<LogOut size={20} />
</div>
{(isExpanded || isMobile) && (
<span className="font-medium text-sm">Sign Out</span>
)}
</button>
</div>
</aside>
</>
);
};
export default AppSidebar;