teg/frontend/src/components/Sidebar.tsx
2026-06-11 15:02:19 +02:00

470 lines
14 KiB
TypeScript

// frontend\src\components\Sidebar.tsx
import {
Dialog,
DialogBackdrop,
DialogPanel,
TransitionChild,
} from '@headlessui/react'
import {
CalendarIcon,
ChartPieIcon,
ChatBubbleLeftRightIcon,
Cog6ToothIcon,
ComputerDesktopIcon,
CpuChipIcon,
DocumentDuplicateIcon,
ServerStackIcon,
ShieldCheckIcon,
VideoCameraIcon,
HomeIcon,
XMarkIcon,
} from '@heroicons/react/24/outline'
import type { ComponentType, SVGProps } from 'react'
import { NavLink, useLocation } from 'react-router'
import { hasRight } from './permissions'
import type { User, Team } from './types'
type NavigationItem = {
name: string
href: string
icon: ComponentType<SVGProps<SVGSVGElement>>
right?: string
children?: NavigationItem[]
}
type NavigationSection = {
name?: string
items: NavigationItem[]
}
const navigationSections: NavigationSection[] = [
{
items: [
{
name: 'Dashboard',
href: '/',
icon: HomeIcon,
},
],
},
{
name: 'Betrieb',
items: [
{
name: 'Geräte',
href: '/geraete',
icon: ComputerDesktopIcon,
right: 'devices:read',
},
{
name: 'Einsätze',
href: '/einsaetze',
icon: VideoCameraIcon,
right: 'operations:read',
children: [
{
name: 'Milestone-Speicher',
href: '/einsaetze/milestone-speicher',
icon: ServerStackIcon,
right: 'administration:read',
},
{
name: 'Milestone-Geräte',
href: '/einsaetze/milestone-devices',
icon: CpuChipIcon,
right: 'administration:read',
},
],
},
],
},
{
name: 'Organisation',
items: [
{
name: 'Kalender',
href: '/kalender',
icon: CalendarIcon,
right: 'calendar:read',
},
{
name: 'Dokumente',
href: '/dokumente',
icon: DocumentDuplicateIcon,
right: 'documents:read',
},
{
name: 'Berichte',
href: '/berichte',
icon: ChartPieIcon,
right: 'reports:read',
},
],
},
]
type SidebarProps = {
user: User
sidebarOpen: boolean
setSidebarOpen: (open: boolean) => void
onNavigate?: () => void
}
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ')
}
function getTeamInitial(team: Team) {
if (team.initial) {
return team.initial
}
return team.name.charAt(0).toUpperCase()
}
function isPathActive(pathname: string, href: string) {
if (href === '/') {
return pathname === '/'
}
return pathname === href || pathname.startsWith(`${href}/`)
}
function itemIsActive(item: NavigationItem, pathname: string): boolean {
return (
isPathActive(pathname, item.href) ||
(item.children ?? []).some((child) => itemIsActive(child, pathname))
)
}
function filterNavigationItems(
items: NavigationItem[],
user: User,
): NavigationItem[] {
return items
.map((item) => ({
...item,
children: item.children
? filterNavigationItems(item.children, user)
: undefined,
}))
.filter((item) => {
const canSeeItem = !item.right || hasRight(user, item.right)
const hasVisibleChildren = Boolean(item.children?.length)
return canSeeItem || hasVisibleChildren
})
}
function getVisibleNavigationSections(user: User) {
return navigationSections
.map((section) => ({
...section,
items: filterNavigationItems(section.items, user),
}))
.filter((section) => section.items.length > 0)
}
function SidebarContent({
user,
setSidebarOpen,
onNavigate,
}: {
user: User
setSidebarOpen: (open: boolean) => void
onNavigate?: () => void
}) {
const teams = user.teams ?? []
const location = useLocation()
const visibleNavigationSections = getVisibleNavigationSections(user)
const isAdministrationActive =
location.pathname === '/administration' ||
location.pathname.startsWith('/administration/')
const isSettingsActive =
location.pathname === '/einstellungen' ||
location.pathname.startsWith('/einstellungen/')
const isFeedbackActive = location.pathname === '/feedback'
function handleNavigate() {
onNavigate?.()
setSidebarOpen(false)
}
function renderNavigationItem(item: NavigationItem) {
const active = itemIsActive(item, location.pathname)
const children = item.children ?? []
const showChildren = children.length > 0
return (
<li key={item.name}>
<NavLink
to={item.href}
onClick={handleNavigate}
className={classNames(
active
? 'bg-white/10 text-white shadow-sm ring-1 ring-white/10'
: 'text-slate-400 hover:bg-white/5 hover:text-white',
'group flex items-center gap-x-3 rounded-xl px-3 py-2.5 text-sm/6 font-semibold transition',
)}
>
<item.icon
aria-hidden="true"
className={classNames(
active
? 'text-white'
: 'text-slate-400 group-hover:text-white',
'size-5 shrink-0 transition',
)}
/>
<span className="truncate">{item.name}</span>
</NavLink>
{showChildren && (
<ul
role="list"
className="ml-[1.35rem] mt-1 space-y-1 border-l border-white/10 pl-3"
>
{children.map((child) => {
const childActive = itemIsActive(child, location.pathname)
return (
<li key={child.name}>
<NavLink
to={child.href}
onClick={handleNavigate}
className={classNames(
childActive
? 'bg-indigo-500/10 text-indigo-200 ring-1 ring-indigo-400/20'
: 'text-slate-400 hover:bg-white/5 hover:text-white',
'group flex items-center gap-x-2 rounded-lg px-2.5 py-2 text-sm/6 font-medium transition',
)}
>
<child.icon
aria-hidden="true"
className={classNames(
childActive
? 'text-indigo-200'
: 'text-slate-500 group-hover:text-white',
'size-4 shrink-0 transition',
)}
/>
<span className="truncate">{child.name}</span>
</NavLink>
</li>
)
})}
</ul>
)}
</li>
)
}
return (
<>
<div className="flex h-16 shrink-0 items-center gap-3">
<img
alt="Your Company"
src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=500"
className="h-8 w-auto"
/>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-white">TEG</p>
<p className="truncate text-xs text-slate-400">SE Düsseldorf</p>
</div>
</div>
<nav className="flex flex-1 flex-col">
<ul role="list" className="flex flex-1 flex-col gap-y-6">
<li>
<div className="space-y-6">
{visibleNavigationSections.map((section, sectionIndex) => (
<section key={section.name ?? `section-${sectionIndex}`}>
{section.name && (
<h2 className="mb-2 px-3 text-[0.6875rem]/6 font-semibold uppercase tracking-wider text-slate-500">
{section.name}
</h2>
)}
<ul role="list" className="space-y-1">
{section.items.map(renderNavigationItem)}
</ul>
</section>
))}
</div>
</li>
<li>
<div className="border-t border-white/10 pt-5">
<h2 className="px-3 text-[0.6875rem]/6 font-semibold uppercase tracking-wider text-slate-500">
Teams
</h2>
<ul role="list" className="mt-2 space-y-1">
{teams.length > 0 ? (
teams.map((team) => (
<li key={team.id}>
<a
href={team.href || '#'}
onClick={handleNavigate}
className={classNames(
team.current
? 'bg-white/10 text-white ring-1 ring-white/10'
: 'text-slate-400 hover:bg-white/5 hover:text-white',
'group flex items-center gap-x-3 rounded-xl px-3 py-2 text-sm/6 font-semibold transition',
)}
>
<span className="flex size-6 shrink-0 items-center justify-center rounded-lg border border-white/10 bg-white/5 text-[0.625rem] font-medium text-slate-300 group-hover:border-white/20 group-hover:text-white">
{getTeamInitial(team)}
</span>
<span className="truncate">{team.name}</span>
</a>
</li>
))
) : (
<li className="px-3 py-1 text-sm/6 text-slate-500">
Keine Teams zugeordnet
</li>
)}
</ul>
</div>
</li>
<li className="mt-auto border-t border-white/10 pt-5">
<h2 className="mb-2 px-3 text-[0.6875rem]/6 font-semibold uppercase tracking-wider text-slate-500">
Hilfe & System
</h2>
<ul role="list" className="space-y-1">
<li>
<NavLink
to="/feedback"
onClick={handleNavigate}
className={classNames(
isFeedbackActive
? 'bg-white/10 text-white ring-1 ring-white/10'
: 'text-slate-400 hover:bg-white/5 hover:text-white',
'group flex items-center gap-x-3 rounded-xl px-3 py-2.5 text-sm/6 font-semibold transition',
)}
>
<ChatBubbleLeftRightIcon
aria-hidden="true"
className="size-5 shrink-0"
/>
Feedback
</NavLink>
</li>
{hasRight(user, 'settings:read') && (
<li>
<NavLink
to="/einstellungen/konto"
onClick={handleNavigate}
className={classNames(
isSettingsActive
? 'bg-white/10 text-white ring-1 ring-white/10'
: 'text-slate-400 hover:bg-white/5 hover:text-white',
'group flex items-center gap-x-3 rounded-xl px-3 py-2.5 text-sm/6 font-semibold transition',
)}
>
<Cog6ToothIcon
aria-hidden="true"
className="size-5 shrink-0"
/>
Einstellungen
</NavLink>
</li>
)}
{hasRight(user, 'administration:read') && (
<li>
<NavLink
to="/administration/benutzer"
onClick={handleNavigate}
className={classNames(
isAdministrationActive
? 'bg-white/10 text-white ring-1 ring-white/10'
: 'text-slate-400 hover:bg-white/5 hover:text-white',
'group flex items-center gap-x-3 rounded-xl px-3 py-2.5 text-sm/6 font-semibold transition',
)}
>
<ShieldCheckIcon
aria-hidden="true"
className="size-5 shrink-0"
/>
Administration
</NavLink>
</li>
)}
</ul>
</li>
</ul>
</nav>
</>
)
}
export default function Sidebar({
user,
sidebarOpen,
setSidebarOpen,
onNavigate,
}: SidebarProps) {
return (
<>
<Dialog
open={sidebarOpen}
onClose={setSidebarOpen}
className="relative z-[2000] lg:hidden"
>
<DialogBackdrop
transition
className="fixed inset-0 bg-gray-950/85 transition-opacity duration-300 ease-linear data-closed:opacity-0"
/>
<div className="fixed inset-0 flex">
<DialogPanel
transition
className="relative mr-16 flex w-full max-w-xs flex-1 transform transition duration-300 ease-in-out data-closed:-translate-x-full"
>
<TransitionChild>
<div className="absolute top-0 left-full flex w-16 justify-center pt-5 duration-300 ease-in-out data-closed:opacity-0">
<button
type="button"
onClick={() => setSidebarOpen(false)}
className="-m-2.5 p-2.5"
>
<span className="sr-only">Close sidebar</span>
<XMarkIcon aria-hidden="true" className="size-6 text-white" />
</button>
</div>
</TransitionChild>
<div className="relative flex grow flex-col gap-y-5 overflow-y-auto bg-slate-950 px-5 pb-4 ring-1 ring-white/10 before:pointer-events-none before:absolute before:inset-0 before:bg-[radial-gradient(circle_at_top_left,rgba(99,102,241,0.16),transparent_32rem)]">
<SidebarContent
user={user}
setSidebarOpen={setSidebarOpen}
onNavigate={onNavigate}
/>
</div>
</DialogPanel>
</div>
</Dialog>
<div className="hidden bg-slate-950 ring-1 ring-white/10 lg:fixed lg:inset-y-0 lg:z-[2000] lg:flex lg:w-72 lg:flex-col">
<div className="relative flex grow flex-col gap-y-5 overflow-y-auto px-5 pb-4 before:pointer-events-none before:absolute before:inset-0 before:bg-[radial-gradient(circle_at_top_left,rgba(99,102,241,0.14),transparent_34rem)]">
<SidebarContent
user={user}
setSidebarOpen={setSidebarOpen}
onNavigate={onNavigate}
/>
</div>
</div>
</>
)
}