portal/src/components/Sidebar.tsx
jtricerolph fa32ea84ee Add self-registration, role management, and department mapping UI
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 00:24:05 +00:00

195 lines
7.1 KiB
TypeScript

import { useState } from 'react'
import { NavLink, useNavigate, useLocation } from 'react-router-dom'
import { LayoutGrid, Users, Activity, Settings, LogOut, ChevronDown, ShieldCheck, Building2 } from 'lucide-react'
import { AppIcon } from './AppIcon'
import type { User, App } from '../types'
interface Props { user: User; activeSlug?: string }
export function Sidebar({ user, activeSlug }: Props) {
const navigate = useNavigate()
const location = useLocation()
// Determine active app's category so its group auto-stays open
const routeSlug = location.pathname.startsWith('/app/')
? location.pathname.split('/')[2]
: null
const activeApp = user.apps.find(a => a.slug === routeSlug)
const activeCat = activeApp?.category ?? null
// Separate uncategorised from categorised
const uncategorised = user.apps.filter(a => !a.category)
const grouped = user.apps.reduce<Record<string, App[]>>((acc, app) => {
if (!app.category) return acc
if (!acc[app.category]) acc[app.category] = []
acc[app.category].push(app)
return acc
}, {})
const categoryNames = Object.keys(grouped).sort()
// All categories open by default; track collapsed ones
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
function toggleCat(cat: string) {
// Never collapse the active category
if (cat === activeCat) return
setCollapsed(prev => {
const next = new Set(prev)
next.has(cat) ? next.delete(cat) : next.add(cat)
return next
})
}
async function logout() {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' })
navigate('/login', { replace: true })
}
return (
<aside style={{
width: 'var(--sidebar-w)', flexShrink: 0,
background: 'var(--navy)', borderRight: '1px solid var(--surface-2)',
display: 'flex', flexDirection: 'column', height: '100dvh',
position: 'sticky', top: 0,
}}>
<div style={{ padding: '1.25rem 1rem 1rem', borderBottom: '1px solid var(--surface-2)' }}>
<div style={{ color: 'var(--gold)', fontWeight: 700, fontSize: '0.9rem', letterSpacing: '0.06em' }}>
MANAGE
</div>
<div style={{ color: 'var(--text-muted)', fontSize: '0.72rem', marginTop: '0.2rem' }}>
{import.meta.env.VITE_HOTEL_NAME}
</div>
</div>
<nav style={{ flex: 1, overflowY: 'auto', padding: '0.5rem 0' }}>
{/* Dashboard */}
<NavLink to="/" end style={({ isActive }) => navItem(isActive && !activeSlug)}>
<LayoutGrid size={14} strokeWidth={1.75} />
Dashboard
</NavLink>
{/* Uncategorised apps — flat list */}
{uncategorised.length > 0 && (
<>
<SectionLabel>Apps</SectionLabel>
{uncategorised.map(app => (
<AppNavLink key={app.slug} app={app} />
))}
</>
)}
{/* Categorised groups */}
{categoryNames.map(cat => {
const isOpen = !collapsed.has(cat)
const isActiveCat = cat === activeCat
return (
<div key={cat}>
<button
onClick={() => toggleCat(cat)}
style={{
width: '100%', background: 'none', border: 'none',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0.5rem 1rem 0.2rem',
color: isActiveCat ? 'var(--gold)' : 'var(--text-muted)',
fontSize: '0.62rem', fontWeight: 600,
textTransform: 'uppercase', letterSpacing: '0.1em',
cursor: isActiveCat ? 'default' : 'pointer',
}}
>
{cat}
<ChevronDown
size={11}
strokeWidth={2}
style={{
transition: 'transform 0.18s',
transform: isOpen ? 'rotate(0deg)' : 'rotate(-90deg)',
opacity: isActiveCat ? 0 : 0.6,
}}
/>
</button>
{isOpen && grouped[cat].map(app => (
<AppNavLink key={app.slug} app={app} indent />
))}
</div>
)
})}
{/* Admin section */}
{user.is_admin && (
<>
<SectionLabel>Admin</SectionLabel>
<NavLink to="/admin/users" style={({ isActive }) => navItem(isActive)}>
<Users size={14} strokeWidth={1.75} />
Users
</NavLink>
<NavLink to="/admin/roles" style={({ isActive }) => navItem(isActive)}>
<ShieldCheck size={14} strokeWidth={1.75} />
Roles
</NavLink>
<NavLink to="/admin/departments" style={({ isActive }) => navItem(isActive)}>
<Building2 size={14} strokeWidth={1.75} />
Departments
</NavLink>
<NavLink to="/admin/monitor" style={({ isActive }) => navItem(isActive)}>
<Activity size={14} strokeWidth={1.75} />
Monitor
</NavLink>
<NavLink to="/admin/settings" style={({ isActive }) => navItem(isActive)}>
<Settings size={14} strokeWidth={1.75} />
Settings
</NavLink>
</>
)}
</nav>
<div style={{ padding: '0.875rem 1rem', borderTop: '1px solid var(--surface-2)' }}>
<div style={{ fontSize: '0.8rem', fontWeight: 600, color: 'var(--text)', marginBottom: '0.1rem' }}>{user.name}</div>
<div style={{ fontSize: '0.7rem', color: 'var(--text-muted)', marginBottom: '0.6rem' }}>{user.email}</div>
<button onClick={logout} style={{
background: 'none', border: '1px solid var(--surface-2)', borderRadius: '5px',
color: 'var(--text-muted)', padding: '0.3rem 0.75rem', fontSize: '0.75rem', width: '100%',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.4rem',
}}>
<LogOut size={13} strokeWidth={1.75} />
Sign out
</button>
</div>
</aside>
)
}
function AppNavLink({ app, indent = false }: { app: App; indent?: boolean }) {
return (
<NavLink to={`/app/${app.slug}`} style={({ isActive }) => ({
...navItem(isActive),
paddingLeft: indent ? '1.5rem' : '1rem',
})}>
<AppIcon name={app.icon} size={14} strokeWidth={1.75} />
<span>{app.name}</span>
</NavLink>
)
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<div style={{
padding: '0.6rem 1rem 0.2rem', fontSize: '0.62rem',
color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.1em',
fontWeight: 600,
}}>
{children}
</div>
)
}
function navItem(active: boolean): React.CSSProperties {
return {
display: 'flex', alignItems: 'center', gap: '0.6rem',
padding: '0.5rem 1rem',
fontSize: '0.85rem', color: active ? 'var(--gold)' : 'var(--text-muted)',
background: active ? 'var(--surface)' : 'transparent',
borderLeft: active ? '2px solid var(--gold)' : '2px solid transparent',
transition: 'color 0.1s, background 0.1s',
}
}