486 lines
16 KiB
TypeScript
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;
|