feat(vorlagen): Abschnitte nur fuer bestimmte Abteilungen (#58)
This commit was merged in pull request #58.
This commit is contained in:
@@ -3,6 +3,7 @@ import { Editor } from "@/components/vorlagen/editor";
|
||||
import { VersionStatus } from "@/generated/prisma/enums";
|
||||
import { darf } from "@/server/auth/rollen";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { abteilungsListe } from "@/server/queries/abteilungen";
|
||||
import { einsetzbareBausteine } from "@/server/queries/bausteine";
|
||||
import { versionMitAufbau } from "@/server/queries/vorlagen";
|
||||
|
||||
@@ -34,6 +35,17 @@ export default async function VersionSeite({ params }: { params: Promise<{ versi
|
||||
* und das waere eine Falschaussage ueber die Herkunft.
|
||||
*/
|
||||
const bausteine = await einsetzbareBausteine();
|
||||
/*
|
||||
* Die gepflegten Abteilungen fuer die Einschraenkung je Abschnitt (#031).
|
||||
* Namen und nicht Kennungen: der Abschnitt gehoert zur Version und muss ein
|
||||
* Umbenennen ueberleben. Dubletten ueber Kunden hinweg fallen weg — die
|
||||
* Einschraenkung gilt ueber den Namen.
|
||||
*/
|
||||
const abteilungen = [
|
||||
...new Set(
|
||||
(await abteilungsListe(benutzer)).filter((zeile) => !zeile.archiviert).map((zeile) => zeile.name),
|
||||
),
|
||||
].sort((links, rechts) => links.localeCompare(rechts, "de"));
|
||||
|
||||
return (
|
||||
<Editor
|
||||
@@ -50,6 +62,7 @@ export default async function VersionSeite({ params }: { params: Promise<{ versi
|
||||
lesend={veroeffentlicht || !darfBearbeiten}
|
||||
darfBearbeiten={darfBearbeiten}
|
||||
bausteine={bausteine}
|
||||
abteilungen={abteilungen}
|
||||
// Auf dem Server formatiert: im Browser waere es eine zweite Uhrzeit und
|
||||
// damit ein Unterschied zwischen Vorrendern und Hydrieren.
|
||||
beispielDatum={new Date().toLocaleDateString("de-DE", {
|
||||
|
||||
@@ -59,6 +59,9 @@ export function BausteinEditor({ entwurf }: { entwurf: BausteinEntwurf }) {
|
||||
abschnitte: [
|
||||
{
|
||||
titel: entwurf.titel,
|
||||
// Ein Baustein kennt keine Abteilungseinschränkung: die gehört zur
|
||||
// Vorlage, in der er landet (#031).
|
||||
nurFuerAbteilungen: [],
|
||||
schritte: entwurf.schritte.map((schritt) => ({
|
||||
titel: schritt.titel,
|
||||
...(schritt.hilfetext === null ? {} : { hilfetext: schritt.hilfetext }),
|
||||
@@ -91,11 +94,16 @@ export function BausteinEditor({ entwurf }: { entwurf: BausteinEntwurf }) {
|
||||
);
|
||||
|
||||
const jetzt = JSON.stringify(
|
||||
alsStruktur({ variablen: [], abschnitte: [{ kennung: "x", titel, schritte }] }).abschnitte[0],
|
||||
alsStruktur({
|
||||
variablen: [],
|
||||
abschnitte: [{ kennung: "x", titel, schritte, nurFuerAbteilungen: [] }],
|
||||
}).abschnitte[0],
|
||||
);
|
||||
const vorher = JSON.stringify(
|
||||
alsStruktur({ variablen: [], abschnitte: [anfang ?? { kennung: "x", titel: "", schritte: [] }] })
|
||||
.abschnitte[0],
|
||||
alsStruktur({
|
||||
variablen: [],
|
||||
abschnitte: [anfang ?? { kennung: "x", titel: "", schritte: [], nurFuerAbteilungen: [] }],
|
||||
}).abschnitte[0],
|
||||
);
|
||||
const geaendert = jetzt !== vorher;
|
||||
const voll = schritte.length >= GRENZEN.schritteJeAbschnitt;
|
||||
|
||||
@@ -51,6 +51,8 @@ export function LaufStarten({
|
||||
const [mitarbeiterId, setzeMitarbeiter] = useState(vorbelegung.mitarbeiterId ?? "");
|
||||
const [freitext, setzeFreitext] = useState("");
|
||||
const [kundeId, setzeKunde] = useState("");
|
||||
/** Nur fuer den Hinweis, welche Abschnitte die Abteilung hinzunimmt (#031). */
|
||||
const [abteilungId, setzeAbteilung] = useState("");
|
||||
const [variablen, setzeVariablen] = useState<Record<string, string>>({});
|
||||
|
||||
const vorlage = useMemo(
|
||||
@@ -58,6 +60,30 @@ export function LaufStarten({
|
||||
[vorlagen, versionId],
|
||||
);
|
||||
|
||||
/**
|
||||
* Welche Abschnitte die gewaehlte Abteilung hinzunimmt — und welche fuer
|
||||
* andere gelten und deshalb wegbleiben (#031).
|
||||
*
|
||||
* Verglichen wird wie im Dienst: ohne Ruecksicht auf Gross- und
|
||||
* Kleinschreibung. Wuerde hier anders verglichen als dort, zeigte der Dialog
|
||||
* etwas anderes an, als der Lauf danach enthaelt.
|
||||
*/
|
||||
const { zusatzAbschnitte, nurFuerAndere } = useMemo(() => {
|
||||
const eingeschraenkt = vorlage?.abteilungsAbschnitte ?? [];
|
||||
const name = auswahl.abteilungen
|
||||
.find((abteilung) => abteilung.id === abteilungId)
|
||||
?.name.trim()
|
||||
.toLocaleLowerCase("de");
|
||||
const passt = (abteilungen: readonly string[]): boolean =>
|
||||
name !== undefined && abteilungen.some((eintrag) => eintrag.trim().toLocaleLowerCase("de") === name);
|
||||
return {
|
||||
zusatzAbschnitte: eingeschraenkt
|
||||
.filter((abschnitt) => passt(abschnitt.abteilungen))
|
||||
.map((abschnitt) => abschnitt.titel),
|
||||
nurFuerAndere: eingeschraenkt.filter((abschnitt) => !passt(abschnitt.abteilungen)),
|
||||
};
|
||||
}, [vorlage, abteilungId, auswahl.abteilungen]);
|
||||
|
||||
// Nach dem Anlegen direkt in den Lauf – wer ihn startet, will ihn abarbeiten.
|
||||
useEffect(() => {
|
||||
if (ergebnis.laufId) router.push(`/laeufe/${ergebnis.laufId}`);
|
||||
@@ -284,7 +310,12 @@ export function LaufStarten({
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Abteilung</span>
|
||||
<select name="abteilungId" defaultValue="" className={FELD}>
|
||||
<select
|
||||
name="abteilungId"
|
||||
value={abteilungId}
|
||||
onChange={(ereignis) => setzeAbteilung(ereignis.target.value)}
|
||||
className={FELD}
|
||||
>
|
||||
<option value="">keine</option>
|
||||
{auswahl.abteilungen.map((abteilung) => (
|
||||
<option key={abteilung.id} value={abteilung.id}>
|
||||
@@ -292,6 +323,26 @@ export function LaufStarten({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/*
|
||||
Vorher sagen, was die Abteilung hinzunimmt (#031). Ohne das wäre
|
||||
die Zahl der Schritte eine Überraschung, und wer sich bei der
|
||||
Abteilung vertippt, merkt es erst in der Abarbeitung.
|
||||
*/}
|
||||
{zusatzAbschnitte.length > 0 ? (
|
||||
<span className="text-xs text-nebentext">
|
||||
Nimmt{" "}
|
||||
{zusatzAbschnitte.length === 1
|
||||
? "einen Abschnitt"
|
||||
: `${zusatzAbschnitte.length} Abschnitte`}{" "}
|
||||
hinzu: {zusatzAbschnitte.join(", ")}
|
||||
</span>
|
||||
) : nurFuerAndere.length > 0 ? (
|
||||
<span className="text-xs text-nebentext">
|
||||
{nurFuerAndere.length === 1
|
||||
? "Ein Abschnitt gilt nur für andere Abteilungen und bleibt weg."
|
||||
: `${nurFuerAndere.length} Abschnitte gelten nur für andere Abteilungen und bleiben weg.`}
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Zuweisen an</span>
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { KAESTCHEN } from "@/lib/formular";
|
||||
|
||||
/**
|
||||
* Fuer welche Abteilungen ein Abschnitt gilt (#031).
|
||||
*
|
||||
* Kein `<select multiple>`: das ist am Handy kaum bedienbar und zeigt nicht, was
|
||||
* gewaehlt ist, ohne zu scrollen. Stattdessen ein Kaestchen je gepflegter
|
||||
* Abteilung und darunter ein Freitextfeld — der Abschnitt haelt **Namen**, und
|
||||
* eine Abteilung, die es noch nicht gibt, soll sich schon nennen lassen.
|
||||
*
|
||||
* Ein Name, der gewaehlt ist, aber in keiner gepflegten Abteilung vorkommt,
|
||||
* erscheint als eigene Zeile mit Entfernen-Knopf. Sonst waere er unsichtbar und
|
||||
* beim naechsten Speichern still weg.
|
||||
*/
|
||||
export function AbteilungenWaehlen({
|
||||
gewaehlt,
|
||||
bekannte,
|
||||
lesend,
|
||||
setzen,
|
||||
}: {
|
||||
gewaehlt: readonly string[];
|
||||
bekannte: readonly string[];
|
||||
lesend: boolean;
|
||||
setzen: (namen: readonly string[]) => void;
|
||||
}) {
|
||||
const [eingabe, setzeEingabe] = useState("");
|
||||
|
||||
const kleinGewaehlt = new Set(gewaehlt.map((name) => name.trim().toLocaleLowerCase("de")));
|
||||
const fremde = gewaehlt.filter(
|
||||
(name) => !bekannte.some((bekannt) => bekannt.toLocaleLowerCase("de") === name.toLocaleLowerCase("de")),
|
||||
);
|
||||
|
||||
function umschalten(name: string, an: boolean): void {
|
||||
if (an) {
|
||||
if (kleinGewaehlt.has(name.trim().toLocaleLowerCase("de"))) return;
|
||||
setzen([...gewaehlt, name]);
|
||||
} else {
|
||||
setzen(
|
||||
gewaehlt.filter(
|
||||
(eintrag) => eintrag.trim().toLocaleLowerCase("de") !== name.trim().toLocaleLowerCase("de"),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function hinzufuegen(): void {
|
||||
const name = eingabe.trim();
|
||||
if (!name) return;
|
||||
setzeEingabe("");
|
||||
umschalten(name, true);
|
||||
}
|
||||
|
||||
return (
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="text-sm text-nebentext">Nur für diese Abteilungen</legend>
|
||||
<p className="max-w-prose text-sm text-nebentext">
|
||||
{gewaehlt.length === 0
|
||||
? "Keine Einschränkung — der Abschnitt gilt für jeden Lauf."
|
||||
: "Der Abschnitt erscheint nur in Läufen mit einer dieser Abteilungen. Ein Lauf ohne Abteilung bekommt ihn nicht."}
|
||||
</p>
|
||||
|
||||
{bekannte.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
{bekannte.map((name) => (
|
||||
<label key={name} className="flex min-h-11 items-center gap-2 text-sm md:min-h-9">
|
||||
<input
|
||||
type="checkbox"
|
||||
className={KAESTCHEN}
|
||||
disabled={lesend}
|
||||
checked={kleinGewaehlt.has(name.toLocaleLowerCase("de"))}
|
||||
onChange={(ereignis) => umschalten(name, ereignis.target.checked)}
|
||||
/>
|
||||
{name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{fremde.length > 0 ? (
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{fremde.map((name) => (
|
||||
<li
|
||||
key={name}
|
||||
className="inline-flex items-center gap-1 rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs"
|
||||
>
|
||||
{name}
|
||||
{lesend ? null : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => umschalten(name, false)}
|
||||
aria-label={`${name} entfernen`}
|
||||
className="text-nebentext hover:text-fehler"
|
||||
>
|
||||
<X className="size-3.5" strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{lesend ? null : (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={eingabe}
|
||||
onChange={(ereignis) => setzeEingabe(ereignis.target.value)}
|
||||
onKeyDown={(ereignis) => {
|
||||
// Enter fügt hinzu, statt das Formular abzuschicken – der Editor
|
||||
// speichert über einen eigenen Knopf.
|
||||
if (ereignis.key === "Enter") {
|
||||
ereignis.preventDefault();
|
||||
hinzufuegen();
|
||||
}
|
||||
}}
|
||||
placeholder="Abteilung, die es noch nicht gibt"
|
||||
aria-label="Weitere Abteilung"
|
||||
className="min-h-11 min-w-0 flex-1 rounded-karte border border-linie bg-grund px-3 text-sm outline-none focus-visible:border-akzent md:min-h-9"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={hinzufuegen}
|
||||
disabled={eingabe.trim() === ""}
|
||||
className="flex min-h-11 items-center gap-1.5 rounded-pille border border-linie px-3 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text disabled:opacity-40 md:min-h-9"
|
||||
>
|
||||
<Plus className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||
hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Blocks } from "lucide-react";
|
||||
import { AbteilungenWaehlen } from "@/components/vorlagen/abteilungen-waehlen";
|
||||
import { KlappKarte, LoeschKnopf, TextKnopf, Textfeld } from "@/components/vorlagen/editor-felder";
|
||||
import { SortierBereich, SortierZeile, VerschiebeKnoepfe } from "@/components/vorlagen/editor-sortieren";
|
||||
import { SchrittKarte } from "@/components/vorlagen/editor-schritt";
|
||||
@@ -30,6 +31,7 @@ export function AbschnittKarte({
|
||||
index,
|
||||
anzahl,
|
||||
lesend,
|
||||
abteilungen,
|
||||
zuletztAngelegt,
|
||||
anfasser,
|
||||
aendern,
|
||||
@@ -44,6 +46,8 @@ export function AbschnittKarte({
|
||||
index: number;
|
||||
anzahl: number;
|
||||
lesend: boolean;
|
||||
/** Die gepflegten Abteilungsnamen – für die Einschränkung (#031). */
|
||||
abteilungen: readonly string[];
|
||||
/** Kennung der zuletzt angelegten Zeile — die wird aufgeklappt gezeigt. */
|
||||
zuletztAngelegt: string | null;
|
||||
anfasser: React.ReactNode;
|
||||
@@ -83,6 +87,11 @@ export function AbschnittKarte({
|
||||
Baustein
|
||||
</span>
|
||||
) : null}
|
||||
{abschnitt.nurFuerAbteilungen.length > 0 ? (
|
||||
<span className="shrink-0 rounded-pille bg-flaeche-hoch px-2.5 py-0.5 text-xs font-normal text-nebentext">
|
||||
nur {abschnitt.nurFuerAbteilungen.join(", ")}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
knoepfe={
|
||||
@@ -139,6 +148,17 @@ export function AbschnittKarte({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/*
|
||||
Die Einschraenkung gehoert zur Vorlage und nicht zum Baustein — deshalb
|
||||
steht sie auch bei einem Baustein-Abschnitt (#031).
|
||||
*/}
|
||||
<AbteilungenWaehlen
|
||||
gewaehlt={abschnitt.nurFuerAbteilungen}
|
||||
bekannte={abteilungen}
|
||||
lesend={lesend}
|
||||
setzen={(nurFuerAbteilungen) => aendern({ nurFuerAbteilungen })}
|
||||
/>
|
||||
|
||||
{baustein ? null : anzahlSchritte === 0 ? (
|
||||
<p className="text-sm text-nebentext">Noch kein Schritt in diesem Abschnitt.</p>
|
||||
) : (
|
||||
|
||||
@@ -83,6 +83,7 @@ export function Editor({
|
||||
darfBearbeiten,
|
||||
beispielDatum,
|
||||
bausteine = [],
|
||||
abteilungen = [],
|
||||
}: {
|
||||
version: EditorVersion;
|
||||
lesend: boolean;
|
||||
@@ -91,6 +92,8 @@ export function Editor({
|
||||
beispielDatum: string;
|
||||
/** Veröffentlichte Bausteine, die sich einsetzen lassen (#030). */
|
||||
bausteine?: readonly EinsetzbarerBaustein[];
|
||||
/** Gepflegte Abteilungsnamen – für die Einschränkung je Abschnitt (#031). */
|
||||
abteilungen?: readonly string[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [aufbau, setzeAufbau] = useState<EditorAufbau>(() =>
|
||||
@@ -336,6 +339,7 @@ export function Editor({
|
||||
abschnitt={abschnitt}
|
||||
index={index}
|
||||
anzahl={aufbau.abschnitte.length}
|
||||
abteilungen={abteilungen}
|
||||
lesend={lesend}
|
||||
zuletztAngelegt={zuletztAngelegt}
|
||||
anfasser={anfasser}
|
||||
|
||||
@@ -41,6 +41,8 @@ export type EditorAbschnitt = {
|
||||
readonly kennung: string;
|
||||
readonly titel: string;
|
||||
readonly schritte: readonly EditorSchritt[];
|
||||
/** Nur fuer diese Abteilungen; leer heisst fuer alle (#031). */
|
||||
readonly nurFuerAbteilungen: readonly string[];
|
||||
/**
|
||||
* Gesetzt, wenn dieser Abschnitt ein eingesetzter **Baustein** ist (#030).
|
||||
*
|
||||
@@ -216,7 +218,7 @@ export function neuerSchritt(): EditorSchritt {
|
||||
}
|
||||
|
||||
export function neuerAbschnitt(): EditorAbschnitt {
|
||||
return { kennung: neueKennung("abschnitt"), titel: "", schritte: [] };
|
||||
return { kennung: neueKennung("abschnitt"), titel: "", schritte: [], nurFuerAbteilungen: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,6 +238,9 @@ export function neuerBausteinAbschnitt(baustein: {
|
||||
kennung: neueKennung("abschnitt"),
|
||||
titel: baustein.titel,
|
||||
baustein: { versionId: baustein.bausteinVersionId, name: baustein.name, version: baustein.version },
|
||||
// Ein Baustein-Abschnitt laesst sich einschraenken wie jeder andere: die
|
||||
// Einschraenkung gehoert zur Vorlage, nicht zum Baustein.
|
||||
nurFuerAbteilungen: [],
|
||||
// Platzhalter, damit die Anzahl stimmt. Gespeichert werden sie nicht.
|
||||
schritte: Array.from({ length: baustein.anzahlSchritte }, () => neuerSchritt()),
|
||||
};
|
||||
@@ -302,6 +307,7 @@ export function alsEditorAufbau(struktur: Struktur): EditorAufbau {
|
||||
kennung: neueKennung("abschnitt"),
|
||||
// Die Nummer gehoert zur Position, nicht in den Titel — siehe titelOhneNummer.
|
||||
titel: titelOhneNummer(abschnitt.titel),
|
||||
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
|
||||
// Den Namen und die Fassung kennt die Struktur nicht; die Seite reicht sie
|
||||
// getrennt nach und setzt sie ueber `bausteineAnreichern` ein (#030).
|
||||
...(abschnitt.bausteinVersionId === undefined
|
||||
@@ -341,6 +347,11 @@ export function alsStruktur(aufbau: EditorAufbau): Struktur {
|
||||
abschnitte: aufbau.abschnitte.map((abschnitt) => ({
|
||||
titel: abschnitt.titel,
|
||||
...(abschnitt.baustein === undefined ? {} : { bausteinVersionId: abschnitt.baustein.versionId }),
|
||||
// Leere Namen und Dubletten fallen weg: beides entsteht beim Tippen im
|
||||
// Freitextfeld und bedeutet dasselbe wie "nicht genannt".
|
||||
nurFuerAbteilungen: [
|
||||
...new Set(abschnitt.nurFuerAbteilungen.map((name) => name.trim()).filter(Boolean)),
|
||||
],
|
||||
/*
|
||||
* Ein Baustein-Abschnitt gibt **keine** Schritte mit: sie kommen beim
|
||||
* Veroeffentlichen aus dem Baustein. Die Schritte im Zustand sind nur
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Welche Abschnitte fuer einen Lauf gelten (#031).
|
||||
*
|
||||
* Framework-frei und ohne Prisma, weil **drei** Schichten sie brauchen: der
|
||||
* Dienst beim Start und beim Abschluss, und die Lesemodelle der Abarbeitung und
|
||||
* der Druckansicht. Lebte sie in einem davon, importierte ein Dienst aus einem
|
||||
* Lesemodell — eine Abhaengigkeit in die falsche Richtung (CLAUDE.md).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Die Auswahl zum Zeitpunkt des Starts.
|
||||
*
|
||||
* Ein Abschnitt ohne Einschraenkung gilt immer. Einer mit Einschraenkung gilt,
|
||||
* wenn die Abteilung des Laufs darin vorkommt — ein Lauf **ohne** Abteilung
|
||||
* bekommt deshalb nur die uneingeschraenkten, und das ist kein Mangel: ein
|
||||
* Firewall-Rollout hat keine Abteilung.
|
||||
*
|
||||
* Verglichen wird ohne Ruecksicht auf Gross- und Kleinschreibung und mit
|
||||
* getrimmten Raendern. Die Namen kommen aus zwei Quellen — der gepflegten
|
||||
* `Abteilung` und dem Freitextfeld im Editor —, und „buchhaltung“ ist dieselbe
|
||||
* Abteilung wie „Buchhaltung“.
|
||||
*/
|
||||
export function geltendeAbschnitteErmitteln(
|
||||
abschnitte: readonly { readonly id: string; readonly nurFuerAbteilungen: readonly string[] }[],
|
||||
abteilungsName: string | null,
|
||||
): readonly string[] {
|
||||
const gesucht = abteilungsName?.trim().toLocaleLowerCase("de") ?? null;
|
||||
return abschnitte
|
||||
.filter((abschnitt) => {
|
||||
if (abschnitt.nurFuerAbteilungen.length === 0) return true;
|
||||
if (gesucht === null) return false;
|
||||
return abschnitt.nurFuerAbteilungen.some((name) => name.trim().toLocaleLowerCase("de") === gesucht);
|
||||
})
|
||||
.map((abschnitt) => abschnitt.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Abschnitte einer Version auf die eines Laufs eindampfen.
|
||||
*
|
||||
* `geltendeAbschnitte` ist beim Start festgeschrieben. **Leer heisst alle** — so
|
||||
* verhalten sich Laeufe von vor dieser Fassung unveraendert, ohne dass eine
|
||||
* Migration Daten nachfuehren muesste.
|
||||
*/
|
||||
export function nurGeltende<T extends { readonly id: string }>(
|
||||
abschnitte: readonly T[],
|
||||
geltendeAbschnitte: readonly string[],
|
||||
): readonly T[] {
|
||||
if (geltendeAbschnitte.length === 0) return abschnitte;
|
||||
const erlaubt = new Set(geltendeAbschnitte);
|
||||
return abschnitte.filter((abschnitt) => erlaubt.has(abschnitt.id));
|
||||
}
|
||||
@@ -107,6 +107,18 @@ export const AbschnittSchema = z.object({
|
||||
* die sich nicht veroeffentlichen laesst.
|
||||
*/
|
||||
bausteinVersionId: leerAlsUndefiniert(z.string().trim().max(60)),
|
||||
/**
|
||||
* Nur fuer diese Abteilungen (#031). Leer heisst: fuer alle.
|
||||
*
|
||||
* **Namen und nicht Kennungen** — der Abschnitt gehoert zur Version und muss
|
||||
* ein Umbenennen oder Archivieren einer Abteilung ueberleben. Deshalb wandert
|
||||
* das Feld auch durch Export und Import: ein Abteilungsname bedeutet in einer
|
||||
* anderen Installation dasselbe, eine Kennung nicht.
|
||||
*/
|
||||
nurFuerAbteilungen: z
|
||||
.array(z.string().trim().min(1).max(200))
|
||||
.max(50, "Höchstens 50 Abteilungen je Abschnitt.")
|
||||
.default([]),
|
||||
schritte: z
|
||||
.array(SchrittSchema)
|
||||
.max(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { nurGeltende } from "@/lib/geltende-abschnitte";
|
||||
import { platzhalterErsetzen } from "@/lib/platzhalter-werte";
|
||||
import { kundenEbeneSichtbar, sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
|
||||
import { kommentareDesLaufs, type KommentarZeile } from "@/server/queries/laeufe";
|
||||
@@ -163,6 +164,8 @@ export async function laufZumDrucken(
|
||||
zugewiesenAn: { select: { name: true } },
|
||||
abgeschlossenVon: { select: { name: true } },
|
||||
archiviertVon: { select: { name: true } },
|
||||
// Beim Start festgeschriebene Abschnittsauswahl (#031).
|
||||
geltendeAbschnitte: true,
|
||||
vorlagenVersion: {
|
||||
select: {
|
||||
version: true,
|
||||
@@ -219,7 +222,9 @@ export async function laufZumDrucken(
|
||||
let anzahlSchritte = 0;
|
||||
let anzahlVomTisch = 0;
|
||||
|
||||
const abschnitte = lauf.vorlagenVersion.abschnitte.map((abschnitt, index) => {
|
||||
// Dieselbe Auswahl wie in der Abarbeitung – nicht mehr und nicht weniger (#031).
|
||||
const geltende = nurGeltende(lauf.vorlagenVersion.abschnitte, lauf.geltendeAbschnitte);
|
||||
const abschnitte = geltende.map((abschnitt, index) => {
|
||||
const schritte = abschnitt.schritte.map((schritt) => {
|
||||
nummer += 1;
|
||||
anzahlSchritte += 1;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { nurGeltende } from "@/lib/geltende-abschnitte";
|
||||
import { platzhalterErsetzen } from "@/lib/platzhalter-werte";
|
||||
import { platzhalterAusserhalbAnfuehrungszeichen, skriptBauen, wertMaskieren, type Shell } from "@/lib/shell";
|
||||
import { STANDARD_SHELL } from "@/lib/editor-aufbau";
|
||||
@@ -236,6 +237,8 @@ export async function laufZumAbarbeiten(
|
||||
const lauf = await prisma.lauf.findUniqueOrThrow({
|
||||
where: { id: laufId },
|
||||
select: {
|
||||
// Beim Start festgeschriebene Abschnittsauswahl (#031).
|
||||
geltendeAbschnitte: true,
|
||||
vorlagenVersion: {
|
||||
select: {
|
||||
abschnitte: {
|
||||
@@ -294,7 +297,8 @@ export async function laufZumAbarbeiten(
|
||||
let anzahlPflicht = 0;
|
||||
let anzahlPflichtOffen = 0;
|
||||
|
||||
const abschnitte = lauf.vorlagenVersion.abschnitte.map((abschnitt, index) => {
|
||||
const geltende = nurGeltende(lauf.vorlagenVersion.abschnitte, lauf.geltendeAbschnitte);
|
||||
const abschnitte = geltende.map((abschnitt, index) => {
|
||||
const befehle: { titel: string; shell: Shell; befehl: string }[] = [];
|
||||
|
||||
const schritte = abschnitt.schritte.map((schritt) => {
|
||||
@@ -599,6 +603,17 @@ export type StartbareVorlage = {
|
||||
readonly optionen: readonly string[];
|
||||
readonly pflicht: boolean;
|
||||
}[];
|
||||
/**
|
||||
* Abschnitte, die nur fuer bestimmte Abteilungen gelten (#031).
|
||||
*
|
||||
* Der Start-Dialog sagt damit **vorher**, was die gewaehlte Abteilung
|
||||
* hinzunimmt. Ohne das waere die Zahl der Schritte eine Ueberraschung, und wer
|
||||
* sich bei der Abteilung vertippt, merkt es erst in der Abarbeitung.
|
||||
*/
|
||||
readonly abteilungsAbschnitte: readonly {
|
||||
readonly titel: string;
|
||||
readonly abteilungen: readonly string[];
|
||||
}[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -641,6 +656,11 @@ export async function startbareVorlagen(benutzer: HandelnderBenutzer): Promise<r
|
||||
pflicht: true,
|
||||
},
|
||||
},
|
||||
abschnitte: {
|
||||
where: { NOT: { nurFuerAbteilungen: { isEmpty: true } } },
|
||||
orderBy: { position: "asc" },
|
||||
select: { titel: true, nurFuerAbteilungen: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -659,6 +679,10 @@ export async function startbareVorlagen(benutzer: HandelnderBenutzer): Promise<r
|
||||
version: version.version,
|
||||
kategorie: vorlage.kategorie,
|
||||
variablen: version.variablen,
|
||||
abteilungsAbschnitte: version.abschnitte.map((abschnitt) => ({
|
||||
titel: abschnitt.titel,
|
||||
abteilungen: abschnitt.nurFuerAbteilungen,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return startbare;
|
||||
|
||||
@@ -266,6 +266,8 @@ export async function versionMitAufbau(
|
||||
})),
|
||||
abschnitte: version.abschnitte.map((abschnitt) => ({
|
||||
titel: abschnitt.titel,
|
||||
nurFuerAbteilungen: abschnitt.nurFuerAbteilungen,
|
||||
...(abschnitt.bausteinVersionId === null ? {} : { bausteinVersionId: abschnitt.bausteinVersionId }),
|
||||
schritte: abschnitt.schritte.map((schritt) => ({
|
||||
titel: schritt.titel,
|
||||
...(schritt.hilfetext === null ? {} : { hilfetext: schritt.hilfetext }),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Prisma, type Lauf } from "@/generated/prisma/client";
|
||||
import { LaufStatus, SchrittZustand, VariablenTyp } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { geltendeAbschnitteErmitteln, nurGeltende } from "@/lib/geltende-abschnitte";
|
||||
import { darf } from "@/server/auth/rollen";
|
||||
import {
|
||||
MandantFehler,
|
||||
@@ -190,6 +191,13 @@ type VersionFuerLauf = {
|
||||
readonly pflicht: boolean;
|
||||
}[];
|
||||
readonly schrittIds: readonly string[];
|
||||
/** Fuer die Auswahl nach Abteilung beim Start (#031). */
|
||||
readonly abschnitte: readonly {
|
||||
readonly id: string;
|
||||
readonly titel: string;
|
||||
readonly nurFuerAbteilungen: readonly string[];
|
||||
readonly schrittIds: readonly string[];
|
||||
}[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -219,7 +227,12 @@ async function versionFuerLaufHolen(versionId: string, kundeId: string): Promise
|
||||
},
|
||||
abschnitte: {
|
||||
orderBy: { position: "asc" },
|
||||
select: { schritte: { orderBy: { position: "asc" }, select: { id: true } } },
|
||||
select: {
|
||||
id: true,
|
||||
titel: true,
|
||||
nurFuerAbteilungen: true,
|
||||
schritte: { orderBy: { position: "asc" }, select: { id: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -238,6 +251,12 @@ async function versionFuerLaufHolen(versionId: string, kundeId: string): Promise
|
||||
vorlagenName: version.vorlage.name,
|
||||
variablen: version.variablen,
|
||||
schrittIds: version.abschnitte.flatMap((abschnitt) => abschnitt.schritte.map((s) => s.id)),
|
||||
abschnitte: version.abschnitte.map((abschnitt) => ({
|
||||
id: abschnitt.id,
|
||||
titel: abschnitt.titel,
|
||||
nurFuerAbteilungen: abschnitt.nurFuerAbteilungen,
|
||||
schrittIds: abschnitt.schritte.map((schritt) => schritt.id),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -246,6 +265,8 @@ type Bezuege = {
|
||||
readonly mitarbeiterName: string | null;
|
||||
readonly geraetId: string | null;
|
||||
readonly abteilungId: string | null;
|
||||
/** Name der gewaehlten Abteilung – fuer die Abschnittsauswahl (#031). */
|
||||
readonly abteilungsName: string | null;
|
||||
/** Nur fuer den Titelvorschlag. */
|
||||
readonly geraetName: string | null;
|
||||
readonly personName: string | null;
|
||||
@@ -310,6 +331,7 @@ async function bezuegePruefen(
|
||||
}
|
||||
|
||||
const abteilungId = text(eingabe.abteilungId);
|
||||
let abteilungsName: string | null = null;
|
||||
if (abteilungId) {
|
||||
const abteilung = await prisma.abteilung.findUnique({
|
||||
where: { id: abteilungId },
|
||||
@@ -322,9 +344,18 @@ async function bezuegePruefen(
|
||||
if (abteilung.archiviert) {
|
||||
throw new LaufFehler(`Die Abteilung „${abteilung.name}“ ist archiviert.`);
|
||||
}
|
||||
abteilungsName = abteilung.name;
|
||||
}
|
||||
|
||||
return { mitarbeiterId, mitarbeiterName, geraetId, abteilungId, geraetName, personName };
|
||||
return {
|
||||
mitarbeiterId,
|
||||
mitarbeiterName,
|
||||
geraetId,
|
||||
abteilungId,
|
||||
abteilungsName,
|
||||
geraetName,
|
||||
personName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -441,6 +472,28 @@ export async function laufAnlegen(eingabe: NeuerLauf, benutzer: HandelnderBenutz
|
||||
person: bezuege.personName,
|
||||
});
|
||||
|
||||
/*
|
||||
* Die Abschnittsauswahl **jetzt** festlegen und am Lauf festhalten (#031).
|
||||
* Danach aendert sie sich nie wieder: ein spaeterer Abteilungswechsel darf
|
||||
* keinen Schritt entfernen, an dem schon eine Notiz haengt, und ein
|
||||
* archivierter Lauf muss genau das zeigen, was damals abgearbeitet wurde.
|
||||
*/
|
||||
const geltende = geltendeAbschnitteErmitteln(version.abschnitte, bezuege.abteilungsName);
|
||||
if (version.abschnitte.length > 0 && geltende.length === 0) {
|
||||
throw new LaufFehler(
|
||||
bezuege.abteilungsName === null
|
||||
? `Von „${version.vorlagenName}“ gilt ohne Abteilung kein Abschnitt — jeder ist auf ` +
|
||||
"bestimmte Abteilungen eingeschränkt. Wähle eine Abteilung."
|
||||
: `Von „${version.vorlagenName}“ gilt für die Abteilung „${bezuege.abteilungsName}“ kein ` +
|
||||
"Abschnitt. Es gäbe nichts abzuarbeiten.",
|
||||
);
|
||||
}
|
||||
const geltendeSchrittIds = new Set(
|
||||
version.abschnitte
|
||||
.filter((abschnitt) => geltende.includes(abschnitt.id))
|
||||
.flatMap((abschnitt) => abschnitt.schrittIds),
|
||||
);
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const lauf = await tx.lauf.create({
|
||||
data: {
|
||||
@@ -457,12 +510,17 @@ export async function laufAnlegen(eingabe: NeuerLauf, benutzer: HandelnderBenutz
|
||||
zugewiesenAnId,
|
||||
faelligAm: eingabe.faelligAm ?? null,
|
||||
erstelltVonId: benutzer.id,
|
||||
geltendeAbschnitte: [...geltende],
|
||||
},
|
||||
});
|
||||
|
||||
if (version.schrittIds.length > 0) {
|
||||
// Nur fuer die geltenden Abschnitte. Ein Schrittergebnis zu einem
|
||||
// ausgefilterten Schritt waere eine Zeile, die niemand je sieht — und sie
|
||||
// zaehlte in jeder Statistik mit.
|
||||
const anzulegen = version.schrittIds.filter((id) => geltendeSchrittIds.has(id));
|
||||
if (anzulegen.length > 0) {
|
||||
await tx.schrittergebnis.createMany({
|
||||
data: version.schrittIds.map((vorlagenSchrittId) => ({
|
||||
data: anzulegen.map((vorlagenSchrittId) => ({
|
||||
laufId: lauf.id,
|
||||
vorlagenSchrittId,
|
||||
zustand: SchrittZustand.OFFEN,
|
||||
@@ -605,11 +663,13 @@ export async function offenePflichtschritte(
|
||||
const lauf = await tx.lauf.findUniqueOrThrow({
|
||||
where: { id: laufId },
|
||||
select: {
|
||||
geltendeAbschnitte: true,
|
||||
vorlagenVersion: {
|
||||
select: {
|
||||
abschnitte: {
|
||||
orderBy: { position: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
titel: true,
|
||||
schritte: {
|
||||
where: { pflicht: true },
|
||||
@@ -626,7 +686,12 @@ export async function offenePflichtschritte(
|
||||
|
||||
const zustaende = new Map(lauf.ergebnisse.map((e) => [e.vorlagenSchrittId, e.zustand]));
|
||||
const offen: OffenerPflichtschritt[] = [];
|
||||
for (const abschnitt of lauf.vorlagenVersion.abschnitte) {
|
||||
/*
|
||||
* Nur die geltenden Abschnitte (#031). Ein ausgefilterter Pflichtschritt darf
|
||||
* den Abschluss nicht blockieren — sonst waere er unerreichbar: abhaken laesst
|
||||
* sich nur, was in der Abarbeitung steht.
|
||||
*/
|
||||
for (const abschnitt of nurGeltende(lauf.vorlagenVersion.abschnitte, lauf.geltendeAbschnitte)) {
|
||||
for (const schritt of abschnitt.schritte) {
|
||||
// Ohne Ergebniszeile gilt OFFEN – ein Schritt ohne Eintrag ist nicht erledigt.
|
||||
const zustand = zustaende.get(schritt.id) ?? SchrittZustand.OFFEN;
|
||||
|
||||
@@ -70,6 +70,9 @@ export async function vorlageExportieren(versionId: string): Promise<Austausch>
|
||||
})),
|
||||
abschnitte: version.abschnitte.map((abschnitt) => ({
|
||||
titel: abschnitt.titel,
|
||||
// Wandert mit: ein Abteilungsname bedeutet in einer anderen Installation
|
||||
// dasselbe. Der Baustein-Verweis wandert bewusst **nicht** mit (#030).
|
||||
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
|
||||
schritte: abschnitt.schritte.map(schrittAbbilden),
|
||||
})),
|
||||
},
|
||||
|
||||
@@ -533,6 +533,7 @@ export async function entwurfSpeichern(
|
||||
// Ein Trigger laesst nur veroeffentlichte Baustein-Versionen zu; der
|
||||
// Dienst prueft dasselbe mit besserer Meldung (#030).
|
||||
bausteinVersionId: abschnitt.bausteinVersionId ?? null,
|
||||
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
|
||||
},
|
||||
});
|
||||
if (abschnitt.schritte.length > 0) {
|
||||
|
||||
Reference in New Issue
Block a user