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
+52
View File
@@ -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>
);
}
+56
View File
@@ -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>
);
}
+45
View File
@@ -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>
);
}
+35
View File
@@ -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>
);
}
+61
View File
@@ -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>
);
}
+45
View File
@@ -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>
);
}
+66
View File
@@ -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>
);
}