feat(mandant): Betriebsmodus, Kundenverwaltung und Einstellungen (#22)
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:
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user