Der Einstieg, fuer den die Anwendung gebaut ist: scannen, sehen was zu tun ist,
loslegen. `/t/<tagCode>` kennt drei Faelle — registriertes Geraet, vorgedruckter
Tag, unbekannter Code —, die Geraeteseite zeigt Zuweisungen, Schnellstart und
Historie, und beides ist am Handy bedienbar.
Im QR steht ausschliesslich `{APP_URL}/t/{tagCode}`: kein Name, keine
Seriennummer, kein Besitzer. Ein Aufkleber haengt jahrelang sichtbar am Geraet
und wird von jedem Telefon gelesen; was darauf steht, ist veroeffentlicht. Die
Masse sind gemessen, nicht behauptet: ein echtes PDF liefert 62 x 29 mm und A4,
und ein bei 300 dpi gerastertes Etikett wird mit demselben Decoder
zurueckgelesen, den auch der Scanner benutzt.
Drei Defekte zeigte erst der gebaute Stand, alle an den Naehten zwischen den
parallel gebauten Teilen: „Starten“ setzte IN_ARBEIT und sprang nicht, „Vorlage
zuweisen“ bot Vorlagen an, die der Dienst danach ablehnte, und die Geraeteliste
verlinkte nichts. Der unabhaengige Review fand drei Blocker — darunter, dass der
Scanner nie die native BarcodeDetector-API benutzte und dass ein Unterpfad in
APP_URL aus dem gedruckten QR herausfiel.
Closes #14
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";
|
||
|
||
/** Seit #014 gibt es `/scannen` – der Knopf fuehrt jetzt irgendwohin. */
|
||
const SCANNEN_VERFUEGBAR = true;
|
||
|
||
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.
|
||
*
|
||
* Der gedaempfte Zustand bleibt im Code: er gilt, solange eine Seite fehlt, und
|
||
* genau diese Lage gab es hier bis #014. 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.
|
||
*/
|
||
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>
|
||
);
|
||
}
|