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 }; }), ), });