feat(stammdaten): Mitarbeiter, Abteilungen und Geraete (#25)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #25.
This commit is contained in:
2026-09-25 10:52:44 +00:00
parent e3d80e5982
commit f85bc87324
81 changed files with 6784 additions and 59 deletions
+22
View File
@@ -70,6 +70,20 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
- Vorschau der Vorlage mit Beispielwerten, so wie ein Lauf sie zeigen wird (#010)
- Versionsgeschichte je Vorlage: wer wann mit welcher Notiz veröffentlicht hat und wie viele Läufe
an einer Version hängen (#010)
- Mitarbeiterliste mit Suche über Anzeigename, Mail, UPN und sAMAccountName, Filter nach Abteilung;
standardmäßig nur aktive Personen, mit Umschalter auch deaktivierte (#011)
- Mitarbeiterseite mit Stammdaten, zugeordneten Geräten und Platz für die Laufhistorie; Personen
werden deaktiviert statt gelöscht (#011)
- Abteilungen unter Admin: Ziel-OU, Standardgruppen und Mail-Domain — daraus entstehen später die
Befehle, die ein Konto anlegen. Abteilungsnamen, die bei Personen vorkommen, aber nicht gepflegt
sind, werden eigens ausgewiesen (#011)
- Geräteliste mit Suche über Hostname, Bezeichnung, Seriennummer und Tag-Code, Filter nach Typ;
Geräte anlegen, bearbeiten, archivieren (#011)
- CSV-Import für Geräte: je Zeile ein Ergebnis, eine fehlerhafte Zeile bricht den Import nicht ab,
sondern wird mit Zeilennummer und Grund gemeldet. Der Zerleger kommt mit Semikolon, BOM, CRLF,
Anführungszeichen und Zeilenumbrüchen im Wert zurecht (#011)
- Die Seitenleiste führt Personen und Geräte unter einem Eintrag; ein Umschalter wechselt zwischen
beiden Listen (#011)
### Sicherheit
@@ -118,3 +132,11 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
im Editor wäre sonst jede ungespeicherte Arbeit verloren gewesen (#010)
- Eine archivierte Kundenzuordnung bleibt beim Speichern der Kopfdaten erhalten, statt sich
stillschweigend in „für alle Kunden“ zu verwandeln (#010)
- Die Stammdaten einer Person aus dem Verzeichnis lassen sich nicht von Hand überschreiben — der
nächste Abgleich würde die Änderung ohnehin zurückdrehen, und ein Wert, der zwischen zwei Läufen
hin- und herspringt, ist schlimmer als ein falscher. Deaktivieren bleibt möglich (#011)
- Ein Gerät kann nur einer Person desselben Kunden gehören; der Dienst sagt es verständlich, bevor
der zusammengesetzte Fremdschlüssel zuschlägt (#011)
- Ein Gerät, an dem Läufe hängen, lässt sich nicht löschen, nur archivieren (#011)
- Der Tag-Code entsteht beim Anlegen, steht sofort im Vergabebuch und lässt sich nie ändern — beides
in einer Transaktion, damit kein Code ohne Eintrag entsteht (#011)
+4 -1
View File
@@ -87,7 +87,10 @@ Dreizeiler: Recht prüfen, Zod-Schema anwenden, Service rufen.
bleiben.
- **`tagCode`:** wird nie geändert und nie wiederverwendet, auch nicht nach Archivierung.
- **Löschen:** Geräte, Abteilungen und Vorlagen werden archiviert. Mitarbeiter werden nur
`aktiv = false`. Nichts wird gelöscht, woran ein Lauf hängt.
`aktiv = false`. Nichts wird gelöscht, woran ein Lauf hängt. Genau **eine** Ausnahme gibt es: ein
Gerät ohne Läufe lässt sich endgültig löschen, weil der Vertipper von eben sonst für immer in der
Liste stünde. Das verlangt `verwaltung` — der Tag-Code ist danach für immer verbrannt, und der
gedruckte Aufkleber wertlos.
- **`{{generiertes_passwort}}`:** wird je Anzeige neu erzeugt und **nie** gespeichert — nicht in
`Schrittergebnis`, `Kommentar`, `Protokoll` oder der Druckansicht.
- **Erweiterbarkeit:** Kein Codepfad nimmt an, dass ein Schritt nur von einem Menschen erledigt
+58 -2
View File
@@ -13,11 +13,12 @@ als Unterpunkte mit Aufgabe und Dauer.
| #008 | M3 – Betriebsmodus und Kunden | 1 h 20 min |
| #009 | M4 – Vorlagen-Versionierung im Service | 1 h 20 min |
| #010 | M5 – Vorlagen-Editor | 2 h 10 min |
| | **Summe** | **9 h** |
| #011 | M6 – Mitarbeiter, Abteilungen, Geräte | 2 h 15 min |
| | **Summe** | **11 h 15 min** |
| Meilenstein | Tatsächlich | Status |
|---|---|---|
| v0.1.0 | 9 h | in Arbeit |
| v0.1.0 | 11 h 15 min | in Arbeit |
---
@@ -396,3 +397,58 @@ als Unterpunkte mit Aufgabe und Dauer.
- #011 Mitarbeiter, Abteilungen und Geräte: Listen mit Suche, Mitarbeiterseite mit Historie,
Abteilungen mit Ziel-OU und Standardgruppen, Geräte mit CSV-Import.
---
## 2026-09-25 · Sitzung 7 · 10:30–12:55
**Issue:** #011 (M6)
**Version:** 0.1.0
### Erledigt
- Mitarbeiter: Dienst mit Sperre für abgeglichene Stammdaten, Liste mit Suche über vier Kennungen,
Personenseite mit Geräten, Vorgesetztem und Platz für die Laufhistorie
- Abteilungen unter Admin: Ziel-OU, Standardgruppen, Mail-Domain, dazu die Liste der
Abteilungsnamen, die bei Personen vorkommen, aber nicht gepflegt sind
- Geräte samt CSV-Import (Sub-Agent): eigener Zerleger in `src/lib/csv.ts`, Tag-Code-Vergabe in
einer Transaktion, Archivieren statt Löschen
- Neues Recht `mitarbeiterBearbeiten`; die Seitenleiste führt Personen und Geräte unter einem
Eintrag, im Modus Dienstleistung anders beschriftet
- Unabhängiger Review: zwei Blocker, sieben weitere Befunde, alle abgearbeitet
- **610 Tests** grün (vorher 601 vor dem Review), dazu 18 E2E-Abläufe
### Entscheidungen
- **Ein CSV-Wert reicht nicht über die Zeile hinaus.** Das Format erlaubt es, und `csvErzeugen`
schreibt es auch so — beim **Lesen** einer fremden Datei ist es trotzdem die schlechtere Annahme:
ein Umbruch in einem Hostnamen kommt nicht vor, ein vergessenes schließendes Anführungszeichen
ständig. Mit der Grenze endet der Schaden an der Zeile, in der er beginnt; ohne sie verschluckt
eine kaputte Zelle alle Folgezeilen, und der Import meldet dafür Erfolg.
- **Eine abgeglichene Person ist vollständig gesperrt**, nicht nur teilweise. Alle Stammdaten
kommen aus dem Verzeichnis, auch der Titel. Übrig bleibt genau ein Weg: aktivieren und
deaktivieren — und der muss bleiben, weil das Offboarding nicht auf den nächsten Abgleich wartet.
- **Löschen eines Geräts verlangt `verwaltung`.** Archivieren ist der Normalfall. Löschen ist nicht
rücknehmbar und verbrennt den Tag-Code für immer — der gedruckte Aufkleber ist danach wertlos.
- **Die Lesemodelle liefern den Kundennamen immer, die Seite entscheidet über die Anzeige.**
Vorher entschied das eine Lesemodell nach `istEigeneOrganisation` und das andere gar nicht.
- **Die Mitarbeitersuche ist ein eigenes Bauteil**, bevor sie zum zweiten Mal gebraucht wird: #013
und #014 brauchen sie wieder, und dreimal abgeschrieben vergisst man den Umschalter „auch
deaktivierte“ zweimal.
### Probleme
- **Der schwerste Fehler war wieder einer, der Erfolg meldet.** Der CSV-Import hat Zeilen
verschluckt und „3 von 3 angelegt“ gesagt. Gefunden hat ihn der Review mit einer Datei, wie sie
aus einem halb ausgefüllten Excel kommt — meine eigenen Tests prüften nur saubere Dateien und die
eine Ausnahme, die ohnehin geworfen wurde.
- **Zwei Funktionen im selben Modul widersprachen sich**: `abteilungsListe` schlüsselte nach Kunde
und Name, `ungepflegteAbteilungen` nach Namen allein. Nebeneinander geschrieben, nicht
nebeneinander gelesen.
- **Vierter Meilenstein in Folge mit einem Kommentar, der eine Zusage macht, die der Code nicht
hält.** Diesmal versprach er zwei verschiedene Meldungen, gerendert wurde eine.
### Nächster Schritt
- #012 Platzhalter, Namensregeln und Escaping: die reine Service-Schicht, aus der die fertigen
PowerShell-Befehle entstehen — mit dichter Testabdeckung, denn hier darf nichts schiefgehen.
@@ -1,9 +1,9 @@
# #011 M6 – Mitarbeiter, Abteilungen, Geräte
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.1.0
**Labels:** phase-1, backend, frontend
**Geschätzt:** 9 h **Tatsächlich:** –
**Geschätzt:** 9 h **Tatsächlich:** 2 h 15 min
## Ziel
@@ -11,23 +11,69 @@ Die Stammdaten, an denen die Historie hängt. Mitarbeiter und Geräte werden nie
## Aufgaben
- [ ] Mitarbeiterliste: Suche über Anzeigename, Mail, UPN und `sam`, Filter nach Abteilung und Aktiv-Status
- [ ] Mitarbeitersuche als wiederverwendbare Komponente: standardmäßig nur aktive, Umschalter „auch deaktivierte“ fürs Offboarding
- [ ] Mitarbeiterseite: Stammdaten, Abteilung, Vorgesetzter, seine Geräte, alle Läufe chronologisch (Onboarding, Übergaben, Offboarding)
- [ ] Mitarbeiter manuell anlegen und bearbeiten (`herkunft = MANUELL`); synchronisierte Felder sind bei `herkunft = ABGLEICH` gesperrt
- [ ] Deaktivieren statt löschen (`aktiv = false`)
- [ ] Abteilungen: Name (eindeutig je Kunde), Ziel-OU, Standardgruppen (`String[]`), Mail-Domain, Notizen
- [ ] Geräte: Stammdaten nach Vorgabe, Pflichtregel „`hostname` oder `bezeichnung` muss gesetzt sein“, Besitzer optional
- [ ] Geräteliste mit Suche über Hostname, Bezeichnung, Seriennummer und `tagCode`, Filter nach Typ
- [ ] CSV-Import für Geräte (`hostname`, `label`, `serialNumber`, `inventoryNumber`, `type`, `model`), `tagCode` wird beim Import erzeugt; eigener `src/lib/csv.ts`
- [ ] Archivieren statt Löschen, sobald Läufe an einem Gerät oder einer Abteilung hängen
- [ ] Screenshots Mitarbeiterliste, Mitarbeiterseite, Geräteliste, Abteilungen in `docs/screenshots/m6/`
- [x] Mitarbeiterliste: Suche über Anzeigename, Mail, UPN und `sam`, Filter nach Abteilung und Aktiv-Status
- [x] Mitarbeitersuche als wiederverwendbare Komponente: standardmäßig nur aktive, Umschalter „auch deaktivierte“ fürs Offboarding
- [x] Mitarbeiterseite: Stammdaten, Abteilung, Vorgesetzter, seine Geräte, alle Läufe chronologisch (Onboarding, Übergaben, Offboarding)
- [x] Mitarbeiter manuell anlegen und bearbeiten (`herkunft = MANUELL`); synchronisierte Felder sind bei `herkunft = ABGLEICH` gesperrt
- [x] Deaktivieren statt löschen (`aktiv = false`)
- [x] Abteilungen: Name (eindeutig je Kunde), Ziel-OU, Standardgruppen (`String[]`), Mail-Domain, Notizen
- [x] Geräte: Stammdaten nach Vorgabe, Pflichtregel „`hostname` oder `bezeichnung` muss gesetzt sein“, Besitzer optional
- [x] Geräteliste mit Suche über Hostname, Bezeichnung, Seriennummer und `tagCode`, Filter nach Typ
- [x] CSV-Import für Geräte (`hostname`, `label`, `serialNumber`, `inventoryNumber`, `type`, `model`), `tagCode` wird beim Import erzeugt; eigener `src/lib/csv.ts`
- [x] Archivieren statt Löschen, sobald Läufe an einem Gerät oder einer Abteilung hängen
- [x] Screenshots Mitarbeiterliste, Mitarbeiterseite, Geräteliste, Abteilungen in `docs/screenshots/m6/`
## Akzeptanzkriterien
- [ ] Test: Mitarbeitersuche liefert standardmäßig nur `aktiv = true`; mit Umschalter auch deaktivierte
- [ ] Test: ein Gerät ohne `hostname` und ohne `bezeichnung` wird abgelehnt
- [ ] Test: ein Gerät mit Läufen lässt sich nicht löschen, nur archivieren
- [ ] Test: `tagCode` eines bestehenden Geräts lässt sich nicht ändern
- [ ] Test: CSV-Import erzeugt je Zeile einen `tagCode`, meldet fehlerhafte Zeilen einzeln und bricht nicht ab
- [ ] Test: bei `herkunft = ABGLEICH` lehnt der Service Änderungen an synchronisierten Feldern ab
- [x] Test: Mitarbeitersuche liefert standardmäßig nur `aktiv = true`; mit Umschalter auch deaktivierte
- [x] Test: ein Gerät ohne `hostname` und ohne `bezeichnung` wird abgelehnt
- [x] Test: ein Gerät mit Läufen lässt sich nicht löschen, nur archivieren
- [x] Test: `tagCode` eines bestehenden Geräts lässt sich nicht ändern
- [x] Test: CSV-Import erzeugt je Zeile einen `tagCode`, meldet fehlerhafte Zeilen einzeln und bricht nicht ab
- [x] Test: bei `herkunft = ABGLEICH` lehnt der Service Änderungen an synchronisierten Feldern ab
## Beim Bauen aufgefallen
- **Der CSV-Import hat Zeilen still verloren und Erfolg gemeldet.** Ein Anführungszeichen, das in
einer Zelle beginnt und nicht schließt, verschluckte alle Folgezeilen bis zum nächsten `"` — der
Import sagte „3 von 3 Zeilen angelegt“, zwei Geräte fehlten, und in einer Bezeichnung stand der
Text der verschluckten Zeilen. Umgekehrt starb der ganze Import an einer Datei, in der nirgends
mehr ein `"` folgte. Beides ist genau das Gegenteil des Akzeptanzkriteriums. Der Zerleger setzt
jetzt am Zeilenende neu auf: **ein Wert reicht standardmäßig nicht über die Zeile hinaus**. Das
Format erlaubt es, für Hostnamen und Seriennummern ist ein Umbruch aber praktisch immer ein
vergessenes Anführungszeichen — und mit dieser Grenze endet der Schaden an der Zeile, in der er
beginnt. Wer eine selbst erzeugte Datei zurückliest, schaltet `werteUeberZeilen` ein.
- **`ungepflegteAbteilungen` kannte die Kundengrenze nicht.** Gruppiert wurde nach dem Namen
allein, also galt „Vertrieb“ als gepflegt, sobald **irgendein** Kunde eine Abteilung dieses
Namens hatte. Beim Kunden ohne sie blieben `{{ou}}` und `{{gruppen}}` im Lauf leer, ohne dass die
Seite es je sagte. Die Funktion direkt darüber machte es seit jeher richtig — zwei Funktionen im
selben Modul, die sich widersprachen.
- **Die Besitzerauswahl bot Personen fremder Kunden an.** Die Liste wurde einmal für die ganze
Seite geholt und in jede Karte gegeben. Der Dienst wies die Wahl ab — aber erst nach dem Klick,
und die Oberfläche hatte etwas angeboten, das sie nicht einlösen konnte.
- **Ein Techniker konnte ein Gerät endgültig löschen.** Abteilungen und Mitarbeiter haben gar
keinen Löschweg, Geräte schon — und der Tag-Code ist danach für immer verbrannt. Löschen verlangt
jetzt `verwaltung`; die Ausnahme steht ausdrücklich in `CLAUDE.md`.
- **Derselbe Kunde wurde zweimal verschieden dargestellt.** Die Mitarbeiterliste blendete die
eigene Organisation immer aus, die Geräteliste zeigte sie immer an. Im Modus Dienstleistung hieß
„keine Kundenangabe“ damit nicht „kein Kunde“, sondern „ausgerechnet dieser eine“. Jetzt liefern
beide Lesemodelle den Namen, und **die Seite** entscheidet anhand des Betriebsmodus, ob sie ihn
zeigt.
- **Ein Kommentar hat wieder eine Zusage gemacht, die der Code nicht hielt** („zwei verschiedene
Gründe, aber nicht dieselbe Meldung“) — gerendert wurde nur eine, und wer die Rolle nicht hatte,
sah gesperrte Felder ohne jede Erklärung. Vierter Meilenstein in Folge mit genau dieser Sorte
Fehler.
- **`FELD` stand neunmal wortgleich in neun Dateien.** Jetzt einmal in `src/lib/formular.ts`.
- **Die E2E-Tests haben ihre Daten liegen gelassen.** Sie können nichts löschen — die Anwendung
kennt bewusst keinen Löschweg für Personen und Abteilungen —, also räumen sie am Ende über die
Datenbank auf. Ohne das sähe jede Datenbank, gegen die sie laufen, nach ein paar Durchläufen aus
wie ein Messie-Keller.
- **Die Mitarbeitersuche ist ein eigenes Bauteil** (`src/components/mitarbeiter-suche.tsx`), weil
#013 sie beim Starten eines Laufs und #014 beim Zuweisen eines Geräts wieder braucht.
## Offen geblieben
- **Ab 501 aktiven Personen** lässt sich ein Besitzer nicht mehr über das Auswahlfeld zuordnen. Die
Grenze schützt davor, ein Verzeichnis mit zehntausend Personen in ein `<select>` zu gießen; die
Zuweisung über die Geräteseite mit Suche kommt mit #014. Bis dahin sagt das Formular es
ausdrücklich, statt einen Weg zu versprechen, den es nicht gibt.
+2 -2
View File
@@ -15,7 +15,7 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
| #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) | erledigt | v0.1.0 | 6 h | 1 h 20 min |
| #010 | [M5 – Vorlagen-Editor](010-vorlagen-editor.md) | erledigt | v0.1.0 | 10 h | 2 h 10 min |
| #011 | [M6 – Mitarbeiter, Abteilungen, Geräte](011-mitarbeiter-abteilungen-geraete.md) | offen | v0.1.0 | 9 h | – |
| #011 | [M6 – Mitarbeiter, Abteilungen, Geräte](011-mitarbeiter-abteilungen-geraete.md) | erledigt | v0.1.0 | 9 h | 2 h 15 min |
| #012 | [M7 – Platzhalter, Namensregeln, Escaping](012-platzhalter-namensregeln.md) | offen | v0.1.0 | 8 h | – |
| #013 | [M8 – Läufe: Service und Abarbeitung](013-laeufe-abarbeitung.md) | offen | v0.1.0 | 14 h | – |
| #014 | [M9 – QR-Tags, Geräteseite, Etiketten, Scanner](014-qr-tags-geraeteseite.md) | offen | v0.1.0 | 12 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** | **9 h** |
| | **Summe** | | | **136 h** | **11 h 15 min** |
Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 510 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

+1
View File
@@ -9,6 +9,7 @@
"node": ">=24"
},
"scripts": {
"beispiel:stammdaten": "tsx scripts/beispiel-stammdaten.ts",
"beispiel:vorlage": "tsx scripts/beispiel-vorlage.ts",
"build": "next build",
"db:down": "docker compose -f docker-compose.dev.yml down",
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env tsx
/**
* Beispiel-Stammdaten fuer die Screenshots (#011).
*
* Personen, Abteilungen und Geraete lassen sich nur mit Inhalt zeigen, und die
* echten Beispieldaten kommen erst mit #017. Bis dahin legt dieses Skript einen
* kleinen, glaubwuerdigen Bestand an — darunter eine Person mit
* `herkunft = ABGLEICH`, damit die gesperrten Stammdaten sichtbar werden.
*
* Es geht durch die echten Dienste, wo es welche gibt: waeren die Regeln anders
* als hier angenommen, schlaegt es fehl, statt eine Lage herzustellen, die es in
* der Anwendung nie gibt. Der abgeglichene Fall entsteht direkt ueber Prisma —
* den Weg dorthin baut erst der Verzeichnisabgleich (#016).
*
* Idempotent. Mit #017 wird das Skript geloescht.
*
* pnpm beispiel:stammdaten
*/
import "dotenv/config";
import { Herkunft } from "../src/generated/prisma/enums.js";
import { prisma } from "../src/lib/db.js";
import { abteilungAnlegen } from "../src/server/services/abteilungen.js";
import { geraetAnlegen } from "../src/server/services/geraete.js";
import { mitarbeiterAnlegen } from "../src/server/services/mitarbeiter.js";
const admin = await prisma.benutzer.findFirst({
where: { rolle: "ADMIN" },
orderBy: { erstelltAm: "asc" },
select: { id: true, rolle: true },
});
if (!admin) {
throw new Error("Es gibt keinen Administrator. Erst pnpm db:seed laufen lassen.");
}
const handelnder = { id: admin.id, rolle: admin.rolle };
const eigene = await prisma.kunde.findFirstOrThrow({ where: { istEigeneOrganisation: true } });
const ABTEILUNGEN = [
{
name: "Buchhaltung",
zielOu: "OU=Buchhaltung,OU=Benutzer,DC=firma,DC=de",
standardGruppen: ["G-Buchhaltung", "G-Alle"],
mailDomain: "firma.de",
},
{
name: "Vertrieb",
zielOu: "OU=Vertrieb,OU=Benutzer,DC=firma,DC=de",
standardGruppen: ["G-Vertrieb", "G-Alle"],
mailDomain: "firma.de",
},
{
name: "IT",
zielOu: "OU=IT,OU=Benutzer,DC=firma,DC=de",
standardGruppen: ["G-IT", "G-Alle", "G-Adminrechte"],
mailDomain: "firma.de",
},
];
const PERSONEN = [
{ vorname: "Maria", nachname: "Huber", sam: "mhuber", abteilung: "Buchhaltung", titel: "Sachbearbeiterin" },
{ vorname: "Jörg", nachname: "Müller-Lüdenscheid", sam: "jmuellerluedensch", abteilung: "Vertrieb" },
{ vorname: "Ali", nachname: "Demir", sam: "ademir", abteilung: "IT", titel: "Systemadministrator" },
{ vorname: "Sonja", nachname: "Brand", sam: "sbrand", abteilung: "Vertrieb" },
];
let neueAbteilungen = 0;
for (const eingabe of ABTEILUNGEN) {
const vorhanden = await prisma.abteilung.findUnique({
where: { kundeId_name: { kundeId: eigene.id, name: eingabe.name } },
});
if (vorhanden) continue;
await abteilungAnlegen(eingabe, handelnder);
neueAbteilungen += 1;
}
let neuePersonen = 0;
for (const person of PERSONEN) {
const vorhanden = await prisma.mitarbeiter.findFirst({ where: { sam: person.sam } });
if (vorhanden) continue;
const mail = `${person.sam}@firma.de`;
await mitarbeiterAnlegen({ ...person, mail, upn: mail }, handelnder);
neuePersonen += 1;
}
/**
* Eine Person aus dem Verzeichnis.
*
* Direkt ueber Prisma, weil der Dienst nur `herkunft = MANUELL` anlegt — den
* anderen Weg baut der Verzeichnisabgleich (#016). Fuer den Screenshot der
* gesperrten Stammdaten braucht es sie trotzdem schon jetzt.
*/
const abgeglichen = await prisma.mitarbeiter.findFirst({ where: { sam: "kwinter" } });
if (!abgeglichen) {
await prisma.mitarbeiter.create({
data: {
kundeId: eigene.id,
vorname: "Katrin",
nachname: "Winter",
anzeigename: "Katrin Winter",
mail: "kwinter@firma.de",
upn: "kwinter@firma.de",
sam: "kwinter",
abteilung: "Einkauf",
titel: "Einkäuferin",
dn: "CN=Katrin Winter,OU=Einkauf,OU=Benutzer,DC=firma,DC=de",
herkunft: Herkunft.ABGLEICH,
zuletztGesehenAm: new Date(),
},
});
neuePersonen += 1;
}
/**
* Ein paar Geraete, darunter eines ohne Hostname.
*
* `PC-001` und `NB-001` heissen so, weil der Auftrag sie in den Beispieldaten
* nennt; der Serverschrank zeigt den Fall „Bezeichnung statt Hostname“, den die
* Pflichtregel ausdruecklich erlaubt.
*/
const GERAETE = [
{ hostname: "PC-001", typ: "PC" as const, modell: "Dell OptiPlex 7010", besitzer: "mhuber" },
{ hostname: "NB-001", typ: "NOTEBOOK" as const, modell: "Lenovo T14", besitzer: "jmuellerluedensch" },
{ hostname: "SRV-DC01", typ: "SERVER" as const, modell: "Dell PowerEdge R450" },
{ bezeichnung: "Serverschrank Keller", typ: "SONSTIGES" as const },
{ hostname: "PRN-EG-01", typ: "DRUCKER" as const, modell: "Kyocera ECOSYS" },
];
let neueGeraete = 0;
for (const geraet of GERAETE) {
// Seriennummern haben keine Leerzeichen – auch die erfundenen nicht.
const kennzeichen = (geraet.hostname ?? geraet.bezeichnung ?? "").replace(/\s+/gu, "-").toUpperCase();
const vorhanden = await prisma.geraet.findFirst({
where: geraet.hostname ? { hostname: geraet.hostname } : { bezeichnung: geraet.bezeichnung },
});
if (vorhanden) continue;
const besitzer = geraet.besitzer
? await prisma.mitarbeiter.findFirst({ where: { sam: geraet.besitzer }, select: { id: true } })
: null;
await geraetAnlegen(
{
...(geraet.hostname ? { hostname: geraet.hostname } : {}),
...(geraet.bezeichnung ? { bezeichnung: geraet.bezeichnung } : {}),
...(geraet.modell ? { modell: geraet.modell } : {}),
...(besitzer ? { mitarbeiterId: besitzer.id } : {}),
typ: geraet.typ,
seriennummer: `SN-${kennzeichen}`,
},
handelnder,
);
neueGeraete += 1;
}
console.log(
`Beispiel-Stammdaten: ${neueAbteilungen} Abteilungen, ${neuePersonen} Personen und ` +
`${neueGeraete} Geräte neu angelegt.`,
);
await prisma.$disconnect();
+27
View File
@@ -98,6 +98,33 @@ const SEITEN: readonly Seite[] = [
await tab.waitForURL(/\/vorlagen\/version\/[^/]+$/);
},
},
{ name: "mitarbeiter-liste", pfad: "/mitarbeiter" },
{
name: "mitarbeiter-seite",
pfad: "/mitarbeiter",
vorbereiten: async (tab) => {
await tab
.getByRole("link", { name: /Maria Huber/ })
.first()
.click();
await tab.waitForURL(/\/mitarbeiter\/[^/]+$/);
},
},
{
// Die gesperrten Stammdaten einer Person aus dem Verzeichnis – der Fall,
// den man sonst erst nach dem ersten LDAP-Abgleich zu sehen bekaeme.
name: "mitarbeiter-verzeichnis",
pfad: "/mitarbeiter",
vorbereiten: async (tab) => {
await tab
.getByRole("link", { name: /Katrin Winter/ })
.first()
.click();
await tab.waitForURL(/\/mitarbeiter\/[^/]+$/);
},
},
{ name: "geraete-liste", pfad: "/geraete" },
{ name: "abteilungen", pfad: "/admin/abteilungen" },
];
function argument(name: string): string | undefined {
+126
View File
@@ -0,0 +1,126 @@
import { Building, Plus } from "lucide-react";
import { AbteilungArchivieren } from "@/components/abteilung-archivieren";
import { AbteilungFormular } from "@/components/abteilung-formular";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import { kundenListe } from "@/server/queries/kunden";
import { abteilungsListe, ungepflegteAbteilungen } from "@/server/queries/abteilungen";
/**
* Abteilungen (#011).
*
* An einer Abteilung haengen Ziel-OU und Standardgruppen — daraus entstehen in
* #012 die Befehle, die ein Konto anlegen. Deshalb verlangt diese Seite
* `verwaltung` und nicht bloss Lesen: wer sie sieht, sieht die Struktur des
* Verzeichnisses.
*/
export default async function AbteilungenSeite() {
// Jede Seite prueft selbst – das Admin-Layout darueber ist das zweite Netz,
// nicht das erste (siehe CLAUDE.md).
const { benutzer } = await sitzungErforderlich("verwaltung", "/admin/abteilungen");
const [zeilen, offene, kundenEbene] = await Promise.all([
abteilungsListe(benutzer),
ungepflegteAbteilungen(benutzer),
kundenEbeneSichtbar(),
]);
const kunden = kundenEbene ? await kundenListe(benutzer) : [];
return (
<>
<h1 className="text-2xl font-bold">Abteilungen</h1>
<p className="mt-2 max-w-prose text-nebentext">
Ziel-OU und Standardgruppen einer Abteilung bestimmen, wo ein neues Konto landet und in welchen
Gruppen es liegt. Die Zuordnung läuft über den Abteilungsnamen der Person — ein Wert aus dem
Verzeichnis bleibt deshalb auch dann erhalten, wenn es dazu noch keine gepflegte Abteilung gibt.
</p>
{offene.length > 0 ? (
<section className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold text-faellig">Noch nicht gepflegt</h2>
<p className="mt-1 max-w-prose text-sm text-nebentext">
Diese Abteilungsnamen kommen bei Personen vor, es gibt aber keine Abteilung dazu. Ohne Ziel-OU und
Standardgruppen bleiben die Platzhalter <code className="mono">{"{{ou}}"}</code> und{" "}
<code className="mono">{"{{gruppen}}"}</code> im Lauf leer.
</p>
<ul className="mt-3 flex flex-wrap gap-2">
{offene.map((eintrag) => (
<li
key={eintrag.name}
className="rounded-pille bg-flaeche-hoch px-3 py-1.5 text-sm text-nebentext"
>
{eintrag.name}{" "}
<span className="text-xs">
({eintrag.anzahl} {eintrag.anzahl === 1 ? "Person" : "Personen"})
</span>
</li>
))}
</ul>
</section>
) : null}
<ul className="mt-6 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-start md:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<Building
className="size-[18px] shrink-0 text-nebentext"
strokeWidth={1.75}
aria-hidden="true"
/>
<span className={zeile.archiviert ? "text-nebentext line-through" : "font-medium"}>
{zeile.name}
</span>
<span className="text-xs text-nebentext">
{zeile.anzahlMitarbeiter} {zeile.anzahlMitarbeiter === 1 ? "Person" : "Personen"}
</span>
</div>
{zeile.zielOu ? <p className="mono mt-1 text-xs text-nebentext">{zeile.zielOu}</p> : null}
{zeile.standardGruppen.length > 0 ? (
<p className="mono mt-1 text-xs text-nebentext">{zeile.standardGruppen.join(", ")}</p>
) : null}
{zeile.mailDomain ? <p className="mt-1 text-xs text-nebentext">@{zeile.mailDomain}</p> : null}
{zeile.notizen ? (
<p className="mt-2 max-w-prose text-sm text-nebentext">{zeile.notizen}</p>
) : null}
</div>
<AbteilungArchivieren abteilungId={zeile.id} archiviert={zeile.archiviert} />
</div>
<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>
<div className="px-4 pb-4">
<AbteilungFormular
werte={{
id: zeile.id,
name: zeile.name,
zielOu: zeile.zielOu,
standardGruppen: zeile.standardGruppen,
mailDomain: zeile.mailDomain,
notizen: zeile.notizen,
}}
/>
</div>
</details>
</li>
))}
</ul>
<section className="mt-8 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="flex items-center gap-2 text-lg font-semibold">
<Plus className="size-5 text-akzent" strokeWidth={2} aria-hidden="true" />
Abteilung anlegen
</h2>
<p className="mt-1 mb-5 max-w-prose text-sm text-nebentext">
Der Name muss genauso lauten wie im Verzeichnis, sonst findet die Zuordnung ihn nicht.
</p>
<AbteilungFormular kunden={kunden.map(({ id, name }) => ({ id, name }))} />
</section>
</>
);
}
+1
View File
@@ -10,6 +10,7 @@ import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
const REITER = [
{ pfad: "/admin", beschriftung: "Einstellungen" },
{ pfad: "/admin/benutzer", beschriftung: "Benutzer" },
{ pfad: "/admin/abteilungen", beschriftung: "Abteilungen" },
] as const;
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
+282
View File
@@ -0,0 +1,282 @@
import Link from "next/link";
import { Plus, QrCode } from "lucide-react";
import { GeraetAktionen } from "@/components/geraet-aktionen";
import { GeraetAnlegen } from "@/components/geraet-anlegen";
import { GeraetBearbeiten } from "@/components/geraet-bearbeiten";
import { FELD, KAESTCHEN } from "@/lib/formular";
import { GERAETE_TYP_LISTE, GERAETE_TYP_TEXT } from "@/lib/geraete-text";
import { GeraetImportieren } from "@/components/geraet-importieren";
import { StammdatenReiter } from "@/components/stammdaten-reiter";
import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import { kundenListe } from "@/server/queries/kunden";
import {
besitzerKandidaten,
geraeteListe,
type BesitzerAuswahl,
type GeraeteZeile,
} from "@/server/queries/geraete";
import { GERAETE_IMPORT_HOECHSTZEILEN, GERAETE_IMPORT_SPALTEN } from "@/server/services/geraete";
/**
* Geraeteliste (#011).
*
* Die Filter laufen als GET-Formular ueber die Adresse, nicht ueber React-State:
* ein gesetzter Filter ist dann verlinkbar, der Zurueck-Knopf tut das Erwartete,
* und die Seite bleibt eine Server-Komponente, die nur die sichtbaren Zeilen
* ueberhaupt laedt. Die Mandantensicht wendet das Lesemodell selbst an.
*
* Karten statt Tabelle: ein Geraet hat sieben Angaben, von denen am Handy keine
* wegfallen darf — eine Tabelle waere dort nur seitwaerts lesbar, und
* horizontales Scrollen ist ausgeschlossen.
*/
const PILLE = "rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext";
function Kennzeichen({ zeile, kundenEbene }: { zeile: GeraeteZeile; kundenEbene: boolean }) {
return (
<div className="flex flex-wrap items-center gap-2">
<span className={PILLE}>{GERAETE_TYP_TEXT[zeile.typ]}</span>
{/* Im Modus Intern gibt es genau eine Organisation, und die erscheint
nirgends (#008). Im Modus Dienstleistung ist der Kunde dagegen die
wichtigste Angabe der Zeile. */}
{kundenEbene ? <span className={PILLE}>{zeile.kundeName}</span> : null}
{zeile.registriertAm === null ? <span className={PILLE}>Tag ohne Zuordnung</span> : null}
{zeile.archiviert ? <span className={PILLE}>archiviert</span> : null}
{zeile.anzahlLaeufe > 0 ? (
<span className={PILLE}>
{zeile.anzahlLaeufe} {zeile.anzahlLaeufe === 1 ? "Lauf" : "Läufe"}
</span>
) : null}
</div>
);
}
function Karte({
zeile,
darfBearbeiten,
darfLoeschen,
besitzer,
kundenEbene,
}: {
zeile: GeraeteZeile;
darfBearbeiten: boolean;
darfLoeschen: boolean;
besitzer: BesitzerAuswahl;
kundenEbene: boolean;
}) {
const angaben = [
zeile.besitzerName
? `${zeile.besitzerName}${zeile.besitzerAktiv === false ? " (deaktiviert)" : ""}`
: null,
zeile.modell,
zeile.seriennummer ? `S/N ${zeile.seriennummer}` : null,
zeile.inventarnummer ? `Inv. ${zeile.inventarnummer}` : null,
].filter((angabe): angabe is string => Boolean(angabe));
return (
<li className="rounded-karte border border-linie bg-flaeche">
<div className="flex flex-col gap-3 p-4 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="mono flex items-center gap-1.5 rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
<QrCode className="size-3.5" strokeWidth={1.75} aria-hidden="true" />
{zeile.tagCode}
</span>
<p className={zeile.archiviert ? "text-nebentext line-through" : "font-medium"}>
{zeile.anzeige}
</p>
</div>
<div className="mt-2">
<Kennzeichen zeile={zeile} kundenEbene={kundenEbene} />
</div>
{angaben.length > 0 ? (
<p className="mt-2 text-xs break-words text-nebentext">{angaben.join(" · ")}</p>
) : null}
</div>
{darfBearbeiten ? (
<GeraetAktionen
geraetId={zeile.id}
archiviert={zeile.archiviert}
anzahlLaeufe={zeile.anzahlLaeufe}
anzeige={zeile.anzeige}
darfLoeschen={darfLoeschen}
/>
) : null}
</div>
{darfBearbeiten ? (
<details>
<summary className="flex min-h-11 cursor-pointer list-none items-center border-t border-linie px-4 text-sm text-nebentext hover:text-text">
Stammdaten bearbeiten
</summary>
<GeraetBearbeiten
geraet={{
id: zeile.id,
tagCode: zeile.tagCode,
hostname: zeile.hostname,
bezeichnung: zeile.bezeichnung,
typ: zeile.typ,
modell: zeile.modell,
seriennummer: zeile.seriennummer,
inventarnummer: zeile.inventarnummer,
notizen: zeile.notizen,
besitzerId: zeile.besitzerId,
besitzerName: zeile.besitzerName,
...(typeof zeile.besitzerAktiv === "boolean" ? { besitzerAktiv: zeile.besitzerAktiv } : {}),
}}
/*
* Nur Personen **dieses** Kunden. Die Liste wird einmal fuer die
* ganze Seite geholt; boete die Karte sie unveraendert an, koennte
* jemand im Modus Dienstleistung eine Person eines anderen Kunden
* waehlen — der Dienst weist das ab, aber erst nach dem Klick.
*/
besitzer={{
kandidaten: besitzer.kandidaten.filter((kandidat) => kandidat.kundeId === zeile.kundeId),
gekuerzt: besitzer.gekuerzt,
}}
/>
</details>
) : null}
</li>
);
}
export default async function GeraeteSeite({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { benutzer } = await sitzungErforderlich("lesen", "/geraete");
const parameter = await searchParams;
const einzeln = (name: string): string =>
(Array.isArray(parameter[name]) ? parameter[name][0] : parameter[name])?.trim() ?? "";
const typWunsch = einzeln("typ");
// Ein unbekannter Typ in der Adresse filtert nicht, sondern wird verworfen –
// eine Fehlerseite fuer einen vertippten Parameter waere die falsche Antwort.
const typ = GERAETE_TYP_LISTE.find((wert) => wert === typWunsch);
const filter = {
suche: einzeln("suche"),
...(typ ? { typ } : {}),
auchArchivierte: einzeln("archiviert") === "1",
};
const darfBearbeiten = darf(benutzer.rolle, "geraeteBearbeiten");
// Loeschen ist nicht ruecknehmbar und verbrennt den Tag-Code fuer immer –
// deshalb `verwaltung` und nicht `geraeteBearbeiten` (#011, nach dem Review).
const darfLoeschen = darf(benutzer.rolle, "verwaltung");
const [zeilen, kundenEbene] = await Promise.all([geraeteListe(benutzer, filter), kundenEbeneSichtbar()]);
// Besitzerauswahl und Kundenwahl braucht nur, wer auch pflegen darf.
const besitzer = darfBearbeiten ? await besitzerKandidaten(benutzer) : { kandidaten: [], gekuerzt: false };
const kunden = darfBearbeiten && kundenEbene ? await kundenListe(benutzer) : [];
const kundenWahl = kunden.filter((kunde) => !kunde.archiviert).map(({ id, name }) => ({ id, name }));
return (
<>
<StammdatenReiter aktiv="/geraete" />
<h1 className="text-2xl font-bold">Geräte</h1>
<p className="mt-2 max-w-prose text-nebentext">
Nicht nur PCs: ein Serverschrank, ein Raum oder eine Firewall bekommen genauso einen Aufkleber. Der
Tag-Code eines Geräts wird nie geändert und nie neu vergeben — auch nicht, nachdem das Gerät
archiviert oder gelöscht wurde.
</p>
<form method="get" className="mt-6 flex flex-col gap-3 md:flex-row md:flex-wrap md:items-end">
<label className="flex min-w-0 flex-1 flex-col gap-1.5 text-sm md:max-w-xs">
<span className="text-nebentext">Suche</span>
<input
name="suche"
defaultValue={filter.suche}
placeholder="Hostname, Bezeichnung, Seriennummer, Tag-Code"
className={FELD}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Typ</span>
<select name="typ" defaultValue={typ ?? ""} className={FELD}>
<option value="">alle</option>
{GERAETE_TYP_LISTE.map((wert) => (
<option key={wert} value={wert}>
{GERAETE_TYP_TEXT[wert]}
</option>
))}
</select>
</label>
<label className="flex min-h-11 items-center gap-2 text-sm text-nebentext">
<input
type="checkbox"
name="archiviert"
value="1"
defaultChecked={filter.auchArchivierte}
className={KAESTCHEN}
/>
auch archivierte
</label>
<button
type="submit"
className="min-h-11 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
>
Filtern
</button>
{filter.suche || typ || filter.auchArchivierte ? (
<Link
href="/geraete"
className="flex min-h-11 items-center px-2 text-sm text-nebentext underline underline-offset-2 hover:text-text md:min-h-10"
>
zurücksetzen
</Link>
) : null}
</form>
{zeilen.length === 0 ? (
<p className="mt-8 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
Kein Gerät passt zu diesen Filtern.
</p>
) : (
<ul className="mt-6 flex flex-col gap-3">
{zeilen.map((zeile) => (
<Karte
key={zeile.id}
zeile={zeile}
darfBearbeiten={darfBearbeiten}
darfLoeschen={darfLoeschen}
besitzer={besitzer}
kundenEbene={kundenEbene}
/>
))}
</ul>
)}
{darfBearbeiten ? (
<div className="mt-8 flex flex-col gap-4">
<section className="rounded-karte border border-linie bg-flaeche p-5">
<h2 className="flex items-center gap-2 text-lg font-semibold">
<Plus className="size-5 text-akzent" strokeWidth={2} aria-hidden="true" />
Gerät anlegen
</h2>
<p className="mt-1 mb-5 max-w-prose text-sm text-nebentext">
Der Tag-Code entsteht beim Anlegen und steht danach in der Meldung — er kommt auf den Aufkleber.
</p>
<GeraetAnlegen besitzer={besitzer} kunden={kundenWahl} />
</section>
<details className="rounded-karte border border-linie bg-flaeche">
<summary className="flex min-h-11 cursor-pointer list-none items-center px-5 text-sm text-nebentext hover:text-text">
Geräte aus einer CSV-Datei einlesen
</summary>
<div className="px-5 pb-5">
<GeraetImportieren
hoechstZeilen={GERAETE_IMPORT_HOECHSTZEILEN}
spalten={Object.values(GERAETE_IMPORT_SPALTEN).map((namen) => namen[0])}
kunden={kundenWahl}
/>
</div>
</details>
</div>
) : null}
</>
);
}
+123
View File
@@ -0,0 +1,123 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft, Laptop } from "lucide-react";
import { MitarbeiterAktiv } from "@/components/mitarbeiter-aktiv";
import { MitarbeiterStammdaten } from "@/components/mitarbeiter-stammdaten";
import { Herkunft } from "@/generated/prisma/enums";
import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import { geraeteDesMitarbeiters, mitarbeiterHolen, vorgesetzterVon } from "@/server/queries/mitarbeiter";
/**
* Die Seite einer Person (#011).
*
* Sie beantwortet drei Fragen: wer ist das, welche Geraete gehoeren ihr, und was
* ist mit ihr passiert. Die Laufhistorie kommt mit #013 dazu — bis dahin steht
* hier, dass sie kommt, statt eine leere Ueberschrift zu zeigen.
*/
export default async function MitarbeiterSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { benutzer } = await sitzungErforderlich("lesen", `/mitarbeiter/${id}`);
const person = await mitarbeiterHolen(id, benutzer);
// Nicht vorhanden und nicht sichtbar sind dieselbe Antwort – die
// Unterscheidung waere schon eine Auskunft ueber fremde Kunden (#009).
if (!person) notFound();
const darfBearbeiten = darf(benutzer.rolle, "mitarbeiterBearbeiten");
const [geraete, kundenEbene, vorgesetzter] = await Promise.all([
geraeteDesMitarbeiters(person.id, benutzer),
kundenEbeneSichtbar(),
vorgesetzterVon(person.id, benutzer),
]);
return (
<>
<Link
href="/mitarbeiter"
className="inline-flex min-h-11 items-center gap-2 text-sm text-nebentext hover:text-text"
>
<ArrowLeft className="size-4" strokeWidth={1.75} aria-hidden="true" />
Alle Personen
</Link>
<h1 className="mt-2 text-2xl font-bold">{person.anzeigename}</h1>
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-nebentext">
{person.titel ? <span>{person.titel}</span> : null}
{person.abteilung ? <span>· {person.abteilung}</span> : null}
{kundenEbene ? <span>· {person.kundeName}</span> : null}
{person.aktiv ? null : <span className="text-faellig">· deaktiviert</span>}
{person.herkunft === Herkunft.ABGLEICH ? <span>· aus dem Verzeichnis</span> : null}
</div>
{vorgesetzter ? (
<p className="mt-1 text-sm text-nebentext">
Vorgesetzter:{" "}
<Link href={`/mitarbeiter/${vorgesetzter.id}`} className="text-akzent underline underline-offset-2">
{vorgesetzter.anzeigename}
</Link>
</p>
) : null}
{darfBearbeiten ? (
<div className="mt-6">
<MitarbeiterAktiv mitarbeiterId={person.id} aktiv={person.aktiv} />
</div>
) : null}
<section className="mt-8 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="flex items-center gap-2 text-lg font-semibold">
<Laptop className="size-5 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
Geräte
</h2>
{geraete.length === 0 ? (
<p className="mt-2 text-sm text-nebentext">Dieser Person ist kein Gerät zugeordnet.</p>
) : (
<ul className="mt-4 flex flex-col gap-2">
{geraete.map((geraet) => (
<li
key={geraet.id}
className="flex flex-wrap items-center gap-2 border-t border-linie py-3 first:border-t-0"
>
<span className="mono rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
{geraet.tagCode}
</span>
<span className={geraet.archiviert ? "text-nebentext line-through" : ""}>{geraet.name}</span>
<span className="text-xs text-nebentext">{geraet.typ}</span>
</li>
))}
</ul>
)}
</section>
<section className="mt-4 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Stammdaten</h2>
<div className="mt-4">
<MitarbeiterStammdaten
person={{
id: person.id,
vorname: person.vorname,
nachname: person.nachname,
anzeigename: person.anzeigename,
mail: person.mail,
upn: person.upn,
sam: person.sam,
abteilung: person.abteilung,
titel: person.titel,
ausVerzeichnis: person.herkunft === Herkunft.ABGLEICH,
}}
darfBearbeiten={darfBearbeiten}
/>
</div>
</section>
<section className="mt-4 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Läufe</h2>
<p className="mt-2 max-w-prose text-sm text-nebentext">
Onboarding, Übergaben und Offboarding erscheinen hier chronologisch, sobald es Läufe gibt — sie
kommen mit dem nächsten Meilenstein.
</p>
</section>
</>
);
}
+143
View File
@@ -0,0 +1,143 @@
import Link from "next/link";
import { Plus, User } from "lucide-react";
import { MitarbeiterAnlegen } from "@/components/mitarbeiter-anlegen";
import { MitarbeiterSuche } from "@/components/mitarbeiter-suche";
import { StammdatenReiter } from "@/components/stammdaten-reiter";
import { Herkunft } from "@/generated/prisma/enums";
import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import { kundenListe } from "@/server/queries/kunden";
import { abteilungsNamen, mitarbeiterListe, type MitarbeiterZeile } from "@/server/queries/mitarbeiter";
/**
* Mitarbeiterliste (#011).
*
* Gesucht wird ueber Anzeigename, Mail, UPN und `sam` — im Ticket steht mal das
* eine, mal das andere. Standardmaessig erscheinen nur aktive Personen; fuers
* Offboarding gibt es den Umschalter.
*
* Die Filter laufen als GET-Formular ueber die Adresse: ein gesetzter Filter ist
* dann verlinkbar, der Zurueck-Knopf tut das Erwartete, und die Seite bleibt
* eine Server-Komponente, die nur die sichtbaren Zeilen laedt.
*/
function Zeile({ zeile, kundenEbene }: { zeile: MitarbeiterZeile; kundenEbene: boolean }) {
const kennungen = [zeile.mail, zeile.upn === zeile.mail ? null : zeile.upn, zeile.sam]
.filter(Boolean)
.join(" · ");
return (
<li className="rounded-karte border border-linie bg-flaeche">
<Link
href={`/mitarbeiter/${zeile.id}`}
className="flex flex-col gap-2 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">
<User className="size-[18px] shrink-0 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
<span className={`font-medium ${zeile.aktiv ? "" : "text-nebentext line-through"}`}>
{zeile.anzeigename}
</span>
{zeile.aktiv ? null : (
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
deaktiviert
</span>
)}
{zeile.herkunft === Herkunft.ABGLEICH ? (
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
aus dem Verzeichnis
</span>
) : null}
{kundenEbene ? (
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
{zeile.kundeName}
</span>
) : null}
</div>
{kennungen ? <p className="mono mt-1 truncate text-xs text-nebentext">{kennungen}</p> : null}
<p className="mt-1 text-xs text-nebentext">
{[
zeile.abteilung,
zeile.titel,
`${zeile.anzahlGeraete} ${zeile.anzahlGeraete === 1 ? "Gerät" : "Geräte"}`,
`${zeile.anzahlLaeufe} ${zeile.anzahlLaeufe === 1 ? "Lauf" : "Läufe"}`,
]
.filter(Boolean)
.join(" · ")}
</p>
</div>
</Link>
</li>
);
}
export default async function MitarbeiterSeite({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { benutzer } = await sitzungErforderlich("lesen", "/mitarbeiter");
const parameter = await searchParams;
const einzeln = (name: string): string =>
(Array.isArray(parameter[name]) ? parameter[name][0] : parameter[name])?.trim() ?? "";
const filter = {
suche: einzeln("suche"),
abteilung: einzeln("abteilung"),
auchDeaktivierte: einzeln("deaktivierte") === "1",
};
const darfBearbeiten = darf(benutzer.rolle, "mitarbeiterBearbeiten");
const [zeilen, abteilungen, kundenEbene] = await Promise.all([
mitarbeiterListe(benutzer, filter),
abteilungsNamen(benutzer),
kundenEbeneSichtbar(),
]);
const kunden = kundenEbene && darfBearbeiten ? await kundenListe(benutzer) : [];
return (
<>
<StammdatenReiter aktiv="/mitarbeiter" />
<h1 className="text-2xl font-bold">{kundenEbene ? "Personen" : "Mitarbeiter"}</h1>
<p className="mt-2 max-w-prose text-nebentext">
Personen werden nie gelöscht, nur deaktiviert — ein archivierter Lauf muss auch in zehn Jahren noch
sagen können, für wen er lief.
</p>
<div className="mt-6">
<MitarbeiterSuche werte={filter} abteilungen={abteilungen} ziel="/mitarbeiter" />
</div>
{zeilen.length === 0 ? (
<p className="mt-8 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
Keine Person passt zu diesen Filtern.
</p>
) : (
<ul className="mt-6 flex flex-col gap-3">
{zeilen.map((zeile) => (
<Zeile key={zeile.id} zeile={zeile} kundenEbene={kundenEbene} />
))}
</ul>
)}
{darfBearbeiten ? (
<details className="mt-8 rounded-karte border border-linie bg-flaeche">
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 px-5 text-sm text-nebentext hover:text-text">
<Plus className="size-4 text-akzent" strokeWidth={2} aria-hidden="true" />
Person von Hand anlegen
</summary>
<div className="px-5 pb-5">
<p className="mb-5 max-w-prose text-sm text-nebentext">
Für das Onboarding, bevor der nächste Verzeichnisabgleich gelaufen ist. Eine so angelegte Person
bleibt vollständig änderbar.
</p>
<MitarbeiterAnlegen
kunden={kunden.map(({ id, name }) => ({ id, name }))}
abteilungen={abteilungen}
/>
</div>
</details>
) : null}
</>
);
}
+1 -3
View File
@@ -7,6 +7,7 @@ import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import { kundenListe } from "@/server/queries/kunden";
import { vorlagenFilterwerte, vorlagenListe, type VorlagenZeile } from "@/server/queries/vorlagen";
import { FELD } from "@/lib/formular";
/**
* Vorlagenliste (#010).
@@ -17,9 +18,6 @@ import { vorlagenFilterwerte, vorlagenListe, type VorlagenZeile } from "@/server
* ueberhaupt laedt. Die Mandantensicht wenden die Lesemodelle selbst an (#009).
*/
const FELD =
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
function Kennzeichen({ zeile }: { zeile: VorlagenZeile }) {
return (
<div className="flex flex-wrap items-center gap-2">
+43
View File
@@ -0,0 +1,43 @@
"use client";
import { useActionState } from "react";
import { abteilungArchivierenAktion, type AktionsErgebnis } from "@/server/actions/abteilungen";
/** Archivieren statt loeschen – an einer Abteilung haengen Laeufe. */
export function AbteilungArchivieren({
abteilungId,
archiviert,
}: {
abteilungId: string;
archiviert: boolean;
}) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(
abteilungArchivierenAktion,
{},
);
return (
<div className="shrink-0">
<form action={aktion}>
<input type="hidden" name="abteilungId" value={abteilungId} />
<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>
);
}
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { FELD } from "@/lib/formular";
import {
abteilungAnlegenAktion,
abteilungBearbeitenAktion,
type AktionsErgebnis,
} from "@/server/actions/abteilungen";
export type AbteilungsWerte = {
readonly id?: string;
readonly name?: string;
readonly zielOu?: string | null;
readonly standardGruppen?: readonly string[];
readonly mailDomain?: string | null;
readonly notizen?: string | null;
};
/**
* Ein Formular fuer Anlegen und Bearbeiten.
*
* Bewusst dasselbe: die Felder sind identisch, und zwei Formulare liefen mit der
* Zeit auseinander — eines bekaeme ein Feld, das andere nicht.
*
* Die Ziel-OU und die Standardgruppen sind der Grund, warum diese Seite
* `verwaltung` verlangt: aus ihnen entstehen in #012 die PowerShell-Befehle, die
* ein Konto anlegen. Ein Tippfehler hier legt es an der falschen Stelle an.
*/
export function AbteilungFormular({
werte = {},
kunden = [],
}: {
werte?: AbteilungsWerte;
kunden?: readonly { id: string; name: string }[];
}) {
const bearbeiten = werte.id !== undefined;
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(
bearbeiten ? abteilungBearbeitenAktion : abteilungAnlegenAktion,
{},
);
return (
<form action={aktion} className="flex flex-col gap-4">
{bearbeiten ? <input type="hidden" name="abteilungId" value={werte.id} /> : null}
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Name</span>
<input name="name" required defaultValue={werte.name ?? ""} className={FELD} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Mail-Domain (optional)</span>
<input
name="mailDomain"
defaultValue={werte.mailDomain ?? ""}
placeholder="firma.de"
className={FELD}
/>
</label>
</div>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Ziel-OU (optional)</span>
<input
name="zielOu"
defaultValue={werte.zielOu ?? ""}
placeholder="OU=Buchhaltung,OU=Benutzer,DC=firma,DC=de"
className={`${FELD} mono`}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Standardgruppen, mit Komma getrennt</span>
<input
name="standardGruppen"
defaultValue={(werte.standardGruppen ?? []).join(", ")}
placeholder="G-Buchhaltung, G-Alle"
className={`${FELD} mono`}
/>
<span className="text-xs text-nebentext">
Die Reihenfolge bleibt erhalten — sie landet später so im Gruppen-Befehl.
</span>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Notizen (optional)</span>
<textarea name="notizen" rows={2} defaultValue={werte.notizen ?? ""} className={`${FELD} py-2`} />
</label>
{!bearbeiten && kunden.length > 0 ? (
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Kunde</span>
<select name="kundeId" required defaultValue="" className={FELD}>
<option value="" disabled>
bitte wählen
</option>
{kunden.map((kunde) => (
<option key={kunde.id} value={kunde.id}>
{kunde.name}
</option>
))}
</select>
</label>
) : null}
{ergebnis.fehler ? (
<p role="alert" className="max-w-prose 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 …" : bearbeiten ? "Speichern" : "Abteilung anlegen"}
</Button>
</form>
);
}
+1 -3
View File
@@ -5,9 +5,7 @@ import { Rolle } from "@/generated/prisma/enums";
import { Button } from "@/components/ui/button";
import { ROLLEN_TEXT } from "@/lib/rollen-text";
import { benutzerAnlegenAktion, type AktionsErgebnis } from "@/server/actions/benutzer";
const FELD =
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
import { FELD } from "@/lib/formular";
/**
* Formular fuer ein lokales Konto. Als Client-Komponente, weil Erfolg und Fehler
+1 -3
View File
@@ -3,9 +3,7 @@
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";
import { FELD } from "@/lib/formular";
export type EinstellungsWerte = {
readonly eigeneOrganisationName: string;
+108
View File
@@ -0,0 +1,108 @@
"use client";
import { useActionState, useState } from "react";
import { Trash2 } from "lucide-react";
import {
geraetArchivierenAktion,
geraetLoeschenAktion,
type AktionsErgebnis,
} from "@/server/actions/geraete";
/**
* Archivieren, Freigeben und – nur wenn kein Lauf daran haengt – Loeschen.
*
* Als Client-Komponente, damit die Begruendung des Dienstes sichtbar wird. „An
* diesem Gerät hängen drei Läufe“ ist die Auskunft, die der Benutzer braucht; in
* einer Fehlerseite waere sie verloren.
*/
export function GeraetAktionen({
geraetId,
archiviert,
anzahlLaeufe,
anzeige,
darfLoeschen,
}: {
geraetId: string;
archiviert: boolean;
anzahlLaeufe: number;
anzeige: string;
/**
* Nur `verwaltung`. Archivieren darf jeder, der Geraete pflegt; Loeschen ist
* nicht ruecknehmbar und verbrennt den Tag-Code fuer immer.
*/
darfLoeschen: boolean;
}) {
const [archivErgebnis, archivAktion, archivLaeuft] = useActionState<AktionsErgebnis, FormData>(
geraetArchivierenAktion,
{},
);
const [loeschErgebnis, loeschAktion, loeschLaeuft] = useActionState<AktionsErgebnis, FormData>(
geraetLoeschenAktion,
{},
);
const [fragt, setFragt] = useState(false);
const knopf =
"min-h-11 rounded-pille px-3 text-sm border border-linie text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10";
return (
<div className="flex shrink-0 flex-col items-start gap-2">
<div className="flex flex-wrap items-center gap-2">
<form action={archivAktion}>
<input type="hidden" name="geraetId" value={geraetId} />
<input type="hidden" name="archiviert" value={archiviert ? "false" : "true"} />
<button type="submit" disabled={archivLaeuft} className={knopf}>
{archiviert ? "wieder freigeben" : "archivieren"}
</button>
</form>
{/*
* Loeschen erscheint nur, solange kein Lauf daran haengt. Der Dienst
* prueft es noch einmal – hier geht es darum, einen Knopf nicht
* anzubieten, der ohnehin nur eine Absage bringt.
*/}
{darfLoeschen && anzahlLaeufe === 0 && !fragt ? (
<button type="button" onClick={() => setFragt(true)} className={`${knopf} flex items-center gap-2`}>
<Trash2 className="size-4" strokeWidth={1.75} aria-hidden="true" />
löschen
</button>
) : null}
</div>
{fragt ? (
<div className="flex flex-col gap-2 rounded-karte border border-linie bg-grund p-3">
<p className="max-w-xs text-xs text-nebentext">
„{anzeige}“ wirklich löschen? Der Tag-Code bleibt für immer vergeben — ein Aufkleber zeigt nie auf
ein zweites Gerät.
</p>
<div className="flex flex-wrap gap-2">
<form action={loeschAktion}>
<input type="hidden" name="geraetId" value={geraetId} />
<button
type="submit"
disabled={loeschLaeuft}
className="min-h-11 rounded-pille bg-fehler/15 px-3 text-sm text-fehler hover:bg-fehler/25 md:min-h-10"
>
{loeschLaeuft ? "Wird gelöscht …" : "ja, löschen"}
</button>
</form>
<button type="button" onClick={() => setFragt(false)} className={knopf}>
abbrechen
</button>
</div>
</div>
) : null}
{archivErgebnis.fehler ? (
<p role="alert" className="max-w-xs text-xs text-fehler">
{archivErgebnis.fehler}
</p>
) : null}
{loeschErgebnis.fehler ? (
<p role="alert" className="max-w-xs text-xs text-fehler">
{loeschErgebnis.fehler}
</p>
) : null}
</div>
);
}
+160
View File
@@ -0,0 +1,160 @@
"use client";
import { useActionState, useState } from "react";
import { Button } from "@/components/ui/button";
import { FELD } from "@/lib/formular";
import { GERAETE_TYP_LISTE, GERAETE_TYP_TEXT } from "@/lib/geraete-text";
import { geraetAnlegenAktion, type AktionsErgebnis } from "@/server/actions/geraete";
export type BesitzerWahl = {
readonly kandidaten: readonly {
readonly id: string;
readonly anzeigename: string;
/** Ein Geraet kann nur einer Person desselben Kunden gehoeren. */
readonly kundeId: string;
}[];
readonly gekuerzt: boolean;
};
/**
* Ein Geraet von Hand anlegen.
*
* Der Tag-Code wird dabei vom Dienst erzeugt und steht anschliessend in der
* Erfolgsmeldung — er ist das, was gleich auf den Aufkleber kommt, also soll ihn
* niemand erst in der Liste suchen muessen.
*/
export function GeraetAnlegen({
besitzer,
kunden,
}: {
besitzer: BesitzerWahl;
kunden: readonly { readonly id: string; readonly name: string }[];
}) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(geraetAnlegenAktion, {});
/*
* Im Modus Dienstleistung haengt die Besitzerwahl am gewaehlten Kunden: ein
* Geraet kann nur einer Person desselben Kunden gehoeren. Ohne diese
* Einschraenkung stuenden Personen zur Auswahl, die der Dienst danach abweist.
* Im Modus Intern gibt es keine Kundenwahl — dann bleibt die Liste, wie sie ist.
*/
const [kundeId, setzeKunde] = useState("");
const waehlbar =
kunden.length === 0 || kundeId === ""
? besitzer.kandidaten
: besitzer.kandidaten.filter((person) => person.kundeId === kundeId);
return (
<form action={aktion} className="flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Hostname</span>
<input name="hostname" className={`${FELD} mono`} placeholder="PC-VERTRIEB-07" />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Bezeichnung</span>
<input name="bezeichnung" className={FELD} placeholder="Serverschrank Keller" />
</label>
</div>
<p className="text-xs text-nebentext">
Eines von beiden genügt. Ein Serverschrank oder ein Raum hat keinen Hostname, ein Notebook keine
Bezeichnung.
</p>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Typ</span>
<select name="typ" defaultValue="SONSTIGES" className={FELD}>
{GERAETE_TYP_LISTE.map((typ) => (
<option key={typ} value={typ}>
{GERAETE_TYP_TEXT[typ]}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Modell</span>
<input name="modell" className={FELD} placeholder="Latitude 5450" />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Seriennummer</span>
<input name="seriennummer" className={`${FELD} mono`} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Inventarnummer</span>
<input name="inventarnummer" className={`${FELD} mono`} />
</label>
</div>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Besitzer (optional)</span>
<select name="mitarbeiterId" defaultValue="" className={FELD}>
<option value="">niemand — Server, Raum oder Poolgerät</option>
{waehlbar.map((person) => (
<option key={person.id} value={person.id}>
{person.anzeigename}
</option>
))}
</select>
</label>
{kunden.length > 0 && kundeId === "" ? (
<p className="text-xs text-nebentext">
Ohne gewählten Kunden stehen hier alle sichtbaren Personen. Nach der Wahl bleiben nur die des Kunden
übrig — ein Gerät kann keiner fremden Person gehören.
</p>
) : null}
{besitzer.gekuerzt ? (
<p className="text-xs text-faellig">
Es werden nur die ersten {besitzer.kandidaten.length} aktiven Personen gezeigt. Wer nicht dabei ist,
lässt sich über dieses Feld vorerst nicht zuordnen — die Zuweisung über die Geräteseite kommt mit
#014.
</p>
) : null}
<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>
{/* Die Kundenwahl erscheint nur im Modus Dienstleistung; im Modus Intern
verwirft der Server eine mitgeschickte Kennung ohnehin (#008). */}
{kunden.length > 0 ? (
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Kunde</span>
<select
name="kundeId"
value={kundeId}
onChange={(ereignis) => setzeKunde(ereignis.target.value)}
className={FELD}
required
>
<option value="">bitte wählen</option>
{kunden.map((kunde) => (
<option key={kunde.id} value={kunde.id}>
{kunde.name}
</option>
))}
</select>
</label>
) : null}
{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 …" : "Gerät anlegen"}
</Button>
</form>
);
}
+164
View File
@@ -0,0 +1,164 @@
"use client";
import { useActionState } from "react";
import type { GeraeteTyp } from "@/generated/prisma/enums";
import { Button } from "@/components/ui/button";
import { FELD } from "@/lib/formular";
import { GERAETE_TYP_LISTE, GERAETE_TYP_TEXT } from "@/lib/geraete-text";
import type { BesitzerWahl } from "@/components/geraet-anlegen";
import { geraetBearbeitenAktion, type AktionsErgebnis } from "@/server/actions/geraete";
export type GeraetZumBearbeiten = {
readonly id: string;
readonly tagCode: string;
readonly hostname: string | null;
readonly bezeichnung: string | null;
readonly typ: GeraeteTyp;
readonly modell: string | null;
readonly seriennummer: string | null;
readonly inventarnummer: string | null;
readonly notizen: string | null;
readonly besitzerId: string | null;
readonly besitzerName: string | null;
/** Nur fuer die Beschriftung, wenn der Besitzer nicht in der Auswahl steht. */
readonly besitzerAktiv?: boolean;
};
/**
* Stammdaten aendern.
*
* Der Tag-Code steht hier als Text und nicht als Feld: er wird nie geaendert. Er
* geht als verstecktes Feld mit, damit ein Aenderungsversuch — ein manipuliertes
* Formular etwa — am Dienst auffaellt und nicht stillschweigend ins Leere laeuft.
*
* Ist der bisherige Besitzer deaktiviert, steht er trotzdem in der Auswahl:
* sonst wuerde das Speichern der Seriennummer die Zuordnung mitnehmen.
*/
export function GeraetBearbeiten({
geraet,
besitzer,
}: {
geraet: GeraetZumBearbeiten;
besitzer: BesitzerWahl;
}) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(geraetBearbeitenAktion, {});
/*
* Der bisherige Besitzer bleibt in der Auswahl, auch wenn er nicht (mehr) in
* den Kandidaten steht — sonst wuerde das Speichern der Seriennummer die
* Zuordnung mitnehmen.
*
* Der Zusatz sagt **warum** er fehlt, und raet nicht: deaktiviert ist der
* haeufige Fall, jenseits der Kandidatengrenze der andere. „(deaktiviert)“
* fest anzuhaengen waere bei einer aktiven Person schlicht falsch.
*/
const kandidaten = besitzer.kandidaten.some((person) => person.id === geraet.besitzerId)
? besitzer.kandidaten
: [
...besitzer.kandidaten,
...(geraet.besitzerId && geraet.besitzerName
? [
{
id: geraet.besitzerId,
anzeigename:
geraet.besitzerAktiv === false
? `${geraet.besitzerName} (deaktiviert)`
: `${geraet.besitzerName} (bisheriger Besitzer)`,
kundeId: "",
},
]
: []),
];
return (
<form action={aktion} className="flex flex-col gap-4 border-t border-linie p-4">
<input type="hidden" name="geraetId" value={geraet.id} />
<input type="hidden" name="tagCode" value={geraet.tagCode} />
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Hostname</span>
<input
name="hostname"
defaultValue={geraet.hostname ?? ""}
className={`${FELD} mono`}
autoComplete="off"
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Bezeichnung</span>
<input name="bezeichnung" defaultValue={geraet.bezeichnung ?? ""} className={FELD} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Typ</span>
<select name="typ" defaultValue={geraet.typ} className={FELD}>
{GERAETE_TYP_LISTE.map((typ) => (
<option key={typ} value={typ}>
{GERAETE_TYP_TEXT[typ]}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Modell</span>
<input name="modell" defaultValue={geraet.modell ?? ""} className={FELD} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Seriennummer</span>
<input
name="seriennummer"
defaultValue={geraet.seriennummer ?? ""}
className={`${FELD} mono`}
autoComplete="off"
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Inventarnummer</span>
<input
name="inventarnummer"
defaultValue={geraet.inventarnummer ?? ""}
className={`${FELD} mono`}
autoComplete="off"
/>
</label>
</div>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Besitzer</span>
<select name="mitarbeiterId" defaultValue={geraet.besitzerId ?? ""} className={FELD}>
<option value="">niemand — Server, Raum oder Poolgerät</option>
{kandidaten.map((person) => (
<option key={person.id} value={person.id}>
{person.anzeigename}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Notizen</span>
<textarea name="notizen" defaultValue={geraet.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"
variant="outline"
disabled={laeuft}
className="h-11 self-start rounded-pille px-5 text-sm"
>
{laeuft ? "Wird gespeichert …" : "Speichern"}
</Button>
</form>
);
}
+128
View File
@@ -0,0 +1,128 @@
"use client";
import { useActionState, useState } from "react";
import { Button } from "@/components/ui/button";
import { FELD } from "@/lib/formular";
import { geraeteImportierenAktion, type AktionsErgebnis } from "@/server/actions/geraete";
/**
* Geraete aus einer CSV-Datei einspielen.
*
* Die Datei wird **im Browser** gelesen und ihr Inhalt als Text mitgeschickt —
* derselbe Weg wie beim Vorlagenimport, und dieselbe Begruendung: das Format ist
* Text, die Dateien sind klein, und so gibt es einen Weg statt zwei.
*
* Die Obergrenze und die erwarteten Spalten stehen **ueber** dem Feld, nicht erst
* in der Fehlermeldung. Wer eine zu grosse Datei waehlt, hat sie sonst schon
* exportiert, bevor er es erfaehrt.
*/
export function GeraetImportieren({
hoechstZeilen,
spalten,
kunden,
}: {
hoechstZeilen: number;
spalten: readonly string[];
kunden: readonly { readonly id: string; readonly name: string }[];
}) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(geraeteImportierenAktion, {});
const [inhalt, setInhalt] = useState("");
const [leseFehler, setLeseFehler] = useState<string | null>(null);
async function dateiLesen(datei: File | undefined) {
if (!datei) return;
setLeseFehler(null);
try {
setInhalt(await datei.text());
} catch {
setLeseFehler("Die Datei ließ sich nicht lesen.");
}
}
return (
<form action={aktion} className="flex flex-col gap-4">
<p className="max-w-prose text-sm text-nebentext">
Spalten: <span className="mono text-text">{spalten.join(", ")}</span>. Die deutschen Namen
(Bezeichnung, Seriennummer, Inventarnummer, Typ, Modell) gehen genauso, Groß- und Kleinschreibung ist
unerheblich. Trennzeichen darf Komma oder Semikolon sein. Höchstens {hoechstZeilen} Zeilen je Datei.
Jede Zeile bekommt ihren eigenen Tag-Code; eine fehlerhafte Zeile bricht den Import nicht ab.
</p>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">CSV-Datei</span>
<input
type="file"
accept="text/csv,.csv,text/plain,.txt"
onChange={(ereignis) => void dateiLesen(ereignis.target.files?.[0])}
className="min-h-11 rounded-karte border border-linie bg-grund px-3 py-2 text-sm text-nebentext file:mr-3 file:rounded-pille file:border-0 file:bg-flaeche-hoch file:px-3 file:py-1.5 file:text-sm file:text-text"
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">… oder Inhalt einfügen</span>
<textarea
name="inhalt"
rows={6}
required
value={inhalt}
onChange={(ereignis) => setInhalt(ereignis.target.value)}
className="mono rounded-karte border border-linie bg-grund px-3 py-2 text-xs text-text outline-none focus-visible:border-akzent"
placeholder={
"hostname;label;serialNumber;inventoryNumber;type;model\nPC-BUERO-01;;SN-4711;INV-9;PC;OptiPlex 7010"
}
/>
</label>
{kunden.length > 0 ? (
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Kunde</span>
<select name="kundeId" defaultValue="" className={FELD} required>
<option value="">bitte wählen</option>
{kunden.map((kunde) => (
<option key={kunde.id} value={kunde.id}>
{kunde.name}
</option>
))}
</select>
</label>
) : null}
{leseFehler ? (
<p role="alert" className="text-sm text-fehler">
{leseFehler}
</p>
) : null}
{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}
{/* Je abgewiesene Zeile die Nummer, die der Editor des Benutzers anzeigt,
und der Grund. Eine Sammelmeldung waere hier nutzlos: die Datei muss an
genau diesen Zeilen geaendert werden. */}
{ergebnis.abgewiesen && ergebnis.abgewiesen.length > 0 ? (
<ul className="flex flex-col gap-1.5 rounded-karte border border-linie bg-grund p-3 text-xs">
{ergebnis.abgewiesen.map((abweisung) => (
<li key={abweisung.zeile} className="flex flex-wrap gap-2">
<span className="mono shrink-0 text-nebentext">Zeile {abweisung.zeile}</span>
<span className="min-w-0 text-fehler">{abweisung.grund}</span>
</li>
))}
</ul>
) : null}
<Button
type="submit"
disabled={laeuft}
className="h-11 self-start rounded-pille px-5 text-sm font-semibold"
>
{laeuft ? "Wird eingelesen …" : "Einlesen"}
</Button>
</form>
);
}
+1 -3
View File
@@ -3,9 +3,7 @@
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";
import { FELD } from "@/lib/formular";
export function KundeAnlegen() {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(kundeAnlegenAktion, {});
+1 -3
View File
@@ -3,9 +3,7 @@
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";
import { FELD } from "@/lib/formular";
export function KundeBearbeiten({
kunde,
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { useActionState } from "react";
import { mitarbeiterAktivSetzenAktion, type AktionsErgebnis } from "@/server/actions/mitarbeiter";
/**
* Deaktivieren und wieder aktivieren.
*
* Der einzige „Weg hinaus“ – geloescht wird nichts. Ein archivierter Lauf zeigt
* auf diese Person, und dieser Verweis muss in zehn Jahren noch aufloesbar sein.
*/
export function MitarbeiterAktiv({ mitarbeiterId, aktiv }: { mitarbeiterId: string; aktiv: boolean }) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(
mitarbeiterAktivSetzenAktion,
{},
);
return (
<div>
<form action={aktion}>
<input type="hidden" name="mitarbeiterId" value={mitarbeiterId} />
<input type="hidden" name="aktiv" value={aktiv ? "false" : "true"} />
<button
type="submit"
disabled={laeuft}
className={`flex min-h-11 w-fit items-center rounded-pille px-4 text-sm md:min-h-10 ${
aktiv
? "border border-linie text-nebentext hover:bg-flaeche-hoch hover:text-text"
: "bg-flaeche-hoch text-nebentext hover:text-text"
}`}
>
{aktiv ? "deaktivieren" : "wieder aktivieren"}
</button>
</form>
{ergebnis.fehler ? (
<p role="alert" className="mt-2 max-w-prose text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
"use client";
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { mitarbeiterAnlegenAktion, type AktionsErgebnis } from "@/server/actions/mitarbeiter";
import { FELD } from "@/lib/formular";
/**
* Eine Person von Hand anlegen.
*
* Gebraucht wird das beim Onboarding: der Techniker steht vor der Person, bevor
* der naechste Verzeichnisabgleich gelaufen ist. Die so angelegte Person traegt
* `herkunft = MANUELL` und bleibt vollstaendig aenderbar — im Gegensatz zu einer
* abgeglichenen (#016).
*/
export function MitarbeiterAnlegen({
kunden,
abteilungen,
}: {
kunden: readonly { id: string; name: string }[];
abteilungen: readonly string[];
}) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(mitarbeiterAnlegenAktion, {});
return (
<form action={aktion} className="flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Vorname</span>
<input name="vorname" required className={FELD} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Nachname</span>
<input name="nachname" required className={FELD} />
</label>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Mail-Adresse (optional)</span>
<input name="mail" type="email" className={FELD} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Abteilung (optional)</span>
<input name="abteilung" list="abteilungs-vorschlaege" className={FELD} />
<datalist id="abteilungs-vorschlaege">
{abteilungen.map((name) => (
<option key={name} value={name} />
))}
</datalist>
</label>
</div>
<details>
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
Verzeichnis-Angaben (UPN, sAMAccountName, Titel)
</summary>
<div className="mt-3 grid gap-4 sm:grid-cols-3">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">UPN</span>
<input name="upn" className={FELD} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">sAMAccountName (≤ 20)</span>
<input name="sam" maxLength={20} className={`${FELD} mono`} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Titel</span>
<input name="titel" className={FELD} />
</label>
</div>
</details>
{kunden.length > 0 ? (
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Kunde</span>
<select name="kundeId" required defaultValue="" className={FELD}>
<option value="" disabled>
bitte wählen
</option>
{kunden.map((kunde) => (
<option key={kunde.id} value={kunde.id}>
{kunde.name}
</option>
))}
</select>
</label>
) : null}
{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 …" : "Person anlegen"}
</Button>
</form>
);
}
+153
View File
@@ -0,0 +1,153 @@
"use client";
import { useActionState } from "react";
import { Lock } from "lucide-react";
import { Button } from "@/components/ui/button";
import { mitarbeiterBearbeitenAktion, type AktionsErgebnis } from "@/server/actions/mitarbeiter";
import { FELD } from "@/lib/formular";
export type StammdatenPerson = {
readonly id: string;
readonly vorname: string;
readonly nachname: string;
readonly anzeigename: string;
readonly mail: string | null;
readonly upn: string | null;
readonly sam: string | null;
readonly abteilung: string | null;
readonly titel: string | null;
/** Bei `true` kommen die Stammdaten aus dem Verzeichnis und sind gesperrt. */
readonly ausVerzeichnis: boolean;
};
/**
* Stammdaten bearbeiten – oder ansehen, wenn sie aus dem Verzeichnis kommen.
*
* Bei `herkunft = ABGLEICH` ist **jedes** gepflegte Feld gesperrt, nicht nur
* einige: alles daran wird im Verzeichnis gepflegt, und der naechste Abgleich
* wuerde eine Aenderung ohnehin zurueckdrehen. Die Felder werden trotzdem
* gezeigt — man will die Werte sehen —, nur eben schreibgeschuetzt. Ein
* ausgeblendetes Feld waere eine schlechtere Auskunft als ein gesperrtes.
*/
export function MitarbeiterStammdaten({
person,
darfBearbeiten,
}: {
person: StammdatenPerson;
darfBearbeiten: boolean;
}) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(
mitarbeiterBearbeitenAktion,
{},
);
/*
* Zwei verschiedene Gruende, dasselbe Ergebnis — aber nicht dieselbe Meldung.
* Wer die Rolle nicht hat, bekommt keinen Satz ueber das Verzeichnis zu lesen,
* der fuer ihn gar nicht der Grund ist.
*/
const gesperrt = person.ausVerzeichnis || !darfBearbeiten;
return (
<form action={aktion} className="flex flex-col gap-4">
<input type="hidden" name="mitarbeiterId" value={person.id} />
{gesperrt ? (
<div className="flex items-start gap-3 rounded-karte border border-linie bg-flaeche-hoch p-4">
<Lock
className="mt-0.5 size-[18px] shrink-0 text-nebentext"
strokeWidth={1.75}
aria-hidden="true"
/>
<p className="max-w-prose text-sm text-nebentext">
{person.ausVerzeichnis
? "Diese Person kommt aus dem Verzeichnis. Die Stammdaten werden dort gepflegt — eine Änderung hier würde der nächste Abgleich zurücksetzen. Deaktivieren lässt sie sich trotzdem."
: "Zum Ändern der Stammdaten fehlt dir die Berechtigung. Dafür braucht es die Rolle Techniker, Redakteur oder Administrator."}
</p>
</div>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Vorname</span>
<input name="vorname" required defaultValue={person.vorname} readOnly={gesperrt} className={FELD} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Nachname</span>
<input
name="nachname"
required
defaultValue={person.nachname}
readOnly={gesperrt}
className={FELD}
/>
</label>
</div>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Anzeigename</span>
<input name="anzeigename" defaultValue={person.anzeigename} readOnly={gesperrt} className={FELD} />
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Mail-Adresse</span>
<input
name="mail"
type="email"
defaultValue={person.mail ?? ""}
readOnly={gesperrt}
className={FELD}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Abteilung</span>
<input
name="abteilung"
defaultValue={person.abteilung ?? ""}
readOnly={gesperrt}
className={FELD}
/>
</label>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">UPN</span>
<input name="upn" defaultValue={person.upn ?? ""} readOnly={gesperrt} className={FELD} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">sAMAccountName</span>
<input
name="sam"
maxLength={20}
defaultValue={person.sam ?? ""}
readOnly={gesperrt}
className={`${FELD} mono`}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Titel</span>
<input name="titel" defaultValue={person.titel ?? ""} readOnly={gesperrt} className={FELD} />
</label>
</div>
{ergebnis.fehler ? (
<p role="alert" className="max-w-prose text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
{ergebnis.erfolg ? (
<p role="status" className="text-sm text-erledigt">
{ergebnis.erfolg}
</p>
) : null}
{gesperrt ? null : (
<Button
type="submit"
disabled={laeuft}
className="h-11 self-start rounded-pille px-5 text-sm font-semibold"
>
{laeuft ? "Wird gespeichert …" : "Stammdaten speichern"}
</Button>
)}
</form>
);
}
+98
View File
@@ -0,0 +1,98 @@
import Link from "next/link";
import { FELD, KAESTCHEN } from "@/lib/formular";
/**
* Das Suchformular ueber Personen (#011).
*
* Eigenes Bauteil, weil dieselbe Suche an mehreren Stellen gebraucht wird: in
* der Personenliste, spaeter beim Starten eines Laufs (#013) und beim Zuweisen
* eines Geraets (#014). Dreimal dasselbe Formular abzuschreiben hiesse, den
* Umschalter „auch deaktivierte“ zweimal zu vergessen.
*
* Bewusst ein GET-Formular und keine Client-Komponente: ein gesetzter Filter ist
* dann verlinkbar, der Zurueck-Knopf tut das Erwartete, und die Seite bleibt
* eine Server-Komponente, die nur die sichtbaren Zeilen laedt.
*
* **Standard sind nur aktive Personen.** Wer offboardet, schaltet um — und sieht
* am gesetzten Haken, dass er es getan hat.
*/
export type MitarbeiterFilterWerte = {
readonly suche: string;
readonly abteilung: string;
readonly auchDeaktivierte: boolean;
};
export function MitarbeiterSuche({
werte,
abteilungen,
ziel,
versteckt = {},
}: {
werte: MitarbeiterFilterWerte;
abteilungen: readonly string[];
/** Adresse, auf die das Formular zeigt – der Filter landet in der Adresszeile. */
ziel: string;
/** Weitere Parameter, die erhalten bleiben sollen, etwa ein vorgewähltes Gerät. */
versteckt?: Readonly<Record<string, string>>;
}) {
const gesetzt = werte.suche !== "" || werte.abteilung !== "" || werte.auchDeaktivierte;
return (
<form method="get" action={ziel} className="flex flex-col gap-3 md:flex-row md:flex-wrap md:items-end">
{Object.entries(versteckt).map(([name, wert]) => (
<input key={name} type="hidden" name={name} value={wert} />
))}
<label className="flex min-w-0 flex-1 flex-col gap-1.5 text-sm md:max-w-xs">
<span className="text-nebentext">Suche</span>
<input
name="suche"
defaultValue={werte.suche}
placeholder="Name, Mail, UPN oder sam"
className={FELD}
/>
</label>
{abteilungen.length > 0 ? (
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Abteilung</span>
<select name="abteilung" defaultValue={werte.abteilung} className={FELD}>
<option value="">alle</option>
{abteilungen.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
</label>
) : null}
<label className="flex min-h-11 items-center gap-2 text-sm text-nebentext">
<input
type="checkbox"
name="deaktivierte"
value="1"
defaultChecked={werte.auchDeaktivierte}
className={KAESTCHEN}
/>
auch deaktivierte
</label>
<button
type="submit"
className="min-h-11 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
>
Filtern
</button>
{gesetzt ? (
<Link
href={ziel}
className="flex min-h-11 items-center px-2 text-sm text-nebentext underline underline-offset-2 hover:text-text md:min-h-10"
>
zurücksetzen
</Link>
) : null}
</form>
);
}
+1 -3
View File
@@ -4,9 +4,7 @@ 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";
import { FELD } from "@/lib/formular";
export type WechselAuskunft = {
readonly zielModus: "INTERN" | "DIENSTLEISTUNG";
+41
View File
@@ -0,0 +1,41 @@
import Link from "next/link";
/**
* Umschalter zwischen Personen und Geraeten (#011).
*
* Die Seitenleiste fuehrt beide unter einem Eintrag („Mitarbeiter & Geräte“),
* weil sie im Alltag zusammengehoeren: man sucht eine Person und landet bei
* ihrem Geraet, oder umgekehrt. Ein zweiter Eintrag in der Leiste waere eine
* Trennung, die es fachlich nicht gibt.
*
* Bewusst kein `usePathname`: die Seiten sind Server-Komponenten und kennen
* ihren Pfad ohnehin.
*/
const REITER = [
{ pfad: "/mitarbeiter", beschriftung: "Personen" },
{ pfad: "/geraete", beschriftung: "Geräte" },
] as const;
export function StammdatenReiter({ aktiv }: { aktiv: "/mitarbeiter" | "/geraete" }) {
return (
<nav aria-label="Stammdaten" className="mb-6 flex flex-wrap gap-2">
{REITER.map((reiter) => {
const istAktiv = reiter.pfad === aktiv;
return (
<Link
key={reiter.pfad}
href={reiter.pfad}
aria-current={istAktiv ? "page" : undefined}
className={`flex min-h-11 items-center rounded-pille px-4 text-sm transition-colors md:min-h-9 ${
istAktiv
? "bg-akzent-flaeche font-semibold text-akzent"
: "border border-linie text-nebentext hover:bg-flaeche-hoch hover:text-text"
}`}
>
{reiter.beschriftung}
</Link>
);
})}
</nav>
);
}
+1 -3
View File
@@ -9,14 +9,12 @@ import {
vorlageExportierenAktion,
type AktionsErgebnis,
} from "@/server/actions/vorlagen";
import { FELD } from "@/lib/formular";
// `w-fit`, damit ein Knopf in einem Block-Element nicht auf volle Breite geht –
// drei verschieden breite Balken untereinander sehen aus wie ein Fehler.
const KNOPF =
"flex w-fit min-h-11 items-center gap-2 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10";
const FELD =
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
function Meldung({ ergebnis }: { ergebnis: AktionsErgebnis }) {
if (ergebnis.fehler) {
return (
+1 -3
View File
@@ -3,9 +3,7 @@
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { vorlageAnlegenAktion, type AktionsErgebnis } from "@/server/actions/vorlagen";
const FELD =
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
import { FELD } from "@/lib/formular";
/**
* Nur die Kopfdaten. Abschnitte, Schritte und Variablen entstehen danach im
+1 -3
View File
@@ -3,9 +3,7 @@
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { vorlageBearbeitenAktion, type AktionsErgebnis } from "@/server/actions/vorlagen";
const FELD =
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
import { FELD } from "@/lib/formular";
/**
* Kopfdaten einer Vorlage: Name, Beschreibung, Kategorie, Schlagworte, Kunde.
+444
View File
@@ -0,0 +1,444 @@
/**
* CSV lesen und schreiben (#011).
*
* Selbst geschrieben und ohne Abhaengigkeit: gebraucht werden hier wenige
* Spalten, und was an echten Dateien wehtut, ist nicht der Umfang des Formats,
* sondern seine Alltagsvarianten — deutsches Excel schreibt Semikolon, setzt ein
* BOM davor und beendet Zeilen mit CRLF.
*
* Framework- und datenbankfrei (siehe Schichtung in CLAUDE.md), damit die
* Randfaelle billig zu testen sind: tests/csv.test.ts haelt sie fest.
*/
export class CsvFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "CsvFehler";
}
}
/**
* Nur diese beiden. Tabulator und Pipe kommen in dieser Anwendung nicht vor, und
* eine Liste von Kandidaten, die niemand liefert, macht die Erkennung nur
* unzuverlaessiger.
*/
export const CSV_TRENNZEICHEN = [",", ";"] as const;
export type CsvTrennzeichen = (typeof CSV_TRENNZEICHEN)[number];
/** Byte Order Mark. Excel stellt es UTF-8-Dateien voran; es gehoert nicht zum Inhalt. */
const BOM = "";
const ZEILENENDE = "\r\n";
export type CsvZeile = {
/**
* Zeilennummer in der Datei, 1-basiert und einschliesslich Kopfzeile. Sie wird
* dem Benutzer gemeldet, muss also die Nummer sein, die sein Editor anzeigt —
* deshalb zaehlen auch Leerzeilen und Umbrueche innerhalb von
* Anfuehrungszeichen mit.
*/
readonly nummer: number;
/** Die Werte in der Reihenfolge der Datei, also auch ueberzaehlige. */
readonly werte: readonly string[];
/** Normalisierter Spaltenname zu Wert. Fehlende Spalten fehlen hier. */
readonly felder: ReadonlyMap<string, string>;
/**
* Gesetzt, wenn diese Zeile sich nicht sauber zerlegen liess.
*
* Dann sind `werte` und `felder` das, was bis zum Fehler zusammenkam — zu
* gebrauchen ist das nicht. Der Aufrufer meldet die Zeile und macht mit der
* naechsten weiter; **werfen waere hier falsch**, denn eine kaputte Zeile darf
* den Import der anderen nicht verhindern.
*/
readonly fehler?: string;
};
export type CsvTabelle = {
readonly trennzeichen: CsvTrennzeichen;
/** Die Kopfzeile, normalisiert — siehe `spaltenName`. */
readonly spalten: readonly string[];
readonly zeilen: readonly CsvZeile[];
};
export type ZerlegeOptionen = {
/** Erzwingt ein Trennzeichen, statt es aus der Kopfzeile zu erkennen. */
readonly trennzeichen?: CsvTrennzeichen;
/**
* Darf ein Wert in Anfuehrungszeichen ueber mehrere Zeilen gehen? Standard: nein.
*
* Das Format erlaubt es, und `csvErzeugen` schreibt es auch so. Beim **Lesen**
* einer fremden Datei ist es trotzdem die schlechtere Annahme: ein Umbruch in
* einem Hostnamen oder einer Seriennummer kommt nicht vor, ein vergessenes
* schliessendes Anfuehrungszeichen dagegen staendig. Ohne diese Grenze
* verschluckt eine einzige kaputte Zelle alle Folgezeilen bis zum naechsten
* `"` — und der Import meldet dafuer Erfolg, weil er die Zeilen nie gesehen
* hat. Mit der Grenze endet der Schaden an der Zeile, in der er beginnt.
*
* Wer eine selbst erzeugte Datei zurueckliest, schaltet es ein.
*/
readonly werteUeberZeilen?: boolean;
/**
* Obergrenze fuer die Datenzeilen (ohne Kopfzeile). Wird sie ueberschritten,
* bricht das Zerlegen mit einem `CsvFehler` ab, statt eine 50-MB-Datei erst
* ganz einzulesen.
*/
readonly hoechstZeilen?: number;
};
/**
* Spaltennamen vergleichbar machen.
*
* Gross- und Kleinschreibung ist laut Auftrag unerheblich; Leerraum,
* Unterstriche und Bindestriche kommen dazu, weil dieselbe Spalte je nach
* Werkzeug „serialNumber“, „Serial Number“ oder „serial_number“ heisst. Wer hier
* nach einem Namen sucht, sucht mit derselben Funktion.
*/
export function spaltenName(roh: string): string {
return roh
.replace(BOM, "")
.trim()
.toLowerCase()
.replace(/[\s_-]+/g, "");
}
type RoherSatz = { readonly nummer: number; readonly werte: readonly string[]; readonly fehler?: string };
/**
* Der eigentliche Zustandsautomat.
*
* `hoechstSaetze` bricht ab, sobald genug gelesen ist — die Erkennung des
* Trennzeichens braucht nur die erste Zeile, und die Obergrenze des Imports soll
* nicht erst nach dem vollstaendigen Einlesen greifen.
*/
function saetzeLesen(
text: string,
trennzeichen: CsvTrennzeichen,
hoechstSaetze?: number,
werteUeberZeilen = false,
): readonly RoherSatz[] {
const saetze: RoherSatz[] = [];
let werte: string[] = [];
let feld = "";
/** Zitierte Werte werden nicht getrimmt: Leerraum in Anfuehrungszeichen ist gewollt. */
let zitiert = false;
let inAnfuehrung = false;
/** Gesetzt, sobald diese Zeile nicht mehr zu gebrauchen ist. */
let satzFehler: string | undefined;
let zeile = 1;
let satzZeile = 1;
let pos = 0;
const feldAbschliessen = (): void => {
werte.push(zitiert ? feld : feld.trim());
feld = "";
zitiert = false;
};
const satzAbschliessen = (): void => {
feldAbschliessen();
// Leerzeilen fallen weg – auch die Zeilen aus lauter Trennzeichen, die Excel
// unter eine Tabelle schreibt. Die Zeilennummer laeuft trotzdem weiter.
if (satzFehler !== undefined) {
saetze.push({ nummer: satzZeile, werte, fehler: satzFehler });
} else if (werte.some((wert) => wert !== "")) {
saetze.push({ nummer: satzZeile, werte });
}
werte = [];
satzFehler = undefined;
};
/**
* Nach einem Fehler bis zum naechsten Zeilenumbruch weiterlesen und dort neu
* aufsetzen.
*
* Der Grund steht in `CsvZeile.fehler`: eine kaputte Zeile darf die naechsten
* nicht mitreissen. Genau das passierte vorher — ein nicht geschlossenes
* Anfuehrungszeichen verschluckte alle Folgezeilen bis zum naechsten `"`, und
* der Import meldete trotzdem Erfolg.
*/
const bisZeilenendeVerwerfen = (): void => {
while (pos < text.length) {
const zeichen = text.charAt(pos);
if (zeichen === "\r" || zeichen === "\n") {
if (zeichen === "\r" && text.charAt(pos + 1) === "\n") pos += 1;
pos += 1;
break;
}
pos += 1;
}
feld = "";
inAnfuehrung = false;
satzAbschliessen();
zeile += 1;
satzZeile = zeile;
};
while (pos < text.length) {
const zeichen = text.charAt(pos);
if (inAnfuehrung) {
if (zeichen === '"') {
if (text.charAt(pos + 1) === '"') {
// Verdoppeltes Anfuehrungszeichen: ein literales Zeichen im Wert.
feld += '"';
pos += 2;
continue;
}
inAnfuehrung = false;
pos += 1;
// Leerraum zwischen schliessendem Anfuehrungszeichen und Trennzeichen
// gehoert nicht zum Wert.
while (text.charAt(pos) === " " || text.charAt(pos) === "\t") pos += 1;
/*
* Nach dem schliessenden Anfuehrungszeichen darf nur ein Trennzeichen,
* ein Zeilenumbruch oder das Dateiende kommen. Steht dort etwas anderes,
* war das Anfuehrungszeichen nicht das Ende des Werts, sondern ein
* Zeichen mitten im Text — und alles Folgende wanderte bisher
* stillschweigend in diesen Wert.
*/
const naechstes = text.charAt(pos);
if (naechstes !== "" && naechstes !== trennzeichen && naechstes !== "\r" && naechstes !== "\n") {
satzFehler =
"Nach einem schließenden Anführungszeichen steht noch Text. " +
"Ein Anführungszeichen innerhalb eines Werts muss verdoppelt werden.";
bisZeilenendeVerwerfen();
if (hoechstSaetze !== undefined && saetze.length >= hoechstSaetze) return saetze;
}
continue;
}
if (zeichen === "\r" || zeichen === "\n") {
if (!werteUeberZeilen) {
// Hier endet der Schaden: die Zeile gilt als kaputt, die naechste
// faengt sauber an. Siehe `werteUeberZeilen`.
satzFehler =
"Ein Anführungszeichen wurde in dieser Zeile geöffnet und nicht geschlossen. " +
"Ein Anführungszeichen innerhalb eines Werts muss verdoppelt werden.";
bisZeilenendeVerwerfen();
if (hoechstSaetze !== undefined && saetze.length >= hoechstSaetze) return saetze;
continue;
}
// Umbruch im Wert. CRLF wird zu einem einzelnen \n normalisiert, damit
// ein Wert nicht je nach Herkunft der Datei anders aussieht.
if (zeichen === "\r" && text.charAt(pos + 1) === "\n") pos += 1;
feld += "\n";
zeile += 1;
pos += 1;
continue;
}
feld += zeichen;
pos += 1;
continue;
}
// Ein Anfuehrungszeichen eroeffnet nur am Wortanfang; steht es mitten im Wert
// (ein Zoll-Mass etwa), ist es ein gewoehnliches Zeichen.
if (zeichen === '"' && feld.trim() === "") {
feld = "";
zitiert = true;
inAnfuehrung = true;
pos += 1;
continue;
}
if (zeichen === trennzeichen) {
feldAbschliessen();
pos += 1;
continue;
}
if (zeichen === "\r" || zeichen === "\n") {
if (zeichen === "\r" && text.charAt(pos + 1) === "\n") pos += 1;
satzAbschliessen();
pos += 1;
zeile += 1;
satzZeile = zeile;
if (hoechstSaetze !== undefined && saetze.length >= hoechstSaetze) return saetze;
continue;
}
feld += zeichen;
pos += 1;
}
if (inAnfuehrung) {
/*
* Am Dateiende offen: die Zeile, in der es begann, ist verloren — und mit
* ihr alles, was danach in den Wert gewandert ist. Gemeldet wird sie als
* fehlerhafte Zeile, **nicht** als Ausnahme: die Zeilen davor sind in
* Ordnung und sollen eingelesen werden.
*/
satzFehler =
"Hier wurde ein Anführungszeichen geöffnet und bis zum Dateiende nie geschlossen. " +
"Alles Folgende wurde als Teil dieses Werts gelesen.";
feld = "";
inAnfuehrung = false;
satzAbschliessen();
return saetze;
}
// Eine Datei muss nicht mit einem Umbruch enden.
if (feld !== "" || werte.length > 0) satzAbschliessen();
return saetze;
}
/**
* Das Trennzeichen aus der Kopfzeile ablesen, statt es zu raten.
*
* Geraten wuerde, wer irgendwo im Text Zeichen zaehlt: ein Komma in einem
* Wert steht dort genauso. Die Kopfzeile dagegen besteht aus Spaltennamen, und
* das richtige Trennzeichen ist das, das sie in mehr Felder zerlegt. Bei
* Gleichstand — also einer einspaltigen Datei — gewinnt das Komma, weil es das
* Zeichen der Norm ist.
*/
export function trennzeichenErkennen(text: string): CsvTrennzeichen {
let bestes: CsvTrennzeichen = ",";
let meiste = 0;
for (const kandidat of CSV_TRENNZEICHEN) {
const anzahl = saetzeLesen(text, kandidat, 1)[0]?.werte.length ?? 0;
if (anzahl > meiste) {
meiste = anzahl;
bestes = kandidat;
}
}
return bestes;
}
/**
* Zerlegt eine CSV-Datei in Kopfzeile und Zeilen.
*
* Kommt ein Spaltenname zweimal vor, gilt der erste. Ihn stillschweigend zu
* ueberschreiben waere die schlechtere Wahl: der Leser sieht die linke Spalte
* und erwartet, dass sie zaehlt.
*/
export function csvZerlegen(text: string, optionen: ZerlegeOptionen = {}): CsvTabelle {
const ohneBom = text.startsWith(BOM) ? text.slice(BOM.length) : text;
const trennzeichen = optionen.trennzeichen ?? trennzeichenErkennen(ohneBom);
// Eine Zeile mehr lesen als erlaubt, sonst waere die Ueberschreitung nicht von
// einer genau passenden Datei zu unterscheiden. Plus die Kopfzeile.
const grenze = optionen.hoechstZeilen === undefined ? undefined : Math.max(0, optionen.hoechstZeilen) + 2;
const saetze = saetzeLesen(ohneBom, trennzeichen, grenze, optionen.werteUeberZeilen ?? false);
const kopf = saetze[0];
if (!kopf) {
throw new CsvFehler("Die Datei ist leer – es fehlt schon die Kopfzeile.");
}
if (kopf.fehler !== undefined) {
// Bei einer kaputten Kopfzeile hilft keine Zeilenmeldung: ohne Spaltennamen
// hat keine einzige Zeile eine Bedeutung.
throw new CsvFehler(`Die Kopfzeile ist nicht lesbar. ${kopf.fehler}`);
}
if (optionen.hoechstZeilen !== undefined && saetze.length - 1 > optionen.hoechstZeilen) {
throw new CsvFehler(
`Die Datei hat mehr als ${optionen.hoechstZeilen} Zeilen. ` +
"Bitte in kleinere Dateien aufteilen und einzeln einlesen.",
);
}
const spalten = kopf.werte.map(spaltenName);
const zeilen: CsvZeile[] = saetze.slice(1).map((satz) => {
const felder = new Map<string, string>();
for (const [index, spalte] of spalten.entries()) {
if (spalte === "" || felder.has(spalte)) continue;
const wert = satz.werte[index];
// Fehlt die Spalte in dieser Zeile, fehlt auch der Eintrag – ein leerer
// String waere dasselbe wie eine leere Zelle, und das ist es nicht.
if (wert !== undefined) felder.set(spalte, wert);
}
return {
nummer: satz.nummer,
werte: satz.werte,
felder,
...(satz.fehler === undefined ? {} : { fehler: satz.fehler }),
};
});
return { trennzeichen, spalten, zeilen };
}
/**
* Der erste gesetzte Wert unter mehreren moeglichen Spaltennamen.
*
* Der Auftrag nennt englische Spalten, die Oberflaeche ist deutsch, und beide
* Schreibweisen sollen in einer Datei stehen duerfen. Leer gilt als nicht
* gesetzt, damit eine vorhandene aber leere Spalte die naechste nicht verdeckt.
*/
export function wertAus(zeile: CsvZeile, ...namen: readonly string[]): string {
for (const name of namen) {
const wert = zeile.felder.get(spaltenName(name))?.trim();
if (wert) return wert;
}
return "";
}
/** Enthaelt eine der Spalten, unter denen hier ueberhaupt etwas erwartet wird? */
export function hatSpalte(tabelle: CsvTabelle, ...namen: readonly string[]): boolean {
return namen.some((name) => tabelle.spalten.includes(spaltenName(name)));
}
export type ErzeugeOptionen = {
/**
* Standard ist das Semikolon: die erzeugten Dateien werden in aller Regel in
* einem deutschen Excel geoeffnet, und das zerlegt Kommadateien nicht von
* selbst.
*/
readonly trennzeichen?: CsvTrennzeichen;
/** Ohne BOM zeigt dasselbe Excel Umlaute als Kauderwelsch. Deshalb standardmaessig an. */
readonly mitBom?: boolean;
};
/**
* Werte, die eine Tabellenkalkulation als Formel liest, entschaerfen.
*
* Excel und LibreOffice werten eine Zelle aus, die mit `=`, `+`, `-` oder `@`
* beginnt. Steht in einem Geraetenamen `=HYPERLINK(…)`, fuehrt das beim
* Oeffnen der exportierten Datei zu einer Formel — der Wert kam aus unserer
* Datenbank, ausgefuehrt wird er auf dem Rechner des Empfaengers.
*
* Ein vorangestelltes Apostroph macht daraus wieder Text. Es ist in der Zelle
* nicht sichtbar, wandert aber beim Zurueckeinlesen mit — deshalb nur dort, wo
* es noetig ist.
*/
function formelEntschaerfen(wert: string): string {
return /^[=+\-@\t\r]/u.test(wert) ? `'${wert}` : wert;
}
function wertMaskieren(rohwert: string, trennzeichen: CsvTrennzeichen): string {
const wert = formelEntschaerfen(rohwert);
const noetig =
wert.includes(trennzeichen) ||
wert.includes('"') ||
wert.includes("\n") ||
wert.includes("\r") ||
wert !== wert.trim();
return noetig ? `"${wert.replaceAll('"', '""')}"` : wert;
}
/**
* Erzeugt eine CSV-Datei.
*
* Steht neben dem Zerlegen, obwohl der Export erst spaeter gebraucht wird: die
* Regeln fuer Anfuehrungszeichen und Trennzeichen gelten in beide Richtungen,
* und getrennt gepflegt laufen sie auseinander. Der Test schreibt und liest
* deshalb im Kreis.
*
* `spalten` gibt die Reihenfolge **und** die Kopfzeile vor, wie sie geschrieben
* wird — beim Export sollen die Spalten ruhig „serialNumber“ heissen.
*/
export function csvErzeugen(
spalten: readonly string[],
zeilen: readonly Readonly<Record<string, string | number | null | undefined>>[],
optionen: ErzeugeOptionen = {},
): string {
const trennzeichen = optionen.trennzeichen ?? ";";
const zeilenText = [
spalten.map((spalte) => wertMaskieren(spalte, trennzeichen)).join(trennzeichen),
...zeilen.map((zeile) =>
spalten
.map((spalte) => {
const wert = zeile[spalte];
return wertMaskieren(wert === null || wert === undefined ? "" : String(wert), trennzeichen);
})
.join(trennzeichen),
),
].join(ZEILENENDE);
return (optionen.mitBom === false ? "" : BOM) + zeilenText + ZEILENENDE;
}
+21
View File
@@ -0,0 +1,21 @@
/**
* Gemeinsame Klassen der Formularfelder (#011).
*
* Stand vorher fuenfmal wortgleich in fuenf Dateien — und war damit genau die
* Sorte Wiederholung, die beim naechsten Feinschliff an vier Stellen
* unveraendert bleibt. In `src/lib`, weil Server- und Client-Komponenten sie
* gleichermassen brauchen.
*/
/** Eingabefelder sind am Handy 44 px hoch (`min-h-11`), sonst trifft sie niemand. */
export const FELD =
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
/**
* Kontrollkaestchen.
*
* Die umschliessende Beschriftung ist 44 px hoch und schaltet mit, die
* Trefferflaeche stimmt also auch ohne diese Groesse — sichtbar zu klein bleibt
* es trotzdem.
*/
export const KAESTCHEN = "size-4 accent-akzent";
+23
View File
@@ -0,0 +1,23 @@
import { GeraeteTyp } from "@/generated/prisma/enums";
/**
* Beschriftungen der Geraete (#011).
*
* In `src/lib`, weil beide Seiten sie brauchen und nichts aus `src/server/*` ins
* Browser-Buendel geraten darf — dieselbe Regel wie bei `ROLLEN_TEXT`
* (CLAUDE.md). Die Liste der Typen kommt aus dem Enum, damit ein neuer Typ nicht
* an zwei Stellen eingetragen werden muss.
*/
export const GERAETE_TYP_TEXT: Readonly<Record<GeraeteTyp, string>> = {
PC: "PC",
NOTEBOOK: "Notebook",
SERVER: "Server",
FIREWALL: "Firewall",
SWITCH: "Switch",
DRUCKER: "Drucker",
RAUM: "Raum",
SONSTIGES: "Sonstiges",
};
export const GERAETE_TYP_LISTE: readonly GeraeteTyp[] = Object.values(GeraeteTyp);
+14 -3
View File
@@ -12,6 +12,14 @@ export type Betriebsmodus = "INTERN" | "DIENSTLEISTUNG";
export type NavEintrag = {
readonly pfad: string;
readonly beschriftung: string;
/**
* Abweichende Beschriftung im Modus DIENSTLEISTUNG.
*
* Dort heisst „Mitarbeiter“ „Personen“: es sind die Leute des Kunden, nicht
* die eigenen Angestellten. Derselbe Eintrag, dieselbe Seite — nur das Wort
* passt sich an (#008, ausgebaut in #011).
*/
readonly beschriftungDienstleistung?: string;
/** Nur in diesem Modus zeigen. Fehlt der Wert, gilt der Eintrag in beiden. */
readonly nurModus?: Betriebsmodus;
readonly symbol: string;
@@ -29,10 +37,9 @@ export const NAVIGATION: readonly NavEintrag[] = [
{
pfad: "/mitarbeiter",
beschriftung: "Mitarbeiter & Geräte",
nurModus: "INTERN",
beschriftungDienstleistung: "Personen & Geräte",
symbol: "Users",
verfuegbar: false,
ausIssue: 11,
verfuegbar: true,
},
{
pfad: "/kunden",
@@ -59,5 +66,9 @@ export function navigationFuer(
return NAVIGATION.filter(
(e) =>
(e.nurModus === undefined || e.nurModus === modus) && (e.recht === undefined || darfEintrag(e.recht)),
).map((eintrag) =>
modus === "DIENSTLEISTUNG" && eintrag.beschriftungDienstleistung
? { ...eintrag, beschriftung: eintrag.beschriftungDienstleistung }
: eintrag,
);
}
+150
View File
@@ -0,0 +1,150 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "@/lib/validierung";
import { MandantFehler, type HandelnderBenutzer } from "@/server/mandant";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import {
AbteilungsFehler,
abteilungAnlegen,
abteilungArchivieren,
abteilungBearbeiten,
} from "@/server/services/abteilungen";
/**
* Server Actions der Abteilungen (#011).
*
* Recht ist `verwaltung`: an einer Abteilung haengen Ziel-OU und
* Standardgruppen — also das, was die spaeteren Befehle in ein Verzeichnis
* schreiben. Ein Tippfehler dort legt ein Konto an der falschen Stelle an.
*/
export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: string };
const RECHTE_MELDUNG =
"Deine Anmeldung gilt nicht mehr. Melde dich in einem zweiten Tab neu an und klicke dann hier " +
"noch einmal — was du eingegeben hast, steht weiterhin auf dieser Seite.";
async function angemeldetOderMeldung(): Promise<
{ readonly benutzer: HandelnderBenutzer } | { readonly fehler: string }
> {
try {
const { benutzer } = await rechtErforderlich("verwaltung");
return { benutzer: { id: benutzer.id, rolle: benutzer.rolle } };
} catch (fehler) {
if (fehler instanceof RechteFehler) return { fehler: RECHTE_MELDUNG };
throw fehler;
}
}
function alsErgebnis(fehler: unknown, neutral: string): AktionsErgebnis {
if (fehler instanceof AbteilungsFehler || fehler instanceof MandantFehler) {
return { fehler: fehler.message };
}
console.error(`${neutral}:`, fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : fehler);
return { fehler: neutral };
}
const AbteilungsSchema = z.object({
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
zielOu: z.string().trim().optional(),
standardGruppen: z.string().trim().optional(),
mailDomain: z.string().trim().optional(),
notizen: z.string().trim().optional(),
kundeId: z.string().trim().optional(),
});
function ausFormular(formular: FormData) {
return AbteilungsSchema.safeParse({
name: formular.get("name"),
zielOu: formular.get("zielOu") ?? undefined,
standardGruppen: formular.get("standardGruppen") ?? undefined,
mailDomain: formular.get("mailDomain") ?? undefined,
notizen: formular.get("notizen") ?? undefined,
kundeId: formular.get("kundeId") ?? undefined,
});
}
/** Standardgruppen kommen als eine Zeile mit Kommas – so, wie sie im Befehl stehen. */
function gruppenZerlegen(roh: string | undefined): string[] {
return (roh ?? "")
.split(",")
.map((gruppe) => gruppe.trim())
.filter(Boolean);
}
export async function abteilungAnlegenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const eingabe = ausFormular(formular);
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
try {
const abteilung = await abteilungAnlegen(
{
...eingabe.data,
standardGruppen: gruppenZerlegen(eingabe.data.standardGruppen),
kundeId: eingabe.data.kundeId || null,
},
zugang.benutzer,
);
revalidatePath("/admin/abteilungen");
return { erfolg: `„${abteilung.name}“ wurde angelegt.` };
} catch (fehler) {
return alsErgebnis(fehler, "Die Abteilung konnte nicht angelegt werden.");
}
}
export async function abteilungBearbeitenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const abteilungId = z.string().min(1).safeParse(formular.get("abteilungId"));
if (!abteilungId.success) return { fehler: "Es fehlt die Abteilung." };
const eingabe = ausFormular(formular);
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
try {
await abteilungBearbeiten(
abteilungId.data,
{ ...eingabe.data, standardGruppen: gruppenZerlegen(eingabe.data.standardGruppen) },
zugang.benutzer,
);
revalidatePath("/admin/abteilungen");
return { erfolg: "Gespeichert." };
} catch (fehler) {
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
}
}
export async function abteilungArchivierenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const eingabe = z
.object({ abteilungId: z.string().min(1), archiviert: z.enum(["true", "false"]) })
.safeParse({ abteilungId: formular.get("abteilungId"), archiviert: formular.get("archiviert") });
if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." };
try {
await abteilungArchivieren(eingabe.data.abteilungId, eingabe.data.archiviert === "true", zugang.benutzer);
revalidatePath("/admin/abteilungen");
return {};
} catch (fehler) {
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
}
}
+289
View File
@@ -0,0 +1,289 @@
"use server";
import { revalidatePath } from "next/cache";
import { GeraeteTyp } from "@/generated/prisma/enums";
import { z } from "@/lib/validierung";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { MandantFehler, kundenEbeneSichtbar, type HandelnderBenutzer } from "@/server/mandant";
import {
GeraeteFehler,
geraeteImportieren,
geraetAnlegen,
geraetArchivieren,
geraetBearbeiten,
geraetLoeschen,
type ImportAbweisung,
} from "@/server/services/geraete";
/**
* Server Actions der Geraete (#011).
*
* Duenn: Recht pruefen, Zod anwenden, Dienst rufen. Das Recht ist
* `geraeteBearbeiten` — ADMIN, REDAKTEUR und TECHNIKER. Lesen darf jeder
* Angemeldete, das prueft die Seite selbst.
*/
export type AktionsErgebnis = {
readonly fehler?: string;
readonly erfolg?: string;
/** Nur beim CSV-Import: je abgewiesene Zeile eine Nummer und ein Grund. */
readonly abgewiesen?: readonly ImportAbweisung[];
};
const RECHTE_MELDUNG =
"Deine Anmeldung gilt nicht mehr. Melde dich in einem zweiten Tab neu an und klicke dann hier " +
"noch einmal — was du eingegeben hast, steht weiterhin auf dieser Seite.";
/**
* Die Rechtepruefung so, dass ein abgelaufenes Cookie kein Datenverlust ist.
*
* `rechtErforderlich` wirft. Ungefangen landet der Wurf in Nexts Fehlergrenze,
* die Seite wird ausgehaengt — und bei einem Formular mit halb eingetippten
* Stammdaten oder einer eingelesenen CSV-Datei ist das genau die Arbeit, die weg
* ist. Befund aus dem Review von #010, geloest wie dort (siehe
* src/server/actions/vorlagen.ts).
*/
async function angemeldetOderMeldung(
recht: "geraeteBearbeiten" | "verwaltung" = "geraeteBearbeiten",
): Promise<{ readonly benutzer: HandelnderBenutzer } | { readonly fehler: string }> {
try {
const { benutzer } = await rechtErforderlich(recht);
// Die Dienste brauchen von der Sitzung nur Kennung und Rolle.
return { benutzer: { id: benutzer.id, rolle: benutzer.rolle } };
} catch (fehler) {
if (fehler instanceof RechteFehler) return { fehler: RECHTE_MELDUNG };
throw fehler;
}
}
/** Uebersetzt die Fehler der Dienste in eine Meldung, alles andere in eine neutrale. */
function alsErgebnis(fehler: unknown, neutral: string): AktionsErgebnis {
if (fehler instanceof GeraeteFehler || fehler instanceof MandantFehler) {
return { fehler: fehler.message };
}
if (fehler instanceof RechteFehler) {
return { fehler: RECHTE_MELDUNG };
}
console.error(`${neutral}:`, fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : fehler);
return { fehler: neutral };
}
const TypSchema = z.enum(GeraeteTyp);
const AnlegenSchema = z.object({
hostname: z.string().trim().optional(),
bezeichnung: z.string().trim().optional(),
seriennummer: z.string().trim().optional(),
inventarnummer: z.string().trim().optional(),
typ: TypSchema.optional(),
modell: z.string().trim().optional(),
notizen: z.string().trim().optional(),
mitarbeiterId: z.string().trim().optional(),
kundeId: z.string().trim().optional(),
});
/**
* Ein Feld, das das Formular nicht mitschickt, ist `undefined` und heisst „nicht
* angefasst“. Ein mitgeschicktes leeres Feld heisst „geleert“ — beim Bearbeiten
* macht das den Unterschied.
*/
function feld(formular: FormData, name: string): string | undefined {
const wert = formular.get(name);
return typeof wert === "string" ? wert : undefined;
}
export async function geraetAnlegenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const eingabe = AnlegenSchema.safeParse({
hostname: feld(formular, "hostname"),
bezeichnung: feld(formular, "bezeichnung"),
seriennummer: feld(formular, "seriennummer"),
inventarnummer: feld(formular, "inventarnummer"),
typ: feld(formular, "typ") || undefined,
modell: feld(formular, "modell"),
notizen: feld(formular, "notizen"),
mitarbeiterId: feld(formular, "mitarbeiterId"),
kundeId: feld(formular, "kundeId"),
});
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
// Eine Kundenwahl gibt es nur im Modus Dienstleistung; `kundeErmitteln`
// verwirft eine mitgeschickte Kennung im Modus Intern ohnehin (#008).
const kundeId = (await kundenEbeneSichtbar()) ? eingabe.data.kundeId || null : null;
try {
const geraet = await geraetAnlegen(
{
hostname: eingabe.data.hostname ?? null,
bezeichnung: eingabe.data.bezeichnung ?? null,
seriennummer: eingabe.data.seriennummer ?? null,
inventarnummer: eingabe.data.inventarnummer ?? null,
...(eingabe.data.typ ? { typ: eingabe.data.typ } : {}),
modell: eingabe.data.modell ?? null,
notizen: eingabe.data.notizen ?? null,
mitarbeiterId: eingabe.data.mitarbeiterId ?? null,
kundeId,
},
zugang.benutzer,
);
revalidatePath("/geraete");
return {
erfolg: `„${geraet.hostname ?? geraet.bezeichnung ?? geraet.tagCode}“ wurde angelegt, Tag-Code ${geraet.tagCode}.`,
};
} catch (fehler) {
return alsErgebnis(fehler, "Das Gerät konnte nicht angelegt werden.");
}
}
const BearbeitenSchema = AnlegenSchema.extend({
geraetId: z.string().min(1),
/** Kommt als verstecktes Feld mit, damit ein Aenderungsversuch auffaellt. */
tagCode: z.string().trim().optional(),
});
export async function geraetBearbeitenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const eingabe = BearbeitenSchema.safeParse({
geraetId: feld(formular, "geraetId"),
hostname: feld(formular, "hostname"),
bezeichnung: feld(formular, "bezeichnung"),
seriennummer: feld(formular, "seriennummer"),
inventarnummer: feld(formular, "inventarnummer"),
typ: feld(formular, "typ") || undefined,
modell: feld(formular, "modell"),
notizen: feld(formular, "notizen"),
mitarbeiterId: feld(formular, "mitarbeiterId"),
tagCode: feld(formular, "tagCode"),
});
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
try {
await geraetBearbeiten(
eingabe.data.geraetId,
{
// Ein Feld, das das Formular nicht mitschickt, bleibt unberuehrt; ein
// leeres Feld heisst „geleert“. Deshalb `?? null` und nicht `|| null`.
...(eingabe.data.hostname === undefined ? {} : { hostname: eingabe.data.hostname }),
...(eingabe.data.bezeichnung === undefined ? {} : { bezeichnung: eingabe.data.bezeichnung }),
...(eingabe.data.seriennummer === undefined ? {} : { seriennummer: eingabe.data.seriennummer }),
...(eingabe.data.inventarnummer === undefined ? {} : { inventarnummer: eingabe.data.inventarnummer }),
...(eingabe.data.typ === undefined ? {} : { typ: eingabe.data.typ }),
...(eingabe.data.modell === undefined ? {} : { modell: eingabe.data.modell }),
...(eingabe.data.notizen === undefined ? {} : { notizen: eingabe.data.notizen }),
...(eingabe.data.mitarbeiterId === undefined ? {} : { mitarbeiterId: eingabe.data.mitarbeiterId }),
/*
* Auch ein **leer** mitgeschickter tagCode ist ein Aenderungsversuch und
* gehoert vor den Dienst. Mit `? :` auf den Wahrheitswert fiel er weg,
* und damit lief genau das ins Leere, was `GeraeteAenderung.tagCode`
* ausdruecklich sichtbar machen soll.
*/
...(eingabe.data.tagCode === undefined ? {} : { tagCode: eingabe.data.tagCode }),
},
zugang.benutzer,
);
revalidatePath("/geraete");
return { erfolg: "Gespeichert." };
} catch (fehler) {
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
}
}
export async function geraetArchivierenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const eingabe = z
.object({ geraetId: z.string().min(1), archiviert: z.enum(["true", "false"]) })
.safeParse({ geraetId: feld(formular, "geraetId"), archiviert: feld(formular, "archiviert") });
if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." };
try {
await geraetArchivieren(eingabe.data.geraetId, eingabe.data.archiviert === "true", zugang.benutzer);
revalidatePath("/geraete");
return {};
} catch (fehler) {
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
}
}
/**
* Endgueltig loeschen — nur `verwaltung`.
*
* Archivieren ist der Normalfall und steht jedem offen, der Geraete pflegt.
* Loeschen dagegen ist nicht ruecknehmbar und verbrennt den Tag-Code fuer immer:
* er kommt nach der Regel des Projekts nie zurueck, der gedruckte Aufkleber ist
* damit wertlos. Gebraucht wird es nur fuer den Vertipper von eben — und dafuer
* darf man den Administrator fragen. Abteilungen und Mitarbeiter haben aus
* demselben Grund gar keinen Loeschweg.
*/
export async function geraetLoeschenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung("verwaltung");
if ("fehler" in zugang) return zugang;
const geraetId = z.string().min(1).safeParse(feld(formular, "geraetId"));
if (!geraetId.success) return { fehler: "Es fehlt das Gerät." };
try {
await geraetLoeschen(geraetId.data, zugang.benutzer);
revalidatePath("/geraete");
return { erfolg: "Gelöscht. Der Tag-Code bleibt vergeben." };
} catch (fehler) {
return alsErgebnis(fehler, "Das Gerät konnte nicht gelöscht werden.");
}
}
export async function geraeteImportierenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const eingabe = z
.object({
inhalt: z.string().min(1, "Bitte eine CSV-Datei auswählen oder den Inhalt einfügen."),
kundeId: z.string().trim().optional(),
})
.safeParse({ inhalt: feld(formular, "inhalt"), kundeId: feld(formular, "kundeId") });
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
const kundeId = (await kundenEbeneSichtbar()) ? eingabe.data.kundeId || null : null;
try {
const ergebnis = await geraeteImportieren(eingabe.data.inhalt, zugang.benutzer, kundeId);
revalidatePath("/geraete");
const teile = [
`${ergebnis.angelegt} von ${ergebnis.gesamt} ${ergebnis.gesamt === 1 ? "Zeile" : "Zeilen"} angelegt`,
];
if (ergebnis.abgewiesen.length > 0) {
teile.push(
`${ergebnis.abgewiesen.length} ${ergebnis.abgewiesen.length === 1 ? "Zeile" : "Zeilen"} abgewiesen`,
);
}
return { erfolg: `${teile.join(", ")}.`, abgewiesen: ergebnis.abgewiesen };
} catch (fehler) {
return alsErgebnis(fehler, "Die Datei konnte nicht eingelesen werden.");
}
}
+155
View File
@@ -0,0 +1,155 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "@/lib/validierung";
import { MandantFehler } from "@/server/mandant";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import {
MitarbeiterFehler,
mitarbeiterAktivSetzen,
mitarbeiterAnlegen,
mitarbeiterBearbeiten,
} from "@/server/services/mitarbeiter";
import type { HandelnderBenutzer } from "@/server/mandant";
/**
* Server Actions der Mitarbeiter (#011).
*
* Duenn: Recht pruefen, Zod anwenden, Dienst rufen. Das Recht ist
* `mitarbeiterBearbeiten` — dieselben Rollen wie bei den Geraeten, weil der
* Techniker beim Onboarding vor der Person steht, bevor der naechste
* Verzeichnisabgleich gelaufen ist.
*/
export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: string };
const RECHTE_MELDUNG =
"Deine Anmeldung gilt nicht mehr. Melde dich in einem zweiten Tab neu an und klicke dann hier " +
"noch einmal — was du eingegeben hast, steht weiterhin auf dieser Seite.";
/**
* Faengt den Rechtefehler ab, statt ihn werfen zu lassen.
*
* Ein ungefangener Wurf landet in Nexts Fehlergrenze und haengt die Seite aus —
* samt allem, was im Formular stand. Nach zwoelf Stunden laeuft die Sitzung von
* selbst ab; das trifft sonst genau die Person, die gerade lange tippt (#010).
*/
async function angemeldetOderMeldung(): Promise<
{ readonly benutzer: HandelnderBenutzer } | { readonly fehler: string }
> {
try {
const { benutzer } = await rechtErforderlich("mitarbeiterBearbeiten");
return { benutzer: { id: benutzer.id, rolle: benutzer.rolle } };
} catch (fehler) {
if (fehler instanceof RechteFehler) return { fehler: RECHTE_MELDUNG };
throw fehler;
}
}
function alsErgebnis(fehler: unknown, neutral: string): AktionsErgebnis {
if (fehler instanceof MitarbeiterFehler || fehler instanceof MandantFehler) {
return { fehler: fehler.message };
}
console.error(`${neutral}:`, fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : fehler);
return { fehler: neutral };
}
const StammdatenSchema = z.object({
vorname: z.string().trim().min(1, "Bitte einen Vornamen angeben."),
nachname: z.string().trim().min(1, "Bitte einen Nachnamen angeben."),
anzeigename: z.string().trim().optional(),
mail: z.string().trim().optional(),
upn: z.string().trim().optional(),
sam: z.string().trim().optional(),
abteilung: z.string().trim().optional(),
titel: z.string().trim().optional(),
kundeId: z.string().trim().optional(),
});
function ausFormular(formular: FormData) {
return StammdatenSchema.safeParse({
vorname: formular.get("vorname"),
nachname: formular.get("nachname"),
anzeigename: formular.get("anzeigename") ?? undefined,
mail: formular.get("mail") ?? undefined,
upn: formular.get("upn") ?? undefined,
sam: formular.get("sam") ?? undefined,
abteilung: formular.get("abteilung") ?? undefined,
titel: formular.get("titel") ?? undefined,
kundeId: formular.get("kundeId") ?? undefined,
});
}
export async function mitarbeiterAnlegenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const eingabe = ausFormular(formular);
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
try {
const mitarbeiter = await mitarbeiterAnlegen(
{ ...eingabe.data, kundeId: eingabe.data.kundeId || null },
zugang.benutzer,
);
revalidatePath("/mitarbeiter");
return { erfolg: `„${mitarbeiter.anzeigename}“ wurde angelegt.` };
} catch (fehler) {
return alsErgebnis(fehler, "Die Person konnte nicht angelegt werden.");
}
}
export async function mitarbeiterBearbeitenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const mitarbeiterId = z.string().min(1).safeParse(formular.get("mitarbeiterId"));
if (!mitarbeiterId.success) return { fehler: "Es fehlt die Person." };
const eingabe = ausFormular(formular);
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
try {
await mitarbeiterBearbeiten(mitarbeiterId.data, eingabe.data, zugang.benutzer);
revalidatePath("/mitarbeiter");
revalidatePath(`/mitarbeiter/${mitarbeiterId.data}`);
return { erfolg: "Gespeichert." };
} catch (fehler) {
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
}
}
export async function mitarbeiterAktivSetzenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const eingabe = z
.object({ mitarbeiterId: z.string().min(1), aktiv: z.enum(["true", "false"]) })
.safeParse({ mitarbeiterId: formular.get("mitarbeiterId"), aktiv: formular.get("aktiv") });
if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." };
try {
const mitarbeiter = await mitarbeiterAktivSetzen(
eingabe.data.mitarbeiterId,
eingabe.data.aktiv === "true",
zugang.benutzer,
);
revalidatePath("/mitarbeiter");
revalidatePath(`/mitarbeiter/${eingabe.data.mitarbeiterId}`);
return { erfolg: mitarbeiter.aktiv ? "Wieder aktiv." : "Deaktiviert." };
} catch (fehler) {
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
}
}
+10
View File
@@ -26,6 +26,16 @@ export const RECHTE = {
laeufeBearbeiten: ["ADMIN", "REDAKTEUR", "TECHNIKER"],
/** Geraete und deren Stammdaten pflegen, Tags registrieren. */
geraeteBearbeiten: ["ADMIN", "REDAKTEUR", "TECHNIKER"],
/**
* Mitarbeiter von Hand anlegen, bearbeiten und deaktivieren (#011).
*
* Dieselben Rollen wie bei den Geraeten, und aus demselben Grund: beim
* Onboarding steht der Techniker vor der Person, bevor der naechste
* Verzeichnisabgleich gelaufen ist. Die **Abteilungen** bleiben dagegen bei
* `verwaltung` — an ihnen haengen Ziel-OU und Standardgruppen, also das, was
* die spaeteren PowerShell-Befehle in ein Verzeichnis schreiben.
*/
mitarbeiterBearbeiten: ["ADMIN", "REDAKTEUR", "TECHNIKER"],
/** Vorlagen anlegen, bearbeiten und veroeffentlichen. */
vorlagenBearbeiten: ["ADMIN", "REDAKTEUR"],
/**
+110
View File
@@ -0,0 +1,110 @@
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
/**
* Lesemodelle der Abteilungen (#011).
*
* Die Zahl der zugeordneten Personen kommt aus einer zweiten Abfrage statt aus
* `_count`: die Zuordnung laeuft ueber den **Namen** im Freitextfeld
* `Mitarbeiter.abteilung`, nicht ueber einen Fremdschluessel. So bleibt ein
* Abteilungswert aus dem Verzeichnis erhalten, auch wenn es dazu (noch) keine
* gepflegte Abteilung gibt — und genau diese Faelle will man sehen.
*/
export type AbteilungsZeile = {
readonly id: string;
readonly name: string;
readonly zielOu: string | null;
readonly standardGruppen: readonly string[];
readonly mailDomain: string | null;
readonly notizen: string | null;
readonly archiviert: boolean;
readonly kundeId: string;
readonly anzahlMitarbeiter: number;
};
async function kundenBedingung(benutzer: HandelnderBenutzer): Promise<{ kundeId: { in: string[] } }[]> {
const sichtbar = await sichtbareKundenIds(benutzer);
return sichtbar === undefined ? [] : [{ kundeId: { in: [...sichtbar] } }];
}
const AUSWAHL = {
id: true,
name: true,
zielOu: true,
standardGruppen: true,
mailDomain: true,
notizen: true,
archiviert: true,
kundeId: true,
} satisfies Prisma.AbteilungSelect;
export async function abteilungsListe(
benutzer: HandelnderBenutzer,
auchArchivierte = true,
): Promise<readonly AbteilungsZeile[]> {
const mandant = await kundenBedingung(benutzer);
const zeilen = await prisma.abteilung.findMany({
where: { AND: [...(auchArchivierte ? [] : [{ archiviert: false }]), ...mandant] },
orderBy: [{ archiviert: "asc" }, { name: "asc" }],
select: AUSWAHL,
});
// Eine Abfrage fuer alle Zaehlungen statt einer je Abteilung.
const zaehlung = await prisma.mitarbeiter.groupBy({
by: ["abteilung", "kundeId"],
where: { AND: [{ aktiv: true }, { abteilung: { not: null } }, ...mandant] },
_count: { _all: true },
});
const anzahlNach = new Map(
zaehlung.map((eintrag) => [`${eintrag.kundeId}\u0000${eintrag.abteilung ?? ""}`, eintrag._count._all]),
);
return zeilen.map((zeile) => ({
...zeile,
standardGruppen: zeile.standardGruppen,
anzahlMitarbeiter: anzahlNach.get(`${zeile.kundeId}\u0000${zeile.name}`) ?? 0,
}));
}
/**
* Abteilungsnamen, die bei Mitarbeitern vorkommen, aber nicht gepflegt sind.
*
* Das ist der Vorschlag fuer „Abteilung anlegen“ nach einem Verzeichnisabgleich:
* die Namen stehen schon in den Daten, es fehlen nur Ziel-OU und Gruppen.
*
* Gruppiert wird nach **Kunde und Name**, nicht nach Name allein. Sonst gilt
* „Vertrieb“ als gepflegt, sobald **irgendein** Kunde eine Abteilung dieses
* Namens hat — und beim Kunden ohne sie blieben `{{ou}}` und `{{gruppen}}` im
* Lauf leer, ohne dass diese Seite es je sagte. Genau so stand es hier, bis der
* Review es gefunden hat; `abteilungsListe` darueber macht es seit jeher
* richtig.
*/
export async function ungepflegteAbteilungen(
benutzer: HandelnderBenutzer,
): Promise<readonly { name: string; anzahl: number; kundeId: string }[]> {
const mandant = await kundenBedingung(benutzer);
const [vorhandene, benutzte] = await Promise.all([
prisma.abteilung.findMany({ where: { AND: mandant }, select: { name: true, kundeId: true } }),
prisma.mitarbeiter.groupBy({
by: ["abteilung", "kundeId"],
where: { AND: [{ aktiv: true }, { abteilung: { not: null } }, ...mandant] },
_count: { _all: true },
}),
]);
const schluessel = (kundeId: string, name: string) => `${kundeId}\u0000${name}`;
const gepflegt = new Set(vorhandene.map((zeile) => schluessel(zeile.kundeId, zeile.name)));
return benutzte
.filter(
(eintrag) =>
eintrag.abteilung !== null && !gepflegt.has(schluessel(eintrag.kundeId, eintrag.abteilung)),
)
.map((eintrag) => ({
name: eintrag.abteilung as string,
anzahl: eintrag._count._all,
kundeId: eintrag.kundeId,
}))
.sort((a, b) => a.name.localeCompare(b.name, "de"));
}
+220
View File
@@ -0,0 +1,220 @@
import type { Prisma } from "@/generated/prisma/client";
import type { GeraeteTyp } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
/**
* Lesemodelle der Geraete (#011).
*
* Die Mandantensicht wenden diese Funktionen **selbst** an. Im Review von #009
* war genau das die Fundstelle fuer drei Blocker: ein Suchfilter brachte ein
* zweites `OR` mit und ueberschrieb damit die Kundenbedingung, weil beide in
* dasselbe Objektliteral gespreizt wurden. Deshalb liegt hier **jede**
* Teilbedingung in einem `AND`-Array, wo keine die andere ueberschreiben kann.
* `tests/geraete.test.ts` haelt das mit einem eigenen Test fest.
*/
export type GeraeteZeile = {
readonly id: string;
readonly tagCode: string;
readonly hostname: string | null;
readonly bezeichnung: string | null;
/** Hostname, sonst Bezeichnung, sonst der Tag-Code. Nie leer. */
readonly anzeige: string;
readonly typ: GeraeteTyp;
readonly modell: string | null;
readonly seriennummer: string | null;
readonly inventarnummer: string | null;
readonly notizen: string | null;
readonly archiviert: boolean;
/** Leer heisst: vorgedruckter Tag, noch keinem Objekt zugeordnet. */
readonly registriertAm: Date | null;
readonly kundeId: string;
readonly kundeName: string;
readonly besitzerId: string | null;
readonly besitzerName: string | null;
readonly besitzerAktiv: boolean | null;
readonly anzahlLaeufe: number;
};
export type GeraeteFilter = {
readonly suche?: string;
readonly typ?: GeraeteTyp;
readonly auchArchivierte?: boolean;
};
/**
* Die Mandantenbedingung als **eigener** Ausdruck, nicht als Objekt zum
* Hineinspreizen. `undefined` heisst „keine Einschraenkung“.
*
* Jede Geraetezeile traegt eine `kundeId` – auch im Modus INTERN, wo es die eine
* eigene Organisation ist. Es gibt hier also kein „gilt fuer alle“ wie bei den
* Vorlagen.
*/
async function kundenBedingung(benutzer: HandelnderBenutzer): Promise<Prisma.GeraetWhereInput | undefined> {
const sichtbar = await sichtbareKundenIds(benutzer);
if (sichtbar === undefined) return undefined;
return { kundeId: { in: [...sichtbar] } };
}
/**
* Auswahl und Abbildung stehen getrennt, damit Liste und Einzelabruf garantiert
* dieselben Felder liefern – zwei Modelle fuer dieselbe Zeile laufen mit der Zeit
* auseinander.
*/
const ZEILEN_AUSWAHL = {
id: true,
tagCode: true,
hostname: true,
bezeichnung: true,
typ: true,
modell: true,
seriennummer: true,
inventarnummer: true,
notizen: true,
archiviert: true,
registriertAm: true,
kundeId: true,
kunde: { select: { name: true } },
mitarbeiter: { select: { id: true, anzeigename: true, aktiv: true } },
_count: { select: { laeufe: true } },
} satisfies Prisma.GeraetSelect;
type Rohzeile = Prisma.GeraetGetPayload<{ select: typeof ZEILEN_AUSWAHL }>;
function zeileBauen(zeile: Rohzeile): GeraeteZeile {
return {
id: zeile.id,
tagCode: zeile.tagCode,
hostname: zeile.hostname,
bezeichnung: zeile.bezeichnung,
anzeige: zeile.hostname ?? zeile.bezeichnung ?? zeile.tagCode,
typ: zeile.typ,
modell: zeile.modell,
seriennummer: zeile.seriennummer,
inventarnummer: zeile.inventarnummer,
notizen: zeile.notizen,
archiviert: zeile.archiviert,
registriertAm: zeile.registriertAm,
kundeId: zeile.kundeId,
kundeName: zeile.kunde.name,
besitzerId: zeile.mitarbeiter?.id ?? null,
besitzerName: zeile.mitarbeiter?.anzeigename ?? null,
besitzerAktiv: zeile.mitarbeiter?.aktiv ?? null,
anzahlLaeufe: zeile._count.laeufe,
};
}
/**
* Die Suche geht ueber Hostname, Bezeichnung, Seriennummer und `tagCode`.
*
* Der `tagCode` ist dabei der Notnagel: wer einen Aufkleber in der Hand hat und
* die Kamera nicht benutzen kann, tippt ihn ab. Deshalb `contains` und nicht
* `equals` – die sechs Zeichen ohne das Praefix sollen genuegen.
*/
function sucheBedingung(suche: string): Prisma.GeraetWhereInput {
const wie = { contains: suche, mode: "insensitive" as const };
return {
OR: [
{ hostname: wie },
{ bezeichnung: wie },
{ seriennummer: wie },
{ inventarnummer: wie },
{ tagCode: wie },
],
};
}
export async function geraeteListe(
benutzer: HandelnderBenutzer,
filter: GeraeteFilter = {},
): Promise<readonly GeraeteZeile[]> {
const suche = filter.suche?.trim();
const bedingungen = [
await kundenBedingung(benutzer),
filter.auchArchivierte ? undefined : { archiviert: false },
filter.typ ? { typ: filter.typ } : undefined,
suche ? sucheBedingung(suche) : undefined,
].filter((bedingung): bedingung is Prisma.GeraetWhereInput => bedingung !== undefined);
const zeilen = await prisma.geraet.findMany({
where: { AND: bedingungen },
// Archivierte nach unten, dann nach Anzeigename. Prisma kann nicht nach
// „Hostname, sonst Bezeichnung“ sortieren; die zwei Schluessel danach kommen
// dem nahe genug und sind stabil.
orderBy: [{ archiviert: "asc" }, { hostname: "asc" }, { bezeichnung: "asc" }],
select: ZEILEN_AUSWAHL,
});
return zeilen.map(zeileBauen);
}
/**
* Ein einzelnes Geraet.
*
* `undefined` heisst „gibt es nicht oder darfst du nicht sehen“ — die
* Unterscheidung waere schon die Auskunft, die wir nicht geben wollen.
*/
export async function geraetHolen(
geraetId: string,
benutzer: HandelnderBenutzer,
): Promise<GeraeteZeile | undefined> {
const mandant = await kundenBedingung(benutzer);
const zeile = await prisma.geraet.findFirst({
where: { AND: [{ id: geraetId }, ...(mandant ? [mandant] : [])] },
select: ZEILEN_AUSWAHL,
});
return zeile ? zeileBauen(zeile) : undefined;
}
export type BesitzerKandidat = {
readonly id: string;
readonly anzeigename: string;
readonly aktiv: boolean;
/** Ein Geraet kann nur einer Person desselben Kunden gehoeren. */
readonly kundeId: string;
};
export type BesitzerAuswahl = {
readonly kandidaten: readonly BesitzerKandidat[];
/** Waren es mehr als die Grenze? Dann sagt die Oberflaeche das auch. */
readonly gekuerzt: boolean;
};
/**
* Die Personen, die als Besitzer in Frage kommen.
*
* Eigenes, schmales Lesemodell mit genau vier Feldern – die Geraeteseite braucht
* nichts weiter, und die Mitarbeitersicht aus #011 wird an anderer Stelle
* gebaut. Deaktivierte sind nicht dabei: wer offboardet, aendert den Besitzer
* nicht mehr, sondern gibt das Geraet zurueck.
*
* Die Grenze ist da, damit ein Verzeichnis mit zehntausend Personen nicht in ein
* Auswahlfeld gegossen wird. Dass gekuerzt wurde, bleibt nicht verborgen.
*/
export async function besitzerKandidaten(
benutzer: HandelnderBenutzer,
grenze = 500,
): Promise<BesitzerAuswahl> {
const sichtbar = await sichtbareKundenIds(benutzer);
const zeilen = await prisma.mitarbeiter.findMany({
where: {
AND: [{ aktiv: true }, ...(sichtbar === undefined ? [] : [{ kundeId: { in: [...sichtbar] } }])],
},
select: { id: true, anzeigename: true, aktiv: true, kundeId: true },
orderBy: { anzeigename: "asc" },
take: grenze + 1,
});
return {
/*
* `kundeId` bleibt drin: ein Geraet kann nur einer Person **desselben**
* Kunden gehoeren (zusammengesetzter Fremdschluessel, siehe
* services/geraete.ts). Ohne das Feld boete die Auswahlliste Personen an,
* die der Dienst danach abweist — eine Wahl, die sie nicht einloesen kann.
*/
kandidaten: zeilen
.slice(0, grenze)
.map(({ id, anzeigename, aktiv, kundeId }) => ({ id, anzeigename, aktiv, kundeId })),
gekuerzt: zeilen.length > grenze,
};
}
+216
View File
@@ -0,0 +1,216 @@
import type { Prisma } from "@/generated/prisma/client";
import { Herkunft } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
/**
* Lesemodelle der Mitarbeiter (#011).
*
* Wie ueberall wenden sie die Mandantensicht **selbst** an. Im Review von #009
* lag genau hier die Fundstelle fuer drei Blocker: ein Suchfilter brachte ein
* zweites `OR` mit und ueberschrieb die Kundenbedingung — ein Buchstabe im
* Suchfeld genuegte. Deshalb liegen alle Teilbedingungen in einem `AND`, wo
* keine die andere erreichen kann.
*/
export type MitarbeiterZeile = {
readonly id: string;
readonly anzeigename: string;
readonly vorname: string;
readonly nachname: string;
readonly mail: string | null;
readonly upn: string | null;
readonly sam: string | null;
readonly abteilung: string | null;
readonly titel: string | null;
readonly aktiv: boolean;
readonly herkunft: Herkunft;
readonly kundeId: string;
readonly kundeName: string;
readonly anzahlGeraete: number;
readonly anzahlLaeufe: number;
};
export type MitarbeiterFilter = {
readonly suche?: string;
readonly abteilung?: string;
/** Standard ist „nur aktive“ – fuers Offboarding schaltet man es um. */
readonly auchDeaktivierte?: boolean;
};
/**
* Die Mandantenbedingung als **Liste von Teilbedingungen**, nie als Objekt zum
* Hineinspreizen: so kann kein zweiter Filter mit demselben Schluessel sie
* ueberschreiben. `[]` heisst „keine Einschraenkung“.
*
* Dieselbe Form passt fuer Mitarbeiter und Geraete, weil beide `kundeId`
* tragen — jede kundenbezogene Zeile tut das (#008).
*/
async function kundenBedingung(benutzer: HandelnderBenutzer): Promise<{ kundeId: { in: string[] } }[]> {
const sichtbar = await sichtbareKundenIds(benutzer);
return sichtbar === undefined ? [] : [{ kundeId: { in: [...sichtbar] } }];
}
const ZEILEN_AUSWAHL = {
id: true,
anzeigename: true,
vorname: true,
nachname: true,
mail: true,
upn: true,
sam: true,
abteilung: true,
titel: true,
aktiv: true,
herkunft: true,
kundeId: true,
kunde: { select: { name: true } },
_count: { select: { geraete: true, laeufe: true } },
} satisfies Prisma.MitarbeiterSelect;
type Rohzeile = Prisma.MitarbeiterGetPayload<{ select: typeof ZEILEN_AUSWAHL }>;
function zeileBauen(zeile: Rohzeile): MitarbeiterZeile {
return {
id: zeile.id,
anzeigename: zeile.anzeigename,
vorname: zeile.vorname,
nachname: zeile.nachname,
mail: zeile.mail,
upn: zeile.upn,
sam: zeile.sam,
abteilung: zeile.abteilung,
titel: zeile.titel,
aktiv: zeile.aktiv,
herkunft: zeile.herkunft,
kundeId: zeile.kundeId,
/*
* Immer der Name, auch bei der eigenen Organisation. Ob er **gezeigt** wird,
* entscheidet der Betriebsmodus — und das entscheidet die Seite mit
* `kundenEbeneSichtbar()`, nicht das Lesemodell. Vorher haengte es hier an
* `istEigeneOrganisation`, und im Modus Dienstleistung fehlte die Pille
* ausgerechnet bei einem Kunden: „keine Pille“ hiess dann nicht „kein
* Kunde“, sondern „ausgerechnet dieser eine“.
*/
kundeName: zeile.kunde.name,
anzahlGeraete: zeile._count.geraete,
anzahlLaeufe: zeile._count.laeufe,
};
}
export async function mitarbeiterListe(
benutzer: HandelnderBenutzer,
filter: MitarbeiterFilter = {},
): Promise<readonly MitarbeiterZeile[]> {
const suche = filter.suche?.trim();
const bedingungen = [
...(await kundenBedingung(benutzer)),
filter.auchDeaktivierte ? undefined : { aktiv: true },
filter.abteilung ? { abteilung: filter.abteilung } : undefined,
// Gesucht wird ueber die vier Felder, unter denen eine Person im Haus
// bekannt ist. Der Anzeigename allein reicht nicht: im Ticket steht oft nur
// die Mail-Adresse oder der sAMAccountName.
suche
? {
OR: [
{ anzeigename: { contains: suche, mode: "insensitive" as const } },
{ mail: { contains: suche, mode: "insensitive" as const } },
{ upn: { contains: suche, mode: "insensitive" as const } },
{ sam: { contains: suche, mode: "insensitive" as const } },
],
}
: undefined,
].filter((bedingung) => bedingung !== undefined);
const zeilen = await prisma.mitarbeiter.findMany({
where: { AND: bedingungen },
orderBy: [{ aktiv: "desc" }, { nachname: "asc" }, { vorname: "asc" }],
select: ZEILEN_AUSWAHL,
});
return zeilen.map(zeileBauen);
}
/** `undefined` heisst „gibt es nicht oder darfst du nicht sehen“. */
export async function mitarbeiterHolen(
mitarbeiterId: string,
benutzer: HandelnderBenutzer,
): Promise<MitarbeiterZeile | undefined> {
const zeile = await prisma.mitarbeiter.findFirst({
where: { AND: [{ id: mitarbeiterId }, ...(await kundenBedingung(benutzer))] },
select: ZEILEN_AUSWAHL,
});
return zeile ? zeileBauen(zeile) : undefined;
}
export type MitarbeiterGeraet = {
readonly id: string;
readonly tagCode: string;
readonly name: string;
readonly typ: string;
readonly archiviert: boolean;
};
/** Die Geraete einer Person – fuer die Mitarbeiterseite. */
export async function geraeteDesMitarbeiters(
mitarbeiterId: string,
benutzer: HandelnderBenutzer,
): Promise<readonly MitarbeiterGeraet[]> {
const zeilen = await prisma.geraet.findMany({
where: { AND: [{ mitarbeiterId }, ...(await kundenBedingung(benutzer))] },
orderBy: [{ archiviert: "asc" }, { hostname: "asc" }],
select: { id: true, tagCode: true, hostname: true, bezeichnung: true, typ: true, archiviert: true },
});
return zeilen.map((zeile) => ({
id: zeile.id,
tagCode: zeile.tagCode,
// Hostname zuerst: danach sucht man. Ohne Hostname traegt die Bezeichnung.
name: zeile.hostname ?? zeile.bezeichnung ?? zeile.tagCode,
typ: zeile.typ,
archiviert: zeile.archiviert,
}));
}
/**
* Der Vorgesetzte einer Person – oder `undefined`.
*
* Die Verknuepfung laeuft ueber `vorgesetzterExterneId` und die `externeId`
* **derselben Verzeichnisquelle**: die Kennung ist nur innerhalb einer Quelle
* eindeutig, und ohne diese Einschraenkung zoege ein zweites Verzeichnis
* fremde Treffer herbei. Gefuellt wird das Feld vom Abgleich (#016); bei einer
* von Hand angelegten Person steht hier nichts, und das ist richtig so.
*/
export async function vorgesetzterVon(
mitarbeiterId: string,
benutzer: HandelnderBenutzer,
): Promise<{ readonly id: string; readonly anzeigename: string } | undefined> {
const person = await prisma.mitarbeiter.findFirst({
where: { AND: [{ id: mitarbeiterId }, ...(await kundenBedingung(benutzer))] },
select: { quelleId: true, vorgesetzterExterneId: true },
});
if (!person?.vorgesetzterExterneId || !person.quelleId) return undefined;
const vorgesetzter = await prisma.mitarbeiter.findFirst({
where: {
AND: [
{ quelleId: person.quelleId, externeId: person.vorgesetzterExterneId },
...(await kundenBedingung(benutzer)),
],
},
select: { id: true, anzeigename: true },
});
return vorgesetzter ?? undefined;
}
/** Die vorkommenden Abteilungsnamen – fuer den Filter. */
export async function abteilungsNamen(benutzer: HandelnderBenutzer): Promise<readonly string[]> {
const zeilen = await prisma.mitarbeiter.findMany({
where: { AND: [{ abteilung: { not: null } }, ...(await kundenBedingung(benutzer))] },
select: { abteilung: true },
distinct: ["abteilung"],
});
return zeilen
.map((zeile) => zeile.abteilung)
.filter((name): name is string => name !== null)
.sort((a, b) => a.localeCompare(b, "de"));
}
+214
View File
@@ -0,0 +1,214 @@
import type { Abteilung, Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { kundeErmitteln, kundeZugriffPruefen, type HandelnderBenutzer } from "@/server/mandant";
/**
* Abteilungen (#011).
*
* Eine Abteilung ist mehr als eine Beschriftung: an ihr haengen die Ziel-OU und
* die Standardgruppen, also genau das, was die spaeteren PowerShell-Befehle in
* ein Verzeichnis schreiben (#012). Ein Tippfehler in der Ziel-OU legt ein Konto
* an der falschen Stelle an — deshalb gehoert das Pflegen zu `verwaltung` und
* nicht zur taeglichen Technikerarbeit.
*
* Der Name ist **je Kunde** eindeutig (`@@unique([kundeId, name])`); im Modus
* INTERN ist das die eine eigene Organisation.
*/
export class AbteilungsFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "AbteilungsFehler";
}
}
export type AbteilungsEingabe = {
readonly name: string;
readonly zielOu?: string | null;
readonly standardGruppen?: readonly string[];
readonly mailDomain?: string | null;
readonly notizen?: string | null;
readonly kundeId?: string | null;
};
function leerAlsNull(wert: string | null | undefined): string | null {
const sauber = wert?.trim();
return sauber ? sauber : null;
}
function namePruefen(roh: string): string {
const name = roh.trim();
if (!name) throw new AbteilungsFehler("Eine Abteilung braucht einen Namen.");
return name;
}
/**
* Eine Mail-Domain ist keine Adresse.
*
* Geprueft wird nur grob: kein `@`, mindestens ein Punkt. Strenger waere eine
* Zusage, die wir nicht halten koennen — gueltige Domains gibt es in mehr Formen,
* als ein Ausdruck sinnvoll abbildet.
*/
function mailDomainPruefen(wert: string | null): string | null {
if (wert === null) return null;
const domain = wert.replace(/^@/u, "");
if (domain.includes("@") || !domain.includes(".")) {
throw new AbteilungsFehler(
`„${wert}“ sieht nicht wie eine Mail-Domain aus. Erwartet wird etwa „firma.de“, keine Adresse.`,
);
}
return domain.toLowerCase();
}
/** Leere Einträge und Doppelungen raus, Reihenfolge bleibt. */
function gruppenSaeubern(gruppen: readonly string[] | undefined): string[] {
const gesehen = new Set<string>();
const sauber: string[] = [];
for (const gruppe of gruppen ?? []) {
const wert = gruppe.trim();
if (!wert || gesehen.has(wert)) continue;
gesehen.add(wert);
sauber.push(wert);
}
return sauber;
}
export async function abteilungAnlegen(
eingabe: AbteilungsEingabe,
benutzer: HandelnderBenutzer,
): Promise<Abteilung> {
const name = namePruefen(eingabe.name);
const kundeId = await kundeErmitteln(eingabe.kundeId, benutzer);
const vorhanden = await prisma.abteilung.findUnique({ where: { kundeId_name: { kundeId, name } } });
if (vorhanden) {
// Die Datenbank wuerde es ohnehin abweisen; hier steht der Satz, den ein
// Mensch lesen kann.
throw new AbteilungsFehler(`Die Abteilung „${name}“ gibt es schon.`);
}
return prisma.$transaction(async (tx) => {
const abteilung = await tx.abteilung.create({
data: {
kundeId,
name,
zielOu: leerAlsNull(eingabe.zielOu),
standardGruppen: gruppenSaeubern(eingabe.standardGruppen),
mailDomain: mailDomainPruefen(leerAlsNull(eingabe.mailDomain)),
notizen: leerAlsNull(eingabe.notizen),
},
});
await tx.protokoll.create({
data: {
entitaet: "Abteilung",
entitaetId: abteilung.id,
aktion: "Abteilung angelegt",
benutzerId: benutzer.id,
nachher: { name: abteilung.name, zielOu: abteilung.zielOu },
},
});
return abteilung;
});
}
export async function abteilungBearbeiten(
abteilungId: string,
aenderung: Partial<AbteilungsEingabe>,
benutzer: HandelnderBenutzer,
): Promise<Abteilung> {
const vorher = await prisma.abteilung.findUnique({ where: { id: abteilungId } });
if (!vorher) throw new AbteilungsFehler("Diese Abteilung gibt es nicht (mehr).");
await kundeZugriffPruefen(vorher.kundeId, benutzer);
const daten: Prisma.AbteilungUpdateInput = {};
const geaendert: string[] = [];
if (aenderung.name !== undefined) {
const name = namePruefen(aenderung.name);
if (name !== vorher.name) {
const andere = await prisma.abteilung.findUnique({
where: { kundeId_name: { kundeId: vorher.kundeId, name } },
});
if (andere) throw new AbteilungsFehler(`Die Abteilung „${name}“ gibt es schon.`);
daten.name = name;
geaendert.push("Name");
}
}
if (aenderung.zielOu !== undefined) {
const zielOu = leerAlsNull(aenderung.zielOu);
if (zielOu !== vorher.zielOu) {
daten.zielOu = zielOu;
geaendert.push("Ziel-OU");
}
}
if (aenderung.mailDomain !== undefined) {
const mailDomain = mailDomainPruefen(leerAlsNull(aenderung.mailDomain));
if (mailDomain !== vorher.mailDomain) {
daten.mailDomain = mailDomain;
geaendert.push("Mail-Domain");
}
}
if (aenderung.notizen !== undefined) {
const notizen = leerAlsNull(aenderung.notizen);
if (notizen !== vorher.notizen) {
daten.notizen = notizen;
geaendert.push("Notizen");
}
}
if (aenderung.standardGruppen !== undefined) {
const gruppen = gruppenSaeubern(aenderung.standardGruppen);
if (gruppen.join("\u0000") !== vorher.standardGruppen.join("\u0000")) {
daten.standardGruppen = gruppen;
geaendert.push("Standardgruppen");
}
}
if (geaendert.length === 0) return vorher;
return prisma.$transaction(async (tx) => {
const nachher = await tx.abteilung.update({ where: { id: abteilungId }, data: daten });
await tx.protokoll.create({
data: {
entitaet: "Abteilung",
entitaetId: abteilungId,
aktion: `${geaendert.join(" und ")} geändert`,
benutzerId: benutzer.id,
vorher: { name: vorher.name, zielOu: vorher.zielOu, standardGruppen: vorher.standardGruppen },
nachher: { name: nachher.name, zielOu: nachher.zielOu, standardGruppen: nachher.standardGruppen },
},
});
return nachher;
});
}
/**
* Archivieren statt loeschen.
*
* An einer Abteilung haengen Laeufe, und ein archivierter Lauf muss auch in zehn
* Jahren noch sagen koennen, fuer welche Abteilung er lief. Der Fremdschluessel
* steht deshalb auf `Restrict`, und es gibt hier keinen Loeschweg.
*/
export async function abteilungArchivieren(
abteilungId: string,
archiviert: boolean,
benutzer: HandelnderBenutzer,
): Promise<Abteilung> {
const vorher = await prisma.abteilung.findUnique({ where: { id: abteilungId } });
if (!vorher) throw new AbteilungsFehler("Diese Abteilung gibt es nicht (mehr).");
await kundeZugriffPruefen(vorher.kundeId, benutzer);
if (vorher.archiviert === archiviert) return vorher;
return prisma.$transaction(async (tx) => {
const nachher = await tx.abteilung.update({ where: { id: abteilungId }, data: { archiviert } });
await tx.protokoll.create({
data: {
entitaet: "Abteilung",
entitaetId: abteilungId,
aktion: archiviert ? "Abteilung archiviert" : "Abteilung wieder freigegeben",
benutzerId: benutzer.id,
nachher: { archiviert },
},
});
return nachher;
});
}
+640
View File
@@ -0,0 +1,640 @@
import { Prisma, type Geraet } from "@/generated/prisma/client";
import { GeraeteTyp } from "@/generated/prisma/enums";
import { CsvFehler, csvZerlegen, hatSpalte, wertAus, type CsvZeile } from "@/lib/csv";
import { prisma } from "@/lib/db";
import { tagCodeEindeutigErzeugen } from "@/lib/tagcode";
import { kundeErmitteln, kundeZugriffPruefen, type HandelnderBenutzer } from "@/server/mandant";
/**
* Geraete und ihre Stammdaten (#011).
*
* Ein Geraet ist nicht nur ein PC: ein Serverschrank, ein Raum oder eine
* Firewall bekommen genauso einen Aufkleber. Drei Regeln tragen das Ganze:
*
* 1. Der `tagCode` wird beim Anlegen erzeugt, nie geaendert und nie
* wiederverwendet. Er landet zusaetzlich in `TagCodeVergabe`, damit er auch
* nach dem Loeschen des Geraets vergeben bleibt.
* 2. Ein **registriertes** Geraet braucht einen Hostname oder eine
* Bezeichnung. Ein vorgedruckter, noch nicht zugeordneter Tag
* (`registriertAm = null`) darf beides leer haben.
* 3. Geloescht wird nur, solange kein Lauf am Geraet haengt. Sonst archiviert.
*
* Alle drei stehen zusaetzlich in der Datenbank (CHECK `Geraet_tagcode_form`,
* CHECK `Geraet_registriert_braucht_namen`, Fremdschluessel auf `Restrict`).
* Hier stehen sie fuer die verstaendliche Meldung.
*/
export class GeraeteFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "GeraeteFehler";
}
}
export type NeuesGeraet = {
readonly hostname?: string | null;
readonly bezeichnung?: string | null;
readonly seriennummer?: string | null;
readonly inventarnummer?: string | null;
readonly typ?: GeraeteTyp;
readonly modell?: string | null;
readonly notizen?: string | null;
/** Besitzer bzw. Hauptnutzer. Server, Raeume und Poolgeraete haben keinen. */
readonly mitarbeiterId?: string | null;
/** Nur im Modus DIENSTLEISTUNG beachtet; im Modus INTERN verworfen (#008). */
readonly kundeId?: string | null;
/**
* `false` legt einen vorgedruckten Tag ohne Zuordnung an – dann sind Hostname
* und Bezeichnung erlaubt leer. Standard ist `true`, weil jedes von Hand oder
* per CSV angelegte Geraet ein echtes Objekt beschreibt.
*/
readonly registrieren?: boolean;
};
export type GeraeteAenderung = {
readonly hostname?: string | null;
readonly bezeichnung?: string | null;
readonly seriennummer?: string | null;
readonly inventarnummer?: string | null;
readonly typ?: GeraeteTyp;
readonly modell?: string | null;
readonly notizen?: string | null;
readonly mitarbeiterId?: string | null;
/**
* Nimmt einen mitgeschickten `tagCode` nur an, um ihn abzuweisen, wenn er
* abweicht. Ohne dieses Feld liefe ein Aenderungsversuch ins Leere und saehe
* fuer den Aufrufer wie ein Erfolg aus.
*/
readonly tagCode?: string;
/** Macht aus einem vorgedruckten Tag ein registriertes Geraet. */
readonly registrieren?: boolean;
};
/** Leer und „nur Leerraum“ sind dasselbe wie „nicht gesetzt“. */
function text(wert: string | null | undefined): string | null {
return wert?.trim() || null;
}
/** Wie das Geraet in einer Meldung heisst. Der tagCode ist immer da. */
function geraetName(geraet: {
readonly hostname: string | null;
readonly bezeichnung: string | null;
readonly tagCode: string;
}): string {
return geraet.hostname ?? geraet.bezeichnung ?? geraet.tagCode;
}
/**
* Die Pflichtregel aus dem Auftrag, an einer Stelle.
*
* Geprueft wird der **Zustand danach**, nicht die Eingabe: wer den Hostname
* leert, ohne eine Bezeichnung zu setzen, faellt genauso hierauf wie wer ohne
* beides anlegt.
*/
function namenPflichtPruefen(
hostname: string | null,
bezeichnung: string | null,
registriert: boolean,
): void {
if (registriert && !hostname && !bezeichnung) {
throw new GeraeteFehler(
"Ein registriertes Gerät braucht einen Hostname oder eine Bezeichnung — " +
"sonst steht es in jeder Liste nur als Aufkleber.",
);
}
}
/**
* Der Besitzer muss zum selben Kunden gehoeren.
*
* Die Datenbank erzwingt das ueber den zusammengesetzten Fremdschluessel
* `Geraet_besitzer_gleicher_kunde`. Hier steht es fuer die Meldung — „Foreign key
* constraint violated“ hilft niemandem.
*
* Ein **deaktivierter** Mitarbeiter wird bewusst nicht abgewiesen: beim
* Offboarding bleibt das Notebook der Person zugeordnet, bis es zurueck ist.
*/
async function besitzerPruefen(mitarbeiterId: string, kundeId: string): Promise<void> {
const person = await prisma.mitarbeiter.findUnique({
where: { id: mitarbeiterId },
select: { id: true, kundeId: true, anzeigename: true },
});
if (!person) throw new GeraeteFehler("Diese Person gibt es nicht.");
if (person.kundeId !== kundeId) {
throw new GeraeteFehler(
`„${person.anzeigename}“ gehört zu einem anderen Kunden und kann dieses Gerät nicht besitzen.`,
);
}
}
/**
* Zieht einen noch freien `tagCode`.
*
* Gefragt wird in beiden Buechern: `TagCodeVergabe` ist das Verzeichnis aller je
* vergebenen Codes, `Geraet.tagCode` der eindeutige Index, der im Zweifel
* entscheidet. Ein Code, der nur in einem von beiden steht, ist trotzdem
* vergeben.
*/
async function freienTagCodeZiehen(): Promise<string> {
return tagCodeEindeutigErzeugen(async (code) => {
const [vergabe, geraet] = await Promise.all([
prisma.tagCodeVergabe.findUnique({ where: { tagCode: code }, select: { tagCode: true } }),
prisma.geraet.findUnique({ where: { tagCode: code }, select: { id: true } }),
]);
return vergabe !== null || geraet !== null;
});
}
type SchreibAuftrag = {
readonly eingabe: NeuesGeraet;
readonly kundeId: string;
readonly durchBenutzerId: string;
readonly aktion: string;
/** Kommt in `Protokoll.nachher` mit – beim Import die Zeilennummer. */
readonly zusatz?: Readonly<Record<string, string | number>>;
};
/**
* Das Anlegen selbst, ohne Mandantenermittlung.
*
* Getrennt, weil der CSV-Import den Kunden **einmal** ermittelt und dann Zeile
* fuer Zeile schreibt. Zweimal denselben Weg zu bauen waere der sichere Weg,
* dass eine Regel nur in einem von beiden landet.
*/
async function geraetSchreiben(auftrag: SchreibAuftrag): Promise<Geraet> {
const { eingabe, kundeId, durchBenutzerId } = auftrag;
const registriert = eingabe.registrieren ?? true;
const hostname = text(eingabe.hostname);
const bezeichnung = text(eingabe.bezeichnung);
namenPflichtPruefen(hostname, bezeichnung, registriert);
const mitarbeiterId = text(eingabe.mitarbeiterId);
if (mitarbeiterId) await besitzerPruefen(mitarbeiterId, kundeId);
const tagCode = await freienTagCodeZiehen();
try {
return await prisma.$transaction(async (tx) => {
// Beides in einer Transaktion: ein Geraet mit einem Code, der nicht im
// Vergabebuch steht, waere nach dem Loeschen des Geraets wieder frei – und
// ein alter Aufkleber im Regal zeigte auf ein fremdes Objekt.
await tx.tagCodeVergabe.create({ data: { tagCode } });
const geraet = await tx.geraet.create({
data: {
kundeId,
tagCode,
hostname,
bezeichnung,
seriennummer: text(eingabe.seriennummer),
inventarnummer: text(eingabe.inventarnummer),
typ: eingabe.typ ?? GeraeteTyp.SONSTIGES,
modell: text(eingabe.modell),
notizen: text(eingabe.notizen),
mitarbeiterId,
registriertAm: registriert ? new Date() : null,
},
});
await tx.protokoll.create({
data: {
entitaet: "Geraet",
entitaetId: geraet.id,
aktion: auftrag.aktion,
benutzerId: durchBenutzerId,
nachher: {
tagCode: geraet.tagCode,
hostname: geraet.hostname,
bezeichnung: geraet.bezeichnung,
typ: geraet.typ,
...(auftrag.zusatz ?? {}),
},
},
});
return geraet;
});
} catch (fehler) {
// Zwei Anfragen haben in derselben Sekunde denselben Code gezogen. Bei 32^6
// Moeglichkeiten praktisch unmoeglich, aber wenn doch, dann lieber mit einer
// Meldung, die zum Wiederholen auffordert, als mit dem rohen Indexfehler.
if (fehler instanceof Prisma.PrismaClientKnownRequestError && fehler.code === "P2002") {
throw new GeraeteFehler("Der erzeugte Tag-Code war schon vergeben. Bitte noch einmal versuchen.");
}
throw fehler;
}
}
/** Legt ein Geraet an und erzeugt seinen `tagCode`. */
export async function geraetAnlegen(eingabe: NeuesGeraet, benutzer: HandelnderBenutzer): Promise<Geraet> {
const kundeId = await kundeErmitteln(eingabe.kundeId, benutzer);
return geraetSchreiben({
eingabe,
kundeId,
durchBenutzerId: benutzer.id,
aktion: "Gerät angelegt",
});
}
/**
* Aendert die Stammdaten eines Geraets.
*
* Der Kunde bleibt, wie er ist: ein Geraet zu einem anderen Kunden zu schieben
* wuerde die Laeufe, die daran haengen, mandantenfremd machen. Wer sich vertan
* hat, legt das Geraet beim richtigen Kunden neu an — der alte Tag-Code bleibt
* dann vergeben, und genau das ist richtig.
*/
export async function geraetBearbeiten(
geraetId: string,
aenderung: GeraeteAenderung,
benutzer: HandelnderBenutzer,
): Promise<Geraet> {
const vorher = await prisma.geraet.findUnique({ where: { id: geraetId } });
if (!vorher) throw new GeraeteFehler("Dieses Gerät gibt es nicht.");
await kundeZugriffPruefen(vorher.kundeId, benutzer);
if (aenderung.tagCode !== undefined && aenderung.tagCode.trim() !== vorher.tagCode) {
throw new GeraeteFehler(
`Der Tag-Code ${vorher.tagCode} bleibt, wie er ist. Er steht auf einem Aufkleber, ` +
"der schon am Gerät klebt — und wird nie geändert und nie neu vergeben.",
);
}
const daten: Prisma.GeraetUpdateInput = {};
const geaendert: string[] = [];
const felder = [
["hostname", "Hostname", aenderung.hostname],
["bezeichnung", "Bezeichnung", aenderung.bezeichnung],
["seriennummer", "Seriennummer", aenderung.seriennummer],
["inventarnummer", "Inventarnummer", aenderung.inventarnummer],
["modell", "Modell", aenderung.modell],
["notizen", "Notizen", aenderung.notizen],
] as const;
for (const [feld, beschriftung, roh] of felder) {
if (roh === undefined) continue;
const wert = text(roh);
if (wert !== vorher[feld]) {
daten[feld] = wert;
geaendert.push(beschriftung);
}
}
if (aenderung.typ !== undefined && aenderung.typ !== vorher.typ) {
daten.typ = aenderung.typ;
geaendert.push("Typ");
}
if (aenderung.mitarbeiterId !== undefined) {
const wert = text(aenderung.mitarbeiterId);
if (wert !== vorher.mitarbeiterId) {
if (wert) await besitzerPruefen(wert, vorher.kundeId);
daten.mitarbeiter = wert ? { connect: { id: wert } } : { disconnect: true };
geaendert.push("Besitzer");
}
}
// Registrieren geht nur in eine Richtung: ein Geraet, das einmal gescannt und
// benannt wurde, wieder zum leeren Aufkleber zu machen, hiesse seine Geschichte
// zu verlieren.
const registriertNachher = vorher.registriertAm !== null || aenderung.registrieren === true;
if (aenderung.registrieren === true && vorher.registriertAm === null) {
daten.registriertAm = new Date();
geaendert.push("Registrierung");
}
namenPflichtPruefen(
aenderung.hostname === undefined ? vorher.hostname : text(aenderung.hostname),
aenderung.bezeichnung === undefined ? vorher.bezeichnung : text(aenderung.bezeichnung),
registriertNachher,
);
if (geaendert.length === 0) return vorher;
return prisma.$transaction(async (tx) => {
const nachher = await tx.geraet.update({ where: { id: geraetId }, data: daten });
await tx.protokoll.create({
data: {
entitaet: "Geraet",
entitaetId: geraetId,
aktion: `${geaendert.join(" und ")} geändert`,
benutzerId: benutzer.id,
// Nur das, was sich geaendert hat – ein Protokoll, das jedes Mal alle
// Felder wiederholt, liest niemand mehr.
vorher: protokollAuszug(vorher, geaendert),
nachher: protokollAuszug(nachher, geaendert),
},
});
return nachher;
});
}
/** Die Felder zu den genannten Beschriftungen – fuer `Protokoll.vorher`/`nachher`. */
function protokollAuszug(geraet: Geraet, geaendert: readonly string[]): Prisma.InputJsonValue {
const auszug: Record<string, string | null> = { tagCode: geraet.tagCode };
const abbildung: Readonly<Record<string, string | null>> = {
Hostname: geraet.hostname,
Bezeichnung: geraet.bezeichnung,
Seriennummer: geraet.seriennummer,
Inventarnummer: geraet.inventarnummer,
Modell: geraet.modell,
Notizen: geraet.notizen,
Typ: geraet.typ,
Besitzer: geraet.mitarbeiterId,
Registrierung: geraet.registriertAm?.toISOString() ?? null,
};
for (const beschriftung of geaendert) {
if (beschriftung in abbildung) auszug[beschriftung] = abbildung[beschriftung] ?? null;
}
return auszug;
}
/**
* Archivieren statt Loeschen.
*
* Ein archiviertes Geraet erscheint nicht mehr in der Auswahl, seine Laeufe aber
* weiter im Archiv. Der `tagCode` bleibt vergeben.
*/
export async function geraetArchivieren(
geraetId: string,
archiviert: boolean,
benutzer: HandelnderBenutzer,
): Promise<void> {
const geraet = await prisma.geraet.findUnique({
where: { id: geraetId },
select: { id: true, kundeId: true, archiviert: true, hostname: true, bezeichnung: true, tagCode: true },
});
if (!geraet) throw new GeraeteFehler("Dieses Gerät gibt es nicht.");
await kundeZugriffPruefen(geraet.kundeId, benutzer);
if (geraet.archiviert === archiviert) return;
await prisma.$transaction(async (tx) => {
await tx.geraet.update({ where: { id: geraetId }, data: { archiviert } });
await tx.protokoll.create({
data: {
entitaet: "Geraet",
entitaetId: geraetId,
aktion: archiviert ? "Gerät archiviert" : "Gerät wieder freigegeben",
benutzerId: benutzer.id,
vorher: { archiviert: geraet.archiviert },
nachher: { archiviert, tagCode: geraet.tagCode },
},
});
});
}
/**
* Loescht ein Geraet – nur, solange kein Lauf daran haengt.
*
* Der `tagCode` bleibt in `TagCodeVergabe` stehen und wird nie wieder vergeben.
* Das ist der Zweck dieses Buches: ohne es waere der Code nach dem Loeschen frei,
* und ein uebrig gebliebener Aufkleber zeigte irgendwann auf ein fremdes Objekt.
*/
export async function geraetLoeschen(geraetId: string, benutzer: HandelnderBenutzer): Promise<void> {
const geraet = await prisma.geraet.findUnique({
where: { id: geraetId },
select: {
id: true,
kundeId: true,
tagCode: true,
hostname: true,
bezeichnung: true,
typ: true,
_count: { select: { laeufe: true } },
},
});
if (!geraet) throw new GeraeteFehler("Dieses Gerät gibt es nicht.");
await kundeZugriffPruefen(geraet.kundeId, benutzer);
if (geraet._count.laeufe > 0) {
const anzahl = geraet._count.laeufe;
throw new GeraeteFehler(
`An „${geraetName(geraet)}“ ${anzahl === 1 ? "hängt ein Lauf" : `hängen ${anzahl} Läufe`}. ` +
"Das Gerät lässt sich deshalb nur archivieren — ein Lauf muss für immer nachvollziehbar bleiben.",
);
}
try {
await prisma.$transaction(async (tx) => {
await tx.geraet.delete({ where: { id: geraetId } });
await tx.protokoll.create({
data: {
entitaet: "Geraet",
entitaetId: geraetId,
aktion: "Gerät gelöscht",
benutzerId: benutzer.id,
vorher: {
tagCode: geraet.tagCode,
hostname: geraet.hostname,
bezeichnung: geraet.bezeichnung,
typ: geraet.typ,
},
},
});
});
} catch (fehler) {
// Zwischen Zaehlung und Loeschen kann ein Lauf entstanden sein; dann greift
// der Fremdschluessel auf Restrict.
if (
fehler instanceof Prisma.PrismaClientKnownRequestError &&
(fehler.code === "P2003" || fehler.code === "P2014")
) {
throw new GeraeteFehler(
`An „${geraetName(geraet)}“ hängt inzwischen ein Lauf. Das Gerät lässt sich nur archivieren.`,
);
}
throw fehler;
}
}
// ---- CSV-Import ------------------------------------------------------------
/**
* Obergrenze je Datei.
*
* Ohne Grenze belegt eine versehentlich exportierte 50-MB-Tabelle die Datenbank
* mit Zeilen, die niemand wollte, und der Import laeuft minutenlang. 500 ist
* grosszuegig fuer einen Bestand, der von Hand gepflegt wird, und die Oberflaeche
* nennt die Zahl, **bevor** jemand dagegen laeuft.
*/
export const GERAETE_IMPORT_HOECHSTZEILEN = 500;
/**
* Die Spalten aus dem Auftrag – englisch, wie dort genannt – und jeweils die
* deutsche Entsprechung. Gross- und Kleinschreibung, Leerraum, Unterstriche und
* Bindestriche sind unerheblich, dafuer sorgt `spaltenName` in src/lib/csv.ts.
*/
export const GERAETE_IMPORT_SPALTEN = {
hostname: ["hostname"],
bezeichnung: ["label", "bezeichnung"],
seriennummer: ["serialNumber", "seriennummer"],
inventarnummer: ["inventoryNumber", "inventarnummer"],
typ: ["type", "typ"],
modell: ["model", "modell"],
} as const;
/**
* Woerter, die auf einen `GeraeteTyp` fuehren.
*
* Die Enum-Werte selbst kommen aus dem Enum, damit ein neuer Typ nicht in zwei
* Listen eingetragen werden muss. Dazu die Woerter, die in echten Exporten
* stehen — englisch und deutsch.
*/
const TYP_WOERTER: ReadonlyMap<string, GeraeteTyp> = new Map<string, GeraeteTyp>([
...Object.values(GeraeteTyp).map((typ) => [typ.toLowerCase(), typ] as const),
["laptop", GeraeteTyp.NOTEBOOK],
["nb", GeraeteTyp.NOTEBOOK],
["desktop", GeraeteTyp.PC],
["workstation", GeraeteTyp.PC],
["printer", GeraeteTyp.DRUCKER],
["room", GeraeteTyp.RAUM],
["other", GeraeteTyp.SONSTIGES],
["sonstige", GeraeteTyp.SONSTIGES],
]);
/** Die erlaubten Typen als Aufzaehlung – fuer die Fehlermeldung und die Oberflaeche. */
export const GERAETE_TYPEN: readonly GeraeteTyp[] = Object.values(GeraeteTyp);
export type ImportAbweisung = {
/** Zeilennummer in der Datei, wie sie ein Editor anzeigt. */
readonly zeile: number;
readonly grund: string;
};
export type ImportErgebnis = {
/** Datenzeilen in der Datei, ohne Kopfzeile und ohne Leerzeilen. */
readonly gesamt: number;
readonly angelegt: number;
readonly abgewiesen: readonly ImportAbweisung[];
};
/**
* Liest einen Typ aus der Spalte `type`.
*
* Ein unbekannter Wert ist ein Zeilenfehler und **kein** stiller Standardwert:
* „Tablet“ als SONSTIGES durchzuwinken heisst, den Bestand still falsch zu
* fuellen. Eine leere Zelle ist etwas anderes — dort steht nichts, also gilt
* SONSTIGES.
*/
function typLesen(roh: string): GeraeteTyp {
if (!roh) return GeraeteTyp.SONSTIGES;
const typ = TYP_WOERTER.get(roh.trim().toLowerCase());
if (!typ) {
throw new GeraeteFehler(
`„${roh}“ ist kein bekannter Gerätetyp. Erlaubt sind: ${GERAETE_TYPEN.join(", ")}.`,
);
}
return typ;
}
/** Eine Zeile der Datei zu einer Geraeteeingabe. Wirft bei einem Zeilenfehler. */
function zeileLesen(zeile: CsvZeile): NeuesGeraet {
const hostname = wertAus(zeile, ...GERAETE_IMPORT_SPALTEN.hostname);
const bezeichnung = wertAus(zeile, ...GERAETE_IMPORT_SPALTEN.bezeichnung);
if (!hostname && !bezeichnung) {
throw new GeraeteFehler("Weder ein Hostname noch eine Bezeichnung steht in dieser Zeile.");
}
return {
hostname,
bezeichnung,
seriennummer: wertAus(zeile, ...GERAETE_IMPORT_SPALTEN.seriennummer),
inventarnummer: wertAus(zeile, ...GERAETE_IMPORT_SPALTEN.inventarnummer),
typ: typLesen(wertAus(zeile, ...GERAETE_IMPORT_SPALTEN.typ)),
modell: wertAus(zeile, ...GERAETE_IMPORT_SPALTEN.modell),
};
}
/**
* Spielt eine CSV-Datei mit Geraeten ein.
*
* **Je Zeile ein Ergebnis.** Eine fehlerhafte Zeile bricht den Import nicht ab;
* sie wird mit Zeilennummer und Grund gemeldet, die uebrigen werden angelegt.
* Deshalb hat jede Zeile ihre eigene Transaktion: eine gemeinsame haette am Ende
* alles zurueckgerollt, was vor dem Fehler gelungen war.
*
* Der `tagCode` wird je Zeile erzeugt, genau wie beim Anlegen von Hand.
*/
export async function geraeteImportieren(
csvText: string,
benutzer: HandelnderBenutzer,
kundeIdAusEingabe?: string | null,
): Promise<ImportErgebnis> {
const kundeId = await kundeErmitteln(kundeIdAusEingabe, benutzer);
let tabelle;
try {
tabelle = csvZerlegen(csvText, { hoechstZeilen: GERAETE_IMPORT_HOECHSTZEILEN });
} catch (fehler) {
if (fehler instanceof CsvFehler) throw new GeraeteFehler(fehler.message);
throw fehler;
}
// Ohne eine der Namensspalten koennte keine einzige Zeile gelingen. Das als
// 500 gleichlautende Zeilenfehler zu melden waere richtig, aber nutzlos.
const alleNamen = Object.values(GERAETE_IMPORT_SPALTEN).flat();
if (!hatSpalte(tabelle, ...GERAETE_IMPORT_SPALTEN.hostname, ...GERAETE_IMPORT_SPALTEN.bezeichnung)) {
throw new GeraeteFehler(
"In der Kopfzeile fehlen „hostname“ und „label“ — eine davon muss es geben. " +
`Gelesen wurden diese Spalten: ${alleNamen.join(", ")}.`,
);
}
const abgewiesen: ImportAbweisung[] = [];
let angelegt = 0;
/*
* Doppelte Hostnamen in derselben Datei faengt diese Menge ab. Dieselbe Datei
* zweimal einzulesen verdoppelte sonst den Bestand — und weil ein tagCode nie
* zurueckkommt, waeren die Aufkleber dafuer schon gedruckt.
*/
const gesehene = new Set<string>();
for (const zeile of tabelle.zeilen) {
if (zeile.fehler !== undefined) {
abgewiesen.push({ zeile: zeile.nummer, grund: zeile.fehler });
continue;
}
try {
const eingabe = zeileLesen(zeile);
const hostname = eingabe.hostname?.trim().toLowerCase();
if (hostname) {
if (gesehene.has(hostname)) {
abgewiesen.push({
zeile: zeile.nummer,
grund: `„${eingabe.hostname?.trim()}“ steht in dieser Datei schon weiter oben.`,
});
continue;
}
const schonDa = await prisma.geraet.findFirst({
where: { kundeId, hostname: { equals: eingabe.hostname?.trim(), mode: "insensitive" } },
select: { tagCode: true },
});
if (schonDa) {
abgewiesen.push({
zeile: zeile.nummer,
grund: `„${eingabe.hostname?.trim()}“ gibt es schon — Tag-Code ${schonDa.tagCode}.`,
});
continue;
}
gesehene.add(hostname);
}
await geraetSchreiben({
eingabe,
kundeId,
durchBenutzerId: benutzer.id,
aktion: "Gerät aus CSV angelegt",
zusatz: { zeile: zeile.nummer },
});
angelegt += 1;
} catch (fehler) {
if (fehler instanceof GeraeteFehler) {
abgewiesen.push({ zeile: zeile.nummer, grund: fehler.message });
continue;
}
// Alles Uebrige – ein Verbindungsabbruch etwa – ist kein Zeilenproblem und
// wuerde in den folgenden Zeilen genauso auftreten.
throw fehler;
}
}
return { gesamt: tabelle.zeilen.length, angelegt, abgewiesen };
}
+252
View File
@@ -0,0 +1,252 @@
import type { Mitarbeiter, Prisma } from "@/generated/prisma/client";
import { Herkunft } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { kundeErmitteln, kundeZugriffPruefen, type HandelnderBenutzer } from "@/server/mandant";
/**
* Mitarbeiter (#011).
*
* Die Personen, an denen die Historie haengt. Zwei Regeln tragen das:
*
* 1. **Nichts wird geloescht.** Ein Mitarbeiter wird auf `aktiv = false`
* gesetzt. Ein archivierter Lauf zeigt auf ihn, und dieser Verweis muss in
* zehn Jahren noch aufloesbar sein — deshalb steht der Fremdschluessel auf
* `Restrict` und es gibt hier gar keinen Loeschweg.
* 2. **Was aus dem Verzeichnis kommt, wird nicht von Hand ueberschrieben.** Bei
* `herkunft = ABGLEICH` sind die abgeglichenen Felder gesperrt: der naechste
* Abgleich wuerde die Aenderung ohnehin zurueckdrehen, und ein Wert, der
* zwischen zwei Laeufen hin- und herspringt, ist schlimmer als ein falscher.
* Was **nicht** aus dem Verzeichnis kommt, bleibt aenderbar (#016 fuellt die
* Gegenseite).
*/
export class MitarbeiterFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "MitarbeiterFehler";
}
}
/**
* Felder, die der Verzeichnisabgleich fuellt.
*
* Als Daten und nicht als Kette von `if`s, damit die Sperre und die Oberflaeche
* aus derselben Liste lesen — sonst zeigt das Formular ein Feld als aenderbar,
* das der Dienst danach abweist.
*/
export const ABGEGLICHENE_FELDER = [
"vorname",
"nachname",
"anzeigename",
"mail",
"upn",
"sam",
"abteilung",
"titel",
] as const;
export type AbgeglichenesFeld = (typeof ABGEGLICHENE_FELDER)[number];
export type MitarbeiterEingabe = {
readonly vorname: string;
readonly nachname: string;
/** Leer: wird aus Vor- und Nachname gebildet. */
readonly anzeigename?: string | null;
readonly mail?: string | null;
readonly upn?: string | null;
readonly sam?: string | null;
readonly abteilung?: string | null;
readonly titel?: string | null;
readonly kundeId?: string | null;
};
function pflicht(wert: string, was: string): string {
const sauber = wert.trim();
if (!sauber) throw new MitarbeiterFehler(`${was} darf nicht leer sein.`);
return sauber;
}
function leerAlsNull(wert: string | null | undefined): string | null {
const sauber = wert?.trim();
return sauber ? sauber : null;
}
/**
* `sam` ist im Active Directory auf 20 Zeichen begrenzt.
*
* Die Regel steht hier, weil ein zu langer Wert erst beim `New-ADUser` im
* Gesicht des Technikers explodieren wuerde — und dann vor dem Kunden.
*/
function samPruefen(wert: string | null): string | null {
if (wert && wert.length > 20) {
throw new MitarbeiterFehler(
`„${wert}“ ist ${wert.length} Zeichen lang. Ein sAMAccountName fasst höchstens 20.`,
);
}
return wert;
}
function anzeigenameBilden(eingabe: MitarbeiterEingabe, vorname: string, nachname: string): string {
const gewuenscht = leerAlsNull(eingabe.anzeigename);
return gewuenscht ?? `${vorname} ${nachname}`.trim();
}
/** Legt einen Mitarbeiter von Hand an — `herkunft = MANUELL`. */
export async function mitarbeiterAnlegen(
eingabe: MitarbeiterEingabe,
benutzer: HandelnderBenutzer,
): Promise<Mitarbeiter> {
const vorname = pflicht(eingabe.vorname, "Der Vorname");
const nachname = pflicht(eingabe.nachname, "Der Nachname");
const kundeId = await kundeErmitteln(eingabe.kundeId, benutzer);
return prisma.$transaction(async (tx) => {
const mitarbeiter = await tx.mitarbeiter.create({
data: {
kundeId,
vorname,
nachname,
anzeigename: anzeigenameBilden(eingabe, vorname, nachname),
mail: leerAlsNull(eingabe.mail),
upn: leerAlsNull(eingabe.upn),
sam: samPruefen(leerAlsNull(eingabe.sam)),
abteilung: leerAlsNull(eingabe.abteilung),
titel: leerAlsNull(eingabe.titel),
herkunft: Herkunft.MANUELL,
},
});
await tx.protokoll.create({
data: {
entitaet: "Mitarbeiter",
entitaetId: mitarbeiter.id,
aktion: "Mitarbeiter angelegt",
benutzerId: benutzer.id,
nachher: { anzeigename: mitarbeiter.anzeigename, herkunft: mitarbeiter.herkunft },
},
});
return mitarbeiter;
});
}
/**
* Aendert die Stammdaten.
*
* Bei `herkunft = ABGLEICH` sind die abgeglichenen Felder gesperrt — abgewiesen
* wird aber nur, was sich wirklich aendern soll. Ein Formular, das alle Felder
* mitschickt, darf deshalb weiterhin die freien Felder aendern, ohne an einem
* unveraenderten gesperrten Feld zu scheitern.
*/
export async function mitarbeiterBearbeiten(
mitarbeiterId: string,
aenderung: Partial<MitarbeiterEingabe>,
benutzer: HandelnderBenutzer,
): Promise<Mitarbeiter> {
const vorher = await prisma.mitarbeiter.findUnique({ where: { id: mitarbeiterId } });
if (!vorher) throw new MitarbeiterFehler("Diese Person gibt es nicht (mehr).");
await kundeZugriffPruefen(vorher.kundeId, benutzer);
const daten: Record<string, string | null> = {};
const geaendert: string[] = [];
function uebernehmen(feld: AbgeglichenesFeld | "anzeigename", neu: string | null) {
if (neu === vorher![feld]) return;
if (vorher!.herkunft === Herkunft.ABGLEICH && (ABGEGLICHENE_FELDER as readonly string[]).includes(feld)) {
throw new MitarbeiterFehler(
`„${vorher!.anzeigename}“ kommt aus dem Verzeichnis. ${feldName(feld)} wird dort gepflegt und ` +
"beim nächsten Abgleich ohnehin zurückgesetzt.",
);
}
daten[feld] = neu;
geaendert.push(feldName(feld));
}
if (aenderung.vorname !== undefined) uebernehmen("vorname", pflicht(aenderung.vorname, "Der Vorname"));
if (aenderung.nachname !== undefined) uebernehmen("nachname", pflicht(aenderung.nachname, "Der Nachname"));
if (aenderung.anzeigename !== undefined) {
const gewuenscht = leerAlsNull(aenderung.anzeigename);
uebernehmen(
"anzeigename",
gewuenscht ??
`${(daten["vorname"] as string) ?? vorher.vorname} ${(daten["nachname"] as string) ?? vorher.nachname}`.trim(),
);
}
if (aenderung.mail !== undefined) uebernehmen("mail", leerAlsNull(aenderung.mail));
if (aenderung.upn !== undefined) uebernehmen("upn", leerAlsNull(aenderung.upn));
if (aenderung.sam !== undefined) uebernehmen("sam", samPruefen(leerAlsNull(aenderung.sam)));
if (aenderung.abteilung !== undefined) uebernehmen("abteilung", leerAlsNull(aenderung.abteilung));
if (aenderung.titel !== undefined) uebernehmen("titel", leerAlsNull(aenderung.titel));
if (geaendert.length === 0) return vorher;
return prisma.$transaction(async (tx) => {
const nachher = await tx.mitarbeiter.update({ where: { id: mitarbeiterId }, data: daten });
await tx.protokoll.create({
data: {
entitaet: "Mitarbeiter",
entitaetId: mitarbeiterId,
aktion: `${geaendert.join(" und ")} geändert`,
benutzerId: benutzer.id,
vorher: auszug(vorher, Object.keys(daten)),
nachher: auszug(nachher, Object.keys(daten)),
},
});
return nachher;
});
}
/**
* Deaktivieren und wieder aktivieren.
*
* Es gibt keinen Loeschweg — auch nicht fuer eine Person ohne Laeufe. Waere
* Loeschen manchmal moeglich, muesste jeder Aufrufer die Bedingung kennen, und
* beim Offboarding entscheidet sich niemand bewusst zwischen zwei Wegen.
*/
export async function mitarbeiterAktivSetzen(
mitarbeiterId: string,
aktiv: boolean,
benutzer: HandelnderBenutzer,
): Promise<Mitarbeiter> {
const vorher = await prisma.mitarbeiter.findUnique({ where: { id: mitarbeiterId } });
if (!vorher) throw new MitarbeiterFehler("Diese Person gibt es nicht (mehr).");
await kundeZugriffPruefen(vorher.kundeId, benutzer);
if (vorher.aktiv === aktiv) return vorher;
return prisma.$transaction(async (tx) => {
const nachher = await tx.mitarbeiter.update({ where: { id: mitarbeiterId }, data: { aktiv } });
await tx.protokoll.create({
data: {
entitaet: "Mitarbeiter",
entitaetId: mitarbeiterId,
aktion: aktiv ? "Mitarbeiter wieder aktiviert" : "Mitarbeiter deaktiviert",
benutzerId: benutzer.id,
vorher: { aktiv: vorher.aktiv },
nachher: { aktiv },
},
});
return nachher;
});
}
const FELD_NAMEN: Record<string, string> = {
vorname: "Vorname",
nachname: "Nachname",
anzeigename: "Anzeigename",
mail: "Mail-Adresse",
upn: "UPN",
sam: "sAMAccountName",
abteilung: "Abteilung",
titel: "Titel",
};
function feldName(feld: string): string {
return FELD_NAMEN[feld] ?? feld;
}
/** Nur die geaenderten Felder ins Protokoll – ein ganzer Datensatz waere Rauschen. */
function auszug(zeile: Mitarbeiter, felder: readonly string[]): Prisma.InputJsonObject {
const ausschnitt: Record<string, string | null> = {};
for (const feld of felder) {
ausschnitt[feld] = (zeile as unknown as Record<string, string | null>)[feld] ?? null;
}
return ausschnitt;
}
+268
View File
@@ -0,0 +1,268 @@
import { beforeEach, describe, expect, it } from "vitest";
import { Betriebsmodus } from "@/generated/prisma/enums";
import {
AbteilungsFehler,
abteilungAnlegen,
abteilungArchivieren,
abteilungBearbeiten,
} from "@/server/services/abteilungen";
import { abteilungsListe, ungepflegteAbteilungen } from "@/server/queries/abteilungen";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Abteilungen (#011).
*
* An einer Abteilung haengen Ziel-OU und Standardgruppen — das, was die
* spaeteren PowerShell-Befehle in ein Verzeichnis schreiben (#012). Deshalb wird
* hier genauer geprueft als bei einer blossen Beschriftung.
*/
async function grundbestand() {
const eigene = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } });
const benutzer = await testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" },
});
return { eigene, admin: { id: benutzer.id, rolle: benutzer.rolle } };
}
describe.runIf(datenbankVerfuegbar)("Abteilungen", () => {
beforeEach(async () => {
await datenbankLeeren();
});
describe("Anlegen", () => {
it("legt Name, Ziel-OU, Gruppen und Mail-Domain an", async () => {
const { admin } = await grundbestand();
const abteilung = await abteilungAnlegen(
{
name: " Buchhaltung ",
zielOu: "OU=Buchhaltung,OU=Benutzer,DC=firma,DC=de",
standardGruppen: ["G-Buchhaltung", "G-Alle"],
mailDomain: "firma.de",
},
admin,
);
expect(abteilung.name).toBe("Buchhaltung");
expect(abteilung.standardGruppen).toEqual(["G-Buchhaltung", "G-Alle"]);
expect(abteilung.mailDomain).toBe("firma.de");
});
it("weist denselben Namen ein zweites Mal ab, mit lesbarer Meldung", async () => {
const { admin } = await grundbestand();
await abteilungAnlegen({ name: "Buchhaltung" }, admin);
await expect(abteilungAnlegen({ name: "Buchhaltung" }, admin)).rejects.toThrow(/gibt es schon/);
});
it("räumt leere und doppelte Gruppen weg, behält die Reihenfolge", async () => {
// Die Reihenfolge landet spaeter im Add-ADGroupMember-Befehl; sie ist
// keine Menge, sondern eine Liste.
const { admin } = await grundbestand();
const abteilung = await abteilungAnlegen(
{ name: "Vertrieb", standardGruppen: ["G-Alle", " ", "G-Vertrieb", "G-Alle", ""] },
admin,
);
expect(abteilung.standardGruppen).toEqual(["G-Alle", "G-Vertrieb"]);
});
it("weist eine Mail-Adresse als Domain ab", async () => {
const { admin } = await grundbestand();
await expect(abteilungAnlegen({ name: "Vertrieb", mailDomain: "max@firma.de" }, admin)).rejects.toThrow(
/Mail-Domain/,
);
await expect(abteilungAnlegen({ name: "Einkauf", mailDomain: "firma" }, admin)).rejects.toThrow(
AbteilungsFehler,
);
});
it("nimmt eine Domain mit führendem @ an und normalisiert sie", async () => {
const { admin } = await grundbestand();
const abteilung = await abteilungAnlegen({ name: "IT", mailDomain: "@Firma.DE" }, admin);
expect(abteilung.mailDomain).toBe("firma.de");
});
it("weist einen leeren Namen ab", async () => {
const { admin } = await grundbestand();
await expect(abteilungAnlegen({ name: " " }, admin)).rejects.toThrow(AbteilungsFehler);
expect(await testPrisma.abteilung.count()).toBe(0);
});
});
describe("Bearbeiten", () => {
it("schreibt nichts, wenn sich nichts ändert", async () => {
const { admin } = await grundbestand();
const abteilung = await abteilungAnlegen({ name: "Buchhaltung" }, admin);
const vorher = await testPrisma.protokoll.count();
await abteilungBearbeiten(abteilung.id, { name: "Buchhaltung" }, admin);
expect(await testPrisma.protokoll.count()).toBe(vorher);
});
it("erkennt eine geänderte Gruppenreihenfolge als Änderung", async () => {
const { admin } = await grundbestand();
const abteilung = await abteilungAnlegen(
{ name: "Buchhaltung", standardGruppen: ["G-Alle", "G-Buchhaltung"] },
admin,
);
const nachher = await abteilungBearbeiten(
abteilung.id,
{ standardGruppen: ["G-Buchhaltung", "G-Alle"] },
admin,
);
expect(nachher.standardGruppen).toEqual(["G-Buchhaltung", "G-Alle"]);
expect(await testPrisma.protokoll.count({ where: { aktion: "Standardgruppen geändert" } })).toBe(1);
});
it("weist einen Namen ab, den es schon gibt", async () => {
const { admin } = await grundbestand();
await abteilungAnlegen({ name: "Buchhaltung" }, admin);
const vertrieb = await abteilungAnlegen({ name: "Vertrieb" }, admin);
await expect(abteilungBearbeiten(vertrieb.id, { name: "Buchhaltung" }, admin)).rejects.toThrow(
/gibt es schon/,
);
});
});
describe("Archivieren statt löschen", () => {
it("archiviert und gibt wieder frei", async () => {
const { admin } = await grundbestand();
const abteilung = await abteilungAnlegen({ name: "Buchhaltung" }, admin);
expect((await abteilungArchivieren(abteilung.id, true, admin)).archiviert).toBe(true);
expect((await abteilungArchivieren(abteilung.id, false, admin)).archiviert).toBe(false);
});
it("bietet gar keinen Löschweg an", async () => {
const dienst = await import("@/server/services/abteilungen");
expect(Object.keys(dienst).filter((name) => /loesch|entfern|delete/iu.test(name))).toEqual([]);
});
it("blendet archivierte auf Wunsch aus", async () => {
const { admin } = await grundbestand();
const abteilung = await abteilungAnlegen({ name: "Buchhaltung" }, admin);
await abteilungArchivieren(abteilung.id, true, admin);
expect(await abteilungsListe(admin, false)).toEqual([]);
expect(await abteilungsListe(admin, true)).toHaveLength(1);
});
});
describe("Zuordnung über den Namen", () => {
it("zählt nur aktive Personen mit passendem Abteilungsnamen", async () => {
const { admin, eigene } = await grundbestand();
await abteilungAnlegen({ name: "Buchhaltung" }, admin);
for (const [name, aktiv] of [
["Maria", true],
["Jens", true],
["Ex", false],
] as const) {
await testPrisma.mitarbeiter.create({
data: {
kundeId: eigene.id,
vorname: name,
nachname: "X",
anzeigename: name,
abteilung: "Buchhaltung",
aktiv,
},
});
}
const zeile = (await abteilungsListe(admin))[0]!;
expect(zeile.anzahlMitarbeiter).toBe(2);
});
it("meldet Abteilungsnamen, die es bei Personen gibt, aber nicht gepflegt sind", async () => {
// Das ist der Vorschlag nach einem Verzeichnisabgleich: der Name steht
// schon in den Daten, es fehlen nur Ziel-OU und Gruppen.
const { admin, eigene } = await grundbestand();
await abteilungAnlegen({ name: "Buchhaltung" }, admin);
for (const abteilung of ["Buchhaltung", "Vertrieb", "Vertrieb"]) {
await testPrisma.mitarbeiter.create({
data: { kundeId: eigene.id, vorname: "P", nachname: "X", anzeigename: "P X", abteilung },
});
}
expect(await ungepflegteAbteilungen(admin)).toEqual([
{ name: "Vertrieb", anzahl: 2, kundeId: eigene.id },
]);
});
});
describe("Mandantensicht", () => {
async function zweiKunden() {
const eigene = await testPrisma.kunde.create({
data: { name: "Eigene", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
const meiner = await testPrisma.kunde.create({ data: { name: "Meiner", kuerzel: "MEIN" } });
const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
await testPrisma.einstellung.create({
data: { id: "global", modus: Betriebsmodus.DIENSTLEISTUNG },
});
const techniker = await testPrisma.benutzer.create({
data: { email: "t@firma.example", name: "Techniker", rolle: "TECHNIKER" },
});
await testPrisma.benutzerKunde.create({ data: { benutzerId: techniker.id, kundeId: meiner.id } });
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
await testPrisma.abteilung.create({ data: { kundeId: meiner.id, name: "Meine Abteilung" } });
const fremde = await testPrisma.abteilung.create({
data: { kundeId: fremder.id, name: "Fremde Abteilung", zielOu: "OU=Geheim" },
});
return { eigene, techniker: { id: techniker.id, rolle: techniker.rolle }, fremde };
}
it("zeigt einem gebundenen Techniker keine fremde Abteilung", async () => {
const { techniker } = await zweiKunden();
expect((await abteilungsListe(techniker)).map((z) => z.name)).toEqual(["Meine Abteilung"]);
});
it("lässt eine fremde Abteilung nicht bearbeiten", async () => {
const { techniker, fremde } = await zweiKunden();
await expect(abteilungBearbeiten(fremde.id, { zielOu: "OU=Anders" }, techniker)).rejects.toThrow(
/Berechtigung/,
);
});
it("hält bei ungepflegten Abteilungen die Kunden auseinander", async () => {
/*
* Der Blocker aus dem Review: gruppiert wurde nach Namen allein. Damit galt
* „Vertrieb“ als gepflegt, sobald **irgendein** Kunde eine Abteilung dieses
* Namens hatte — und beim Kunden ohne sie blieben `{{ou}}` und
* `{{gruppen}}` im Lauf leer, ohne dass die Seite es je sagte.
*/
const { eigene } = await zweiKunden();
const admin = await testPrisma.benutzer.create({
data: { email: "a@firma.example", name: "Admin", rolle: "ADMIN" },
});
const handelnder = { id: admin.id, rolle: admin.rolle };
const meiner = await testPrisma.kunde.findFirstOrThrow({ where: { kuerzel: "MEIN" } });
const fremder = await testPrisma.kunde.findFirstOrThrow({ where: { kuerzel: "FREMD" } });
// Nur der eine Kunde pflegt „Vertrieb“.
await testPrisma.abteilung.create({ data: { kundeId: meiner.id, name: "Vertrieb" } });
for (const kundeId of [meiner.id, fremder.id, fremder.id]) {
await testPrisma.mitarbeiter.create({
data: { kundeId, vorname: "P", nachname: "X", anzeigename: "P X", abteilung: "Vertrieb" },
});
}
const offen = await ungepflegteAbteilungen(handelnder);
expect(offen).toEqual([{ name: "Vertrieb", anzahl: 2, kundeId: fremder.id }]);
expect(offen.some((eintrag) => eintrag.kundeId === meiner.id)).toBe(false);
expect(eigene.id).toBeTruthy();
});
it("lässt eine fremde Abteilung nicht archivieren", async () => {
const { techniker, fremde } = await zweiKunden();
await expect(abteilungArchivieren(fremde.id, true, techniker)).rejects.toThrow(/Berechtigung/);
});
it("erlaubt denselben Abteilungsnamen bei zwei Kunden", async () => {
// Der Eindeutigkeitsindex geht ueber (kundeId, name) – „Buchhaltung“ gibt
// es bei jedem Kunden genau einmal, und das ist richtig so.
const { eigene } = await zweiKunden();
const zweiter = await testPrisma.kunde.findFirstOrThrow({ where: { kuerzel: "MEIN" } });
await testPrisma.abteilung.create({ data: { kundeId: eigene.id, name: "Buchhaltung" } });
await expect(
testPrisma.abteilung.create({ data: { kundeId: zweiter.id, name: "Buchhaltung" } }),
).resolves.toBeTruthy();
});
});
});
+254
View File
@@ -0,0 +1,254 @@
import { describe, expect, it } from "vitest";
import {
CsvFehler,
csvErzeugen,
csvZerlegen,
hatSpalte,
spaltenName,
trennzeichenErkennen,
wertAus,
} from "@/lib/csv";
/**
* CSV lesen und schreiben (#011).
*
* Reine Logik ohne Datenbank — und genau der Ort, an dem sich die Randfälle
* billig absichern lassen. Geprüft wird, was in echten Dateien steht: das
* Semikolon des deutschen Excel, ein BOM davor, CRLF, Werte in
* Anführungszeichen mit Trennzeichen und Umbruch darin, verdoppelte
* Anführungszeichen, Leerzeilen und Kopfzeilen in jeder Schreibweise.
*/
/** Kürzer als `zeilen.map(...)` in jedem Test. */
function werte(text: string, spalte: string): readonly string[] {
return csvZerlegen(text).zeilen.map((zeile) => wertAus(zeile, spalte));
}
describe("Spaltennamen normalisieren", () => {
it("macht Schreibweise, Leerraum, Unterstrich und Bindestrich unerheblich", () => {
for (const roh of ["serialNumber", "SerialNumber", " Serial Number ", "serial_number", "SERIAL-NUMBER"]) {
expect(spaltenName(roh), roh).toBe("serialnumber");
}
});
});
describe("Trennzeichen erkennen", () => {
it("erkennt das Semikolon, das deutsches Excel schreibt", () => {
expect(trennzeichenErkennen("hostname;label;type\na;b;c\n")).toBe(";");
});
it("erkennt das Komma", () => {
expect(trennzeichenErkennen("hostname,label,type\na,b,c\n")).toBe(",");
});
it("liest nur die Kopfzeile und lässt sich von Kommas in den Werten nicht täuschen", () => {
// Die Werte enthalten viermal so viele Kommas wie die Kopfzeile Semikolons.
const text = "hostname;label\nPC-1;Regal, Keller, hinten, links\nPC-2;Regal, Keller, vorn, rechts\n";
expect(trennzeichenErkennen(text)).toBe(";");
expect(werte(text, "label")).toEqual(["Regal, Keller, hinten, links", "Regal, Keller, vorn, rechts"]);
});
it("nimmt bei einer einspaltigen Datei das Komma – das Zeichen der Norm", () => {
expect(trennzeichenErkennen("hostname\nPC-1\n")).toBe(",");
});
it("lässt sich überstimmen", () => {
const tabelle = csvZerlegen("a;b\n1;2\n", { trennzeichen: "," });
expect(tabelle.spalten).toEqual(["a;b"]);
});
});
describe("Kopfzeile", () => {
it("ist unabhängig von Groß- und Kleinschreibung", () => {
const tabelle = csvZerlegen("HostName,LABEL,Serial Number\npc-1,Regal,SN1\n");
expect(tabelle.spalten).toEqual(["hostname", "label", "serialnumber"]);
expect(wertAus(tabelle.zeilen[0]!, "serialNumber")).toBe("SN1");
});
it("überspringt ein BOM am Dateianfang", () => {
const tabelle = csvZerlegen("hostname,label\npc-1,Regal\n");
expect(tabelle.spalten).toEqual(["hostname", "label"]);
});
it("nimmt bei einem doppelten Spaltennamen den ersten", () => {
const tabelle = csvZerlegen("hostname,label,hostname\npc-1,Regal,pc-2\n");
expect(wertAus(tabelle.zeilen[0]!, "hostname")).toBe("pc-1");
});
it("wirft bei einer leeren Datei", () => {
expect(() => csvZerlegen("")).toThrow(CsvFehler);
expect(() => csvZerlegen("\r\n")).toThrow(CsvFehler);
});
it("sagt, welche Spalten es gibt", () => {
const tabelle = csvZerlegen("hostname;typ\npc-1;PC\n");
expect(hatSpalte(tabelle, "hostname")).toBe(true);
expect(hatSpalte(tabelle, "label", "bezeichnung")).toBe(false);
expect(hatSpalte(tabelle, "type", "typ")).toBe(true);
});
});
describe("Werte in Anführungszeichen", () => {
it("nimmt Trennzeichen und verdoppelte Anführungszeichen auf", () => {
const text = 'hostname,label\npc-1,"Regal, Keller"\npc-2,"Der ""gute"" Platz"\n';
expect(werte(text, "label")).toEqual(["Regal, Keller", 'Der "gute" Platz']);
});
it("nimmt Umbrüche im Wert nur auf ausdrücklichen Wunsch auf", () => {
// Standard ist **nein**: ein Umbruch in einem Hostnamen kommt nicht vor, ein
// vergessenes schliessendes Anfuehrungszeichen staendig. Siehe
// `werteUeberZeilen` — ohne die Grenze verschluckt eine kaputte Zelle alle
// Folgezeilen, und der Import meldet dafuer Erfolg.
const text = 'hostname,label\npc-1,"Zeile 1\nZeile 2"\n';
const tabelle = csvZerlegen(text, { werteUeberZeilen: true });
expect(tabelle.zeilen.map((zeile) => wertAus(zeile, "label"))).toEqual(["Zeile 1\nZeile 2"]);
// Streng gelesen ist Zeile 2 kaputt, und Zeile 3 („Zeile 2\"“) ist nur noch
// der Rest davon – gemeldet wird beides, verschluckt nichts.
const streng = csvZerlegen(text);
expect(streng.zeilen[0]?.nummer).toBe(2);
expect(streng.zeilen[0]?.fehler).toMatch(/nicht geschlossen/);
});
it("normalisiert CRLF innerhalb eines Werts auf einen einzelnen Umbruch", () => {
const tabelle = csvZerlegen('hostname,label\r\npc-1,"oben\r\nunten"\r\n', {
werteUeberZeilen: true,
});
expect(tabelle.zeilen.map((zeile) => wertAus(zeile, "label"))).toEqual(["oben\nunten"]);
});
it("behält Leerraum innerhalb der Anführungszeichen, verwirft ihn außerhalb", () => {
const text = 'hostname , label\n pc-1 , " Regal " \n';
const tabelle = csvZerlegen(text);
expect(tabelle.spalten).toEqual(["hostname", "label"]);
expect(wertAus(tabelle.zeilen[0]!, "hostname")).toBe("pc-1");
// `wertAus` trimmt für die Anwendung; der rohe Wert bleibt erhalten.
expect(tabelle.zeilen[0]!.werte[1]).toBe(" Regal ");
});
it("behandelt ein Anführungszeichen mitten im Wert als gewöhnliches Zeichen", () => {
expect(werte('hostname,label\npc-1,24" Monitor\n', "label")).toEqual(['24" Monitor']);
});
it("meldet ein nicht geschlossenes Anführungszeichen als Zeilenfehler, statt zu werfen", () => {
/*
* Frueher warf das hier — und damit starb der ganze Import an einer einzigen
* kaputten Zelle. Das Akzeptanzkriterium verlangt das Gegenteil: die Zeile
* wird gemeldet, die anderen werden eingelesen.
*/
const tabelle = csvZerlegen('hostname,label\npc-1,"offen\npc-2,gut\n');
expect(tabelle.zeilen.map((zeile) => zeile.nummer)).toEqual([2, 3]);
expect(tabelle.zeilen[0]?.fehler).toMatch(/Anführungszeichen/);
expect(tabelle.zeilen[1]?.fehler).toBeUndefined();
expect(wertAus(tabelle.zeilen[1]!, "hostname")).toBe("pc-2");
});
it("meldet Text nach dem schließenden Anführungszeichen, statt ihn anzuhängen", () => {
// Vorher wanderte alles Folgende stillschweigend in diesen Wert – samt der
// naechsten Zeilen.
const tabelle = csvZerlegen('hostname,label\npc-1,"Regal" Keller,x\npc-2,gut\n');
expect(tabelle.zeilen[0]?.fehler).toMatch(/schließenden Anführungszeichen/);
expect(wertAus(tabelle.zeilen[1]!, "hostname")).toBe("pc-2");
});
it("wirft nur, wenn schon die Kopfzeile unlesbar ist", () => {
// Ohne Spaltennamen hat keine einzige Zeile eine Bedeutung – da hilft keine
// Zeilenmeldung.
expect(() => csvZerlegen('hostname,"label\npc-1,Regal\n')).toThrow(CsvFehler);
expect(() => csvZerlegen('hostname,"label\npc-1,Regal\n')).toThrow(/Kopfzeile/);
});
});
describe("Zeilen", () => {
it("überspringt Leerzeilen und Zeilen aus lauter Trennzeichen", () => {
const tabelle = csvZerlegen("hostname;label\n\npc-1;Regal\n;;\r\n\npc-2;Kiste\n");
expect(tabelle.zeilen.map((zeile) => wertAus(zeile, "hostname"))).toEqual(["pc-1", "pc-2"]);
});
it("zählt Zeilennummern wie ein Editor – über Leerzeilen und Umbrüche in Werten hinweg", () => {
const text = [
"hostname,label", // 1
"", // 2
"pc-1,Regal", // 3
'pc-2,"oben', // 4
'unten"', // 5
"pc-3,Kiste", // 6
].join("\n");
const tabelle = csvZerlegen(text, { werteUeberZeilen: true });
expect(tabelle.zeilen.map((zeile) => zeile.nummer)).toEqual([3, 4, 6]);
});
it("kommt ohne abschließenden Umbruch aus", () => {
expect(werte("hostname,label\npc-1,Regal", "hostname")).toEqual(["pc-1"]);
});
it("verträgt fehlende und zusätzliche Spalten", () => {
const tabelle = csvZerlegen("hostname,label,type\npc-1\npc-2,Regal,PC,zuviel,noch mehr\n");
const [kurz, lang] = tabelle.zeilen;
// Fehlende Spalte: kein Eintrag, also auch kein leerer String.
expect(kurz!.felder.has("label")).toBe(false);
expect(wertAus(kurz!, "label")).toBe("");
expect(wertAus(lang!, "type")).toBe("PC");
// Ueberzaehlige Werte bleiben erreichbar, ohne eine Spalte zu belegen.
expect(lang!.werte).toHaveLength(5);
});
it("bricht bei zu vielen Zeilen ab und nennt die Grenze", () => {
const viele = ["hostname", ...Array.from({ length: 5 }, (_, i) => `pc-${i}`)].join("\n");
expect(() => csvZerlegen(viele, { hoechstZeilen: 3 })).toThrow(/mehr als 3 Zeilen/);
// Genau an der Grenze geht es noch.
expect(csvZerlegen(viele, { hoechstZeilen: 5 }).zeilen).toHaveLength(5);
});
});
describe("Mehrere Namen für dieselbe Spalte", () => {
it("nimmt den ersten gesetzten Wert", () => {
const tabelle = csvZerlegen("label;bezeichnung\n;Regal\nKiste;\n");
expect(tabelle.zeilen.map((zeile) => wertAus(zeile, "label", "bezeichnung"))).toEqual(["Regal", "Kiste"]);
});
});
describe("Erzeugen", () => {
it("maskiert Trennzeichen, Anführungszeichen, Umbrüche und Randleerraum", () => {
const text = csvErzeugen(
["a", "b"],
[
{ a: 'x;y "z"', b: "oben\nunten" },
{ a: " Rand ", b: "" },
],
{
mitBom: false,
},
);
expect(text).toBe('a;b\r\n"x;y ""z""";"oben\nunten"\r\n" Rand ";\r\n');
});
it("stellt standardmäßig ein BOM voran und trennt mit Semikolon – für deutsches Excel", () => {
const text = csvErzeugen(["hostname"], [{ hostname: "pc-1" }]);
expect(text.startsWith("")).toBe(true);
expect(text.endsWith("\r\n")).toBe(true);
});
it("schreibt fehlende Werte als leere Zelle", () => {
expect(csvErzeugen(["a", "b"], [{ a: null }, { b: 7 }], { mitBom: false })).toBe("a;b\r\n;\r\n;7\r\n");
});
it("liest zurück, was es geschrieben hat", () => {
const spalten = ["hostname", "label", "type"];
const zeilen = [
{ hostname: "pc-1", label: 'Regal, "Keller"', type: "PC" },
{ hostname: "", label: "oben\nunten", type: "RAUM" },
{ hostname: " Rand ", label: "a;b,c", type: "" },
];
for (const trennzeichen of [",", ";"] as const) {
const text = csvErzeugen(spalten, zeilen, { trennzeichen });
// Der Rundlauf liest eine **selbst erzeugte** Datei – dort sind Umbrueche
// in Werten gewollt und korrekt maskiert.
const tabelle = csvZerlegen(text, { werteUeberZeilen: true });
expect(tabelle.trennzeichen, trennzeichen).toBe(trennzeichen);
expect(tabelle.zeilen.map((zeile) => zeile.werte)).toEqual(
zeilen.map((zeile) => spalten.map((spalte) => zeile[spalte as keyof typeof zeile] ?? "")),
);
}
});
});
+198
View File
@@ -0,0 +1,198 @@
import { expect, test, type Page } from "@playwright/test";
import "dotenv/config";
/**
* Die Geraeteliste durch die Oberflaeche (#011).
*
* Diese Datei gibt es aus demselben Grund wie tests/e2e/vorlagen-editor.spec.ts:
* ein Screenshot sagt in dieser Umgebung **nichts** ueber Bedienbarkeit, weil
* `next dev` nicht hydriert (siehe CLAUDE.md, „Oberflaeche pruefen“). Geprueft
* wird deshalb, was nur mit angehaengtem React gelingt — anlegen, suchen,
* einlesen, loeschen — und anschliessend, ob es wirklich in der Datenbank steht:
* durch Neuladen.
*
* Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`) und
* `SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD` sind gesetzt.
*
* Der Ablauf raeumt hinter sich auf: an den angelegten Geraeten haengt kein Lauf,
* sie lassen sich also loeschen. Ihre Tag-Codes bleiben vergeben, und genau das
* ist die Zusage aus #011 — ein Aufkleber zeigt nie auf ein zweites Geraet.
*/
const MARKE = `ZZ-E2E-${Date.now()}`;
async function anmelden(seite: Page) {
const email = process.env["SEED_ADMIN_EMAIL"];
const passwort = process.env["SEED_ADMIN_PASSWORD"];
if (!email || !passwort) throw new Error("SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen.");
await seite.goto("/anmelden");
await seite.fill('input[name="email"]', email);
await seite.fill('input[name="passwort"]', passwort);
await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]);
}
/** Der Abschnitt „Gerät anlegen“ – eindeutig ueber seinen eigenen Knopf. */
function anlegenFormular(seite: Page) {
return seite.locator("section", { has: seite.getByRole("button", { name: "Gerät anlegen" }) });
}
function karte(seite: Page, name: string) {
return seite.locator("li").filter({ hasText: name });
}
/** Loescht alle Geraete, deren Name mit der Marke dieses Durchlaufs beginnt. */
async function aufraeumen(seite: Page) {
await seite.goto(`/geraete?suche=${MARKE}`);
for (let versuch = 0; versuch < 10; versuch += 1) {
const karten = seite.locator("li").filter({ hasText: MARKE });
if ((await karten.count()) === 0) return;
const erste = karten.first();
await erste.getByRole("button", { name: "löschen", exact: true }).click();
await erste.getByRole("button", { name: "ja, löschen" }).click();
await expect(karten).toHaveCount(await karten.count().then((anzahl) => anzahl - 1));
}
}
test.describe("Geräte", () => {
test("legt ein Gerät an, findet es in der Liste und sucht es", async ({ page }) => {
test.slow();
const name = `${MARKE}-PC-01`;
await anmelden(page);
await page.goto("/geraete");
// Der Reiter fuehrt zwischen Personen und Geraeten hin und her.
await expect(page.getByRole("link", { name: "Geräte", exact: true })).toHaveAttribute(
"aria-current",
"page",
);
const formular = anlegenFormular(page);
await formular.getByLabel("Hostname").fill(name);
await formular.getByLabel("Seriennummer").fill(`${MARKE}-SN`);
await formular.getByLabel("Typ").selectOption("NOTEBOOK");
await formular.getByRole("button", { name: "Gerät anlegen" }).click();
// Der Tag-Code steht in der Meldung – er kommt gleich auf den Aufkleber.
const meldung = formular.getByRole("status");
await expect(meldung).toContainText(/wurde angelegt, Tag-Code LZ-[A-Z2-9]{6}\./);
const tagCode = (await meldung.textContent())?.match(/LZ-[A-Z2-9]{6}/)?.[0];
expect(tagCode).toBeTruthy();
// Der Beweis, dass es nicht nur im Browser steht: neu laden.
await page.goto("/geraete");
await expect(karte(page, name)).toContainText("Notebook");
await expect(karte(page, name)).toContainText(tagCode!);
// Suche über den Hostname …
await page.locator('input[name="suche"]').fill(name);
await page.getByRole("button", { name: "Filtern" }).click();
await expect(page.locator("ul > li").filter({ hasText: MARKE })).toHaveCount(1);
// … und über den abgetippten Tag-Code ohne Präfix.
await page.locator('input[name="suche"]').fill(tagCode!.slice(3));
await page.getByRole("button", { name: "Filtern" }).click();
await expect(karte(page, name)).toBeVisible();
// Ein Suchbegriff, der zu nichts passt, sagt das auch.
await page.locator('input[name="suche"]').fill(`${MARKE}-gibt-es-nicht`);
await page.getByRole("button", { name: "Filtern" }).click();
await expect(page.getByText("Kein Gerät passt zu diesen Filtern.")).toBeVisible();
await aufraeumen(page);
});
test("ändert Stammdaten und archiviert statt zu löschen", async ({ page }) => {
test.slow();
const name = `${MARKE}-PC-02`;
await anmelden(page);
await page.goto("/geraete");
const formular = anlegenFormular(page);
await formular.getByLabel("Hostname").fill(name);
await formular.getByRole("button", { name: "Gerät anlegen" }).click();
await expect(formular.getByRole("status")).toContainText("wurde angelegt");
await page.goto(`/geraete?suche=${MARKE}`);
const meine = karte(page, name);
await meine.getByText("Stammdaten bearbeiten").click();
await meine.getByLabel("Seriennummer").fill(`${MARKE}-SN-NEU`);
await meine.getByRole("button", { name: "Speichern" }).click();
await expect(meine.getByRole("status")).toContainText("Gespeichert.");
// Und wieder der Beweis, dass es in der Datenbank steht.
await page.reload();
await expect(karte(page, name)).toContainText(`S/N ${MARKE}-SN-NEU`);
// Archivieren nimmt das Gerät aus der Liste, ohne es zu entfernen.
await karte(page, name).getByRole("button", { name: "archivieren" }).click();
await expect(karte(page, name)).toHaveCount(0);
await page.goto(`/geraete?suche=${MARKE}&archiviert=1`);
await expect(karte(page, name)).toContainText("archiviert");
await karte(page, name).getByRole("button", { name: "wieder freigeben" }).click();
await expect(karte(page, name).getByRole("button", { name: "archivieren" })).toBeVisible();
await aufraeumen(page);
});
test("liest eine CSV-Datei ein und meldet die kaputte Zeile einzeln", async ({ page }) => {
test.slow();
await anmelden(page);
await page.goto("/geraete");
const bereich = page.locator("details", { hasText: "Geräte aus einer CSV-Datei einlesen" });
await bereich.getByText("Geräte aus einer CSV-Datei einlesen").click();
await bereich
.locator('textarea[name="inhalt"]')
.fill(
[
"hostname;label;serialNumber;inventoryNumber;type;model",
`${MARKE}-CSV-GUT;;SN-GUT;INV-1;PC;OptiPlex`,
`${MARKE}-CSV-BOESE;;SN-BOESE;INV-2;Tablet;`,
].join("\r\n"),
);
await bereich.getByRole("button", { name: "Einlesen" }).click();
await expect(bereich.getByRole("status")).toContainText("1 von 2 Zeilen angelegt, 1 Zeile abgewiesen.");
// Mit Zeilennummer, damit die Datei an genau dieser Stelle zu ändern ist.
await expect(bereich.getByText("Zeile 3")).toBeVisible();
await expect(bereich.getByText(/kein bekannter Gerätetyp/)).toBeVisible();
// Die gute Zeile steht in der Datenbank, die kaputte nicht.
await page.goto(`/geraete?suche=${MARKE}-CSV`);
await expect(karte(page, `${MARKE}-CSV-GUT`)).toBeVisible();
await expect(page.locator("ul > li").filter({ hasText: MARKE })).toHaveCount(1);
await aufraeumen(page);
});
test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile, viewport }) => {
test.skip(!isMobile, "Nur im Projekt „handy“.");
test.slow();
const name = `${MARKE}-RAUM`;
await anmelden(page);
await page.goto("/geraete");
const formular = anlegenFormular(page);
await formular.getByLabel("Bezeichnung").fill(name);
await formular.getByLabel("Typ").selectOption("RAUM");
await formular.getByRole("button", { name: "Gerät anlegen" }).click();
await expect(formular.getByRole("status")).toContainText("wurde angelegt");
await page.goto(`/geraete?suche=${MARKE}`);
await expect(karte(page, name)).toBeVisible();
const masse = await page.evaluate(() => ({
scroll: document.documentElement.scrollWidth,
sichtbar: document.documentElement.clientWidth,
}));
expect(masse.scroll).toBeLessThanOrEqual(masse.sichtbar);
expect(viewport?.width).toBeLessThanOrEqual(430);
// Die Bedienelemente sind am Handy mindestens 44 px hoch.
const knopf = karte(page, name).getByRole("button", { name: "archivieren" });
const kasten = await knopf.boundingBox();
expect(kasten?.height ?? 0).toBeGreaterThanOrEqual(44);
await aufraeumen(page);
});
});
+133
View File
@@ -0,0 +1,133 @@
import { expect, test, type Page } from "@playwright/test";
import "dotenv/config";
import { prisma } from "@/lib/db";
/**
* Personen und Abteilungen durch die Oberflaeche (#011).
*
* Geprueft wird, was nur mit angehaengtem React gelingt — und danach, ob es
* wirklich in der Datenbank steht: durch Neuladen. Ein Screenshot sagt darueber
* nichts (siehe CLAUDE.md, „Oberflaeche pruefen“).
*
* Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`).
*
* Aufgeraeumt wird am Ende ueber die Datenbank und nicht ueber die Oberflaeche:
* die Anwendung kennt bewusst keinen Loeschweg fuer Personen und Abteilungen
* (deaktivieren und archivieren sind das Gegenteil von Aufraeumen). Ohne diesen
* Nachlauf saehe jede Datenbank, gegen die diese Tests laufen, nach ein paar
* Durchlaeufen aus wie ein Messie-Keller — in der Entwicklungsdatenbank faellt
* das sofort auf den Screenshots auf.
*/
const STEMPEL = Date.now();
async function anmelden(seite: Page) {
const email = process.env["SEED_ADMIN_EMAIL"];
const passwort = process.env["SEED_ADMIN_PASSWORD"];
if (!email || !passwort) throw new Error("SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen.");
await seite.goto("/anmelden");
await seite.fill('input[name="email"]', email);
await seite.fill('input[name="passwort"]', passwort);
await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]);
}
test.describe("Personen", () => {
test("legt an, findet über die Suche und deaktiviert", async ({ page }) => {
test.slow();
const nachname = `ZZPruef${STEMPEL}`;
await anmelden(page);
await page.goto("/mitarbeiter");
// `summary` ist in Chromium kein `button`, sondern ein Aufklapp-Element.
await page.locator("summary", { hasText: "Person von Hand anlegen" }).click();
await page.getByLabel("Vorname").fill("Testa");
await page.getByLabel("Nachname").fill(nachname);
await page.getByLabel("Mail-Adresse (optional)").fill(`testa.${STEMPEL}@firma.de`);
await page.getByRole("button", { name: "Person anlegen" }).click();
await expect(page.getByText(`„Testa ${nachname}“ wurde angelegt.`)).toBeVisible();
// Über die Suche finden – das ist der Weg, den ein Techniker geht.
await page.goto(`/mitarbeiter?suche=${nachname}`);
const treffer = page.getByRole("link", { name: new RegExp(nachname) });
await expect(treffer).toHaveCount(1);
await treffer.click();
await page.waitForURL(/\/mitarbeiter\/[^/]+$/);
// Deaktivieren – der einzige Weg hinaus, geloescht wird nichts.
await page.getByRole("button", { name: "deaktivieren" }).click();
await expect(page.getByText("deaktiviert")).toBeVisible();
// Und damit aus der Standardliste verschwinden, aber nicht aus der Welt.
await page.goto(`/mitarbeiter?suche=${nachname}`);
await expect(page.getByRole("link", { name: new RegExp(nachname) })).toHaveCount(0);
await page.goto(`/mitarbeiter?suche=${nachname}&deaktivierte=1`);
await expect(page.getByRole("link", { name: new RegExp(nachname) })).toHaveCount(1);
});
test("sperrt die Stammdaten einer Person aus dem Verzeichnis", async ({ page }) => {
// Vorausgesetzt wird die Beispielperson aus `pnpm beispiel:stammdaten`.
test.slow();
await anmelden(page);
await page.goto("/mitarbeiter?suche=kwinter");
const treffer = page.getByRole("link", { name: /Katrin Winter/ });
test.skip((await treffer.count()) === 0, "Beispiel-Stammdaten fehlen (pnpm beispiel:stammdaten).");
await treffer.click();
await page.waitForURL(/\/mitarbeiter\/[^/]+$/);
await expect(page.getByText(/kommt aus dem Verzeichnis/)).toBeVisible();
await expect(page.getByLabel("Vorname")).toHaveAttribute("readonly", "");
await expect(page.getByRole("button", { name: "Stammdaten speichern" })).toHaveCount(0);
// Deaktivieren muss trotzdem gehen: das Offboarding wartet nicht auf den
// naechsten Abgleich.
await expect(page.getByRole("button", { name: "deaktivieren" })).toBeVisible();
});
});
test.describe("Abteilungen", () => {
test("legt eine Abteilung mit Ziel-OU und Gruppen an", async ({ page }) => {
test.slow();
const name = `ZZ Abteilung ${STEMPEL}`;
await anmelden(page);
await page.goto("/admin/abteilungen");
const formular = page.locator("section", { hasText: "Abteilung anlegen" });
await formular.getByLabel("Name").fill(name);
await formular.getByLabel("Ziel-OU (optional)").fill("OU=Test,DC=firma,DC=de");
await formular.getByLabel("Standardgruppen, mit Komma getrennt").fill("G-Test, G-Alle");
await formular.getByRole("button", { name: "Abteilung anlegen" }).click();
await expect(page.getByText(`„${name}“ wurde angelegt.`)).toBeVisible();
await page.reload();
const eintrag = page.locator("li", { hasText: name }).first();
await expect(eintrag).toContainText("OU=Test,DC=firma,DC=de");
await expect(eintrag).toContainText("G-Test, G-Alle");
// Denselben Namen ein zweites Mal – mit lesbarer Meldung, nicht mit einem
// Datenbankfehler.
await formular.getByLabel("Name").fill(name);
await formular.getByRole("button", { name: "Abteilung anlegen" }).click();
await expect(page.getByText(/gibt es schon/)).toBeVisible();
// Aufräumen: archivieren, denn geloescht wird nichts.
await eintrag.getByRole("button", { name: "archivieren" }).click();
await expect(eintrag.getByRole("button", { name: "wieder freigeben" })).toBeVisible();
});
});
test.afterAll(async () => {
const personen = await prisma.mitarbeiter.findMany({
where: { nachname: { startsWith: "ZZPruef" } },
select: { id: true },
});
const abteilungen = await prisma.abteilung.findMany({
where: { name: { startsWith: "ZZ Abteilung" } },
select: { id: true },
});
const kennungen = [...personen, ...abteilungen].map((zeile) => zeile.id);
if (kennungen.length > 0) {
await prisma.protokoll.deleteMany({ where: { entitaetId: { in: kennungen } } });
await prisma.mitarbeiter.deleteMany({ where: { id: { in: personen.map((z) => z.id) } } });
await prisma.abteilung.deleteMany({ where: { id: { in: abteilungen.map((z) => z.id) } } });
}
await prisma.$disconnect();
});
+559
View File
@@ -0,0 +1,559 @@
import { beforeEach, describe, expect, it } from "vitest";
import { Betriebsmodus, GeraeteTyp, Rolle } from "@/generated/prisma/enums";
import { istTagCode } from "@/lib/tagcode";
import {
GERAETE_IMPORT_HOECHSTZEILEN,
GeraeteFehler,
geraeteImportieren,
geraetAnlegen,
geraetArchivieren,
geraetBearbeiten,
geraetLoeschen,
} from "@/server/services/geraete";
import { besitzerKandidaten, geraetHolen, geraeteListe } from "@/server/queries/geraete";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Geräte, Tag-Codes und der CSV-Import (#011).
*
* Festgehalten werden die Zusagen aus dem Auftrag: ein registriertes Gerät hat
* einen Namen, ein `tagCode` ändert sich nie und kommt nie zurück, an einem
* Gerät mit Läufen wird nichts gelöscht, und eine kaputte CSV-Zeile bricht den
* Import nicht ab. Dazu die Mandantensicht des Lesemodells — im Review von #009
* war genau dort ein Suchfilter, der die Kundenbedingung überschrieben hat.
*/
async function grundbestand(modus: Betriebsmodus = Betriebsmodus.INTERN) {
const eigene = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global", modus } });
const benutzer = await testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Administrator", rolle: Rolle.ADMIN },
});
return { eigene, admin: { id: benutzer.id, rolle: Rolle.ADMIN } };
}
/** Ein Lauf an einem Gerät – mehr braucht es dafür nicht als eine veröffentlichte Version. */
async function laufAnlegen(kundeId: string, geraetId: string) {
const vorlage = await testPrisma.vorlage.create({ data: { name: "PC-Neuinstallation" } });
const version = await testPrisma.vorlagenVersion.create({
data: {
vorlageId: vorlage.id,
version: 1,
status: "VEROEFFENTLICHT",
veroeffentlichtAm: new Date(),
},
});
return testPrisma.lauf.create({
data: { vorlagenVersionId: version.id, titel: "Neuinstallation", kundeId, geraetId },
});
}
describe.runIf(datenbankVerfuegbar)("Geräte", () => {
beforeEach(async () => {
await datenbankLeeren();
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
});
describe("Anlegen", () => {
it("erzeugt einen Tag-Code und schreibt ihn ins Vergabebuch", async () => {
const { admin, eigene } = await grundbestand();
const geraet = await geraetAnlegen({ hostname: "PC-VERTRIEB-07", typ: GeraeteTyp.PC }, admin);
expect(istTagCode(geraet.tagCode)).toBe(true);
expect(geraet.kundeId).toBe(eigene.id);
expect(geraet.registriertAm).not.toBeNull();
// Beides in einer Transaktion: ein Code ohne Eintrag wäre nach dem Löschen
// des Geräts wieder frei.
await expect(
testPrisma.tagCodeVergabe.findUnique({ where: { tagCode: geraet.tagCode } }),
).resolves.not.toBeNull();
});
it("lehnt ein registriertes Gerät ohne Hostname und ohne Bezeichnung ab", async () => {
const { admin } = await grundbestand();
await expect(geraetAnlegen({ seriennummer: "SN-1" }, admin)).rejects.toThrow(GeraeteFehler);
await expect(geraetAnlegen({ hostname: " ", bezeichnung: "" }, admin)).rejects.toThrow(
/Hostname oder eine Bezeichnung/,
);
// Und es ist auch kein Tag-Code verbraucht worden.
expect(await testPrisma.tagCodeVergabe.count()).toBe(0);
expect(await testPrisma.geraet.count()).toBe(0);
});
it("erlaubt einen vorgedruckten Tag ohne jeden Namen", async () => {
const { admin } = await grundbestand();
const tag = await geraetAnlegen({ registrieren: false }, admin);
expect(tag.registriertAm).toBeNull();
expect(tag.hostname).toBeNull();
expect(tag.bezeichnung).toBeNull();
});
it("nimmt einen Besitzer nur, wenn er zum selben Kunden gehört", async () => {
const { admin, eigene } = await grundbestand();
const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
const eigenePerson = await testPrisma.mitarbeiter.create({
data: { kundeId: eigene.id, vorname: "Ada", nachname: "Lovelace", anzeigename: "Ada Lovelace" },
});
const fremdePerson = await testPrisma.mitarbeiter.create({
data: { kundeId: fremder.id, vorname: "Max", nachname: "Fremd", anzeigename: "Max Fremd" },
});
const geraet = await geraetAnlegen(
{ hostname: "NB-ADA", typ: GeraeteTyp.NOTEBOOK, mitarbeiterId: eigenePerson.id },
admin,
);
expect(geraet.mitarbeiterId).toBe(eigenePerson.id);
await expect(
geraetAnlegen({ hostname: "NB-MAX", mitarbeiterId: fremdePerson.id }, admin),
).rejects.toThrow(/anderen Kunden/);
});
it("protokolliert das Anlegen", async () => {
const { admin } = await grundbestand();
const geraet = await geraetAnlegen({ bezeichnung: "Serverschrank Keller" }, admin);
const eintraege = await testPrisma.protokoll.findMany({
where: { entitaet: "Geraet", entitaetId: geraet.id },
});
expect(eintraege).toHaveLength(1);
expect(eintraege[0]?.aktion).toBe("Gerät angelegt");
expect(eintraege[0]?.nachher).toMatchObject({ tagCode: geraet.tagCode });
});
});
describe("Bearbeiten", () => {
it("weist jeden Änderungsversuch am Tag-Code ab", async () => {
const { admin } = await grundbestand();
const geraet = await geraetAnlegen({ hostname: "PC-1" }, admin);
await expect(
geraetBearbeiten(geraet.id, { tagCode: "LZ-ZZZZZZ", hostname: "PC-2" }, admin),
).rejects.toThrow(/bleibt, wie er ist/);
// Nichts ist passiert – auch nicht der Hostname, der im selben Aufruf stand.
const frisch = await testPrisma.geraet.findUniqueOrThrow({ where: { id: geraet.id } });
expect(frisch.tagCode).toBe(geraet.tagCode);
expect(frisch.hostname).toBe("PC-1");
});
it("lässt den unveränderten Tag-Code mitkommen, ohne sich zu beschweren", async () => {
const { admin } = await grundbestand();
const geraet = await geraetAnlegen({ hostname: "PC-1" }, admin);
const nachher = await geraetBearbeiten(geraet.id, { tagCode: geraet.tagCode, hostname: "PC-2" }, admin);
expect(nachher.hostname).toBe("PC-2");
});
it("lässt den Hostname nicht leeren, wenn dann kein Name übrig bleibt", async () => {
const { admin } = await grundbestand();
const geraet = await geraetAnlegen({ hostname: "PC-1" }, admin);
await expect(geraetBearbeiten(geraet.id, { hostname: "" }, admin)).rejects.toThrow(
/Hostname oder eine Bezeichnung/,
);
// Mit einer Bezeichnung als Ersatz geht es.
const nachher = await geraetBearbeiten(
geraet.id,
{ hostname: "", bezeichnung: "Kiste im Regal" },
admin,
);
expect(nachher.hostname).toBeNull();
expect(nachher.bezeichnung).toBe("Kiste im Regal");
});
it("protokolliert nur, was sich geändert hat", async () => {
const { admin } = await grundbestand();
const geraet = await geraetAnlegen({ hostname: "PC-1", seriennummer: "SN-1" }, admin);
await geraetBearbeiten(geraet.id, { hostname: "PC-2", seriennummer: "SN-1" }, admin);
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { entitaet: "Geraet", entitaetId: geraet.id, aktion: { contains: "geändert" } },
});
expect(eintrag.aktion).toBe("Hostname geändert");
expect(eintrag.vorher).toMatchObject({ Hostname: "PC-1" });
expect(eintrag.nachher).toMatchObject({ Hostname: "PC-2" });
expect(eintrag.nachher).not.toHaveProperty("Seriennummer");
});
it("schreibt gar nichts, wenn sich nichts geändert hat", async () => {
const { admin } = await grundbestand();
const geraet = await geraetAnlegen({ hostname: "PC-1" }, admin);
await geraetBearbeiten(geraet.id, { hostname: "PC-1" }, admin);
expect(await testPrisma.protokoll.count({ where: { aktion: { contains: "geändert" } } })).toBe(0);
});
it("registriert einen vorgedruckten Tag, sobald er einen Namen bekommt", async () => {
const { admin } = await grundbestand();
const tag = await geraetAnlegen({ registrieren: false }, admin);
await expect(geraetBearbeiten(tag.id, { registrieren: true }, admin)).rejects.toThrow(
/Hostname oder eine Bezeichnung/,
);
const nachher = await geraetBearbeiten(tag.id, { registrieren: true, hostname: "PC-NEU" }, admin);
expect(nachher.registriertAm).not.toBeNull();
expect(nachher.tagCode).toBe(tag.tagCode);
});
});
describe("Archivieren und Löschen", () => {
it("lässt ein Gerät mit Läufen nicht löschen, nur archivieren", async () => {
const { admin, eigene } = await grundbestand();
const geraet = await geraetAnlegen({ hostname: "PC-1" }, admin);
await laufAnlegen(eigene.id, geraet.id);
await expect(geraetLoeschen(geraet.id, admin)).rejects.toThrow(/nur archivieren/);
expect(await testPrisma.geraet.count()).toBe(1);
await geraetArchivieren(geraet.id, true, admin);
const frisch = await testPrisma.geraet.findUniqueOrThrow({ where: { id: geraet.id } });
expect(frisch.archiviert).toBe(true);
// Und zurück.
await geraetArchivieren(geraet.id, false, admin);
await expect(testPrisma.geraet.findUniqueOrThrow({ where: { id: geraet.id } })).resolves.toMatchObject({
archiviert: false,
});
});
it("gibt einen Tag-Code auch nach dem Löschen nicht zurück", async () => {
const { admin } = await grundbestand();
const geraet = await geraetAnlegen({ hostname: "PC-WEG" }, admin);
await geraetLoeschen(geraet.id, admin);
expect(await testPrisma.geraet.count()).toBe(0);
// Das Vergabebuch behält ihn: ein übrig gebliebener Aufkleber darf nie auf
// ein zweites Objekt zeigen.
await expect(
testPrisma.tagCodeVergabe.findUnique({ where: { tagCode: geraet.tagCode } }),
).resolves.not.toBeNull();
});
it("protokolliert das Löschen, und der Eintrag überlebt das Gerät", async () => {
const { admin } = await grundbestand();
const geraet = await geraetAnlegen({ hostname: "PC-WEG" }, admin);
await geraetLoeschen(geraet.id, admin);
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { entitaetId: geraet.id, aktion: "Gerät gelöscht" },
});
expect(eintrag.vorher).toMatchObject({ tagCode: geraet.tagCode, hostname: "PC-WEG" });
});
});
describe("CSV-Import", () => {
it("verliert keine Zeile an einem kaputten Anführungszeichen", async () => {
/*
* Der Blocker aus dem Review: ein Anführungszeichen, das in einer Zelle
* beginnt und nicht schliesst, verschluckte alle Folgezeilen bis zum
* naechsten `"`. Der Import meldete dafuer **Erfolg** — drei von drei
* Zeilen angelegt —, und in einer Bezeichnung stand der Text der
* verschluckten Zeilen. Jetzt endet der Schaden an seiner Zeile.
*/
const { admin } = await grundbestand();
const text = [
"hostname,label,serialNumber",
"PC-1,,SN-1",
'PC-2,"Regal,SN-2',
"PC-3,,SN-3",
'PC-4,"Kiste",SN-4',
"PC-5,,SN-5",
].join("\n");
const ergebnis = await geraeteImportieren(text, admin);
expect(ergebnis.angelegt).toBe(4);
expect(ergebnis.abgewiesen).toHaveLength(1);
expect(ergebnis.abgewiesen[0]?.zeile).toBe(3);
expect(ergebnis.abgewiesen[0]?.grund).toMatch(/Anführungszeichen/);
const namen = (await testPrisma.geraet.findMany({ select: { hostname: true } }))
.map((zeile) => zeile.hostname)
.sort();
expect(namen).toEqual(["PC-1", "PC-3", "PC-4", "PC-5"]);
});
it("weist eine doppelte Zeile ab, statt den Bestand zu verdoppeln", async () => {
// Dieselbe Datei zweimal eingelesen verdoppelte sonst alles – und weil ein
// tagCode nie zurueckkommt, waeren die Aufkleber dafuer schon gedruckt.
const { admin } = await grundbestand();
const text = ["hostname", "PC-DOPPELT", "PC-DOPPELT", "PC-EINMAL"].join("\n");
const erster = await geraeteImportieren(text, admin);
expect(erster.angelegt).toBe(2);
expect(erster.abgewiesen[0]?.grund).toMatch(/schon weiter oben/);
const zweiter = await geraeteImportieren(text, admin);
expect(zweiter.angelegt).toBe(0);
expect(zweiter.abgewiesen).toHaveLength(3);
expect(zweiter.abgewiesen[0]?.grund).toMatch(/gibt es schon/);
expect(await testPrisma.geraet.count()).toBe(2);
});
const KOPF = "hostname,label,serialNumber,inventoryNumber,type,model";
it("legt die guten Zeilen an und meldet die kaputten einzeln", async () => {
const { admin } = await grundbestand();
const text = [
KOPF,
"PC-BUERO-01,,SN-1,INV-1,PC,OptiPlex",
",Serverschrank Keller,,,SONSTIGES,",
"PC-KAPUTT,,SN-3,,Tablet,", // unbekannter Typ
",,SN-4,,PC,", // weder Hostname noch Bezeichnung
"NB-05,,SN-5,,notebook,Latitude", // Schreibweise unerheblich
].join("\r\n");
const ergebnis = await geraeteImportieren(text, admin);
expect(ergebnis.gesamt).toBe(5);
expect(ergebnis.angelegt).toBe(3);
// Zeilennummern wie in der Datei: Kopfzeile ist 1.
expect(ergebnis.abgewiesen.map((a) => a.zeile)).toEqual([4, 5]);
expect(ergebnis.abgewiesen[0]?.grund).toMatch(/kein bekannter Gerätetyp/);
expect(ergebnis.abgewiesen[1]?.grund).toMatch(/Weder ein Hostname noch eine Bezeichnung/);
const geraete = await testPrisma.geraet.findMany({ orderBy: { tagCode: "asc" } });
expect(geraete.map((g) => g.hostname ?? g.bezeichnung).sort()).toEqual([
"NB-05",
"PC-BUERO-01",
"Serverschrank Keller",
]);
expect(geraete.find((g) => g.hostname === "NB-05")?.typ).toBe(GeraeteTyp.NOTEBOOK);
});
it("erzeugt je Zeile einen eigenen Tag-Code und trägt jeden ein", async () => {
const { admin } = await grundbestand();
const text = [KOPF, "PC-1,,,,PC,", "PC-2,,,,PC,", "PC-3,,,,PC,"].join("\n");
await geraeteImportieren(text, admin);
const codes = (await testPrisma.geraet.findMany({ select: { tagCode: true } })).map((g) => g.tagCode);
expect(codes).toHaveLength(3);
expect(new Set(codes).size).toBe(3);
expect(codes.every(istTagCode)).toBe(true);
expect(await testPrisma.tagCodeVergabe.count()).toBe(3);
});
it("versteht deutsches Excel: Semikolon, BOM und deutsche Spaltennamen", async () => {
const { admin } = await grundbestand();
const text =
"Hostname;Bezeichnung;Seriennummer;Inventarnummer;Typ;Modell\r\n" +
'PC-EXCEL;;"SN;mit;Semikolon";INV-9;Drucker;"Kyocera ""P3260"""\r\n';
const ergebnis = await geraeteImportieren(text, admin);
expect(ergebnis.angelegt).toBe(1);
const geraet = await testPrisma.geraet.findFirstOrThrow();
expect(geraet.seriennummer).toBe("SN;mit;Semikolon");
expect(geraet.modell).toBe('Kyocera "P3260"');
expect(geraet.typ).toBe(GeraeteTyp.DRUCKER);
});
it("setzt ohne Typspalte den Standard, ohne etwas zu erfinden", async () => {
const { admin } = await grundbestand();
const ergebnis = await geraeteImportieren("hostname\nPC-OHNE-TYP\n", admin);
expect(ergebnis.angelegt).toBe(1);
await expect(testPrisma.geraet.findFirstOrThrow()).resolves.toMatchObject({
typ: GeraeteTyp.SONSTIGES,
});
});
it("weist eine Datei ohne Namensspalte als Ganzes ab", async () => {
const { admin } = await grundbestand();
await expect(geraeteImportieren("serialNumber,type\nSN-1,PC\n", admin)).rejects.toThrow(/hostname/);
expect(await testPrisma.geraet.count()).toBe(0);
});
it("bricht bei zu vielen Zeilen ab, bevor eine einzige geschrieben ist", async () => {
const { admin } = await grundbestand();
const zeilen = Array.from({ length: GERAETE_IMPORT_HOECHSTZEILEN + 1 }, (_, i) => `PC-${i},,,,PC,`);
await expect(geraeteImportieren([KOPF, ...zeilen].join("\n"), admin)).rejects.toThrow(
new RegExp(`mehr als ${GERAETE_IMPORT_HOECHSTZEILEN} Zeilen`),
);
expect(await testPrisma.geraet.count()).toBe(0);
});
it("protokolliert jede eingelesene Zeile mit ihrer Nummer", async () => {
const { admin } = await grundbestand();
await geraeteImportieren([KOPF, "PC-1,,,,PC,"].join("\n"), admin);
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { aktion: "Gerät aus CSV angelegt" },
});
expect(eintrag.nachher).toMatchObject({ hostname: "PC-1", zeile: 2 });
});
});
describe("Lesemodell und Filter", () => {
it("findet über Hostname, Bezeichnung, Seriennummer und Tag-Code", async () => {
const { admin } = await grundbestand();
const pc = await geraetAnlegen(
{ hostname: "PC-BUERO-01", seriennummer: "SN-4711", typ: GeraeteTyp.PC },
admin,
);
await geraetAnlegen({ bezeichnung: "Serverschrank Keller", typ: GeraeteTyp.SERVER }, admin);
const namen = async (suche: string) =>
(await geraeteListe(admin, { suche })).map((zeile) => zeile.anzeige);
expect(await namen("buero")).toEqual(["PC-BUERO-01"]);
expect(await namen("keller")).toEqual(["Serverschrank Keller"]);
expect(await namen("4711")).toEqual(["PC-BUERO-01"]);
// Die sechs Zeichen ohne das Präfix genügen – abgetippt vom Aufkleber.
expect(await namen(pc.tagCode.slice(3))).toEqual(["PC-BUERO-01"]);
});
it("filtert nach Typ und zeigt Archivierte nur auf Wunsch", async () => {
const { admin } = await grundbestand();
await geraetAnlegen({ hostname: "PC-1", typ: GeraeteTyp.PC }, admin);
const drucker = await geraetAnlegen({ bezeichnung: "Drucker Flur", typ: GeraeteTyp.DRUCKER }, admin);
await geraetArchivieren(drucker.id, true, admin);
expect((await geraeteListe(admin)).map((z) => z.anzeige)).toEqual(["PC-1"]);
expect((await geraeteListe(admin, { auchArchivierte: true })).map((z) => z.anzeige)).toEqual([
"PC-1",
"Drucker Flur",
]);
expect(
(await geraeteListe(admin, { typ: GeraeteTyp.DRUCKER, auchArchivierte: true })).map((z) => z.anzeige),
).toEqual(["Drucker Flur"]);
expect(await geraeteListe(admin, { typ: GeraeteTyp.FIREWALL })).toEqual([]);
});
it("zeigt Besitzer und Anzahl der Läufe", async () => {
const { admin, eigene } = await grundbestand();
const person = await testPrisma.mitarbeiter.create({
data: { kundeId: eigene.id, vorname: "Ada", nachname: "Lovelace", anzeigename: "Ada Lovelace" },
});
const geraet = await geraetAnlegen({ hostname: "NB-ADA", mitarbeiterId: person.id }, admin);
await laufAnlegen(eigene.id, geraet.id);
const zeile = await geraetHolen(geraet.id, admin);
expect(zeile?.besitzerName).toBe("Ada Lovelace");
expect(zeile?.besitzerAktiv).toBe(true);
expect(zeile?.anzahlLaeufe).toBe(1);
});
});
/**
* Die Mandantensicht. Der wichtigste Test dieser Datei: ein Suchbegriff darf
* die Kundenbedingung nicht aushebeln. Vor #009 tat er das, weil zwei `OR` im
* selben Objektliteral standen und das zweite gewann.
*/
describe("Mandantensicht", () => {
async function zweiKunden() {
const { eigene } = await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
const meiner = await testPrisma.kunde.create({ data: { name: "Meiner", kuerzel: "MEIN" } });
const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
const techniker = await testPrisma.benutzer.create({
data: { email: "t@firma.example", name: "Techniker", rolle: Rolle.TECHNIKER },
});
await testPrisma.benutzerKunde.create({
data: { benutzerId: techniker.id, kundeId: meiner.id },
});
await testPrisma.geraet.create({
data: {
kundeId: meiner.id,
tagCode: "LZ-AAAAAA",
hostname: "GEHEIM-MEIN",
registriertAm: new Date(),
},
});
await testPrisma.geraet.create({
data: {
kundeId: fremder.id,
tagCode: "LZ-BBBBBB",
hostname: "GEHEIM-FREMD",
registriertAm: new Date(),
},
});
return { eigene, meiner, fremder, techniker: { id: techniker.id, rolle: Rolle.TECHNIKER } };
}
it("zeigt einem gebundenen Techniker nur die Geräte seiner Kunden", async () => {
const { techniker } = await zweiKunden();
expect((await geraeteListe(techniker)).map((z) => z.anzeige)).toEqual(["GEHEIM-MEIN"]);
});
it("lässt sich die Kundengrenze nicht durch einen Suchbegriff aushebeln", async () => {
const { techniker } = await zweiKunden();
// Genau der Fall aus dem Review: die Suche bringt ein eigenes `OR` mit.
expect(await geraeteListe(techniker, { suche: "GEHEIM-FREMD" })).toEqual([]);
expect(await geraeteListe(techniker, { suche: "GEHEIM" })).toHaveLength(1);
expect(await geraeteListe(techniker, { suche: "LZ-BBBBBB" })).toEqual([]);
// Und auch nicht mit Typ- und Archivfilter zusammen.
expect(
await geraeteListe(techniker, { suche: "GEHEIM", auchArchivierte: true, typ: GeraeteTyp.SONSTIGES }),
).toHaveLength(1);
});
it("gibt ein fremdes Gerät auch beim Einzelabruf nicht heraus", async () => {
const { techniker, fremder } = await zweiKunden();
const fremdesGeraet = await testPrisma.geraet.findFirstOrThrow({
where: { kundeId: fremder.id },
});
expect(await geraetHolen(fremdesGeraet.id, techniker)).toBeUndefined();
});
it("schlägt als Besitzer nur aktive Personen sichtbarer Kunden vor", async () => {
const { techniker, meiner, fremder } = await zweiKunden();
await testPrisma.mitarbeiter.createMany({
data: [
{ kundeId: meiner.id, vorname: "A", nachname: "Aktiv", anzeigename: "Anna Aktiv" },
{
kundeId: meiner.id,
vorname: "D",
nachname: "Weg",
anzeigename: "Dieter Weg",
aktiv: false,
},
{ kundeId: fremder.id, vorname: "F", nachname: "Fremd", anzeigename: "Frida Fremd" },
],
});
const auswahl = await besitzerKandidaten(techniker);
expect(auswahl.kandidaten.map((k) => k.anzeigename)).toEqual(["Anna Aktiv"]);
expect(auswahl.gekuerzt).toBe(false);
});
it("nennt bei jedem Kandidaten den Kunden, damit die Auswahl ihn filtern kann", async () => {
/*
* Der Befund aus dem Review: die Seite holt die Kandidaten **einmal** und
* gab sie in jede Karte. Ein Administrator sieht alle Kunden — die
* Auswahlliste bot damit Personen an, die der Dienst danach abweist, weil
* ein Geraet nur einer Person desselben Kunden gehoeren kann. Ohne
* `kundeId` am Kandidaten laesst sich das in der Oberflaeche nicht
* verhindern.
*/
const { meiner, fremder } = await zweiKunden();
const admin = await testPrisma.benutzer.create({
data: { email: "a2@firma.example", name: "Admin", rolle: Rolle.ADMIN },
});
await testPrisma.mitarbeiter.createMany({
data: [
{ kundeId: meiner.id, vorname: "A", nachname: "A", anzeigename: "Anna A" },
{ kundeId: fremder.id, vorname: "B", nachname: "B", anzeigename: "Bert B" },
],
});
const auswahl = await besitzerKandidaten({ id: admin.id, rolle: admin.rolle });
expect(auswahl.kandidaten.map((k) => k.anzeigename)).toEqual(["Anna A", "Bert B"]);
expect(auswahl.kandidaten.map((k) => k.kundeId).sort()).toEqual([meiner.id, fremder.id].sort());
// Was die Oberflaeche daraus macht: nur die des eigenen Kunden.
expect(auswahl.kandidaten.filter((k) => k.kundeId === meiner.id).map((k) => k.anzeigename)).toEqual([
"Anna A",
]);
});
it("sagt, wenn die Besitzerauswahl gekürzt wurde", async () => {
const { techniker, meiner } = await zweiKunden();
await testPrisma.mitarbeiter.createMany({
data: Array.from({ length: 3 }, (_, i) => ({
kundeId: meiner.id,
vorname: "P",
nachname: `Nummer ${i}`,
anzeigename: `Person ${i}`,
})),
});
const auswahl = await besitzerKandidaten(techniker, 2);
expect(auswahl.kandidaten).toHaveLength(2);
expect(auswahl.gekuerzt).toBe(true);
});
});
});
+409
View File
@@ -0,0 +1,409 @@
import { beforeEach, describe, expect, it } from "vitest";
import { Betriebsmodus, Herkunft } from "@/generated/prisma/enums";
import {
ABGEGLICHENE_FELDER,
MitarbeiterFehler,
mitarbeiterAktivSetzen,
mitarbeiterAnlegen,
mitarbeiterBearbeiten,
} from "@/server/services/mitarbeiter";
import {
abteilungsNamen,
geraeteDesMitarbeiters,
mitarbeiterHolen,
mitarbeiterListe,
} from "@/server/queries/mitarbeiter";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Mitarbeiter (#011).
*
* Zwei Regeln tragen das Ganze: nichts wird geloescht, und was aus dem
* Verzeichnis kommt, wird nicht von Hand ueberschrieben. Alles hier dreht sich
* darum — dazu die Mandantensicht, die im Review von #009 dreimal die Fundstelle
* war.
*/
async function grundbestand() {
const eigene = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } });
const admin = await testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" },
});
return { eigene, admin: { id: admin.id, rolle: admin.rolle } };
}
describe.runIf(datenbankVerfuegbar)("Mitarbeiter", () => {
beforeEach(async () => {
await datenbankLeeren();
});
describe("Anlegen", () => {
it("bildet den Anzeigenamen aus Vor- und Nachname, wenn keiner angegeben ist", async () => {
const { admin } = await grundbestand();
const person = await mitarbeiterAnlegen({ vorname: " Maria ", nachname: " Huber " }, admin);
expect(person.anzeigename).toBe("Maria Huber");
expect(person.herkunft).toBe(Herkunft.MANUELL);
expect(person.aktiv).toBe(true);
});
it("nimmt einen eigenen Anzeigenamen an", async () => {
const { admin } = await grundbestand();
const person = await mitarbeiterAnlegen(
{ vorname: "Maria", nachname: "Huber", anzeigename: "Huber, Maria" },
admin,
);
expect(person.anzeigename).toBe("Huber, Maria");
});
it("weist einen leeren Namen ab", async () => {
const { admin } = await grundbestand();
await expect(mitarbeiterAnlegen({ vorname: " ", nachname: "Huber" }, admin)).rejects.toThrow(
MitarbeiterFehler,
);
expect(await testPrisma.mitarbeiter.count()).toBe(0);
});
it("weist einen sAMAccountName über 20 Zeichen ab", async () => {
// Das Active Directory kuerzt nicht, es lehnt ab — und zwar erst beim
// New-ADUser vor dem Kunden.
const { admin } = await grundbestand();
await expect(
mitarbeiterAnlegen({ vorname: "Jörg", nachname: "Müller", sam: "j".repeat(21) }, admin),
).rejects.toThrow(/höchstens 20/);
await expect(
mitarbeiterAnlegen({ vorname: "Jörg", nachname: "Müller", sam: "j".repeat(20) }, admin),
).resolves.toBeTruthy();
});
it("schreibt einen Protokolleintrag", async () => {
const { admin } = await grundbestand();
const person = await mitarbeiterAnlegen({ vorname: "Maria", nachname: "Huber" }, admin);
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { entitaetId: person.id },
});
expect(eintrag.aktion).toBe("Mitarbeiter angelegt");
expect(eintrag.benutzerId).toBe(admin.id);
});
});
describe("Aus dem Verzeichnis abgeglichene Personen", () => {
async function abgeglichen(kundeId: string) {
return testPrisma.mitarbeiter.create({
data: {
kundeId,
vorname: "Maria",
nachname: "Huber",
anzeigename: "Maria Huber",
mail: "maria.huber@firma.example",
abteilung: "Buchhaltung",
herkunft: Herkunft.ABGLEICH,
},
});
}
it("lässt abgeglichene Felder nicht von Hand ändern", async () => {
// Der naechste Abgleich wuerde die Aenderung ohnehin zurueckdrehen. Ein
// Wert, der zwischen zwei Laeufen hin- und herspringt, ist schlimmer als
// ein falscher, weil niemand ihm mehr traut.
const { admin, eigene } = await grundbestand();
const person = await abgeglichen(eigene.id);
for (const aenderung of [
{ vorname: "Marie" },
{ nachname: "Hubert" },
{ mail: "anders@firma.example" },
{ abteilung: "Vertrieb" },
]) {
await expect(
mitarbeiterBearbeiten(person.id, aenderung, admin),
JSON.stringify(aenderung),
).rejects.toThrow(/Verzeichnis/);
}
});
it("stört sich nicht an unverändert mitgeschickten Feldern", async () => {
// Das Formular schickt alle Felder mit. Wuerde schon das Mitschicken
// scheitern, liefe der Benutzer gegen eine Fehlermeldung, ohne etwas
// geaendert zu haben. Abgewiesen wird nur, was sich wirklich aendern soll.
const { admin, eigene } = await grundbestand();
const person = await abgeglichen(eigene.id);
const vorherProtokoll = await testPrisma.protokoll.count();
const nachher = await mitarbeiterBearbeiten(
person.id,
{
vorname: "Maria",
nachname: "Huber",
anzeigename: "Maria Huber",
mail: "maria.huber@firma.example",
abteilung: "Buchhaltung",
},
admin,
);
expect(nachher.anzeigename).toBe("Maria Huber");
expect(await testPrisma.protokoll.count()).toBe(vorherProtokoll);
});
it("sperrt bei ABGLEICH jedes gepflegte Feld, nicht nur einige", async () => {
// Alle Stammdaten einer abgeglichenen Person kommen aus dem Verzeichnis —
// auch der Titel. Uebrig bleibt genau ein Weg: aktivieren und
// deaktivieren. Das ist Absicht und keine Luecke.
const { admin, eigene } = await grundbestand();
const person = await abgeglichen(eigene.id);
for (const feld of ABGEGLICHENE_FELDER) {
await expect(
mitarbeiterBearbeiten(person.id, { [feld]: `anders-${feld}` }, admin),
feld,
).rejects.toThrow(/Verzeichnis/);
}
});
it("lässt eine manuell angelegte Person vollständig ändern", async () => {
const { admin } = await grundbestand();
const person = await mitarbeiterAnlegen({ vorname: "Maria", nachname: "Huber" }, admin);
const nachher = await mitarbeiterBearbeiten(
person.id,
{ vorname: "Marie", mail: "marie@firma.example" },
admin,
);
expect(nachher.vorname).toBe("Marie");
expect(nachher.mail).toBe("marie@firma.example");
});
it("lässt sich auch bei Herkunft ABGLEICH deaktivieren", async () => {
// Das Offboarding muss auch dann gehen, wenn das Verzeichnis noch nicht
// nachgezogen hat.
const { admin, eigene } = await grundbestand();
const person = await abgeglichen(eigene.id);
const nachher = await mitarbeiterAktivSetzen(person.id, false, admin);
expect(nachher.aktiv).toBe(false);
});
});
describe("Bearbeiten", () => {
it("schreibt nichts, wenn sich nichts ändert", async () => {
const { admin } = await grundbestand();
const person = await mitarbeiterAnlegen({ vorname: "Maria", nachname: "Huber" }, admin);
const vorher = await testPrisma.protokoll.count();
await mitarbeiterBearbeiten(person.id, { vorname: "Maria", nachname: "Huber" }, admin);
expect(await testPrisma.protokoll.count()).toBe(vorher);
});
it("nennt im Protokoll, was sich geändert hat", async () => {
const { admin } = await grundbestand();
const person = await mitarbeiterAnlegen({ vorname: "Maria", nachname: "Huber" }, admin);
await mitarbeiterBearbeiten(person.id, { mail: "m.huber@firma.example" }, admin);
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { entitaetId: person.id, aktion: { contains: "geändert" } },
});
expect(eintrag.aktion).toBe("Mail-Adresse geändert");
expect(eintrag.vorher).toEqual({ mail: null });
expect(eintrag.nachher).toEqual({ mail: "m.huber@firma.example" });
});
});
describe("Deaktivieren statt löschen", () => {
it("setzt aktiv auf false und protokolliert es", async () => {
const { admin } = await grundbestand();
const person = await mitarbeiterAnlegen({ vorname: "Maria", nachname: "Huber" }, admin);
await mitarbeiterAktivSetzen(person.id, false, admin);
expect((await testPrisma.mitarbeiter.findUniqueOrThrow({ where: { id: person.id } })).aktiv).toBe(
false,
);
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { entitaetId: person.id, aktion: "Mitarbeiter deaktiviert" },
});
expect(eintrag).toBeTruthy();
});
it("bietet gar keinen Löschweg an", async () => {
// Waere Loeschen manchmal moeglich, muesste jeder Aufrufer die Bedingung
// kennen — und beim Offboarding entscheidet sich niemand bewusst zwischen
// zwei Wegen. Geprueft wird der Dienst selbst: was er nicht anbietet, kann
// auch niemand versehentlich rufen.
const dienst = await import("@/server/services/mitarbeiter");
const verdaechtig = Object.keys(dienst).filter((name) => /loesch|entfern|delete/iu.test(name));
expect(verdaechtig).toEqual([]);
expect(Object.keys(dienst)).toContain("mitarbeiterAktivSetzen");
});
});
describe("Liste und Suche", () => {
async function bestand() {
const { admin, eigene } = await grundbestand();
await mitarbeiterAnlegen(
{
vorname: "Maria",
nachname: "Huber",
mail: "maria.huber@firma.example",
upn: "maria.huber@firma.example",
sam: "mhuber",
abteilung: "Buchhaltung",
},
admin,
);
const jens = await mitarbeiterAnlegen(
{ vorname: "Jens", nachname: "Ex", sam: "jex", abteilung: "Vertrieb" },
admin,
);
await mitarbeiterAktivSetzen(jens.id, false, admin);
return { admin, eigene, jens };
}
it("zeigt standardmäßig nur aktive Personen", async () => {
const { admin } = await bestand();
const namen = (await mitarbeiterListe(admin)).map((z) => z.anzeigename);
expect(namen).toEqual(["Maria Huber"]);
});
it("zeigt mit Umschalter auch deaktivierte", async () => {
const { admin } = await bestand();
const namen = (await mitarbeiterListe(admin, { auchDeaktivierte: true })).map((z) => z.anzeigename);
expect(namen.sort()).toEqual(["Jens Ex", "Maria Huber"]);
});
it("sucht über Anzeigename, Mail, UPN und sam", async () => {
const { admin } = await bestand();
for (const suche of ["huber", "MARIA.HUBER@firma", "mhuber"]) {
const treffer = await mitarbeiterListe(admin, { suche });
expect(
treffer.map((z) => z.anzeigename),
suche,
).toEqual(["Maria Huber"]);
}
});
it("lässt den Aktiv-Filter auch beim Suchen stehen", async () => {
// Genau der Blocker aus #009 in neuem Gewand: der Suchfilter bringt ein
// eigenes `OR` mit. Liegt es nicht in einem `AND`, gewinnt es.
const { admin } = await bestand();
const treffer = await mitarbeiterListe(admin, { suche: "e" });
expect(treffer.map((z) => z.anzeigename)).toEqual(["Maria Huber"]);
});
it("filtert nach Abteilung", async () => {
const { admin } = await bestand();
expect((await mitarbeiterListe(admin, { abteilung: "Buchhaltung" })).length).toBe(1);
expect((await mitarbeiterListe(admin, { abteilung: "Vertrieb" })).length).toBe(0);
});
it("liefert die vorkommenden Abteilungsnamen", async () => {
const { admin } = await bestand();
expect(await abteilungsNamen(admin)).toEqual(["Buchhaltung", "Vertrieb"]);
});
it("zählt Geräte und Läufe", async () => {
const { admin, eigene } = await bestand();
const person = (await mitarbeiterListe(admin))[0]!;
await testPrisma.geraet.create({
data: { kundeId: eigene.id, mitarbeiterId: person.id, tagCode: "LZ-7K3F9Q", hostname: "PC-001" },
});
const frisch = (await mitarbeiterListe(admin))[0]!;
expect(frisch.anzahlGeraete).toBe(1);
expect(frisch.anzahlLaeufe).toBe(0);
});
it("gibt die Geräte einer Person zurück, Hostname vor Bezeichnung", async () => {
const { admin, eigene } = await bestand();
const person = (await mitarbeiterListe(admin))[0]!;
await testPrisma.geraet.create({
data: { kundeId: eigene.id, mitarbeiterId: person.id, tagCode: "LZ-7K3F9Q", hostname: "PC-001" },
});
await testPrisma.geraet.create({
data: {
kundeId: eigene.id,
mitarbeiterId: person.id,
tagCode: "LZ-8M4G2R",
bezeichnung: "Monitor rechts",
},
});
const geraete = await geraeteDesMitarbeiters(person.id, admin);
expect(geraete.map((g) => g.name).sort()).toEqual(["Monitor rechts", "PC-001"]);
});
});
describe("Mandantensicht", () => {
async function zweiKunden() {
const eigene = await testPrisma.kunde.create({
data: { name: "Eigene", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
const meiner = await testPrisma.kunde.create({ data: { name: "Meiner", kuerzel: "MEIN" } });
const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
await testPrisma.einstellung.create({
data: { id: "global", modus: Betriebsmodus.DIENSTLEISTUNG },
});
const techniker = await testPrisma.benutzer.create({
data: { email: "t@firma.example", name: "Techniker", rolle: "TECHNIKER" },
});
await testPrisma.benutzerKunde.create({
data: { benutzerId: techniker.id, kundeId: meiner.id },
});
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
for (const [kundeId, name] of [
[meiner.id, "Meine Person"],
[fremder.id, "Fremde Person"],
] as const) {
await testPrisma.mitarbeiter.create({
data: { kundeId, vorname: name, nachname: "X", anzeigename: name, abteilung: "Geheim" },
});
}
return {
eigene,
techniker: { id: techniker.id, rolle: techniker.rolle },
fremdePerson: await testPrisma.mitarbeiter.findFirstOrThrow({ where: { kundeId: fremder.id } }),
};
}
it("zeigt einem gebundenen Techniker keine fremde Person", async () => {
const { techniker } = await zweiKunden();
expect((await mitarbeiterListe(techniker)).map((z) => z.anzeigename)).toEqual(["Meine Person"]);
});
it("hält die Grenze auch beim Suchen", async () => {
const { techniker } = await zweiKunden();
expect((await mitarbeiterListe(techniker, { suche: "Person" })).map((z) => z.anzeigename)).toEqual([
"Meine Person",
]);
});
it("gibt eine fremde Person nicht einzeln heraus", async () => {
const { techniker, fremdePerson } = await zweiKunden();
expect(await mitarbeiterHolen(fremdePerson.id, techniker)).toBeUndefined();
});
it("zeigt die Geräte einer fremden Person nicht", async () => {
const { techniker, fremdePerson } = await zweiKunden();
await testPrisma.geraet.create({
data: { kundeId: fremdePerson.kundeId, mitarbeiterId: fremdePerson.id, tagCode: "LZ-7K3F9Q" },
});
expect(await geraeteDesMitarbeiters(fremdePerson.id, techniker)).toEqual([]);
});
it("blendet fremde Abteilungsnamen aus dem Filter aus", async () => {
const { techniker } = await zweiKunden();
await testPrisma.mitarbeiter.updateMany({
where: { anzeigename: "Fremde Person" },
data: { abteilung: "Nur-fuer-Fremde" },
});
expect(await abteilungsNamen(techniker)).not.toContain("Nur-fuer-Fremde");
});
it("lässt eine fremde Person nicht deaktivieren", async () => {
const { techniker, fremdePerson } = await zweiKunden();
await expect(mitarbeiterAktivSetzen(fremdePerson.id, false, techniker)).rejects.toThrow(/Berechtigung/);
});
it("lässt eine fremde Person nicht bearbeiten", async () => {
const { techniker, fremdePerson } = await zweiKunden();
await expect(mitarbeiterBearbeiten(fremdePerson.id, { titel: "Chef" }, techniker)).rejects.toThrow(
/Berechtigung/,
);
});
});
});
+11 -2
View File
@@ -17,10 +17,19 @@ describe("Seitenleiste nach Betriebsmodus", () => {
expect(pfade).toContain("/mitarbeiter");
});
it("zeigt im Modus Dienstleistung Kunden statt Mitarbeiter & Geräte", () => {
it("zeigt im Modus Dienstleistung zusätzlich die Kunden", () => {
const pfade = pfadeFuer("DIENSTLEISTUNG", "ADMIN");
expect(pfade).toContain("/kunden");
expect(pfade).not.toContain("/mitarbeiter");
});
it("nennt dieselbe Seite je Modus anders", () => {
// Es sind die Leute des Kunden, nicht die eigenen Angestellten. Die Seite
// bleibt dieselbe — sie ist in beiden Modi erreichbar, sonst kaeme man im
// Modus Dienstleistung gar nicht an die Personen eines Kunden (#011).
const beschriftung = (modus: "INTERN" | "DIENSTLEISTUNG") =>
navigationFuer(modus).find((e) => e.pfad === "/mitarbeiter")?.beschriftung;
expect(beschriftung("INTERN")).toBe("Mitarbeiter & Geräte");
expect(beschriftung("DIENSTLEISTUNG")).toBe("Personen & Geräte");
});
});
+4 -3
View File
@@ -14,6 +14,7 @@ const MATRIX: Record<Recht, Record<Rolle, boolean>> = {
lesen: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: true },
laeufeBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: false },
geraeteBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: false },
mitarbeiterBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: true, LESER: false },
vorlagenBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: false, LESER: false },
archivVerwalten: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: false },
verwaltung: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: false },
@@ -25,10 +26,10 @@ describe("Rechtematrix", () => {
);
it("prueft jede Rolle gegen jedes Recht", () => {
// Haelt fest, dass die Tabelle unten wirklich vollstaendig ist: vier Rollen, sechs Rechte.
// Haelt fest, dass die Tabelle unten wirklich vollstaendig ist: vier Rollen, sieben Rechte.
expect(ALLE_ROLLEN).toHaveLength(4);
expect(ALLE_RECHTE).toHaveLength(6);
expect(faelle).toHaveLength(24);
expect(ALLE_RECHTE).toHaveLength(7);
expect(faelle).toHaveLength(28);
});
it.each(faelle)("%s: %s -> %s", (recht, rolle, erwartet) => {