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