Files
Laufzettel/src/components/vorlagen/abteilungen-waehlen.tsx
T
Florian Wilde b8166e10ea
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
feat(vorlagen): Abschnitte nur fuer bestimmte Abteilungen
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
2026-10-04 20:04:14 +02:00

138 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}