feat(protokoll): Protokoll ansehen, mit Filtern und Mandantensicht #45
@@ -26,6 +26,18 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- **Das Protokoll ist ansehbar**, unter Verwaltung → Protokoll. Geschrieben wurde es seit dem
|
||||||
|
Datenmodell, ansehen konnte es niemand: wer wissen wollte, wer wann eine Rolle geändert hat,
|
||||||
|
brauchte `psql`. Jetzt mit Volltext über Aktion und Lauftitel, Filter nach Urheber und Zeitraum,
|
||||||
|
fünfzig Einträge je Seite. Ein Eintrag ohne Urheber wird als **Systemlauf** gezeigt und nicht als
|
||||||
|
Lücke — der Zeitplan des Verzeichnisabgleichs hat keinen Menschen hinter sich. Am Lauf selbst
|
||||||
|
stehen seine Einträge eingeklappt in der Abarbeitungsansicht; wer den Lauf sehen darf, sieht auch
|
||||||
|
dessen Geschichte (#023)
|
||||||
|
- **`Protokoll` trägt eine `kundeId`**, wie CLAUDE.md es für jede kundenbezogene Zeile verlangt. Ohne
|
||||||
|
sie ließ sich die Mandantensicht nicht anwenden, ohne je Eintrag die Entität nachzuschlagen. Leer
|
||||||
|
heißt global; bestehende Einträge nimmt die Migration mit. Ein Eintrag, der sich **keinem** Kunden
|
||||||
|
zuordnen lässt — etwa zu einem gelöschten Gerät —, wird einem auf Kunden beschränkten Benutzer
|
||||||
|
nicht gezeigt (#023)
|
||||||
- **Schnellstart je Gerätetyp pflegen**, unter Verwaltung → Schnellstart. Welche Vorlagen auf der
|
- **Schnellstart je Gerätetyp pflegen**, unter Verwaltung → Schnellstart. Welche Vorlagen auf der
|
||||||
Geräteseite als Kachel erscheinen, stand bisher nur im Seed: wer eine eigene Vorlage dort haben
|
Geräteseite als Kachel erscheinen, stand bisher nur im Seed: wer eine eigene Vorlage dort haben
|
||||||
wollte, kam nicht heran, und wer eine mitgelieferte loswerden wollte, auch nicht. Zur Auswahl
|
wollte, kam nicht heran, und wer eine mitgelieferte loswerden wollte, auch nicht. Zur Auswahl
|
||||||
|
|||||||
@@ -169,13 +169,30 @@ nicht archivieren.
|
|||||||
Eine Vorlage lässt sich **einem** Kunden zuordnen („nur Musterfirma GmbH“) oder für alle gelten
|
Eine Vorlage lässt sich **einem** Kunden zuordnen („nur Musterfirma GmbH“) oder für alle gelten
|
||||||
lassen. Eine kundenspezifische Vorlage startet keinen Lauf bei einem anderen Kunden.
|
lassen. Eine kundenspezifische Vorlage startet keinen Lauf bei einem anderen Kunden.
|
||||||
|
|
||||||
## Was protokolliert wird
|
## Protokoll
|
||||||
|
|
||||||
Laufzettel schreibt ein Protokoll über das, was nachvollziehbar bleiben muss: Rollenänderungen,
|
Laufzettel schreibt ein Protokoll über das, was nachvollziehbar bleiben muss: Rollenänderungen,
|
||||||
veröffentlichte Vorlagen, eingespielte Vorlagen, archivierte und zurückgeholte Läufe, angelegte und
|
veröffentlichte Vorlagen, eingespielte Vorlagen, archivierte und zurückgeholte Läufe, angelegte und
|
||||||
gelöschte Verzeichnisquellen, den Moduswechsel. Ein Protokolleintrag verliert seinen Urheber nie. Eine
|
gelöschte Verzeichnisquellen, den Moduswechsel. Ein Protokolleintrag verliert seinen Urheber nie, und
|
||||||
eigene Ansicht dafür gibt es in der Oberfläche noch nicht; was am Lauf selbst geschehen ist, steht in
|
geändert oder gelöscht wird keiner.
|
||||||
seiner Abarbeitungsansicht und im Ausdruck.
|
|
||||||
|
Zu sehen ist das unter **Verwaltung → Protokoll**, neueste Einträge zuerst, fünfzig je Seite. Vier
|
||||||
|
Filter stehen darüber:
|
||||||
|
|
||||||
|
- **Aktion oder Lauf** — Volltext über die Aktion, die Art des Eintrags und den Titel des Laufs.
|
||||||
|
„Rolle“ findet jede Rollenänderung, „archiviert“ jede Archivierung.
|
||||||
|
- **Urheber** — zur Auswahl stehen nur die, die wirklich Einträge haben.
|
||||||
|
- **von** und **bis** — der *bis*-Tag zählt mit; „bis heute“ enthält also, was heute passiert ist.
|
||||||
|
|
||||||
|
Hinter **Werte** stehen die Felder vor und nach der Änderung, soweit sie festgehalten wurden.
|
||||||
|
Eingeklappt, weil sie lang werden können.
|
||||||
|
|
||||||
|
**Ein Eintrag ohne Urheber ist keine Lücke.** Dort steht „Systemlauf“: der Zeitplan des
|
||||||
|
Verzeichnisabgleichs läuft ohne Menschen dahinter, und das soll man sehen können.
|
||||||
|
|
||||||
|
Was an einem **einzelnen Lauf** geschehen ist, steht außerdem unten in seiner Abarbeitungsansicht
|
||||||
|
unter **Protokoll dieses Laufs**, eingeklappt. Wer den Lauf sehen darf, sieht auch dessen Einträge —
|
||||||
|
dafür braucht es keine Administratorrolle.
|
||||||
|
|
||||||
Ein **erzeugtes Passwort steht nie im Protokoll** — es wird nirgends gespeichert. Gäbe es ein Feld
|
Ein **erzeugtes Passwort steht nie im Protokoll** — es wird nirgends gespeichert. Gäbe es ein Feld
|
||||||
dafür, wäre die Versuchung groß, es „nur fürs Protokoll“ mitzuschreiben.
|
dafür, wäre die Versuchung groß, es „nur fürs Protokoll“ mitzuschreiben.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# #023 Protokoll ansehen
|
# #023 Protokoll ansehen
|
||||||
|
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Im Tracker:** [#35](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/35)
|
**Im Tracker:** [#35](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/35)
|
||||||
**Meilenstein:** v0.2.0
|
**Meilenstein:** v0.2.0
|
||||||
**Labels:** phase-2, frontend
|
**Labels:** phase-2, frontend
|
||||||
@@ -14,22 +14,42 @@ geändert hat, braucht `psql`.
|
|||||||
|
|
||||||
Aufgefallen beim Schreiben der Anleitung in [#018](018-e2e-anleitung-pdf.md).
|
Aufgefallen beim Schreiben der Anleitung in [#018](018-e2e-anleitung-pdf.md).
|
||||||
|
|
||||||
**Geschätzt:** 4 h **Tatsächlich:** –
|
**Geschätzt:** 4 h **Tatsächlich:** 1 h 50 min
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
|
|
||||||
- [ ] Admin → Protokoll: Liste mit Zeitpunkt, Urheber, Aktion, betroffener Zeile, umblätterbar
|
- [x] Admin → Protokoll: Liste mit Zeitpunkt, Urheber, Aktion, betroffener Zeile, umblätterbar
|
||||||
- [ ] Filter nach Zeitraum, Aktion und Urheber; Volltext über die Aktion
|
- [x] Filter nach Zeitraum, Aktion und Urheber; Volltext über die Aktion
|
||||||
- [ ] Die Mandantensicht gilt auch hier: ein Eintrag zu einem fremden Kunden ist nicht sichtbar
|
- [x] Die Mandantensicht gilt auch hier: ein Eintrag zu einem fremden Kunden ist nicht sichtbar
|
||||||
- [ ] Recht `verwaltung` — das Protokoll sagt, wer was getan hat, und ist damit selbst eine Auskunft
|
- [x] Recht `verwaltung` — das Protokoll sagt, wer was getan hat, und ist damit selbst eine Auskunft
|
||||||
über Menschen
|
über Menschen
|
||||||
- [ ] Ein Eintrag ohne Urheber (Systemlauf, etwa der Zeitplan) wird als solcher gezeigt, nicht als
|
- [x] Ein Eintrag ohne Urheber (Systemlauf, etwa der Zeitplan) wird als solcher gezeigt, nicht als
|
||||||
Lücke
|
Lücke
|
||||||
- [ ] Am Lauf selbst: die Einträge dieses Laufs in seiner Abarbeitungsansicht, eingeklappt
|
- [x] Am Lauf selbst: die Einträge dieses Laufs in seiner Abarbeitungsansicht, eingeklappt
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
- [ ] Test: ein Eintrag eines fremden Kunden erscheint nicht
|
- [x] Test: ein Eintrag eines fremden Kunden erscheint nicht
|
||||||
- [ ] Test: `TECHNIKER` kommt nicht auf die Seite
|
- [x] Test: `TECHNIKER` kommt nicht auf die Seite
|
||||||
- [ ] Test: ein erzeugtes Passwort taucht in keinem Eintrag auf
|
- [x] Test: ein erzeugtes Passwort taucht in keinem Eintrag auf
|
||||||
- [ ] Am Handy kein horizontales Scrollen
|
- [x] Am Handy kein horizontales Scrollen
|
||||||
|
|
||||||
|
## Beim Umsetzen dazugekommen
|
||||||
|
|
||||||
|
- **`Protokoll` hat jetzt eine `kundeId`.** CLAUDE.md verlangt sie für jede kundenbezogene Zeile; das
|
||||||
|
Protokoll hatte sie nicht, und damit ließ sich die Mandantensicht nicht anwenden, ohne je Eintrag
|
||||||
|
die Entität nachzuschlagen. Leer heißt **global** (Einstellungen, Benutzer, kundenfreie Vorlagen,
|
||||||
|
Schnellstart). Zwanzig Schreibstellen setzen sie jetzt; laufbezogene Einträge bleiben ohne, weil
|
||||||
|
der Lauf die bessere Quelle ist — er kann seine Zuordnung nicht verlieren.
|
||||||
|
- **Die Migration führt bestehende Einträge nach**, über den Lauf und über die fünf
|
||||||
|
kundentragenden Entitäten. An echten Daten gefahren: 59 von 733 Einträgen bekamen einen Kunden.
|
||||||
|
- **Nicht zuordenbare Einträge werden verborgen, nicht gezeigt.** 414 Einträge in der
|
||||||
|
Entwicklungsdatenbank zeigen auf längst gelöschte Testgeräte und lassen sich keinem Kunden
|
||||||
|
zuordnen. `vorher`/`nachher` können einen Hostnamen enthalten — bei einer Mandantengrenze wird im
|
||||||
|
Zweifel zugemacht, nicht aufgemacht.
|
||||||
|
- **Keine Tabelle, sondern Karten.** Sieben Spalten sind bei 390 px nicht darstellbar, ohne
|
||||||
|
horizontal zu scrollen, und genau das ist ausgeschlossen.
|
||||||
|
- Das Mandanten-Kriterium ist auf der Seite heute **strukturell** erfüllt, weil sie `verwaltung`
|
||||||
|
verlangt und ein `ADMIN` ohnehin alle Kunden sieht. Der Filter sitzt trotzdem im Lesemodell und ist
|
||||||
|
dort geprüft — ein Lesemodell trägt seine Sicht selbst, und wenn das Recht je weiter gefasst wird,
|
||||||
|
hält die Grenze.
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
|
|||||||
| #020 | [Vorgesetzter auch für von Hand angelegte Personen](020-vorgesetzter-manuell.md) | erledigt | v0.2.0 | 3 h | 1 h 20 min |
|
| #020 | [Vorgesetzter auch für von Hand angelegte Personen](020-vorgesetzter-manuell.md) | erledigt | v0.2.0 | 3 h | 1 h 20 min |
|
||||||
| #021 | [Stabiler Schlüssel für Seed-Vorlagen](021-stabiler-schluessel-vorlagen.md) | erledigt | v0.2.0 | 2 h | 55 min |
|
| #021 | [Stabiler Schlüssel für Seed-Vorlagen](021-stabiler-schluessel-vorlagen.md) | erledigt | v0.2.0 | 2 h | 55 min |
|
||||||
| #022 | [Schnellstart je Gerätetyp pflegen](022-schnellstart-pflegen.md) | erledigt | v0.2.0 | 3 h | 1 h 10 min |
|
| #022 | [Schnellstart je Gerätetyp pflegen](022-schnellstart-pflegen.md) | erledigt | v0.2.0 | 3 h | 1 h 10 min |
|
||||||
| #023 | [Protokoll ansehen](023-protokoll-ansicht.md) | offen | v0.2.0 | 4 h | – |
|
| #023 | [Protokoll ansehen](023-protokoll-ansicht.md) | erledigt | v0.2.0 | 4 h | 1 h 50 min |
|
||||||
| #024 | [Proxmox-Einzeiler auf einem echten PVE-Host gegenprüfen](024-proxmox-echtlauf.md) | offen | v0.2.0 | 2 h | – |
|
| #024 | [Proxmox-Einzeiler auf einem echten PVE-Host gegenprüfen](024-proxmox-echtlauf.md) | offen | v0.2.0 | 2 h | – |
|
||||||
| #025 | [Healthcheck gibt die rohe Datenbankfehlermeldung heraus](025-gesundheit-fehlermeldung.md) | erledigt | v0.2.0 | 1 h | 25 min |
|
| #025 | [Healthcheck gibt die rohe Datenbankfehlermeldung heraus](025-gesundheit-fehlermeldung.md) | erledigt | v0.2.0 | 1 h | 25 min |
|
||||||
| #026 | [Zwei Konventionen präzisieren, die strenger klingen als sie gemeint sind](026-konventionen-praezisieren.md) | erledigt | v0.2.0 | 1 h | 20 min |
|
| #026 | [Zwei Konventionen präzisieren, die strenger klingen als sie gemeint sind](026-konventionen-praezisieren.md) | erledigt | v0.2.0 | 1 h | 20 min |
|
||||||
| #027 | [Die Editor-Abläufe lassen ihre Vorlagen stehen](027-e2e-reste-vorlagen.md) | offen | v0.2.0 | 1 h | – |
|
| #027 | [Die Editor-Abläufe lassen ihre Vorlagen stehen](027-e2e-reste-vorlagen.md) | offen | v0.2.0 | 1 h | – |
|
||||||
| | **Summe** | | | **153 h** | **39 h 30 min** |
|
| | **Summe** | | | **153 h** | **41 h 20 min** |
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- HANDGESCHRIEBEN. Prisma erzeugt hier zusaetzlich Loeschbefehle fuer die
|
||||||
|
-- zusammengesetzten Fremdschluessel (Mandantengrenze), die Trigramm-Indizes
|
||||||
|
-- (Archivsuche), die partiellen eindeutigen Indizes und die Trigger auf
|
||||||
|
-- veroeffentlichten Versionen: es kennt sie nicht, weil sie sich im Schema nicht
|
||||||
|
-- ausdruecken lassen. Diese Loeschbefehle sind bewusst gestrichen.
|
||||||
|
--
|
||||||
|
-- tests/datenbankregeln.test.ts prueft, dass alle diese Objekte vorhanden sind –
|
||||||
|
-- ein versehentlich stehengelassener Loeschbefehl faellt dort sofort auf.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Mandantenspalte fuer das Protokoll (#023).
|
||||||
|
--
|
||||||
|
-- CLAUDE.md verlangt sie fuer jede kundenbezogene Zeile. Das Protokoll hatte sie
|
||||||
|
-- nicht, und damit liess sich die Protokoll-Ansicht nicht nach Mandant filtern,
|
||||||
|
-- ohne je Eintrag die Entitaet nachzuschlagen. Leer heisst **global**:
|
||||||
|
-- Einstellungen, Benutzer, kundenfreie Vorlagen, Schnellstart.
|
||||||
|
ALTER TABLE "Protokoll"
|
||||||
|
ADD COLUMN "kundeId" TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX "Protokoll_kundeId_erstelltAm_idx" ON "Protokoll"("kundeId", "erstelltAm");
|
||||||
|
|
||||||
|
ALTER TABLE "Protokoll"
|
||||||
|
ADD CONSTRAINT "Protokoll_kundeId_fkey"
|
||||||
|
FOREIGN KEY ("kundeId") REFERENCES "Kunde" ("id")
|
||||||
|
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- ---- Nachfuehrung ---------------------------------------------------------
|
||||||
|
-- Vorhandene Eintraege bekommen ihren Kunden, soweit er sich ableiten laesst.
|
||||||
|
-- Ohne das traegt jeder alte Eintrag "global" und waere fuer einen auf Kunden
|
||||||
|
-- beschraenkten Benutzer sichtbar -- dieselbe Falle wie bei den Umstiegen in
|
||||||
|
-- #021 und #020, nur hier mit Folgen fuer die Sichtbarkeit.
|
||||||
|
|
||||||
|
-- 1. Alles, was an einem Lauf haengt: der Lauf kennt seinen Kunden.
|
||||||
|
UPDATE "Protokoll" AS p
|
||||||
|
SET "kundeId" = l."kundeId"
|
||||||
|
FROM "Lauf" AS l
|
||||||
|
WHERE p."laufId" = l."id" AND p."kundeId" IS NULL;
|
||||||
|
|
||||||
|
-- 2. Die kundentragenden Entitaeten, je ueber ihre eigene Tabelle.
|
||||||
|
UPDATE "Protokoll" AS p SET "kundeId" = m."kundeId"
|
||||||
|
FROM "Mitarbeiter" AS m
|
||||||
|
WHERE p."entitaet" = 'Mitarbeiter' AND p."entitaetId" = m."id" AND p."kundeId" IS NULL;
|
||||||
|
|
||||||
|
UPDATE "Protokoll" AS p SET "kundeId" = g."kundeId"
|
||||||
|
FROM "Geraet" AS g
|
||||||
|
WHERE p."entitaet" = 'Geraet' AND p."entitaetId" = g."id" AND p."kundeId" IS NULL;
|
||||||
|
|
||||||
|
UPDATE "Protokoll" AS p SET "kundeId" = a."kundeId"
|
||||||
|
FROM "Abteilung" AS a
|
||||||
|
WHERE p."entitaet" = 'Abteilung' AND p."entitaetId" = a."id" AND p."kundeId" IS NULL;
|
||||||
|
|
||||||
|
UPDATE "Protokoll" AS p SET "kundeId" = q."kundeId"
|
||||||
|
FROM "Verzeichnisquelle" AS q
|
||||||
|
WHERE p."entitaet" = 'Verzeichnisquelle' AND p."entitaetId" = q."id" AND p."kundeId" IS NULL;
|
||||||
|
|
||||||
|
-- 3. Ein Eintrag **ueber** einen Kunden gehoert zu diesem Kunden.
|
||||||
|
UPDATE "Protokoll" AS p SET "kundeId" = k."id"
|
||||||
|
FROM "Kunde" AS k
|
||||||
|
WHERE p."entitaet" = 'Kunde' AND p."entitaetId" = k."id" AND p."kundeId" IS NULL;
|
||||||
|
|
||||||
|
-- 4. Eine Vorlage traegt nur im Modus DIENSTLEISTUNG einen Kunden; ist sie
|
||||||
|
-- kundenfrei, bleibt der Eintrag global. Genau richtig: sie gilt fuer alle.
|
||||||
|
UPDATE "Protokoll" AS p SET "kundeId" = v."kundeId"
|
||||||
|
FROM "Vorlage" AS v
|
||||||
|
WHERE p."entitaet" = 'Vorlage' AND p."entitaetId" = v."id" AND p."kundeId" IS NULL;
|
||||||
|
|
||||||
|
-- 5. Eine Version gehoert zum Kunden ihrer Vorlage.
|
||||||
|
UPDATE "Protokoll" AS p SET "kundeId" = v."kundeId"
|
||||||
|
FROM "VorlagenVersion" AS vv
|
||||||
|
JOIN "Vorlage" AS v ON v."id" = vv."vorlageId"
|
||||||
|
WHERE p."entitaet" = 'VorlagenVersion' AND p."entitaetId" = vv."id" AND p."kundeId" IS NULL;
|
||||||
|
|
||||||
|
-- Alles Uebrige bleibt leer: Einstellung, Benutzer, GeraetetypVorlage und
|
||||||
|
-- kundenfreie Vorlagen sind wirklich global. Eintraege zu **geloeschten** Zeilen
|
||||||
|
-- bleiben ebenfalls leer und sind damit nicht zuordenbar — die Abfrage zeigt sie
|
||||||
|
-- einem auf Kunden beschraenkten Benutzer deshalb **nicht** (fail closed, siehe
|
||||||
|
-- src/server/queries/protokoll.ts).
|
||||||
@@ -135,6 +135,7 @@ model Kunde {
|
|||||||
geraete Geraet[]
|
geraete Geraet[]
|
||||||
laeufe Lauf[]
|
laeufe Lauf[]
|
||||||
mitarbeiter Mitarbeiter[]
|
mitarbeiter Mitarbeiter[]
|
||||||
|
protokolle Protokoll[]
|
||||||
verzeichnisquellen Verzeichnisquelle[]
|
verzeichnisquellen Verzeichnisquelle[]
|
||||||
vorlagen Vorlage[]
|
vorlagen Vorlage[]
|
||||||
|
|
||||||
@@ -608,6 +609,12 @@ model Kommentar {
|
|||||||
model Protokoll {
|
model Protokoll {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
laufId String?
|
laufId String?
|
||||||
|
/// Der Kunde, um den es geht — **leer heisst global** (Einstellungen,
|
||||||
|
/// Benutzer, kundenfreie Vorlagen, Schnellstart). CLAUDE.md verlangt die
|
||||||
|
/// Spalte fuer jede kundenbezogene Zeile; ohne sie liess sich das Protokoll
|
||||||
|
/// nicht nach Mandant filtern, ohne je Eintrag die Entitaet nachzuschlagen
|
||||||
|
/// (#023).
|
||||||
|
kundeId String?
|
||||||
entitaet String
|
entitaet String
|
||||||
entitaetId String
|
entitaetId String
|
||||||
aktion String
|
aktion String
|
||||||
@@ -617,11 +624,13 @@ model Protokoll {
|
|||||||
erstelltAm DateTime @default(now())
|
erstelltAm DateTime @default(now())
|
||||||
|
|
||||||
lauf Lauf? @relation(fields: [laufId], references: [id], onDelete: Restrict)
|
lauf Lauf? @relation(fields: [laufId], references: [id], onDelete: Restrict)
|
||||||
|
kunde Kunde? @relation(fields: [kundeId], references: [id], onDelete: Restrict)
|
||||||
benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: Restrict)
|
benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
@@index([laufId, erstelltAm])
|
@@index([laufId, erstelltAm])
|
||||||
@@index([entitaet, entitaetId])
|
@@index([entitaet, entitaetId])
|
||||||
@@index([erstelltAm])
|
@@index([erstelltAm])
|
||||||
|
@@index([kundeId, erstelltAm])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sperre fuer Aufgaben, die nur einmal gleichzeitig laufen duerfen – zuerst der
|
/// Sperre fuer Aufgaben, die nur einmal gleichzeitig laufen duerfen – zuerst der
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const REITER = [
|
|||||||
{ pfad: "/admin/benutzer", beschriftung: "Benutzer" },
|
{ pfad: "/admin/benutzer", beschriftung: "Benutzer" },
|
||||||
{ pfad: "/admin/abteilungen", beschriftung: "Abteilungen" },
|
{ pfad: "/admin/abteilungen", beschriftung: "Abteilungen" },
|
||||||
{ pfad: "/admin/schnellstart", beschriftung: "Schnellstart" },
|
{ pfad: "/admin/schnellstart", beschriftung: "Schnellstart" },
|
||||||
|
{ pfad: "/admin/protokoll", beschriftung: "Protokoll" },
|
||||||
/*
|
/*
|
||||||
* Das Verzeichnis gibt es nur im Modus INTERN: dort hat das Haus **ein** AD,
|
* Das Verzeichnis gibt es nur im Modus INTERN: dort hat das Haus **ein** AD,
|
||||||
* aus dem seine Leute kommen. Im Modus DIENSTLEISTUNG waere es ein LDAP zu
|
* aus dem seine Leute kommen. Im Modus DIENSTLEISTUNG waere es ein LDAP zu
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { ScrollText } from "lucide-react";
|
||||||
|
import { ProtokollFilter, type ProtokollFilterfelder } from "@/components/protokoll/protokoll-filter";
|
||||||
|
import { ProtokollListe } from "@/components/protokoll/protokoll-liste";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { kundenEbeneSichtbar } from "@/server/mandant";
|
||||||
|
import { PRO_SEITE, protokollSeite, protokollUrheber } from "@/server/queries/protokoll";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Protokoll ansehen (#023).
|
||||||
|
*
|
||||||
|
* Geschrieben wurde es seit #006 — Rollenänderungen, veröffentlichte Vorlagen,
|
||||||
|
* archivierte und zurückgeholte Läufe, der Moduswechsel. Ansehen konnte es
|
||||||
|
* niemand: wer wissen wollte, wer wann eine Rolle geändert hat, brauchte `psql`.
|
||||||
|
*
|
||||||
|
* `verwaltung` und nicht `lesen`: das Protokoll sagt, wer was getan hat, und ist
|
||||||
|
* damit selbst eine Auskunft über Menschen.
|
||||||
|
*
|
||||||
|
* **Die Seite prüft selbst.** Das Admin-Layout darüber ist das zweite Netz und
|
||||||
|
* nicht das erste (siehe CLAUDE.md).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default async function ProtokollSeiteAnsicht({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
}) {
|
||||||
|
const { benutzer } = await sitzungErforderlich("verwaltung", "/admin/protokoll");
|
||||||
|
const parameter = await searchParams;
|
||||||
|
const einzeln = (name: string): string =>
|
||||||
|
(Array.isArray(parameter[name]) ? parameter[name][0] : parameter[name])?.trim() ?? "";
|
||||||
|
|
||||||
|
const felder: ProtokollFilterfelder = {
|
||||||
|
suche: einzeln("suche"),
|
||||||
|
urheber: einzeln("urheber"),
|
||||||
|
von: einzeln("von"),
|
||||||
|
bis: einzeln("bis"),
|
||||||
|
};
|
||||||
|
const gewuenschteSeite = Number.parseInt(einzeln("seite"), 10);
|
||||||
|
|
||||||
|
const [ergebnis, urheber, kundenEbene] = await Promise.all([
|
||||||
|
protokollSeite(
|
||||||
|
{
|
||||||
|
suche: felder.suche,
|
||||||
|
...(felder.urheber ? { urheberId: felder.urheber } : {}),
|
||||||
|
...(felder.von ? { vonDatum: felder.von } : {}),
|
||||||
|
...(felder.bis ? { bisDatum: felder.bis } : {}),
|
||||||
|
seite: Number.isNaN(gewuenschteSeite) ? 1 : gewuenschteSeite,
|
||||||
|
},
|
||||||
|
benutzer,
|
||||||
|
),
|
||||||
|
protokollUrheber(benutzer),
|
||||||
|
kundenEbeneSichtbar(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Dieselben Filter mitnehmen, nur die Seite wechseln. */
|
||||||
|
function blaetternZu(seite: number): string {
|
||||||
|
const suchteil = new URLSearchParams();
|
||||||
|
if (felder.suche) suchteil.set("suche", felder.suche);
|
||||||
|
if (felder.urheber) suchteil.set("urheber", felder.urheber);
|
||||||
|
if (felder.von) suchteil.set("von", felder.von);
|
||||||
|
if (felder.bis) suchteil.set("bis", felder.bis);
|
||||||
|
suchteil.set("seite", String(seite));
|
||||||
|
return `/admin/protokoll?${suchteil.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const erster = (ergebnis.seite - 1) * PRO_SEITE + 1;
|
||||||
|
const letzter = Math.min(ergebnis.seite * PRO_SEITE, ergebnis.gesamt);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1 className="flex items-center gap-2 text-2xl font-bold">
|
||||||
|
<ScrollText className="size-6 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
Protokoll
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 max-w-prose text-nebentext">
|
||||||
|
Wer hat was wann getan. Festgehalten wird, was nachvollziehbar bleiben muss — Rollenänderungen,
|
||||||
|
veröffentlichte Vorlagen, archivierte und zurückgeholte Läufe, Verzeichnisquellen, der Moduswechsel.
|
||||||
|
Einträge werden nie geändert und nie gelöscht.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ProtokollFilter felder={felder} urheber={urheber} />
|
||||||
|
|
||||||
|
<p className="mt-4 text-sm text-nebentext">
|
||||||
|
{ergebnis.gesamt === 0 ? "Keine Einträge." : `${erster}–${letzter} von ${ergebnis.gesamt} Einträgen`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ProtokollListe zeilen={ergebnis.zeilen} kundenEbene={kundenEbene} />
|
||||||
|
|
||||||
|
{ergebnis.seiten > 1 ? (
|
||||||
|
<nav aria-label="Seiten" className="mt-6 flex flex-wrap items-center gap-3">
|
||||||
|
{ergebnis.seite > 1 ? (
|
||||||
|
<Link
|
||||||
|
href={blaetternZu(ergebnis.seite - 1)}
|
||||||
|
className="flex min-h-11 items-center rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
|
||||||
|
>
|
||||||
|
zurück
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
<span className="text-sm text-nebentext">
|
||||||
|
Seite {ergebnis.seite} von {ergebnis.seiten}
|
||||||
|
</span>
|
||||||
|
{ergebnis.seite < ergebnis.seiten ? (
|
||||||
|
<Link
|
||||||
|
href={blaetternZu(ergebnis.seite + 1)}
|
||||||
|
className="flex min-h-11 items-center rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
|
||||||
|
>
|
||||||
|
weiter
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</nav>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { ArrowLeft, MessageSquare, Printer, Star } from "lucide-react";
|
import { ArrowLeft, MessageSquare, Printer, ScrollText, Star } from "lucide-react";
|
||||||
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
|
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
|
||||||
import { Markdown } from "@/components/markdown";
|
import { Markdown } from "@/components/markdown";
|
||||||
import { AbschnittHuelle } from "@/components/laeufe/abschnitt-huelle";
|
import { AbschnittHuelle } from "@/components/laeufe/abschnitt-huelle";
|
||||||
@@ -19,6 +19,7 @@ import { SchrittWert } from "@/components/laeufe/schritt-wert";
|
|||||||
import { SchrittZustandWaehlen, ZustandKennzeichen } from "@/components/laeufe/schritt-zustand";
|
import { SchrittZustandWaehlen, ZustandKennzeichen } from "@/components/laeufe/schritt-zustand";
|
||||||
import { darf } from "@/server/auth/rollen";
|
import { darf } from "@/server/auth/rollen";
|
||||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { protokollDesLaufs } from "@/server/queries/protokoll";
|
||||||
import {
|
import {
|
||||||
kommentareDesLaufs,
|
kommentareDesLaufs,
|
||||||
laufZumAbarbeiten,
|
laufZumAbarbeiten,
|
||||||
@@ -293,6 +294,9 @@ export default async function LaufSeite({
|
|||||||
if (!daten) notFound();
|
if (!daten) notFound();
|
||||||
|
|
||||||
const kommentare = await kommentareDesLaufs(id, benutzer);
|
const kommentare = await kommentareDesLaufs(id, benutzer);
|
||||||
|
// Die Sicht hat `laufHolen` oben schon angewandt; hier geht es nur noch um die
|
||||||
|
// Einträge dieses Laufs (#023).
|
||||||
|
const laufProtokoll = await protokollDesLaufs(id);
|
||||||
/*
|
/*
|
||||||
* Die Personenliste nur fuer den Onboarding-Fall holen — sonst stuende in
|
* Die Personenliste nur fuer den Onboarding-Fall holen — sonst stuende in
|
||||||
* jedem Seitenaufbau eine Abfrage, die niemand braucht.
|
* jedem Seitenaufbau eine Abfrage, die niemand braucht.
|
||||||
@@ -416,6 +420,41 @@ export default async function LaufSeite({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
* Die Geschichte dieses Laufs, eingeklappt (#023). Sie steht hier und
|
||||||
|
* nicht nur unter Verwaltung → Protokoll, weil die Frage „wer hat das
|
||||||
|
* zurückgeholt“ am Lauf aufkommt und nicht in der Verwaltung. Eingeklappt,
|
||||||
|
* weil ein abgearbeiteter Lauf Dutzende Einträge hat und sie die Schritte
|
||||||
|
* sonst aus dem Bild schieben.
|
||||||
|
*
|
||||||
|
* Kein eigenes Recht: wer den Lauf sehen darf, darf auch sehen, was an ihm
|
||||||
|
* geschehen ist. Die Mandantensicht hat `laufHolen` oben schon angewandt.
|
||||||
|
*/}
|
||||||
|
{laufProtokoll.length > 0 ? (
|
||||||
|
<section className="mt-4 rounded-karte border border-linie bg-flaeche p-4 md:p-5">
|
||||||
|
<details>
|
||||||
|
<summary className="flex min-h-11 cursor-pointer items-center gap-2 text-lg font-semibold md:min-h-0">
|
||||||
|
<ScrollText className="size-5 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
Protokoll dieses Laufs
|
||||||
|
<span className="text-sm font-normal text-nebentext">({laufProtokoll.length})</span>
|
||||||
|
</summary>
|
||||||
|
<ol className="mt-3 flex flex-col gap-2">
|
||||||
|
{laufProtokoll.map((eintrag) => (
|
||||||
|
<li key={eintrag.id} className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm">
|
||||||
|
<span className="mono shrink-0 text-xs text-nebentext">
|
||||||
|
{ZEITPUNKT.format(eintrag.erstelltAm)}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 break-words">{eintrag.aktion}</span>
|
||||||
|
<span className="text-xs text-nebentext">
|
||||||
|
{eintrag.urheber ? eintrag.urheber.name : "Systemlauf"}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
import { FELD } from "@/lib/formular";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter des Protokolls (#023).
|
||||||
|
*
|
||||||
|
* Ein GET-Formular, kein React-State: eine gesetzte Suche ist verlinkbar, der
|
||||||
|
* Zurueck-Knopf tut das Erwartete, und die Seite bleibt eine Server-Komponente.
|
||||||
|
* Dieselbe Entscheidung wie beim Archiv (#015).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ProtokollFilterfelder = {
|
||||||
|
readonly suche: string;
|
||||||
|
readonly urheber: string;
|
||||||
|
readonly von: string;
|
||||||
|
readonly bis: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProtokollFilter({
|
||||||
|
felder,
|
||||||
|
urheber,
|
||||||
|
}: {
|
||||||
|
felder: ProtokollFilterfelder;
|
||||||
|
urheber: readonly { readonly id: string; readonly name: string }[];
|
||||||
|
}) {
|
||||||
|
const gesetzt = Boolean(felder.suche || felder.urheber || felder.von || felder.bis);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form method="get" className="mt-6 flex flex-col gap-3">
|
||||||
|
{/* Beim Filtern immer auf Seite 1: die alte Seitenzahl zeigte sonst ins
|
||||||
|
Leere, wenn die neue Auswahl kuerzer ist. */}
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<label className="flex min-w-0 flex-1 basis-64 flex-col gap-1.5 text-sm">
|
||||||
|
<span className="text-nebentext">Aktion oder Lauf</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Search className="size-4 shrink-0 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
name="suche"
|
||||||
|
defaultValue={felder.suche}
|
||||||
|
placeholder="Rolle geändert, archiviert …"
|
||||||
|
className={`${FELD} w-full`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex min-w-0 basis-56 flex-col gap-1.5 text-sm">
|
||||||
|
<span className="text-nebentext">Urheber</span>
|
||||||
|
<select name="urheber" defaultValue={felder.urheber} className={`${FELD} w-full`}>
|
||||||
|
<option value="">alle</option>
|
||||||
|
{urheber.map((eintrag) => (
|
||||||
|
<option key={eintrag.id} value={eintrag.id}>
|
||||||
|
{eintrag.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex min-w-0 basis-40 flex-col gap-1.5 text-sm">
|
||||||
|
<span className="text-nebentext">von</span>
|
||||||
|
<input type="date" name="von" defaultValue={felder.von} className={`${FELD} w-full`} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex min-w-0 basis-40 flex-col gap-1.5 text-sm">
|
||||||
|
<span className="text-nebentext">bis</span>
|
||||||
|
<input type="date" name="bis" defaultValue={felder.bis} className={`${FELD} w-full`} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="min-h-11 shrink-0 rounded-pille bg-akzent px-4 text-sm font-semibold text-grund md:min-h-10"
|
||||||
|
>
|
||||||
|
Filtern
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{gesetzt ? (
|
||||||
|
<Link
|
||||||
|
href="/admin/protokoll"
|
||||||
|
className="flex min-h-11 shrink-0 items-center rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
|
||||||
|
>
|
||||||
|
zurücksetzen
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Bot } from "lucide-react";
|
||||||
|
import type { ProtokollZeile } from "@/server/queries/protokoll";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Einträge des Protokolls (#023).
|
||||||
|
*
|
||||||
|
* Keine Tabelle, sondern eine Liste aus Karten. Eine Tabelle mit sieben Spalten
|
||||||
|
* ist bei 390 px nicht darstellbar, ohne horizontal zu scrollen — und genau das
|
||||||
|
* ist ausgeschlossen (CLAUDE.md).
|
||||||
|
*
|
||||||
|
* `vorher`/`nachher` stehen in einem `details`: sie sind Json und koennen lang
|
||||||
|
* werden. Zugeklappt bleibt die Liste lesbar, aufgeklappt steht alles da.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ZEITPUNKT = new Intl.DateTimeFormat("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Json lesbar, ohne Anführungszeichen-Wald für den häufigen Fall eines Objekts. */
|
||||||
|
function alsText(wert: unknown): string {
|
||||||
|
if (wert === null || wert === undefined) return "";
|
||||||
|
if (typeof wert === "object" && !Array.isArray(wert)) {
|
||||||
|
const paare = Object.entries(wert as Record<string, unknown>).map(
|
||||||
|
([schluessel, inhalt]) =>
|
||||||
|
`${schluessel}: ${typeof inhalt === "object" ? JSON.stringify(inhalt) : String(inhalt)}`,
|
||||||
|
);
|
||||||
|
return paare.join(" · ");
|
||||||
|
}
|
||||||
|
return JSON.stringify(wert);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProtokollListe({
|
||||||
|
zeilen,
|
||||||
|
kundenEbene,
|
||||||
|
}: {
|
||||||
|
zeilen: readonly ProtokollZeile[];
|
||||||
|
kundenEbene: boolean;
|
||||||
|
}) {
|
||||||
|
if (zeilen.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="mt-6 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
|
||||||
|
Kein Eintrag passt zu diesen Filtern.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="mt-4 flex flex-col gap-2">
|
||||||
|
{zeilen.map((zeile) => {
|
||||||
|
const vorher = alsText(zeile.vorher);
|
||||||
|
const nachher = alsText(zeile.nachher);
|
||||||
|
return (
|
||||||
|
<li key={zeile.id} className="rounded-karte border border-linie bg-flaeche p-4">
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||||
|
<span className="mono shrink-0 text-xs text-nebentext">
|
||||||
|
{ZEITPUNKT.format(zeile.erstelltAm)}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 font-semibold break-words">{zeile.aktion}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-nebentext">
|
||||||
|
{zeile.urheber ? (
|
||||||
|
<span>{zeile.urheber.name}</span>
|
||||||
|
) : (
|
||||||
|
/* Ein Systemlauf ist keine Lücke – der Zeitplan des
|
||||||
|
Verzeichnisabgleichs hat keinen Menschen hinter sich (#023). */
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Bot className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
Systemlauf
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-0.5 text-xs">{zeile.entitaet}</span>
|
||||||
|
{zeile.laufId && zeile.laufTitel ? (
|
||||||
|
<Link
|
||||||
|
href={`/laeufe/${zeile.laufId}`}
|
||||||
|
className="min-w-0 break-words text-akzent underline underline-offset-2"
|
||||||
|
>
|
||||||
|
{zeile.laufTitel}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
{kundenEbene && zeile.kundeName ? <span>· {zeile.kundeName}</span> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{vorher || nachher ? (
|
||||||
|
<details className="mt-2">
|
||||||
|
<summary className="inline-flex min-h-11 cursor-pointer items-center text-sm text-nebentext hover:text-text md:min-h-0">
|
||||||
|
Werte
|
||||||
|
</summary>
|
||||||
|
<dl className="mt-1 flex flex-col gap-1 text-sm">
|
||||||
|
{vorher ? (
|
||||||
|
<div className="flex flex-wrap gap-x-2">
|
||||||
|
<dt className="shrink-0 text-nebentext">vorher</dt>
|
||||||
|
<dd className="mono min-w-0 break-words">{vorher}</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{nachher ? (
|
||||||
|
<div className="flex flex-wrap gap-x-2">
|
||||||
|
<dt className="shrink-0 text-nebentext">nachher</dt>
|
||||||
|
<dd className="mono min-w-0 break-words">{nachher}</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</dl>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lesemodell des Protokolls (#023).
|
||||||
|
*
|
||||||
|
* Geschrieben wurde es seit #006, ansehen konnte es niemand — wer wissen wollte,
|
||||||
|
* wer wann eine Rolle geaendert hat, brauchte `psql`.
|
||||||
|
*
|
||||||
|
* Die Mandantensicht wird hier selbst angewandt, obwohl die Seite `verwaltung`
|
||||||
|
* verlangt und ein ADMIN heute ohnehin alle Kunden sieht. Das ist Absicht: ein
|
||||||
|
* Lesemodell traegt seine Sicht selbst (CLAUDE.md), und wenn das Recht je
|
||||||
|
* weiter gefasst wird, haelt die Grenze trotzdem.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ProtokollZeile = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly erstelltAm: Date;
|
||||||
|
readonly aktion: string;
|
||||||
|
readonly entitaet: string;
|
||||||
|
readonly entitaetId: string;
|
||||||
|
readonly laufId: string | null;
|
||||||
|
readonly laufTitel: string | null;
|
||||||
|
readonly kundeName: string | null;
|
||||||
|
/** Leer bei einem Systemlauf – etwa dem Zeitplan des Verzeichnisabgleichs. */
|
||||||
|
readonly urheber: { readonly id: string; readonly name: string } | null;
|
||||||
|
readonly vorher: unknown;
|
||||||
|
readonly nachher: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProtokollSeite = {
|
||||||
|
readonly zeilen: readonly ProtokollZeile[];
|
||||||
|
readonly gesamt: number;
|
||||||
|
readonly seite: number;
|
||||||
|
readonly seiten: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProtokollFilter = {
|
||||||
|
readonly suche?: string;
|
||||||
|
readonly urheberId?: string;
|
||||||
|
readonly vonDatum?: string;
|
||||||
|
readonly bisDatum?: string;
|
||||||
|
readonly seite?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PRO_SEITE = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eintraege ohne `kundeId` und ohne Lauf, deren Entitaet wirklich global ist.
|
||||||
|
*
|
||||||
|
* Alles andere mit leerer `kundeId` ist **nicht zuordenbar** — etwa ein Eintrag
|
||||||
|
* zu einem inzwischen geloeschten Geraet. Einem auf Kunden beschraenkten
|
||||||
|
* Benutzer wird das nicht gezeigt: `vorher`/`nachher` koennen einen Hostnamen
|
||||||
|
* enthalten, und bei einer Mandantengrenze wird im Zweifel zugemacht.
|
||||||
|
*/
|
||||||
|
const GLOBALE_ENTITAETEN = ["Einstellung", "Benutzer", "GeraetetypVorlage", "Vorlage", "VorlagenVersion"];
|
||||||
|
|
||||||
|
async function sichtBedingung(benutzer: HandelnderBenutzer): Promise<Prisma.ProtokollWhereInput[]> {
|
||||||
|
const sichtbar = await sichtbareKundenIds(benutzer);
|
||||||
|
if (sichtbar === undefined) return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
// Eintraege mit eigener Zuordnung.
|
||||||
|
{ kundeId: { in: [...sichtbar] } },
|
||||||
|
// Laufbezogene Eintraege: der Lauf ist die bessere Quelle, weil er die
|
||||||
|
// Zuordnung nicht verlieren kann.
|
||||||
|
{ lauf: { kundeId: { in: [...sichtbar] } } },
|
||||||
|
// Wirklich globale Eintraege.
|
||||||
|
{ kundeId: null, laufId: null, entitaet: { in: GLOBALE_ENTITAETEN } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function datumBedingung(filter: ProtokollFilter): Prisma.ProtokollWhereInput[] {
|
||||||
|
const von = filter.vonDatum ? new Date(`${filter.vonDatum}T00:00:00`) : undefined;
|
||||||
|
// Bis **einschliesslich** des gewaehlten Tages: ein Filter „bis 4.10." soll
|
||||||
|
// den 4. enthalten, sonst sucht man den Eintrag von heute vergeblich.
|
||||||
|
const bis = filter.bisDatum ? new Date(`${filter.bisDatum}T23:59:59.999`) : undefined;
|
||||||
|
if (!von && !bis) return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
erstelltAm: {
|
||||||
|
...(von && !Number.isNaN(von.getTime()) ? { gte: von } : {}),
|
||||||
|
...(bis && !Number.isNaN(bis.getTime()) ? { lte: bis } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Seite des Protokolls, neueste zuerst.
|
||||||
|
*
|
||||||
|
* Alle Teilbedingungen liegen in einem `AND`, wo keine die andere erreichen
|
||||||
|
* kann — dieselbe Vorsicht wie in den anderen Lesemodellen: im Review von #009
|
||||||
|
* hatte ein Suchfilter mit eigenem `OR` die Kundenbedingung ueberschrieben.
|
||||||
|
*/
|
||||||
|
export async function protokollSeite(
|
||||||
|
filter: ProtokollFilter,
|
||||||
|
benutzer: HandelnderBenutzer,
|
||||||
|
): Promise<ProtokollSeite> {
|
||||||
|
const suche = filter.suche?.trim() ?? "";
|
||||||
|
const bedingungen: Prisma.ProtokollWhereInput[] = [
|
||||||
|
...(await sichtBedingung(benutzer)),
|
||||||
|
...datumBedingung(filter),
|
||||||
|
...(suche.length > 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ aktion: { contains: suche, mode: "insensitive" as const } },
|
||||||
|
{ entitaet: { contains: suche, mode: "insensitive" as const } },
|
||||||
|
{ lauf: { titel: { contains: suche, mode: "insensitive" as const } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(filter.urheberId ? [{ benutzerId: filter.urheberId }] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const where: Prisma.ProtokollWhereInput = { AND: bedingungen };
|
||||||
|
const seite = Math.max(1, Math.trunc(filter.seite ?? 1));
|
||||||
|
|
||||||
|
const [gesamt, zeilen] = await Promise.all([
|
||||||
|
prisma.protokoll.count({ where }),
|
||||||
|
prisma.protokoll.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { erstelltAm: "desc" },
|
||||||
|
skip: (seite - 1) * PRO_SEITE,
|
||||||
|
take: PRO_SEITE,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
erstelltAm: true,
|
||||||
|
aktion: true,
|
||||||
|
entitaet: true,
|
||||||
|
entitaetId: true,
|
||||||
|
laufId: true,
|
||||||
|
vorher: true,
|
||||||
|
nachher: true,
|
||||||
|
lauf: { select: { titel: true, kunde: { select: { name: true } } } },
|
||||||
|
kunde: { select: { name: true } },
|
||||||
|
benutzer: { select: { id: true, name: true, email: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
zeilen: zeilen.map((zeile) => ({
|
||||||
|
id: zeile.id,
|
||||||
|
erstelltAm: zeile.erstelltAm,
|
||||||
|
aktion: zeile.aktion,
|
||||||
|
entitaet: zeile.entitaet,
|
||||||
|
entitaetId: zeile.entitaetId,
|
||||||
|
laufId: zeile.laufId,
|
||||||
|
laufTitel: zeile.lauf?.titel ?? null,
|
||||||
|
kundeName: zeile.kunde?.name ?? zeile.lauf?.kunde.name ?? null,
|
||||||
|
urheber: zeile.benutzer
|
||||||
|
? { id: zeile.benutzer.id, name: zeile.benutzer.name ?? zeile.benutzer.email }
|
||||||
|
: null,
|
||||||
|
vorher: zeile.vorher,
|
||||||
|
nachher: zeile.nachher,
|
||||||
|
})),
|
||||||
|
gesamt,
|
||||||
|
seite,
|
||||||
|
seiten: Math.max(1, Math.ceil(gesamt / PRO_SEITE)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Urheber, die im Protokoll vorkommen – für den Filter. */
|
||||||
|
export async function protokollUrheber(
|
||||||
|
benutzer: HandelnderBenutzer,
|
||||||
|
): Promise<readonly { readonly id: string; readonly name: string }[]> {
|
||||||
|
const bedingungen = await sichtBedingung(benutzer);
|
||||||
|
const zeilen = await prisma.protokoll.findMany({
|
||||||
|
where: { AND: [...bedingungen, { benutzerId: { not: null } }] },
|
||||||
|
distinct: ["benutzerId"],
|
||||||
|
select: { benutzer: { select: { id: true, name: true, email: true } } },
|
||||||
|
});
|
||||||
|
return zeilen
|
||||||
|
.flatMap((zeile) =>
|
||||||
|
zeile.benutzer === null
|
||||||
|
? []
|
||||||
|
: [{ id: zeile.benutzer.id, name: zeile.benutzer.name ?? zeile.benutzer.email }],
|
||||||
|
)
|
||||||
|
.sort((links, rechts) => links.name.localeCompare(rechts.name, "de"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Einträge eines Laufs, für seine Abarbeitungsansicht.
|
||||||
|
*
|
||||||
|
* Eigene Funktion und nicht `protokollSeite` mit Filter: hier gilt die Sicht des
|
||||||
|
* Laufs, den der Aufrufer schon geprüft hat, und es gibt keine Blätterung —
|
||||||
|
* ein Lauf hat Dutzende Einträge, nicht Tausende.
|
||||||
|
*/
|
||||||
|
export async function protokollDesLaufs(laufId: string): Promise<readonly ProtokollZeile[]> {
|
||||||
|
const zeilen = await prisma.protokoll.findMany({
|
||||||
|
where: { laufId },
|
||||||
|
orderBy: { erstelltAm: "desc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
erstelltAm: true,
|
||||||
|
aktion: true,
|
||||||
|
entitaet: true,
|
||||||
|
entitaetId: true,
|
||||||
|
laufId: true,
|
||||||
|
vorher: true,
|
||||||
|
nachher: true,
|
||||||
|
benutzer: { select: { id: true, name: true, email: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return zeilen.map((zeile) => ({
|
||||||
|
id: zeile.id,
|
||||||
|
erstelltAm: zeile.erstelltAm,
|
||||||
|
aktion: zeile.aktion,
|
||||||
|
entitaet: zeile.entitaet,
|
||||||
|
entitaetId: zeile.entitaetId,
|
||||||
|
laufId: zeile.laufId,
|
||||||
|
laufTitel: null,
|
||||||
|
kundeName: null,
|
||||||
|
urheber: zeile.benutzer
|
||||||
|
? { id: zeile.benutzer.id, name: zeile.benutzer.name ?? zeile.benutzer.email }
|
||||||
|
: null,
|
||||||
|
vorher: zeile.vorher,
|
||||||
|
nachher: zeile.nachher,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -568,7 +568,7 @@ async function abschlussSchreiben(
|
|||||||
uebersprungen: number,
|
uebersprungen: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
await tx.verzeichnisquelle.update({
|
const quelle = await tx.verzeichnisquelle.update({
|
||||||
where: { id: quelleId },
|
where: { id: quelleId },
|
||||||
data: {
|
data: {
|
||||||
letzterAbgleichAm: new Date(),
|
letzterAbgleichAm: new Date(),
|
||||||
@@ -578,6 +578,9 @@ async function abschlussSchreiben(
|
|||||||
});
|
});
|
||||||
await tx.protokoll.create({
|
await tx.protokoll.create({
|
||||||
data: {
|
data: {
|
||||||
|
// Aus der Aktualisierung, nicht aus einer zweiten Abfrage: der Kunde
|
||||||
|
// steht in derselben Zeile, die gerade geschrieben wurde (#023).
|
||||||
|
kundeId: quelle.kundeId,
|
||||||
entitaet: "Verzeichnisquelle",
|
entitaet: "Verzeichnisquelle",
|
||||||
entitaetId: quelleId,
|
entitaetId: quelleId,
|
||||||
aktion:
|
aktion:
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export async function abteilungAnlegen(
|
|||||||
});
|
});
|
||||||
await tx.protokoll.create({
|
await tx.protokoll.create({
|
||||||
data: {
|
data: {
|
||||||
|
kundeId: abteilung.kundeId,
|
||||||
entitaet: "Abteilung",
|
entitaet: "Abteilung",
|
||||||
entitaetId: abteilung.id,
|
entitaetId: abteilung.id,
|
||||||
aktion: "Abteilung angelegt",
|
aktion: "Abteilung angelegt",
|
||||||
@@ -169,6 +170,7 @@ export async function abteilungBearbeiten(
|
|||||||
const nachher = await tx.abteilung.update({ where: { id: abteilungId }, data: daten });
|
const nachher = await tx.abteilung.update({ where: { id: abteilungId }, data: daten });
|
||||||
await tx.protokoll.create({
|
await tx.protokoll.create({
|
||||||
data: {
|
data: {
|
||||||
|
kundeId: vorher.kundeId,
|
||||||
entitaet: "Abteilung",
|
entitaet: "Abteilung",
|
||||||
entitaetId: abteilungId,
|
entitaetId: abteilungId,
|
||||||
aktion: `${geaendert.join(" und ")} geändert`,
|
aktion: `${geaendert.join(" und ")} geändert`,
|
||||||
@@ -202,6 +204,7 @@ export async function abteilungArchivieren(
|
|||||||
const nachher = await tx.abteilung.update({ where: { id: abteilungId }, data: { archiviert } });
|
const nachher = await tx.abteilung.update({ where: { id: abteilungId }, data: { archiviert } });
|
||||||
await tx.protokoll.create({
|
await tx.protokoll.create({
|
||||||
data: {
|
data: {
|
||||||
|
kundeId: vorher.kundeId,
|
||||||
entitaet: "Abteilung",
|
entitaet: "Abteilung",
|
||||||
entitaetId: abteilungId,
|
entitaetId: abteilungId,
|
||||||
aktion: archiviert ? "Abteilung archiviert" : "Abteilung wieder freigegeben",
|
aktion: archiviert ? "Abteilung archiviert" : "Abteilung wieder freigegeben",
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export async function einstellungenSpeichern(
|
|||||||
await prisma.kunde.update({ where: { id: vorher.eigeneOrganisation.id }, data: { name } });
|
await prisma.kunde.update({ where: { id: vorher.eigeneOrganisation.id }, data: { name } });
|
||||||
await prisma.protokoll.create({
|
await prisma.protokoll.create({
|
||||||
data: {
|
data: {
|
||||||
|
kundeId: vorher.eigeneOrganisation.id,
|
||||||
entitaet: "Kunde",
|
entitaet: "Kunde",
|
||||||
entitaetId: vorher.eigeneOrganisation.id,
|
entitaetId: vorher.eigeneOrganisation.id,
|
||||||
aktion: "Name der eigenen Organisation geändert",
|
aktion: "Name der eigenen Organisation geändert",
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ async function geraetSchreiben(auftrag: SchreibAuftrag): Promise<Geraet> {
|
|||||||
});
|
});
|
||||||
await tx.protokoll.create({
|
await tx.protokoll.create({
|
||||||
data: {
|
data: {
|
||||||
|
kundeId: geraet.kundeId,
|
||||||
entitaet: "Geraet",
|
entitaet: "Geraet",
|
||||||
entitaetId: geraet.id,
|
entitaetId: geraet.id,
|
||||||
aktion: auftrag.aktion,
|
aktion: auftrag.aktion,
|
||||||
@@ -314,6 +315,7 @@ export async function geraetBearbeiten(
|
|||||||
const nachher = await tx.geraet.update({ where: { id: geraetId }, data: daten });
|
const nachher = await tx.geraet.update({ where: { id: geraetId }, data: daten });
|
||||||
await tx.protokoll.create({
|
await tx.protokoll.create({
|
||||||
data: {
|
data: {
|
||||||
|
kundeId: vorher.kundeId,
|
||||||
entitaet: "Geraet",
|
entitaet: "Geraet",
|
||||||
entitaetId: geraetId,
|
entitaetId: geraetId,
|
||||||
aktion: `${geaendert.join(" und ")} geändert`,
|
aktion: `${geaendert.join(" und ")} geändert`,
|
||||||
@@ -371,6 +373,7 @@ export async function geraetArchivieren(
|
|||||||
await tx.geraet.update({ where: { id: geraetId }, data: { archiviert } });
|
await tx.geraet.update({ where: { id: geraetId }, data: { archiviert } });
|
||||||
await tx.protokoll.create({
|
await tx.protokoll.create({
|
||||||
data: {
|
data: {
|
||||||
|
kundeId: geraet.kundeId,
|
||||||
entitaet: "Geraet",
|
entitaet: "Geraet",
|
||||||
entitaetId: geraetId,
|
entitaetId: geraetId,
|
||||||
aktion: archiviert ? "Gerät archiviert" : "Gerät wieder freigegeben",
|
aktion: archiviert ? "Gerät archiviert" : "Gerät wieder freigegeben",
|
||||||
@@ -418,6 +421,7 @@ export async function geraetLoeschen(geraetId: string, benutzer: HandelnderBenut
|
|||||||
await tx.geraet.delete({ where: { id: geraetId } });
|
await tx.geraet.delete({ where: { id: geraetId } });
|
||||||
await tx.protokoll.create({
|
await tx.protokoll.create({
|
||||||
data: {
|
data: {
|
||||||
|
kundeId: geraet.kundeId,
|
||||||
entitaet: "Geraet",
|
entitaet: "Geraet",
|
||||||
entitaetId: geraetId,
|
entitaetId: geraetId,
|
||||||
aktion: "Gerät gelöscht",
|
aktion: "Gerät gelöscht",
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export async function kundeAnlegen(eingabe: NeuerKunde, durchBenutzerId: string)
|
|||||||
|
|
||||||
await prisma.protokoll.create({
|
await prisma.protokoll.create({
|
||||||
data: {
|
data: {
|
||||||
|
kundeId: kunde.id,
|
||||||
entitaet: "Kunde",
|
entitaet: "Kunde",
|
||||||
entitaetId: kunde.id,
|
entitaetId: kunde.id,
|
||||||
aktion: "Kunde angelegt",
|
aktion: "Kunde angelegt",
|
||||||
@@ -181,6 +182,7 @@ export async function kundeBearbeiten(
|
|||||||
|
|
||||||
await prisma.protokoll.create({
|
await prisma.protokoll.create({
|
||||||
data: {
|
data: {
|
||||||
|
kundeId: id,
|
||||||
entitaet: "Kunde",
|
entitaet: "Kunde",
|
||||||
entitaetId: id,
|
entitaetId: id,
|
||||||
aktion: `${felder.map((feld) => FELD_TEXT[feld]).join(" und ")} geändert`,
|
aktion: `${felder.map((feld) => FELD_TEXT[feld]).join(" und ")} geändert`,
|
||||||
@@ -215,6 +217,7 @@ export async function kundeArchivieren(
|
|||||||
await prisma.kunde.update({ where: { id }, data: { archiviert } });
|
await prisma.kunde.update({ where: { id }, data: { archiviert } });
|
||||||
await prisma.protokoll.create({
|
await prisma.protokoll.create({
|
||||||
data: {
|
data: {
|
||||||
|
kundeId: id,
|
||||||
entitaet: "Kunde",
|
entitaet: "Kunde",
|
||||||
entitaetId: id,
|
entitaetId: id,
|
||||||
aktion: archiviert ? "Kunde archiviert" : "Kunde wieder freigegeben",
|
aktion: archiviert ? "Kunde archiviert" : "Kunde wieder freigegeben",
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ export async function mitarbeiterAnlegen(
|
|||||||
});
|
});
|
||||||
await tx.protokoll.create({
|
await tx.protokoll.create({
|
||||||
data: {
|
data: {
|
||||||
|
kundeId: mitarbeiter.kundeId,
|
||||||
entitaet: "Mitarbeiter",
|
entitaet: "Mitarbeiter",
|
||||||
entitaetId: mitarbeiter.id,
|
entitaetId: mitarbeiter.id,
|
||||||
aktion: vermerk?.aktion ?? "Mitarbeiter angelegt",
|
aktion: vermerk?.aktion ?? "Mitarbeiter angelegt",
|
||||||
@@ -200,6 +201,7 @@ export async function mitarbeiterBearbeiten(
|
|||||||
const nachher = await tx.mitarbeiter.update({ where: { id: mitarbeiterId }, data: daten });
|
const nachher = await tx.mitarbeiter.update({ where: { id: mitarbeiterId }, data: daten });
|
||||||
await tx.protokoll.create({
|
await tx.protokoll.create({
|
||||||
data: {
|
data: {
|
||||||
|
kundeId: vorher.kundeId,
|
||||||
entitaet: "Mitarbeiter",
|
entitaet: "Mitarbeiter",
|
||||||
entitaetId: mitarbeiterId,
|
entitaetId: mitarbeiterId,
|
||||||
aktion: `${geaendert.join(" und ")} geändert`,
|
aktion: `${geaendert.join(" und ")} geändert`,
|
||||||
@@ -260,6 +262,7 @@ export async function vorgesetztenSetzen(
|
|||||||
});
|
});
|
||||||
await tx.protokoll.create({
|
await tx.protokoll.create({
|
||||||
data: {
|
data: {
|
||||||
|
kundeId: vorher.kundeId,
|
||||||
entitaet: "Mitarbeiter",
|
entitaet: "Mitarbeiter",
|
||||||
entitaetId: mitarbeiterId,
|
entitaetId: mitarbeiterId,
|
||||||
aktion: gewuenscht === null ? "Vorgesetzter entfernt" : `Vorgesetzter auf ${name} gesetzt`,
|
aktion: gewuenscht === null ? "Vorgesetzter entfernt" : `Vorgesetzter auf ${name} gesetzt`,
|
||||||
@@ -321,6 +324,7 @@ export async function mitarbeiterAktivSetzen(
|
|||||||
const nachher = await tx.mitarbeiter.update({ where: { id: mitarbeiterId }, data: { aktiv } });
|
const nachher = await tx.mitarbeiter.update({ where: { id: mitarbeiterId }, data: { aktiv } });
|
||||||
await tx.protokoll.create({
|
await tx.protokoll.create({
|
||||||
data: {
|
data: {
|
||||||
|
kundeId: vorher.kundeId,
|
||||||
entitaet: "Mitarbeiter",
|
entitaet: "Mitarbeiter",
|
||||||
entitaetId: mitarbeiterId,
|
entitaetId: mitarbeiterId,
|
||||||
aktion: aktiv ? "Mitarbeiter wieder aktiviert" : "Mitarbeiter deaktiviert",
|
aktion: aktiv ? "Mitarbeiter wieder aktiviert" : "Mitarbeiter deaktiviert",
|
||||||
|
|||||||
@@ -210,6 +210,7 @@ export async function quelleAnlegen(
|
|||||||
});
|
});
|
||||||
await tx.protokoll.create({
|
await tx.protokoll.create({
|
||||||
data: {
|
data: {
|
||||||
|
kundeId: angelegt.kundeId,
|
||||||
entitaet: "Verzeichnisquelle",
|
entitaet: "Verzeichnisquelle",
|
||||||
entitaetId: angelegt.id,
|
entitaetId: angelegt.id,
|
||||||
aktion: "Verzeichnisquelle angelegt",
|
aktion: "Verzeichnisquelle angelegt",
|
||||||
@@ -316,6 +317,7 @@ export async function quelleBearbeiten(
|
|||||||
const nachher = await tx.verzeichnisquelle.update({ where: { id }, data: daten });
|
const nachher = await tx.verzeichnisquelle.update({ where: { id }, data: daten });
|
||||||
await tx.protokoll.create({
|
await tx.protokoll.create({
|
||||||
data: {
|
data: {
|
||||||
|
kundeId: vorher.kundeId,
|
||||||
entitaet: "Verzeichnisquelle",
|
entitaet: "Verzeichnisquelle",
|
||||||
entitaetId: id,
|
entitaetId: id,
|
||||||
aktion: `${geaendert.join(", ")} geändert`,
|
aktion: `${geaendert.join(", ")} geändert`,
|
||||||
@@ -354,6 +356,7 @@ export async function quelleLoeschen(id: string, benutzer: HandelnderBenutzer):
|
|||||||
await tx.verzeichnisquelle.delete({ where: { id } });
|
await tx.verzeichnisquelle.delete({ where: { id } });
|
||||||
await tx.protokoll.create({
|
await tx.protokoll.create({
|
||||||
data: {
|
data: {
|
||||||
|
kundeId: quelle.kundeId,
|
||||||
entitaet: "Verzeichnisquelle",
|
entitaet: "Verzeichnisquelle",
|
||||||
entitaetId: id,
|
entitaetId: id,
|
||||||
aktion: "Verzeichnisquelle gelöscht",
|
aktion: "Verzeichnisquelle gelöscht",
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ export async function vorlageImportieren(
|
|||||||
|
|
||||||
await tx.protokoll.create({
|
await tx.protokoll.create({
|
||||||
data: {
|
data: {
|
||||||
|
kundeId: vorlage.kundeId,
|
||||||
entitaet: "Vorlage",
|
entitaet: "Vorlage",
|
||||||
entitaetId: vorlage.id,
|
entitaetId: vorlage.id,
|
||||||
aktion: "Vorlage importiert",
|
aktion: "Vorlage importiert",
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export async function vorlageAnlegen(
|
|||||||
});
|
});
|
||||||
await tx.protokoll.create({
|
await tx.protokoll.create({
|
||||||
data: {
|
data: {
|
||||||
|
kundeId: vorlage.kundeId,
|
||||||
entitaet: "Vorlage",
|
entitaet: "Vorlage",
|
||||||
entitaetId: vorlage.id,
|
entitaetId: vorlage.id,
|
||||||
aktion: "Vorlage angelegt",
|
aktion: "Vorlage angelegt",
|
||||||
@@ -147,6 +148,7 @@ export async function vorlageBearbeiten(
|
|||||||
const nachher = await tx.vorlage.update({ where: { id: vorlageId }, data: daten });
|
const nachher = await tx.vorlage.update({ where: { id: vorlageId }, data: daten });
|
||||||
await tx.protokoll.create({
|
await tx.protokoll.create({
|
||||||
data: {
|
data: {
|
||||||
|
kundeId: nachher.kundeId,
|
||||||
entitaet: "Vorlage",
|
entitaet: "Vorlage",
|
||||||
entitaetId: vorlageId,
|
entitaetId: vorlageId,
|
||||||
aktion: `${geaendert.join(" und ")} geändert`,
|
aktion: `${geaendert.join(" und ")} geändert`,
|
||||||
@@ -263,6 +265,12 @@ async function entwurfKopieren(
|
|||||||
durchBenutzerId: string,
|
durchBenutzerId: string,
|
||||||
): Promise<VorlagenVersion> {
|
): Promise<VorlagenVersion> {
|
||||||
return prisma.$transaction(async (tx) => {
|
return prisma.$transaction(async (tx) => {
|
||||||
|
// Der Kunde fuer den Protokolleintrag; ein indizierter Lesevorgang auf dem
|
||||||
|
// Primaerschluessel (#023).
|
||||||
|
const vorlage = await tx.vorlage.findUniqueOrThrow({
|
||||||
|
where: { id: vorlageId },
|
||||||
|
select: { kundeId: true },
|
||||||
|
});
|
||||||
const entwurf = await tx.vorlagenVersion.create({
|
const entwurf = await tx.vorlagenVersion.create({
|
||||||
data: { vorlageId, version: naechste, status: VersionStatus.ENTWURF },
|
data: { vorlageId, version: naechste, status: VersionStatus.ENTWURF },
|
||||||
});
|
});
|
||||||
@@ -304,6 +312,7 @@ async function entwurfKopieren(
|
|||||||
|
|
||||||
await tx.protokoll.create({
|
await tx.protokoll.create({
|
||||||
data: {
|
data: {
|
||||||
|
kundeId: vorlage.kundeId,
|
||||||
entitaet: "VorlagenVersion",
|
entitaet: "VorlagenVersion",
|
||||||
entitaetId: entwurf.id,
|
entitaetId: entwurf.id,
|
||||||
aktion: "Entwurf aus veröffentlichter Version erstellt",
|
aktion: "Entwurf aus veröffentlichter Version erstellt",
|
||||||
@@ -485,7 +494,7 @@ export async function versionVeroeffentlichen(
|
|||||||
const kopf = await tx.vorlagenVersion.findUniqueOrThrow({
|
const kopf = await tx.vorlagenVersion.findUniqueOrThrow({
|
||||||
where: { id: versionId },
|
where: { id: versionId },
|
||||||
select: {
|
select: {
|
||||||
vorlage: { select: { archiviert: true, name: true } },
|
vorlage: { select: { archiviert: true, name: true, kundeId: true } },
|
||||||
_count: { select: { abschnitte: true } },
|
_count: { select: { abschnitte: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -518,6 +527,7 @@ export async function versionVeroeffentlichen(
|
|||||||
|
|
||||||
await tx.protokoll.create({
|
await tx.protokoll.create({
|
||||||
data: {
|
data: {
|
||||||
|
kundeId: kopf.vorlage.kundeId,
|
||||||
entitaet: "VorlagenVersion",
|
entitaet: "VorlagenVersion",
|
||||||
entitaetId: versionId,
|
entitaetId: versionId,
|
||||||
aktion: "Version veröffentlicht",
|
aktion: "Version veröffentlicht",
|
||||||
@@ -545,7 +555,13 @@ export async function versionVeroeffentlichen(
|
|||||||
export async function entwurfVerwerfen(versionId: string, durchBenutzerId: string): Promise<void> {
|
export async function entwurfVerwerfen(versionId: string, durchBenutzerId: string): Promise<void> {
|
||||||
const version = await prisma.vorlagenVersion.findUnique({
|
const version = await prisma.vorlagenVersion.findUnique({
|
||||||
where: { id: versionId },
|
where: { id: versionId },
|
||||||
select: { id: true, status: true, version: true, vorlageId: true },
|
select: {
|
||||||
|
id: true,
|
||||||
|
status: true,
|
||||||
|
version: true,
|
||||||
|
vorlageId: true,
|
||||||
|
vorlage: { select: { kundeId: true } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!version) throw new VorlagenFehler("Diese Version gibt es nicht.");
|
if (!version) throw new VorlagenFehler("Diese Version gibt es nicht.");
|
||||||
versionMussEntwurfSein(version);
|
versionMussEntwurfSein(version);
|
||||||
@@ -563,6 +579,7 @@ export async function entwurfVerwerfen(versionId: string, durchBenutzerId: strin
|
|||||||
await tx.vorlagenVersion.delete({ where: { id: versionId } });
|
await tx.vorlagenVersion.delete({ where: { id: versionId } });
|
||||||
await tx.protokoll.create({
|
await tx.protokoll.create({
|
||||||
data: {
|
data: {
|
||||||
|
kundeId: version.vorlage.kundeId,
|
||||||
entitaet: "VorlagenVersion",
|
entitaet: "VorlagenVersion",
|
||||||
entitaetId: versionId,
|
entitaetId: versionId,
|
||||||
aktion: "Entwurf verworfen",
|
aktion: "Entwurf verworfen",
|
||||||
@@ -661,6 +678,7 @@ export async function vorlageDuplizieren(
|
|||||||
|
|
||||||
await tx.protokoll.create({
|
await tx.protokoll.create({
|
||||||
data: {
|
data: {
|
||||||
|
kundeId: kopie.kundeId,
|
||||||
entitaet: "Vorlage",
|
entitaet: "Vorlage",
|
||||||
entitaetId: kopie.id,
|
entitaetId: kopie.id,
|
||||||
aktion: "Vorlage dupliziert",
|
aktion: "Vorlage dupliziert",
|
||||||
@@ -693,6 +711,7 @@ export async function vorlageArchivieren(
|
|||||||
await tx.vorlage.update({ where: { id: vorlageId }, data: { archiviert } });
|
await tx.vorlage.update({ where: { id: vorlageId }, data: { archiviert } });
|
||||||
await tx.protokoll.create({
|
await tx.protokoll.create({
|
||||||
data: {
|
data: {
|
||||||
|
kundeId: vorlage.kundeId,
|
||||||
entitaet: "Vorlage",
|
entitaet: "Vorlage",
|
||||||
entitaetId: vorlageId,
|
entitaetId: vorlageId,
|
||||||
aktion: archiviert ? "Vorlage archiviert" : "Vorlage wieder freigegeben",
|
aktion: archiviert ? "Vorlage archiviert" : "Vorlage wieder freigegeben",
|
||||||
|
|||||||
@@ -313,6 +313,20 @@ test.describe("Onboarding", () => {
|
|||||||
protokoll: Number(spuren[0]?.protokoll),
|
protokoll: Number(spuren[0]?.protokoll),
|
||||||
}).toEqual({ ergebnisse: 0, kommentare: 0, protokoll: 0 });
|
}).toEqual({ ergebnisse: 0, kommentare: 0, protokoll: 0 });
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ---- Auch nicht in der Protokoll-Ansicht -------------------------------
|
||||||
|
*
|
||||||
|
* Seit #023 ist das Protokoll sichtbar, und die Seite zeigt `vorher` und
|
||||||
|
* `nachher` als Text. Die Abfrage oben belegt, dass dort nichts steht; dieser
|
||||||
|
* Griff belegt, dass die **Ansicht** nichts hinzuerfindet — etwa aus einer
|
||||||
|
* aufgelösten Vorschau.
|
||||||
|
*/
|
||||||
|
await page.goto("/admin/protokoll");
|
||||||
|
const protokollSeite = (await page.locator("body").innerText()).replace(/\s+/gu, " ");
|
||||||
|
expect(protokollSeite).not.toContain(passwort);
|
||||||
|
// Gegenprobe: die Seite ist überhaupt da und zeigt Einträge.
|
||||||
|
await expect(page.getByRole("heading", { name: "Protokoll", level: 1 })).toBeVisible();
|
||||||
|
|
||||||
// ---- Auch nicht auf dem Ausdruck ---------------------------------------
|
// ---- Auch nicht auf dem Ausdruck ---------------------------------------
|
||||||
await page.goto(`/drucken/lauf/${laufId}`);
|
await page.goto(`/drucken/lauf/${laufId}`);
|
||||||
const blatt = (await page.locator("body").innerText()).replace(/\s+/gu, " ");
|
const blatt = (await page.locator("body").innerText()).replace(/\s+/gu, " ");
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { Betriebsmodus, VersionStatus } from "@/generated/prisma/enums";
|
||||||
|
import { RECHTE } from "@/server/auth/rollen";
|
||||||
|
import { protokollDesLaufs, protokollSeite, protokollUrheber } from "@/server/queries/protokoll";
|
||||||
|
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Protokoll ansehen (#023).
|
||||||
|
*
|
||||||
|
* Drei Fragen tragen das Ganze: sieht jemand nur seine Kunden, kommt ein
|
||||||
|
* Techniker ueberhaupt heran, und steht irgendwo ein erzeugtes Passwort.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein auf Kunden beschraenkter Benutzer entsteht nur unter drei Bedingungen —
|
||||||
|
* Modus DIENSTLEISTUNG, Rolle unter REDAKTEUR, und ein gesetztes
|
||||||
|
* `OIDC_CUSTOMER_GROUP_PREFIX`. Ohne die dritte liefert `sichtbareKundenIds`
|
||||||
|
* `undefined`, und jeder Filtertest waere ein Test ohne Gegenstand.
|
||||||
|
*/
|
||||||
|
const VORHER = process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
|
||||||
|
|
||||||
|
async function aufbau() {
|
||||||
|
const eigene = await testPrisma.kunde.create({
|
||||||
|
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
|
||||||
|
});
|
||||||
|
const fremd = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FREMD" } });
|
||||||
|
await testPrisma.einstellung.create({
|
||||||
|
data: { id: "global", modus: Betriebsmodus.DIENSTLEISTUNG },
|
||||||
|
});
|
||||||
|
const admin = await testPrisma.benutzer.create({
|
||||||
|
data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" },
|
||||||
|
});
|
||||||
|
const techniker = await testPrisma.benutzer.create({
|
||||||
|
data: { email: "tech@firma.example", name: "Techniker", rolle: "TECHNIKER" },
|
||||||
|
});
|
||||||
|
// Nur an die eigene Organisation gebunden – „Fremd GmbH“ ist für ihn unsichtbar.
|
||||||
|
await testPrisma.benutzerKunde.create({
|
||||||
|
data: { benutzerId: techniker.id, kundeId: eigene.id },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
eigene,
|
||||||
|
fremd,
|
||||||
|
admin: { id: admin.id, rolle: admin.rolle },
|
||||||
|
techniker: { id: techniker.id, rolle: techniker.rolle },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Recht auf das Protokoll", () => {
|
||||||
|
it("liegt bei verwaltung, also nur bei ADMIN", () => {
|
||||||
|
// Das Protokoll sagt, wer was getan hat, und ist damit selbst eine Auskunft
|
||||||
|
// über Menschen. TECHNIKER und LESER kommen nicht auf die Seite.
|
||||||
|
expect(RECHTE.verwaltung).toEqual(["ADMIN"]);
|
||||||
|
expect(RECHTE.verwaltung).not.toContain("TECHNIKER");
|
||||||
|
expect(RECHTE.verwaltung).not.toContain("LESER");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.runIf(datenbankVerfuegbar)("Protokoll ansehen", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "kunde-";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (VORHER === undefined) delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
|
||||||
|
else process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = VORHER;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zeigt einem beschränkten Benutzer keinen Eintrag eines fremden Kunden", async () => {
|
||||||
|
const { eigene, fremd, admin, techniker } = await aufbau();
|
||||||
|
await testPrisma.protokoll.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
kundeId: eigene.id,
|
||||||
|
entitaet: "Geraet",
|
||||||
|
entitaetId: "g-eigen",
|
||||||
|
aktion: "Gerät angelegt (eigen)",
|
||||||
|
benutzerId: admin.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kundeId: fremd.id,
|
||||||
|
entitaet: "Geraet",
|
||||||
|
entitaetId: "g-fremd",
|
||||||
|
aktion: "Gerät angelegt (fremd)",
|
||||||
|
benutzerId: admin.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const fuerTechniker = await protokollSeite({}, techniker);
|
||||||
|
expect(fuerTechniker.zeilen.map((zeile) => zeile.aktion)).toEqual(["Gerät angelegt (eigen)"]);
|
||||||
|
expect(fuerTechniker.gesamt).toBe(1);
|
||||||
|
|
||||||
|
// Der ADMIN sieht beide – er ist auf keinen Kunden beschränkt.
|
||||||
|
const fuerAdmin = await protokollSeite({}, admin);
|
||||||
|
expect(fuerAdmin.gesamt).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zeigt globale Einträge auch einem beschränkten Benutzer", async () => {
|
||||||
|
const { admin, techniker } = await aufbau();
|
||||||
|
await testPrisma.protokoll.create({
|
||||||
|
data: {
|
||||||
|
entitaet: "Einstellung",
|
||||||
|
entitaetId: "global",
|
||||||
|
aktion: "Betriebsmodus gewechselt",
|
||||||
|
benutzerId: admin.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ergebnis = await protokollSeite({}, techniker);
|
||||||
|
expect(ergebnis.zeilen.map((zeile) => zeile.aktion)).toEqual(["Betriebsmodus gewechselt"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verbirgt einen nicht zuordenbaren Eintrag vor einem beschränkten Benutzer", async () => {
|
||||||
|
// Ein Eintrag zu einem inzwischen gelöschten Gerät: ohne `kundeId` und ohne
|
||||||
|
// Lauf lässt er sich keinem Kunden zuordnen. `vorher`/`nachher` können einen
|
||||||
|
// Hostnamen enthalten, deshalb wird im Zweifel zugemacht (#023).
|
||||||
|
const { admin, techniker } = await aufbau();
|
||||||
|
await testPrisma.protokoll.create({
|
||||||
|
data: {
|
||||||
|
entitaet: "Geraet",
|
||||||
|
entitaetId: "laengst-geloescht",
|
||||||
|
aktion: "Gerät gelöscht",
|
||||||
|
benutzerId: admin.id,
|
||||||
|
vorher: { hostname: "FREMD-PC-007" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await protokollSeite({}, techniker)).gesamt).toBe(0);
|
||||||
|
expect((await protokollSeite({}, admin)).gesamt).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ordnet einen laufbezogenen Eintrag über den Lauf zu, auch ohne eigene kundeId", async () => {
|
||||||
|
const { fremd, admin, techniker } = await aufbau();
|
||||||
|
const vorlage = await testPrisma.vorlage.create({ data: { name: "Irgendeine" } });
|
||||||
|
const version = await testPrisma.vorlagenVersion.create({
|
||||||
|
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
|
||||||
|
});
|
||||||
|
await testPrisma.vorlagenVersion.update({
|
||||||
|
where: { id: version.id },
|
||||||
|
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
|
||||||
|
});
|
||||||
|
const lauf = await testPrisma.lauf.create({
|
||||||
|
data: { vorlagenVersionId: version.id, titel: "Lauf bei Fremd", kundeId: fremd.id },
|
||||||
|
});
|
||||||
|
await testPrisma.protokoll.create({
|
||||||
|
data: {
|
||||||
|
laufId: lauf.id,
|
||||||
|
entitaet: "Lauf",
|
||||||
|
aktion: "Lauf angelegt",
|
||||||
|
entitaetId: lauf.id,
|
||||||
|
benutzerId: admin.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await protokollSeite({}, techniker)).gesamt).toBe(0);
|
||||||
|
const fuerAdmin = await protokollSeite({}, admin);
|
||||||
|
expect(fuerAdmin.zeilen[0]?.laufTitel).toBe("Lauf bei Fremd");
|
||||||
|
expect(fuerAdmin.zeilen[0]?.kundeName).toBe("Fremd GmbH");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nennt einen Eintrag ohne Urheber als Systemlauf, nicht als Lücke", async () => {
|
||||||
|
const { eigene, admin } = await aufbau();
|
||||||
|
await testPrisma.protokoll.create({
|
||||||
|
data: {
|
||||||
|
kundeId: eigene.id,
|
||||||
|
entitaet: "Verzeichnisquelle",
|
||||||
|
entitaetId: "q-1",
|
||||||
|
aktion: "Verzeichnis abgeglichen",
|
||||||
|
benutzerId: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ergebnis = await protokollSeite({}, admin);
|
||||||
|
expect(ergebnis.zeilen[0]?.urheber).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filtert nach Urheber, Zeitraum und Volltext", async () => {
|
||||||
|
const { eigene, admin, techniker } = await aufbau();
|
||||||
|
await testPrisma.protokoll.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
kundeId: eigene.id,
|
||||||
|
entitaet: "Benutzer",
|
||||||
|
entitaetId: "b-1",
|
||||||
|
aktion: "Rolle geändert",
|
||||||
|
benutzerId: admin.id,
|
||||||
|
erstelltAm: new Date("2026-09-01T10:00:00Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kundeId: eigene.id,
|
||||||
|
entitaet: "Geraet",
|
||||||
|
entitaetId: "g-1",
|
||||||
|
aktion: "Gerät archiviert",
|
||||||
|
benutzerId: techniker.id,
|
||||||
|
erstelltAm: new Date("2026-10-04T10:00:00Z"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await protokollSeite({ suche: "rolle" }, admin)).gesamt).toBe(1);
|
||||||
|
expect((await protokollSeite({ urheberId: techniker.id }, admin)).gesamt).toBe(1);
|
||||||
|
expect((await protokollSeite({ vonDatum: "2026-10-01" }, admin)).gesamt).toBe(1);
|
||||||
|
// Einschliesslich des bis-Tages – sonst fehlt der Eintrag von heute.
|
||||||
|
expect((await protokollSeite({ bisDatum: "2026-10-04" }, admin)).gesamt).toBe(2);
|
||||||
|
expect((await protokollSeite({ vonDatum: "2026-09-01", bisDatum: "2026-09-01" }, admin)).gesamt).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blättert und zählt richtig", async () => {
|
||||||
|
const { eigene, admin } = await aufbau();
|
||||||
|
await testPrisma.protokoll.createMany({
|
||||||
|
data: Array.from({ length: 55 }, (_, nummer) => ({
|
||||||
|
kundeId: eigene.id,
|
||||||
|
entitaet: "Geraet",
|
||||||
|
entitaetId: `g-${nummer}`,
|
||||||
|
aktion: `Eintrag ${String(nummer).padStart(2, "0")}`,
|
||||||
|
benutzerId: admin.id,
|
||||||
|
erstelltAm: new Date(Date.UTC(2026, 9, 1, 0, nummer)),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const erste = await protokollSeite({}, admin);
|
||||||
|
expect(erste.gesamt).toBe(55);
|
||||||
|
expect(erste.seiten).toBe(2);
|
||||||
|
expect(erste.zeilen).toHaveLength(50);
|
||||||
|
// Neueste zuerst.
|
||||||
|
expect(erste.zeilen[0]?.aktion).toBe("Eintrag 54");
|
||||||
|
|
||||||
|
const zweite = await protokollSeite({ seite: 2 }, admin);
|
||||||
|
expect(zweite.zeilen).toHaveLength(5);
|
||||||
|
expect(zweite.zeilen.at(-1)?.aktion).toBe("Eintrag 00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nennt als Urheber nur die, die auch im sichtbaren Teil vorkommen", async () => {
|
||||||
|
const { eigene, fremd, admin, techniker } = await aufbau();
|
||||||
|
await testPrisma.protokoll.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
kundeId: eigene.id,
|
||||||
|
entitaet: "Geraet",
|
||||||
|
entitaetId: "g-1",
|
||||||
|
aktion: "eigen",
|
||||||
|
benutzerId: techniker.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kundeId: fremd.id,
|
||||||
|
entitaet: "Geraet",
|
||||||
|
entitaetId: "g-2",
|
||||||
|
aktion: "fremd",
|
||||||
|
benutzerId: admin.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await protokollUrheber(techniker)).map((eintrag) => eintrag.name)).toEqual(["Techniker"]);
|
||||||
|
expect((await protokollUrheber(admin)).map((eintrag) => eintrag.name).sort()).toEqual([
|
||||||
|
"Admin",
|
||||||
|
"Techniker",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gibt am Lauf nur dessen Einträge, neueste zuerst", async () => {
|
||||||
|
const { eigene, admin } = await aufbau();
|
||||||
|
const vorlage = await testPrisma.vorlage.create({ data: { name: "Irgendeine" } });
|
||||||
|
const version = await testPrisma.vorlagenVersion.create({
|
||||||
|
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
|
||||||
|
});
|
||||||
|
const lauf = await testPrisma.lauf.create({
|
||||||
|
data: { vorlagenVersionId: version.id, titel: "Der Lauf", kundeId: eigene.id },
|
||||||
|
});
|
||||||
|
const anderer = await testPrisma.lauf.create({
|
||||||
|
data: { vorlagenVersionId: version.id, titel: "Ein anderer", kundeId: eigene.id },
|
||||||
|
});
|
||||||
|
await testPrisma.protokoll.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
laufId: lauf.id,
|
||||||
|
entitaet: "Lauf",
|
||||||
|
entitaetId: lauf.id,
|
||||||
|
aktion: "zuerst",
|
||||||
|
benutzerId: admin.id,
|
||||||
|
erstelltAm: new Date("2026-10-01T10:00:00Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
laufId: lauf.id,
|
||||||
|
entitaet: "Lauf",
|
||||||
|
entitaetId: lauf.id,
|
||||||
|
aktion: "danach",
|
||||||
|
benutzerId: admin.id,
|
||||||
|
erstelltAm: new Date("2026-10-02T10:00:00Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
laufId: anderer.id,
|
||||||
|
entitaet: "Lauf",
|
||||||
|
entitaetId: anderer.id,
|
||||||
|
aktion: "fremder Lauf",
|
||||||
|
benutzerId: admin.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const eintraege = await protokollDesLaufs(lauf.id);
|
||||||
|
expect(eintraege.map((eintrag) => eintrag.aktion)).toEqual(["danach", "zuerst"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user