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
+212 -2
View File
@@ -1,8 +1,10 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { GeraeteTyp } from "@/generated/prisma/enums";
import { z } from "@/lib/validierung";
import type { Recht } from "@/server/auth/rollen";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { MandantFehler, kundenEbeneSichtbar, type HandelnderBenutzer } from "@/server/mandant";
import {
@@ -12,8 +14,10 @@ import {
geraetArchivieren,
geraetBearbeiten,
geraetLoeschen,
laufFuerGeraetAnlegen,
type ImportAbweisung,
} from "@/server/services/geraete";
import { LaufFehler, laufZurBearbeitungOeffnen } from "@/server/services/laeufe";
/**
* Server Actions der Geraete (#011).
@@ -44,7 +48,7 @@ const RECHTE_MELDUNG =
* src/server/actions/vorlagen.ts).
*/
async function angemeldetOderMeldung(
recht: "geraeteBearbeiten" | "verwaltung" = "geraeteBearbeiten",
recht: Recht = "geraeteBearbeiten",
): Promise<{ readonly benutzer: HandelnderBenutzer } | { readonly fehler: string }> {
try {
const { benutzer } = await rechtErforderlich(recht);
@@ -58,7 +62,10 @@ async function angemeldetOderMeldung(
/** Uebersetzt die Fehler der Dienste in eine Meldung, alles andere in eine neutrale. */
function alsErgebnis(fehler: unknown, neutral: string): AktionsErgebnis {
if (fehler instanceof GeraeteFehler || fehler instanceof MandantFehler) {
// `LaufFehler` steht mit dabei, seit die Geraeteseite eine Vorlage zuweist
// (#014): dass eine Version noch ein Entwurf ist oder eine Pflichtangabe fehlt,
// formuliert der Laufdienst — und zwar so, wie es der Techniker lesen soll.
if (fehler instanceof GeraeteFehler || fehler instanceof MandantFehler || fehler instanceof LaufFehler) {
return { fehler: fehler.message };
}
if (fehler instanceof RechteFehler) {
@@ -287,3 +294,206 @@ export async function geraeteImportierenAktion(
return alsErgebnis(fehler, "Die Datei konnte nicht eingelesen werden.");
}
}
// ---- Geraeteseite und Tag-Einstieg (#014) ----------------------------------
const RegistrierenSchema = z.object({
geraetId: z.string().min(1),
typ: TypSchema,
hostname: z.string().trim().optional(),
bezeichnung: z.string().trim().optional(),
seriennummer: z.string().trim().optional(),
inventarnummer: z.string().trim().optional(),
mitarbeiterId: z.string().trim().optional(),
});
/**
* Macht aus einem vorgedruckten Tag ein Gerät (#014).
*
* Keine eigene Regel: das Registrieren ist eine Änderung mit
* `registrieren: true`, und ob dabei ein Name vorliegt, entscheidet der Dienst an
* der einen Stelle, an der diese Regel steht. Der Erfolg endet auf der
* Geräteseite — wer am Gerät steht und es gerade benannt hat, will als nächstes
* loslegen, nicht ein Formular mit einer Erfolgsmeldung sehen.
*/
export async function geraetRegistrierenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const eingabe = RegistrierenSchema.safeParse({
geraetId: feld(formular, "geraetId"),
typ: feld(formular, "typ"),
hostname: feld(formular, "hostname"),
bezeichnung: feld(formular, "bezeichnung"),
seriennummer: feld(formular, "seriennummer"),
inventarnummer: feld(formular, "inventarnummer"),
mitarbeiterId: feld(formular, "mitarbeiterId"),
});
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
try {
await geraetBearbeiten(
eingabe.data.geraetId,
{
registrieren: true,
typ: eingabe.data.typ,
hostname: eingabe.data.hostname ?? null,
bezeichnung: eingabe.data.bezeichnung ?? null,
seriennummer: eingabe.data.seriennummer ?? null,
inventarnummer: eingabe.data.inventarnummer ?? null,
mitarbeiterId: eingabe.data.mitarbeiterId ?? null,
},
zugang.benutzer,
);
} catch (fehler) {
return alsErgebnis(fehler, "Das Gerät konnte nicht registriert werden.");
}
revalidatePath("/geraete");
revalidatePath(`/geraete/${eingabe.data.geraetId}`);
// Nach dem `try`: `redirect` arbeitet mit einem geworfenen Fehler, den ein
// `catch` darüber verschluckt hätte — das Gerät wäre registriert, und die
// Oberfläche zeigte trotzdem eine Störung.
redirect(`/geraete/${eingabe.data.geraetId}`);
}
const ZuweisenSchema = z.object({
geraetId: z.string().min(1),
vorlagenVersionId: z.string().min(1, "Bitte eine Vorlage wählen."),
zugewiesenAnId: z.string().trim().optional(),
faelligAm: z.string().trim().optional(),
});
/**
* Ein Datumsfeld des Browsers liefert `2026-09-30`; ein leeres Feld und ein
* unbrauchbarer Wert heissen beide „keine Faelligkeit“.
*/
function datumLesen(roh: string | undefined): Date | null {
if (!roh) return null;
const datum = new Date(roh);
return Number.isNaN(datum.getTime()) ? null : datum;
}
/**
* „Vorlage zuweisen“ auf der Geräteseite (#014).
*
* Das Recht ist `laeufeBearbeiten` und nicht `geraeteBearbeiten`: hier entsteht
* ein Lauf, kein Stammdatensatz. Der Lauf beginnt als `OFFEN` und erscheint
* damit unter „Zugewiesen“ — gestartet wird er erst, wenn jemand ihn öffnet.
*/
export async function vorlageZuweisenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung("laeufeBearbeiten");
if ("fehler" in zugang) return zugang;
const eingabe = ZuweisenSchema.safeParse({
geraetId: feld(formular, "geraetId"),
vorlagenVersionId: feld(formular, "vorlagenVersionId"),
zugewiesenAnId: feld(formular, "zugewiesenAnId"),
faelligAm: feld(formular, "faelligAm"),
});
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
try {
const lauf = await laufFuerGeraetAnlegen(
{
geraetId: eingabe.data.geraetId,
vorlagenVersionId: eingabe.data.vorlagenVersionId,
zugewiesenAnId: eingabe.data.zugewiesenAnId || null,
faelligAm: datumLesen(eingabe.data.faelligAm),
},
zugang.benutzer,
);
revalidatePath(`/geraete/${eingabe.data.geraetId}`);
revalidatePath("/laeufe");
return { erfolg: `„${lauf.titel}“ steht jetzt unter „Zugewiesen“.` };
} catch (fehler) {
return alsErgebnis(fehler, "Der Lauf konnte nicht angelegt werden.");
}
}
/**
* „Starten“ an einer Zuweisung auf der Geräteseite (#014).
*
* Dasselbe Öffnen wie in der Abarbeitungsansicht — aber **mit Sprung in den
* Lauf**, und zwar vom Server aus. Zuerst stand hier `laufOeffnenAktion` aus
* `actions/laeufe.ts`, und die Komponente sprang danach selbst über einen
* Effekt. Das ging nicht auf: `revalidatePath` baut die Geräteseite neu, dabei
* verschwindet die Zuweisung samt Knopf aus dem Baum, und der Effekt kam nie
* dazu. Der Lauf stand auf `IN_ARBEIT`, der Techniker blieb am Gerät — und
* nichts sagte ihm, dass er schon angefangen hat.
*/
export async function laufStartenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung("laeufeBearbeiten");
if ("fehler" in zugang) return zugang;
const eingabe = z
.object({ laufId: z.string().min(1), geraetId: z.string().min(1) })
.safeParse({ laufId: feld(formular, "laufId"), geraetId: feld(formular, "geraetId") });
if (!eingabe.success) return { fehler: "Es fehlt der Lauf." };
try {
await laufZurBearbeitungOeffnen(eingabe.data.laufId, zugang.benutzer);
} catch (fehler) {
return alsErgebnis(fehler, "Der Lauf konnte nicht geöffnet werden.");
}
revalidatePath(`/geraete/${eingabe.data.geraetId}`);
revalidatePath("/laeufe");
// Außerhalb des `try`, weil `redirect` über einen geworfenen Fehler arbeitet.
redirect(`/laeufe/${eingabe.data.laufId}`);
}
/**
* Der Schnellstart auf der Geräteseite (#014).
*
* Ein Tipp auf eine Vorlage des Gerätetyps legt den Lauf an **und** startet ihn:
* wer am Gerät steht und hier tippt, fängt jetzt an. Deshalb zwei Dienste
* hintereinander — `laufFuerGeraetAnlegen` erzeugt den `OFFEN`en Lauf mit Gerät
* und Besitzer, `laufZurBearbeitungOeffnen` macht daraus `IN_ARBEIT` mit
* `gestartetAm`. Beide Übergänge stehen weiter je an ihrer einen Stelle.
*
* Vorlagen mit Pflichtangaben kommen hier nicht an: die Geräteseite schickt sie
* in den Start-Dialog, weil dieser Weg nichts erfragen kann. Käme eine trotzdem,
* weist `laufAnlegen` sie mit der Liste der fehlenden Angaben ab — sichtbar, und
* ohne einen halben Lauf zu hinterlassen.
*/
export async function laufSchnellstartAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung("laeufeBearbeiten");
if ("fehler" in zugang) return zugang;
const eingabe = z.object({ geraetId: z.string().min(1), vorlagenVersionId: z.string().min(1) }).safeParse({
geraetId: feld(formular, "geraetId"),
vorlagenVersionId: feld(formular, "vorlagenVersionId"),
});
if (!eingabe.success) return { fehler: "Es fehlt die Vorlage." };
let laufId: string;
try {
const lauf = await laufFuerGeraetAnlegen(eingabe.data, zugang.benutzer);
await laufZurBearbeitungOeffnen(lauf.id, zugang.benutzer);
laufId = lauf.id;
} catch (fehler) {
return alsErgebnis(fehler, "Der Lauf konnte nicht gestartet werden.");
}
revalidatePath(`/geraete/${eingabe.data.geraetId}`);
revalidatePath("/laeufe");
// Außerhalb des `try`, weil `redirect` über einen geworfenen Fehler arbeitet.
redirect(`/laeufe/${laufId}`);
}
+90
View File
@@ -0,0 +1,90 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "@/lib/validierung";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { MandantFehler, kundenEbeneSichtbar } from "@/server/mandant";
import { GeraeteFehler } from "@/server/services/geraete";
import { TAGS_STAPEL_HOECHSTENS, TagsFehler, tagsErzeugen } from "@/server/services/tags";
/**
* Server Action der Tag-Stapel (#014).
*
* Duenn: ein Recht pruefen, Zod anwenden, den Dienst rufen. Das Recht ist
* `verwaltung` — ein Stapel verbrennt N Tag-Codes unwiderruflich (siehe
* `services/tags.ts`).
*/
export type TagsErgebnis = {
readonly fehler?: string;
readonly erfolg?: string;
/** Die Codes des gerade erzeugten Stapels – fuer den Knopf „Etiketten drucken“. */
readonly tagCodes?: readonly string[];
};
const RECHTE_MELDUNG =
"Deine Anmeldung gilt nicht mehr. Melde dich in einem zweiten Tab neu an und klicke dann hier " +
"noch einmal — was du eingegeben hast, steht weiterhin auf dieser Seite.";
const StapelSchema = z.object({
/*
* `z.coerce`, weil ein Formularfeld immer Text schickt. Die Obergrenze steht
* hier **und** im Dienst: hier fuer die Meldung vor dem ersten Schreibvorgang,
* dort, weil sich kein Dienst darauf verlassen darf, dass vor ihm jemand
* geprueft hat.
*/
anzahl: z.coerce
.number()
.int("Bitte eine ganze Zahl angeben.")
.min(1, "Mindestens ein Tag.")
.max(TAGS_STAPEL_HOECHSTENS, `Höchstens ${TAGS_STAPEL_HOECHSTENS} Tags auf einmal.`),
kundeId: z.string().trim().optional(),
});
export async function tagsErzeugenAktion(_vorher: TagsErgebnis, formular: FormData): Promise<TagsErgebnis> {
let benutzerId: string;
let rolle: Parameters<typeof tagsErzeugen>[2]["rolle"];
try {
const { benutzer } = await rechtErforderlich("verwaltung");
benutzerId = benutzer.id;
rolle = benutzer.rolle;
} catch (fehler) {
// Ein geworfener Rechtefehler wuerde die Seite aushaengen; bei einem Formular
// mit halb eingetippter Anzahl ist das die Arbeit, die weg ist (Befund aus
// dem Review von #010).
if (fehler instanceof RechteFehler) return { fehler: RECHTE_MELDUNG };
throw fehler;
}
const roh = formular.get("anzahl");
const eingabe = StapelSchema.safeParse({
anzahl: typeof roh === "string" && roh.trim() !== "" ? roh.trim() : undefined,
kundeId: typeof formular.get("kundeId") === "string" ? String(formular.get("kundeId")) : undefined,
});
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
// Eine Kundenwahl gibt es nur im Modus Dienstleistung; `kundeErmitteln`
// verwirft eine mitgeschickte Kennung im Modus Intern ohnehin (#008).
const kundeId = (await kundenEbeneSichtbar()) ? eingabe.data.kundeId || null : null;
try {
const stapel = await tagsErzeugen(eingabe.data.anzahl, kundeId, { id: benutzerId, rolle });
revalidatePath("/tags");
return {
erfolg: `${stapel.tagCodes.length} ${stapel.tagCodes.length === 1 ? "Tag" : "Tags"} angelegt.`,
tagCodes: stapel.tagCodes,
};
} catch (fehler) {
if (fehler instanceof TagsFehler || fehler instanceof GeraeteFehler || fehler instanceof MandantFehler) {
return { fehler: fehler.message };
}
if (fehler instanceof RechteFehler) return { fehler: RECHTE_MELDUNG };
console.error(
"Der Tag-Stapel konnte nicht angelegt werden:",
fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : fehler,
);
return { fehler: "Der Tag-Stapel konnte nicht angelegt werden." };
}
}