feat(stammdaten): Mitarbeiter, Abteilungen und Geraete (#25)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #25.
This commit is contained in:
2026-09-25 10:52:44 +00:00
parent e3d80e5982
commit f85bc87324
81 changed files with 6784 additions and 59 deletions
+150
View File
@@ -0,0 +1,150 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "@/lib/validierung";
import { MandantFehler, type HandelnderBenutzer } from "@/server/mandant";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import {
AbteilungsFehler,
abteilungAnlegen,
abteilungArchivieren,
abteilungBearbeiten,
} from "@/server/services/abteilungen";
/**
* Server Actions der Abteilungen (#011).
*
* Recht ist `verwaltung`: an einer Abteilung haengen Ziel-OU und
* Standardgruppen — also das, was die spaeteren Befehle in ein Verzeichnis
* schreiben. Ein Tippfehler dort legt ein Konto an der falschen Stelle an.
*/
export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: 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.";
async function angemeldetOderMeldung(): Promise<
{ readonly benutzer: HandelnderBenutzer } | { readonly fehler: string }
> {
try {
const { benutzer } = await rechtErforderlich("verwaltung");
return { benutzer: { id: benutzer.id, rolle: benutzer.rolle } };
} catch (fehler) {
if (fehler instanceof RechteFehler) return { fehler: RECHTE_MELDUNG };
throw fehler;
}
}
function alsErgebnis(fehler: unknown, neutral: string): AktionsErgebnis {
if (fehler instanceof AbteilungsFehler || fehler instanceof MandantFehler) {
return { fehler: fehler.message };
}
console.error(`${neutral}:`, fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : fehler);
return { fehler: neutral };
}
const AbteilungsSchema = z.object({
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
zielOu: z.string().trim().optional(),
standardGruppen: z.string().trim().optional(),
mailDomain: z.string().trim().optional(),
notizen: z.string().trim().optional(),
kundeId: z.string().trim().optional(),
});
function ausFormular(formular: FormData) {
return AbteilungsSchema.safeParse({
name: formular.get("name"),
zielOu: formular.get("zielOu") ?? undefined,
standardGruppen: formular.get("standardGruppen") ?? undefined,
mailDomain: formular.get("mailDomain") ?? undefined,
notizen: formular.get("notizen") ?? undefined,
kundeId: formular.get("kundeId") ?? undefined,
});
}
/** Standardgruppen kommen als eine Zeile mit Kommas – so, wie sie im Befehl stehen. */
function gruppenZerlegen(roh: string | undefined): string[] {
return (roh ?? "")
.split(",")
.map((gruppe) => gruppe.trim())
.filter(Boolean);
}
export async function abteilungAnlegenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const eingabe = ausFormular(formular);
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
try {
const abteilung = await abteilungAnlegen(
{
...eingabe.data,
standardGruppen: gruppenZerlegen(eingabe.data.standardGruppen),
kundeId: eingabe.data.kundeId || null,
},
zugang.benutzer,
);
revalidatePath("/admin/abteilungen");
return { erfolg: `„${abteilung.name}“ wurde angelegt.` };
} catch (fehler) {
return alsErgebnis(fehler, "Die Abteilung konnte nicht angelegt werden.");
}
}
export async function abteilungBearbeitenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const abteilungId = z.string().min(1).safeParse(formular.get("abteilungId"));
if (!abteilungId.success) return { fehler: "Es fehlt die Abteilung." };
const eingabe = ausFormular(formular);
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
try {
await abteilungBearbeiten(
abteilungId.data,
{ ...eingabe.data, standardGruppen: gruppenZerlegen(eingabe.data.standardGruppen) },
zugang.benutzer,
);
revalidatePath("/admin/abteilungen");
return { erfolg: "Gespeichert." };
} catch (fehler) {
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
}
}
export async function abteilungArchivierenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const eingabe = z
.object({ abteilungId: z.string().min(1), archiviert: z.enum(["true", "false"]) })
.safeParse({ abteilungId: formular.get("abteilungId"), archiviert: formular.get("archiviert") });
if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." };
try {
await abteilungArchivieren(eingabe.data.abteilungId, eingabe.data.archiviert === "true", zugang.benutzer);
revalidatePath("/admin/abteilungen");
return {};
} catch (fehler) {
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
}
}
+289
View File
@@ -0,0 +1,289 @@
"use server";
import { revalidatePath } from "next/cache";
import { GeraeteTyp } from "@/generated/prisma/enums";
import { z } from "@/lib/validierung";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { MandantFehler, kundenEbeneSichtbar, type HandelnderBenutzer } from "@/server/mandant";
import {
GeraeteFehler,
geraeteImportieren,
geraetAnlegen,
geraetArchivieren,
geraetBearbeiten,
geraetLoeschen,
type ImportAbweisung,
} from "@/server/services/geraete";
/**
* Server Actions der Geraete (#011).
*
* Duenn: Recht pruefen, Zod anwenden, Dienst rufen. Das Recht ist
* `geraeteBearbeiten` — ADMIN, REDAKTEUR und TECHNIKER. Lesen darf jeder
* Angemeldete, das prueft die Seite selbst.
*/
export type AktionsErgebnis = {
readonly fehler?: string;
readonly erfolg?: string;
/** Nur beim CSV-Import: je abgewiesene Zeile eine Nummer und ein Grund. */
readonly abgewiesen?: readonly ImportAbweisung[];
};
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.";
/**
* Die Rechtepruefung so, dass ein abgelaufenes Cookie kein Datenverlust ist.
*
* `rechtErforderlich` wirft. Ungefangen landet der Wurf in Nexts Fehlergrenze,
* die Seite wird ausgehaengt — und bei einem Formular mit halb eingetippten
* Stammdaten oder einer eingelesenen CSV-Datei ist das genau die Arbeit, die weg
* ist. Befund aus dem Review von #010, geloest wie dort (siehe
* src/server/actions/vorlagen.ts).
*/
async function angemeldetOderMeldung(
recht: "geraeteBearbeiten" | "verwaltung" = "geraeteBearbeiten",
): Promise<{ readonly benutzer: HandelnderBenutzer } | { readonly fehler: string }> {
try {
const { benutzer } = await rechtErforderlich(recht);
// Die Dienste brauchen von der Sitzung nur Kennung und Rolle.
return { benutzer: { id: benutzer.id, rolle: benutzer.rolle } };
} catch (fehler) {
if (fehler instanceof RechteFehler) return { fehler: RECHTE_MELDUNG };
throw fehler;
}
}
/** 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) {
return { fehler: fehler.message };
}
if (fehler instanceof RechteFehler) {
return { fehler: RECHTE_MELDUNG };
}
console.error(`${neutral}:`, fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : fehler);
return { fehler: neutral };
}
const TypSchema = z.enum(GeraeteTyp);
const AnlegenSchema = z.object({
hostname: z.string().trim().optional(),
bezeichnung: z.string().trim().optional(),
seriennummer: z.string().trim().optional(),
inventarnummer: z.string().trim().optional(),
typ: TypSchema.optional(),
modell: z.string().trim().optional(),
notizen: z.string().trim().optional(),
mitarbeiterId: z.string().trim().optional(),
kundeId: z.string().trim().optional(),
});
/**
* Ein Feld, das das Formular nicht mitschickt, ist `undefined` und heisst „nicht
* angefasst“. Ein mitgeschicktes leeres Feld heisst „geleert“ — beim Bearbeiten
* macht das den Unterschied.
*/
function feld(formular: FormData, name: string): string | undefined {
const wert = formular.get(name);
return typeof wert === "string" ? wert : undefined;
}
export async function geraetAnlegenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const eingabe = AnlegenSchema.safeParse({
hostname: feld(formular, "hostname"),
bezeichnung: feld(formular, "bezeichnung"),
seriennummer: feld(formular, "seriennummer"),
inventarnummer: feld(formular, "inventarnummer"),
typ: feld(formular, "typ") || undefined,
modell: feld(formular, "modell"),
notizen: feld(formular, "notizen"),
mitarbeiterId: feld(formular, "mitarbeiterId"),
kundeId: feld(formular, "kundeId"),
});
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 geraet = await geraetAnlegen(
{
hostname: eingabe.data.hostname ?? null,
bezeichnung: eingabe.data.bezeichnung ?? null,
seriennummer: eingabe.data.seriennummer ?? null,
inventarnummer: eingabe.data.inventarnummer ?? null,
...(eingabe.data.typ ? { typ: eingabe.data.typ } : {}),
modell: eingabe.data.modell ?? null,
notizen: eingabe.data.notizen ?? null,
mitarbeiterId: eingabe.data.mitarbeiterId ?? null,
kundeId,
},
zugang.benutzer,
);
revalidatePath("/geraete");
return {
erfolg: `„${geraet.hostname ?? geraet.bezeichnung ?? geraet.tagCode}“ wurde angelegt, Tag-Code ${geraet.tagCode}.`,
};
} catch (fehler) {
return alsErgebnis(fehler, "Das Gerät konnte nicht angelegt werden.");
}
}
const BearbeitenSchema = AnlegenSchema.extend({
geraetId: z.string().min(1),
/** Kommt als verstecktes Feld mit, damit ein Aenderungsversuch auffaellt. */
tagCode: z.string().trim().optional(),
});
export async function geraetBearbeitenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const eingabe = BearbeitenSchema.safeParse({
geraetId: feld(formular, "geraetId"),
hostname: feld(formular, "hostname"),
bezeichnung: feld(formular, "bezeichnung"),
seriennummer: feld(formular, "seriennummer"),
inventarnummer: feld(formular, "inventarnummer"),
typ: feld(formular, "typ") || undefined,
modell: feld(formular, "modell"),
notizen: feld(formular, "notizen"),
mitarbeiterId: feld(formular, "mitarbeiterId"),
tagCode: feld(formular, "tagCode"),
});
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
try {
await geraetBearbeiten(
eingabe.data.geraetId,
{
// Ein Feld, das das Formular nicht mitschickt, bleibt unberuehrt; ein
// leeres Feld heisst „geleert“. Deshalb `?? null` und nicht `|| null`.
...(eingabe.data.hostname === undefined ? {} : { hostname: eingabe.data.hostname }),
...(eingabe.data.bezeichnung === undefined ? {} : { bezeichnung: eingabe.data.bezeichnung }),
...(eingabe.data.seriennummer === undefined ? {} : { seriennummer: eingabe.data.seriennummer }),
...(eingabe.data.inventarnummer === undefined ? {} : { inventarnummer: eingabe.data.inventarnummer }),
...(eingabe.data.typ === undefined ? {} : { typ: eingabe.data.typ }),
...(eingabe.data.modell === undefined ? {} : { modell: eingabe.data.modell }),
...(eingabe.data.notizen === undefined ? {} : { notizen: eingabe.data.notizen }),
...(eingabe.data.mitarbeiterId === undefined ? {} : { mitarbeiterId: eingabe.data.mitarbeiterId }),
/*
* Auch ein **leer** mitgeschickter tagCode ist ein Aenderungsversuch und
* gehoert vor den Dienst. Mit `? :` auf den Wahrheitswert fiel er weg,
* und damit lief genau das ins Leere, was `GeraeteAenderung.tagCode`
* ausdruecklich sichtbar machen soll.
*/
...(eingabe.data.tagCode === undefined ? {} : { tagCode: eingabe.data.tagCode }),
},
zugang.benutzer,
);
revalidatePath("/geraete");
return { erfolg: "Gespeichert." };
} catch (fehler) {
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
}
}
export async function geraetArchivierenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const eingabe = z
.object({ geraetId: z.string().min(1), archiviert: z.enum(["true", "false"]) })
.safeParse({ geraetId: feld(formular, "geraetId"), archiviert: feld(formular, "archiviert") });
if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." };
try {
await geraetArchivieren(eingabe.data.geraetId, eingabe.data.archiviert === "true", zugang.benutzer);
revalidatePath("/geraete");
return {};
} catch (fehler) {
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
}
}
/**
* Endgueltig loeschen — nur `verwaltung`.
*
* Archivieren ist der Normalfall und steht jedem offen, der Geraete pflegt.
* Loeschen dagegen ist nicht ruecknehmbar und verbrennt den Tag-Code fuer immer:
* er kommt nach der Regel des Projekts nie zurueck, der gedruckte Aufkleber ist
* damit wertlos. Gebraucht wird es nur fuer den Vertipper von eben — und dafuer
* darf man den Administrator fragen. Abteilungen und Mitarbeiter haben aus
* demselben Grund gar keinen Loeschweg.
*/
export async function geraetLoeschenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung("verwaltung");
if ("fehler" in zugang) return zugang;
const geraetId = z.string().min(1).safeParse(feld(formular, "geraetId"));
if (!geraetId.success) return { fehler: "Es fehlt das Gerät." };
try {
await geraetLoeschen(geraetId.data, zugang.benutzer);
revalidatePath("/geraete");
return { erfolg: "Gelöscht. Der Tag-Code bleibt vergeben." };
} catch (fehler) {
return alsErgebnis(fehler, "Das Gerät konnte nicht gelöscht werden.");
}
}
export async function geraeteImportierenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const eingabe = z
.object({
inhalt: z.string().min(1, "Bitte eine CSV-Datei auswählen oder den Inhalt einfügen."),
kundeId: z.string().trim().optional(),
})
.safeParse({ inhalt: feld(formular, "inhalt"), kundeId: feld(formular, "kundeId") });
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
const kundeId = (await kundenEbeneSichtbar()) ? eingabe.data.kundeId || null : null;
try {
const ergebnis = await geraeteImportieren(eingabe.data.inhalt, zugang.benutzer, kundeId);
revalidatePath("/geraete");
const teile = [
`${ergebnis.angelegt} von ${ergebnis.gesamt} ${ergebnis.gesamt === 1 ? "Zeile" : "Zeilen"} angelegt`,
];
if (ergebnis.abgewiesen.length > 0) {
teile.push(
`${ergebnis.abgewiesen.length} ${ergebnis.abgewiesen.length === 1 ? "Zeile" : "Zeilen"} abgewiesen`,
);
}
return { erfolg: `${teile.join(", ")}.`, abgewiesen: ergebnis.abgewiesen };
} catch (fehler) {
return alsErgebnis(fehler, "Die Datei konnte nicht eingelesen werden.");
}
}
+155
View File
@@ -0,0 +1,155 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "@/lib/validierung";
import { MandantFehler } from "@/server/mandant";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import {
MitarbeiterFehler,
mitarbeiterAktivSetzen,
mitarbeiterAnlegen,
mitarbeiterBearbeiten,
} from "@/server/services/mitarbeiter";
import type { HandelnderBenutzer } from "@/server/mandant";
/**
* Server Actions der Mitarbeiter (#011).
*
* Duenn: Recht pruefen, Zod anwenden, Dienst rufen. Das Recht ist
* `mitarbeiterBearbeiten` — dieselben Rollen wie bei den Geraeten, weil der
* Techniker beim Onboarding vor der Person steht, bevor der naechste
* Verzeichnisabgleich gelaufen ist.
*/
export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: 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.";
/**
* Faengt den Rechtefehler ab, statt ihn werfen zu lassen.
*
* Ein ungefangener Wurf landet in Nexts Fehlergrenze und haengt die Seite aus —
* samt allem, was im Formular stand. Nach zwoelf Stunden laeuft die Sitzung von
* selbst ab; das trifft sonst genau die Person, die gerade lange tippt (#010).
*/
async function angemeldetOderMeldung(): Promise<
{ readonly benutzer: HandelnderBenutzer } | { readonly fehler: string }
> {
try {
const { benutzer } = await rechtErforderlich("mitarbeiterBearbeiten");
return { benutzer: { id: benutzer.id, rolle: benutzer.rolle } };
} catch (fehler) {
if (fehler instanceof RechteFehler) return { fehler: RECHTE_MELDUNG };
throw fehler;
}
}
function alsErgebnis(fehler: unknown, neutral: string): AktionsErgebnis {
if (fehler instanceof MitarbeiterFehler || fehler instanceof MandantFehler) {
return { fehler: fehler.message };
}
console.error(`${neutral}:`, fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : fehler);
return { fehler: neutral };
}
const StammdatenSchema = z.object({
vorname: z.string().trim().min(1, "Bitte einen Vornamen angeben."),
nachname: z.string().trim().min(1, "Bitte einen Nachnamen angeben."),
anzeigename: z.string().trim().optional(),
mail: z.string().trim().optional(),
upn: z.string().trim().optional(),
sam: z.string().trim().optional(),
abteilung: z.string().trim().optional(),
titel: z.string().trim().optional(),
kundeId: z.string().trim().optional(),
});
function ausFormular(formular: FormData) {
return StammdatenSchema.safeParse({
vorname: formular.get("vorname"),
nachname: formular.get("nachname"),
anzeigename: formular.get("anzeigename") ?? undefined,
mail: formular.get("mail") ?? undefined,
upn: formular.get("upn") ?? undefined,
sam: formular.get("sam") ?? undefined,
abteilung: formular.get("abteilung") ?? undefined,
titel: formular.get("titel") ?? undefined,
kundeId: formular.get("kundeId") ?? undefined,
});
}
export async function mitarbeiterAnlegenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const eingabe = ausFormular(formular);
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
try {
const mitarbeiter = await mitarbeiterAnlegen(
{ ...eingabe.data, kundeId: eingabe.data.kundeId || null },
zugang.benutzer,
);
revalidatePath("/mitarbeiter");
return { erfolg: `„${mitarbeiter.anzeigename}“ wurde angelegt.` };
} catch (fehler) {
return alsErgebnis(fehler, "Die Person konnte nicht angelegt werden.");
}
}
export async function mitarbeiterBearbeitenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const mitarbeiterId = z.string().min(1).safeParse(formular.get("mitarbeiterId"));
if (!mitarbeiterId.success) return { fehler: "Es fehlt die Person." };
const eingabe = ausFormular(formular);
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
try {
await mitarbeiterBearbeiten(mitarbeiterId.data, eingabe.data, zugang.benutzer);
revalidatePath("/mitarbeiter");
revalidatePath(`/mitarbeiter/${mitarbeiterId.data}`);
return { erfolg: "Gespeichert." };
} catch (fehler) {
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
}
}
export async function mitarbeiterAktivSetzenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const eingabe = z
.object({ mitarbeiterId: z.string().min(1), aktiv: z.enum(["true", "false"]) })
.safeParse({ mitarbeiterId: formular.get("mitarbeiterId"), aktiv: formular.get("aktiv") });
if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." };
try {
const mitarbeiter = await mitarbeiterAktivSetzen(
eingabe.data.mitarbeiterId,
eingabe.data.aktiv === "true",
zugang.benutzer,
);
revalidatePath("/mitarbeiter");
revalidatePath(`/mitarbeiter/${eingabe.data.mitarbeiterId}`);
return { erfolg: mitarbeiter.aktiv ? "Wieder aktiv." : "Deaktiviert." };
} catch (fehler) {
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
}
}