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:
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* ProxLedger-Monogramm: ein P auf zwei Buchungslinien.
|
||||
* Varianten: "standard" (heller Grund), "sidebar" (dunkler Grund), "mono" (einfarbig für PDF/Rechnungen).
|
||||
*/
|
||||
|
||||
export type LogoVariante = "standard" | "sidebar" | "mono";
|
||||
|
||||
interface LogoProps {
|
||||
variante?: LogoVariante;
|
||||
groesse?: number;
|
||||
className?: string;
|
||||
/** Für dekorative Verwendung neben dem Schriftzug. */
|
||||
titel?: string;
|
||||
}
|
||||
|
||||
const FARBEN: Record<LogoVariante, { p: string; linien: string }> = {
|
||||
standard: { p: "#1E2A44", linien: "#17A2B8" },
|
||||
sidebar: { p: "#FFFFFF", linien: "#17A2B8" },
|
||||
mono: { p: "#1E2A44", linien: "#1E2A44" },
|
||||
};
|
||||
|
||||
export function Logo({ variante = "standard", groesse = 40, className, titel }: LogoProps) {
|
||||
const farben = FARBEN[variante];
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 48 48"
|
||||
width={groesse}
|
||||
height={groesse}
|
||||
className={className}
|
||||
role={titel ? "img" : "presentation"}
|
||||
aria-label={titel}
|
||||
aria-hidden={titel ? undefined : true}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{titel ? <title>{titel}</title> : null}
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8 4h19a12 12 0 0 1 0 24H16v16H8zM16 11h10a5 5 0 0 1 0 10H16z"
|
||||
fill={farben.p}
|
||||
/>
|
||||
<path d="M22 36h20M22 43h14" stroke={farben.linien} strokeWidth="3.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Logo mit Schriftzug, wie in Sidebar und Login verwendet. */
|
||||
export function LogoMitSchriftzug({
|
||||
variante = "standard",
|
||||
groesse = 36,
|
||||
}: {
|
||||
variante?: LogoVariante;
|
||||
groesse?: number;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Logo variante={variante} groesse={groesse} className="brand-image opacity-100" titel="ProxLedger" />
|
||||
<span className="brand-text ms-2">ProxLedger</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { formatDatum, formatTageBis, tageBis } from "@/lib/format";
|
||||
import { STATUS_TEXT } from "@/mock/queries";
|
||||
import type { SubscriptionStatus } from "@/mock/types";
|
||||
import { FARB_KLASSE, type Farbton } from "./SmallBox";
|
||||
|
||||
export function Badge({ farbe, children }: { farbe: Farbton; children: React.ReactNode }) {
|
||||
return <span className={`badge ${FARB_KLASSE[farbe]}`}>{children}</span>;
|
||||
}
|
||||
|
||||
const STATUS_FARBE: Record<SubscriptionStatus, Farbton> = {
|
||||
active: "success",
|
||||
notfound: "neutral",
|
||||
invalid: "danger",
|
||||
expired: "danger",
|
||||
suspended: "warning",
|
||||
};
|
||||
|
||||
export function StatusBadge({ status }: { status: SubscriptionStatus }) {
|
||||
return <Badge farbe={STATUS_FARBE[status]}>{STATUS_TEXT[status]}</Badge>;
|
||||
}
|
||||
|
||||
/** Ablaufdatum mit Ampel: rot unter 14 Tagen, gelb unter 30 Tagen, sonst Info. */
|
||||
export function AblaufBadge({ datum, heute }: { datum: string | null; heute: Date }) {
|
||||
if (!datum) return <Badge farbe="neutral">–</Badge>;
|
||||
const tage = tageBis(datum, heute);
|
||||
const farbe: Farbton = tage === null || tage < 14 ? "danger" : tage < 30 ? "warning" : "info";
|
||||
return (
|
||||
<span className="d-inline-flex align-items-center gap-2">
|
||||
<Badge farbe={farbe}>{formatDatum(datum)}</Badge>
|
||||
<small className="text-body-secondary">{formatTageBis(tage)}</small>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function EinkaufBadge({ bezahlt }: { bezahlt: boolean | null }) {
|
||||
if (bezahlt === null) return <Badge farbe="neutral">Keine Rechnung</Badge>;
|
||||
return bezahlt ? <Badge farbe="success">Bezahlt</Badge> : <Badge farbe="danger">Offen</Badge>;
|
||||
}
|
||||
|
||||
export function VerkaufBadge({ erstellt, bezahlt }: { erstellt: boolean; bezahlt: boolean }) {
|
||||
if (!erstellt) return <Badge farbe="danger">Fehlt</Badge>;
|
||||
return bezahlt ? <Badge farbe="success">Bezahlt</Badge> : <Badge farbe="warning">Erstellt</Badge>;
|
||||
}
|
||||
|
||||
export function KeineMeldungBadge() {
|
||||
return (
|
||||
<span className="badge pl-bg-warning ms-2" title="Der Host hat sich länger als 48 Stunden nicht gemeldet">
|
||||
<i className="bi bi-wifi-off me-1" aria-hidden="true" />
|
||||
Keine Meldung
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type CalloutFarbe = "warning" | "danger" | "info";
|
||||
|
||||
const STIL: Record<CalloutFarbe, { rand: string; icon: string }> = {
|
||||
warning: { rand: "var(--pl-warning)", icon: "bi-exclamation-triangle-fill" },
|
||||
danger: { rand: "var(--pl-danger)", icon: "bi-x-octagon-fill" },
|
||||
info: { rand: "var(--pl-info)", icon: "bi-info-circle-fill" },
|
||||
};
|
||||
|
||||
export function Callout({
|
||||
farbe = "warning",
|
||||
titel,
|
||||
children,
|
||||
aktion,
|
||||
}: {
|
||||
farbe?: CalloutFarbe;
|
||||
titel: string;
|
||||
children: ReactNode;
|
||||
aktion?: ReactNode;
|
||||
}) {
|
||||
const stil = STIL[farbe];
|
||||
return (
|
||||
<div
|
||||
className="card bg-body shadow-sm mb-4 d-flex flex-row align-items-center gap-3 p-3"
|
||||
style={{ borderLeft: `5px solid ${stil.rand}` }}
|
||||
role="note"
|
||||
>
|
||||
<i className={`bi ${stil.icon} fs-4`} style={{ color: stil.rand }} aria-hidden="true" />
|
||||
<div className="flex-grow-1">
|
||||
<strong>{titel}</strong>
|
||||
<div className="small text-body-secondary">{children}</div>
|
||||
</div>
|
||||
{aktion}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Fortschrittsbalken, z. B. für den Rechnungsstatus je Kunde. */
|
||||
export function ProgressBar({ prozent, beschriftung }: { prozent: number; beschriftung?: string }) {
|
||||
const farbe =
|
||||
prozent === 100 ? "var(--pl-success)" : prozent >= 50 ? "var(--pl-warning)" : "var(--pl-danger)";
|
||||
return (
|
||||
<div
|
||||
className="progress"
|
||||
role="progressbar"
|
||||
aria-valuenow={prozent}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={beschriftung}
|
||||
style={{ height: "0.75rem" }}
|
||||
>
|
||||
<div className="progress-bar" style={{ width: `${prozent}%`, backgroundColor: farbe }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type Rahmen = "primary" | "info" | "warning" | "danger" | "keiner";
|
||||
|
||||
const RAHMEN_KLASSE: Record<Rahmen, string> = {
|
||||
primary: "pl-card-outline-primary",
|
||||
info: "pl-card-outline-info",
|
||||
warning: "pl-card-outline-warning",
|
||||
danger: "pl-card-outline-danger",
|
||||
keiner: "",
|
||||
};
|
||||
|
||||
interface CardProps {
|
||||
titel?: ReactNode;
|
||||
werkzeuge?: ReactNode;
|
||||
rahmen?: Rahmen;
|
||||
/** Ohne Innenabstand, z. B. für randlose Tabellen. */
|
||||
tabelle?: boolean;
|
||||
fuss?: ReactNode;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Card({
|
||||
titel,
|
||||
werkzeuge,
|
||||
rahmen = "keiner",
|
||||
tabelle = false,
|
||||
fuss,
|
||||
className = "",
|
||||
children,
|
||||
}: CardProps) {
|
||||
return (
|
||||
<div className={`card mb-4 shadow-sm ${RAHMEN_KLASSE[rahmen]} ${className}`}>
|
||||
{titel ? (
|
||||
<div className="card-header d-flex align-items-center justify-content-between">
|
||||
<h3 className="card-title">{titel}</h3>
|
||||
{werkzeuge ? <div className="card-tools d-flex gap-2">{werkzeuge}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={tabelle ? "card-body p-0 pl-table-card" : "card-body"}>{children}</div>
|
||||
{fuss ? <div className="card-footer">{fuss}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
/** Monospace-Wert (Key, Server-ID, Rechnungsnummer) mit Kopier-Button. */
|
||||
export function CopyableCode({ wert, className = "" }: { wert: string | null; className?: string }) {
|
||||
const [kopiert, setKopiert] = useState(false);
|
||||
|
||||
if (!wert) return <span className="text-body-secondary">–</span>;
|
||||
|
||||
async function kopieren() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(wert as string);
|
||||
setKopiert(true);
|
||||
setTimeout(() => setKopiert(false), 1500);
|
||||
} catch {
|
||||
// Zwischenablage nicht verfügbar (z. B. ohne HTTPS) – Wert bleibt markierbar.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`d-inline-flex align-items-center gap-1 ${className}`}>
|
||||
<span className="pl-mono">{wert}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="pl-copy-button"
|
||||
onClick={kopieren}
|
||||
title={`${wert} kopieren`}
|
||||
aria-label={`${wert} kopieren`}
|
||||
>
|
||||
<i className={`bi ${kopiert ? "bi-check-lg" : "bi-clipboard"}`} aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export type Farbton = "info" | "success" | "warning" | "danger" | "neutral" | "primary";
|
||||
|
||||
export const FARB_KLASSE: Record<Farbton, string> = {
|
||||
info: "pl-bg-info",
|
||||
success: "pl-bg-success",
|
||||
warning: "pl-bg-warning",
|
||||
danger: "pl-bg-danger",
|
||||
neutral: "pl-bg-neutral",
|
||||
primary: "pl-bg-primary",
|
||||
};
|
||||
|
||||
interface SmallBoxProps {
|
||||
wert: number | string;
|
||||
text: string;
|
||||
zusatz?: string;
|
||||
icon: string;
|
||||
farbe: Farbton;
|
||||
href: string;
|
||||
linkText?: string;
|
||||
}
|
||||
|
||||
export function SmallBox({ wert, text, zusatz, icon, farbe, href, linkText = "Mehr Infos" }: SmallBoxProps) {
|
||||
return (
|
||||
<div className={`small-box ${FARB_KLASSE[farbe]}`}>
|
||||
<div className="inner">
|
||||
<h3>{wert}</h3>
|
||||
<p className="mb-2">
|
||||
{text}
|
||||
{zusatz ? <small className="d-block">{zusatz}</small> : null}
|
||||
</p>
|
||||
</div>
|
||||
<i className={`bi ${icon} small-box-icon`} aria-hidden="true" />
|
||||
<Link href={href} className="small-box-footer pl-touch-target">
|
||||
{linkText} <i className="bi bi-arrow-right-circle" aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface InfoBoxProps {
|
||||
text: string;
|
||||
wert: string;
|
||||
icon: string;
|
||||
farbe: Farbton;
|
||||
}
|
||||
|
||||
export function InfoBox({ text, wert, icon, farbe }: InfoBoxProps) {
|
||||
return (
|
||||
<div className="info-box shadow-sm mb-3 bg-body">
|
||||
<span className={`info-box-icon ${FARB_KLASSE[farbe]}`}>
|
||||
<i className={`bi ${icon}`} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="info-box-content">
|
||||
<span className="info-box-text">{text}</span>
|
||||
<span className="info-box-number">{wert}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
|
||||
export interface Tab {
|
||||
id: string;
|
||||
titel: string;
|
||||
icon?: string;
|
||||
inhalt: ReactNode;
|
||||
}
|
||||
|
||||
/** Karten-Tabs im AdminLTE-Stil, ohne Bootstrap-JavaScript. */
|
||||
export function Tabs({ tabs }: { tabs: Tab[] }) {
|
||||
const [aktiv, setAktiv] = useState(tabs[0]?.id);
|
||||
|
||||
return (
|
||||
<div className="card shadow-sm mb-4">
|
||||
<div className="card-header p-0 border-bottom-0">
|
||||
<ul className="nav nav-tabs" role="tablist">
|
||||
{tabs.map((tab) => (
|
||||
<li className="nav-item" key={tab.id} role="presentation">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={aktiv === tab.id}
|
||||
className={`nav-link pl-touch-target ${aktiv === tab.id ? "active" : ""}`}
|
||||
onClick={() => setAktiv(tab.id)}
|
||||
>
|
||||
{tab.icon ? <i className={`bi ${tab.icon} me-1`} aria-hidden="true" /> : null}
|
||||
{tab.titel}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{tabs.map((tab) => (
|
||||
<div key={tab.id} role="tabpanel" hidden={aktiv !== tab.id}>
|
||||
{aktiv === tab.id ? tab.inhalt : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Badge } from "./Badges";
|
||||
import type { Farbton } from "./SmallBox";
|
||||
|
||||
export interface Aufgabe {
|
||||
id: string;
|
||||
titel: string;
|
||||
detail: string;
|
||||
dringlichkeit: "gefahr" | "warnung" | "info";
|
||||
badgeText: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const BADGE_FARBE: Record<Aufgabe["dringlichkeit"], Farbton> = {
|
||||
gefahr: "danger",
|
||||
warnung: "warning",
|
||||
info: "info",
|
||||
};
|
||||
|
||||
/**
|
||||
* Aufgabenliste im AdminLTE-Todo-Stil. Im Prototyp merkt sich das Abhaken den Zustand nur lokal;
|
||||
* ab #008 setzt es den Rechnungsstatus in der Datenbank.
|
||||
*/
|
||||
export function TodoList({ aufgaben }: { aufgaben: Aufgabe[] }) {
|
||||
const [erledigt, setErledigt] = useState<string[]>([]);
|
||||
|
||||
function umschalten(id: string) {
|
||||
setErledigt((alt) => (alt.includes(id) ? alt.filter((x) => x !== id) : [...alt, id]));
|
||||
}
|
||||
|
||||
if (aufgaben.length === 0) {
|
||||
return <p className="text-body-secondary mb-0">Nichts zu tun – alle Punkte erledigt.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="pl-todo-list">
|
||||
{aufgaben.map((aufgabe) => {
|
||||
const istErledigt = erledigt.includes(aufgabe.id);
|
||||
return (
|
||||
<li
|
||||
key={aufgabe.id}
|
||||
className={`pl-todo-${aufgabe.dringlichkeit} ${istErledigt ? "pl-todo-erledigt" : ""}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input mt-1 pl-touch-target"
|
||||
checked={istErledigt}
|
||||
onChange={() => umschalten(aufgabe.id)}
|
||||
aria-label={`${aufgabe.titel} erledigt`}
|
||||
/>
|
||||
<div className="flex-grow-1 pl-todo-text">
|
||||
<Link href={aufgabe.href} className="fw-semibold text-decoration-none">
|
||||
{aufgabe.titel}
|
||||
</Link>
|
||||
<div className="small text-body-secondary">{aufgabe.detail}</div>
|
||||
</div>
|
||||
<Badge farbe={BADGE_FARBE[aufgabe.dringlichkeit]}>{aufgabe.badgeText}</Badge>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user