feat(mandant): Betriebsmodus, Kundenverwaltung und Einstellungen (#22)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

Zwei Betriebsmodi, ein Codepfad. src/server/mandant.ts ist die eine Stelle,
an der die Mandantenregel steht.

Der Review fand drei Blocker: die Lesemodelle wandten die Mandantensicht nicht
an, der Protokolleintrag des Moduswechsels stand außerhalb der Transaktion, und
Prüfung und Vollzug lagen so weit auseinander, dass zwei gleichzeitige Wechsel
beide durchliefen. Alle drei geschlossen, der letzte mit einem Advisory Lock.

336 Tests.

Closes #8

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #22.
This commit is contained in:
2026-09-25 05:03:11 +00:00
co-authored by Claude Opus 5
parent 40e95dbe5f
commit ecc3c579df
43 changed files with 2729 additions and 53 deletions
+43
View File
@@ -0,0 +1,43 @@
import Link from "next/link";
import { headers } from "next/headers";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
/**
* Der Verwaltungsbereich. Die Rechtepruefung steht hier **und** in jeder Seite
* darunter: Next rendert ein Layout bei Navigation innerhalb desselben Layouts
* nicht neu, es ist also das zweite Netz und nicht das erste (siehe CLAUDE.md).
*/
const REITER = [
{ pfad: "/admin", beschriftung: "Einstellungen" },
{ pfad: "/admin/benutzer", beschriftung: "Benutzer" },
] as const;
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
await sitzungErforderlich("verwaltung", "/admin");
const pfad = (await headers()).get("x-pfad") ?? "/admin";
return (
<>
<nav aria-label="Verwaltung" className="mb-8 flex flex-wrap gap-2">
{REITER.map((reiter) => {
const aktiv = pfad === reiter.pfad;
return (
<Link
key={reiter.pfad}
href={reiter.pfad}
aria-current={aktiv ? "page" : undefined}
className={`flex min-h-11 items-center rounded-pille px-4 text-sm transition-colors md:min-h-9 ${
aktiv
? "bg-akzent-flaeche font-semibold text-akzent"
: "border border-linie text-nebentext hover:bg-flaeche-hoch hover:text-text"
}`}
>
{reiter.beschriftung}
</Link>
);
})}
</nav>
{children}
</>
);
}
+90
View File
@@ -0,0 +1,90 @@
import { Betriebsmodus } from "@/generated/prisma/enums";
import { EinstellungsFormular } from "@/components/einstellungen-formular";
import { ModusWechsel } from "@/components/modus-wechsel";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { einstellungenHolen, modusWechselPruefen } from "@/server/services/einstellungen";
const MODUS_NAME: Record<Betriebsmodus, string> = {
INTERN: "Intern",
DIENSTLEISTUNG: "Dienstleistung",
};
const MODUS_ERKLAERUNG: Record<Betriebsmodus, string> = {
INTERN:
"Einsatz in einem Unternehmen für die eigenen Mitarbeiter. Ein Verzeichnis per LDAP, keine Kundenebene.",
DIENSTLEISTUNG:
"Einsatz als IT-Dienstleister für mehrere Kunden. Geräte, Personen und Abteilungen hängen an einem Kunden; kein LDAP zu Kunden-Verzeichnissen.",
};
export default async function EinstellungenSeite() {
await sitzungErforderlich("verwaltung", "/admin");
const einstellungen = await einstellungenHolen();
const gegenteil =
einstellungen.modus === Betriebsmodus.INTERN ? Betriebsmodus.DIENSTLEISTUNG : Betriebsmodus.INTERN;
const pruefung = await modusWechselPruefen(gegenteil);
return (
<>
<h1 className="text-2xl font-bold">Einstellungen</h1>
<section className="mt-8 rounded-karte border border-linie bg-flaeche p-5">
<div className="flex flex-wrap items-baseline gap-3">
<h2 className="text-lg font-semibold">Betriebsmodus</h2>
<span className="rounded-pille bg-akzent-flaeche px-3 py-1 text-xs font-semibold text-akzent">
{MODUS_NAME[einstellungen.modus]}
</span>
</div>
<p className="mt-2 mb-5 max-w-prose text-sm text-nebentext">
{MODUS_ERKLAERUNG[einstellungen.modus]}
</p>
<ModusWechsel
auskunft={{
zielModus: gegenteil,
zielName: MODUS_NAME[gegenteil],
moeglich: pruefung.moeglich,
...(pruefung.grund === undefined ? {} : { grund: pruefung.grund }),
brauchtAngaben: pruefung.brauchtAngaben,
folgen: pruefung.folgen,
vorschlagName: einstellungen.eigeneOrganisation.name,
vorschlagKuerzel: einstellungen.eigeneOrganisation.kuerzel,
}}
/>
</section>
<section className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
<EinstellungsFormular
werte={{
eigeneOrganisationName: einstellungen.eigeneOrganisation.name,
gesperrteOu: einstellungen.gesperrteOu ?? "",
regeln: {
upn: einstellungen.namensregeln.upn ?? "",
sam: einstellungen.namensregeln.sam ?? "",
mail: einstellungen.namensregeln.mail ?? "",
hostname: einstellungen.namensregeln.hostname ?? "",
},
kundenEbeneSichtbar: einstellungen.modus === Betriebsmodus.DIENSTLEISTUNG,
}}
/>
</section>
<section className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Adresse für die QR-Codes</h2>
<p className="mt-1 mb-3 max-w-prose text-sm text-nebentext">
Jeder gedruckte QR-Code enthält diese Adresse plus den Tag-Code. Sie kommt aus der Umgebung (
<code className="mono">APP_URL</code>) und lässt sich hier nicht ändern — gedruckte Aufkleber würden
sonst ins Leere zeigen.
</p>
<p className="mono rounded-karte border border-linie bg-grund px-3 py-2 text-sm">
{einstellungen.appUrl ? (
`${einstellungen.appUrl}/t/LZ-7K3F9Q`
) : (
<span className="text-faellig">
APP_URL ist nicht gesetzt — Etiketten können noch nicht gedruckt werden.
</span>
)}
</p>
</section>
</>
);
}
+105
View File
@@ -0,0 +1,105 @@
import { redirect } from "next/navigation";
import { KundeAnlegen } from "@/components/kunde-anlegen";
import { KundeBearbeiten } from "@/components/kunde-bearbeiten";
import { KundeArchivieren } from "@/components/kunde-archivieren";
import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import { kundenListe, type KundenZeile } from "@/server/queries/kunden";
/**
* Kundenliste – nur im Modus Dienstleistung.
*
* Im Modus Intern gibt es genau einen Kunden, und der erscheint nirgends. Die
* Seite leitet dann auf die Uebersicht, statt eine leere Liste zu zeigen.
*/
function Zaehlung({ zeile }: { zeile: KundenZeile }) {
const teile = [
[zeile.anzahlGeraete, "Gerät", "Geräte"],
[zeile.anzahlMitarbeiter, "Person", "Personen"],
[zeile.anzahlLaeufe, "Lauf", "Läufe"],
] as const;
return (
<p className="mt-1 text-xs text-nebentext">
{teile.map(([anzahl, eins, viele]) => `${anzahl} ${anzahl === 1 ? eins : viele}`).join(" · ")}
</p>
);
}
export default async function KundenSeite() {
const { benutzer } = await sitzungErforderlich("lesen", "/kunden");
if (!(await kundenEbeneSichtbar())) {
redirect("/");
}
const darfVerwalten = darf(benutzer.rolle, "verwaltung");
const zeilen = await kundenListe(benutzer, true);
return (
<>
<h1 className="text-2xl font-bold">Kunden</h1>
<p className="mt-2 max-w-prose text-nebentext">
Geräte, Personen, Abteilungen und Läufe hängen an einem Kunden. Kunden werden archiviert, nicht
gelöscht — sonst verlöre das Archiv seine Bezüge.
</p>
<ul className="mt-8 flex flex-col gap-3">
{zeilen.map((zeile) => (
<li key={zeile.id} className="rounded-karte border border-linie bg-flaeche">
<div className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="mono rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
{zeile.kuerzel}
</span>
<p className={zeile.archiviert ? "text-nebentext line-through" : "font-medium"}>
{zeile.name}
</p>
{zeile.istEigeneOrganisation ? (
<span className="rounded-pille bg-akzent-flaeche px-2.5 py-1 text-xs text-akzent">
eigene Organisation
</span>
) : null}
</div>
<Zaehlung zeile={zeile} />
{zeile.notizen ? (
<p className="mt-2 max-w-prose text-sm text-nebentext">{zeile.notizen}</p>
) : null}
</div>
{darfVerwalten && !zeile.istEigeneOrganisation ? (
<KundeArchivieren kundeId={zeile.id} archiviert={zeile.archiviert} />
) : null}
</div>
{darfVerwalten ? (
<details className="group">
<summary className="flex min-h-11 cursor-pointer list-none items-center px-4 text-sm text-nebentext hover:text-text">
Bearbeiten
</summary>
<KundeBearbeiten
kunde={{
id: zeile.id,
name: zeile.name,
kuerzel: zeile.kuerzel,
notizen: zeile.notizen,
}}
/>
</details>
) : null}
</li>
))}
</ul>
{darfVerwalten ? (
<section className="mt-8 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Kunde anlegen</h2>
<p className="mt-1 mb-5 max-w-prose text-sm text-nebentext">
Das Kürzel erscheint auf den Etiketten und lässt sich in der Hostname-Regel als{" "}
<code className="mono">{"{kundenkuerzel}"}</code> verwenden.
</p>
<KundeAnlegen />
</section>
) : null}
</>
);
}
+5 -1
View File
@@ -1,5 +1,6 @@
import { headers } from "next/headers";
import { Huelle } from "@/components/huelle";
import { betriebsmodus } from "@/server/mandant";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
/**
@@ -16,9 +17,12 @@ export default async function AppLayout({ children }: { children: React.ReactNod
// /laeufe?filter=offen kommt, soll auch dorthin zurueck und nicht auf /laeufe.
const ziel = pfad + (kopfzeilen.get("x-abfrage") ?? "");
const { benutzer } = await sitzungErforderlich(undefined, ziel);
// Der Modus steuert Navigation und Beschriftungen – aber nur ein- und
// ausgeblendete Eintraege, nicht zwei Codepfade.
const modus = await betriebsmodus();
return (
<Huelle aktiv={pfad} benutzer={{ name: benutzer.name, rolle: benutzer.rolle }}>
<Huelle aktiv={pfad} modus={modus} benutzer={{ name: benutzer.name, rolle: benutzer.rolle }}>
{children}
</Huelle>
);