470 lines
14 KiB
TypeScript
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>
|
|
</>
|
|
)
|
|
}
|