Beim Oeffnen der Warnungen und auf der Host-Detailansicht zoomte der Browser die ganze Seite heraus. Ursache war jeweils ein Element breiter als das Fenster: die 22 rem breite Klappliste, drei Tabellen ohne table-responsive und eine 32-stellige Server-ID ohne Umbruch. Klapplisten richten sich auf dem Telefon jetzt am Fenster aus statt am Knopf, die Tabellen bekommen ihre Huelle, lange IDs duerfen umbrechen. Geprueft mit einer neuen Aufnahme in Telefonbreite (--mobil). Dabei ein eigener Fehler aus #037 aufgefallen: Die Laufzeit-Detailseite rechnete weiter mit dem Listenpreis statt mit dem Betrag nach Rabatt - Marge und Vorschlag fuer die Kundenrechnung waren um den Rabatt zu hoch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
232 lines
8.1 KiB
TypeScript
232 lines
8.1 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||
import { SearchDialog, type SuchEintrag } from "./SearchDialog";
|
||
import type { Rolle } from "@/generated/prisma/enums";
|
||
import { ROLLEN_TEXT } from "@/server/auth/rollen";
|
||
import { menuUmschalten } from "./menuZustand";
|
||
|
||
export interface KopfleistenAlert {
|
||
id: string;
|
||
titel: string;
|
||
beschreibung: string;
|
||
schwere: "INFO" | "WARNUNG" | "GEFAHR";
|
||
hostId: string | null;
|
||
}
|
||
|
||
const SCHWERE_ICON: Record<KopfleistenAlert["schwere"], string> = {
|
||
GEFAHR: "bi-exclamation-octagon-fill pl-text-danger",
|
||
WARNUNG: "bi-exclamation-triangle-fill pl-text-warning",
|
||
INFO: "bi-info-circle-fill text-body-secondary",
|
||
};
|
||
|
||
/** Farbschema aus dem <html>-Attribut lesen, damit Server und Client übereinstimmen. */
|
||
function themaIstDunkel() {
|
||
return document.documentElement.dataset.bsTheme === "dark";
|
||
}
|
||
|
||
function themaAbonnieren(aenderung: () => void) {
|
||
const beobachter = new MutationObserver(aenderung);
|
||
beobachter.observe(document.documentElement, { attributes: true, attributeFilter: ["data-bs-theme"] });
|
||
return () => beobachter.disconnect();
|
||
}
|
||
|
||
export function Header({
|
||
alerts,
|
||
suchEintraege,
|
||
benutzer,
|
||
rolle,
|
||
}: {
|
||
alerts: KopfleistenAlert[];
|
||
suchEintraege: SuchEintrag[];
|
||
benutzer: string;
|
||
rolle: Rolle;
|
||
}) {
|
||
const [sucheOffen, setSucheOffen] = useState(false);
|
||
const [glockeOffen, setGlockeOffen] = useState(false);
|
||
const [benutzerOffen, setBenutzerOffen] = useState(false);
|
||
const dunkel = useSyncExternalStore(themaAbonnieren, themaIstDunkel, () => false);
|
||
|
||
useEffect(() => {
|
||
function tasten(event: KeyboardEvent) {
|
||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
|
||
event.preventDefault();
|
||
setSucheOffen(true);
|
||
}
|
||
}
|
||
window.addEventListener("keydown", tasten);
|
||
return () => window.removeEventListener("keydown", tasten);
|
||
}, []);
|
||
|
||
/** Seitenmenü umschalten – der Zustand liegt in menuZustand.ts, damit das Menü selbst ihn kennt. */
|
||
const sidebarUmschalten = useCallback(() => {
|
||
menuUmschalten();
|
||
}, []);
|
||
|
||
function themaUmschalten() {
|
||
const neu = dunkel ? "light" : "dark";
|
||
document.documentElement.dataset.bsTheme = neu;
|
||
try {
|
||
localStorage.setItem("pl-theme", neu);
|
||
} catch {
|
||
// Speicherzugriff kann blockiert sein; das Thema gilt dann nur für diese Sitzung.
|
||
}
|
||
}
|
||
|
||
function vollbild() {
|
||
if (document.fullscreenElement) {
|
||
void document.exitFullscreen();
|
||
} else {
|
||
void document.documentElement.requestFullscreen();
|
||
}
|
||
}
|
||
|
||
return (
|
||
<nav className="app-header navbar navbar-expand bg-body shadow-sm">
|
||
<div className="container-fluid">
|
||
<ul className="navbar-nav">
|
||
<li className="nav-item">
|
||
<button
|
||
type="button"
|
||
className="nav-link btn btn-link pl-touch-target"
|
||
onClick={sidebarUmschalten}
|
||
aria-label="Menü ein- oder ausklappen"
|
||
>
|
||
<i className="bi bi-list fs-5" aria-hidden="true" />
|
||
</button>
|
||
</li>
|
||
<li className="nav-item d-none d-md-block">
|
||
<Link href="/" className="nav-link">
|
||
Start
|
||
</Link>
|
||
</li>
|
||
<li className="nav-item d-none d-md-block">
|
||
<Link href="/einstellungen/tokens" className="nav-link">
|
||
Agent installieren
|
||
</Link>
|
||
</li>
|
||
</ul>
|
||
|
||
<ul className="navbar-nav ms-auto align-items-center">
|
||
<li className="nav-item">
|
||
<button
|
||
type="button"
|
||
className="nav-link btn btn-link pl-touch-target"
|
||
onClick={() => setSucheOffen(true)}
|
||
aria-label="Globale Suche öffnen (Strg+K)"
|
||
title="Suche (Strg+K)"
|
||
>
|
||
<i className="bi bi-search" aria-hidden="true" />
|
||
</button>
|
||
</li>
|
||
|
||
<li className="nav-item dropdown">
|
||
<button
|
||
type="button"
|
||
className="nav-link btn btn-link position-relative pl-touch-target"
|
||
onClick={() => {
|
||
setGlockeOffen((o) => !o);
|
||
setBenutzerOffen(false);
|
||
}}
|
||
aria-label={`${alerts.length} offene Warnungen anzeigen`}
|
||
aria-expanded={glockeOffen}
|
||
>
|
||
<i className="bi bi-bell-fill" aria-hidden="true" />
|
||
{alerts.length > 0 ? (
|
||
<span className="badge pl-bg-danger position-absolute top-0 start-100 translate-middle rounded-pill">
|
||
{alerts.length}
|
||
</span>
|
||
) : null}
|
||
</button>
|
||
<div
|
||
className={`dropdown-menu dropdown-menu-end shadow pl-klappliste ${glockeOffen ? "show" : ""}`}
|
||
>
|
||
<span className="dropdown-header">{alerts.length} offene Warnungen</span>
|
||
<div className="dropdown-divider" />
|
||
{alerts.slice(0, 6).map((alert) => (
|
||
<Link
|
||
key={alert.id}
|
||
href={alert.hostId ? `/hosts/${alert.hostId}` : "/warnungen"}
|
||
className="dropdown-item d-flex gap-2 py-2"
|
||
onClick={() => setGlockeOffen(false)}
|
||
>
|
||
<i className={`bi ${SCHWERE_ICON[alert.schwere]} mt-1`} aria-hidden="true" />
|
||
<span className="text-wrap">
|
||
<span className="d-block fw-semibold">{alert.titel}</span>
|
||
<span className="d-block small text-body-secondary">{alert.beschreibung}</span>
|
||
</span>
|
||
</Link>
|
||
))}
|
||
<div className="dropdown-divider" />
|
||
<Link
|
||
href="/warnungen"
|
||
className="dropdown-item text-center"
|
||
onClick={() => setGlockeOffen(false)}
|
||
>
|
||
Alle Warnungen ansehen
|
||
</Link>
|
||
</div>
|
||
</li>
|
||
|
||
<li className="nav-item">
|
||
<button
|
||
type="button"
|
||
className="nav-link btn btn-link pl-touch-target"
|
||
onClick={themaUmschalten}
|
||
aria-label={dunkel ? "Helles Design verwenden" : "Dunkles Design verwenden"}
|
||
title={dunkel ? "Helles Design" : "Dunkles Design"}
|
||
>
|
||
<i className={`bi ${dunkel ? "bi-sun" : "bi-moon-stars"}`} aria-hidden="true" />
|
||
</button>
|
||
</li>
|
||
|
||
<li className="nav-item d-none d-md-block">
|
||
<button
|
||
type="button"
|
||
className="nav-link btn btn-link pl-touch-target"
|
||
onClick={vollbild}
|
||
aria-label="Vollbild umschalten"
|
||
>
|
||
<i className="bi bi-arrows-fullscreen" aria-hidden="true" />
|
||
</button>
|
||
</li>
|
||
|
||
<li className="nav-item dropdown">
|
||
<button
|
||
type="button"
|
||
className="nav-link btn btn-link d-flex align-items-center gap-2 pl-touch-target"
|
||
onClick={() => {
|
||
setBenutzerOffen((o) => !o);
|
||
setGlockeOffen(false);
|
||
}}
|
||
aria-expanded={benutzerOffen}
|
||
>
|
||
<i className="bi bi-person-circle" aria-hidden="true" />
|
||
<span className="d-none d-sm-inline">{benutzer}</span>
|
||
</button>
|
||
<div
|
||
className={`dropdown-menu dropdown-menu-end shadow pl-klappliste ${benutzerOffen ? "show" : ""}`}
|
||
>
|
||
<span className="dropdown-header">
|
||
Angemeldet als {benutzer} · {ROLLEN_TEXT[rolle]}
|
||
</span>
|
||
<div className="dropdown-divider" />
|
||
<Link href="/einstellungen/passkeys" className="dropdown-item">
|
||
Meine Passkeys
|
||
</Link>
|
||
<a className="dropdown-item" href="/api/auth/abmelden">
|
||
Abmelden
|
||
</a>
|
||
</div>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
{sucheOffen ? (
|
||
<SearchDialog eintraege={suchEintraege} onSchliessen={() => setSucheOffen(false)} />
|
||
) : null}
|
||
</nav>
|
||
);
|
||
}
|