234 lines
9.1 KiB
TypeScript
234 lines
9.1 KiB
TypeScript
import React, { useState } from 'react';
|
||
import { NavLink } from 'react-router-dom';
|
||
import { useSessionStore } from '../../core/session/sessionStore';
|
||
import { useMetadata } from '../../services/hooks/useMetadata';
|
||
import { SYSTEM_MODULES } from '../../core/modules/registry';
|
||
import { SYSTEM_ROLES } from '../../core/permissions/types';
|
||
import {
|
||
LayoutDashboard,
|
||
Workflow,
|
||
Users,
|
||
ShoppingBag,
|
||
Boxes,
|
||
BadgeDollarSign,
|
||
UserCheck,
|
||
MessageSquareText,
|
||
Headset,
|
||
CheckSquare,
|
||
Globe,
|
||
Megaphone,
|
||
BarChart3,
|
||
Cpu,
|
||
Sparkles,
|
||
Grid,
|
||
Settings,
|
||
Truck,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
} from 'lucide-react';
|
||
|
||
const ICON_MAP: Record<string, React.ReactNode> = {
|
||
LayoutDashboard: <LayoutDashboard className="w-4 h-4 shrink-0" />,
|
||
Workflow: <Workflow className="w-4 h-4 shrink-0 text-krona-gold" />,
|
||
Users: <Users className="w-4 h-4 shrink-0" />,
|
||
ShoppingBag: <ShoppingBag className="w-4 h-4 shrink-0" />,
|
||
Boxes: <Boxes className="w-4 h-4 shrink-0" />,
|
||
Truck: <Truck className="w-4 h-4 shrink-0" />,
|
||
BadgeDollarSign: <BadgeDollarSign className="w-4 h-4 shrink-0" />,
|
||
UserCheck: <UserCheck className="w-4 h-4 shrink-0" />,
|
||
MessageSquareText: <MessageSquareText className="w-4 h-4 shrink-0" />,
|
||
Headset: <Headset className="w-4 h-4 shrink-0" />,
|
||
CheckSquare: <CheckSquare className="w-4 h-4 shrink-0" />,
|
||
Globe: <Globe className="w-4 h-4 shrink-0" />,
|
||
Megaphone: <Megaphone className="w-4 h-4 shrink-0" />,
|
||
BarChart3: <BarChart3 className="w-4 h-4 shrink-0" />,
|
||
Cpu: <Cpu className="w-4 h-4 shrink-0" />,
|
||
Sparkles: <Sparkles className="w-4 h-4 shrink-0 text-krona-gold" />,
|
||
Grid: <Grid className="w-4 h-4 shrink-0" />,
|
||
Settings: <Settings className="w-4 h-4 shrink-0" />,
|
||
};
|
||
|
||
export interface SidebarProps {
|
||
isTabletCompact?: boolean;
|
||
}
|
||
|
||
export const Sidebar: React.FC<SidebarProps> = ({ isTabletCompact = false }) => {
|
||
const [userCollapsed, setUserCollapsed] = useState<boolean | null>(null);
|
||
const collapsed = userCollapsed !== null ? userCollapsed : isTabletCompact;
|
||
const { currentRoleId, hasModulePermission } = useSessionStore();
|
||
const { entities } = useMetadata();
|
||
const customEntities = entities.filter((e) => !e.isSystem);
|
||
const role = SYSTEM_ROLES[currentRoleId];
|
||
|
||
// Filter modules by role permissions (strict deny-by-default via sessionStore)
|
||
const visibleModules = SYSTEM_MODULES.filter((m) => hasModulePermission(m.id));
|
||
|
||
return (
|
||
<aside
|
||
data-testid="sidebar"
|
||
className={`relative h-screen bg-[#252722] text-[#D8D8D0] hidden md:flex flex-col border-r border-[#3A3D35] transition-all duration-200 z-20 select-none ${
|
||
collapsed ? 'w-16' : 'w-56'
|
||
}`}
|
||
>
|
||
{/* Brand Header */}
|
||
<div className="h-14 px-3.5 flex items-center justify-between border-b border-[#3A3D35] bg-[#20221E]">
|
||
<div className="flex items-center gap-2.5 overflow-hidden">
|
||
{/* Logo asset from public/branding/krona-symbol.png */}
|
||
<img
|
||
src="/branding/krona-symbol.png"
|
||
alt="Крона"
|
||
className="w-8 h-8 object-contain shrink-0"
|
||
/>
|
||
{!collapsed && (
|
||
<div className="flex flex-col min-w-0 leading-tight select-none">
|
||
<span className="text-sm font-bold tracking-wider text-[#E5B842] uppercase truncate">
|
||
Крона
|
||
</span>
|
||
<span className="text-[9px] uppercase tracking-widest text-[#B78335] font-semibold">
|
||
Бизнес-платформа
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<button
|
||
onClick={() => setUserCollapsed(!collapsed)}
|
||
className="p-1 rounded text-[#858980] hover:text-[#D8D8D0] hover:bg-[#30332C] transition-colors"
|
||
title={collapsed ? 'Развернуть меню' : 'Свернуть меню'}
|
||
>
|
||
{collapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Navigation List */}
|
||
<div className="flex-1 overflow-y-auto py-2.5 px-2 space-y-0.5">
|
||
{visibleModules.map((item) => (
|
||
<NavLink
|
||
key={item.id}
|
||
to={item.route}
|
||
end={item.route === '/'}
|
||
className={({ isActive }) =>
|
||
`group relative flex items-center gap-2.5 px-2.5 py-2 rounded-md text-xs font-medium transition-all ${
|
||
isActive
|
||
? 'bg-[#34382F] text-[#F5F0E5] font-semibold'
|
||
: 'text-[#BFC1B9] hover:text-[#F5F0E5] hover:bg-[#30332C]'
|
||
}`
|
||
}
|
||
title={collapsed ? item.label : undefined}
|
||
>
|
||
{({ isActive }) => (
|
||
<>
|
||
{/* Active indicator bar */}
|
||
{isActive && (
|
||
<div className="absolute left-0 top-1.5 bottom-1.5 w-1 bg-[#B78335] rounded-r" />
|
||
)}
|
||
|
||
<div className={`shrink-0 ${isActive ? 'text-[#D4B16D]' : 'text-[#BFC1B9]'}`}>
|
||
{ICON_MAP[item.iconName] || <LayoutDashboard className="w-4 h-4 shrink-0" />}
|
||
</div>
|
||
|
||
{!collapsed && (
|
||
<div className="flex-1 flex items-center justify-between truncate">
|
||
<span className="truncate">{item.label}</span>
|
||
{item.badge && (
|
||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-[#3A3D35] text-[#D8D8D0] font-mono">
|
||
{item.badge}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</NavLink>
|
||
))}
|
||
|
||
{/* Custom Entities Section */}
|
||
{customEntities.length > 0 && (
|
||
<div className="pt-2 mt-2 border-t border-[#3A3D35]/80">
|
||
{!collapsed && (
|
||
<div className="px-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-[#858980]">
|
||
Пользовательские модули
|
||
</div>
|
||
)}
|
||
{customEntities.map((ent) => (
|
||
<NavLink
|
||
key={ent.code}
|
||
to={`/entities/${ent.code}`}
|
||
className={({ isActive }) =>
|
||
`group relative flex items-center gap-2.5 px-2.5 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||
isActive
|
||
? 'bg-[#34382F] text-[#F5F0E5] font-semibold'
|
||
: 'text-[#BFC1B9] hover:text-[#F5F0E5] hover:bg-[#30332C]'
|
||
}`
|
||
}
|
||
title={collapsed ? ent.name : undefined}
|
||
>
|
||
{({ isActive }) => (
|
||
<>
|
||
{isActive && (
|
||
<div className="absolute left-0 top-1.5 bottom-1.5 w-1 bg-[#B78335] rounded-r" />
|
||
)}
|
||
<div className="shrink-0 text-krona-gold">
|
||
<Sparkles className="w-3.5 h-3.5" />
|
||
</div>
|
||
{!collapsed && (
|
||
<span className="truncate text-xs">{ent.name}</span>
|
||
)}
|
||
</>
|
||
)}
|
||
</NavLink>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Configuration Engine Quick Link */}
|
||
<div className="pt-1">
|
||
<NavLink
|
||
to="/settings/entities"
|
||
className={({ isActive }) =>
|
||
`group relative flex items-center gap-2.5 px-2.5 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||
isActive
|
||
? 'bg-[#34382F] text-[#F5F0E5] font-semibold'
|
||
: 'text-[#BFC1B9] hover:text-[#F5F0E5] hover:bg-[#30332C]'
|
||
}`
|
||
}
|
||
title={collapsed ? 'Конструктор сущностей' : undefined}
|
||
>
|
||
{({ isActive }) => (
|
||
<>
|
||
{isActive && (
|
||
<div className="absolute left-0 top-1.5 bottom-1.5 w-1 bg-[#B78335] rounded-r" />
|
||
)}
|
||
<div className="shrink-0 text-krona-green">
|
||
<Workflow className="w-3.5 h-3.5" />
|
||
</div>
|
||
{!collapsed && (
|
||
<span className="truncate text-xs">Конструктор сущностей</span>
|
||
)}
|
||
</>
|
||
)}
|
||
</NavLink>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Footer / Role indicator */}
|
||
<div className="p-2.5 border-t border-[#3A3D35] bg-[#20221E]/60 text-xs">
|
||
{!collapsed ? (
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-2 h-2 rounded-full bg-krona-green animate-pulse" />
|
||
<div className="truncate">
|
||
<div className="text-[11px] font-semibold text-[#F5F0E5] truncate">{role.label}</div>
|
||
<div className="text-[9px] text-[#858980]">RBAC: {role.badge}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="flex justify-center" title={`Текущая роль: ${role.label}`}>
|
||
<div className="w-2.5 h-2.5 rounded-full bg-krona-green" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
</aside>
|
||
);
|
||
};
|