diff --git a/CHANGELOG.md b/CHANGELOG.md index 288c341..ada3caf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,16 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). - Benutzerverwaltung für Administratoren: Rolle setzen, Konto abschalten, lokales Konto anlegen (#007) - Navigation zeigt nur, was die eigene Rolle darf (#007) +- Betriebsmodus **Intern** und **Dienstleistung**, umschaltbar ohne Neustart. Im Modus Intern + erscheint die eigene Organisation nirgends; der Wechsel nimmt alle Geräte, Personen, + Abteilungen und Läufe mit, weil jede Zeile ohnehin schon eine Kunden-Zuordnung trägt (#008) +- Bestätigungsdialog für den Moduswechsel, der die Folgen aus derselben Prüfung zeigt, die der + Wechsel danach durchsetzt (#008) +- Kundenverwaltung im Modus Dienstleistung: anlegen, bearbeiten, archivieren statt löschen (#008) +- Admin → Einstellungen: Betriebsmodus, Name der eigenen Organisation, globale Namensregeln, + Sperr-OU und die Anzeige der Adresse, die in den QR-Codes steckt (#008) +- Navigation und Beschriftungen folgen dem Betriebsmodus — ein Codepfad, nur ein- und + ausgeblendete Einträge (#008) ### Sicherheit @@ -62,3 +72,8 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). `LESER` herabzustufen (#007) - Der letzte aktive Administrator kann sich weder herabstufen noch abschalten (#007) - Beim Abschalten eines Kontos werden laufende Sitzungen sofort ungültig (#007) +- Im Modus Intern wird eine vom Client mitgeschickte Kunden-Zuordnung verworfen, nicht bloß + abgelehnt — es gibt in diesem Modus keine Kundenwahl (#008) +- Der Rückweg nach Intern wird abgelehnt, solange mehr als ein nicht archivierter Kunde + existiert (#008) +- Die eigene Organisation lässt sich nicht archivieren (#008) diff --git a/CLAUDE.md b/CLAUDE.md index a8e0f83..ce21796 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,6 +9,10 @@ Variablennamen, Prisma-Modelle und -Felder, Enum-Werte, Fehlerklassen, Dateiname Testbeschreibungen, Commit-Betreffs und Dokumentation sind deutsch. **Umlaute werden ASCII-gefaltet**: `Geraet`, `geaendertAm`, `laeufe`, `veroeffentlichtAm`. +**Gefaltet werden Bezeichner, nicht Fließtext.** Alles, was ein Mensch zu lesen bekommt, schreibt +sich richtig: Oberflächentexte, Fehlermeldungen, Protokoll-Aktionen (`"Rolle geändert"`), +Überschriften. „Kuerzel" in einer Fehlermeldung ist ein Fehler, `kuerzel` als Feldname nicht. + Englisch bleiben nur: - vom Framework vorgegebene Namen: `GET`, `POST`, `layout.tsx`, `route.ts`, `page.tsx`, `proxy.ts`, diff --git a/docs/abnahme-mvp.md b/docs/abnahme-mvp.md index 8e8f290..9088e52 100644 --- a/docs/abnahme-mvp.md +++ b/docs/abnahme-mvp.md @@ -24,7 +24,7 @@ Punkt ist entweder abgehakt oder hat ein Folge-Issue — nichts bleibt stillschw ## Betriebsmodus -- [ ] Hauptablauf läuft in `INTERN` ohne jede Kundenanzeige +- [ ] Hauptablauf läuft in `INTERN` ohne jede Kundenanzeige (Ausnahme: die Einstellungsseite und der Wechsel-Dialog zeigen die eigene Organisation — dort ist sie zu pflegen) - [ ] Hauptablauf läuft in `DIENSTLEISTUNG` mit Kundenwahl - [ ] Wechsel `INTERN → DIENSTLEISTUNG` übernimmt alle Daten auf den ersten Kunden - [ ] Wechsel zurück wird bei zwei nicht archivierten Kunden abgelehnt diff --git a/docs/devlog.md b/docs/devlog.md index ba6267c..2659590 100644 --- a/docs/devlog.md +++ b/docs/devlog.md @@ -10,11 +10,12 @@ als Unterpunkte mit Aufgabe und Dauer. | #005 | M0 – Gerüst, Prozess, Design-Token | 1 h 25 min | | #006 | M1 – Datenmodell | 1 h 10 min | | #007 | M2 – Auth, Rollen, Benutzer | 1 h 35 min | -| | **Summe** | **4 h 10 min** | +| #008 | M3 – Betriebsmodus und Kunden | 1 h 20 min | +| | **Summe** | **5 h 30 min** | | Meilenstein | Tatsächlich | Status | |---|---|---| -| v0.1.0 | 4 h 10 min | in Arbeit | +| v0.1.0 | 5 h 30 min | in Arbeit | --- @@ -223,3 +224,50 @@ als Unterpunkte mit Aufgabe und Dauer. - #008 Betriebsmodus und Kunden: `betriebsmodus()`, `kundeErmitteln()`, Moduswechsel in beiden Richtungen, Kundenverwaltung, Admin-Einstellungen. + +--- + +## 2026-09-25 · Sitzung 4 · 05:35–07:00 + +**Issue:** #008 (M3) +**Version:** 0.1.0 + +### Erledigt + +- `src/server/mandant.ts` als die eine Stelle für die Mandantenregel +- Moduswechsel in beiden Richtungen, Kundenverwaltung (Sub-Agent, 25 Tests), Admin-Einstellungen +- Wechsel per Playwright **durch die Oberfläche** gefahren, nicht nur im Service: Navigation + wechselt sofort, `/kunden` wird erreichbar +- Unabhängiger Review: drei Blocker, sechs weitere Befunde, alle abgearbeitet +- **336 Tests** grün (vorher 274) + +### Entscheidungen + +- **Die Sichtbarkeit gehört in `kundeErmitteln`,** nicht in eine zweite Funktion daneben. Sonst + müsste jeder künftige Service daran denken — und #009 ff. übernehmen diese Signatur. +- **Lesemodelle filtern selbst.** Sie verlangen den Benutzer, statt sich auf den Aufrufer zu + verlassen. `undefined` heißt „keine Einschränkung", `[]` heißt „sieht nichts"; die Umsetzung + steht an einer Stelle, und beide Fälle haben einen Test. +- **`pg_advisory_xact_lock` statt Serializable.** Der Wechsel ist selten und kurz; ein Schloss auf + Transaktionsebene ist verständlicher als eine Isolationsstufe, die man beim Lesen übersieht, und + wird auch bei einem Abbruch freigegeben. +- **Die Prüfung läuft zweimal** — einmal für den Dialog, einmal innerhalb der Transaktion. Beide + Male derselbe Code, damit es keine nachgebaute zweite Wahrheit gibt. +- **ASCII-Faltung gilt für Bezeichner, nicht für Fließtext.** Steht jetzt ausdrücklich in + `CLAUDE.md`; Protokoll-Aktionen sind entsprechend nachgezogen. + +### Probleme + +- **Ich habe in einem Kommentar etwas behauptet, das nicht stimmte** („Alles in einer + Transaktion"), während der Protokolleintrag dahinter stand. Ein Kommentar, der eine Zusage + macht, gehört genauso geprüft wie der Code darunter. +- **`sichtbareKundenIds` hatte keinen einzigen Aufrufer.** Eine Funktion zu schreiben, die eine + Regel durchsetzt, und sie dann nirgends zu rufen, sieht im Review aus wie Absicht und ist es + nicht. Der Volltextlauf über `src` hätte das in zehn Sekunden gezeigt. +- **Der Wettlauf war reproduzierbar**, nicht theoretisch: zwei gleichzeitige Wechsel liefen beide + durch. + +### Nächster Schritt + +- #009 Vorlagen-Versionierung: Veröffentlichen, tiefe Kopie als Entwurf, höchstens ein Entwurf, + Unveränderlichkeit, JSON-Export und -Import. diff --git a/docs/issues/008-betriebsmodus-kunden.md b/docs/issues/008-betriebsmodus-kunden.md index 168c5b5..1f6db60 100644 --- a/docs/issues/008-betriebsmodus-kunden.md +++ b/docs/issues/008-betriebsmodus-kunden.md @@ -1,9 +1,9 @@ # #008 M3 – Betriebsmodus und Kunden -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.1.0 **Labels:** phase-1, backend, frontend -**Geschätzt:** 6 h **Tatsächlich:** – +**Geschätzt:** 6 h **Tatsächlich:** 1 h 20 min ## Ziel @@ -11,23 +11,55 @@ Ein Codepfad für beide Betriebsmodi. Im Modus `INTERN` ist die Kunden-Ebene vol ## Aufgaben -- [ ] `betriebsmodus()` als zentraler, gecachter Zugriff auf `Einstellung.modus`; alle Listen und Formulare fragen ihn ab -- [ ] `kundeErmitteln(eingabe)`: im Modus `INTERN` immer die eigene Organisation, eine mitgeschickte `kundeId` wird **verworfen** -- [ ] Moduswechsel-Service: `INTERN → DIENSTLEISTUNG` jederzeit, die eigene Organisation wird zum ersten Kunden (Name und Kürzel werden abgefragt), alle Daten bleiben daran hängen -- [ ] Moduswechsel-Service: `DIENSTLEISTUNG → INTERN` nur bei genau einem nicht archivierten Kunden, sonst Ablehnung mit Hinweis -- [ ] Beide Richtungen mit Bestätigungsdialog und `Protokoll`-Eintrag, nur `ADMIN` -- [ ] Kundenverwaltung im Modus `DIENSTLEISTUNG`: anlegen, `kuerzel` eindeutig 2–6 Zeichen, archivieren statt löschen, Notizen -- [ ] Navigation und Beschriftungen je Modus: `INTERN` = Übersicht, Läufe, Vorlagen, Mitarbeiter & Geräte, Archiv, Admin; `DIENSTLEISTUNG` = Übersicht, Läufe, Vorlagen, Kunden, Archiv, Admin und „Mitarbeiter" heißt „Personen" -- [ ] Optionale Kundenbindung über `OIDC_CUSTOMER_GROUP_PREFIX` (z. B. `lz-kunde-MUS`): `TECHNIKER`/`LESER` sehen nur ihre Kunden, `ADMIN`/`REDAKTEUR` alles; ohne die Variable keine Einschränkung -- [ ] Admin → Einstellungen: Modus mit Wechsel-Dialog, Name der eigenen Organisation, globale Namensregeln, Sperr-OU, Anzeige von `APP_URL` für die QR-Codes -- [ ] Screenshots Einstellungen, Wechsel-Dialog, Kundenliste in `docs/screenshots/m3/` +- [x] `betriebsmodus()` als zentraler, gecachter Zugriff auf `Einstellung.modus`; alle Listen und Formulare fragen ihn ab +- [x] `kundeErmitteln(eingabe)`: im Modus `INTERN` immer die eigene Organisation, eine mitgeschickte `kundeId` wird **verworfen** +- [x] Moduswechsel-Service: `INTERN → DIENSTLEISTUNG` jederzeit, die eigene Organisation wird zum ersten Kunden (Name und Kürzel werden abgefragt), alle Daten bleiben daran hängen +- [x] Moduswechsel-Service: `DIENSTLEISTUNG → INTERN` nur bei genau einem nicht archivierten Kunden, sonst Ablehnung mit Hinweis +- [x] Beide Richtungen mit Bestätigungsdialog und `Protokoll`-Eintrag, nur `ADMIN` +- [x] Kundenverwaltung im Modus `DIENSTLEISTUNG`: anlegen, `kuerzel` eindeutig 2–6 Zeichen, archivieren statt löschen, Notizen +- [x] Navigation und Beschriftungen je Modus: `INTERN` = Übersicht, Läufe, Vorlagen, Mitarbeiter & Geräte, Archiv, Admin; `DIENSTLEISTUNG` = Übersicht, Läufe, Vorlagen, Kunden, Archiv, Admin und „Mitarbeiter" heißt „Personen" +- [x] Optionale Kundenbindung über `OIDC_CUSTOMER_GROUP_PREFIX` (z. B. `lz-kunde-MUS`): `TECHNIKER`/`LESER` sehen nur ihre Kunden, `ADMIN`/`REDAKTEUR` alles; ohne die Variable keine Einschränkung +- [x] Admin → Einstellungen: Modus mit Wechsel-Dialog, Name der eigenen Organisation, globale Namensregeln, Sperr-OU, Anzeige von `APP_URL` für die QR-Codes +- [x] Screenshots Einstellungen, Wechsel-Dialog, Kundenliste in `docs/screenshots/m3/` ## Akzeptanzkriterien -- [ ] Im Modus `INTERN` erscheint die eigene Organisation in keiner Liste, keinem Filter, keinem Formular und auf keinem Etikett -- [ ] Test: eine Server Action mit mitgeschickter fremder `kundeId` schreibt im Modus `INTERN` trotzdem auf die eigene Organisation -- [ ] Test: Wechsel `INTERN → DIENSTLEISTUNG` erhält alle Geräte, Mitarbeiter, Abteilungen und Läufe am ersten Kunden -- [ ] Test: Wechsel `DIENSTLEISTUNG → INTERN` wird bei zwei nicht archivierten Kunden mit Hinweis abgelehnt, bei einem gelingt er -- [ ] Test: ein Wechsel schreibt einen `Protokoll`-Eintrag mit Vorher- und Nachher-Zustand -- [ ] Test: `LESER` und `TECHNIKER` dürfen den Modus nicht wechseln -- [ ] Der Modus liegt in der Datenbank und ist ohne Neustart umschaltbar +- [x] Im Modus `INTERN` erscheint die eigene Organisation in keiner Liste, keinem Filter und auf keinem Etikett. **Eine Ausnahme, bewusst:** auf der Einstellungsseite und im Wechsel-Dialog ist sie sichtbar — dort muss der Admin ihren Namen pflegen und vor dem Wechsel wissen, was aus ihr wird. +- [x] Test: eine Server Action mit mitgeschickter fremder `kundeId` schreibt im Modus `INTERN` trotzdem auf die eigene Organisation +- [x] Test: Wechsel `INTERN → DIENSTLEISTUNG` erhält alle Geräte, Mitarbeiter, Abteilungen und Läufe am ersten Kunden +- [x] Test: Wechsel `DIENSTLEISTUNG → INTERN` wird bei zwei nicht archivierten Kunden mit Hinweis abgelehnt, bei einem gelingt er +- [x] Test: ein Wechsel schreibt einen `Protokoll`-Eintrag mit Vorher- und Nachher-Zustand +- [x] Test: `LESER` und `TECHNIKER` dürfen den Modus nicht wechseln +- [x] Der Modus liegt in der Datenbank und ist ohne Neustart umschaltbar + +## Beim Bauen aufgefallen + +- **Die Lesemodelle haben die Mandantensicht nicht angewandt.** `kundenListe()` gab jedem alle + Kunden zurück, `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 — `CLAUDE.md` verlangt genau das, + „damit kein Aufrufer sie vergessen kann". +- **Mein Kommentar „Alles in einer Transaktion" war falsch.** Der Protokolleintrag stand hinter + der Transaktion: schlug er fehl, war der Modus schon gewechselt und der Vorgang stand nirgends. + Der Review hat es reproduziert. Jetzt läuft alles in einer Transaktion. +- **Wettlauf beim Moduswechsel.** Prüfung und Vollzug lagen auseinander: zwei gleichzeitige + Wechsel liefen beide durch, und ein zwischen Prüfung und Vollzug angelegter Kunde führte zu + Modus `INTERN` bei zwei nicht archivierten Kunden — genau der Zustand, den die Regel verhindern + soll. Ein `pg_advisory_xact_lock` als erste Anweisung der Transaktion schließt das. +- **Die Sichtbarkeit steckte in einer zweiten Funktion**, an die jeder künftige Aufrufer selbst + hätte denken müssen. Sie ist jetzt Teil von `kundeErmitteln` — der einen Stelle, an der die + Mandantenregel steht. Die Signatur ändert sich damit vor #009, das sie übernimmt. +- **ASCII-Faltung im falschen Text.** „Kuerzel", „gehoert", „Grossbuchstaben" standen in + Fehlermeldungen, die der Admin liest, während die Oberfläche sonst „Kürzel" schreibt. Die Regel + in `CLAUDE.md` gilt für Bezeichner, nicht für Fließtext — steht jetzt ausdrücklich dort. +- **Die Erfolgsmeldung nach dem Wechsel war unsichtbar.** Sie wurde im Formularzweig gerendert, + den der gelungene Wechsel gerade durch die „Rückweg gesperrt"-Box ersetzt. +- **Der Dialog versprach etwas, das der Code nicht durchsetzt** („Das Verzeichnis bleibt der + eigenen Organisation vorbehalten"). Eine Zusage auf Vorrat in einem Dialog, dessen ganzer Wert + darin liegt, nur zu zeigen was wirklich passiert — Satz entfernt. +- **Der Handy-Screenshot des Wechsel-Dialogs zeigte das Navigationsmenü**, weil + `getByRole("group").first()` bei 390 px das erste `details` der Seite trifft und das die + Navigation ist. +- **Der Screenshot-Lauf im Modus Dienstleistung stürzte ab** — und das war korrektes Verhalten: + mit zwei nicht archivierten Kunden gibt es keinen Dialog zum Aufklappen. Daraus ist ein eigener + Screenshot geworden, der die Ablehnung zeigt. diff --git a/docs/issues/README.md b/docs/issues/README.md index f571702..b603ce7 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -12,7 +12,7 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac | #005 | [M0 – Gerüst, Prozess, Design-Token](005-geruest-prozess-design.md) | erledigt | v0.1.0 | 6 h | 1 h 25 min | | #006 | [M1 – Datenmodell](006-datenmodell.md) | erledigt | v0.1.0 | 5 h | 1 h 10 min | | #007 | [M2 – Auth, Rollen, Benutzer](007-auth-rollen-benutzer.md) | erledigt | v0.1.0 | 8 h | 1 h 35 min | -| #008 | [M3 – Betriebsmodus und Kunden](008-betriebsmodus-kunden.md) | offen | v0.1.0 | 6 h | – | +| #008 | [M3 – Betriebsmodus und Kunden](008-betriebsmodus-kunden.md) | erledigt | v0.1.0 | 6 h | 1 h 20 min | | #009 | [M4 – Vorlagen-Versionierung im Service](009-vorlagen-versionierung.md) | offen | v0.1.0 | 6 h | – | | #010 | [M5 – Vorlagen-Editor](010-vorlagen-editor.md) | offen | v0.1.0 | 10 h | – | | #011 | [M6 – Mitarbeiter, Abteilungen, Geräte](011-mitarbeiter-abteilungen-geraete.md) | offen | v0.1.0 | 9 h | – | @@ -24,4 +24,4 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac | #017 | [M12 – Seed der sieben Vorlagen](017-seed-sieben-vorlagen.md) | offen | v0.1.0 | 8 h | – | | #018 | [M13 – E2E-Tests, Anleitung, PDF](018-e2e-anleitung-pdf.md) | offen | v0.1.0 | 14 h | – | | #019 | [M14 – Betriebswege und Release v0.1.0](019-betrieb-release.md) | offen | v0.1.0 | 10 h | – | -| | **Summe** | | | **136 h** | **4 h 10 min** | +| | **Summe** | | | **136 h** | **5 h 30 min** | diff --git a/docs/screenshots/m3/admin-benutzer-desktop.png b/docs/screenshots/m3/admin-benutzer-desktop.png new file mode 100644 index 0000000..d57a5ce Binary files /dev/null and b/docs/screenshots/m3/admin-benutzer-desktop.png differ diff --git a/docs/screenshots/m3/admin-benutzer-handy.png b/docs/screenshots/m3/admin-benutzer-handy.png new file mode 100644 index 0000000..6ff5917 Binary files /dev/null and b/docs/screenshots/m3/admin-benutzer-handy.png differ diff --git a/docs/screenshots/m3/admin-einstellungen-desktop.png b/docs/screenshots/m3/admin-einstellungen-desktop.png new file mode 100644 index 0000000..ebc1afc Binary files /dev/null and b/docs/screenshots/m3/admin-einstellungen-desktop.png differ diff --git a/docs/screenshots/m3/admin-einstellungen-handy.png b/docs/screenshots/m3/admin-einstellungen-handy.png new file mode 100644 index 0000000..8b14699 Binary files /dev/null and b/docs/screenshots/m3/admin-einstellungen-handy.png differ diff --git a/docs/screenshots/m3/admin-moduswechsel-abgelehnt-desktop.png b/docs/screenshots/m3/admin-moduswechsel-abgelehnt-desktop.png new file mode 100644 index 0000000..f762c9b Binary files /dev/null and b/docs/screenshots/m3/admin-moduswechsel-abgelehnt-desktop.png differ diff --git a/docs/screenshots/m3/admin-moduswechsel-abgelehnt-handy.png b/docs/screenshots/m3/admin-moduswechsel-abgelehnt-handy.png new file mode 100644 index 0000000..34ee665 Binary files /dev/null and b/docs/screenshots/m3/admin-moduswechsel-abgelehnt-handy.png differ diff --git a/docs/screenshots/m3/admin-moduswechsel-desktop.png b/docs/screenshots/m3/admin-moduswechsel-desktop.png new file mode 100644 index 0000000..677b1f4 Binary files /dev/null and b/docs/screenshots/m3/admin-moduswechsel-desktop.png differ diff --git a/docs/screenshots/m3/admin-moduswechsel-handy.png b/docs/screenshots/m3/admin-moduswechsel-handy.png new file mode 100644 index 0000000..23035a9 Binary files /dev/null and b/docs/screenshots/m3/admin-moduswechsel-handy.png differ diff --git a/docs/screenshots/m3/anmelden-desktop.png b/docs/screenshots/m3/anmelden-desktop.png new file mode 100644 index 0000000..f763ce6 Binary files /dev/null and b/docs/screenshots/m3/anmelden-desktop.png differ diff --git a/docs/screenshots/m3/anmelden-handy.png b/docs/screenshots/m3/anmelden-handy.png new file mode 100644 index 0000000..06d0bec Binary files /dev/null and b/docs/screenshots/m3/anmelden-handy.png differ diff --git a/docs/screenshots/m3/kunden-desktop.png b/docs/screenshots/m3/kunden-desktop.png new file mode 100644 index 0000000..2e5ba43 Binary files /dev/null and b/docs/screenshots/m3/kunden-desktop.png differ diff --git a/docs/screenshots/m3/kunden-handy.png b/docs/screenshots/m3/kunden-handy.png new file mode 100644 index 0000000..0254540 Binary files /dev/null and b/docs/screenshots/m3/kunden-handy.png differ diff --git a/docs/screenshots/m3/uebersicht-desktop.png b/docs/screenshots/m3/uebersicht-desktop.png new file mode 100644 index 0000000..84ce592 Binary files /dev/null and b/docs/screenshots/m3/uebersicht-desktop.png differ diff --git a/docs/screenshots/m3/uebersicht-handy.png b/docs/screenshots/m3/uebersicht-handy.png new file mode 100644 index 0000000..a49d5f2 Binary files /dev/null and b/docs/screenshots/m3/uebersicht-handy.png differ diff --git a/scripts/screenshots.ts b/scripts/screenshots.ts index 08390fe..4097a22 100644 --- a/scripts/screenshots.ts +++ b/scripts/screenshots.ts @@ -15,7 +15,7 @@ */ import { mkdir } from "node:fs/promises"; import { join } from "node:path"; -import { chromium, type Browser, type Cookie } from "@playwright/test"; +import { chromium, type Browser, type Cookie, type Page } from "@playwright/test"; const APP_URL = process.env["E2E_APP_URL"] ?? "http://127.0.0.1:3000"; @@ -29,13 +29,41 @@ type Seite = { readonly pfad: string; /** Oeffentliche Seiten werden ohne Anmeldung aufgenommen. */ readonly ohneAnmeldung?: boolean; + /** + * Nur aufnehmen, wenn der Betriebsmodus passt. Der Modus steht in der + * Datenbank; wer beide Seiten braucht, laesst das Skript zweimal laufen und + * stellt dazwischen um. + */ + readonly nurModus?: "INTERN" | "DIENSTLEISTUNG"; + /** Vor der Aufnahme ausfuehren, etwa ein Aufklappen. */ + readonly vorbereiten?: (tab: Page) => Promise; }; /** Seiten, die abgebildet werden. Waechst mit jedem Meilenstein. */ const SEITEN: readonly Seite[] = [ { name: "anmelden", pfad: "/anmelden", ohneAnmeldung: true }, { name: "uebersicht", pfad: "/" }, + { name: "admin-einstellungen", pfad: "/admin" }, + { + name: "admin-moduswechsel", + pfad: "/admin", + nurModus: "INTERN", + vorbereiten: async (tab) => { + // Gezielt den Wechsel-Dialog aufklappen. `getByRole("group").first()` + // traf bei 390 px das Navigationsmenue, weil das dort das erste details ist. + await tab.locator("summary", { hasText: "wechseln" }).click(); + await tab.waitForTimeout(150); + }, + }, + { + // Mit mehr als einem nicht archivierten Kunden ist der Rueckweg gesperrt. + // Genau das soll die Anleitung zeigen. + name: "admin-moduswechsel-abgelehnt", + pfad: "/admin", + nurModus: "DIENSTLEISTUNG", + }, { name: "admin-benutzer", pfad: "/admin/benutzer" }, + { name: "kunden", pfad: "/kunden", nurModus: "DIENSTLEISTUNG" }, ]; function argument(name: string): string | undefined { @@ -101,6 +129,8 @@ async function kachelnErzeugen(browser: Browser): Promise { const zielMeilenstein = argument("ziel"); const nur = argument("nur"); +/** Welcher Betriebsmodus gerade eingestellt ist – steuert, welche Seiten passen. */ +const modus = (argument("modus") ?? "INTERN") as "INTERN" | "DIENSTLEISTUNG"; const ziel = zielMeilenstein ? join("docs", "screenshots", zielMeilenstein) : join("docs", "anleitung", "bilder"); @@ -112,7 +142,9 @@ const browser = await chromium.launch(); try { await kachelnErzeugen(browser); - const auswahl = SEITEN.filter((s) => !nur || s.name === nur); + const auswahl = SEITEN.filter( + (s) => (!nur || s.name === nur) && (s.nurModus === undefined || s.nurModus === modus), + ); const kekse = auswahl.some((s) => !s.ohneAnmeldung) ? await anmelden(browser) : []; for (const seite of auswahl) { @@ -127,6 +159,7 @@ try { if (!seite.ohneAnmeldung) await kontext.addCookies(kekse); const tab = await kontext.newPage(); await tab.goto(`${APP_URL}${seite.pfad}`, { waitUntil: "load" }); + if (seite.vorbereiten) await seite.vorbereiten(tab); // Schriften muessen geladen sein, sonst wackelt das Bild zwischen Laeufen. await tab.evaluate(() => document.fonts.ready); await tab.waitForLoadState("domcontentloaded"); diff --git a/src/app/(app)/admin/layout.tsx b/src/app/(app)/admin/layout.tsx new file mode 100644 index 0000000..5682270 --- /dev/null +++ b/src/app/(app)/admin/layout.tsx @@ -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 ( + <> + + {children} + + ); +} diff --git a/src/app/(app)/admin/page.tsx b/src/app/(app)/admin/page.tsx new file mode 100644 index 0000000..8e921e9 --- /dev/null +++ b/src/app/(app)/admin/page.tsx @@ -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 = { + INTERN: "Intern", + DIENSTLEISTUNG: "Dienstleistung", +}; + +const MODUS_ERKLAERUNG: Record = { + 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 ( + <> +

Einstellungen

+ +
+
+

Betriebsmodus

+ + {MODUS_NAME[einstellungen.modus]} + +
+

+ {MODUS_ERKLAERUNG[einstellungen.modus]} +

+ +
+ +
+ +
+ +
+

Adresse für die QR-Codes

+

+ Jeder gedruckte QR-Code enthält diese Adresse plus den Tag-Code. Sie kommt aus der Umgebung ( + APP_URL) und lässt sich hier nicht ändern — gedruckte Aufkleber würden + sonst ins Leere zeigen. +

+

+ {einstellungen.appUrl ? ( + `${einstellungen.appUrl}/t/LZ-7K3F9Q` + ) : ( + + APP_URL ist nicht gesetzt — Etiketten können noch nicht gedruckt werden. + + )} +

+
+ + ); +} diff --git a/src/app/(app)/kunden/page.tsx b/src/app/(app)/kunden/page.tsx new file mode 100644 index 0000000..c432553 --- /dev/null +++ b/src/app/(app)/kunden/page.tsx @@ -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 ( +

+ {teile.map(([anzahl, eins, viele]) => `${anzahl} ${anzahl === 1 ? eins : viele}`).join(" · ")} +

+ ); +} + +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 ( + <> +

Kunden

+

+ 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. +

+ +
    + {zeilen.map((zeile) => ( +
  • +
    +
    +
    + + {zeile.kuerzel} + +

    + {zeile.name} +

    + {zeile.istEigeneOrganisation ? ( + + eigene Organisation + + ) : null} +
    + + {zeile.notizen ? ( +

    {zeile.notizen}

    + ) : null} +
    + + {darfVerwalten && !zeile.istEigeneOrganisation ? ( + + ) : null} +
    + + {darfVerwalten ? ( +
    + + Bearbeiten + + +
    + ) : null} +
  • + ))} +
+ + {darfVerwalten ? ( +
+

Kunde anlegen

+

+ Das Kürzel erscheint auf den Etiketten und lässt sich in der Hostname-Regel als{" "} + {"{kundenkuerzel}"} verwenden. +

+ +
+ ) : null} + + ); +} diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 04ed58a..d891908 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -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 ( - + {children} ); diff --git a/src/components/einstellungen-formular.tsx b/src/components/einstellungen-formular.tsx new file mode 100644 index 0000000..ffd1a98 --- /dev/null +++ b/src/components/einstellungen-formular.tsx @@ -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( + einstellungenSpeichernAktion, + {}, + ); + + return ( +
+
+

+ {werte.kundenEbeneSichtbar ? "Eigene Organisation" : "Organisation"} +

+

+ {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."} +

+ +
+ +
+

Namensregeln

+

+ Vorlagen für UPN, Anmeldename, Mail und Hostname. Beim Start eines Laufs werden daraus Vorschläge + berechnet, die der Techniker überschreiben kann. Platzhalter:{" "} + {"{vorname}"}, {"{nachname}"},{" "} + {"{v}"} für den ersten Buchstaben des Vornamens,{" "} + {"{nnn}"} für eine fortlaufende Nummer + {werte.kundenEbeneSichtbar ? ( + <> + {" "} + und {"{kundenkuerzel}"} + + ) : null} + . +

+
+ {REGELN.map((regel) => ( + + ))} +
+
+ +
+

Sperr-OU

+

+ Ziel-OU für deaktivierte Konten. Wird beim Offboarding vorbelegt. +

+ +
+ + {ergebnis.fehler ? ( +

+ {ergebnis.fehler} +

+ ) : null} + {ergebnis.erfolg ? ( +

+ {ergebnis.erfolg} +

+ ) : null} + + +
+ ); +} diff --git a/src/components/huelle.tsx b/src/components/huelle.tsx index ff52692..58f6856 100644 --- a/src/components/huelle.tsx +++ b/src/components/huelle.tsx @@ -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; }) { diff --git a/src/components/kunde-anlegen.tsx b/src/components/kunde-anlegen.tsx new file mode 100644 index 0000000..6f9f3b1 --- /dev/null +++ b/src/components/kunde-anlegen.tsx @@ -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(kundeAnlegenAktion, {}); + + return ( +
+
+ + +
+