Eine Seite zeigt je Integration, ob sie vorgesehen, eingerichtet oder gestoert ist, wann sie zuletzt geglueckt ist und woran sie klemmt. Der Wartungsplaner steht sichtbar als offene Luecke darin, nicht als Leerstelle. Telegram-Chats werden hier gekoppelt und wieder getrennt, Meldungen je Chat einzeln geschaltet. Aufgaben bekommen einen Schalter zum Spiegeln — einzeln und je Aufgabe, denn wer versehentlich einen ganzen Bereich spiegelt, schickt Kundennamen nach Hause. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
136 lines
4.6 KiB
TypeScript
136 lines
4.6 KiB
TypeScript
'use client'
|
||
|
||
import Link from 'next/link'
|
||
import { usePathname } from 'next/navigation'
|
||
import { useTheme } from 'next-themes'
|
||
import { LogOut, Moon, Shield, Sun } from 'lucide-react'
|
||
import { signOut } from 'next-auth/react'
|
||
import type { Rolle } from '@/generated/prisma/enums'
|
||
import { ROLLEN_NAME, istAdmin } from '@/lib/rollen'
|
||
import { cn } from '@/lib/utils'
|
||
import { Button } from '@/components/ui/button'
|
||
|
||
type Eintrag = { pfad: string; name: string; nurAdmin?: boolean; zaehler?: number }
|
||
|
||
export function Kopfleiste({
|
||
name,
|
||
email,
|
||
rolle,
|
||
instanzName,
|
||
inbox = 0,
|
||
}: {
|
||
name: string
|
||
email: string
|
||
rolle: Rolle
|
||
instanzName: string
|
||
/** Anzahl noch nicht einsortierter Aufgaben. */
|
||
inbox?: number
|
||
}) {
|
||
const EINTRAEGE: Eintrag[] = [
|
||
{ pfad: '/', name: 'Start' },
|
||
{ pfad: '/inbox', name: 'Inbox', zaehler: inbox },
|
||
{ pfad: '/aufgaben', name: 'Aufgaben' },
|
||
{ pfad: '/woche', name: 'Woche' },
|
||
{ pfad: '/projekte', name: 'Projekte' },
|
||
{ pfad: '/ideen', name: 'Ideen' },
|
||
{ pfad: '/bereiche', name: 'Bereiche' },
|
||
{ pfad: '/kalenderkonten', name: 'Kalender' },
|
||
{ pfad: '/integrationen', name: 'Integrationen' },
|
||
{ pfad: '/einstellungen', name: 'Einstellungen' },
|
||
{ pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true },
|
||
]
|
||
|
||
const pfad = usePathname()
|
||
const sichtbar = EINTRAEGE.filter((e) => !e.nurAdmin || istAdmin(rolle))
|
||
|
||
return (
|
||
<header className="border-rand bg-karte border-b">
|
||
{/*
|
||
Bewusst eine einzige Zeile auf allen Breiten: Wird es eng, scrollt die
|
||
Navigation waagerecht, statt umzubrechen und den Namen der Anwendung in
|
||
die zweite Zeile zu drängen.
|
||
*/}
|
||
<div className="mx-auto flex w-full max-w-5xl items-center gap-3 px-4 py-3 sm:gap-6">
|
||
<Link href="/" className="shrink-0 text-base font-semibold tracking-tight">
|
||
Planer
|
||
</Link>
|
||
|
||
<nav
|
||
aria-label="Hauptnavigation"
|
||
className="flex min-w-0 flex-1 gap-1 overflow-x-auto"
|
||
>
|
||
{sichtbar.map((eintrag) => {
|
||
const aktiv =
|
||
eintrag.pfad === '/' ? pfad === '/' : pfad.startsWith(eintrag.pfad)
|
||
return (
|
||
<Link
|
||
key={eintrag.pfad}
|
||
href={eintrag.pfad}
|
||
aria-current={aktiv ? 'page' : undefined}
|
||
className={cn(
|
||
'shrink-0 rounded-md px-3 py-1.5 text-sm whitespace-nowrap transition-colors',
|
||
aktiv
|
||
? 'bg-gedaempft text-vordergrund font-medium'
|
||
: 'text-gedaempft-vordergrund hover:bg-gedaempft hover:text-vordergrund',
|
||
)}
|
||
>
|
||
{eintrag.nurAdmin && (
|
||
<Shield className="mr-1 inline size-3.5" aria-hidden />
|
||
)}
|
||
{eintrag.name}
|
||
{eintrag.zaehler !== undefined && eintrag.zaehler > 0 && (
|
||
<span
|
||
className="bg-betont text-betont-vordergrund ml-1.5 inline-flex min-w-4 justify-center rounded-full px-1 text-[0.6875rem] leading-4 font-medium"
|
||
aria-label={`${eintrag.zaehler} offen`}
|
||
>
|
||
{eintrag.zaehler}
|
||
</span>
|
||
)}
|
||
</Link>
|
||
)
|
||
})}
|
||
</nav>
|
||
|
||
<div className="flex shrink-0 items-center gap-1">
|
||
<FarbschemaKnopf />
|
||
<span
|
||
className="text-gedaempft-vordergrund hidden text-xs sm:inline"
|
||
title={`${email} · ${ROLLEN_NAME[rolle]} · ${instanzName}`}
|
||
>
|
||
{name}
|
||
</span>
|
||
<Button
|
||
art="leise"
|
||
groesse="quadrat"
|
||
title="Abmelden"
|
||
onClick={() => void signOut({ callbackUrl: '/anmelden' })}
|
||
>
|
||
<LogOut aria-hidden />
|
||
<span className="sr-only">Abmelden</span>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
)
|
||
}
|
||
|
||
function FarbschemaKnopf() {
|
||
const { resolvedTheme, setTheme } = useTheme()
|
||
|
||
// Welches Symbol gilt, entscheidet die dark-Klasse und damit CSS. next-themes
|
||
// setzt sie schon vor der Hydratation, deshalb braucht es hier weder einen
|
||
// Effekt noch einen Zustand – und es blitzt nichts auf.
|
||
return (
|
||
<Button
|
||
art="leise"
|
||
groesse="quadrat"
|
||
title="Farbschema wechseln"
|
||
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
|
||
>
|
||
<Moon className="dark:hidden" aria-hidden />
|
||
<Sun className="hidden dark:block" aria-hidden />
|
||
<span className="sr-only">Farbschema wechseln</span>
|
||
</Button>
|
||
)
|
||
}
|