274 lines
16 KiB
TypeScript
274 lines
16 KiB
TypeScript
import { useState } from 'react';
|
|
import { Outlet, Link, useLocation } from 'react-router-dom';
|
|
import { useAuth } from '../../shared/auth/AuthContext';
|
|
import { useSystem } from '../../shared/context/SystemContext';
|
|
import { Settings, LogOut, Box, ChevronDown, ChevronRight, Layers, User as UserIcon, Video, Shield, Info, Home, UserCog } from 'lucide-react';
|
|
import type { IModuleDefinition } from '../../core/types';
|
|
import './MainLayout.css';
|
|
|
|
interface MainLayoutProps {
|
|
modulesList: IModuleDefinition[];
|
|
}
|
|
|
|
export function MainLayout({ modulesList }: MainLayoutProps) {
|
|
const location = useLocation();
|
|
const { user, logout } = useAuth();
|
|
const { modules } = useSystem();
|
|
const [expandedModules, setExpandedModules] = useState<string[]>(['asset-management']);
|
|
|
|
const isAdmin = user ? (['admin', 'supervisor'] as string[]).includes(user.role) : false;
|
|
|
|
const checkRole = (requiredRoles: string[]) => {
|
|
if (!user) return false;
|
|
// Supervisor possesses all rights
|
|
if (user.role === 'supervisor') return true;
|
|
return requiredRoles.includes(user.role);
|
|
};
|
|
|
|
const toggleModule = (moduleId: string) => {
|
|
setExpandedModules(prev =>
|
|
prev.includes(moduleId)
|
|
? prev.filter(id => id !== moduleId)
|
|
: [...prev, moduleId]
|
|
);
|
|
};
|
|
|
|
const isModuleActive = (code: string) => modules[code]?.active;
|
|
|
|
return (
|
|
<div className="layout-container">
|
|
<aside className="sidebar">
|
|
<div className="sidebar-header">
|
|
<div className="brand">
|
|
<Box className="brand-icon" size={24} />
|
|
<span className="brand-text">Smart IMS</span>
|
|
</div>
|
|
</div>
|
|
|
|
<nav className="sidebar-nav">
|
|
{/* Platform Home */}
|
|
<div className="module-group mb-4">
|
|
<Link to="/home" className={`nav-item ${location.pathname === '/home' ? 'active' : ''}`}>
|
|
<Home size={18} />
|
|
<span>플랫폼 홈</span>
|
|
</Link>
|
|
</div>
|
|
|
|
{/* System Management (Platform Core) */}
|
|
{isAdmin && (
|
|
<div className="module-group">
|
|
<button
|
|
className={`module-header ${expandedModules.includes('sys_mgmt') ? 'active' : ''}`}
|
|
onClick={() => toggleModule('sys_mgmt')}
|
|
>
|
|
<div className="module-title">
|
|
<Settings size={18} />
|
|
<span>시스템 관리</span>
|
|
</div>
|
|
{expandedModules.includes('sys_mgmt') ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
|
</button>
|
|
|
|
{expandedModules.includes('sys_mgmt') && (
|
|
<div className="module-items">
|
|
<Link to="/admin/settings" className={`nav-item ${location.pathname.includes('/admin/settings') ? 'active' : ''}`}>
|
|
<Settings size={18} />
|
|
<span>기본 설정</span>
|
|
</Link>
|
|
<Link to="/admin/users" className={`nav-item ${location.pathname.includes('/admin/users') ? 'active' : ''}`}>
|
|
<UserIcon size={18} />
|
|
<span>사용자 관리</span>
|
|
</Link>
|
|
<Link to="/admin/license" className={`nav-item ${location.pathname.includes('/admin/license') ? 'active' : ''}`}>
|
|
<Shield size={18} />
|
|
<span>모듈/라이선스 관리</span>
|
|
</Link>
|
|
<Link to="/admin/version" className={`nav-item ${location.pathname.includes('/admin/version') ? 'active' : ''}`}>
|
|
<Info size={18} />
|
|
<span>버전 정보</span>
|
|
</Link>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Dynamic Modules Injection */}
|
|
{modulesList.map((mod) => {
|
|
const moduleKey = mod.moduleName.split('-')[0];
|
|
if (!isModuleActive(moduleKey)) return null;
|
|
|
|
// Check roles with hierarchy
|
|
if (mod.requiredRoles && !checkRole(mod.requiredRoles)) return null;
|
|
|
|
const hasSubMenu = mod.routes.filter(r => r.label).length > 1;
|
|
const isExpanded = expandedModules.includes(mod.moduleName);
|
|
|
|
return (
|
|
<div key={mod.moduleName} className="module-group">
|
|
{hasSubMenu ? (
|
|
<>
|
|
<button
|
|
className={`module-header ${isExpanded ? 'active' : ''}`}
|
|
onClick={() => toggleModule(mod.moduleName)}
|
|
>
|
|
<div className="module-title">
|
|
{mod.moduleName === 'cctv' ? <Video size={18} /> : <Layers size={18} />}
|
|
<span>{mod.label || (mod.moduleName.split('-')[0].charAt(0).toUpperCase() + mod.moduleName.split('-')[0].slice(1) + ' 관리')}</span>
|
|
</div>
|
|
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
|
</button>
|
|
{isExpanded && (
|
|
<div className="module-items">
|
|
{(() => {
|
|
const groupedRoutes: Record<string, typeof mod.routes> = {};
|
|
const ungroupedRoutes: typeof mod.routes = [];
|
|
|
|
mod.routes.filter(r => {
|
|
const isLabelVisible = !!r.label;
|
|
const isSidebarPosition = !r.position || r.position === 'sidebar';
|
|
const isSettingsRoute = r.path.includes('settings');
|
|
const hasPermission = user?.role !== 'user' || !isSettingsRoute;
|
|
|
|
return isLabelVisible && isSidebarPosition && hasPermission;
|
|
}).forEach(route => {
|
|
if (route.group) {
|
|
if (!groupedRoutes[route.group]) groupedRoutes[route.group] = [];
|
|
groupedRoutes[route.group].push(route);
|
|
} else {
|
|
ungroupedRoutes.push(route);
|
|
}
|
|
});
|
|
|
|
return (
|
|
<>
|
|
{ungroupedRoutes.map(route => (
|
|
<Link
|
|
key={`${mod.moduleName}-${route.path}`}
|
|
to={`${mod.basePath}${route.path}`}
|
|
className={`nav-item ${location.pathname.startsWith(`${mod.basePath}${route.path}`) ? 'active' : ''}`}
|
|
>
|
|
{route.icon || <Box size={18} />}
|
|
<span>{route.label}</span>
|
|
</Link>
|
|
))}
|
|
|
|
{Object.entries(groupedRoutes).map(([groupName, routes]) => (
|
|
<div key={groupName} className="menu-group-section">
|
|
<div className="menu-group-label text-xs font-bold text-slate-500 uppercase px-3 py-2 mt-2 mb-1">
|
|
{groupName}
|
|
</div>
|
|
{routes.map(route => (
|
|
<Link
|
|
key={`${mod.moduleName}-${route.path}`}
|
|
to={`${mod.basePath}${route.path}`}
|
|
className={`nav-item ${location.pathname.startsWith(`${mod.basePath}${route.path}`) ? 'active' : ''}`}
|
|
>
|
|
{route.icon || <Box size={18} />}
|
|
<span>{route.label}</span>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
))}
|
|
</>
|
|
);
|
|
})()}
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<Link
|
|
to={`${mod.basePath}${mod.routes[0]?.path || ''}`}
|
|
className={`module-header ${location.pathname.startsWith(mod.basePath) ? 'active' : ''}`}
|
|
style={{ textDecoration: 'none' }}
|
|
>
|
|
<div className="module-title">
|
|
<Video size={18} />
|
|
<span>{mod.label || mod.moduleName.split('-')[0].toUpperCase()}</span>
|
|
</div>
|
|
</Link>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
<div className="sidebar-divider my-4 border-t border-slate-100 opacity-50"></div>
|
|
|
|
{/* User Preferences - Accessible to all roles */}
|
|
<div className="module-group">
|
|
<Link to="/preferences" className={`nav-item ${location.pathname === '/preferences' ? 'active' : ''}`}>
|
|
<UserCog size={18} />
|
|
<span>기본 설정</span>
|
|
</Link>
|
|
</div>
|
|
</nav>
|
|
|
|
<div className="sidebar-footer">
|
|
<div className="user-info">
|
|
<div className="user-avatar bg-slate-700 text-white flex items-center justify-center rounded-full w-8 h-8 text-xs font-bold">
|
|
{user?.name?.slice(0, 2) || 'USER'}
|
|
</div>
|
|
<div className="user-details">
|
|
<span className="user-name">{user?.name}</span>
|
|
<span className="user-role text-xs text-slate-400 capitalize">{user?.role}</span>
|
|
</div>
|
|
</div>
|
|
<button className="logout-btn" onClick={logout} title="로그아웃">
|
|
<LogOut size={18} />
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
|
|
<main className="main-content">
|
|
<header className="top-header">
|
|
<div className="header-tabs">
|
|
{/* Dynamic Tabs based on Active Module 'top' routes */}
|
|
{(() => {
|
|
// Find active module based on current path
|
|
const activeModule = modulesList.find(m => location.pathname.startsWith(m.basePath));
|
|
if (!activeModule) return null;
|
|
|
|
const topRoutes = location.pathname.includes('/settings')
|
|
? []
|
|
: activeModule.routes.filter(r => r.position === 'top');
|
|
|
|
return topRoutes.map(route => (
|
|
<Link
|
|
key={`${activeModule.moduleName}-top-${route.path}`}
|
|
to={`${activeModule.basePath}${route.path}`}
|
|
className={`tab-item ${location.pathname.startsWith(`${activeModule.basePath}${route.path}`) ? 'active' : ''}`}
|
|
>
|
|
{route.label}
|
|
</Link>
|
|
));
|
|
})()}
|
|
|
|
{/* Asset Settings Specific Tabs */}
|
|
{location.pathname.startsWith('/asset/settings') && (
|
|
<div className="header-tabs">
|
|
{[
|
|
{ id: 'basic', label: '기본 설정' },
|
|
{ id: 'category', label: '카테고리 관리' },
|
|
{ id: 'location', label: '설치 위치' },
|
|
{ id: 'status', label: '자산 상태' },
|
|
{ id: 'maintenance', label: '정비 구분' }
|
|
].map(tab => (
|
|
<Link
|
|
key={tab.id}
|
|
to={`/asset/settings?tab=${tab.id}`}
|
|
className={`tab-item ${(new URLSearchParams(location.search).get('tab') || 'basic') === tab.id ? 'active' : ''}`}
|
|
>
|
|
{tab.label}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div id="header-portal-root" className="header-portal-root"></div>
|
|
</header>
|
|
<div className="content-area">
|
|
<Outlet />
|
|
</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|