feat(vorlagen): Abschnitte nur fuer bestimmte Abteilungen
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

Beim Onboarding in der Buchhaltung gehoeren Schritte dazu, die sonst niemand
braucht: DATEV, Drucker fuer Formulare, Bankportal. Bisher hiess die Wahl: eine
Vorlage je Abteilung pflegen, oder Schritte, die fuer die meisten "nicht
zutreffend" sind.

Jeder Abschnitt kann jetzt nennen, fuer welche Abteilungen er gilt -- als
**Namen**, nicht als Kennungen. Der Abschnitt gehoert zur Version und muss ein
Umbenennen oder Archivieren einer Abteilung ueberleben; dieselbe Entscheidung
wie bei Mitarbeiter.abteilung. Deshalb wandert die Einschraenkung auch durch
Export und Import.

Welche Abschnitte ein Lauf hat, wird beim Start festgeschrieben. Ein spaeterer
Abteilungswechsel aendert ihn nicht -- sonst verschwaende ein Schritt, an dem
schon eine Notiz haengt, und ein archivierter Lauf muss genau das zeigen, was
damals abgearbeitet wurde.

Leer heisst alle: Laeufe von vor dieser Fassung verhalten sich unveraendert,
ohne dass eine Migration Daten nachfuehren muesste. Damit das eindeutig bleibt,
wird ein Lauf abgewiesen, fuer den kein Abschnitt uebrig bleibt -- es gaebe
ohnehin nichts abzuarbeiten.

Die Abschlussregel zaehlt nur geltende Pflichtschritte. Vorher waere der
Abschluss unerreichbar gewesen: abhaken laesst sich nur, was in der Abarbeitung
steht. Der Test hat das sofort gezeigt.

Die Regel liegt in src/lib, weil drei Schichten sie brauchen -- der Dienst beim
Start und beim Abschluss, die Lesemodelle der Abarbeitung und des Drucks. In
einem Lesemodell waere sie eine Abhaengigkeit in die falsche Richtung.

Im gebauten Stand durchgeklickt: einschraenken, speichern, veroeffentlichen,
zwei Laeufe mit verschiedenen Abteilungen starten -- 6 Abschnitte mit 23
Schritten fuer die Buchhaltung, 5 mit 20 fuer den Vertrieb. Dabei einen eigenen
Numerusfehler im Hinweistext gefunden und behoben.

Closes #51
This commit is contained in:
Florian Wilde
2026-10-04 20:04:14 +02:00
parent 80870133b8
commit b8166e10ea
24 changed files with 815 additions and 37 deletions
@@ -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>
);
}