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>
237 lines
7.7 KiB
TypeScript
237 lines
7.7 KiB
TypeScript
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>
|
||
);
|
||
}
|