211 lines
9.0 KiB
TypeScript
211 lines
9.0 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { NavLink, Navigate, Outlet, useNavigate, useLocation } from 'react-router-dom';
|
|
import { LogOut, ChevronDown, Database, Users, KeyRound } from 'lucide-react';
|
|
import { cn } from '../lib/cn';
|
|
import { useAuth } from '../auth/context';
|
|
import { onAuthErrorAll, orderBookingClient } from '../api/clients';
|
|
import { APP_VERSION } from '../api/config';
|
|
import { NoticeStrips } from '../components/common/NoticeBanner';
|
|
import { ChangePasswordModal } from '../components/auth/ChangePasswordModal';
|
|
|
|
import { REPORT_MAP } from './ReportPage';
|
|
|
|
import { routeConfig } from '../routesConfig';
|
|
import logo from '../assets/logo.png';
|
|
|
|
const PATH_LABELS: Record<string, string> = {
|
|
"/my-orders": "My Orders",
|
|
"/my-calls": "My Calls",
|
|
"/my-daily": "My Daily Logs",
|
|
"/orders": "Orders",
|
|
"/calls": "Calls",
|
|
"/daily": "Daily Logs",
|
|
"/stores": "Stores",
|
|
"/sales-report": "DSR",
|
|
};
|
|
|
|
/** Auth-guarded shell: navy top bar + tab nav + routed <Outlet>. */
|
|
export function ConsoleLayout() {
|
|
const { authed, logout, isAdmin, roles: userRoles, userEmail } = useAuth();
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const user = orderBookingClient.currentUser();
|
|
|
|
const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false);
|
|
|
|
const isReportsActive = location.pathname.startsWith('/reports');
|
|
|
|
// A 401 from any workflow client bounces back to login.
|
|
useEffect(() => {
|
|
onAuthErrorAll(() => {
|
|
logout();
|
|
navigate('/login', { replace: true });
|
|
});
|
|
}, [logout, navigate]);
|
|
|
|
if (!authed) return <Navigate to="/login" replace />;
|
|
|
|
const reportRoute = routeConfig.find(r => r.path === '/reports/:reportType');
|
|
const canSeeReports = reportRoute && (
|
|
(reportRoute.adminOnly && isAdmin) ||
|
|
(reportRoute.roles && reportRoute.roles.some(r => userRoles.includes(r))) ||
|
|
(!reportRoute.adminOnly && !reportRoute.roles) // open to all
|
|
);
|
|
|
|
return (
|
|
<div className="h-screen bg-[var(--secondary-color)] flex flex-col">
|
|
<header className="sticky top-0 z-50 shrink-0 flex items-center justify-between gap-6 px-6 h-[56px] shadow-sm" style={{ background: 'var(--nav-bg-color)' }}>
|
|
<div className="flex items-center min-w-max w-48 gap-3">
|
|
<img src={logo} alt="Logo" className="h-8 w-auto object-contain" />
|
|
<span className="text-lg font-bold text-white tracking-wide leading-none">Krishna Sales</span>
|
|
</div>
|
|
<nav className="flex items-center justify-end gap-2 h-full flex-1 mr-4">
|
|
{routeConfig.filter(route => {
|
|
if (route.path.includes('/:')) return false; // Skip detail pages
|
|
if (!PATH_LABELS[route.path]) return false; // Skip unmapped routes
|
|
|
|
if (route.adminOnly && !isAdmin) return false;
|
|
if (!route.adminOnly && route.roles) {
|
|
return route.roles.some(r => userRoles.includes(r));
|
|
}
|
|
return true;
|
|
}).map((route) => {
|
|
const key = route.path.replace('/', '');
|
|
return (
|
|
<NavLink
|
|
key={key}
|
|
to={route.path}
|
|
className={({ isActive }) =>
|
|
cn(
|
|
'flex items-center gap-1.5 no-underline font-sans text-[13px] font-semibold px-3 py-1.5 rounded-md transition-all duration-150',
|
|
isActive ? 'bg-white/20 text-white' : 'text-white/90 hover:bg-white/10 hover:text-white',
|
|
)
|
|
}
|
|
>
|
|
{PATH_LABELS[route.path]}
|
|
</NavLink>
|
|
);
|
|
})}
|
|
|
|
{canSeeReports && (
|
|
<div className="relative group flex items-center h-full">
|
|
<button className={cn(
|
|
"flex items-center gap-1.5 no-underline font-sans text-[13px] font-semibold px-3 py-1.5 rounded-md transition-all duration-150 cursor-pointer",
|
|
isReportsActive ? "bg-white/20 text-white" : "text-white/90 hover:bg-white/10 hover:text-white"
|
|
)}>
|
|
Reports
|
|
<ChevronDown size={14} className="ml-0.5 opacity-70" />
|
|
</button>
|
|
|
|
<div className="absolute top-[80%] right-0 mt-1 w-56 bg-white rounded-md shadow-lg py-1 border border-gray-200 hidden group-hover:block z-50">
|
|
{Object.entries(REPORT_MAP).map(([key, report]) => (
|
|
<NavLink
|
|
key={key}
|
|
to={`/reports/${key}`}
|
|
className={({ isActive }) =>
|
|
cn(
|
|
"block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100",
|
|
isActive && "bg-gray-100 font-semibold"
|
|
)
|
|
}
|
|
>
|
|
{report.title}
|
|
</NavLink>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</nav>
|
|
<div className="relative group flex items-center justify-end shrink-0 w-48 h-full py-2">
|
|
<button
|
|
className="w-8 h-8 rounded-full bg-black/20 text-white flex items-center justify-center text-sm font-bold hover:bg-black/30 transition-colors cursor-pointer"
|
|
>
|
|
{user?.name ? user.name.charAt(0).toUpperCase() : 'S'}
|
|
</button>
|
|
|
|
<div className="absolute top-[85%] right-0 mt-1 w-60 bg-white rounded-md shadow-xl py-2 border border-gray-200 hidden group-hover:block z-50 text-left">
|
|
<div className="px-4 py-3 border-b border-gray-100 mb-1">
|
|
<p className="text-sm font-bold text-gray-900 truncate">{user?.name || 'User'}</p>
|
|
<p className="text-xs text-gray-500 truncate mt-0.5">{user?.email || 'user@example.com'}</p>
|
|
{userRoles && userRoles.length > 0 && (
|
|
<div className="mt-2 flex flex-wrap gap-1">
|
|
{userRoles.map(role => (
|
|
<span key={role} className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium bg-blue-100 text-blue-800">
|
|
{role}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{(isAdmin || userRoles?.includes('Admin') || userRoles?.includes('Manager')) && (
|
|
<div className="px-2 pb-1 border-b border-gray-100 mb-1 flex flex-col gap-1">
|
|
{isAdmin && (
|
|
<NavLink
|
|
to="/admin/datasets"
|
|
className={({ isActive }) => cn("w-full text-left px-3 py-2 text-sm font-medium hover:bg-indigo-50 hover:text-indigo-700 rounded-md transition-colors flex items-center gap-2 cursor-pointer", isActive ? "text-indigo-700 bg-indigo-50" : "text-gray-700")}
|
|
>
|
|
<Database size={16} />
|
|
Manage Datasets
|
|
</NavLink>
|
|
)}
|
|
<NavLink
|
|
to="/admin/users"
|
|
className={({ isActive }) => cn("w-full text-left px-3 py-2 text-sm font-medium hover:bg-indigo-50 hover:text-indigo-700 rounded-md transition-colors flex items-center gap-2 cursor-pointer", isActive ? "text-indigo-700 bg-indigo-50" : "text-gray-700")}
|
|
>
|
|
<Users size={16} />
|
|
Manage Users
|
|
</NavLink>
|
|
</div>
|
|
)}
|
|
<div className="px-2 pb-2 flex flex-col gap-1.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setIsChangePasswordOpen(true);
|
|
}}
|
|
className="w-full text-left px-3 py-2 text-sm text-gray-700 font-medium hover:bg-emerald-50 hover:text-emerald-700 rounded-md transition-colors flex items-center gap-2 cursor-pointer"
|
|
>
|
|
<KeyRound size={16} className="text-emerald-600" />
|
|
Change Password
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
logout();
|
|
navigate('/login', { replace: true });
|
|
}}
|
|
className="w-full text-left px-3 py-2 text-sm text-red-600 font-medium hover:bg-red-50 rounded-md transition-colors flex items-center gap-2 cursor-pointer"
|
|
>
|
|
<LogOut size={16} />
|
|
Sign out
|
|
</button>
|
|
<div className="px-3 pt-2 border-t border-gray-100">
|
|
<span className="text-xs font-semibold text-gray-500 tracking-wide">Version {APP_VERSION}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<NoticeStrips />
|
|
|
|
<main className="flex-1 overflow-y-auto p-6">
|
|
<div className="max-w-[1240px] w-full mx-auto flex flex-col gap-5">
|
|
<Outlet />
|
|
</div>
|
|
</main>
|
|
|
|
<ChangePasswordModal
|
|
open={isChangePasswordOpen}
|
|
onClose={() => setIsChangePasswordOpen(false)}
|
|
userEmail={userEmail || user?.email}
|
|
userName={user?.name}
|
|
onSuccessLogout={() => {
|
|
setIsChangePasswordOpen(false);
|
|
logout();
|
|
navigate('/login', { replace: true });
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|