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>
)}
</>
);
}
+15
View File
@@ -0,0 +1,15 @@
/**
* Eigene, helle Huelle fuer den Etikettendruck (#014).
*
* Bewusst **ausserhalb** von `src/app/(app)`: die Huelle dort bringt Seitenleiste,
* Kopfzeile und den dunklen Grund mit, und beides gehoert auf keinen Bogen. Die
* Rechtepruefung uebernimmt diese Huelle deshalb **nicht** — jede Seite darunter
* prueft selbst, so wie es CLAUDE.md fuer alle Seiten verlangt.
*
* `druck-hell` stellt die Farbtoken auf die helle Fassung um (globals.css);
* `min-h-screen` deckt den dunklen Grund des Wurzel-Layouts auch am Bildschirm ab,
* damit die Vorschau aussieht wie das Papier.
*/
export default function EtikettenDruckLayout({ children }: { children: React.ReactNode }) {
return <div className="druck-hell min-h-screen">{children}</div>;
}
+229
View File
@@ -0,0 +1,229 @@
import Link from "next/link";
import type { Metadata } from "next";
import { Etikett, etikettenPfad, type EtikettDaten, type EtikettLayout } from "@/components/tags/etikett";
import { EtikettenDrucken } from "@/components/tags/etiketten-drucken";
import { QR_ERSATZ_HERKUNFT, qrSvg } from "@/lib/qr";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import { etikettenZuCodes, unregistrierteTags, type EtikettZeile } from "@/server/queries/tags";
/**
* Etikettendruck (#014).
*
* Zwei Formate, ueber `?layout=` waehlbar:
*
* `etikett` 62 × 29 mm, ein Stueck je Seite — fuer den Etikettendrucker
* `bogen` A4 mit 3 × 8 Stueck à 70 × 37 mm — fuer den Laserdrucker
*
* Ohne `?codes=` stehen alle noch nicht zugeordneten Tags auf dem Bogen; das ist
* der Stapeldruck nach `/tags`.
*
* **Die Seite prueft ihr Recht selbst.** Sie liegt ausserhalb von `(app)`, also
* deckt die Huelle dort sie nicht ab — und ein Bogen nennt Hostnamen und
* Besitzer. `lesen` genuegt: dieselben Angaben stehen in der Geraeteliste, und
* ein Techniker muss einen abgerissenen Aufkleber nachdrucken koennen, ohne den
* Administrator zu fragen. Die Mandantensicht wenden die Lesemodelle an: ein
* Etikett eines fremden Kunden erscheint hier nicht.
*/
export const metadata: Metadata = {
title: "Etiketten drucken",
};
/**
* Das Seitenformat je Layout.
*
* `@page` laesst sich nicht auf eine Klasse einschraenken und kann deshalb nicht
* in globals.css stehen — dort gaebe es nur eines von beiden Formaten. Also
* schreibt die Seite die Regel, die zum gewaehlten Layout gehoert.
*
* `margin: 0`, weil die Masse den Bogen genau ausfuellen (3 · 70 = 210 mm,
* 8 · 37 = 296 mm auf 210 × 297 mm) und der Stanzbogen keinen Rand hat. Ein
* Seitenrand verschoebe jedes Etikett gegen seine Stanzung.
*
* `print-color-adjust: exact` haelt Druckern das Sparen ab: ohne die Angabe
* lassen manche Flaechen weg, und ein QR ohne Flaeche ist kein QR.
*/
const SEITENFORMAT: Readonly<Record<EtikettLayout, string>> = {
etikett: "@page { size: 62mm 29mm; margin: 0; }",
bogen: "@page { size: A4; margin: 0; }",
};
const DRUCKSTIL = `
@media print {
html, body { margin: 0; padding: 0; background: #ffffff; }
/* Ein Etikett darf nie ueber zwei Seiten fallen. */
[data-etikett] { break-inside: avoid; }
/* Farbflaechen und der QR muessen mitkommen, nicht "gespart" werden. */
* { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
}
`;
function layoutLesen(wert: string): EtikettLayout {
// Ein unbekannter Wert in der Adresse fuehrt auf den Bogen und nicht auf eine
// Fehlerseite — ein vertippter Parameter ist kein Grund, nichts zu drucken.
return wert === "etikett" ? "etikett" : "bogen";
}
/** Komma, Leerzeichen und Zeilenumbruch trennen gleichermassen. */
function codesLesen(wert: string): readonly string[] {
/*
* Entdoppelt, weil das Lesemodell entdoppelt: sonst meldete `?codes=X,X`
* „1 von 2 fehlt“, obwohl alles da ist (Reviewbefund zu #014). Gezaehlt wird
* gegen dieselbe Liste, die auch gesucht wird.
*/
return [
...new Set(
wert
.split(/[\s,;]+/)
.map((code) => code.trim().toUpperCase())
.filter((code) => code !== ""),
),
];
}
export default async function EtikettenSeite({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const parameter = await searchParams;
const einzeln = (name: string): string =>
(Array.isArray(parameter[name]) ? parameter[name][0] : parameter[name])?.trim() ?? "";
// Nicht ueber `basisAdresse` geprueft, sondern an der Quelle: hier geht es um
// die **Einstellung**, nicht um die Adresse, die daraus faellt.
const ohneAppUrl = !process.env["APP_URL"]?.trim();
const layout = layoutLesen(einzeln("layout"));
const gewuenschteCodes = codesLesen(einzeln("codes"));
const eigenePfad = etikettenPfad(gewuenschteCodes, layout);
/*
* Zwei Rechte, weil es zwei verschiedene Dinge sind (Reviewbefund zu #014):
*
* - **Bestimmte Codes drucken** braucht `lesen`. Ein Techniker muss einen
* abgerissenen Aufkleber nachdrucken koennen, und dieselben Angaben stehen
* in der Geraeteliste.
* - **Ohne Codes** steht hier der ganze Vorrat an noch nicht zugeordneten
* Tags — dieselbe Liste wie auf `/tags`, und die verlangt `verwaltung`.
* Vorher war der Bogen die offene Hintertuer zu einer Seite mit Schloss.
*/
const { benutzer } = await sitzungErforderlich(
gewuenschteCodes.length > 0 ? "lesen" : "verwaltung",
eigenePfad,
);
const handelnder = { id: benutzer.id, rolle: benutzer.rolle };
const [zeilen, kundenEbene] = await Promise.all([
gewuenschteCodes.length > 0
? etikettenZuCodes(gewuenschteCodes, handelnder)
: unregistrierteTags(handelnder),
kundenEbeneSichtbar(),
]);
// Die QR-Codes entstehen alle in einem Zug; jedes Etikett traegt sein SVG
// eingesetzt bei sich: 24 einzeln geholte Bilder je Bogen waeren 24 Anfragen
// mitten im Druckdialog. Denselben Weg geht die Geraeteseite (#014).
const etiketten: readonly EtikettDaten[] = await Promise.all(
zeilen.map(async (zeile: EtikettZeile) => ({
tagCode: zeile.tagCode,
qrSvg: await qrSvg(zeile.tagCode),
anzeige: zeile.anzeige,
besitzerName: zeile.besitzerName,
// Im Modus INTERN **nie** (#008): dort gibt es genau eine Organisation.
kundeKuerzel: kundenEbene ? zeile.kundeKuerzel : null,
})),
);
const fehlend = gewuenschteCodes.length > 0 ? gewuenschteCodes.length - etiketten.length : 0;
return (
<>
<style>{`${SEITENFORMAT[layout]}${DRUCKSTIL}`}</style>
<div className="nicht-drucken mx-auto flex max-w-3xl flex-col gap-4 p-4">
<h1 className="text-xl font-bold">Etiketten drucken</h1>
{ohneAppUrl ? (
/*
* Ohne `APP_URL` zeigt jeder QR auf `http://localhost:3000` — auf dem
* Bildschirm sieht das Etikett trotzdem richtig aus, und der Fehler
* faellt erst auf, wenn jemand den geklebten Aufkleber scannt. Dann
* ist der Bogen gedruckt (Reviewbefund zu #014).
*/
<p role="alert" className="max-w-prose rounded-karte border border-fehler p-4 text-sm text-fehler">
<strong>APP_URL ist nicht gesetzt.</strong> Die QR-Codes zeigen dann auf{" "}
<span className="mono">{QR_ERSATZ_HERKUNFT}</span> und führen von keinem anderen Gerät
irgendwohin. Erst die Adresse eintragen, dann drucken — ein geklebter Aufkleber lässt sich nicht
berichtigen.
</p>
) : null}
<p className="text-sm text-nebentext">
{layout === "etikett"
? "Format 62 × 29 mm, ein Etikett je Seite — für den Etikettendrucker."
: "A4-Bogen mit 3 × 8 Etiketten à 70 × 37 mm — für den Stanzbogen im Laserdrucker."}{" "}
{etiketten.length} {etiketten.length === 1 ? "Etikett" : "Etiketten"}
{gewuenschteCodes.length === 0 ? " — alle noch nicht zugeordneten Tags." : "."}
</p>
<div className="flex flex-wrap items-center gap-3">
<EtikettenDrucken />
<Link
href={etikettenPfad(gewuenschteCodes, layout === "etikett" ? "bogen" : "etikett")}
className="flex min-h-11 items-center rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
>
{layout === "etikett" ? "Als A4-Bogen" : "Als Einzeletikett 62 × 29 mm"}
</Link>
</div>
{fehlend > 0 ? (
<p className="text-sm text-faellig">
{fehlend} der angefragten {gewuenschteCodes.length} Codes {fehlend === 1 ? "ist" : "sind"} nicht
dabei: unbekannt oder zu einem anderen Kunden gehörend. Ein archiviertes Gerät lässt sich
weiterhin drucken — der Aufkleber am Karton im Lager soll lesbar bleiben.
</p>
) : null}
<p className="text-xs text-nebentext">
Im Druckdialog Skalierung auf 100 % stellen und Kopf- und Fußzeilen abschalten — sonst sitzen die
Etiketten neben ihrer Stanzung.
</p>
</div>
{etiketten.length === 0 ? (
<p className="nicht-drucken mx-auto max-w-3xl rounded-karte border border-linie p-5 text-sm text-nebentext">
Kein Etikett zu drucken. Einen Stapel neuer Tags legt die Seite{" "}
<Link href="/tags" className="underline underline-offset-2">
Tags
</Link>{" "}
an.
</p>
) : (
<div
className={
layout === "bogen"
? // Am Bildschirm umbrechend, damit bei 390 px nichts seitwaerts
// laeuft; im Druck das feste Raster des Stanzbogens.
"flex flex-wrap justify-center gap-2 p-2 print:grid print:grid-cols-3 print:gap-0 print:p-0"
: "flex flex-col items-center gap-2 p-2 print:gap-0 print:p-0"
}
>
{etiketten.map((daten, stelle) => (
<div
key={daten.tagCode}
className={
layout === "etikett" && stelle < etiketten.length - 1
? // Ein Etikett je Seite – aber kein Seitenumbruch hinter dem
// letzten, sonst faellt ein leeres Etikett aus dem Drucker.
"border border-dashed border-linie print:border-0 print:break-after-page"
: "border border-dashed border-linie print:border-0"
}
>
<Etikett daten={daten} layout={layout} />
</div>
))}
</div>
)}
</>
);
}
+26
View File
@@ -144,3 +144,29 @@ samp,
display: none !important;
}
}
/*
* Druckansichten und Etiketten sind immer hell (#014).
*
* `.druck-hell` stellt die Farbtoken eines Teilbaums auf die helle Fassung um.
* Dadurch bleiben die Komponenten darin bei `bg-flaeche`, `text-text` und
* `border-linie`, statt feste Hexwerte mitzubringen: der helle Modus oben greift
* nur auf der Wurzel, und die gehoert dem Wurzel-Layout — eine Druckseite unter
* `src/app/drucken/` kann sie nicht umschalten.
*
* Reinweiss statt des gebrochenen Weiss des hellen Modus: Papier ist weiss, und
* ein Grauschleier hinter dem QR kostet Toner und Kontrast.
*/
.druck-hell {
--color-grund: #ffffff;
--color-flaeche: #ffffff;
--color-flaeche-hoch: #f6f7f4;
--color-linie: #d9ddd4;
--color-text: #0b0d10;
--color-nebentext: #5b6472;
--color-akzent: #5e8a00;
--color-auf-akzent: #ffffff;
--color-akzent-flaeche: rgb(94 138 0 / 0.1);
background-color: var(--color-grund);
color: var(--color-text);
}