195 lines
7.1 KiB
TypeScript
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',
|
|
}
|
|
}
|