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

Der Einstieg, fuer den die Anwendung gebaut ist: scannen, sehen was zu tun ist,
loslegen. `/t/<tagCode>` kennt drei Faelle — registriertes Geraet, vorgedruckter
Tag, unbekannter Code —, die Geraeteseite zeigt Zuweisungen, Schnellstart und
Historie, und beides ist am Handy bedienbar.

Im QR steht ausschliesslich `{APP_URL}/t/{tagCode}`: kein Name, keine
Seriennummer, kein Besitzer. Ein Aufkleber haengt jahrelang sichtbar am Geraet
und wird von jedem Telefon gelesen; was darauf steht, ist veroeffentlicht. Die
Masse sind gemessen, nicht behauptet: ein echtes PDF liefert 62 x 29 mm und A4,
und ein bei 300 dpi gerastertes Etikett wird mit demselben Decoder
zurueckgelesen, den auch der Scanner benutzt.

Drei Defekte zeigte erst der gebaute Stand, alle an den Naehten zwischen den
parallel gebauten Teilen: „Starten“ setzte IN_ARBEIT und sprang nicht, „Vorlage
zuweisen“ bot Vorlagen an, die der Dienst danach ablehnte, und die Geraeteliste
verlinkte nichts. Der unabhaengige Review fand drei Blocker — darunter, dass der
Scanner nie die native BarcodeDetector-API benutzte und dass ein Unterpfad in
APP_URL aus dem gedruckten QR herausfiel.

Closes #14

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