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,130 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { einstellungenSpeichernAktion, type AktionsErgebnis } from "@/server/actions/einstellungen";
|
||||
|
||||
const FELD =
|
||||
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
|
||||
|
||||
export type EinstellungsWerte = {
|
||||
readonly eigeneOrganisationName: string;
|
||||
readonly gesperrteOu: string;
|
||||
readonly regeln: {
|
||||
readonly upn: string;
|
||||
readonly sam: string;
|
||||
readonly mail: string;
|
||||
readonly hostname: string;
|
||||
};
|
||||
/** Im Modus Dienstleistung heisst die Organisation in der Oberflaeche "Kunde". */
|
||||
readonly kundenEbeneSichtbar: boolean;
|
||||
};
|
||||
|
||||
const REGELN = [
|
||||
{
|
||||
name: "regel_upn",
|
||||
beschriftung: "UPN",
|
||||
schluessel: "upn",
|
||||
beispiel: "{vorname}.{nachname}@firma.de",
|
||||
},
|
||||
{ name: "regel_sam", beschriftung: "Anmeldename (sam)", schluessel: "sam", beispiel: "{v}{nachname}" },
|
||||
{ name: "regel_mail", beschriftung: "Mail", schluessel: "mail", beispiel: "{vorname}.{nachname}@firma.de" },
|
||||
{ name: "regel_hostname", beschriftung: "Hostname", schluessel: "hostname", beispiel: "PC-{nnn}" },
|
||||
] as const;
|
||||
|
||||
export function EinstellungsFormular({ werte }: { werte: EinstellungsWerte }) {
|
||||
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(
|
||||
einstellungenSpeichernAktion,
|
||||
{},
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={aktion} className="flex flex-col gap-8">
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{werte.kundenEbeneSichtbar ? "Eigene Organisation" : "Organisation"}
|
||||
</h2>
|
||||
<p className="mt-1 mb-4 max-w-prose text-sm text-nebentext">
|
||||
{werte.kundenEbeneSichtbar
|
||||
? "Der eigene Betrieb, der im Modus Dienstleistung als erster Kunde geführt wird."
|
||||
: "Im Modus Intern erscheint dieser Name nirgends in der Oberfläche. Er wird erst sichtbar, wenn du auf Dienstleistung umstellst."}
|
||||
</p>
|
||||
<label className="flex max-w-md flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Name</span>
|
||||
<input
|
||||
name="eigeneOrganisationName"
|
||||
defaultValue={werte.eigeneOrganisationName}
|
||||
required
|
||||
className={FELD}
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold">Namensregeln</h2>
|
||||
<p className="mt-1 mb-4 max-w-prose text-sm text-nebentext">
|
||||
Vorlagen für UPN, Anmeldename, Mail und Hostname. Beim Start eines Laufs werden daraus Vorschläge
|
||||
berechnet, die der Techniker überschreiben kann. Platzhalter:{" "}
|
||||
<code className="mono">{"{vorname}"}</code>, <code className="mono">{"{nachname}"}</code>,{" "}
|
||||
<code className="mono">{"{v}"}</code> für den ersten Buchstaben des Vornamens,{" "}
|
||||
<code className="mono">{"{nnn}"}</code> für eine fortlaufende Nummer
|
||||
{werte.kundenEbeneSichtbar ? (
|
||||
<>
|
||||
{" "}
|
||||
und <code className="mono">{"{kundenkuerzel}"}</code>
|
||||
</>
|
||||
) : null}
|
||||
.
|
||||
</p>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{REGELN.map((regel) => (
|
||||
<label key={regel.name} className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">{regel.beschriftung}</span>
|
||||
<input
|
||||
name={regel.name}
|
||||
defaultValue={werte.regeln[regel.schluessel]}
|
||||
placeholder={regel.beispiel}
|
||||
className={`${FELD} mono`}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold">Sperr-OU</h2>
|
||||
<p className="mt-1 mb-4 max-w-prose text-sm text-nebentext">
|
||||
Ziel-OU für deaktivierte Konten. Wird beim Offboarding vorbelegt.
|
||||
</p>
|
||||
<label className="flex max-w-lg flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Organisationseinheit</span>
|
||||
<input
|
||||
name="gesperrteOu"
|
||||
defaultValue={werte.gesperrteOu}
|
||||
placeholder="OU=Gesperrt,OU=Benutzer,DC=firma,DC=local"
|
||||
className={`${FELD} mono`}
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
{ergebnis.fehler ? (
|
||||
<p role="alert" className="text-sm text-fehler">
|
||||
{ergebnis.fehler}
|
||||
</p>
|
||||
) : null}
|
||||
{ergebnis.erfolg ? (
|
||||
<p role="status" className="text-sm text-erledigt">
|
||||
{ergebnis.erfolg}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={laeuft}
|
||||
className="h-11 self-start rounded-pille px-5 text-sm font-semibold"
|
||||
>
|
||||
{laeuft ? "Wird gespeichert …" : "Speichern"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -137,12 +137,16 @@ function BenutzerBlock({ benutzer }: { benutzer: AngemeldeterBenutzer }) {
|
||||
*/
|
||||
export function Huelle({
|
||||
children,
|
||||
modus = "INTERN",
|
||||
modus,
|
||||
aktiv = "/",
|
||||
benutzer,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
modus?: Betriebsmodus;
|
||||
/**
|
||||
* Pflicht, ohne stillen Standard: ein vergessener Parameter wuerde sonst die
|
||||
* Kundenebene ausblenden, statt aufzufallen.
|
||||
*/
|
||||
modus: Betriebsmodus;
|
||||
aktiv?: string;
|
||||
benutzer?: AngemeldeterBenutzer;
|
||||
}) {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { kundeAnlegenAktion, type AktionsErgebnis } from "@/server/actions/kunden";
|
||||
|
||||
const FELD =
|
||||
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
|
||||
|
||||
export function KundeAnlegen() {
|
||||
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(kundeAnlegenAktion, {});
|
||||
|
||||
return (
|
||||
<form action={aktion} className="flex flex-col gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-[1fr_10rem]">
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Name</span>
|
||||
<input name="name" required className={FELD} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Kürzel (2–6)</span>
|
||||
<input name="kuerzel" required pattern="[A-Za-z0-9]{2,6}" className={`${FELD} mono uppercase`} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Notizen (optional)</span>
|
||||
<textarea name="notizen" rows={2} className={`${FELD} py-2`} />
|
||||
</label>
|
||||
|
||||
{ergebnis.fehler ? (
|
||||
<p role="alert" className="text-sm text-fehler">
|
||||
{ergebnis.fehler}
|
||||
</p>
|
||||
) : null}
|
||||
{ergebnis.erfolg ? (
|
||||
<p role="status" className="text-sm text-erledigt">
|
||||
{ergebnis.erfolg}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={laeuft}
|
||||
className="h-11 self-start rounded-pille px-5 text-sm font-semibold"
|
||||
>
|
||||
{laeuft ? "Wird angelegt …" : "Kunde anlegen"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { kundeArchivierenAktion, type AktionsErgebnis } from "@/server/actions/kunden";
|
||||
|
||||
/**
|
||||
* Archivieren und Freigeben eines Kunden.
|
||||
*
|
||||
* Als Client-Komponente, damit die Begruendung sichtbar wird, wenn der Service
|
||||
* ablehnt — etwa beim Versuch, die eigene Organisation zu archivieren. Zuvor
|
||||
* endete das in einer Fehlerseite.
|
||||
*/
|
||||
export function KundeArchivieren({ kundeId, archiviert }: { kundeId: string; archiviert: boolean }) {
|
||||
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(kundeArchivierenAktion, {});
|
||||
|
||||
return (
|
||||
<div className="shrink-0">
|
||||
<form action={aktion}>
|
||||
<input type="hidden" name="id" value={kundeId} />
|
||||
<input type="hidden" name="archiviert" value={archiviert ? "false" : "true"} />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={laeuft}
|
||||
className={`min-h-11 rounded-pille px-3 text-sm md:min-h-9 ${
|
||||
archiviert
|
||||
? "bg-flaeche-hoch text-nebentext hover:text-text"
|
||||
: "border border-linie text-nebentext hover:bg-flaeche-hoch hover:text-text"
|
||||
}`}
|
||||
>
|
||||
{archiviert ? "wieder freigeben" : "archivieren"}
|
||||
</button>
|
||||
</form>
|
||||
{ergebnis.fehler ? (
|
||||
<p role="alert" className="mt-2 max-w-xs text-xs text-fehler">
|
||||
{ergebnis.fehler}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { kundeBearbeitenAktion, type AktionsErgebnis } from "@/server/actions/kunden";
|
||||
|
||||
const FELD =
|
||||
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
|
||||
|
||||
export function KundeBearbeiten({
|
||||
kunde,
|
||||
}: {
|
||||
kunde: {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly kuerzel: string;
|
||||
readonly notizen: string | null;
|
||||
};
|
||||
}) {
|
||||
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(kundeBearbeitenAktion, {});
|
||||
|
||||
return (
|
||||
<form action={aktion} className="flex flex-col gap-3 border-t border-linie p-4">
|
||||
<input type="hidden" name="id" value={kunde.id} />
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_10rem]">
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Name</span>
|
||||
<input name="name" defaultValue={kunde.name} required className={FELD} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Kürzel</span>
|
||||
<input
|
||||
name="kuerzel"
|
||||
defaultValue={kunde.kuerzel}
|
||||
required
|
||||
pattern="[A-Za-z0-9]{2,6}"
|
||||
className={`${FELD} mono uppercase`}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Notizen</span>
|
||||
<textarea name="notizen" defaultValue={kunde.notizen ?? ""} rows={2} className={`${FELD} py-2`} />
|
||||
</label>
|
||||
|
||||
{ergebnis.fehler ? (
|
||||
<p role="alert" className="text-sm text-fehler">
|
||||
{ergebnis.fehler}
|
||||
</p>
|
||||
) : null}
|
||||
{ergebnis.erfolg ? (
|
||||
<p role="status" className="text-sm text-erledigt">
|
||||
{ergebnis.erfolg}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={laeuft}
|
||||
variant="outline"
|
||||
className="h-11 self-start rounded-pille px-4 text-sm md:h-9"
|
||||
>
|
||||
{laeuft ? "Wird gespeichert …" : "Speichern"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { modusWechselnAktion, type AktionsErgebnis } from "@/server/actions/einstellungen";
|
||||
|
||||
const FELD =
|
||||
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
|
||||
|
||||
export type WechselAuskunft = {
|
||||
readonly zielModus: "INTERN" | "DIENSTLEISTUNG";
|
||||
readonly zielName: string;
|
||||
readonly moeglich: boolean;
|
||||
readonly grund?: string;
|
||||
readonly brauchtAngaben: boolean;
|
||||
readonly folgen: readonly string[];
|
||||
readonly vorschlagName: string;
|
||||
readonly vorschlagKuerzel: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Bestaetigungsdialog fuer den Moduswechsel.
|
||||
*
|
||||
* Die Folgen kommen aus derselben Pruefung, die der Wechsel danach durchsetzt –
|
||||
* keine zweite, nachgebaute Wahrheit. Aufgeklappt wird mit `details`, damit der
|
||||
* Dialog auch ohne JavaScript zugaenglich ist; das Haekchen ist Pflicht, weil
|
||||
* der Wechsel aendert, was die ganze Anwendung zeigt.
|
||||
*/
|
||||
export function ModusWechsel({ auskunft }: { auskunft: WechselAuskunft }) {
|
||||
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(modusWechselnAktion, {});
|
||||
|
||||
// Die Rueckmeldung steht ausserhalb der beiden Zweige: nach einem gelungenen
|
||||
// Wechsel zeigt die Seite die Gegenrichtung, und die ist meist gesperrt – der
|
||||
// Erfolgstext waere sonst genau dann verschwunden, wenn er gebraucht wird.
|
||||
const rueckmeldung = (
|
||||
<>
|
||||
{ergebnis.fehler ? (
|
||||
<p role="alert" className="text-sm text-fehler">
|
||||
{ergebnis.fehler}
|
||||
</p>
|
||||
) : null}
|
||||
{ergebnis.erfolg ? (
|
||||
<p role="status" className="text-sm text-erledigt">
|
||||
{ergebnis.erfolg}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
if (!auskunft.moeglich) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{rueckmeldung}
|
||||
<div className="rounded-karte border border-linie bg-flaeche-hoch p-4">
|
||||
<p className="text-sm text-nebentext">
|
||||
<span className="font-medium text-text">
|
||||
Wechsel zu {auskunft.zielName} gerade nicht möglich.
|
||||
</span>{" "}
|
||||
{auskunft.grund}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{ergebnis.erfolg || ergebnis.fehler ? rueckmeldung : null}
|
||||
<details className="group rounded-karte border border-linie bg-flaeche-hoch">
|
||||
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 px-4 py-3 text-sm font-medium">
|
||||
<AlertTriangle className="size-[18px] text-faellig" strokeWidth={1.75} aria-hidden="true" />
|
||||
Zu {auskunft.zielName} wechseln
|
||||
<span className="ml-auto text-xs text-nebentext group-open:hidden">aufklappen</span>
|
||||
</summary>
|
||||
|
||||
<form action={aktion} className="flex flex-col gap-4 border-t border-linie p-4">
|
||||
<input type="hidden" name="ziel" value={auskunft.zielModus} />
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium">Das passiert:</p>
|
||||
<ul className="mt-2 flex list-disc flex-col gap-1 pl-5 text-sm text-nebentext">
|
||||
{auskunft.folgen.map((folge) => (
|
||||
<li key={folge}>{folge}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{auskunft.brauchtAngaben ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Name als erster Kunde</span>
|
||||
<input name="name" defaultValue={auskunft.vorschlagName} required className={FELD} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Kürzel (2–6 Zeichen)</span>
|
||||
<input
|
||||
name="kuerzel"
|
||||
defaultValue={auskunft.vorschlagKuerzel}
|
||||
required
|
||||
pattern="[A-Za-z0-9]{2,6}"
|
||||
className={`${FELD} mono uppercase`}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<label className="flex items-start gap-3 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="bestaetigt"
|
||||
value="ja"
|
||||
required
|
||||
className="mt-1 size-4 accent-[var(--color-akzent)]"
|
||||
/>
|
||||
<span>Ja, den Betriebsmodus jetzt auf {auskunft.zielName} umstellen.</span>
|
||||
</label>
|
||||
|
||||
{rueckmeldung}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={laeuft}
|
||||
className="h-11 self-start rounded-pille px-5 text-sm font-semibold"
|
||||
>
|
||||
{laeuft ? "Wird umgestellt …" : `Auf ${auskunft.zielName} umstellen`}
|
||||
</Button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user