Wartungsvorlagen mit Checklisten, Prueftermine aus einem naechtlichen Lauf, Prueffristen-Dashboard, Pruefwizard mit Foto und Unterschrift, unveraenderliche Protokolle als PDF, Maengel-Workflow, Rueckrufe und Lebensdauerueberwachung. Elf neue Tabellen, alle mit organisationId und Row Level Security. Die Unveraenderbarkeit abgeschlossener Protokolle setzt ein Datenbanktrigger durch, nicht der Anwendungscode: eine Korrektur erzeugt eine neue Pruefung mit Verweis auf die alte, beide bleiben lesbar. 79 neue Tests (47 Unit, 10 gegen eine echte Datenbank, 22 E2E). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
430 lines
14 KiB
TypeScript
430 lines
14 KiB
TypeScript
import { z } from "zod";
|
|
import { TRPCError } from "@trpc/server";
|
|
import { ChecklistenpunktTyp, Prisma } from "@prisma/client";
|
|
import { router, mitRecht } from "@/server/trpc";
|
|
import { AuditAktion, protokolliereIn } from "@/lib/audit";
|
|
import {
|
|
blaettern,
|
|
idEingabe,
|
|
listenAntwort,
|
|
listenEingabe,
|
|
ohneGeloeschte,
|
|
sortierenNach,
|
|
sucheUeber,
|
|
} from "@/server/routers/stammdaten/gemeinsam";
|
|
|
|
/**
|
|
* Wartungsvorlagen und ihre Checklisten.
|
|
*
|
|
* Die Vorlage ist die Regel — "Pressluftatmer, alle 12 Monate, DGUV 49" — und
|
|
* die Checklistenpunkte sind die Fragen, die dabei zu beantworten sind. Beides
|
|
* pflegt der Gerätewart, beides ist Voraussetzung für alles Weitere: ohne
|
|
* Vorlage gibt es keine Termine, ohne Punkte keine Prüfung.
|
|
*
|
|
* Gelöscht wird nur weich. Eine Vorlage hängt an abgeschlossenen Protokollen,
|
|
* und die müssen in zehn Jahren noch erklärbar sein.
|
|
*/
|
|
|
|
const SORTIERFELDER = ["name", "intervallMonate", "erstelltAm"] as const;
|
|
const SUCHFELDER = ["name", "pruefvorschrift", "beschreibung"];
|
|
|
|
const LISTEN_AUSWAHL = {
|
|
id: true,
|
|
name: true,
|
|
intervallMonate: true,
|
|
vorwarnzeitTage: true,
|
|
pruefvorschrift: true,
|
|
standardDauerMinuten: true,
|
|
standardExtern: true,
|
|
automatisch: true,
|
|
aktiv: true,
|
|
geloeschtAm: true,
|
|
geraetetyp: { select: { id: true, name: true, kategorie: true } },
|
|
_count: { select: { punkte: true, termine: true, pruefungen: true } },
|
|
} satisfies Prisma.WartungsvorlageSelect;
|
|
|
|
const vorlageFelder = z.object({
|
|
name: z.string().trim().min(1, "Die Bezeichnung fehlt.").max(160),
|
|
geraetetypId: z.string().uuid().nullish(),
|
|
intervallMonate: z
|
|
.number()
|
|
.int()
|
|
.min(1, "Ein Intervall von null Monaten ergibt keinen Termin.")
|
|
.max(600),
|
|
vorwarnzeitTage: z.number().int().min(0).max(365).default(30),
|
|
pruefvorschrift: z.string().trim().max(200).nullish(),
|
|
standardDauerMinuten: z.number().int().min(1).max(10_000).nullish(),
|
|
standardExtern: z.boolean().default(false),
|
|
automatisch: z.boolean().default(true),
|
|
beschreibung: z.string().trim().max(1000).nullish(),
|
|
aktiv: z.boolean().default(true),
|
|
});
|
|
|
|
const punktBasis = z.object({
|
|
text: z.string().trim().min(1, "Der Prüfpunkt braucht einen Text.").max(300),
|
|
typ: z.nativeEnum(ChecklistenpunktTyp).default(ChecklistenpunktTyp.JA_NEIN),
|
|
pflicht: z.boolean().default(true),
|
|
sollwert: z.string().trim().max(120).nullish(),
|
|
einheit: z.string().trim().max(20).nullish(),
|
|
minWert: z.number().nullish(),
|
|
maxWert: z.number().nullish(),
|
|
hinweis: z.string().trim().max(500).nullish(),
|
|
sortierung: z.number().int().min(0).max(9999).default(0),
|
|
});
|
|
|
|
/** Ein Bereich, dessen Untergrenze über der Obergrenze liegt, ist kein Bereich. */
|
|
function bereichSinnvoll(wert: { minWert?: number | null; maxWert?: number | null }): boolean {
|
|
if (wert.minWert === null || wert.minWert === undefined) return true;
|
|
if (wert.maxWert === null || wert.maxWert === undefined) return true;
|
|
return wert.minWert <= wert.maxWert;
|
|
}
|
|
|
|
const BEREICH_FEHLER = {
|
|
message: "Der kleinste zulässige Wert darf nicht über dem größten liegen.",
|
|
path: ["minWert"],
|
|
};
|
|
|
|
const punktFelder = punktBasis.refine(bereichSinnvoll, BEREICH_FEHLER);
|
|
const punktAenderung = punktBasis.partial().refine(bereichSinnvoll, BEREICH_FEHLER);
|
|
|
|
export const wartungsvorlageRouter = router({
|
|
liste: mitRecht("pruefung.lesen")
|
|
.input(
|
|
listenEingabe(SORTIERFELDER).extend({
|
|
geraetetypId: z.string().uuid().optional(),
|
|
nurAktive: z.boolean().default(false),
|
|
}),
|
|
)
|
|
.query(async ({ ctx, input }) =>
|
|
ctx.db(async (db) => {
|
|
const wo: Prisma.WartungsvorlageWhereInput = {
|
|
...ohneGeloeschte(input.mitGeloeschten),
|
|
...(input.geraetetypId ? { geraetetypId: input.geraetetypId } : {}),
|
|
...(input.nurAktive ? { aktiv: true } : {}),
|
|
...(sucheUeber(SUCHFELDER, input.suche) ?? {}),
|
|
};
|
|
|
|
const [eintraege, gesamt] = await Promise.all([
|
|
db.wartungsvorlage.findMany({
|
|
where: wo,
|
|
select: LISTEN_AUSWAHL,
|
|
orderBy: sortierenNach(input.sortierung, input.richtung, {
|
|
name: "asc",
|
|
}) as Prisma.WartungsvorlageOrderByWithRelationInput,
|
|
...blaettern(input),
|
|
}),
|
|
db.wartungsvorlage.count({ where: wo }),
|
|
]);
|
|
|
|
return listenAntwort(eintraege, gesamt, input);
|
|
}),
|
|
),
|
|
|
|
/** Eine Vorlage mit ihrer vollständigen Checkliste. */
|
|
einzeln: mitRecht("pruefung.lesen")
|
|
.input(idEingabe)
|
|
.query(async ({ ctx, input }) =>
|
|
ctx.db(async (db) => {
|
|
const vorlage = await db.wartungsvorlage.findUnique({
|
|
where: { id: input.id },
|
|
include: {
|
|
geraetetyp: { select: { id: true, name: true, kategorie: true } },
|
|
punkte: {
|
|
where: { geloeschtAm: null },
|
|
orderBy: [{ sortierung: "asc" }, { erstelltAm: "asc" }],
|
|
},
|
|
_count: { select: { termine: true, pruefungen: true } },
|
|
},
|
|
});
|
|
|
|
if (!vorlage) {
|
|
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Vorlage gibt es nicht." });
|
|
}
|
|
return vorlage;
|
|
}),
|
|
),
|
|
|
|
anlegen: mitRecht("pruefung.planen")
|
|
.input(vorlageFelder)
|
|
.mutation(async ({ ctx, input }) =>
|
|
ctx.db(async (db) => {
|
|
const vorlage = await db.wartungsvorlage.create({
|
|
data: { organisationId: ctx.sitzung.organisationId, ...input },
|
|
});
|
|
|
|
await protokolliereIn(db, {
|
|
organisationId: ctx.sitzung.organisationId,
|
|
aktion: AuditAktion.ERSTELLT,
|
|
entitaet: "Wartungsvorlage",
|
|
entitaetId: vorlage.id,
|
|
beschreibung: `Wartungsvorlage "${vorlage.name}" angelegt (alle ${vorlage.intervallMonate} Monate)`,
|
|
sitzung: ctx.sitzung,
|
|
nachher: vorlage,
|
|
ipAdresse: ctx.ipAdresse,
|
|
userAgent: ctx.userAgent,
|
|
});
|
|
|
|
return vorlage;
|
|
}),
|
|
),
|
|
|
|
aendern: mitRecht("pruefung.planen")
|
|
.input(vorlageFelder.partial().extend({ id: z.string().uuid() }))
|
|
.mutation(async ({ ctx, input }) =>
|
|
ctx.db(async (db) => {
|
|
const { id, ...aenderungen } = input;
|
|
|
|
const vorher = await db.wartungsvorlage.findUnique({ where: { id } });
|
|
if (!vorher) {
|
|
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Vorlage gibt es nicht." });
|
|
}
|
|
|
|
const nachher = await db.wartungsvorlage.update({ where: { id }, data: aenderungen });
|
|
|
|
await protokolliereIn(db, {
|
|
organisationId: ctx.sitzung.organisationId,
|
|
aktion: AuditAktion.GEAENDERT,
|
|
entitaet: "Wartungsvorlage",
|
|
entitaetId: id,
|
|
beschreibung: `Wartungsvorlage "${nachher.name}" geändert`,
|
|
sitzung: ctx.sitzung,
|
|
vorher,
|
|
nachher,
|
|
ipAdresse: ctx.ipAdresse,
|
|
userAgent: ctx.userAgent,
|
|
});
|
|
|
|
return nachher;
|
|
}),
|
|
),
|
|
|
|
/**
|
|
* Weiches Löschen. Offene Termine dieser Vorlage werden gleich mit auf
|
|
* "entfallen" gesetzt — sonst bliebe im Dashboard eine Fälligkeit stehen,
|
|
* zu der es keine Regel mehr gibt.
|
|
*/
|
|
loeschen: mitRecht("pruefung.planen")
|
|
.input(idEingabe)
|
|
.mutation(async ({ ctx, input }) =>
|
|
ctx.db(async (db) => {
|
|
const vorher = await db.wartungsvorlage.findUnique({ where: { id: input.id } });
|
|
if (!vorher || vorher.geloeschtAm) {
|
|
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Vorlage gibt es nicht." });
|
|
}
|
|
|
|
const nachher = await db.wartungsvorlage.update({
|
|
where: { id: input.id },
|
|
data: { geloeschtAm: new Date(), aktiv: false },
|
|
});
|
|
|
|
const entfallen = await db.wartungstermin.updateMany({
|
|
where: { vorlageId: input.id, status: "OFFEN", geloeschtAm: null },
|
|
data: { status: "ENTFALLEN" },
|
|
});
|
|
|
|
await protokolliereIn(db, {
|
|
organisationId: ctx.sitzung.organisationId,
|
|
aktion: AuditAktion.GELOESCHT,
|
|
entitaet: "Wartungsvorlage",
|
|
entitaetId: input.id,
|
|
beschreibung: `Wartungsvorlage "${vorher.name}" gelöscht, ${entfallen.count} offene Termine entfallen`,
|
|
sitzung: ctx.sitzung,
|
|
vorher,
|
|
nachher,
|
|
kontext: { entfalleneTermine: entfallen.count },
|
|
ipAdresse: ctx.ipAdresse,
|
|
userAgent: ctx.userAgent,
|
|
});
|
|
|
|
return { erfolgreich: true, entfalleneTermine: entfallen.count };
|
|
}),
|
|
),
|
|
|
|
wiederherstellen: mitRecht("pruefung.planen")
|
|
.input(idEingabe)
|
|
.mutation(async ({ ctx, input }) =>
|
|
ctx.db(async (db) => {
|
|
const vorher = await db.wartungsvorlage.findUnique({ where: { id: input.id } });
|
|
if (!vorher?.geloeschtAm) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "Diese Vorlage ist gar nicht gelöscht.",
|
|
});
|
|
}
|
|
|
|
const nachher = await db.wartungsvorlage.update({
|
|
where: { id: input.id },
|
|
data: { geloeschtAm: null, aktiv: true },
|
|
});
|
|
|
|
await protokolliereIn(db, {
|
|
organisationId: ctx.sitzung.organisationId,
|
|
aktion: AuditAktion.WIEDERHERGESTELLT,
|
|
entitaet: "Wartungsvorlage",
|
|
entitaetId: input.id,
|
|
beschreibung: `Wartungsvorlage "${nachher.name}" wiederhergestellt`,
|
|
sitzung: ctx.sitzung,
|
|
vorher,
|
|
nachher,
|
|
ipAdresse: ctx.ipAdresse,
|
|
userAgent: ctx.userAgent,
|
|
});
|
|
|
|
return nachher;
|
|
}),
|
|
),
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Checklistenpunkte
|
|
// ---------------------------------------------------------------------------
|
|
|
|
punktAnlegen: mitRecht("pruefung.planen")
|
|
.input(z.object({ vorlageId: z.string().uuid() }).and(punktFelder))
|
|
.mutation(async ({ ctx, input }) =>
|
|
ctx.db(async (db) => {
|
|
const { vorlageId, ...felder } = input;
|
|
|
|
const vorlage = await db.wartungsvorlage.findUnique({
|
|
where: { id: vorlageId },
|
|
select: { id: true, name: true },
|
|
});
|
|
if (!vorlage) {
|
|
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Vorlage gibt es nicht." });
|
|
}
|
|
|
|
// Neue Punkte hängen sich hinten an, wenn keine Sortierung angegeben ist.
|
|
const sortierung =
|
|
felder.sortierung > 0
|
|
? felder.sortierung
|
|
: ((
|
|
await db.checklistenpunkt.aggregate({
|
|
where: { vorlageId, geloeschtAm: null },
|
|
_max: { sortierung: true },
|
|
})
|
|
)._max.sortierung ?? 0) + 10;
|
|
|
|
const punkt = await db.checklistenpunkt.create({
|
|
data: {
|
|
organisationId: ctx.sitzung.organisationId,
|
|
vorlageId,
|
|
...felder,
|
|
sortierung,
|
|
},
|
|
});
|
|
|
|
await protokolliereIn(db, {
|
|
organisationId: ctx.sitzung.organisationId,
|
|
aktion: AuditAktion.ERSTELLT,
|
|
entitaet: "Checklistenpunkt",
|
|
entitaetId: punkt.id,
|
|
beschreibung: `Prüfpunkt "${punkt.text}" zu "${vorlage.name}" ergänzt`,
|
|
sitzung: ctx.sitzung,
|
|
nachher: punkt,
|
|
kontext: { vorlageId },
|
|
ipAdresse: ctx.ipAdresse,
|
|
userAgent: ctx.userAgent,
|
|
});
|
|
|
|
return punkt;
|
|
}),
|
|
),
|
|
|
|
punktAendern: mitRecht("pruefung.planen")
|
|
.input(z.object({ id: z.string().uuid() }).and(punktAenderung))
|
|
.mutation(async ({ ctx, input }) =>
|
|
ctx.db(async (db) => {
|
|
const { id, ...aenderungen } = input;
|
|
|
|
const vorher = await db.checklistenpunkt.findUnique({ where: { id } });
|
|
if (!vorher) {
|
|
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Prüfpunkt gibt es nicht." });
|
|
}
|
|
|
|
const nachher = await db.checklistenpunkt.update({ where: { id }, data: aenderungen });
|
|
|
|
await protokolliereIn(db, {
|
|
organisationId: ctx.sitzung.organisationId,
|
|
aktion: AuditAktion.GEAENDERT,
|
|
entitaet: "Checklistenpunkt",
|
|
entitaetId: id,
|
|
beschreibung: `Prüfpunkt "${nachher.text}" geändert`,
|
|
sitzung: ctx.sitzung,
|
|
vorher,
|
|
nachher,
|
|
ipAdresse: ctx.ipAdresse,
|
|
userAgent: ctx.userAgent,
|
|
});
|
|
|
|
return nachher;
|
|
}),
|
|
),
|
|
|
|
/**
|
|
* Weiches Löschen — ein Punkt kann in abgeschlossenen Protokollen stehen.
|
|
* Die Datenbank verhindert das harte Löschen ohnehin (Restrict auf
|
|
* pruefergebnis), aber die verständliche Fassung steht hier.
|
|
*/
|
|
punktLoeschen: mitRecht("pruefung.planen")
|
|
.input(idEingabe)
|
|
.mutation(async ({ ctx, input }) =>
|
|
ctx.db(async (db) => {
|
|
const vorher = await db.checklistenpunkt.findUnique({ where: { id: input.id } });
|
|
if (!vorher || vorher.geloeschtAm) {
|
|
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Prüfpunkt gibt es nicht." });
|
|
}
|
|
|
|
await db.checklistenpunkt.update({
|
|
where: { id: input.id },
|
|
data: { geloeschtAm: new Date() },
|
|
});
|
|
|
|
await protokolliereIn(db, {
|
|
organisationId: ctx.sitzung.organisationId,
|
|
aktion: AuditAktion.GELOESCHT,
|
|
entitaet: "Checklistenpunkt",
|
|
entitaetId: input.id,
|
|
beschreibung: `Prüfpunkt "${vorher.text}" entfernt`,
|
|
sitzung: ctx.sitzung,
|
|
vorher,
|
|
ipAdresse: ctx.ipAdresse,
|
|
userAgent: ctx.userAgent,
|
|
});
|
|
|
|
return { erfolgreich: true };
|
|
}),
|
|
),
|
|
|
|
/** Reihenfolge der Checkliste festlegen — die Prüfung läuft sie so ab. */
|
|
punkteSortieren: mitRecht("pruefung.planen")
|
|
.input(
|
|
z.object({
|
|
vorlageId: z.string().uuid(),
|
|
reihenfolge: z.array(z.string().uuid()).min(1).max(500),
|
|
}),
|
|
)
|
|
.mutation(async ({ ctx, input }) =>
|
|
ctx.db(async (db) => {
|
|
for (const [index, punktId] of input.reihenfolge.entries()) {
|
|
await db.checklistenpunkt.updateMany({
|
|
where: { id: punktId, vorlageId: input.vorlageId },
|
|
data: { sortierung: (index + 1) * 10 },
|
|
});
|
|
}
|
|
|
|
await protokolliereIn(db, {
|
|
organisationId: ctx.sitzung.organisationId,
|
|
aktion: AuditAktion.GEAENDERT,
|
|
entitaet: "Wartungsvorlage",
|
|
entitaetId: input.vorlageId,
|
|
beschreibung: `Reihenfolge der Checkliste geändert (${input.reihenfolge.length} Punkte)`,
|
|
sitzung: ctx.sitzung,
|
|
kontext: { reihenfolge: input.reihenfolge },
|
|
ipAdresse: ctx.ipAdresse,
|
|
userAgent: ctx.userAgent,
|
|
});
|
|
|
|
return { sortiert: input.reihenfolge.length };
|
|
}),
|
|
),
|
|
});
|