feat(tags): QR-Aufkleber, Geraeteseite, Etikettendruck und Scanner
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

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>
This commit was merged in pull request #28.
This commit is contained in:
2026-09-25 19:30:50 +00:00
co-authored by Claude Opus 5
parent c78a6ac1af
commit e7a2e2d5c7
58 changed files with 4828 additions and 54 deletions
@@ -0,0 +1,112 @@
"use client";
import { useActionState } from "react";
import { FELD } from "@/lib/formular";
import { GERAETE_TYP_LISTE, GERAETE_TYP_TEXT } from "@/lib/geraete-text";
import { geraetRegistrierenAktion, type AktionsErgebnis } from "@/server/actions/geraete";
/**
* Das Registrierformular hinter einem vorgedruckten Aufkleber (#014).
*
* Es erscheint, wenn jemand einen Tag scannt, der noch keinem Gerät gehört. Der
* Techniker steht dann davor, hat das Handy in der Hand und liest den Hostname
* vom Anmeldebildschirm ab — deshalb genau die fünf Felder aus dem Auftrag und
* keines mehr. Modell und Notizen lassen sich danach in der Geräteliste
* nachtragen.
*
* **Der Tag-Code steht hier, aber nicht als Feld.** Er ist schon gedruckt und
* geklebt; der Dienst weist jede Änderung daran ab.
*/
export function GeraetRegistrieren({
geraetId,
tagCode,
besitzer,
}: {
geraetId: string;
tagCode: string;
besitzer: {
readonly kandidaten: readonly { readonly id: string; readonly anzeigename: string }[];
readonly gekuerzt: boolean;
};
}) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(geraetRegistrierenAktion, {});
return (
<form action={aktion} className="flex flex-col gap-4">
<input type="hidden" name="geraetId" value={geraetId} />
<p className="text-sm text-nebentext">
Tag-Code <span className="mono text-text">{tagCode}</span> — er bleibt, wie er ist.
</p>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Typ</span>
<select name="typ" defaultValue="SONSTIGES" className={FELD}>
{GERAETE_TYP_LISTE.map((typ) => (
<option key={typ} value={typ}>
{GERAETE_TYP_TEXT[typ]}
</option>
))}
</select>
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Hostname</span>
<input name="hostname" className={`${FELD} mono`} placeholder="PC-VERTRIEB-07" autoFocus />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Bezeichnung</span>
<input name="bezeichnung" className={FELD} placeholder="Serverschrank Keller" />
</label>
</div>
<p className="text-xs text-nebentext">
Eines von beiden genügt. Ein Raum oder ein Serverschrank hat keinen Hostname, ein Notebook keine
Bezeichnung.
</p>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Seriennummer</span>
<input name="seriennummer" className={`${FELD} mono`} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Inventarnummer</span>
<input name="inventarnummer" className={`${FELD} mono`} />
</label>
</div>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Besitzer (optional)</span>
<select name="mitarbeiterId" defaultValue="" className={FELD}>
<option value="">niemand — Server, Raum oder Poolgerät</option>
{besitzer.kandidaten.map((person) => (
<option key={person.id} value={person.id}>
{person.anzeigename}
</option>
))}
</select>
</label>
{besitzer.gekuerzt ? (
<p className="text-xs text-faellig">
Es stehen nur die ersten {besitzer.kandidaten.length} aktiven Personen zur Auswahl. Wer nicht dabei
ist, lässt sich nach dem Registrieren über die Geräteliste zuordnen.
</p>
) : null}
{ergebnis.fehler ? (
<p role="alert" className="max-w-prose text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
<button
type="submit"
disabled={laeuft}
className="flex min-h-11 w-fit items-center gap-2 rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
>
{laeuft ? "Wird registriert …" : "Gerät registrieren"}
</button>
</form>
);
}
+44
View File
@@ -0,0 +1,44 @@
"use client";
import { useActionState } from "react";
import { PlayCircle } from "lucide-react";
import { laufStartenAktion, type AktionsErgebnis } from "@/server/actions/geraete";
/**
* „Starten“ an einer Zuweisung auf der Geräteseite (#014).
*
* Aus `OFFEN` wird `IN_ARBEIT`, `gestartetAm` entsteht — und danach steht der
* Techniker **im Lauf**. Ein bloßer Link hätte ihn nur geöffnet und nicht
* gestartet; der Unterschied ist der ganze Sinn von `OFFEN`: die Liste soll
* nicht nach Arbeit aussehen, die niemand tut.
*
* Der Sprung passiert im Dienst, nicht hier. Ein `router.push` in einem Effekt
* sah richtig aus und war es nicht: `revalidatePath` baut die Geräteseite neu,
* dabei verschwindet die Zuweisung mitsamt diesem Knopf, und der Effekt kam nie
* dazu — der Lauf lief, der Techniker blieb stehen.
*/
export function LaufStartenKnopf({ laufId, geraetId }: { laufId: string; geraetId: string }) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(laufStartenAktion, {});
return (
<div className="flex flex-col gap-2">
<form action={aktion}>
<input type="hidden" name="laufId" value={laufId} />
<input type="hidden" name="geraetId" value={geraetId} />
<button
type="submit"
disabled={laeuft}
className="flex min-h-11 w-fit items-center gap-2 rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
>
<PlayCircle className="size-[18px]" strokeWidth={2} aria-hidden="true" />
{laeuft ? "Wird geöffnet …" : "Starten"}
</button>
</form>
{ergebnis.fehler ? (
<p role="alert" className="max-w-prose text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { useActionState } from "react";
import Link from "next/link";
import { Rocket, SlidersHorizontal } from "lucide-react";
import { laufSchnellstartAktion, type AktionsErgebnis } from "@/server/actions/geraete";
import type { GeraeteVorlage } from "@/server/queries/geraete";
/**
* Der Schnellstart auf der Geräteseite (#014).
*
* Eine Kachel je Vorlage, die für diesen Gerätetyp hinterlegt ist. Ein Tipp legt
* den Lauf an, startet ihn und springt hinein — mit Gerät und Besitzer, ohne eine
* einzige Eingabe.
*
* **Zwei Sorten Kacheln, und der Unterschied steht darauf.** Eine Vorlage mit
* Pflichtangaben kann so nicht starten, weil hier niemand gefragt wird; sie führt
* in den Start-Dialog, der fragt. Das verschweigt die Kachel nicht — ein Tipp,
* der einmal sofort startet und einmal ein Formular öffnet, ohne dass man es
* vorher sieht, wäre die unangenehmere Lösung.
*
* Alle Kacheln teilen einen Zustand: es wird ein Lauf gestartet, nicht drei. Nach
* dem Tippen sind die übrigen deshalb gesperrt, bis die Seite in den Lauf
* wechselt.
*/
export function Schnellstart({
geraetId,
vorlagen,
personId,
}: {
geraetId: string;
vorlagen: readonly GeraeteVorlage[];
/** Der Besitzer für den Start-Dialog. Leer, wenn das Gerät niemandem gehört. */
personId: string | null;
}) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(laufSchnellstartAktion, {});
const kachel =
"flex min-h-11 w-full flex-col items-start gap-1 rounded-karte border border-linie bg-flaeche-hoch p-4 text-left hover:border-akzent disabled:pointer-events-none disabled:opacity-40";
return (
<div className="flex flex-col gap-3">
<div className="grid gap-3 sm:grid-cols-2">
{vorlagen.map((vorlage) =>
vorlage.pflichtangaben ? (
<Link
key={vorlage.versionId}
href={`/laeufe/starten?vorlage=${encodeURIComponent(vorlage.versionId)}&geraet=${encodeURIComponent(geraetId)}${
personId ? `&person=${encodeURIComponent(personId)}` : ""
}`}
className={kachel}
>
<span className="flex items-center gap-2 font-medium">
<SlidersHorizontal
className="size-4 shrink-0 text-nebentext"
strokeWidth={1.75}
aria-hidden="true"
/>
<span className="min-w-0 break-words">{vorlage.name}</span>
</span>
<span className="text-xs text-nebentext">
v{vorlage.version}
{vorlage.kategorie ? ` · ${vorlage.kategorie}` : ""} · fragt noch nach Angaben
</span>
</Link>
) : (
<form key={vorlage.versionId} action={aktion}>
<input type="hidden" name="geraetId" value={geraetId} />
<input type="hidden" name="vorlagenVersionId" value={vorlage.versionId} />
<button type="submit" disabled={laeuft} className={kachel}>
<span className="flex items-center gap-2 font-medium">
<Rocket className="size-4 shrink-0 text-akzent" strokeWidth={2} aria-hidden="true" />
<span className="min-w-0 break-words">{vorlage.name}</span>
</span>
<span className="text-xs text-nebentext">
v{vorlage.version}
{vorlage.kategorie ? ` · ${vorlage.kategorie}` : ""}
{laeuft ? " · wird gestartet …" : ""}
</span>
</button>
</form>
),
)}
</div>
{ergebnis.fehler ? (
<p role="alert" className="max-w-prose text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
"use client";
import { useActionState, useState } from "react";
import Link from "next/link";
import { ArrowRight, ClipboardPlus } from "lucide-react";
import { FELD } from "@/lib/formular";
import { vorlageZuweisenAktion, type AktionsErgebnis } from "@/server/actions/geraete";
import type { GeraeteVorlage } from "@/server/queries/geraete";
/**
* „Vorlage zuweisen“ auf der Geräteseite (#014).
*
* Der Weg für die Vorbereitung: eine Vorlage wählen, sie jemandem geben, ein
* Datum setzen — fertig. Der Lauf entsteht als `OFFEN` und wartet unter
* „Zugewiesen“, bis ihn jemand öffnet. Für „jetzt sofort selbst machen“ gibt es
* den Schnellstart darüber.
*
* Gerät und Besitzer kommen nicht aus diesem Formular: sie stehen fest, weil die
* Seite zu genau einem Gerät gehört. Der Dienst liest beides selbst am Gerät —
* ein verstecktes Feld mit der Personenkennung wäre eine Angabe, an die sich
* niemand hält.
*
* **Eine Vorlage mit Pflichtangaben führt in den Start-Dialog**, statt hier
* abgeschickt zu werden. Dieses Formular fragt Titel, Ticket und Variablen nicht
* ab; `laufAnlegen` würde den Lauf deshalb zu Recht abweisen („Diese Angaben
* fehlen: …“) — eine Auswahl anzubieten, die danach scheitert, ist keine
* Auswahl. Dieselbe Entscheidung trifft der Schnellstart darüber.
*/
export function VorlageZuweisen({
geraetId,
vorlagen,
bearbeiter,
besitzerName,
}: {
geraetId: string;
vorlagen: readonly GeraeteVorlage[];
bearbeiter: readonly { readonly id: string; readonly name: string }[];
/** Nur für den Hinweis: an wen der Lauf geht, wenn das Gerät jemandem gehört. */
besitzerName: string | null;
}) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(vorlageZuweisenAktion, {});
const [versionId, setzeVersion] = useState("");
const gewaehlt = vorlagen.find((vorlage) => vorlage.versionId === versionId);
if (vorlagen.length === 0) {
return (
<p className="max-w-prose text-sm text-nebentext">
Es gibt keine veröffentlichte Vorlage, die zu diesem Gerät passt. Ein Entwurf ist noch niemandes
Arbeitsanweisung — erst das Veröffentlichen macht ihn dazu.
</p>
);
}
return (
<form action={aktion} className="flex flex-col gap-4">
<input type="hidden" name="geraetId" value={geraetId} />
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Vorlage</span>
<select
name="vorlagenVersionId"
required
value={versionId}
onChange={(ereignis) => setzeVersion(ereignis.target.value)}
className={FELD}
>
<option value="">bitte wählen</option>
{vorlagen.map((vorlage) => (
<option key={vorlage.versionId} value={vorlage.versionId}>
{vorlage.kategorie ? `${vorlage.kategorie} · ` : ""}
{vorlage.name} (v{vorlage.version}){vorlage.pflichtangaben ? " — fragt noch nach Angaben" : ""}
</option>
))}
</select>
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Techniker (optional)</span>
<select name="zugewiesenAnId" defaultValue="" className={FELD}>
<option value="">niemandem</option>
{bearbeiter.map((person) => (
<option key={person.id} value={person.id}>
{person.name}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Fällig am (optional)</span>
<input name="faelligAm" type="date" className={FELD} />
</label>
</div>
{besitzerName ? (
<p className="max-w-prose text-xs text-nebentext">
Der Lauf wird für {besitzerName} geführt — die Person, der dieses Gerät gehört.
</p>
) : null}
{ergebnis.fehler ? (
<p role="alert" className="max-w-prose text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
{ergebnis.erfolg ? (
<p role="status" className="max-w-prose text-sm text-erledigt">
{ergebnis.erfolg}
</p>
) : null}
{gewaehlt?.pflichtangaben ? (
<div className="flex flex-col gap-2">
<Link
href={`/laeufe/starten?vorlage=${gewaehlt.versionId}&geraet=${geraetId}`}
className="flex min-h-11 w-fit items-center gap-2 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
>
<ArrowRight className="size-4" strokeWidth={1.75} aria-hidden="true" />
Im Start-Dialog anlegen
</Link>
<p className="max-w-prose text-xs text-nebentext">
„{gewaehlt.name}“ verlangt Angaben, die hier nicht abgefragt werden. Der Start-Dialog fragt danach
— Gerät und, wenn es einen Besitzer hat, auch die Person sind dort schon vorbelegt.
</p>
</div>
) : (
<button
type="submit"
disabled={laeuft}
className="flex min-h-11 w-fit items-center gap-2 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
>
<ClipboardPlus className="size-4" strokeWidth={1.75} aria-hidden="true" />
{laeuft ? "Wird angelegt …" : "Lauf zuweisen"}
</button>
)}
</form>
);
}
+6 -6
View File
@@ -22,8 +22,8 @@ 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;
/** Seit #014 gibt es `/scannen` – der Knopf fuehrt jetzt irgendwohin. */
const SCANNEN_VERFUEGBAR = true;
const SYMBOLE: Record<string, LucideIcon> = {
LayoutDashboard,
@@ -96,10 +96,10 @@ function NavZeile({ eintrag, aktiv }: { eintrag: NavEintrag; aktiv: boolean }) {
/**
* 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.
* 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
+248
View File
@@ -0,0 +1,248 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { CameraOff, Keyboard, ScanLine } from "lucide-react";
import { FELD } from "@/lib/formular";
import { tagCodeAusFund } from "@/lib/tagcode";
/**
* Der Scanner (#014).
*
* Das ist der Einstieg, fuer den die ganze Anwendung gebaut ist: der Techniker
* steht am Geraet, scannt den Aufkleber und ist auf der Geraeteseite. Vier
* Entscheidungen stecken darin:
*
* 1. **Ein Codepfad, native API wenn vorhanden.** Geladen wird
* `barcode-detector/polyfill`: der setzt `globalThis.BarcodeDetector` nur,
* **wenn** der Browser keine mitbringt. Auf Android laeuft damit die
* eingebaute, sonst WebAssembly — und der Aufrufcode ist derselbe.
* (`barcode-detector/ponyfill` waere das Gegenteil: er gibt **immer** die
* eigene Klasse zurueck, und jedes Geraet laedt 1,1 MB WebAssembly, auch wo
* es das gar nicht braucht. Reviewbefund zu #014.)
* 2. **Das WebAssembly kommt aus dem eigenen Haus.** `zxing-wasm` laedt es sonst
* aus einem CDN — das verbietet die Content-Security-Policy (`connect-src
* 'self'`), und eine selbst gehostete Anwendung soll ohne Internet laufen.
* `scripts/wasm-bereitstellen.mjs` legt die Datei unter `public/zxing/`.
* 3. **Es gibt immer einen Weg ohne Kamera.** Kein HTTPS, verweigerte
* Berechtigung, Rechner ohne Kamera: dann steht hier ein Eingabefeld fuer den
* Code, der auf dem Aufkleber lesbar unter dem QR steht. Ein Scanner, der
* nicht startet, darf keine Sackgasse sein.
* 4. **Erkannt wird laufend, gesprungen genau einmal.** Sonst schoebe die
* Kamera bei jedem Bild eine weitere Navigation nach.
*/
export function KameraScanner() {
const router = useRouter();
const videoRef = useRef<HTMLVideoElement>(null);
const gesprungen = useRef(false);
const [fehler, setzeFehler] = useState<string | null>(null);
const [laeuft, setzeLaeuft] = useState(false);
const [fremderFund, setzeFremderFund] = useState(false);
const [eingetippt, setzeEingetippt] = useState("");
const springen = useCallback(
(tagCode: string) => {
if (gesprungen.current) return;
gesprungen.current = true;
router.push(`/t/${tagCode}`);
},
[router],
);
useEffect(() => {
let abgebrochen = false;
let strom: MediaStream | undefined;
let zeitgeber: ReturnType<typeof setInterval> | undefined;
const video = videoRef.current;
async function starten(): Promise<void> {
if (!globalThis.isSecureContext) {
setzeFehler(
"Die Kamera gibt es nur über HTTPS. Trag den Code unten ein — er steht lesbar unter dem QR.",
);
return;
}
if (!navigator.mediaDevices?.getUserMedia) {
setzeFehler("Dieser Browser gibt keine Kamera frei. Trag den Code unten ein.");
return;
}
try {
/*
* Der Polyfill wird erst hier geladen: er bringt ein WebAssembly von
* gut einem Megabyte mit, und das soll nicht in jedem Seitenaufbau der
* Anwendung stecken, sondern nur auf dieser einen Seite.
*/
/*
* Der Polyfill setzt `globalThis.BarcodeDetector`, falls der Browser
* keine hat; wo es eine gibt, bleibt sie stehen. Der Pfad zum
* WebAssembly wird trotzdem immer gesetzt — er kostet nichts und gilt
* fuer den Fall, dass der Polyfill einspringt.
*/
const { setZXingModuleOverrides } = await import("barcode-detector/polyfill");
setZXingModuleOverrides({ locateFile: () => "/zxing/zxing_reader.wasm" });
const BarcodeDetector = globalThis.BarcodeDetector;
strom = await navigator.mediaDevices.getUserMedia({
// Die rueckwaertige Kamera, sonst filmt das Handy den Techniker.
video: { facingMode: { ideal: "environment" } },
audio: false,
});
if (abgebrochen) {
for (const spur of strom.getTracks()) spur.stop();
return;
}
if (!video) return;
video.srcObject = strom;
await video.play();
setzeLaeuft(true);
const leser = new BarcodeDetector({ formats: ["qr_code"] });
/*
* Ein einzelnes Bild, das sich nicht lesen laesst, ist normal — ein
* Decoder, der **gar nicht** laeuft, ist es nicht. Der Unterschied war
* vorher unsichtbar: fehlte das WebAssembly, warf jeder Versuch, dreimal
* je Sekunde, und auf dem Bildschirm stand unveraendert „Halte den
* Aufkleber ins Bild“ (Reviewbefund zu #014). Drei Fehlschlaege
* hintereinander heissen deshalb: aufhoeren und es sagen.
*/
let fehlschlaege = 0;
zeitgeber = setInterval(() => {
void (async () => {
if (abgebrochen || gesprungen.current || video.readyState < 2) return;
try {
const funde = await leser.detect(video);
fehlschlaege = 0;
if (funde.length === 0) {
setzeFremderFund(false);
return;
}
for (const fund of funde) {
const code = tagCodeAusFund(fund.rawValue);
if (code) {
springen(code);
return;
}
}
// Etwas gefunden, aber keinen unserer Codes: das sagen wir, statt
// den Techniker rätseln zu lassen, warum nichts passiert.
setzeFremderFund(true);
} catch {
fehlschlaege += 1;
if (fehlschlaege < 3) return;
if (zeitgeber) clearInterval(zeitgeber);
setzeLaeuft(false);
setzeFehler(
"Der QR-Leser lässt sich nicht starten. Trag den Code unten ein — er steht lesbar unter dem QR.",
);
}
})();
}, 300);
} catch (ausnahme) {
const name = ausnahme instanceof Error ? ausnahme.name : "";
setzeFehler(
name === "NotAllowedError"
? "Die Kamera ist nicht freigegeben. Gib sie im Browser frei oder trag den Code unten ein."
: "Die Kamera ließ sich nicht starten. Trag den Code unten ein.",
);
}
}
void starten();
return () => {
abgebrochen = true;
if (zeitgeber) clearInterval(zeitgeber);
if (strom) for (const spur of strom.getTracks()) spur.stop();
// Auch die Verbindung zum Element loesen: gestoppte Spuren geben die
// Kamera frei, ein haengendes `srcObject` haelt das letzte Bild. Das
// Element kommt aus der Schliessung, nicht aus der Referenz — bis der
// Effekt aufraeumt, kann dort laengst ein anderes stehen.
if (video) video.srcObject = null;
};
}, [springen]);
const ausEingabe = tagCodeAusFund(eingetippt);
return (
<div className="flex flex-col gap-5">
{fehler ? (
<p
role="status"
className="flex max-w-prose items-start gap-2 rounded-karte border border-linie bg-flaeche p-4 text-sm text-nebentext"
>
<CameraOff className="mt-0.5 size-4 shrink-0" strokeWidth={1.75} aria-hidden="true" />
{fehler}
</p>
) : (
<div className="relative overflow-hidden rounded-karte border border-linie bg-grund">
{/* `muted` und `playsInline`: ohne sie startet iOS das Bild gar nicht erst. */}
<video
ref={videoRef}
muted
playsInline
aria-label="Kamerabild zum Scannen"
className="aspect-[3/4] w-full object-cover sm:aspect-video"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 flex items-center justify-center"
>
<div className="size-48 rounded-karte border-2 border-akzent/70" />
</div>
{!laeuft ? (
<p className="absolute inset-x-0 bottom-0 bg-grund/80 p-3 text-center text-sm text-nebentext">
Kamera wird gestartet …
</p>
) : null}
</div>
)}
{laeuft && fremderFund ? (
<p role="status" className="max-w-prose text-sm text-faellig">
Das ist kein Laufzettel-Tag. Halte den Aufkleber mit dem Code <span className="mono">LZ-…</span> ins
Bild.
</p>
) : null}
<form
onSubmit={(ereignis) => {
ereignis.preventDefault();
if (ausEingabe) springen(ausEingabe);
}}
className="flex flex-col gap-2"
>
<label className="flex flex-col gap-1.5 text-sm">
<span className="flex items-center gap-2 text-nebentext">
<Keyboard className="size-4" strokeWidth={1.75} aria-hidden="true" />
Code eintippen
</span>
<input
value={eingetippt}
onChange={(ereignis) => setzeEingetippt(ereignis.target.value)}
placeholder="LZ-7K3F9Q"
autoCapitalize="characters"
autoCorrect="off"
spellCheck={false}
className={`${FELD} mono max-w-xs`}
/>
</label>
<button
type="submit"
disabled={!ausEingabe}
className="flex min-h-11 w-fit items-center gap-2 rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
>
<ScanLine className="size-[18px]" strokeWidth={2} aria-hidden="true" />
Öffnen
</button>
{eingetippt.trim() !== "" && !ausEingabe ? (
<p className="max-w-prose text-xs text-nebentext">
Ein Code sieht aus wie <span className="mono">LZ-7K3F9Q</span>. Die Zeichen 0, O, 1 und I gibt es
darin nicht — sie sind zu leicht zu verwechseln.
</p>
) : null}
</form>
</div>
);
}
+117
View File
@@ -0,0 +1,117 @@
import { Bildmarke } from "@/components/bildmarke";
/**
* Ein Etikett (#014).
*
* Zwei Formate, beide in Millimetern und beide **immer hell**: die Masse stehen
* in `mm`, weil ein Etikett auf Papier eine physische Groesse hat und ein Klebeband
* keine Pixel kennt. Die Farben kommen aus den Token von `.druck-hell` (siehe
* `src/app/globals.css`), nicht aus rohen Hexwerten — nur das QR-SVG selbst
* bringt Schwarz und Weiss fest mit, weil der Decoder den Kontrast braucht
* (`src/lib/qr.ts`).
*
* Keine Geschaeftslogik hier: was auf dem Etikett stehen darf, hat die Seite
* entschieden — insbesondere, ob das Kundenkuerzel erscheint (`INTERN`: nie).
*/
export type EtikettLayout = "etikett" | "bogen";
/**
* Die Adresse der Druckansicht.
*
* Steht hier, damit niemand die Parameternamen abtippt: die Geraeteseite braucht
* genau einen Aufruf (`etikettenPfad([geraet.tagCode])`), die Tag-Liste den
* Stapel als Bogen. Ohne Codes zeigt die Seite alle noch nicht zugeordneten
* Tags — das ist der Stapeldruck.
*/
export function etikettenPfad(tagCodes: readonly string[] = [], layout: EtikettLayout = "etikett"): string {
const parameter = new URLSearchParams({ layout });
if (tagCodes.length > 0) parameter.set("codes", tagCodes.join(","));
return `/drucken/etiketten?${parameter.toString()}`;
}
export type EtikettDaten = {
readonly tagCode: string;
/** Das fertige QR-SVG aus `qrSvg` – die Seite erzeugt es, nicht diese Komponente. */
readonly qrSvg: string;
/** Hostname, sonst Bezeichnung. Ein Tag ohne Zuordnung hat nichts davon. */
readonly anzeige?: string | null;
readonly besitzerName?: string | null;
/** Nur im Modus DIENSTLEISTUNG gesetzt (#008). */
readonly kundeKuerzel?: string | null;
};
/*
* Die Masse als feste Klassennamen und nicht als Zeichenkette zusammengesetzt:
* Tailwind liest den Quelltext, ein `w-[${wert}]` findet es nicht und die Klasse
* fehlt im Stylesheet. Das faellt erst am leeren Bogen auf.
*
* etikett 62 × 29 mm, ein Stueck je Seite (Etikettendrucker)
* bogen 70 × 37 mm, 3 × 8 auf A4 (3 · 70 = 210, 8 · 37 = 296 mm)
*
* Exportiert, weil `tests/qr.test.ts` daraus die Pixelmasse fuer die
* 300-dpi-Rasterung rechnet. Stuenden die Millimeter dort zweitens, koennte das
* Etikett schrumpfen, ohne dass der Test es merkt.
*/
export const ETIKETT_RAHMEN: Readonly<Record<EtikettLayout, string>> = {
etikett: "h-[29mm] w-[62mm] p-[2mm] gap-[2mm]",
bogen: "h-[37mm] w-[70mm] p-[3mm] gap-[2.5mm]",
};
/** Der QR nimmt die Hoehe minus Rand – quadratisch, sonst verzerrt er. */
export const ETIKETT_QR_FLAECHE: Readonly<Record<EtikettLayout, string>> = {
etikett: "size-[25mm]",
bogen: "size-[31mm]",
};
const CODE_GROESSE: Readonly<Record<EtikettLayout, string>> = {
etikett: "text-[3.4mm]",
bogen: "text-[3.8mm]",
};
const TEXT_GROESSE: Readonly<Record<EtikettLayout, string>> = {
etikett: "text-[2.6mm]",
bogen: "text-[2.9mm]",
};
export function Etikett({ daten, layout }: { daten: EtikettDaten; layout: EtikettLayout }) {
return (
<div
className={`flex items-center overflow-hidden bg-flaeche text-text ${ETIKETT_RAHMEN[layout]}`}
// `data-`-Haken fuer die Screenshots und die Pruefung des Druckbilds.
data-etikett={daten.tagCode}
>
{/*
Das SVG kommt aus `qrSvg` und damit aus dem eigenen Code – kein
Benutzertext. Eingesetzt statt als `img`, damit der Drucker Vektoren
bekommt und keine zweite Anfrage nötig ist; ein Bogen mit 24 Etiketten
wären sonst 24 Abrufe.
*/}
<div
className={`shrink-0 [&>svg]:block [&>svg]:size-full ${ETIKETT_QR_FLAECHE[layout]}`}
aria-label={`QR-Code ${daten.tagCode}`}
role="img"
dangerouslySetInnerHTML={{ __html: daten.qrSvg }}
/>
<div className="flex min-w-0 flex-1 flex-col justify-center">
<p className={`mono leading-tight font-bold ${CODE_GROESSE[layout]}`}>{daten.tagCode}</p>
{daten.anzeige ? (
<p className={`mt-[1mm] leading-tight break-words ${TEXT_GROESSE[layout]}`}>{daten.anzeige}</p>
) : null}
{daten.besitzerName ? (
<p className={`text-nebentext leading-tight break-words ${TEXT_GROESSE[layout]}`}>
{daten.besitzerName}
</p>
) : null}
<div className={`mt-auto flex items-center gap-[1.2mm] text-nebentext ${TEXT_GROESSE[layout]}`}>
<Bildmarke className="size-[3.2mm] shrink-0" />
<span>laufzettel</span>
{/* Im Modus Intern gibt es genau eine Organisation, und die erscheint
auf keinem Etikett (#008). */}
{daten.kundeKuerzel ? <span className="mono ml-auto">{daten.kundeKuerzel}</span> : null}
</div>
</div>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
"use client";
import { Printer } from "lucide-react";
import { Button } from "@/components/ui/button";
/**
* Der Knopf, der den Druckdialog oeffnet (#014).
*
* Eine eigene Client-Komponente, weil `window.print()` nur im Browser existiert.
* Sie traegt `nicht-drucken`, damit sie nicht selbst auf dem Bogen landet.
*/
export function EtikettenDrucken({ beschriftung = "Jetzt drucken" }: { beschriftung?: string }) {
return (
<Button
type="button"
onClick={() => window.print()}
className="nicht-drucken h-11 rounded-pille px-5 text-sm font-semibold md:h-10"
>
<Printer className="size-4" strokeWidth={2} aria-hidden="true" />
{beschriftung}
</Button>
);
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
import Link from "next/link";
import { useActionState } from "react";
import { Printer, Tags } from "lucide-react";
import { etikettenPfad } from "@/components/tags/etikett";
import { Button } from "@/components/ui/button";
import { FELD } from "@/lib/formular";
import { tagsErzeugenAktion, type TagsErgebnis } from "@/server/actions/tags";
/**
* Einen Stapel vorgedruckter Tags anlegen (#014).
*
* Die erzeugten Codes stehen anschliessend **hier** und nicht nur in der Liste:
* gleich nach dem Anlegen will man sie drucken, und zwar genau diesen Stapel und
* nicht alles, was noch offen ist. Deshalb fuehrt der Knopf daneben mit den
* Codes im Gepaeck auf die Druckansicht.
*/
export function TagStapel({
standard,
hoechstens,
kunden,
}: {
readonly standard: number;
readonly hoechstens: number;
readonly kunden: readonly { readonly id: string; readonly name: string }[];
}) {
const [ergebnis, aktion, laeuft] = useActionState<TagsErgebnis, FormData>(tagsErzeugenAktion, {});
return (
<form action={aktion} className="flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Anzahl</span>
<input
name="anzahl"
type="number"
inputMode="numeric"
min={1}
max={hoechstens}
defaultValue={standard}
className={`${FELD} mono`}
/>
</label>
{/* Die Kundenwahl erscheint nur im Modus Dienstleistung; im Modus Intern
verwirft der Server eine mitgeschickte Kennung ohnehin (#008). */}
{kunden.length > 0 ? (
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Kunde</span>
<select name="kundeId" defaultValue="" className={FELD} required>
<option value="">bitte wählen</option>
{kunden.map((kunde) => (
<option key={kunde.id} value={kunde.id}>
{kunde.name}
</option>
))}
</select>
</label>
) : null}
</div>
<p className="text-xs text-nebentext">
Höchstens {hoechstens} auf einmal. Jeder Tag verbraucht einen Tag-Code für immer — auch einer, dessen
Aufkleber nie geklebt wird.
</p>
{ergebnis.fehler ? (
<p role="alert" className="text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
{ergebnis.erfolg ? (
<p role="status" className="text-sm text-erledigt">
{ergebnis.erfolg}
</p>
) : null}
<div className="flex flex-wrap items-center gap-3">
<Button
type="submit"
disabled={laeuft}
className="h-11 rounded-pille px-5 text-sm font-semibold md:h-10"
>
<Tags className="size-4" strokeWidth={2} aria-hidden="true" />
{laeuft ? "Wird angelegt …" : "Stapel anlegen"}
</Button>
{ergebnis.tagCodes && ergebnis.tagCodes.length > 0 ? (
<Link
href={etikettenPfad(ergebnis.tagCodes, "bogen")}
className="flex min-h-11 items-center gap-2 rounded-pille border border-akzent px-4 text-sm text-akzent hover:bg-akzent-flaeche md:min-h-10"
>
<Printer className="size-4" strokeWidth={2} aria-hidden="true" />
Etiketten für diesen Stapel drucken
</Link>
) : null}
</div>
</form>
);
}