Files
Laufzettel/src/components/huelle.tsx
T
Florian WildeandClaude Opus 5 548c090562
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(vorlagen): Editor, Liste und Versionsgeschichte
Der Redakteur baut eine Vorlage jetzt vollstaendig in der Oberflaeche: Liste mit
Suche und Filtern, Editor mit Abschnitten, Schritten und Variablen, Vorschau mit
Beispielwerten, Veroeffentlichen mit Aenderungsnotiz, Versionsgeschichte.

Sortiert wird mit dem Finger, der Maus, der Tastatur **und** zwei Knoepfen an
jeder Zeile — ein Touch-Drag gelingt auf einem 390-px-Geraet nicht jedem, und
die Reihenfolge ist fachlich zu wichtig fuer nur einen Weg. Schritte, Variablen
und Abschnitte klappen zu: dieselbe Vorlage misst am Handy 3 482 statt 16 986 px.

- src/lib/platzhalter.ts als einzige Quelle der eingebauten Platzhalter; der
  Editor warnt vor unbekannten, blockiert aber nichts
- Hilfetexte als Markdown ohne rohes HTML, Bilder als Alternativtext
- Erste Playwright-Tests: der Editor wird durch die Oberflaeche gefahren

Wichtigster Befund des Meilensteins: `next dev` hydriert in dieser Umgebung
ueberhaupt nicht — die Seiten sehen richtig aus, aber kein Knopf tut etwas
(2 von 1302 Elementen tragen React-Knoten, im gebauten Stand 1281 von 1296).
Nicht an diesem Code: auch ohne proxy.ts und ohne output: standalone. Ein
Screenshot beweist damit nichts ueber Bedienbarkeit. Oberflaechen werden ab
sofort gegen `pnpm build && pnpm start` abgenommen, und was nur mit angehaengtem
React gelingt, gehoert in einen Playwright-Test.

Der unabhaengige Review fand zwei Blocker: eine abgelaufene Anmeldung haette im
Editor jede ungespeicherte Arbeit vernichtet (der Rechtefehler lief bis in Nexts
Fehlergrenze), und das Speichern der Kopfdaten loeste eine archivierte
Kundenzuordnung stillschweigend auf. Dazu sechs weitere Befunde, alle behoben.

496 Tests gruen (vorher 428), dazu 5 E2E-Tests.

Schliesst #010

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 10:22:33 +02:00

237 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Link from "next/link";
import {
Archive,
Building2,
FileText,
LayoutDashboard,
ListChecks,
LogOut,
Menu,
ScanLine,
Settings,
Users,
type LucideIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Bildmarke } from "@/components/bildmarke";
import { Wortmarke } from "@/components/wortmarke";
import type { Rolle } from "@/generated/prisma/enums";
import { navigationFuer, type Betriebsmodus, type NavEintrag } from "@/lib/navigation";
import { ROLLEN_TEXT } from "@/lib/rollen-text";
import { darf } from "@/server/auth/rollen";
import { cn } from "@/lib/utils";
import { versionText } from "@/lib/version";
/** Wird in #014 auf `true` gestellt, zusammen mit der Seite. */
const SCANNEN_VERFUEGBAR = false;
const SYMBOLE: Record<string, LucideIcon> = {
LayoutDashboard,
ListChecks,
FileText,
Users,
Building2,
Archive,
Settings,
};
function NavListe({ modus, aktiv, rolle }: { modus: Betriebsmodus; aktiv: string; rolle?: Rolle }) {
const eintraege = navigationFuer(modus, (recht) => (rolle ? darf(rolle, recht) : false));
return (
<ul className="flex flex-col gap-1">
{eintraege.map((eintrag) => (
<li key={eintrag.pfad}>
<NavZeile eintrag={eintrag} aktiv={istAktiv(eintrag.pfad, aktiv)} />
</li>
))}
</ul>
);
}
/** Die Uebersicht nur bei genauer Uebereinstimmung, alles andere auch fuer Unterseiten. */
function istAktiv(pfad: string, aktiv: string): boolean {
if (pfad === "/") return aktiv === "/";
return aktiv === pfad || aktiv.startsWith(`${pfad}/`);
}
function NavZeile({ eintrag, aktiv }: { eintrag: NavEintrag; aktiv: boolean }) {
const Symbol = SYMBOLE[eintrag.symbol] ?? LayoutDashboard;
const inhalt = (
<>
<Symbol className="size-[18px] shrink-0" strokeWidth={1.75} aria-hidden="true" />
<span className="truncate">{eintrag.beschriftung}</span>
</>
);
const gemeinsam =
"flex min-h-11 items-center gap-3 rounded-pille px-3 text-sm transition-colors md:min-h-10";
// Noch nicht vorhandene Seiten werden nicht verlinkt, damit kein Klick ins Leere geht.
if (!eintrag.verfuegbar) {
return (
<span
className={`${gemeinsam} cursor-not-allowed text-nebentext/50`}
title={`Kommt mit #${String(eintrag.ausIssue).padStart(3, "0")}`}
aria-disabled="true"
>
{inhalt}
</span>
);
}
return (
<Link
href={eintrag.pfad}
aria-current={aktiv ? "page" : undefined}
className={`${gemeinsam} ${
aktiv
? "bg-akzent-flaeche font-semibold text-akzent"
: "text-nebentext hover:bg-flaeche-hoch hover:text-text"
}`}
>
{inhalt}
</Link>
);
}
/**
* Der Einstieg des Technikers, an beiden Stellen ganz oben.
*
* Solange es `/scannen` nicht gibt (#014), wird **nicht** verlinkt: ein Knopf,
* der so prominent sitzt, darf nicht auf eine Fehlerseite fuehren. Next holt den
* Verweis beim Darueberfahren sogar vorab — jede Seite erzeugte damit eine 404
* im Hintergrund. Dieselbe Regel gilt schon fuer die Navigation.
*/
function ScannenKnopf({ className }: { className?: string }) {
// Pille statt der shadcn-Vorgabe rounded-lg, und 44 px am Handy / 40 px am
// Schreibtisch statt 32 px – so verlangt es die Gestaltungsvorgabe.
const gemeinsam = cn("h-11 gap-2 rounded-pille px-4 text-sm font-semibold md:h-10", className);
const inhalt = (
<>
<ScanLine className="size-[18px]" strokeWidth={2} aria-hidden="true" />
Scannen
</>
);
if (!SCANNEN_VERFUEGBAR) {
// Nicht als abgeblendeter Akzentknopf – das sieht aus wie ein Fehler. Die
// gedaempfte Form ist dieselbe, die die Navigation fuer noch fehlende Seiten
// benutzt.
return (
<span
title="Kommt mit #014"
aria-disabled="true"
className={cn(
gemeinsam,
"flex cursor-not-allowed items-center justify-center border border-linie text-nebentext/60",
)}
>
{inhalt}
</span>
);
}
return (
<Button asChild className={gemeinsam}>
<Link href="/scannen">{inhalt}</Link>
</Button>
);
}
export type AngemeldeterBenutzer = {
readonly name: string;
readonly rolle: Rolle;
};
function BenutzerBlock({ benutzer }: { benutzer: AngemeldeterBenutzer }) {
return (
<div className="flex items-center justify-between gap-2 border-t border-linie pt-3">
<div className="min-w-0">
<p className="truncate text-sm text-text">{benutzer.name}</p>
<p className="truncate text-xs text-nebentext">{ROLLEN_TEXT[benutzer.rolle]}</p>
</div>
{/* Abmelden per POST, damit ein fremder Link niemanden abmelden kann. */}
<form action="/api/auth/abmelden" method="post">
<button
type="submit"
className="flex size-11 items-center justify-center rounded-pille text-nebentext hover:bg-flaeche-hoch hover:text-text md:size-9"
title="Abmelden"
aria-label="Abmelden"
>
<LogOut className="size-[18px]" strokeWidth={1.75} aria-hidden="true" />
</button>
</form>
</div>
);
}
/**
* Huelle der Anwendung: dunkle Seitenleiste am Schreibtisch, am Handy eine
* Kopfzeile mit aufklappbarem Menue. Der Scannen-Knopf sitzt an beiden Stellen
* ganz oben, weil der Techniker damit anfaengt.
*/
export function Huelle({
children,
modus,
aktiv = "/",
benutzer,
}: {
children: React.ReactNode;
/**
* Pflicht, ohne stillen Standard: ein vergessener Parameter wuerde sonst die
* Kundenebene ausblenden, statt aufzufallen.
*/
modus: Betriebsmodus;
aktiv?: string;
benutzer?: AngemeldeterBenutzer;
}) {
return (
<div className="min-h-dvh md:flex">
{/* Handy: Kopfzeile mit Menue */}
<header className="sticky top-0 z-20 border-b border-linie bg-flaeche md:hidden">
<div className="flex items-center gap-3 px-4 py-3">
<Bildmarke className="size-6 shrink-0 text-akzent" />
<Wortmarke className="text-lg" />
<ScannenKnopf className="ml-auto" />
<details className="relative">
<summary
className="flex size-11 cursor-pointer list-none items-center justify-center rounded-pille text-nebentext"
aria-label="Menü"
>
<Menu className="size-5" strokeWidth={1.75} aria-hidden="true" />
</summary>
<nav className="absolute right-0 z-30 mt-2 w-60 rounded-karte border border-linie bg-flaeche-hoch p-2">
<NavListe modus={modus} aktiv={aktiv} rolle={benutzer?.rolle} />
{benutzer ? (
<div className="mt-2 px-1">
<BenutzerBlock benutzer={benutzer} />
</div>
) : null}
</nav>
</details>
</div>
</header>
{/* Schreibtisch: Seitenleiste */}
<aside className="hidden w-60 shrink-0 flex-col border-r border-linie bg-flaeche p-4 md:flex">
<Link href="/" className="mb-4 flex items-center gap-2">
<Bildmarke className="size-7 shrink-0 text-akzent" />
<Wortmarke className="text-xl" />
</Link>
<ScannenKnopf className="mb-6" />
<nav aria-label="Hauptnavigation">
<NavListe modus={modus} aktiv={aktiv} rolle={benutzer?.rolle} />
</nav>
<div className="mt-auto flex flex-col gap-3 pt-6">
{benutzer ? <BenutzerBlock benutzer={benutzer} /> : null}
<span className="mono text-xs text-nebentext">{versionText()}</span>
</div>
</aside>
<main className="min-w-0 flex-1 p-4 md:p-8">{children}</main>
{/* Am Handy steht die Version unten im Inhalt, nicht fest ueber allem. */}
<span className="mono block px-4 pb-4 text-xs text-nebentext md:hidden">{versionText()}</span>
</div>
);
}