feat(ui): klickbarer Prototyp mit Beispieldaten (#002)

Layout im AdminLTE-Stil (Sidebar, Kopfleiste, Inhaltskopf, Footer), Logo als
SVG-Komponente, Farbschema mit Dark-Mode, Dashboard, Kunden- und Host-Ansichten,
Rechnungsuebersicht, Jahresbahn und globale Suche (Strg+K).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 20:03:15 +02:00
co-authored by Claude Opus 5
parent d98bde9922
commit 8596a18a27
47 changed files with 4128 additions and 40 deletions
+49
View File
@@ -0,0 +1,49 @@
import Link from "next/link";
import type { ReactNode } from "react";
export interface Brotkrume {
titel: string;
href?: string;
}
/** Inhaltskopf: Seitentitel links, Breadcrumb rechts (AdminLTE-Standard). */
export function ContentHeader({
titel,
brotkrumen = [],
aktion,
}: {
titel: string;
brotkrumen?: Brotkrume[];
aktion?: ReactNode;
}) {
return (
<div className="app-content-header">
<div className="container-fluid">
<div className="row align-items-center">
<div className="col-sm-6 d-flex align-items-center gap-3">
<h1 className="pl-page-title">{titel}</h1>
{aktion}
</div>
<div className="col-sm-6">
<nav aria-label="Brotkrumen">
<ol className="breadcrumb float-sm-end mb-0">
<li className="breadcrumb-item">
<Link href="/">Start</Link>
</li>
{brotkrumen.map((k, i) => (
<li
key={`${k.titel}-${i}`}
className={`breadcrumb-item ${i === brotkrumen.length - 1 ? "active" : ""}`}
aria-current={i === brotkrumen.length - 1 ? "page" : undefined}
>
{k.href ? <Link href={k.href}>{k.titel}</Link> : k.titel}
</li>
))}
</ol>
</nav>
</div>
</div>
</div>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { APP_VERSION } from "@/lib/version";
export function Footer() {
return (
<footer className="app-footer bg-body">
<div className="float-end d-none d-sm-inline">Prototyp mit Beispieldaten (#002)</div>
<strong>ProxLedger</strong> · Version {APP_VERSION}
</footer>
);
}
+218
View File
@@ -0,0 +1,218 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import type { SuchEintrag } from "@/mock/suchindex";
import type { Alert } from "@/mock/types";
import { SearchDialog } from "./SearchDialog";
const SCHWERE_ICON: Record<Alert["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,
}: {
alerts: Alert[];
suchEintraege: SuchEintrag[];
benutzer: string;
}) {
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);
}, []);
/** Sidebar umschalten: auf schmalen Bildschirmen als Overlay, sonst eingeklappt. */
const sidebarUmschalten = useCallback(() => {
const body = document.body;
if (window.innerWidth < 992) {
body.classList.toggle("sidebar-open");
body.classList.remove("sidebar-collapse");
} else {
body.classList.toggle("sidebar-collapse");
body.classList.remove("sidebar-open");
}
}, []);
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 ${glockeOffen ? "show" : ""}`}
style={{ width: "22rem" }}
>
<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}` : "/hosts"}
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="/hosts" 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 ${benutzerOffen ? "show" : ""}`}>
<span className="dropdown-header">Angemeldet als {benutzer}</span>
<div className="dropdown-divider" />
<Link href="/einstellungen/system" className="dropdown-item">
Einstellungen
</Link>
<span className="dropdown-item text-body-secondary">Abmelden (ab #013)</span>
</div>
</li>
</ul>
</div>
{sucheOffen ? (
<SearchDialog eintraege={suchEintraege} onSchliessen={() => setSucheOffen(false)} />
) : null}
</nav>
);
}
+110
View File
@@ -0,0 +1,110 @@
"use client";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import type { SuchEintrag } from "@/mock/suchindex";
const TYP_FARBE: Record<SuchEintrag["typ"], string> = {
Host: "pl-bg-info",
Kunde: "pl-bg-primary",
Rechnung: "pl-bg-neutral",
};
/**
* Globale Suche als Dialog, per Strg+K oder über die Kopfleiste.
* Wird nur gerendert, solange er offen ist – dadurch startet er jedes Mal leer.
*/
export function SearchDialog({
eintraege,
onSchliessen,
}: {
eintraege: SuchEintrag[];
onSchliessen: () => void;
}) {
const [begriff, setBegriff] = useState("");
const [markiert, setMarkiert] = useState(0);
const router = useRouter();
const treffer = useMemo(() => {
const q = begriff.trim().toLowerCase();
if (q.length < 2) return [];
return eintraege.filter((e) => e.begriffe.some((b) => b.includes(q))).slice(0, 12);
}, [begriff, eintraege]);
function oeffnen(eintrag: SuchEintrag) {
onSchliessen();
router.push(eintrag.href);
}
function tasten(event: React.KeyboardEvent) {
if (event.key === "Escape") onSchliessen();
if (event.key === "ArrowDown") {
event.preventDefault();
setMarkiert((i) => Math.min(i + 1, treffer.length - 1));
}
if (event.key === "ArrowUp") {
event.preventDefault();
setMarkiert((i) => Math.max(i - 1, 0));
}
if (event.key === "Enter" && treffer[markiert]) {
event.preventDefault();
oeffnen(treffer[markiert]);
}
}
return (
<>
<div className="pl-search-backdrop" onClick={onSchliessen} />
<div className="pl-search-dialog" role="dialog" aria-modal="true" aria-label="Globale Suche">
<div className="card shadow-lg mb-0">
<div className="card-body p-2">
<div className="input-group">
<span className="input-group-text bg-body">
<i className="bi bi-search" aria-hidden="true" />
</span>
<input
autoFocus
type="search"
className="form-control form-control-lg"
placeholder="Key, Host, Kunde oder Rechnungsnummer …"
value={begriff}
onChange={(e) => {
setBegriff(e.target.value);
setMarkiert(0);
}}
onKeyDown={tasten}
aria-label="Suchbegriff"
/>
</div>
</div>
{treffer.length > 0 ? (
<ul className="list-group list-group-flush">
{treffer.map((eintrag, index) => (
<li key={`${eintrag.typ}-${eintrag.titel}-${index}`} className="list-group-item p-0">
<button
type="button"
className={`btn btn-link w-100 text-start text-decoration-none d-flex align-items-center gap-2 py-2 ${
index === markiert ? "bg-body-secondary" : ""
}`}
onMouseEnter={() => setMarkiert(index)}
onClick={() => oeffnen(eintrag)}
>
<span className={`badge ${TYP_FARBE[eintrag.typ]}`}>{eintrag.typ}</span>
<span className="flex-grow-1">
<span className="d-block text-body fw-semibold">{eintrag.titel}</span>
<span className="d-block small text-body-secondary">{eintrag.untertitel}</span>
</span>
</button>
</li>
))}
</ul>
) : (
<div className="card-body pt-0 small text-body-secondary">
Mindestens zwei Zeichen eingeben. Beispiel: <span className="pl-mono">pve1c-a6bd21fcb6</span>
</div>
)}
</div>
</div>
</>
);
}
+126
View File
@@ -0,0 +1,126 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
import { LogoMitSchriftzug } from "@/components/Logo";
interface MenuEintrag {
href: string;
titel: string;
icon: string;
badge?: { text: string; klasse: string };
}
export function Sidebar({
hostsAnzahl,
offeneRechnungspunkte,
benutzer,
}: {
hostsAnzahl: number;
offeneRechnungspunkte: number;
benutzer: string;
}) {
const pathname = usePathname();
const router = useRouter();
const [suche, setSuche] = useState("");
const haupt: MenuEintrag[] = [
{ href: "/", titel: "Dashboard", icon: "bi-speedometer2" },
{
href: "/hosts",
titel: "Hosts",
icon: "bi-hdd-stack",
badge: { text: String(hostsAnzahl), klasse: "pl-bg-info" },
},
{ href: "/kunden", titel: "Kunden", icon: "bi-people" },
{
href: "/rechnungen",
titel: "Rechnungen",
icon: "bi-receipt",
badge:
offeneRechnungspunkte > 0
? { text: String(offeneRechnungspunkte), klasse: "pl-bg-danger" }
: undefined,
},
{ href: "/jahresbahn", titel: "Jahresbahn", icon: "bi-calendar3-range" },
];
const einstellungen: MenuEintrag[] = [
{ href: "/einstellungen/tokens", titel: "Agent-Tokens", icon: "bi-key" },
{ href: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen", icon: "bi-bell" },
{ href: "/einstellungen/system", titel: "System", icon: "bi-sliders" },
];
function istAktiv(href: string) {
if (href === "/") return pathname === "/";
return pathname === href || pathname.startsWith(`${href}/`);
}
function sucheAbsenden(event: React.FormEvent) {
event.preventDefault();
if (suche.trim().length > 0) {
router.push(`/hosts?q=${encodeURIComponent(suche.trim())}`);
}
}
function eintrag(item: MenuEintrag) {
return (
<li className="nav-item" key={item.href}>
<Link href={item.href} className={`nav-link pl-touch-target ${istAktiv(item.href) ? "active" : ""}`}>
<i className={`nav-icon bi ${item.icon}`} aria-hidden="true" />
<p className="ms-2 mb-0 flex-grow-1">{item.titel}</p>
{item.badge ? (
<span className={`nav-badge badge ${item.badge.klasse}`}>{item.badge.text}</span>
) : null}
</Link>
</li>
);
}
return (
<aside className="app-sidebar shadow" data-bs-theme="dark">
<div className="sidebar-brand">
<Link href="/" className="brand-link">
<LogoMitSchriftzug variante="sidebar" groesse={34} />
</Link>
</div>
<div className="pl-sidebar-user">
<span className="pl-sidebar-user-avatar" aria-hidden="true">
{benutzer.slice(0, 1)}
</span>
<span>
<span className="d-block small text-white">{benutzer}</span>
<span className="d-block small opacity-75">Administrator</span>
</span>
</div>
<form className="pl-sidebar-search" role="search" onSubmit={sucheAbsenden}>
<div className="input-group input-group-sm">
<input
type="search"
className="form-control"
placeholder="Suchen …"
aria-label="Hosts, Kunden oder Rechnungen suchen"
value={suche}
onChange={(e) => setSuche(e.target.value)}
/>
<button className="btn btn-primary" type="submit" aria-label="Suchen">
<i className="bi bi-search" aria-hidden="true" />
</button>
</div>
</form>
<div className="sidebar-wrapper">
<nav className="mt-2">
<ul className="nav sidebar-menu flex-column" role="menu">
{haupt.map(eintrag)}
<li className="nav-header">Einstellungen</li>
{einstellungen.map(eintrag)}
</ul>
</nav>
</div>
</aside>
);
}