krishnasales_mobile/src/screens/ConsoleLayout.tsx

266 lines
11 KiB
TypeScript

import { useEffect, useState } from 'react';
import { NavLink, Navigate, Outlet, useNavigate } from 'react-router-dom';
import { LogOut, Menu, X, Database, Users, KeyRound } from 'lucide-react';
import { cn } from '../lib/cn';
import { useAuth } from '../auth/context';
import { onAuthErrorAll, orderBookingClient } from '../api/clients';
import { SCREENS } from './tabs';
import { routeConfig } from '../routesConfig';
import { APP_VERSION } from '../api/config';
import { ChangePasswordModal } from '../components/auth/ChangePasswordModal';
import { PasswordExpiryWarningBanner } from '../components/auth/PasswordExpiryWarningBanner';
import { NoticeStrips } from '../components/common/NoticeBanner';
import logoUrl from '../assets/logo.png';
import './screen.css';
const canAccessScreen = (key: string, isAdmin: boolean, roles: string[]) => {
const route = routeConfig.find(r => r.path === `/${key}`);
if (!route) return true;
if (route.adminOnly && !isAdmin) return false;
if (!route.adminOnly && route.roles) {
return route.roles.some(r => roles.includes(r));
}
return true;
};
/** Auth-guarded shell: slim navy top bar + fixed bottom tab nav + routed <Outlet>. */
export function ConsoleLayout() {
const { authed, logout, isAdmin, roles: userRoles, userEmail, passwordStatus } = useAuth();
const navigate = useNavigate();
const user = orderBookingClient.currentUser();
const effectivePasswordStatus = passwordStatus ?? user?.password_status;
const isExpired = Boolean(
effectivePasswordStatus?.must_change ||
(effectivePasswordStatus?.days_left !== undefined && effectivePasswordStatus.days_left <= 0)
);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false);
// If password has expired, automatically move the user directly to change password
useEffect(() => {
if (isExpired) {
setIsChangePasswordOpen(true);
}
}, [isExpired]);
// 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 />;
return (
<div className="h-[100dvh] overflow-hidden bg-app flex flex-col">
<header className="print:hidden sticky top-0 z-20 shrink-0 flex items-center justify-between gap-3 px-4 h-14 pt-[env(safe-area-inset-top)] top-navbar shadow-md">
<div className="flex items-center gap-3 flex-1">
<button
type="button"
aria-label="Toggle Menu"
onClick={() => setIsMenuOpen(true)}
className="inline-flex items-center justify-center size-9 rounded-md text-white hover:text-white hover:bg-white/10 transition-colors duration-150 cursor-pointer nav-menu-icon"
>
<Menu size={20} />
</button>
<div className="flex flex-col justify-center leading-none">
<span className="text-white font-extrabold text-on-navy tracking-[-0.01em] leading-none nav-title">Krishna Sales</span>
{/* <span className="text-2xs text-on-navy-muted">Sandbox {APP_ID}{user?.name ? ` · ${user.name}` : ''}</span> */}
</div>
</div>
</header>
<NoticeStrips />
<PasswordExpiryWarningBanner
passwordStatus={effectivePasswordStatus}
onChangePassword={() => setIsChangePasswordOpen(true)}
/>
<main className="flex-1 overflow-y-auto px-4 pt-4 pb-24">
<div className="w-full max-w-[1200px] mx-auto flex flex-col gap-5">
<Outlet />
</div>
</main>
<nav
className="print:hidden fixed bottom-0 inset-x-0 z-20 h-16 pb-[env(safe-area-inset-bottom)] bg-app border-t border-border-subtle grid shadow-[0_-2px_16px_rgba(11,27,59,0.06)]"
style={{ gridTemplateColumns: `repeat(${SCREENS.filter(t => t.key !== 'analytics' && t.key !== 'all-daily' && t.key !== 'sales-report' && canAccessScreen(t.key, isAdmin, userRoles)).length}, minmax(0, 1fr))` }}
>
{SCREENS.filter(t => t.key !== 'analytics' && t.key !== 'all-daily' && t.key !== 'sales-report' && canAccessScreen(t.key, isAdmin, userRoles)).map((t) => {
const Icon = t.icon;
return (
<NavLink
key={t.key}
to={`/${t.key}`}
className="flex flex-col items-center justify-center gap-1 no-underline min-h-[48px]"
>
{({ isActive }) => (
<>
<span
className={cn(
'flex items-center justify-center h-7 w-14 rounded-pill transition-colors duration-150 bottom-nav-icon',
isActive ? 'active' : 'text-faint',
)}
>
<Icon size={20} />
</span>
<span
className={cn(
'text-2xs leading-none transition-colors duration-150 bottom-nav-text',
isActive ? 'font-semibold active' : 'font-medium text-faint',
)}
>
{t.label}
</span>
</>
)}
</NavLink>
);
})}
</nav>
{/* Sidebar Overlay */}
{isMenuOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 transition-opacity"
onClick={() => setIsMenuOpen(false)}
/>
)}
<div
className={cn(
"fixed inset-y-0 left-0 z-50 w-64 bg-app border-r border-border-subtle shadow-xl transform transition-transform duration-300 ease-in-out flex flex-col",
isMenuOpen ? "translate-x-0" : "-translate-x-full"
)}
>
<div className="h-14 flex items-center justify-between px-4 border-b border-border-subtle pt-[env(safe-area-inset-top)] shrink-0">
<div className="flex items-center gap-2.5">
<img src={logoUrl} alt="Krishna" className="w-7 h-7 rounded-full object-cover shadow-sm" />
<span className="font-bold text-foreground tracking-tight">Krishna Field Sales</span>
</div>
<button
type="button"
onClick={() => setIsMenuOpen(false)}
className="inline-flex items-center justify-center size-8 rounded-md text-faint hover:text-foreground hover:bg-black/5 transition-colors cursor-pointer"
>
<X size={20} />
</button>
</div>
<div className="flex-1 overflow-y-auto py-2">
{SCREENS.filter(t => canAccessScreen(t.key, isAdmin, userRoles)).map((t) => {
const Icon = t.icon;
return (
<NavLink
key={t.key}
to={`/${t.key}`}
onClick={() => setIsMenuOpen(false)}
className={({ isActive }) => cn(
"flex items-center gap-3 px-4 py-3 mx-2 rounded-lg no-underline transition-colors duration-150 sidebar-item",
isActive ? "active" : ""
)}
>
<Icon size={20} className="shrink-0" />
<span>{t.label}</span>
</NavLink>
);
})}
<div className="my-2 border-t border-border-subtle" />
{(isAdmin || userRoles.includes('Admin') || userRoles.includes('Manager')) && (
<NavLink
to="/admin/users"
onClick={() => setIsMenuOpen(false)}
className={({ isActive }) => cn(
"flex items-center gap-3 px-4 py-3 mx-2 rounded-lg no-underline transition-colors duration-150 sidebar-item",
isActive ? "active" : ""
)}
>
<Users size={20} className="shrink-0" />
<span>Sales Officers</span>
</NavLink>
)}
{isAdmin && (
<NavLink
to="/admin/datasets"
onClick={() => setIsMenuOpen(false)}
className={({ isActive }) => cn(
"flex items-center gap-3 px-4 py-3 mx-2 rounded-lg no-underline transition-colors duration-150 sidebar-item",
isActive ? "active" : ""
)}
>
<Database size={20} className="shrink-0" />
<span>Manage Datasets</span>
</NavLink>
)}
</div>
<div className="p-4 border-t border-border-subtle shrink-0 flex flex-col gap-3 mb-[env(safe-area-inset-bottom)]">
{user && (
<div className="flex items-center gap-3 px-1 py-2">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-lg shadow-sm border border-primary/20">
{(user.name || 'U').charAt(0).toUpperCase()}
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-bold text-foreground truncate">{user.name || 'User'}</span>
<span className="text-xs text-foreground/70 truncate">{user.email || 'user@email.com'}</span>
{userRoles && userRoles.length > 0 && (
<span className="text-[10px] uppercase tracking-wider font-bold text-primary mt-0.5 truncate">{userRoles.join(', ')}</span>
)}
</div>
</div>
)}
<button
type="button"
onClick={() => {
setIsMenuOpen(false);
setIsChangePasswordOpen(true);
}}
className="flex items-center justify-center gap-2 w-full py-2.5 px-4 rounded-lg bg-emerald-50 hover:bg-emerald-100 text-emerald-800 transition-colors font-medium text-sm cursor-pointer border border-emerald-200/60"
>
<KeyRound size={16} className="text-emerald-700" />
<span>Change password</span>
</button>
<button
type="button"
onClick={() => {
setIsMenuOpen(false);
logout();
navigate('/login', { replace: true });
}}
className="flex items-center justify-center gap-2 w-full py-2.5 px-4 rounded-lg bg-black/5 hover:bg-black/10 text-foreground transition-colors font-medium text-sm cursor-pointer"
>
<LogOut size={16} />
<span>Sign out</span>
</button>
<div className="text-center mt-3">
<span className="text-xs font-semibold text-foreground/60 tracking-wide">Version {APP_VERSION}</span>
</div>
</div>
</div>
<ChangePasswordModal
open={isChangePasswordOpen}
onClose={() => {
if (!isExpired) {
setIsChangePasswordOpen(false);
}
}}
required={isExpired}
userEmail={userEmail || user?.email}
userName={user?.name}
onSuccessLogout={() => {
setIsChangePasswordOpen(false);
logout();
navigate('/login', { replace: true });
}}
/>
</div>
);
}