feat(schnellstart): Kacheln je Geraetetyp in der Oberflaeche pflegen (#44)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #44.
This commit is contained in:
2026-10-04 15:41:47 +00:00
parent 0060a55387
commit f5936327a0
17 changed files with 910 additions and 24 deletions
+1
View File
@@ -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
+51
View File
@@ -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 (
<>
<h1 className="flex items-center gap-2 text-2xl font-bold">
<Rocket className="size-6 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
Schnellstart
</h1>
<p className="mt-2 max-w-prose text-nebentext">
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.
</p>
<p className="mt-2 max-w-prose text-sm text-nebentext">
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.
</p>
<div className="mt-6 flex flex-col gap-4">
{typen.map((eintrag) => (
<SchnellstartPflege
key={eintrag.typ}
typ={eintrag.typ}
zugewiesen={eintrag.zugewiesen}
auswahl={auswahl}
/>
))}
</div>
</>
);
}
+167
View File
@@ -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<readonly Eintrag[]>(zugewiesen);
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(
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 (
<section className="rounded-karte border border-linie bg-flaeche p-5">
<h3 className="text-lg font-semibold">{GERAETE_TYP_TEXT[typ]}</h3>
{liste.length === 0 ? (
<p className="mt-2 text-sm text-nebentext">
Kein Schnellstart. Auf der Geräteseite erscheint dann nur „Vorlage zuweisen“.
</p>
) : (
<ol className="mt-3 flex flex-col gap-2">
{liste.map((eintrag, stelle) => (
<li
key={eintrag.vorlageId}
className="flex flex-wrap items-center gap-2 border-t border-linie py-2 first:border-t-0"
>
<span className="mono w-7 shrink-0 text-xs text-nebentext">
{String(stelle + 1).padStart(2, "0")}
</span>
<span className="min-w-0 flex-1 break-words">{eintrag.name}</span>
{eintrag.kundeName ? (
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
nur {eintrag.kundeName}
</span>
) : null}
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={() => verschieben(stelle, -1)}
disabled={stelle === 0}
aria-label={`${eintrag.name} nach oben`}
className="flex size-11 items-center justify-center rounded-pille text-nebentext hover:bg-flaeche-hoch hover:text-text disabled:opacity-40 md:size-10"
>
<ArrowUp className="size-4" strokeWidth={1.75} aria-hidden="true" />
</button>
<button
type="button"
onClick={() => verschieben(stelle, 1)}
disabled={stelle === liste.length - 1}
aria-label={`${eintrag.name} nach unten`}
className="flex size-11 items-center justify-center rounded-pille text-nebentext hover:bg-flaeche-hoch hover:text-text disabled:opacity-40 md:size-10"
>
<ArrowDown className="size-4" strokeWidth={1.75} aria-hidden="true" />
</button>
<button
type="button"
onClick={() => setListe(liste.filter((andere) => andere.vorlageId !== eintrag.vorlageId))}
aria-label={`${eintrag.name} entfernen`}
className="flex size-11 items-center justify-center rounded-pille text-nebentext hover:bg-flaeche-hoch hover:text-fehler md:size-10"
>
<X className="size-4" strokeWidth={1.75} aria-hidden="true" />
</button>
</div>
</li>
))}
</ol>
)}
{offen.length > 0 ? (
<div className="mt-4 flex flex-wrap items-end gap-2">
<label className="flex min-w-0 flex-1 flex-col gap-1 text-sm">
<span className="text-nebentext">Vorlage hinzufügen</span>
<select
aria-label={`Vorlage für ${GERAETE_TYP_TEXT[typ]} hinzufügen`}
value=""
onChange={(ereignis) => {
const gewaehlt = offen.find((eintrag) => eintrag.vorlageId === ereignis.target.value);
if (gewaehlt) setListe([...liste, gewaehlt]);
}}
className="min-h-11 w-full rounded-pille border border-linie bg-flaeche-hoch px-4 text-sm md:min-h-10"
>
<option value="">auswählen …</option>
{offen.map((eintrag) => (
<option key={eintrag.vorlageId} value={eintrag.vorlageId}>
{eintrag.name}
{eintrag.kundeName ? ` (nur ${eintrag.kundeName})` : ""}
</option>
))}
</select>
</label>
<Plus className="mb-3 size-4 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
</div>
) : null}
<form action={aktion} className="mt-4 flex flex-wrap items-center gap-3">
<input type="hidden" name="geraeteTyp" value={typ} />
<input type="hidden" name="vorlageIds" value={liste.map((eintrag) => eintrag.vorlageId).join("\n")} />
<button
type="submit"
disabled={laeuft}
className="min-h-11 rounded-pille bg-akzent px-4 text-sm font-semibold text-grund md:min-h-10"
>
Speichern
</button>
{geaendert ? <span className="text-sm text-faellig">nicht gespeichert</span> : null}
</form>
{ergebnis.fehler ? (
<p role="alert" className="mt-2 max-w-prose text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
{ergebnis.erfolg && !geaendert ? (
<p role="status" className="mt-2 text-sm text-erledigt">
{ergebnis.erfolg}
</p>
) : null}
</section>
);
}
+58
View File
@@ -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<AktionsErgebnis> {
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." };
}
}
+90
View File
@@ -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<SchnellstartPflege> {
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<string, SchnellstartEintrag>(
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()],
};
}
+119
View File
@@ -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<void> {
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) },
},
});
});
}