From 4bd34c4233327a8f38bfa68a8611b40122f3db87 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sun, 4 Oct 2026 17:40:46 +0200 Subject: [PATCH] feat(schnellstart): Kacheln je Geraetetyp in der Oberflaeche pflegen Welche Vorlagen auf der Geraeteseite als Kachel erscheinen, stand nur im Seed. Wer eine eigene Vorlage dort haben wollte, kam nicht heran; wer eine mitgelieferte loswerden wollte, auch nicht. Neue Seite Verwaltung -> Schnellstart: je Geraetetyp eine Liste, Reihenfolge ueber Hoch und Runter. Kein Drag & Drop -- der Editor braucht dnd-kit fuer hundert Schritte, hier sind es zwei bis vier Kacheln, und zwei Knoepfe sind am Handy verlaesslicher als eine Ziehgeste neben einer scrollenden Seite. Gespeichert wird die ganze Liste; position kommt aus ihrer Reihenfolge, damit es nur eine Wahrheit darueber gibt. Zur Auswahl stehen nur veroeffentlichte, nicht archivierte Vorlagen. Einstellung.schnellstartGepflegt haelt den Seed heraus, sobald jemand gespeichert hat -- auch bei unveraenderter Liste. Eine fehlende Zeile sagt nicht, ob sie noch nie da war oder bewusst entfernt wurde. Das Durchklicken des gebauten Stands fand einen Fehler, den die Suite nicht gesehen haette: die Umsortierung wurde nicht gespeichert. Altdaten tragen mehrere Zeilen auf position 0, und bei gleicher Position ist die Reihenfolge in Postgres nicht festgelegt -- Lesemodell und Dienst gaben sie unterschiedlich zurueck, und der Vergleich hielt die Umsortierung fuer einen Leerlauf. Behoben durch einen eindeutigen Tiebreak in beiden Abfragen, mit Regressionstest. Closes #34 --- CHANGELOG.md | 10 + docs/anleitung/03-qr-tags.md | 5 +- docs/anleitung/04-vorlagen.md | 6 +- docs/anleitung/07-admin.md | 27 ++ docs/issues/022-schnellstart-pflegen.md | 49 +++- docs/issues/README.md | 4 +- .../migration.sql | 21 ++ prisma/schema.prisma | 17 +- prisma/seed-daten/vorlagen.ts | 15 + src/app/(app)/admin/layout.tsx | 1 + src/app/(app)/admin/schnellstart/page.tsx | 51 ++++ src/components/schnellstart-pflege.tsx | 167 +++++++++++ src/server/actions/schnellstart.ts | 58 ++++ src/server/queries/schnellstart.ts | 90 ++++++ src/server/services/schnellstart.ts | 119 ++++++++ tests/schnellstart.test.ts | 264 ++++++++++++++++++ tests/seed.test.ts | 30 ++ 17 files changed, 910 insertions(+), 24 deletions(-) create mode 100644 prisma/migrations/20261004180000_schnellstart_gepflegt/migration.sql create mode 100644 src/app/(app)/admin/schnellstart/page.tsx create mode 100644 src/components/schnellstart-pflege.tsx create mode 100644 src/server/actions/schnellstart.ts create mode 100644 src/server/queries/schnellstart.ts create mode 100644 src/server/services/schnellstart.ts create mode 100644 tests/schnellstart.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b03dd4..ba228a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,12 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### Hinzugefügt +- **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 + wollte, kam nicht heran, und wer eine mitgelieferte loswerden wollte, auch nicht. Zur Auswahl + stehen nur veröffentlichte, nicht archivierte Vorlagen; die Reihenfolge der Liste ist die + Reihenfolge der Kacheln. Sobald jemand hier gespeichert hat, legt der Seed keine eigenen Vorgaben + mehr nach — eine entfernte Kachel soll nicht beim nächsten Einrichtungslauf zurückkommen (#022) - **Vorgesetzter auch für von Hand angelegte Personen.** Die Zuordnung lief allein über die Kennung des Verzeichnisses, und die ist nur innerhalb einer Verzeichnisquelle eindeutig: wer eine Person selbst eingetragen hatte, konnte ihr keinen Vorgesetzten geben, und `{{vorgesetzter_sam}}` blieb im @@ -229,6 +235,10 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### Behoben +- Die Umsortierung im Schnellstart wurde nicht gespeichert, wenn mehrere Zeilen desselben + Gerätetyps dieselbe `position` trugen — bei gleicher Position ist die Reihenfolge in Postgres nicht + festgelegt, und Lesemodell und Dienst gaben die Zeilen unterschiedlich zurück. Gefunden beim + Durchklicken des gebauten Stands, nicht von der Testsuite (#022) - Der Compose-Stand las `DATABASE_URL` aus der `.env` — dort steht die Adresse für die Entwicklung (`localhost` und ein offener Port), und im Container zeigt sie ins Leere. Wer den dokumentierten Weg ging, bekam einen Stand, der nicht startet. Die Adresse baut sich jetzt immer aus den diff --git a/docs/anleitung/03-qr-tags.md b/docs/anleitung/03-qr-tags.md index ab00619..0bc563c 100644 --- a/docs/anleitung/03-qr-tags.md +++ b/docs/anleitung/03-qr-tags.md @@ -136,8 +136,9 @@ sofort anfangen? Was war schon? den Fall, dass der Aufkleber abgerissen ist. - **Zugewiesen** — was an diesem Gerät wartet. Ein offener Lauf hat den Knopf **Starten**: er setzt ihn auf „in Arbeit“ und führt hinein. Ein laufender hat **Weiter**, ein fertiger **Ansehen**. -- **Schnellstart** — eine Kachel je Vorlage, die für diesen Gerätetyp hinterlegt ist. Ein Tipp legt - den Lauf mit diesem Gerät und seinem Besitzer an und beginnt ihn, ohne eine einzige Eingabe. +- **Schnellstart** — eine Kachel je Vorlage, die für diesen Gerätetyp hinterlegt ist (gepflegt unter + **Verwaltung → Schnellstart**, siehe Kapitel 07). Ein Tipp legt den Lauf mit diesem Gerät und + seinem Besitzer an und beginnt ihn, ohne eine einzige Eingabe. Steht auf der Kachel „fragt noch nach Angaben“, führt sie stattdessen in den Start-Dialog — dort sind Gerät und Person schon vorbelegt. - **Vorlage zuweisen** — für später oder für jemand anderen: Vorlage, **Techniker (optional)**, diff --git a/docs/anleitung/04-vorlagen.md b/docs/anleitung/04-vorlagen.md index c423859..e09d963 100644 --- a/docs/anleitung/04-vorlagen.md +++ b/docs/anleitung/04-vorlagen.md @@ -231,9 +231,9 @@ den Fall, dass jemand eine Vorlage von Hand schreiben oder ein Skript dafür bau ## Vorlagen einem Gerätetyp zuordnen Damit auf der Geräteseite ein **Schnellstart** erscheint, muss für den Gerätetyp eine Vorlage -hinterlegt sein — etwa „PC Neuinstallation“ für den Typ PC. Diese Zuordnung wird beim Einrichten der -Anwendung gesetzt und lässt sich in der Oberfläche noch nicht ändern; wende dich dafür an die -Person, die Laufzettel betreibt. +hinterlegt sein — etwa „PC Neuinstallation“ für den Typ PC. Gepflegt wird das unter +**Verwaltung → Schnellstart**; das verlangt die Rolle Administrator und ist in Kapitel 07 +beschrieben. Ohne Zuordnung bleibt der Schnellstart leer, und das ist kein Mangel: unter **Vorlage zuweisen** steht auf der Geräteseite jede Vorlage, die zu diesem Gerät passt, und der Start-Dialog kennt sie diff --git a/docs/anleitung/07-admin.md b/docs/anleitung/07-admin.md index 18dd0ff..b93cf3f 100644 --- a/docs/anleitung/07-admin.md +++ b/docs/anleitung/07-admin.md @@ -88,6 +88,33 @@ Aufkleber sonst ins Leere zeigen würden. Steht dort „APP_URL ist nicht gesetz noch nicht gedruckt werden“, muss das erst beim Betrieb der Anwendung eingetragen werden. **Drucke vorher keine Etiketten.** +## Schnellstart je Gerätetyp + +Auf der Seite eines Geräts stehen oben die Vorlagen, die dort am häufigsten gebraucht werden — ein +Klick, und der Lauf steht. Welche das sind, wird unter **Verwaltung → Schnellstart** festgelegt, je +Gerätetyp getrennt. + +Zu jedem Gerätetyp steht dort die Liste in der Reihenfolge, in der die Kacheln erscheinen. Mit den +Pfeilen wird umsortiert, mit dem **×** entfernt, und über **Vorlage hinzufügen** kommt eine dazu. +Nach jeder Änderung steht „nicht gespeichert“ daneben, bis **Speichern** gedrückt ist — die +Reihenfolge wandert als Ganzes in die Datenbank, nicht Zeile für Zeile. + +Drei Dinge, die hier anders sind als erwartet: + +- **Zur Auswahl stehen nur veröffentlichte, nicht archivierte Vorlagen.** Ein Entwurf taugt nicht: + aus ihm lässt sich kein Lauf starten, die Kachel täte also nichts. +- **Eine archivierte Vorlage verschwindet von selbst** aus dem Schnellstart. Niemand muss hier + aufräumen, nachdem eine Vorlage weggelegt wurde. +- **Eine Vorlage, die nur für einen Kunden gilt**, lässt sich zuweisen und wird mit dem Kundennamen + gezeigt. Sie erscheint trotzdem nur bei den Geräten dieses Kunden. + +**Ein leerer Schnellstart ist kein Mangel.** Unter **Vorlage zuweisen** steht auf der Geräteseite +jede Vorlage, die zu diesem Gerät passt, und der Start-Dialog kennt sie ohnehin alle. + +Sobald hier einmal gespeichert wurde, hält sich der Seed heraus: die mitgelieferten Vorgaben werden +danach nicht mehr nachgelegt. Das ist gewollt — eine entfernte Kachel soll nicht beim nächsten +Einrichtungslauf zurückkommen. + ## Betriebsmodus umstellen ![Der Bestätigungsdialog für den Moduswechsel mit den Folgen](bilder/admin-moduswechsel-desktop.png) diff --git a/docs/issues/022-schnellstart-pflegen.md b/docs/issues/022-schnellstart-pflegen.md index 6a97fdf..51502ac 100644 --- a/docs/issues/022-schnellstart-pflegen.md +++ b/docs/issues/022-schnellstart-pflegen.md @@ -1,10 +1,10 @@ # #022 Schnellstart je Gerätetyp pflegen -**Status:** offen +**Status:** erledigt **Im Tracker:** [#34](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/34) **Meilenstein:** v0.2.0 **Labels:** phase-2, frontend, backend -**Geschätzt:** 3 h **Tatsächlich:** – +**Geschätzt:** 3 h **Tatsächlich:** 1 h 10 min ## Ziel @@ -18,17 +18,44 @@ den Schnellstart, konnte aber nicht sagen, wer ihn einrichtet. ## Aufgaben -- [ ] Admin-Seite (oder ein Abschnitt unter Admin → Einstellungen): je Gerätetyp eine sortierbare - Liste der Vorlagen, die dort erscheinen sollen -- [ ] Nur **veröffentlichte**, nicht archivierte Vorlagen zur Auswahl; die Reihenfolge ist die +- [x] Eigene Seite **Verwaltung → Schnellstart** (kein Abschnitt unter Einstellungen: acht Gerätetypen + mit je einer Liste sind zu viel für eine Seite, die schon den Moduswechsel trägt) +- [x] Nur **veröffentlichte**, nicht archivierte Vorlagen zur Auswahl; die Reihenfolge ist die Reihenfolge der Kacheln (`position`) -- [ ] Recht `verwaltung`, Änderung ins `Protokoll` -- [ ] Der Seed legt die Vorgaben weiter an, überschreibt aber nie eine im Betrieb geänderte +- [x] Recht `verwaltung`, Änderung ins `Protokoll` +- [x] Der Seed legt die Vorgaben weiter an, überschreibt aber nie eine im Betrieb geänderte Zuordnung — dieselbe Regel wie für alles andere im Seed -- [ ] Anleitung Kapitel 03 und 07 nachziehen +- [x] Anleitung Kapitel 03 und 07 nachziehen ## Akzeptanzkriterien -- [ ] Test: eine zugewiesene Vorlage erscheint als Kachel auf der Geräteseite des passenden Typs -- [ ] Test: eine archivierte Vorlage verschwindet aus dem Schnellstart, ohne dass jemand aufräumt -- [ ] Test: `LESER` und `TECHNIKER` dürfen nicht ändern +- [x] Test: eine zugewiesene Vorlage erscheint als Kachel auf der Geräteseite des passenden Typs +- [x] Test: eine archivierte Vorlage verschwindet aus dem Schnellstart, ohne dass jemand aufräumt +- [x] Test: `LESER` und `TECHNIKER` dürfen nicht ändern + +## Beim Umsetzen dazugekommen + +- **Die Reihenfolge läuft über Hoch und Runter, nicht über Drag & Drop.** Der Vorlagen-Editor + benutzt dnd-kit, weil dort hundert Schritte in Abschnitten liegen; hier sind es zwei bis vier + Kacheln, und zwei Knöpfe sind am Handy verlässlicher als eine Ziehgeste neben einer scrollenden + Seite. +- **`Einstellung.schnellstartGepflegt` als Marke.** Eine fehlende Zeile in `GeraetetypVorlage` sagt + nicht, ob sie noch nie da war oder bewusst entfernt wurde — ohne Marke käme eine entfernte Kachel + beim nächsten Seed-Lauf zurück. Gesetzt wird sie **auch bei unverändert gespeicherter Liste**: das + Speichern selbst ist die Aussage „ich pflege das jetzt“. +- **Gespeichert wird die ganze Liste, nicht Zeile für Zeile.** `position` kommt aus der Reihenfolge + des Formulars; zwei Wahrheiten über die Reihenfolge gäbe es sonst. +- **Das Durchklicken des gebauten Stands hat einen echten Fehler gefunden**, den die Suite nicht + gesehen hätte: die Umsortierung wurde nicht gespeichert. Altdaten tragen mehrere Zeilen auf + `position` 0 — der Seed zählt je Vorgabe von 0, und ein Beispielskript legte eine zweite dazu. Bei + gleicher Position ist die Reihenfolge in Postgres nicht festgelegt, und das Lesemodell gab die + Zeilen anders zurück als der Dienst. Der Vergleich „unverändert" hielt die Umsortierung deshalb + für einen Leerlauf: die Marke wurde gesetzt, geschrieben wurde nichts. Behoben durch einen + eindeutigen Tiebreak (`position`, dann `vorlageId`) in **beiden** Abfragen. +- Dabei fiel auf, dass ich die Ursache zuerst falsch benannt hatte: eine zusätzliche Prüfung auf + aufeinanderfolgende Positionen behebt den Fehler **nicht** (Gegenprobe gefahren, Tests blieben + grün). Sie zieht nur alte Positionen gerade; der Kommentar sagt das jetzt so, und ein eigener Test + deckt genau das ab. +- Zwei der drei Akzeptanzkriterien waren schon durch `tests/geraeteseite.test.ts` gedeckt — die + Kachel am passenden Typ und das Verschwinden einer archivierten Vorlage. Das stand bei + [#014](014-qr-tags-geraeteseite.md) schon richtig im Leseweg; gefehlt hat nur die Pflege. diff --git a/docs/issues/README.md b/docs/issues/README.md index 3f6e5c5..d204003 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -26,10 +26,10 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac | #019 | [M14 – Betriebswege und Release v0.1.0](019-betrieb-release.md) | erledigt | v0.1.0 | 10 h | 5 h 25 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 | -| #022 | [Schnellstart je Gerätetyp pflegen](022-schnellstart-pflegen.md) | offen | v0.2.0 | 3 h | – | +| #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 | – | | #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 | | #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 | – | -| | **Summe** | | | **153 h** | **38 h 20 min** | +| | **Summe** | | | **153 h** | **39 h 30 min** | diff --git a/prisma/migrations/20261004180000_schnellstart_gepflegt/migration.sql b/prisma/migrations/20261004180000_schnellstart_gepflegt/migration.sql new file mode 100644 index 0000000..106ead2 --- /dev/null +++ b/prisma/migrations/20261004180000_schnellstart_gepflegt/migration.sql @@ -0,0 +1,21 @@ +-- --------------------------------------------------------------------------- +-- 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. +-- --------------------------------------------------------------------------- + +-- Marke fuer den Schnellstart (#022). +-- +-- Der Seed legt Vorgaben je Geraetetyp an. Ohne diese Marke kaeme eine in der +-- Oberflaeche entfernte Zuordnung beim naechsten Lauf zurueck: eine fehlende +-- Zeile sagt nicht, ob sie noch nie da war oder bewusst entfernt wurde. +-- +-- Bestehende Installationen bekommen `false`. Das ist richtig: dort hat noch +-- niemand den Schnellstart in der Oberflaeche gepflegt, weil es sie nicht gab. +ALTER TABLE "Einstellung" + ADD COLUMN "schnellstartGepflegt" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d6fdf69..0285d56 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -98,15 +98,20 @@ enum Herkunft { /// der Datenbank und nicht in der Umgebung, damit der Admin ohne Neustart /// umschalten kann. model Einstellung { - id String @id @default("global") - modus Betriebsmodus @default(INTERN) + id String @id @default("global") + modus Betriebsmodus @default(INTERN) /// Globale Namensregeln fuer upn, sam, mail und hostname. Im Modus /// DIENSTLEISTUNG koennen Kunden eigene Regeln haben, die diese ueberlagern. - namensregeln Json @default("{}") + namensregeln Json @default("{}") /// Ziel-OU fuer deaktivierte Konten, Vorbelegung im Offboarding. - gesperrteOu String? - erstelltAm DateTime @default(now()) - geaendertAm DateTime @default(now()) @updatedAt + gesperrteOu String? + /// Sobald jemand den Schnellstart in der Oberflaeche gespeichert hat, laesst + /// der Seed `GeraetetypVorlage` in Ruhe (#022). Ohne diese Marke kaeme eine + /// entfernte Zuordnung beim naechsten Seed-Lauf zurueck: eine fehlende Zeile + /// sagt nicht, ob sie noch nie da war oder bewusst entfernt wurde. + schnellstartGepflegt Boolean @default(false) + erstelltAm DateTime @default(now()) + geaendertAm DateTime @default(now()) @updatedAt } model Kunde { diff --git a/prisma/seed-daten/vorlagen.ts b/prisma/seed-daten/vorlagen.ts index 0cd6534..a27221b 100644 --- a/prisma/seed-daten/vorlagen.ts +++ b/prisma/seed-daten/vorlagen.ts @@ -273,6 +273,21 @@ export async function typVorgabenSaeen( db: PrismaClient, vorlagen: readonly GesaeteVorlage[], ): Promise { + /* + * Hat jemand den Schnellstart in der Oberflaeche gespeichert, haelt der Seed + * sich heraus — auch bei einer Vorgabe, die dort fehlt (#022). Eine fehlende + * Zeile sagt nicht, ob sie noch nie da war oder bewusst entfernt wurde; ohne + * diese Marke kaeme eine entfernte Kachel beim naechsten Lauf zurueck. + */ + const einstellung = await db.einstellung.findUnique({ + where: { id: "global" }, + select: { schnellstartGepflegt: true }, + }); + if (einstellung?.schnellstartGepflegt) { + console.log(" Schnellstart je Geraetetyp: in der Oberflaeche gepflegt, bleibt unberührt"); + return 0; + } + const nachName = new Map(vorlagen.map((eintrag) => [eintrag.name, eintrag.vorlageId])); let angelegt = 0; diff --git a/src/app/(app)/admin/layout.tsx b/src/app/(app)/admin/layout.tsx index 42e77a2..c530ba2 100644 --- a/src/app/(app)/admin/layout.tsx +++ b/src/app/(app)/admin/layout.tsx @@ -13,6 +13,7 @@ const REITER = [ { pfad: "/admin", beschriftung: "Einstellungen" }, { pfad: "/admin/benutzer", beschriftung: "Benutzer" }, { pfad: "/admin/abteilungen", beschriftung: "Abteilungen" }, + { pfad: "/admin/schnellstart", beschriftung: "Schnellstart" }, /* * 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 diff --git a/src/app/(app)/admin/schnellstart/page.tsx b/src/app/(app)/admin/schnellstart/page.tsx new file mode 100644 index 0000000..5c7f21c --- /dev/null +++ b/src/app/(app)/admin/schnellstart/page.tsx @@ -0,0 +1,51 @@ +import { Rocket } from "lucide-react"; +import { SchnellstartPflege } from "@/components/schnellstart-pflege"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { schnellstartPflege } from "@/server/queries/schnellstart"; + +/** + * Schnellstart je Gerätetyp (#022). + * + * Welche Vorlagen auf der Geräteseite als Kachel erscheinen, stand bisher nur im + * Seed. Wer eine eigene Vorlage dort haben wollte, kam nicht heran. + * + * `verwaltung`, wie bei den Abteilungen: die Zuordnung gilt global und bestimmt, + * was jeder Techniker an jedem Gerät als erstes sieht. + */ + +export default async function SchnellstartSeite() { + // Jede Seite prüft selbst – das Admin-Layout darüber ist das zweite Netz, + // nicht das erste (siehe CLAUDE.md). + await sitzungErforderlich("verwaltung", "/admin/schnellstart"); + const { typen, auswahl } = await schnellstartPflege(); + + return ( + <> +

+

+

+ Auf der Seite eines Geräts stehen oben die Vorlagen, die dort am häufigsten gebraucht werden — ein + Klick, und der Lauf steht. Hier wird festgelegt, welche das je Gerätetyp sind und in welcher + Reihenfolge sie erscheinen. +

+

+ Zur Auswahl stehen nur veröffentlichte, nicht archivierte Vorlagen. Wird eine Vorlage später + archiviert, verschwindet sie von selbst aus dem Schnellstart — niemand muss hier aufräumen. Eine + Vorlage, die nur für einen Kunden gilt, erscheint auch nur bei dessen Geräten. +

+ +
+ {typen.map((eintrag) => ( + + ))} +
+ + ); +} diff --git a/src/components/schnellstart-pflege.tsx b/src/components/schnellstart-pflege.tsx new file mode 100644 index 0000000..509237d --- /dev/null +++ b/src/components/schnellstart-pflege.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useActionState, useState } from "react"; +import { ArrowDown, ArrowUp, Plus, X } from "lucide-react"; +import type { GeraeteTyp } from "@/generated/prisma/enums"; +import { GERAETE_TYP_TEXT } from "@/lib/geraete-text"; +import { schnellstartSpeichernAktion, type AktionsErgebnis } from "@/server/actions/schnellstart"; + +export type Eintrag = { + readonly vorlageId: string; + readonly name: string; + readonly kategorie: string | null; + readonly kundeName: string | null; +}; + +/** + * Schnellstart eines Geraetetyps pflegen (#022). + * + * Die Reihenfolge laeuft ueber Hoch und Runter, nicht ueber Drag & Drop. Der + * Editor benutzt dnd-kit, weil dort hundert Schritte in Abschnitten liegen; hier + * sind es zwei bis vier Kacheln, und zwei Knoepfe sind am Handy verlaesslicher + * als eine Ziehgeste neben einer scrollenden Seite. + * + * Gespeichert wird die **ganze** Liste als ein Feld, eine Kennung je Zeile. So + * gibt es eine Wahrheit ueber die Reihenfolge: die des Formulars. + */ +export function SchnellstartPflege({ + typ, + zugewiesen, + auswahl, +}: { + typ: GeraeteTyp; + zugewiesen: readonly Eintrag[]; + auswahl: readonly Eintrag[]; +}) { + const [liste, setListe] = useState(zugewiesen); + const [ergebnis, aktion, laeuft] = useActionState( + schnellstartSpeichernAktion, + {}, + ); + + const offen = auswahl.filter( + (eintrag) => !liste.some((gewaehlt) => gewaehlt.vorlageId === eintrag.vorlageId), + ); + + function verschieben(stelle: number, richtung: -1 | 1) { + const ziel = stelle + richtung; + if (ziel < 0 || ziel >= liste.length) return; + const kopie = [...liste]; + const [heraus] = kopie.splice(stelle, 1); + if (heraus) kopie.splice(ziel, 0, heraus); + setListe(kopie); + } + + const geaendert = + liste.length !== zugewiesen.length || + liste.some((eintrag, stelle) => eintrag.vorlageId !== zugewiesen[stelle]?.vorlageId); + + return ( +
+

{GERAETE_TYP_TEXT[typ]}

+ + {liste.length === 0 ? ( +

+ Kein Schnellstart. Auf der Geräteseite erscheint dann nur „Vorlage zuweisen“. +

+ ) : ( +
    + {liste.map((eintrag, stelle) => ( +
  1. + + {String(stelle + 1).padStart(2, "0")} + + {eintrag.name} + {eintrag.kundeName ? ( + + nur {eintrag.kundeName} + + ) : null} +
    + + + +
    +
  2. + ))} +
+ )} + + {offen.length > 0 ? ( +
+ +
+ ) : null} + +
+ + eintrag.vorlageId).join("\n")} /> + + {geaendert ? nicht gespeichert : null} +
+ + {ergebnis.fehler ? ( +

+ {ergebnis.fehler} +

+ ) : null} + {ergebnis.erfolg && !geaendert ? ( +

+ {ergebnis.erfolg} +

+ ) : null} +
+ ); +} diff --git a/src/server/actions/schnellstart.ts b/src/server/actions/schnellstart.ts new file mode 100644 index 0000000..b25cece --- /dev/null +++ b/src/server/actions/schnellstart.ts @@ -0,0 +1,58 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "@/lib/validierung"; +import { GeraeteTyp } from "@/generated/prisma/enums"; +import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht"; +import { SchnellstartFehler, schnellstartSetzen } from "@/server/services/schnellstart"; + +/** Server Action für den Schnellstart (#022). Recht prüfen, Zod, Service rufen. */ + +export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: string }; + +const Schema = z.object({ + geraeteTyp: z.enum(GeraeteTyp), + /** + * Die ganze Liste in Anzeigereihenfolge, als ein Feld mit Zeilenumbrüchen. + * + * Eine leere Liste ist eine Angabe: „für diesen Typ gibt es keinen + * Schnellstart“. Deshalb kein `min(1)`. + */ + vorlageIds: z.string(), +}); + +export async function schnellstartSpeichernAktion( + _vorher: AktionsErgebnis, + formular: FormData, +): Promise { + try { + const { benutzer } = await rechtErforderlich("verwaltung"); + + const eingabe = Schema.safeParse({ + geraeteTyp: formular.get("geraeteTyp"), + vorlageIds: formular.get("vorlageIds") ?? "", + }); + if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." }; + + const ids = eingabe.data.vorlageIds + .split("\n") + .map((zeile) => zeile.trim()) + .filter((zeile) => zeile.length > 0); + + await schnellstartSetzen(eingabe.data.geraeteTyp, ids, benutzer.id); + revalidatePath("/admin/schnellstart"); + // Die Geräteseiten zeigen die Kacheln – ohne das bliebe dort der alte Stand. + revalidatePath("/geraete", "layout"); + return { erfolg: "Gespeichert." }; + } catch (fehler) { + if (fehler instanceof SchnellstartFehler) return { fehler: fehler.message }; + if (fehler instanceof RechteFehler) { + return { fehler: "Dafür fehlt dir die Berechtigung." }; + } + console.error( + "Schnellstart speichern fehlgeschlagen:", + fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : "unbekannter Fehler", + ); + return { fehler: "Der Schnellstart konnte nicht gespeichert werden." }; + } +} diff --git a/src/server/queries/schnellstart.ts b/src/server/queries/schnellstart.ts new file mode 100644 index 0000000..003b408 --- /dev/null +++ b/src/server/queries/schnellstart.ts @@ -0,0 +1,90 @@ +import type { GeraeteTyp } from "@/generated/prisma/enums"; +import { VersionStatus } from "@/generated/prisma/enums"; +import { prisma } from "@/lib/db"; +import { GERAETE_TYP_LISTE } from "@/lib/geraete-text"; + +/** + * Lesemodell fuer die Pflege des Schnellstarts (#022). + * + * `GeraetetypVorlage` ist global: die Zuordnung haengt am Geraetetyp, nicht am + * Kunden. Deshalb gibt es hier **keine** Mandantensicht — die Seite verlangt + * `verwaltung`, und wer die hat, sieht ohnehin die ganze Installation. Dass eine + * kundengebundene Vorlage trotzdem nur bei ihrem Kunden erscheint, entscheidet + * `vorlagenFuerGeraet` beim Lesen der Geraeteseite, nicht diese Pflege. + */ + +export type SchnellstartEintrag = { + readonly vorlageId: string; + readonly name: string; + readonly kategorie: string | null; + /** Gesetzt, wenn die Vorlage nur fuer einen Kunden gilt. */ + readonly kundeName: string | null; +}; + +export type SchnellstartTyp = { + readonly typ: GeraeteTyp; + readonly zugewiesen: readonly SchnellstartEintrag[]; +}; + +export type SchnellstartPflege = { + readonly typen: readonly SchnellstartTyp[]; + /** Alles, was sich zuweisen laesst – veroeffentlicht und nicht archiviert. */ + readonly auswahl: readonly SchnellstartEintrag[]; +}; + +export async function schnellstartPflege(): Promise { + const [vorlagen, vorgaben] = await Promise.all([ + prisma.vorlage.findMany({ + where: { + archiviert: false, + // Ohne veroeffentlichte Version laesst sich kein Lauf starten; sie + // anzubieten waere eine Kachel, die nichts tut. + versionen: { some: { status: VersionStatus.VEROEFFENTLICHT } }, + }, + orderBy: [{ kategorie: "asc" }, { name: "asc" }], + select: { + id: true, + name: true, + kategorie: true, + kunde: { select: { name: true } }, + }, + }), + prisma.geraetetypVorlage.findMany({ + // `vorlageId` als zweites Kriterium, damit die Reihenfolge **eindeutig** + // ist. Zwei Zeilen mit derselben `position` gibt es wirklich — der Seed + // zaehlt je Vorgabe von 0, und Altdaten tragen beides auf 0. Ohne diesen + // Tiebreak gab Postgres sie hier anders zurueck als im Dienst, und eine + // echte Umsortierung galt dort als "unveraendert" (#022, gefunden beim + // Durchklicken des gebauten Stands). + orderBy: [{ geraeteTyp: "asc" }, { position: "asc" }, { vorlageId: "asc" }], + select: { geraeteTyp: true, vorlageId: true }, + }), + ]); + + const nachId = new Map( + vorlagen.map((vorlage) => [ + vorlage.id, + { + vorlageId: vorlage.id, + name: vorlage.name, + kategorie: vorlage.kategorie, + kundeName: vorlage.kunde?.name ?? null, + }, + ]), + ); + + return { + typen: GERAETE_TYP_LISTE.map((typ) => ({ + typ, + zugewiesen: vorgaben + .filter((vorgabe) => vorgabe.geraeteTyp === typ) + // Eine archivierte oder unveroeffentlichte Vorlage bleibt in der Tabelle + // stehen und verschwindet trotzdem aus der Liste — dieselbe Regel, die + // `vorlagenFuerGeraet` auf der Geraeteseite anwendet. Niemand muss + // aufraeumen, damit der Schnellstart stimmt. + .map((vorgabe) => nachId.get(vorgabe.vorlageId)) + .filter((eintrag): eintrag is SchnellstartEintrag => eintrag !== undefined), + })), + auswahl: [...nachId.values()], + }; +} diff --git a/src/server/services/schnellstart.ts b/src/server/services/schnellstart.ts new file mode 100644 index 0000000..7b2d3d2 --- /dev/null +++ b/src/server/services/schnellstart.ts @@ -0,0 +1,119 @@ +import { GeraeteTyp, VersionStatus } from "@/generated/prisma/enums"; +import { prisma } from "@/lib/db"; + +/** + * Pflege des Schnellstarts je Geraetetyp (#022). + * + * Welche Vorlagen auf der Geraeteseite als Kachel erscheinen, stand bisher nur + * im Seed. Wer eine eigene Vorlage dort haben wollte, kam nicht heran; wer eine + * mitgelieferte loswerden wollte, auch nicht. + * + * Die Zuordnung ist global und traegt keine `kundeId` — sie haengt am Typ. Dass + * eine kundengebundene Vorlage nur bei ihrem Kunden erscheint, entscheidet + * `vorlagenFuerGeraet` beim Lesen. + */ + +export class SchnellstartFehler extends Error { + constructor(meldung: string) { + super(meldung); + this.name = "SchnellstartFehler"; + } +} + +/** + * Setzt die Liste eines Typs auf genau diese Vorlagen, in dieser Reihenfolge. + * + * Ersetzen und nicht ergaenzen: die Oberflaeche schickt die ganze Liste, und + * `position` kommt aus ihrer Reihenfolge. Zwei Wahrheiten ueber die Reihenfolge + * gaebe es sonst — eine im Formular und eine in der Spalte. + */ +export async function schnellstartSetzen( + geraeteTyp: GeraeteTyp, + vorlageIds: readonly string[], + durchBenutzerId: string, +): Promise { + const doppelt = vorlageIds.filter((id, stelle) => vorlageIds.indexOf(id) !== stelle); + if (doppelt.length > 0) { + throw new SchnellstartFehler("Eine Vorlage kann je Gerätetyp nur einmal im Schnellstart stehen."); + } + + if (vorlageIds.length > 0) { + const brauchbar = await prisma.vorlage.findMany({ + where: { + id: { in: [...vorlageIds] }, + archiviert: false, + versionen: { some: { status: VersionStatus.VEROEFFENTLICHT } }, + }, + select: { id: true }, + }); + if (brauchbar.length !== vorlageIds.length) { + throw new SchnellstartFehler( + "Mindestens eine der gewählten Vorlagen gibt es nicht mehr, ist archiviert oder hat keine " + + "veröffentlichte Version. Seite neu laden und noch einmal versuchen.", + ); + } + } + + const vorher = await prisma.geraetetypVorlage.findMany({ + where: { geraeteTyp }, + // Derselbe Tiebreak wie im Lesemodell, und aus demselben Grund: bei + // gleicher `position` ist die Reihenfolge sonst nicht eindeutig, und der + // Vergleich unten haelt eine echte Umsortierung fuer einen Leerlauf. + orderBy: [{ position: "asc" }, { vorlageId: "asc" }], + select: { vorlageId: true, position: true, vorlage: { select: { name: true } } }, + }); + const vorherIds = vorher.map((eintrag) => eintrag.vorlageId); + /* + * Eine Liste mit gleichen Positionen gilt nicht als unveraendert, auch wenn + * dieselben Vorlagen in derselben Reihenfolge kommen: Altdaten tragen mehrere + * Zeilen auf `position` 0, und einmal Speichern soll sie geradeziehen. + * + * Das ist Aufraeumen, nicht die Behebung des Fehlers aus #022 — der lag am + * fehlenden Tiebreak in beiden Abfragen. Ohne ihn gab Postgres die Zeilen hier + * anders zurueck als im Lesemodell, und eine echte Umsortierung galt als + * Leerlauf. + */ + const positionenSauber = vorher.every((eintrag, stelle) => eintrag.position === stelle); + const unveraendert = + positionenSauber && + vorherIds.length === vorlageIds.length && + vorherIds.every((id, stelle) => id === vorlageIds[stelle]); + + await prisma.$transaction(async (tx) => { + // Immer schreiben, auch bei unveraenderter Liste: die Marke ist der Punkt, + // an dem der Seed aufhoert, eigene Vorgaben nachzulegen. Wer die Seite + // einmal gespeichert hat, pflegt den Schnellstart selbst (#022). + await tx.einstellung.update({ + where: { id: "global" }, + data: { schnellstartGepflegt: true }, + }); + + if (unveraendert) return; + + await tx.geraetetypVorlage.deleteMany({ where: { geraeteTyp } }); + if (vorlageIds.length > 0) { + await tx.geraetetypVorlage.createMany({ + data: vorlageIds.map((vorlageId, position) => ({ geraeteTyp, vorlageId, position })), + }); + } + + const namen = await tx.vorlage.findMany({ + where: { id: { in: [...vorlageIds] } }, + select: { id: true, name: true }, + }); + const nachId = new Map(namen.map((eintrag) => [eintrag.id, eintrag.name])); + + await tx.protokoll.create({ + data: { + entitaet: "GeraetetypVorlage", + entitaetId: geraeteTyp, + aktion: `Schnellstart für ${geraeteTyp} geändert`, + benutzerId: durchBenutzerId, + // Namen und nicht Kennungen: ein Protokolleintrag muss auch dann noch + // lesbar sein, wenn die Vorlage spaeter anders heisst. + vorher: { vorlagen: vorher.map((eintrag) => eintrag.vorlage.name) }, + nachher: { vorlagen: vorlageIds.map((id) => nachId.get(id) ?? id) }, + }, + }); + }); +} diff --git a/tests/schnellstart.test.ts b/tests/schnellstart.test.ts new file mode 100644 index 0000000..d2c1bac --- /dev/null +++ b/tests/schnellstart.test.ts @@ -0,0 +1,264 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { Betriebsmodus, GeraeteTyp, VersionStatus } from "@/generated/prisma/enums"; +import { RECHTE } from "@/server/auth/rollen"; +import { schnellstartPflege } from "@/server/queries/schnellstart"; +import { SchnellstartFehler, schnellstartSetzen } from "@/server/services/schnellstart"; +import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; + +/** + * Schnellstart je Geraetetyp pflegen (#022). + * + * Dass die Kacheln auf der Geraeteseite richtig erscheinen, prueft + * `tests/geraeteseite.test.ts` — auch den Fall, dass eine archivierte Vorlage von + * selbst verschwindet. Hier geht es um das Pflegen: die Reihenfolge, die + * Abweisungen, das Protokoll, und die Marke, die den Seed heraushaelt. + */ + +async function grundbestand() { + const kunde = 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 { kunde, adminId: admin.id }; +} + +/** Eine Vorlage mit veroeffentlichter Version 1 – nur die ist zuweisbar. */ +async function vorlageAnlegen( + name: string, + optionen: { archiviert?: boolean; nurEntwurf?: boolean; kundeId?: string } = {}, +): Promise { + const vorlage = await testPrisma.vorlage.create({ + data: { + name, + archiviert: optionen.archiviert ?? false, + ...(optionen.kundeId ? { kundeId: optionen.kundeId } : {}), + }, + }); + const version = await testPrisma.vorlagenVersion.create({ + data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF }, + }); + if (!optionen.nurEntwurf) { + await testPrisma.vorlagenVersion.update({ + where: { id: version.id }, + data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() }, + }); + } + return vorlage.id; +} + +async function listeVon(typ: GeraeteTyp): Promise { + const { typen } = await schnellstartPflege(); + return typen.find((eintrag) => eintrag.typ === typ)?.zugewiesen.map((e) => e.name) ?? []; +} + +describe("Recht auf den Schnellstart", () => { + it("liegt bei verwaltung, also nur bei ADMIN", () => { + // Die Zuordnung gilt global und bestimmt, was jeder Techniker an jedem Gerät + // zuerst sieht. TECHNIKER und LESER dürfen das nicht ändern. + expect(RECHTE.verwaltung).toEqual(["ADMIN"]); + }); +}); + +describe.runIf(datenbankVerfuegbar)("Schnellstart pflegen", () => { + beforeEach(async () => { + await datenbankLeeren(); + }); + + it("setzt die Liste in der übergebenen Reihenfolge", async () => { + const { adminId } = await grundbestand(); + const erste = await vorlageAnlegen("Erster Griff"); + const zweite = await vorlageAnlegen("Zweiter Griff"); + + await schnellstartSetzen(GeraeteTyp.PC, [zweite, erste], adminId); + expect(await listeVon(GeraeteTyp.PC)).toEqual(["Zweiter Griff", "Erster Griff"]); + + // Umsortieren heißt ersetzen, nicht ergänzen. + await schnellstartSetzen(GeraeteTyp.PC, [erste, zweite], adminId); + expect(await listeVon(GeraeteTyp.PC)).toEqual(["Erster Griff", "Zweiter Griff"]); + expect(await testPrisma.geraetetypVorlage.count({ where: { geraeteTyp: GeraeteTyp.PC } })).toBe(2); + }); + + it("sortiert auch um, wenn alte Zeilen dieselbe Position tragen", async () => { + /* + * Der Fall, den erst das Durchklicken des gebauten Stands zeigte. Altdaten + * tragen mehrere Zeilen auf `position` 0 — der Seed zaehlt je Vorgabe von 0, + * und ein Beispielskript legte eine zweite dazu. Bei gleicher Position ist + * die Reihenfolge in Postgres nicht festgelegt, und der Vergleich + * "unveraendert" hielt eine echte Umsortierung deshalb fuer einen Leerlauf: + * die Marke wurde gesetzt, geschrieben wurde nichts. + */ + const { adminId } = await grundbestand(); + const a = await vorlageAnlegen("Alpha"); + const b = await vorlageAnlegen("Beta"); + await testPrisma.geraetetypVorlage.createMany({ + data: [ + { geraeteTyp: GeraeteTyp.PC, vorlageId: a, position: 0 }, + { geraeteTyp: GeraeteTyp.PC, vorlageId: b, position: 0 }, + ], + }); + + // Genau die Reihenfolge schicken, die das Lesemodell zeigt – ein Mensch, der + // nichts umsortiert, aber auf Speichern drückt. + const gezeigt = await listeVon(GeraeteTyp.PC); + const nachName = new Map([ + ["Alpha", a], + ["Beta", b], + ]); + const umgekehrt = [...gezeigt].reverse().map((name) => nachName.get(name) ?? ""); + await schnellstartSetzen(GeraeteTyp.PC, umgekehrt, adminId); + + expect(await listeVon(GeraeteTyp.PC)).toEqual([...gezeigt].reverse()); + // Und die Positionen stehen danach aufeinanderfolgend, nicht beide auf 0. + const zeilen = await testPrisma.geraetetypVorlage.findMany({ + where: { geraeteTyp: GeraeteTyp.PC }, + orderBy: { position: "asc" }, + select: { position: true }, + }); + expect(zeilen.map((zeile) => zeile.position)).toEqual([0, 1]); + }); + + it("zieht beim Speichern alte, gleiche Positionen gerade", async () => { + // Ohne Aenderung gespeichert: die Liste bleibt, die Positionen werden + // aufeinanderfolgend. Aufraeumen, kein Fehlerfall (#022). + const { adminId } = await grundbestand(); + const a = await vorlageAnlegen("Alpha"); + const b = await vorlageAnlegen("Beta"); + await testPrisma.geraetetypVorlage.createMany({ + data: [ + { geraeteTyp: GeraeteTyp.PC, vorlageId: a, position: 0 }, + { geraeteTyp: GeraeteTyp.PC, vorlageId: b, position: 0 }, + ], + }); + + const gezeigt = await listeVon(GeraeteTyp.PC); + const nachName = new Map([ + ["Alpha", a], + ["Beta", b], + ]); + await schnellstartSetzen( + GeraeteTyp.PC, + gezeigt.map((name) => nachName.get(name) ?? ""), + adminId, + ); + + expect(await listeVon(GeraeteTyp.PC)).toEqual(gezeigt); + const zeilen = await testPrisma.geraetetypVorlage.findMany({ + where: { geraeteTyp: GeraeteTyp.PC }, + orderBy: { position: "asc" }, + select: { position: true }, + }); + expect(zeilen.map((zeile) => zeile.position)).toEqual([0, 1]); + }); + + it("nimmt eine leere Liste als Angabe und entfernt alles", async () => { + const { adminId } = await grundbestand(); + const vorlage = await vorlageAnlegen("Einziger Griff"); + await schnellstartSetzen(GeraeteTyp.PC, [vorlage], adminId); + + await schnellstartSetzen(GeraeteTyp.PC, [], adminId); + expect(await listeVon(GeraeteTyp.PC)).toEqual([]); + }); + + it("lässt andere Gerätetypen unberührt", async () => { + const { adminId } = await grundbestand(); + const vorlage = await vorlageAnlegen("Für beide"); + await schnellstartSetzen(GeraeteTyp.PC, [vorlage], adminId); + await schnellstartSetzen(GeraeteTyp.SERVER, [vorlage], adminId); + + await schnellstartSetzen(GeraeteTyp.PC, [], adminId); + expect(await listeVon(GeraeteTyp.SERVER)).toEqual(["Für beide"]); + }); + + it("weist dieselbe Vorlage zweimal in einer Liste ab", async () => { + const { adminId } = await grundbestand(); + const vorlage = await vorlageAnlegen("Einmal reicht"); + await expect(schnellstartSetzen(GeraeteTyp.PC, [vorlage, vorlage], adminId)).rejects.toThrow( + SchnellstartFehler, + ); + }); + + it("weist eine archivierte Vorlage ab", async () => { + const { adminId } = await grundbestand(); + const weggelegt = await vorlageAnlegen("Weggelegt", { archiviert: true }); + await expect(schnellstartSetzen(GeraeteTyp.PC, [weggelegt], adminId)).rejects.toThrow(/archiviert/); + }); + + it("weist eine Vorlage ohne veröffentlichte Version ab", async () => { + // Eine Kachel, die zu einem Entwurf führt, kann keinen Lauf starten. + const { adminId } = await grundbestand(); + const entwurf = await vorlageAnlegen("Noch Entwurf", { nurEntwurf: true }); + await expect(schnellstartSetzen(GeraeteTyp.PC, [entwurf], adminId)).rejects.toThrow( + /veröffentlichte Version/, + ); + }); + + it("bietet zur Auswahl nur veröffentlichte, nicht archivierte Vorlagen", async () => { + const { kunde } = await grundbestand(); + await vorlageAnlegen("Brauchbar"); + await vorlageAnlegen("Weggelegt", { archiviert: true }); + await vorlageAnlegen("Noch Entwurf", { nurEntwurf: true }); + await vorlageAnlegen("Nur für einen Kunden", { kundeId: kunde.id }); + + const { auswahl } = await schnellstartPflege(); + expect(auswahl.map((eintrag) => eintrag.name).sort()).toEqual(["Brauchbar", "Nur für einen Kunden"]); + // Eine kundengebundene Vorlage ist zuweisbar und wird als solche gezeigt; + // dass sie nur bei ihren Geräten erscheint, entscheidet das Lesen. + expect(auswahl.find((eintrag) => eintrag.name === "Nur für einen Kunden")?.kundeName).toBe( + "Eigene Organisation", + ); + }); + + it("nennt jeden Gerätetyp, auch die ohne Zuordnung", async () => { + await grundbestand(); + const { typen } = await schnellstartPflege(); + expect(typen.map((eintrag) => eintrag.typ)).toEqual(Object.values(GeraeteTyp)); + }); + + it("schreibt die Änderung mit Namen ins Protokoll, nicht mit Kennungen", async () => { + const { adminId } = await grundbestand(); + const erste = await vorlageAnlegen("Erster Griff"); + const zweite = await vorlageAnlegen("Zweiter Griff"); + await schnellstartSetzen(GeraeteTyp.PC, [erste], adminId); + await schnellstartSetzen(GeraeteTyp.PC, [erste, zweite], adminId); + + const eintraege = await testPrisma.protokoll.findMany({ + where: { entitaet: "GeraetetypVorlage" }, + orderBy: { erstelltAm: "asc" }, + }); + expect(eintraege).toHaveLength(2); + expect(eintraege[1]?.aktion).toBe("Schnellstart für PC geändert"); + expect(eintraege[1]?.vorher).toEqual({ vorlagen: ["Erster Griff"] }); + expect(eintraege[1]?.nachher).toEqual({ vorlagen: ["Erster Griff", "Zweiter Griff"] }); + // Ein Protokolleintrag muss auch lesbar sein, wenn die Vorlage später + // anders heißt – deshalb Namen und keine Kennungen. + expect(JSON.stringify(eintraege[1]?.nachher)).not.toContain(erste); + }); + + it("schreibt keinen Protokolleintrag, wenn sich die Liste nicht ändert", async () => { + const { adminId } = await grundbestand(); + const vorlage = await vorlageAnlegen("Unverändert"); + await schnellstartSetzen(GeraeteTyp.PC, [vorlage], adminId); + await schnellstartSetzen(GeraeteTyp.PC, [vorlage], adminId); + + expect(await testPrisma.protokoll.count({ where: { entitaet: "GeraetetypVorlage" } })).toBe(1); + }); + + it("setzt die Marke, die den Seed heraushält – auch bei unveränderter Liste", async () => { + const { adminId } = await grundbestand(); + const vorlage = await vorlageAnlegen("Einziger Griff"); + expect( + (await testPrisma.einstellung.findUniqueOrThrow({ where: { id: "global" } })).schnellstartGepflegt, + ).toBe(false); + + // Ein Speichern, das nichts ändert, ist trotzdem die Aussage „ich pflege das + // selbst“ — sonst legte der Seed seine Vorgaben weiter nach. + await schnellstartSetzen(GeraeteTyp.PC, [], adminId); + expect( + (await testPrisma.einstellung.findUniqueOrThrow({ where: { id: "global" } })).schnellstartGepflegt, + ).toBe(true); + expect(vorlage).toBeTruthy(); + }); +}); diff --git a/tests/seed.test.ts b/tests/seed.test.ts index feeb06b..be974df 100644 --- a/tests/seed.test.ts +++ b/tests/seed.test.ts @@ -414,6 +414,36 @@ describe.runIf(datenbankVerfuegbar)("Seed gegen die Datenbank", () => { expect(await typVorgabenSaeen(testPrisma, gesaet)).toBe(0); }); + it("laesst den Schnellstart in Ruhe, sobald er in der Oberflaeche gepflegt wurde", async () => { + // Der Fall aus #022: wer eine Kachel entfernt, soll sie nicht beim naechsten + // Seed-Lauf zurueckbekommen. Eine fehlende Zeile sagt nicht, ob sie noch nie + // da war oder bewusst entfernt wurde — die Marke sagt es. + const gesaet = await vorlagenSaeen(testPrisma); + expect(await typVorgabenSaeen(testPrisma, gesaet)).toBeGreaterThan(0); + + await testPrisma.geraetetypVorlage.deleteMany({ where: { geraeteTyp: GeraeteTyp.PC } }); + // upsert, weil dieser Block die Einstellung nicht selbst anlegt - und genau + // deshalb belegt der Test auch, dass typVorgabenSaeen ohne sie zurechtkommt. + await testPrisma.einstellung.upsert({ + where: { id: "global" }, + create: { id: "global", schnellstartGepflegt: true }, + update: { schnellstartGepflegt: true }, + }); + + expect(await typVorgabenSaeen(testPrisma, gesaet)).toBe(0); + expect(await testPrisma.geraetetypVorlage.count({ where: { geraeteTyp: GeraeteTyp.PC } })).toBe(0); + }); + + it("legt die Vorgaben weiter an, solange niemand sie gepflegt hat", async () => { + const gesaet = await vorlagenSaeen(testPrisma); + await typVorgabenSaeen(testPrisma, gesaet); + const vorher = await testPrisma.geraetetypVorlage.count({ where: { geraeteTyp: GeraeteTyp.PC } }); + expect(vorher).toBeGreaterThan(0); + + await testPrisma.geraetetypVorlage.deleteMany({ where: { geraeteTyp: GeraeteTyp.PC } }); + expect(await typVorgabenSaeen(testPrisma, gesaet)).toBe(vorher); + }); + it("legt Abteilungen, Personen, Geraete und freie Tags an", async () => { const eigene = await eigeneOrganisation(); const bestand = await stammdatenSaeen(testPrisma, eigene.id); -- 2.54.0