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);
}
@@ -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>
);
}
+28
View File
@@ -21,3 +21,31 @@ export function oeffentlicheAdresse(pfad: string, ersatzHerkunft: string): URL {
}
return new URL(pfad, ersatzHerkunft);
}
/**
* Die Basisadresse der Anwendung – **mit** ihrem Pfad (#014).
*
* `oeffentlicheAdresse` loest einen absoluten Pfad gegen die Basis auf, und
* dabei faellt ein Unterpfad heraus: aus `https://firma.de/laufzettel` und
* `/t/LZ-…` wird `https://firma.de/t/LZ-…`. Fuer eine Umleitung im laufenden
* Betrieb faellt das niemandem auf, weil der Reverse Proxy den Pfad ohnehin
* wieder anhaengt. Auf einem **gedruckten Aufkleber** ist es das Ende: er zeigt
* fuer immer ins Leere.
*
* Wer eine Adresse baut, die jemand behaelt, nimmt deshalb diese Basis und
* haengt relativ an. Der Schraegstrich am Ende entscheidet darueber, ob das
* letzte Pfadstueck ersetzt oder ergaenzt wird — er wird hier gesetzt, statt
* darauf zu hoffen.
*/
export function basisAdresse(ersatzHerkunft: string): URL {
const roh = process.env["APP_URL"]?.trim();
let basis: URL;
try {
basis = new URL(roh || ersatzHerkunft);
} catch {
// Eine unbrauchbare APP_URL soll den Druck nicht blockieren.
basis = new URL(ersatzHerkunft);
}
if (!basis.pathname.endsWith("/")) basis.pathname += "/";
return basis;
}
+116
View File
@@ -0,0 +1,116 @@
import QRCode from "qrcode";
import { basisAdresse } from "@/lib/adressen";
import { istTagCode } from "@/lib/tagcode";
/**
* QR-Codes fuer die Aufkleber (#014).
*
* Framework- und datenbankfrei, wie jedes Modul in `src/lib`: hier stehen nur
* die Rechnung von `tagCode` zu Adresse und die Erzeugung des SVG. Wer den Code
* kennen darf, entscheiden Route Handler und Seiten.
*
* **Im QR steht ausschliesslich die Adresse `{APP_URL}/t/{tagCode}`.** Kein
* Name, keine Seriennummer, kein Besitzer. Ein Aufkleber haengt jahrelang
* sichtbar am Geraet und wird von jedem Telefon gelesen, auch von einem fremden;
* was darauf steht, ist damit veroeffentlicht. Die Adresse selbst verraet
* nichts — dahinter verlangt `/t/[tagCode]` eine Anmeldung.
*/
export class QrFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "QrFehler";
}
}
/**
* Fehlerkorrektur M wie im Auftrag: rund 15 % der Module duerfen fehlen.
*
* Das passt zu einem Aufkleber, der verkratzt und verstaubt, ohne den Code so
* gross zu machen, dass er auf ein 62 × 29 mm grosses Etikett nicht mehr passt.
*/
export const QR_FEHLERKORREKTUR = "M" as const;
/**
* Ruhezone in Modulen.
*
* Vier ist das Mass aus ISO/IEC 18004 und zugleich der Standard von `qrcode`.
* Die Zone wird hier ausdruecklich gesetzt und nicht dem Paket ueberlassen,
* damit niemand sie am Etikett „einspart“: ohne weissen Rand findet der Decoder
* das Suchmuster nicht mehr, und das faellt erst am gedruckten Bogen auf.
*/
export const QR_RUHEZONE = 4;
/**
* Basisadresse, wenn weder `APP_URL` noch eine Anfrage eine liefert.
*
* `oeffentlicheAdresse` verlangt eine Ersatzherkunft, weil im Betrieb die
* laufende Anfrage sie stellt. Beim Etikettendruck gibt es keine — eine
* Server-Komponente hat keine Anfrageadresse zur Hand, und ein Etikett wird
* gedruckt, nicht angeklickt. Ohne gesetzte `APP_URL` waere jeder Aufkleber
* ohnehin falsch, egal welche Adresse hier stuende; der Entwicklungsstand ist
* dann die Adresse, unter der die Anwendung ueberhaupt laeuft.
*/
export const QR_ERSATZ_HERKUNFT = "http://localhost:3000";
/**
* Die Adresse, die im QR steht.
*
* Die Basis kommt aus `APP_URL` ueber `oeffentlicheAdresse` (#007) und nicht aus
* der eingehenden Anfrage: hinter einem Reverse Proxy traegt die Anfrage den
* internen Host, und ein Aufkleber mit `http://laufzettel-app:3000/t/…` ist
* wertlos — er klebt Jahre am Geraet und laesst sich nicht nachtraeglich
* korrigieren. Wer eine Ersatzherkunft hat (ein Route Handler etwa), gibt sie
* mit; sie greift nur, wenn `APP_URL` fehlt.
*/
export function tagAdresse(tagCode: string, ersatzHerkunft: string = QR_ERSATZ_HERKUNFT): string {
if (!istTagCode(tagCode)) {
// Der Code wird in die Adresse eingesetzt. Ein nicht geprueftes Zeichen
// stuende damit im QR jedes gedruckten Aufklebers.
throw new QrFehler(`„${tagCode}“ hat nicht die Form eines Tag-Codes.`);
}
/*
* **Relativ angehaengt, nicht als absoluter Pfad.** Liegt die Anwendung unter
* einem Unterpfad (`https://firma.de/laufzettel`), verschluckt `new
* URL("/t/…", basis)` genau diesen Unterpfad — der Aufkleber zeigte dann auf
* `https://firma.de/t/…`, und dort steht nichts. Auf Papier ist das nicht
* mehr zu korrigieren (Reviewbefund zu #014). `basisAdresse` haelt den Pfad
* fest, und hier wird relativ angehaengt.
*/
return new URL(`t/${tagCode}`, basisAdresse(ersatzHerkunft)).toString();
}
export type QrOptionen = {
/** Greift nur ohne gesetzte `APP_URL`; siehe `tagAdresse`. */
readonly ersatzHerkunft?: string;
/**
* Feste Kantenlaenge in Pixeln fuer das `svg`-Element. Ohne Angabe traegt das
* SVG nur eine `viewBox` und fuellt seinen Platz — genau das, was ein Etikett
* in Millimetern braucht.
*/
readonly breite?: number;
/** Ruhezone in Modulen. Standard `QR_RUHEZONE`. */
readonly rand?: number;
};
/**
* Das QR-SVG zu einem `tagCode`.
*
* SVG und nicht PNG: es wird in einen Druck mit Millimeterangaben gesetzt, und
* nur ein Vektor kommt auf jedem Drucker scharf heraus. Erzeugt wird es lokal
* mit `qrcode`; eine externe API kaeme nicht in Frage, sie bekaeme mit jedem
* Aufruf den Bestand an Tag-Codes geliefert.
*/
export async function qrSvg(tagCode: string, optionen: QrOptionen = {}): Promise<string> {
const adresse = tagAdresse(tagCode, optionen.ersatzHerkunft ?? QR_ERSATZ_HERKUNFT);
return QRCode.toString(adresse, {
type: "svg",
errorCorrectionLevel: QR_FEHLERKORREKTUR,
margin: optionen.rand ?? QR_RUHEZONE,
...(optionen.breite === undefined ? {} : { width: optionen.breite }),
// Schwarz auf Weiss, feste Werte: ein Etikett ist immer hell, und der
// Decoder braucht den Kontrast. Die Token der Oberflaeche gelten hier nicht.
color: { dark: "#000000ff", light: "#ffffffff" },
});
}
+42
View File
@@ -77,3 +77,45 @@ export async function tagCodeEindeutigErzeugen(
"Das deutet auf eine defekte Vergabepruefung hin, nicht auf einen erschoepften Vorrat.",
);
}
/**
* Liest den Tag-Code aus dem, was der Scanner gefunden hat (#014).
*
* Auf dem Aufkleber steht ein QR mit `{APP_URL}/t/{tagCode}`. Gescannt wird aber
* nicht nur der eigene Aufkleber: in der Kamera landen auch fremde QR-Codes,
* alte Etiketten einer anderen Anwendung oder ein abgetippter Code aus dem
* Ersatzfeld. Deshalb wird hier entschieden, **was** ein Fund bedeutet, und
* nicht in der Kamerakomponente.
*
* Angenommen wird dreierlei, und zwar bewusst grosszuegig:
*
* 1. eine Adresse, deren Pfad auf `/t/<code>` endet — **egal von welchem Host**.
* Ein Haus zieht um, ein zweiter Name zeigt auf dieselbe Anwendung, ein
* Reverse Proxy schreibt um: an all dem soll der Aufkleber im Regal nicht
* scheitern. Der Code selbst ist die Auskunft, nicht der Host.
* 2. der blanke Code `LZ-XXXXXX`
* 3. derselbe in Kleinbuchstaben — abgetippt wird klein, und das Alphabet des
* Codes ist ohnehin nur gross.
*
* Alles andere ergibt `null`; der Scanner sagt dann „kein Laufzettel-Tag“ und
* sucht weiter, statt auf eine Fehlerseite zu springen.
*/
export function tagCodeAusFund(fund: string): string | null {
const roh = fund.trim();
if (!roh) return null;
const alsCode = roh.toUpperCase();
if (istTagCode(alsCode)) return alsCode;
try {
// `new URL` scheitert bei allem, was keine absolute Adresse ist – genau die
// Unterscheidung, die hier gebraucht wird.
const adresse = new URL(roh);
const teile = adresse.pathname.split("/").filter(Boolean);
if (teile.length !== 2 || teile[0] !== "t") return null;
const code = decodeURIComponent(teile[1] ?? "").toUpperCase();
return istTagCode(code) ? code : null;
} catch {
return null;
}
}
+8 -1
View File
@@ -52,7 +52,14 @@ export function istOeffentlich(pfad: string): boolean {
export function sicherheitsrichtlinie(nonce: string, entwicklung: boolean): string {
return [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${entwicklung ? " 'unsafe-eval'" : ""}`,
/*
* `wasm-unsafe-eval` ist der schmale Bruder von `unsafe-eval`: er erlaubt
* ausschliesslich das Uebersetzen von WebAssembly, sonst nichts. Der Scanner
* (#014) liest QR-Codes ueber `zxing-wasm`, und ohne diese Erlaubnis lehnt
* Chrome das Modul ab — die Kamera laeuft, erkennt aber nie etwas. Die
* strenge Fassung von `script-src` bleibt davon unberuehrt.
*/
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic' 'wasm-unsafe-eval'${entwicklung ? " 'unsafe-eval'" : ""}`,
// Tailwind erzeugt Stilangaben zur Laufzeit, deshalb unsafe-inline fuer Stile.
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: blob:`,
+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." };
}
}
+272 -3
View File
@@ -1,5 +1,5 @@
import type { Prisma } from "@/generated/prisma/client";
import type { GeraeteTyp } from "@/generated/prisma/enums";
import { LaufStatus, SchrittZustand, VersionStatus, type GeraeteTyp } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
@@ -34,6 +34,8 @@ export type GeraeteZeile = {
readonly besitzerId: string | null;
readonly besitzerName: string | null;
readonly besitzerAktiv: boolean | null;
/** Der rohe `department`-Wert der Person – die Geraeteseite nennt ihn im Kopf (#014). */
readonly besitzerAbteilung: string | null;
readonly anzahlLaeufe: number;
};
@@ -76,7 +78,7 @@ const ZEILEN_AUSWAHL = {
registriertAm: true,
kundeId: true,
kunde: { select: { name: true } },
mitarbeiter: { select: { id: true, anzeigename: true, aktiv: true } },
mitarbeiter: { select: { id: true, anzeigename: true, aktiv: true, abteilung: true } },
_count: { select: { laeufe: true } },
} satisfies Prisma.GeraetSelect;
@@ -101,6 +103,7 @@ function zeileBauen(zeile: Rohzeile): GeraeteZeile {
besitzerId: zeile.mitarbeiter?.id ?? null,
besitzerName: zeile.mitarbeiter?.anzeigename ?? null,
besitzerAktiv: zeile.mitarbeiter?.aktiv ?? null,
besitzerAbteilung: zeile.mitarbeiter?.abteilung ?? null,
anzahlLaeufe: zeile._count.laeufe,
};
}
@@ -167,6 +170,258 @@ export async function geraetHolen(
return zeile ? zeileBauen(zeile) : undefined;
}
/**
* Das Geraet zu einem Tag-Code – der Einstieg von `/t/<code>` (#014).
*
* **Gross- und Kleinschreibung sind unerheblich.** Der Code wird abgetippt, und
* ein Handy schreibt von sich aus klein; wer `lz-7k3f9q` eingibt, meint den
* Aufkleber, auf dem `LZ-7K3F9Q` steht. Weiter geht die Nachsicht bewusst
* nicht: verwechselbare Zeichen bleiben stehen, wie sie eingegeben wurden. Ein
* `I` auf ein `L` zu biegen koennte auf ein **anderes, vorhandenes** Geraet
* fuehren, und wer vor dem Geraet steht, saehe den Irrtum nie.
*
* `undefined` heisst „gibt es nicht oder darfst du nicht sehen“ – dieselbe
* Antwort, aus demselben Grund wie in `geraetHolen`.
*/
export async function geraetNachTagCode(
tagCode: string,
benutzer: HandelnderBenutzer,
): Promise<GeraeteZeile | undefined> {
const code = tagCode.trim().toUpperCase();
if (code === "") return undefined;
const mandant = await kundenBedingung(benutzer);
const zeile = await prisma.geraet.findFirst({
where: { AND: [{ tagCode: code }, ...(mandant ? [mandant] : [])] },
select: ZEILEN_AUSWAHL,
});
return zeile ? zeileBauen(zeile) : undefined;
}
export type GeraetLaufZeile = {
readonly id: string;
readonly titel: string;
readonly status: LaufStatus;
readonly vorlagenName: string;
readonly version: number;
readonly zugewiesenAn: string | null;
readonly faelligAm: Date | null;
readonly erstelltAm: Date;
readonly abgeschlossenAm: Date | null;
readonly anzahlSchritte: number;
readonly anzahlErledigt: number;
};
export type GeraetLaeufe = {
/** `OFFEN` und `IN_ARBEIT` – die Arbeit, die an diesem Geraet wartet. */
readonly zugewiesen: readonly GeraetLaufZeile[];
/** `ABGESCHLOSSEN` und `ARCHIVIERT`, das Jüngste zuerst. */
readonly historie: readonly GeraetLaufZeile[];
/** Waren es mehr als `HISTORIE_HOECHSTENS`? Dann sagt die Seite das auch. */
readonly historieGekuerzt: boolean;
};
/**
* So viele abgeschlossene Laeufe zeigt die Geraeteseite.
*
* Die Zuweisungen bleiben ungekappt: was offen ist, muss vollstaendig sichtbar
* sein, und mehr als eine Handvoll gleichzeitig hat ein Geraet nie. Die Historie
* dagegen waechst ueber Jahre; ungekappt waere die Seite am Handy irgendwann eine
* Chronik statt eines Einstiegs.
*/
export const HISTORIE_HOECHSTENS = 20;
const LAUF_AUSWAHL = {
id: true,
titel: true,
status: true,
faelligAm: true,
erstelltAm: true,
abgeschlossenAm: true,
zugewiesenAn: { select: { name: true } },
vorlagenVersion: { select: { version: true, vorlage: { select: { name: true } } } },
ergebnisse: { select: { zustand: true } },
} satisfies Prisma.LaufSelect;
type RohLauf = Prisma.LaufGetPayload<{ select: typeof LAUF_AUSWAHL }>;
function laufZeileBauen(zeile: RohLauf): GeraetLaufZeile {
return {
id: zeile.id,
titel: zeile.titel,
status: zeile.status,
vorlagenName: zeile.vorlagenVersion.vorlage.name,
version: zeile.vorlagenVersion.version,
zugewiesenAn: zeile.zugewiesenAn?.name ?? null,
faelligAm: zeile.faelligAm,
erstelltAm: zeile.erstelltAm,
abgeschlossenAm: zeile.abgeschlossenAm,
anzahlSchritte: zeile.ergebnisse.length,
// Erledigt heisst hier „vom Tisch“: `NICHT_ZUTREFFEND` zaehlt mit,
// `FEHLGESCHLAGEN` nicht – genau wie in der Laufliste und beim Abschluss.
anzahlErledigt: zeile.ergebnisse.filter(
(ergebnis) =>
ergebnis.zustand === SchrittZustand.ERLEDIGT || ergebnis.zustand === SchrittZustand.NICHT_ZUTREFFEND,
).length,
};
}
/**
* Die Laeufe eines Geraets, getrennt in Zuweisungen und Historie (#014).
*
* Zwei Abfragen statt einer, weil beide Haelften verschieden sortiert und
* verschieden gekappt sind: das Offene nach Faelligkeit und vollstaendig, das
* Erledigte nach Abschluss und begrenzt.
*
* Die Mandantensicht steht auch hier im Lesemodell selbst – ein Lauf traegt seine
* eigene `kundeId`, und die Bedingung greift auf ihr. Die `geraetId` allein
* duerfte nichts oeffnen.
*/
export async function laeufeDesGeraets(
geraetId: string,
benutzer: HandelnderBenutzer,
): Promise<GeraetLaeufe> {
const sichtbar = await sichtbareKundenIds(benutzer);
const mandant: Prisma.LaufWhereInput[] = sichtbar === undefined ? [] : [{ kundeId: { in: [...sichtbar] } }];
const [zugewiesen, historie] = await Promise.all([
prisma.lauf.findMany({
where: {
AND: [{ geraetId }, { status: { in: [LaufStatus.OFFEN, LaufStatus.IN_ARBEIT] } }, ...mandant],
},
orderBy: [{ faelligAm: { sort: "asc", nulls: "last" } }, { erstelltAm: "desc" }],
select: LAUF_AUSWAHL,
}),
prisma.lauf.findMany({
where: {
AND: [
{ geraetId },
{ status: { in: [LaufStatus.ABGESCHLOSSEN, LaufStatus.ARCHIVIERT] } },
...mandant,
],
},
// Abgeschlossen zuerst, und wo das fehlt, nach Anlage – ein archivierter
// Lauf ohne `abgeschlossenAm` faellt sonst ans Ende der Chronik.
orderBy: [{ abgeschlossenAm: { sort: "desc", nulls: "last" } }, { erstelltAm: "desc" }],
take: HISTORIE_HOECHSTENS + 1,
select: LAUF_AUSWAHL,
}),
]);
return {
zugewiesen: zugewiesen.map(laufZeileBauen),
historie: historie.slice(0, HISTORIE_HOECHSTENS).map(laufZeileBauen),
historieGekuerzt: historie.length > HISTORIE_HOECHSTENS,
};
}
export type GeraeteVorlage = {
readonly versionId: string;
readonly vorlageId: string;
readonly name: string;
readonly version: number;
readonly kategorie: string | null;
/**
* Hat diese Version Pflichtvariablen?
*
* Der Schnellstart legt einen Lauf mit einem Tipp an und kann dabei nichts
* erfragen. Eine Vorlage, die eine Pflichtangabe verlangt, wuerde `laufAnlegen`
* deshalb abweisen — sie fuehrt statt dessen in den Start-Dialog, der fragen
* kann. Die Regel selbst bleibt im Dienst; dieses Feld entscheidet nur, welcher
* der beiden Wege angeboten wird.
*/
readonly pflichtangaben: boolean;
};
export type VorlagenFuerGeraet = {
/** Die Vorgaben aus `GeraetetypVorlage` fuer diesen Typ, in ihrer Reihenfolge. */
readonly schnellstart: readonly GeraeteVorlage[];
/** Alles, was sich an diesem Geraet starten laesst – fuer „Vorlage zuweisen“. */
readonly alle: readonly GeraeteVorlage[];
};
/**
* Die Vorlagen, die an diesem Geraet in Frage kommen (#014).
*
* Je Vorlage genau die **zuletzt veroeffentlichte** Version: wer an einem Geraet
* losgeht, will die geltende Fassung, und ein Entwurf ist noch niemandes
* Arbeitsanweisung. Eine Vorlage ohne veroeffentlichte Version steht deshalb
* nicht dabei, eine archivierte auch nicht.
*
* Gefiltert wird auf den Kunden **des Geraets** und nicht nur auf die Sicht des
* Benutzers: ein Administrator sieht im Modus Dienstleistung alle Kunden, und
* eine kundenspezifische Vorlage von Kunde B liesse sich an einem Geraet von
* Kunde A nicht starten — `laufAnlegen` weist das ab. Eine Auswahl anzubieten,
* die der Dienst danach ablehnt, ist keine Auswahl.
*/
export async function vorlagenFuerGeraet(
geraet: { readonly typ: GeraeteTyp; readonly kundeId: string },
benutzer: HandelnderBenutzer,
): Promise<VorlagenFuerGeraet> {
const sichtbar = await sichtbareKundenIds(benutzer);
// Die Mandantensicht auch hier selbst: ohne sie liesse sich mit der `kundeId`
// eines fremden Geraets erfragen, welche Vorlagen dieser Kunde hat.
if (sichtbar !== undefined && !sichtbar.includes(geraet.kundeId)) {
return { schnellstart: [], alle: [] };
}
const vorlagen = await prisma.vorlage.findMany({
where: {
AND: [
{ archiviert: false },
// Eine Vorlage ohne Kundenbezug gilt fuer alle (#009).
{ OR: [{ kundeId: null }, { kundeId: geraet.kundeId }] },
],
},
orderBy: [{ kategorie: "asc" }, { name: "asc" }],
select: {
id: true,
name: true,
kategorie: true,
versionen: {
where: { status: VersionStatus.VEROEFFENTLICHT },
orderBy: { version: "desc" },
take: 1,
select: {
id: true,
version: true,
// Nur ob es welche gibt, nicht welche: mehr braucht die Entscheidung
// zwischen Schnellstart und Start-Dialog nicht.
variablen: { where: { pflicht: true }, select: { schluessel: true }, take: 1 },
},
},
typVorgaben: { where: { geraeteTyp: geraet.typ }, select: { position: true } },
},
});
const alle: GeraeteVorlage[] = [];
const schnellstart: { eintrag: GeraeteVorlage; position: number }[] = [];
for (const vorlage of vorlagen) {
const version = vorlage.versionen[0];
if (!version) continue;
const eintrag: GeraeteVorlage = {
versionId: version.id,
vorlageId: vorlage.id,
name: vorlage.name,
version: version.version,
kategorie: vorlage.kategorie,
pflichtangaben: version.variablen.length > 0,
};
alle.push(eintrag);
const vorgabe = vorlage.typVorgaben[0];
if (vorgabe) schnellstart.push({ eintrag, position: vorgabe.position });
}
return {
schnellstart: schnellstart
// Die Position ist die gepflegte Reihenfolge; bei gleicher Position bleibt
// die Sortierung der Abfrage (Kategorie, Name) erhalten.
.sort((links, rechts) => links.position - rechts.position)
.map((eintrag) => eintrag.eintrag),
alle,
};
}
export type BesitzerKandidat = {
readonly id: string;
readonly anzeigename: string;
@@ -195,11 +450,25 @@ export type BesitzerAuswahl = {
export async function besitzerKandidaten(
benutzer: HandelnderBenutzer,
grenze = 500,
/**
* Nur Personen dieses Kunden.
*
* Gehoert in die **Abfrage** und nicht hinter sie: wer erst 500 Personen aller
* sichtbaren Kunden holt und danach filtert, hat bei einem grossen
* Dienstleister womoeglich keine einzige passende in der Hand — und die
* Meldung „nur die ersten 500“ stimmt dann auch nicht mehr (Reviewbefund zu
* #014).
*/
kundeId?: string,
): Promise<BesitzerAuswahl> {
const sichtbar = await sichtbareKundenIds(benutzer);
const zeilen = await prisma.mitarbeiter.findMany({
where: {
AND: [{ aktiv: true }, ...(sichtbar === undefined ? [] : [{ kundeId: { in: [...sichtbar] } }])],
AND: [
{ aktiv: true },
...(sichtbar === undefined ? [] : [{ kundeId: { in: [...sichtbar] } }]),
...(kundeId === undefined ? [] : [{ kundeId }]),
],
},
select: { id: true, anzeigename: true, aktiv: true, kundeId: true },
orderBy: { anzeigename: "asc" },
+135
View File
@@ -0,0 +1,135 @@
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { istTagCode } from "@/lib/tagcode";
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
/**
* Lesemodelle der Tags und Etiketten (#014).
*
* Ein „Tag“ ist kein eigenes Modell: es ist ein `Geraet` mit
* `registriertAm = null`, also ein vorgedruckter Aufkleber, dem noch kein Objekt
* zugeordnet ist. Deshalb liest dieses Modul aus `Geraet` — nur mit den Feldern,
* die auf ein Etikett passen, und nicht mit denen der Geraeteliste.
*
* **Die Mandantensicht wendet jede Funktion hier selbst an**, in einem
* `AND`-Array, in dem keine Teilbedingung eine andere ueberschreiben kann (der
* Befund aus dem Review von #009). Ein Etikett nennt Hostname und Besitzer; ein
* Bogen mit fremden Kunden darauf waere ein Datenleck auf Papier.
*/
export type EtikettZeile = {
readonly id: string;
readonly tagCode: string;
readonly hostname: string | null;
readonly bezeichnung: string | null;
/** Hostname, sonst Bezeichnung, sonst leer — ein Tag ohne Zuordnung hat keinen Namen. */
readonly anzeige: string | null;
readonly besitzerName: string | null;
/** Steht nur im Modus DIENSTLEISTUNG auf dem Etikett (#008). */
readonly kundeKuerzel: string;
readonly kundeName: string;
/** Leer heisst: vorgedruckter Tag, noch keinem Objekt zugeordnet. */
readonly registriertAm: Date | null;
readonly erstelltAm: Date;
};
/**
* Die Mandantenbedingung als eigener Ausdruck. `undefined` heisst „keine
* Einschraenkung“ und ist etwas anderes als eine leere Liste (siehe
* `sichtbareKundenIds`).
*/
async function kundenBedingung(benutzer: HandelnderBenutzer): Promise<Prisma.GeraetWhereInput | undefined> {
const sichtbar = await sichtbareKundenIds(benutzer);
if (sichtbar === undefined) return undefined;
return { kundeId: { in: [...sichtbar] } };
}
const ETIKETT_AUSWAHL = {
id: true,
tagCode: true,
hostname: true,
bezeichnung: true,
registriertAm: true,
erstelltAm: true,
kunde: { select: { name: true, kuerzel: true } },
mitarbeiter: { select: { anzeigename: true } },
} satisfies Prisma.GeraetSelect;
type Rohzeile = Prisma.GeraetGetPayload<{ select: typeof ETIKETT_AUSWAHL }>;
function zeileBauen(zeile: Rohzeile): EtikettZeile {
return {
id: zeile.id,
tagCode: zeile.tagCode,
hostname: zeile.hostname,
bezeichnung: zeile.bezeichnung,
anzeige: zeile.hostname ?? zeile.bezeichnung,
besitzerName: zeile.mitarbeiter?.anzeigename ?? null,
kundeKuerzel: zeile.kunde.kuerzel,
kundeName: zeile.kunde.name,
registriertAm: zeile.registriertAm,
erstelltAm: zeile.erstelltAm,
};
}
/**
* Wie viele Zeilen ein Aufruf hoechstens liefert.
*
* Ein Bogen fasst 24 Etiketten; 500 sind gut 20 Bogen und damit mehr, als jemand
* in einem Zug druckt. Die Grenze steht hier, damit die Seite nach dem dritten
* Stapel nicht tausende Zeilen in ein Dokument gieszt.
*/
export const TAGS_LISTE_GRENZE = 500;
/**
* Die noch nicht zugeordneten Tags.
*
* Neueste zuerst: gefragt ist immer der Stapel von gerade, nicht der vom letzten
* Jahr. Archivierte bleiben aussen vor — ein archivierter Aufkleber soll nicht
* noch einmal gedruckt werden.
*/
export async function unregistrierteTags(
benutzer: HandelnderBenutzer,
grenze: number = TAGS_LISTE_GRENZE,
): Promise<readonly EtikettZeile[]> {
const mandant = await kundenBedingung(benutzer);
const zeilen = await prisma.geraet.findMany({
where: { AND: [{ registriertAm: null }, { archiviert: false }, ...(mandant ? [mandant] : [])] },
orderBy: [{ erstelltAm: "desc" }, { tagCode: "asc" }],
take: grenze,
select: ETIKETT_AUSWAHL,
});
return zeilen.map(zeileBauen);
}
/**
* Die Etikettenzeilen zu bestimmten Tag-Codes, in genau der uebergebenen
* Reihenfolge.
*
* Die Reihenfolge ist keine Kosmetik: ein Bogen wird ausgeschnitten und der
* Reihe nach aufgeklebt, und wer zwei Bogen desselben Stapels nacheinander
* druckt, erwartet dieselbe Anordnung. Codes, die es nicht gibt oder die zu
* einem fremden Kunden gehoeren, fehlen still — die Unterscheidung waere schon
* die Auskunft, die wir nicht geben wollen.
*/
export async function etikettenZuCodes(
codes: readonly string[],
benutzer: HandelnderBenutzer,
): Promise<readonly EtikettZeile[]> {
// Ein formal falscher Code kann keine Zeile treffen. Ihn gar nicht erst zu
// fragen haelt die Abfrage klein, wenn jemand die Adresse von Hand baut.
const gewuenscht = [...new Set(codes.map((code) => code.trim().toUpperCase()))].filter(istTagCode);
if (gewuenscht.length === 0) return [];
const mandant = await kundenBedingung(benutzer);
const zeilen = await prisma.geraet.findMany({
where: { AND: [{ tagCode: { in: gewuenscht } }, ...(mandant ? [mandant] : [])] },
take: TAGS_LISTE_GRENZE,
select: ETIKETT_AUSWAHL,
});
const nachCode = new Map(zeilen.map((zeile) => [zeile.tagCode, zeileBauen(zeile)]));
return gewuenscht
.map((code) => nachCode.get(code))
.filter((zeile): zeile is EtikettZeile => zeile !== undefined);
}
+57 -1
View File
@@ -1,9 +1,10 @@
import { Prisma, type Geraet } from "@/generated/prisma/client";
import { Prisma, type Geraet, type Lauf } from "@/generated/prisma/client";
import { GeraeteTyp } from "@/generated/prisma/enums";
import { CsvFehler, csvZerlegen, hatSpalte, wertAus, type CsvZeile } from "@/lib/csv";
import { prisma } from "@/lib/db";
import { tagCodeEindeutigErzeugen } from "@/lib/tagcode";
import { kundeErmitteln, kundeZugriffPruefen, type HandelnderBenutzer } from "@/server/mandant";
import { laufAnlegen } from "@/server/services/laeufe";
/**
* Geraete und ihre Stammdaten (#011).
@@ -445,6 +446,61 @@ export async function geraetLoeschen(geraetId: string, benutzer: HandelnderBenut
}
}
// ---- Ein Lauf am Geraet ----------------------------------------------------
export type LaufAmGeraet = {
readonly geraetId: string;
readonly vorlagenVersionId: string;
readonly zugewiesenAnId?: string | null;
readonly faelligAm?: Date | null;
};
/**
* Legt einen Lauf fuer dieses Geraet an — „Vorlage zuweisen“ auf der
* Geraeteseite (#014).
*
* Die Regeln des Laufs bleiben in `services/laeufe.ts`: die Version muss
* veroeffentlicht sein, das Geraet darf nicht archiviert sein, die Zuweisung
* muss jemanden treffen, der den Lauf auch oeffnen kann. Nichts davon steht hier
* ein zweites Mal — diese Funktion beantwortet nur die zwei Fragen, die das
* **Geraet** beantwortet:
*
* 1. **Welcher Kunde?** Der des Geraets. Eine von aussen mitgeschickte `kundeId`
* gibt es hier deshalb nicht; im Modus INTERN verwirft `kundeErmitteln` sie
* ohnehin (#008).
* 2. **Welche Person?** Der Besitzer, wenn es einen gibt. Wer an einem Notebook
* einen Lauf anlegt, meint die Person, der es gehoert; ein Server und ein
* Raum haben keine, und dann bleibt das Feld leer.
*
* Der Lauf entsteht als `OFFEN` und damit als Zuweisung — gestartet wird er
* erst, wenn jemand ihn zur Bearbeitung oeffnet.
*/
export async function laufFuerGeraetAnlegen(
eingabe: LaufAmGeraet,
benutzer: HandelnderBenutzer,
): Promise<Lauf> {
const geraet = await prisma.geraet.findUnique({
where: { id: eingabe.geraetId },
select: { id: true, kundeId: true, mitarbeiterId: true },
});
if (!geraet) throw new GeraeteFehler("Dieses Gerät gibt es nicht.");
// Die `geraetId` kommt von aussen: ohne diese Pruefung liesse sich mit einer
// fremden Kennung Arbeit im Mandanten eines anderen Kunden anlegen.
await kundeZugriffPruefen(geraet.kundeId, benutzer);
return laufAnlegen(
{
vorlagenVersionId: eingabe.vorlagenVersionId,
geraetId: geraet.id,
mitarbeiterId: geraet.mitarbeiterId,
zugewiesenAnId: eingabe.zugewiesenAnId ?? null,
faelligAm: eingabe.faelligAm ?? null,
kundeId: geraet.kundeId,
},
benutzer,
);
}
// ---- CSV-Import ------------------------------------------------------------
/**
+97
View File
@@ -0,0 +1,97 @@
import { geraetAnlegen } from "@/server/services/geraete";
import type { HandelnderBenutzer } from "@/server/mandant";
/**
* Stapel vorgedruckter Tags (#014).
*
* Wer Aufkleber drucken lassen will, braucht die Codes, **bevor** die Geraete
* existieren: der Bogen kommt aus dem Drucker, wird aufgeklebt und erst am Geraet
* gescannt und benannt. Ein solcher Tag ist ein `Geraet` mit
* `registriertAm = null` — kein zweites Modell, kein zweiter Codepfad.
*
* Die Regeln der Vergabe stehen **nicht** hier, sondern in
* `services/geraete.ts`: dort wird der `tagCode` gezogen, in derselben
* Transaktion ins Vergabebuch geschrieben und protokolliert. Dieses Modul ruft
* `geraetAnlegen({ registrieren: false })` in einer Schleife und sonst nichts.
* Die Regeln nachzuschreiben waere der sichere Weg, dass eine von ihnen nur in
* einem der beiden Wege landet — und ein Tag ohne Eintrag im Vergabebuch ist ein
* Aufkleber, dessen Code irgendwann ein zweites Mal vergeben wird.
*/
export class TagsFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "TagsFehler";
}
}
/** Ein A4-Bogen fasst 24 Etiketten; 50 sind gut zwei Bogen und der uebliche Vorrat. */
export const TAGS_STAPEL_STANDARD = 50;
/**
* Obergrenze je Stapel.
*
* Jeder Tag zieht einen `tagCode`, der nie zurueckkommt — ein vertippter Stapel
* ist also nicht ruecknehmbar, sondern nur Papier, das man nicht bedruckt. Dazu
* kommt die Laufzeit: jeder Tag ist eine eigene Transaktion, weil das die
* Vergabe verlangt. 500 sind gut 20 Bogen, mehr druckt niemand in einem Zug, und
* die Oberflaeche nennt die Zahl, **bevor** jemand dagegen laeuft.
*/
export const TAGS_STAPEL_HOECHSTENS = 500;
export type TagStapel = {
/** Der Kunde, dem die Tags gehoeren – im Modus INTERN die eigene Organisation. */
readonly kundeId: string;
/** In der Reihenfolge der Erzeugung, also in der Reihenfolge des Bogens. */
readonly tagCodes: readonly string[];
};
/**
* Legt `anzahl` Tags ohne Zuordnung an.
*
* Die Rechtepruefung sitzt nicht hier, sondern in der Server Action und auf der
* Seite — wie in jedem Dienst dieses Projekts. Verlangt wird dort `verwaltung`:
* ein Stapel verbrennt N Codes unwiderruflich und ist eine Beschaffung, keine
* Geraetepflege. Aus demselben Grund verlangt auch das endgueltige Loeschen eines
* Geraets `verwaltung` (#011).
*
* Es gibt **keine** gemeinsame Transaktion ueber den Stapel. Die Vergabe eines
* `tagCode` ist je Tag eine eigene, und ein Abbruch in der Mitte laesst 30
* brauchbare Tags stehen statt 50 verlorene — Tags ohne Zuordnung stoeren
* niemanden, sie warten in der Liste auf ihren Aufkleber.
*/
export async function tagsErzeugen(
anzahl: number,
kundeIdAusEingabe: string | null | undefined,
benutzer: HandelnderBenutzer,
): Promise<TagStapel> {
if (!Number.isInteger(anzahl) || anzahl < 1) {
throw new TagsFehler("Die Anzahl muss eine ganze Zahl ab 1 sein.");
}
if (anzahl > TAGS_STAPEL_HOECHSTENS) {
throw new TagsFehler(
`Höchstens ${TAGS_STAPEL_HOECHSTENS} Tags auf einmal. Jeder davon verbraucht einen ` +
"Tag-Code für immer — lieber zweimal drucken als einmal zu viel.",
);
}
const tagCodes: string[] = [];
let kundeId: string | undefined;
for (let nummer = 0; nummer < anzahl; nummer += 1) {
// Nacheinander und nicht mit `Promise.all`: `geraetAnlegen` prueft vor dem
// Schreiben, ob der gezogene Code frei ist. Parallel gezogene Codes koennten
// sich gegenseitig ueberholen, und der Stapel schluege mit einem Indexfehler
// fehl, statt fertig zu werden.
const geraet = await geraetAnlegen({ registrieren: false, kundeId: kundeIdAusEingabe }, benutzer);
tagCodes.push(geraet.tagCode);
kundeId ??= geraet.kundeId;
}
// `anzahl >= 1` ist oben geprueft, die Schleife laeuft also mindestens einmal.
// Der Compiler weiss das nicht, und ein `!` waere eine Behauptung statt einer
// Pruefung.
if (kundeId === undefined) {
throw new TagsFehler("Es wurde kein Tag angelegt.");
}
return { kundeId, tagCodes };
}