Files
Laufzettel/src/components/huelle.tsx
T
florian.wilde e3d80e5982
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(vorlagen): Editor, Liste und Versionsgeschichte (#24)
2026-09-25 08:23:22 +00: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>
);
}