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

Laufzettel läuft jetzt in zwei Betriebsmodi, aber nicht in zwei Codepfaden. Jede
kundenbezogene Zeile trägt ohnehin schon eine Kunden-Zuordnung; im Modus INTERN
ist das die eine Organisation, die in der Oberfläche nirgends erscheint. Der
Wechsel ist deshalb eine Datenänderung und keine Migration — dieselbe Zeile
bleibt, nichts hängt um.

src/server/mandant.ts ist die eine Stelle, an der die Mandantenregel steht:
kundeErmitteln verwirft im Modus INTERN eine mitgeschickte kundeId, verlangt im
Modus DIENSTLEISTUNG eine Wahl und prüft, ob der Benutzer den Kunden überhaupt
sehen darf. Die Sichtbarkeit steckte zunächst in einer zweiten Funktion daneben,
an die jeder künftige Aufrufer selbst hätte denken müssen; da #009 diese Signatur
übernimmt, ist sie jetzt eingebaut.

Der unabhängige Review fand drei Blocker, alle behoben.

Die Lesemodelle wandten die Mandantensicht nicht an. kundenListe gab jedem alle
Kunden zurück, und sichtbareKundenIds hatte in der Produktion keinen einzigen
Aufrufer — ein gebundener Techniker hätte Namen, Notizen und Bestandszahlen
fremder Kunden gesehen. Die Funktionen verlangen jetzt den Benutzer und filtern
selbst, wie CLAUDE.md es verlangt.

Mein Kommentar "Alles in einer Transaktion" war falsch: der Protokolleintrag
stand dahinter. Schlug er fehl, war der Modus gewechselt und der Vorgang stand
nirgends. Und Prüfung und Vollzug lagen auseinander, sodass zwei gleichzeitige
Wechsel beide durchliefen und ein zwischendurch angelegter Kunde zu Modus INTERN
bei zwei Kunden führte. Beides reproduziert, beides geschlossen: alles in einer
Transaktion, mit pg_advisory_xact_lock als erster Anweisung.

Dazu sechs kleinere Befunde: ASCII-Faltung in Texten, die Menschen lesen (die
Regel gilt für Bezeichner, nicht für Fließtext — steht jetzt so in CLAUDE.md);
eine Erfolgsmeldung, die nach dem Wechsel unsichtbar blieb; eine Zusage im
Dialog, die der Code nicht durchsetzt; fehlende Fehlerbehandlung beim
Archivieren; ein Handy-Screenshot, der das Navigationsmenü statt des Dialogs
zeigte.

Der Wechsel wurde nicht nur im Service geprüft, sondern per Playwright durch die
Oberfläche gefahren: anmelden, Dialog aufklappen, bestätigen, und danach steht
die Navigation auf Kunden.

336 Tests.

Closes #8

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-25 07:01:46 +02:00
co-authored by Claude Opus 5
parent 40e95dbe5f
commit 8e67591912
43 changed files with 2729 additions and 53 deletions
+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}
</>
);
}