dak.c2s/frontend/src/components/layout/Header.tsx
CCS Admin 5f7b4c6e1d feat: add /account route, sidebar entry and header link
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 09:48:43 +00:00

197 lines
7.1 KiB
TypeScript

import { useNavigate } from 'react-router-dom'
import { useAuth } from '@/context/AuthContext'
import { useNotifications } from '@/hooks/useNotifications'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import { Bell, CheckCheck, LogOut, Menu, Moon, Sun, UserCog } from 'lucide-react'
import { useTheme } from '@/hooks/useTheme'
interface HeaderProps {
onToggleSidebar: () => void
}
export function Header({ onToggleSidebar }: HeaderProps) {
const navigate = useNavigate()
const { user, logout } = useAuth()
const { notifications, unreadCount, markAsRead, markAllAsRead } = useNotifications()
const { theme, toggleTheme } = useTheme()
const initials = user?.username
? user.username
.split(/[\s._-]/)
.map((part) => part[0])
.join('')
.toUpperCase()
.slice(0, 2)
: '??'
const roleBadgeVariant = user?.role === 'admin' ? 'default' as const : 'secondary' as const
const roleLabel = user?.role === 'admin' ? 'Admin' : 'DAK Mitarbeiter'
return (
<header className="flex h-14 items-center gap-4 border-b bg-background px-4 lg:px-6">
<Button
variant="ghost"
size="icon"
className="lg:hidden"
onClick={onToggleSidebar}
>
<Menu className="h-5 w-5" />
<span className="sr-only">Navigation umschalten</span>
</Button>
<div className="flex-1" />
{/* Dark mode toggle */}
<Button variant="ghost" size="icon" onClick={toggleTheme}>
{theme === 'dark' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
<span className="sr-only">Design umschalten</span>
</Button>
{/* Notification bell with dropdown */}
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-destructive-foreground">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
<span className="sr-only">Benachrichtigungen</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0">
<div className="flex items-center justify-between px-4 py-3">
<h4 className="text-sm font-semibold">Benachrichtigungen</h4>
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
className="h-auto px-2 py-1 text-xs"
onClick={markAllAsRead}
>
<CheckCheck className="mr-1 h-3 w-3" />
Alle als gelesen markieren
</Button>
)}
</div>
<Separator />
<ScrollArea className="max-h-80">
{notifications.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-muted-foreground">
Keine Benachrichtigungen
</p>
) : (
<div className="flex flex-col">
{notifications.map((n) => (
<button
key={n.id}
className={`flex flex-col gap-1 px-4 py-3 text-left transition-colors hover:bg-muted/50 ${
!n.is_read ? 'bg-muted/30' : ''
}`}
onClick={() => {
if (!n.is_read) markAsRead(n.id)
}}
>
<div className="flex items-start justify-between gap-2">
<span className="text-sm font-medium leading-tight">
{!n.is_read && (
<span className="mr-1.5 inline-block h-2 w-2 rounded-full bg-primary" />
)}
{n.title}
</span>
</div>
{n.message && (
<span className="text-xs text-muted-foreground line-clamp-2">
{n.message}
</span>
)}
<span className="text-[11px] text-muted-foreground">
{formatRelativeTime(n.created_at)}
</span>
</button>
))}
</div>
)}
</ScrollArea>
</PopoverContent>
</Popover>
{/* User menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex items-center gap-2 px-2">
<Avatar size="sm">
{user?.avatar_url && <AvatarImage src={user.avatar_url} />}
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<span className="hidden text-sm font-medium md:inline-block">
{user?.username}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{user?.username}</p>
<p className="text-xs leading-none text-muted-foreground">{user?.email}</p>
<Badge variant={roleBadgeVariant} className="mt-1 w-fit">
{roleLabel}
</Badge>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => navigate('/account')} className="cursor-pointer">
<UserCog className="mr-2 h-4 w-4" />
Konto verwalten
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => logout()} className="cursor-pointer">
<LogOut className="mr-2 h-4 w-4" />
Abmelden
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</header>
)
}
function formatRelativeTime(dateStr: string): string {
try {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60_000)
const diffHrs = Math.floor(diffMs / 3_600_000)
const diffDays = Math.floor(diffMs / 86_400_000)
if (diffMin < 1) return 'Gerade eben'
if (diffMin < 60) return `vor ${diffMin} Min.`
if (diffHrs < 24) return `vor ${diffHrs} Std.`
if (diffDays < 7) return `vor ${diffDays} Tag${diffDays > 1 ? 'en' : ''}`
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
} catch {
return dateStr
}
}