feat(stammdaten): Mitarbeiter, Abteilungen und Geraete (#25)
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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** |
|
||||
|
||||
|
After Width: | Height: | Size: 310 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 343 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 394 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 510 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 118 KiB |
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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, {});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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"],
|
||||
/**
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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] ?? "")),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||