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
+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>
</>
);
}