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
+427
View File
@@ -0,0 +1,427 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft, ArrowRight, CircleDot, Clock, History, ListChecks, Printer, QrCode } from "lucide-react";
import { LaufStatus } from "@/generated/prisma/enums";
import { LaufStartenKnopf } from "@/components/geraete/lauf-starten";
import { etikettenPfad } from "@/components/tags/etikett";
import { Schnellstart } from "@/components/geraete/schnellstart";
import { VorlageZuweisen } from "@/components/geraete/vorlage-zuweisen";
import { GERAETE_TYP_TEXT } from "@/lib/geraete-text";
import { qrSvg } from "@/lib/qr";
import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import {
HISTORIE_HOECHSTENS,
geraetHolen,
laeufeDesGeraets,
vorlagenFuerGeraet,
type GeraetLaufZeile,
type GeraeteZeile,
} from "@/server/queries/geraete";
import { startAuswahl } from "@/server/queries/laeufe";
/**
* Die Geraeteseite (#014).
*
* Das Ziel jedes gescannten Aufklebers und damit die Seite, die ein Techniker am
* Handy vor dem Geraet aufschlaegt. Sie beantwortet in dieser Reihenfolge: was
* ist das hier, was wartet daran, was kann ich sofort anfangen, was war schon.
*
* Deshalb die Anordnung: erst der Kopf mit `tagCode` und Besitzer, dann die
* Zuweisungen, dann der Schnellstart. Das Vorbereiten fuer jemand anderen
* („Vorlage zuweisen“) steht weiter unten — es passiert am Schreibtisch, nicht
* im Serverraum.
*
* Nummerierte Abschnitte gibt es hier bewusst nicht: die zweistellige Nummer
* gehoert zur Abarbeitung, wo sie eine Reihenfolge ausdrueckt. Hier waere sie
* Schmuck.
*/
const DATUM = new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" });
const STATUS_TEXT: Record<LaufStatus, string> = {
OFFEN: "offen",
IN_ARBEIT: "in Arbeit",
ABGESCHLOSSEN: "abgeschlossen",
ARCHIVIERT: "archiviert",
};
const PILLE = "rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext";
const KNOPF =
"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";
function Fortschritt({ zeile }: { zeile: GeraetLaufZeile }) {
const anteil = zeile.anzahlSchritte === 0 ? 0 : zeile.anzahlErledigt / zeile.anzahlSchritte;
return (
<div className="mt-2 flex items-center gap-2">
<div
className="h-1.5 w-28 overflow-hidden rounded-pille bg-flaeche-hoch"
role="progressbar"
aria-valuemin={0}
aria-valuemax={zeile.anzahlSchritte}
aria-valuenow={zeile.anzahlErledigt}
aria-label="Fortschritt"
>
<div className="h-full bg-akzent" style={{ width: `${Math.round(anteil * 100)}%` }} />
</div>
<span className="text-xs text-nebentext">
{zeile.anzahlErledigt} von {zeile.anzahlSchritte}
</span>
</div>
);
}
/** Eine Zuweisung: was ist es, wie weit ist es, und der Weg hinein. */
function Zuweisung({
zeile,
geraetId,
darfArbeiten,
}: {
zeile: GeraetLaufZeile;
geraetId: string;
darfArbeiten: boolean;
}) {
const ueberfaellig = zeile.faelligAm !== null && zeile.faelligAm < new Date();
return (
<li className="rounded-karte border border-linie bg-flaeche">
<div className="flex flex-col gap-3 p-4 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<CircleDot
className={`size-[18px] shrink-0 ${
zeile.status === LaufStatus.IN_ARBEIT ? "text-akzent" : "text-nebentext"
}`}
strokeWidth={1.75}
aria-hidden="true"
/>
<Link
href={`/laeufe/${zeile.id}`}
className="min-w-0 font-medium break-words underline-offset-2 hover:underline"
>
{zeile.titel}
</Link>
<span className={PILLE}>{STATUS_TEXT[zeile.status]}</span>
{ueberfaellig ? (
<span className="flex items-center gap-1 rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-faellig">
<Clock className="size-3" strokeWidth={2} aria-hidden="true" />
seit {DATUM.format(zeile.faelligAm as Date)} fällig
</span>
) : null}
</div>
<p className="mt-1 text-xs break-words text-nebentext">
{[
`${zeile.vorlagenName} v${zeile.version}`,
zeile.zugewiesenAn ? `für ${zeile.zugewiesenAn}` : "niemandem zugewiesen",
zeile.faelligAm && !ueberfaellig ? `fällig am ${DATUM.format(zeile.faelligAm)}` : null,
]
.filter((stueck) => stueck !== null)
.join(" · ")}
</p>
<Fortschritt zeile={zeile} />
</div>
{/*
* „Starten“ setzt `IN_ARBEIT`, „Weiter“ ist nur der Weg zurück in einen
* Lauf, der schon läuft. Wer Läufe nicht bearbeiten darf, bekommt nur den
* Weg zum Ansehen.
*/}
{darfArbeiten && zeile.status === LaufStatus.OFFEN ? (
<LaufStartenKnopf laufId={zeile.id} geraetId={geraetId} />
) : (
<Link href={`/laeufe/${zeile.id}`} className={KNOPF}>
<ArrowRight className="size-4" strokeWidth={1.75} aria-hidden="true" />
{zeile.status === LaufStatus.IN_ARBEIT ? "Weiter" : "Ansehen"}
</Link>
)}
</div>
</li>
);
}
function Kopf({
geraet,
kundenEbene,
qr,
}: {
geraet: GeraeteZeile;
kundenEbene: boolean;
/** Das fertige QR-SVG – erzeugt von der Seite, nicht von dieser Komponente. */
qr: string;
}) {
const angaben: readonly { readonly name: string; readonly wert: string; readonly mono?: boolean }[] = [
{ name: "Typ", wert: GERAETE_TYP_TEXT[geraet.typ] },
...(geraet.modell ? [{ name: "Modell", wert: geraet.modell }] : []),
...(geraet.seriennummer ? [{ name: "Seriennummer", wert: geraet.seriennummer, mono: true }] : []),
...(geraet.inventarnummer ? [{ name: "Inventarnummer", wert: geraet.inventarnummer, mono: true }] : []),
];
return (
<section className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
<dl className="grid gap-4 sm:grid-cols-2">
<div className="min-w-0">
<dt className="text-xs text-nebentext">Besitzer</dt>
<dd className="mt-1 text-sm break-words">
{geraet.besitzerId ? (
<>
<Link
href={`/mitarbeiter/${geraet.besitzerId}`}
className="text-akzent underline underline-offset-2"
>
{geraet.besitzerName}
</Link>
{geraet.besitzerAbteilung ? (
<span className="text-nebentext"> · {geraet.besitzerAbteilung}</span>
) : null}
{geraet.besitzerAktiv === false ? <span className="text-faellig"> · deaktiviert</span> : null}
</>
) : (
<span className="text-nebentext">niemand — Server, Raum oder Poolgerät</span>
)}
</dd>
</div>
{angaben.map((angabe) => (
<div key={angabe.name} className="min-w-0">
<dt className="text-xs text-nebentext">{angabe.name}</dt>
<dd className={`mt-1 text-sm break-words${angabe.mono ? " mono" : ""}`}>{angabe.wert}</dd>
</div>
))}
{kundenEbene ? (
<div className="min-w-0">
<dt className="text-xs text-nebentext">Kunde</dt>
<dd className="mt-1 text-sm break-words">{geraet.kundeName}</dd>
</div>
) : null}
</dl>
{geraet.notizen ? (
<p className="mt-4 border-t border-linie pt-4 text-sm break-words text-nebentext">{geraet.notizen}</p>
) : null}
<details className="mt-4 border-t border-linie pt-4">
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 text-sm text-nebentext hover:text-text md:min-h-10">
<QrCode className="size-4" strokeWidth={1.75} aria-hidden="true" />
QR-Code anzeigen
</summary>
{/*
* Fuer den Fall, dass der Aufkleber abgerissen ist oder das Geraet vor
* einem steht und das Handy in der Hand: vom Bildschirm gescannt fuehrt
* der Code genau dorthin, wohin auch der geklebte fuehrt.
*
* Das SVG steht eingesetzt hier und kommt nicht ueber einen eigenen
* Endpunkt — dieselbe Quelle wie das gedruckte Etikett, eine Anfrage
* weniger, und kein Weg, der ohne Aufrufer im Baum steht.
*/}
<div
role="img"
aria-label={`QR-Code zu ${geraet.tagCode}`}
className="mt-3 w-40 rounded-karte bg-white p-2"
dangerouslySetInnerHTML={{ __html: qr }}
/>
</details>
</section>
);
}
export default async function GeraeteSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { benutzer } = await sitzungErforderlich("lesen", `/geraete/${id}`);
const geraet = await geraetHolen(id, benutzer);
// Nicht vorhanden und nicht sichtbar sind dieselbe Antwort – die Unterscheidung
// waere schon eine Auskunft ueber fremde Kunden (#009).
if (!geraet) notFound();
const darfArbeiten = darf(benutzer.rolle, "laeufeBearbeiten");
const [laeufe, kundenEbene, qr] = await Promise.all([
laeufeDesGeraets(geraet.id, benutzer),
kundenEbeneSichtbar(),
// Dasselbe SVG wie auf dem Etikett – eine Quelle, ein Ergebnis.
qrSvg(geraet.tagCode),
]);
/*
* Neue Arbeit nimmt nur ein Geraet auf, das es auch wirklich gibt: ein
* archiviertes ist aus dem Bestand genommen (`laufAnlegen` weist es ab), und
* ein vorgedruckter Tag ohne Zuordnung beschreibt noch kein Objekt. Statt
* Formulare anzubieten, die der Dienst danach ablehnt, steht dort dann der
* naechste Schritt.
*/
const nimmtArbeitAuf = darfArbeiten && !geraet.archiviert && geraet.registriertAm !== null;
const vorlagen = nimmtArbeitAuf
? await vorlagenFuerGeraet(geraet, benutzer)
: { schnellstart: [], alle: [] };
/*
* Die Techniker kommen aus `startAuswahl` und nicht aus einer eigenen Abfrage.
* Zuweisen laesst sich nur an jemanden, der den Lauf auch oeffnen koennte —
* eine zweite, aehnlich gebaute Liste waere genau der Fehler, den der Review
* zu #013 gefunden hat: ein Lauf, zugewiesen an jemanden, der ihn nie sieht.
*/
const bearbeiter = nimmtArbeitAuf ? (await startAuswahl(benutzer)).bearbeiter : [];
const besitzerFuerStart = geraet.besitzerAktiv === true ? geraet.besitzerId : null;
return (
<>
<Link
href="/geraete"
className="inline-flex min-h-11 items-center gap-2 text-sm text-nebentext hover:text-text"
>
<ArrowLeft className="size-4" strokeWidth={1.75} aria-hidden="true" />
Alle Geräte
</Link>
<div className="mt-2 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<h1
className={`text-2xl font-bold break-words ${geraet.archiviert ? "text-nebentext line-through" : ""}`}
>
{geraet.anzeige}
</h1>
<div className="mt-2 flex flex-wrap items-center gap-2">
<span className="mono flex items-center gap-1.5 rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
<QrCode className="size-3.5" strokeWidth={1.75} aria-hidden="true" />
{geraet.tagCode}
</span>
<span className={PILLE}>{GERAETE_TYP_TEXT[geraet.typ]}</span>
{geraet.registriertAm === null ? <span className={PILLE}>Tag ohne Zuordnung</span> : null}
{geraet.archiviert ? <span className={PILLE}>archiviert</span> : null}
</div>
</div>
{/*
* Die Adresse baut der Etikettendruck selbst (#014) — sie haengt am
* Tag-Code, nicht an der Kennung des Geraets: gedruckt wird der
* Aufkleber, und der kennt nur seinen Code.
*/}
<Link href={etikettenPfad([geraet.tagCode])} className={KNOPF}>
<Printer className="size-4" strokeWidth={1.75} aria-hidden="true" />
Etikett drucken
</Link>
</div>
<Kopf geraet={geraet} kundenEbene={kundenEbene} qr={qr} />
{geraet.registriertAm === null ? (
<section className="mt-4 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Noch keinem Gerät zugeordnet</h2>
<p className="mt-2 max-w-prose text-sm text-nebentext">
Dieser Aufkleber ist vorgedruckt und wartet auf ein Gerät. Wer davor steht, trägt Typ und Namen
ein — danach läuft hier alles Weitere.
</p>
<Link href={`/t/${geraet.tagCode}`} className={`${KNOPF} mt-4`}>
<QrCode className="size-4" strokeWidth={1.75} aria-hidden="true" />
Gerät registrieren
</Link>
</section>
) : null}
<section className="mt-8">
<h2 className="flex items-center gap-2 text-lg font-semibold">
<ListChecks className="size-5 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
Zugewiesen
</h2>
{laeufe.zugewiesen.length === 0 ? (
<p className="mt-3 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
An diesem Gerät wartet keine Arbeit.
</p>
) : (
<ul className="mt-3 flex flex-col gap-3">
{laeufe.zugewiesen.map((zeile) => (
<Zuweisung key={zeile.id} zeile={zeile} geraetId={geraet.id} darfArbeiten={darfArbeiten} />
))}
</ul>
)}
</section>
{nimmtArbeitAuf && vorlagen.schnellstart.length > 0 ? (
<section className="mt-8">
<h2 className="text-lg font-semibold">Schnellstart</h2>
<p className="mt-1 mb-3 max-w-prose text-sm text-nebentext">
Für {GERAETE_TYP_TEXT[geraet.typ]} hinterlegte Vorlagen. Ein Tipp legt den Lauf mit diesem Gerät
{geraet.besitzerName ? ` und ${geraet.besitzerName}` : ""} an und beginnt ihn.
</p>
<Schnellstart geraetId={geraet.id} vorlagen={vorlagen.schnellstart} personId={besitzerFuerStart} />
</section>
) : null}
{nimmtArbeitAuf ? (
<section className="mt-8 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Vorlage zuweisen</h2>
<p className="mt-1 mb-4 max-w-prose text-sm text-nebentext">
Für später oder für jemand anderen: der Lauf entsteht als <strong>offen</strong> und erscheint
oben unter „Zugewiesen“.
</p>
<VorlageZuweisen
geraetId={geraet.id}
vorlagen={vorlagen.alle}
bearbeiter={bearbeiter}
besitzerName={geraet.besitzerName}
/>
</section>
) : null}
<section className="mt-8">
<h2 className="flex items-center gap-2 text-lg font-semibold">
<History className="size-5 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
Historie
</h2>
{laeufe.historie.length === 0 ? (
<p className="mt-3 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
An diesem Gerät ist noch kein Lauf abgeschlossen.
</p>
) : (
<ul className="mt-3 rounded-karte border border-linie bg-flaeche px-5">
{laeufe.historie.map((zeile) => (
<li key={zeile.id} className="border-t border-linie py-4 first:border-t-0">
<div className="flex flex-wrap items-center gap-2">
<Link
href={`/laeufe/${zeile.id}`}
className="min-w-0 text-sm break-words underline-offset-2 hover:underline"
>
{zeile.titel}
</Link>
<span className={PILLE}>{STATUS_TEXT[zeile.status]}</span>
</div>
<p className="mt-1 text-xs break-words text-nebentext">
{[
`${zeile.vorlagenName} v${zeile.version}`,
zeile.abgeschlossenAm
? `abgeschlossen am ${DATUM.format(zeile.abgeschlossenAm)}`
: `angelegt am ${DATUM.format(zeile.erstelltAm)}`,
`${zeile.anzahlErledigt} von ${zeile.anzahlSchritte} Schritten`,
].join(" · ")}
</p>
</li>
))}
</ul>
)}
{/* Eine gekappte Liste sagt das – sonst fehlte ein Lauf stillschweigend. */}
{laeufe.historieGekuerzt ? (
<p className="mt-3 max-w-prose text-sm text-nebentext">
Es werden die letzten {HISTORIE_HOECHSTENS} Läufe gezeigt.{" "}
<Link
href={`/laeufe?suche=${encodeURIComponent(geraet.tagCode)}`}
className="text-akzent underline underline-offset-2"
>
In den Läufen nach diesem Tag-Code suchen
</Link>
{/*
* Ehrlich benannt: die Laufliste zeigt keine archivierten Läufe,
* und gerade die alten sind archiviert. Die vollständige Suche
* darüber kommt mit dem Archiv (#015) — bis dahin ist dieser Weg
* die halbe Antwort, und das soll er auch sagen (Reviewbefund zu
* #014).
*/}{" "}
— archivierte Läufe stehen dort noch nicht, die findet erst das Archiv
</p>
) : null}
</section>
</>
);
}
+35 -3
View File
@@ -84,9 +84,26 @@ function Karte({
<QrCode className="size-3.5" strokeWidth={1.75} aria-hidden="true" />
{zeile.tagCode}
</span>
<p className={zeile.archiviert ? "text-nebentext line-through" : "font-medium"}>
{zeile.anzeige}
</p>
{/*
* Seit #014 fuehrt der Name auf die Geraeteseite — dorthin, wo auch
* der gescannte Aufkleber landet. Ein vorgedruckter Tag ohne
* Zuordnung bleibt Text: dort gaebe es nichts zu sehen, sein Weg
* ist `/t/<code>` und das Registrierformular.
*/}
{zeile.registriertAm === null ? (
<p className="text-nebentext">{zeile.anzeige}</p>
) : (
<Link
href={`/geraete/${zeile.id}`}
className={
zeile.archiviert
? "text-nebentext line-through hover:text-text"
: "font-medium underline-offset-4 hover:underline"
}
>
{zeile.anzeige}
</Link>
)}
</div>
<div className="mt-2">
<Kennzeichen zeile={zeile} kundenEbene={kundenEbene} />
@@ -275,6 +292,21 @@ export default async function GeraeteSeite({
/>
</div>
</details>
{darfLoeschen ? (
/*
* Vorgedruckte Aufkleber sind Beschaffung, nicht Geraetepflege —
* deshalb `verwaltung`, dasselbe Recht wie beim Loeschen. Der Weg
* dorthin steht hier, weil niemand eine Seite findet, die nur in der
* Adresszeile existiert (#014).
*/
<p className="text-sm text-nebentext">
Aufkleber auf Vorrat drucken?{" "}
<Link href="/tags" className="text-akzent underline underline-offset-2">
Tag-Stapel anlegen
</Link>
</p>
) : null}
</div>
) : null}
</>
+17 -1
View File
@@ -38,6 +38,18 @@ export default async function LaufStartenSeite({
startAuswahl(benutzer),
kundenEbeneSichtbar(),
]);
/*
* Kommt ein Gerät mit, aber keine Person, dann ist der Besitzer gemeint.
* Dieselbe Regel gilt im Dialog schon beim Wechsel des Geräts — sie darf nicht
* daran hängen, dass jemand das Feld **anfasst**: von der Geräteseite und vom
* gescannten Aufkleber kommt die Wahl fertig an, und der Techniker sähe sonst
* „keine Person aus dem Verzeichnis“ (Reviewbefund zu #014).
*/
const geraetAusAdresse = einzeln("geraet");
const besitzerDesGeraets = geraetAusAdresse
? (auswahl.geraete.find((geraet) => geraet.id === geraetAusAdresse)?.besitzerId ?? undefined)
: undefined;
const kunden = kundenEbene ? await kundenListe(benutzer) : [];
return (
@@ -63,7 +75,11 @@ export default async function LaufStartenSeite({
vorbelegung={{
...(einzeln("vorlage") ? { versionId: einzeln("vorlage") as string } : {}),
...(einzeln("geraet") ? { geraetId: einzeln("geraet") as string } : {}),
...(einzeln("person") ? { mitarbeiterId: einzeln("person") as string } : {}),
...(einzeln("person")
? { mitarbeiterId: einzeln("person") as string }
: besitzerDesGeraets
? { mitarbeiterId: besitzerDesGeraets }
: {}),
}}
kundenEbene={kundenEbene}
kunden={kunden.map(({ id, name }) => ({ id, name }))}
+30
View File
@@ -0,0 +1,30 @@
import { KameraScanner } from "@/components/scanner/kamera-scanner";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
/**
* Scannen (#014).
*
* Die Seite selbst ist duenn: anmelden, Ueberschrift, Kamera. Was der Scanner
* tut, ist Sache des Browsers; was ein Fund **bedeutet**, entscheidet
* `tagCodeAusFund` in `src/lib/tagcode.ts`, und was hinter dem Code steht,
* entscheidet `/t/[tagCode]`.
*/
export const metadata = { title: "Scannen" };
export default async function ScannenSeite() {
await sitzungErforderlich("lesen", "/scannen");
return (
<>
<h1 className="text-2xl font-bold">Scannen</h1>
<p className="mt-2 max-w-prose text-nebentext">
Halte den Aufkleber ins Bild. Laufzettel springt von selbst auf die Geräteseite, sobald der Code
erkannt ist.
</p>
<div className="mt-6 max-w-xl">
<KameraScanner />
</div>
</>
);
}
+115
View File
@@ -0,0 +1,115 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { QrCode, Search } from "lucide-react";
import { GeraetRegistrieren } from "@/components/geraete/geraet-registrieren";
import { TAGCODE_PRAEFIX, istTagCode } from "@/lib/tagcode";
import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { besitzerKandidaten, geraetNachTagCode } from "@/server/queries/geraete";
/**
* Der Einstieg ueber den Aufkleber: `/t/<tagCode>` (#014).
*
* Auf dem Etikett steht ein QR mit `{APP_URL}/t/{tagCode}`, darunter der Code zum
* Abtippen. Diese Seite ist der Weichensteller und kennt drei Faelle:
*
* 1. Der Code gehoert zu einem registrierten Geraet → weiter zur Geraeteseite.
* 2. Der Code gehoert zu einem vorgedruckten Tag ohne Zuordnung → das
* Registrierformular. Danach dieselbe Geraeteseite.
* 3. Der Code ist unbekannt oder formal falsch → „Kein Laufzettel-Tag“, mit dem
* Weg zur Suche. Beides ist **dieselbe** Antwort, und zwar mit Absicht: eine
* Unterscheidung wuerde verraten, welche Codes es gibt, und einem fremden
* Kunden gegenueber ist schon das eine Auskunft.
*
* **Die Datei liegt in der Gruppe `(app)`, die Adresse bleibt `/t/<code>`.**
* Eine Routengruppe aendert den Pfad nicht — der QR-Inhalt bleibt also so kurz
* wie gedruckt. Dafuer tragen die beiden Faelle, die hier wirklich etwas
* anzeigen, die gewohnte Huelle mit Navigation: wer einen fremden Aufkleber
* scannt, steht sonst auf einer Seite ohne jeden Weg weiter. Der Ruecksprung nach
* der Anmeldung haengt nicht daran: das Cookie-Tor im Proxy leitet mit
* `?ziel=/t/<code>` um, und diese Seite prueft ihr Recht ohnehin selbst — die
* Huelle ist auch hier das zweite Netz, nicht das erste.
*/
export default async function TagSeite({ params }: { params: Promise<{ tagCode: string }> }) {
const { tagCode } = await params;
/*
* Gross- und Kleinschreibung sind unerheblich. Ein Handy schreibt von sich aus
* klein, und wer `lz-7k3f9q` eintippt, meint den Aufkleber mit `LZ-7K3F9Q`.
* Weiter geht die Nachsicht nicht: ein `I` auf ein `L` zu biegen koennte auf ein
* anderes, vorhandenes Geraet fuehren — und wer davor steht, saehe den Irrtum
* nie. Gross zu Klein kann das nicht, weil beide dasselbe Zeichen bezeichnen.
*/
const code = tagCode.trim().toUpperCase();
const gueltig = istTagCode(code);
// Erst die Anmeldung, dann die Datenbank: das Ziel ist die kanonische Adresse
// dieses Aufklebers, damit der Scan nach dem Anmelden dort ankommt, wo er
// hinwollte.
const { benutzer } = await sitzungErforderlich(
"lesen",
`/t/${encodeURIComponent(gueltig ? code : tagCode)}`,
);
const geraet = gueltig ? await geraetNachTagCode(code, benutzer) : undefined;
if (geraet && geraet.registriertAm !== null) {
redirect(`/geraete/${geraet.id}`);
}
if (!geraet) {
return (
<div className="mx-auto max-w-lg">
<h1 className="flex items-center gap-2 text-2xl font-bold">
<QrCode className="size-6 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
Kein Laufzettel-Tag
</h1>
<p className="mt-3 text-nebentext">
Zu <span className="mono break-all text-text">{code}</span> gehört kein Gerät. Ein Laufzettel-Code
beginnt mit <span className="mono">{TAGCODE_PRAEFIX}</span> und hat sechs Zeichen dahinter — vom
Aufkleber abgelesen, ohne die verwechselbaren Zeichen 0, 1, I und O.
</p>
<Link
href="/geraete"
className="mt-6 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"
>
<Search className="size-4" strokeWidth={1.75} aria-hidden="true" />
Gerät suchen
</Link>
</div>
);
}
if (!darf(benutzer.rolle, "geraeteBearbeiten")) {
return (
<div className="mx-auto max-w-lg">
<h1 className="text-2xl font-bold">Noch keinem Gerät zugeordnet</h1>
<p className="mt-3 text-nebentext">
Der Aufkleber <span className="mono text-text">{geraet.tagCode}</span> ist vorgedruckt und wartet
auf ein Gerät. Zum Registrieren fehlt dir die Berechtigung — ein Techniker oder Administrator
übernimmt das.
</p>
</div>
);
}
/*
* Nur Personen **dieses** Kunden: ein Geraet kann nur jemandem desselben Kunden
* gehoeren (zusammengesetzter Fremdschluessel, siehe services/geraete.ts). Der
* Kunde geht in die Abfrage; nachtraeglich zu filtern liesse bei mehr als 500
* Personen genau die passenden herausfallen.
*/
const besitzer = await besitzerKandidaten(benutzer, 500, geraet.kundeId);
return (
<div className="mx-auto max-w-2xl">
<h1 className="text-2xl font-bold">Gerät registrieren</h1>
<p className="mt-2 max-w-prose text-nebentext">
Dieser Aufkleber ist vorgedruckt und gehört noch zu keinem Gerät. Trag ein, wovor du stehst — danach
geht es direkt auf die Geräteseite.
</p>
<div className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
<GeraetRegistrieren geraetId={geraet.id} tagCode={geraet.tagCode} besitzer={besitzer} />
</div>
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
import Link from "next/link";
import { Printer, QrCode, Tags } from "lucide-react";
import { etikettenPfad } from "@/components/tags/etikett";
import { TagStapel } from "@/components/tags/tag-stapel";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import { kundenListe } from "@/server/queries/kunden";
import { unregistrierteTags } from "@/server/queries/tags";
import { TAGS_STAPEL_HOECHSTENS, TAGS_STAPEL_STANDARD } from "@/server/services/tags";
/**
* Vorgedruckte Tags (#014).
*
* Hier entsteht der Stapel, bevor es die Geraete gibt: Codes ziehen, Bogen
* drucken, aufkleben, spaeter am Geraet scannen und benennen. Ein Tag ist ein
* `Geraet` mit `registriertAm = null` und steht deshalb auch in der Geraeteliste
* — dort als „Tag ohne Zuordnung“.
*
* **Die Seite prueft ihr Recht selbst** (CLAUDE.md): `verwaltung`, wie die
* Aktion. Ein Stapel verbrennt N Tag-Codes unwiderruflich.
*/
const DATUM = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" });
const PILLE = "rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext";
export default async function TagsSeite() {
const { benutzer } = await sitzungErforderlich("verwaltung", "/tags");
const handelnder = { id: benutzer.id, rolle: benutzer.rolle };
const [tags, kundenEbene] = await Promise.all([unregistrierteTags(handelnder), kundenEbeneSichtbar()]);
const kunden = kundenEbene ? await kundenListe(handelnder) : [];
const kundenWahl = kunden.filter((kunde) => !kunde.archiviert).map(({ id, name }) => ({ id, name }));
return (
<>
<h1 className="text-2xl font-bold">Tags</h1>
<p className="mt-2 max-w-prose text-nebentext">
Vorgedruckte Aufkleber ohne Gerät. Der Tag-Code entsteht hier, der Aufkleber kommt aufs Objekt, und
wer ihn später scannt, füllt die Stammdaten aus. Ein Tag-Code wird nie geändert und nie neu vergeben —
ein Stapel, den niemand klebt, ist verbraucht.
</p>
<section className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="flex items-center gap-2 text-lg font-semibold">
<Tags className="size-5 text-akzent" strokeWidth={2} aria-hidden="true" />
Stapel anlegen
</h2>
<p className="mt-1 mb-5 max-w-prose text-sm text-nebentext">
{TAGS_STAPEL_STANDARD} Tags sind gut zwei A4-Bogen und der übliche Vorrat.
</p>
<TagStapel standard={TAGS_STAPEL_STANDARD} hoechstens={TAGS_STAPEL_HOECHSTENS} kunden={kundenWahl} />
</section>
<div className="mt-8 flex flex-wrap items-end justify-between gap-3">
<h2 className="text-lg font-semibold">
Noch nicht zugeordnet
<span className="ml-2 text-sm font-normal text-nebentext">
{tags.length} {tags.length === 1 ? "Tag" : "Tags"}
</span>
</h2>
{tags.length > 0 ? (
<Link
href={etikettenPfad([], "bogen")}
className="flex min-h-11 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"
>
<Printer className="size-4" strokeWidth={2} aria-hidden="true" />
Etiketten drucken
</Link>
) : null}
</div>
{tags.length === 0 ? (
<p className="mt-4 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
Kein Tag wartet auf ein Gerät. Alles, was angelegt wurde, ist registriert.
</p>
) : (
<ul className="mt-4 flex flex-col gap-3">
{tags.map((tag) => (
<li
key={tag.tagCode}
className="flex flex-col gap-3 rounded-karte border border-linie bg-flaeche p-4 md:flex-row md:items-center md:justify-between"
>
<div className="flex min-w-0 flex-wrap items-center gap-2">
{/*
* Der Code führt dorthin, wo auch der Scan landet: zum
* Registrierformular. Im Regal wird gescannt, am Schreibtisch
* geklickt — beides derselbe Weg, und niemand muss den Code
* abtippen (#014).
*/}
<Link
href={`/t/${tag.tagCode}`}
className="mono flex min-h-11 items-center gap-1.5 rounded-pille bg-flaeche-hoch px-2.5 text-xs text-nebentext hover:text-text md:min-h-9"
>
<QrCode className="size-3.5" strokeWidth={1.75} aria-hidden="true" />
{tag.tagCode}
</Link>
{/* Im Modus Intern gibt es genau eine Organisation, und die
erscheint nirgends (#008). */}
{kundenEbene ? <span className={PILLE}>{tag.kundeName}</span> : null}
<span className={PILLE}>angelegt {DATUM.format(tag.erstelltAm)}</span>
</div>
<Link
href={etikettenPfad([tag.tagCode], "etikett")}
className="flex min-h-11 shrink-0 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"
>
<Printer className="size-4" strokeWidth={2} aria-hidden="true" />
Einzeletikett
</Link>
</li>
))}
</ul>
)}
</>
);
}