feat(stammdaten): Mitarbeiter, Abteilungen und Geraete
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

Die Stammdaten, an denen die Historie haengt. Personen werden deaktiviert,
Geraete und Abteilungen archiviert — geloescht wird nichts, woran ein Lauf
haengen kann.

- Mitarbeiterliste mit Suche ueber Anzeigename, Mail, UPN und sAMAccountName,
  Filter nach Abteilung, standardmaessig nur aktive; die Suche ist ein eigenes
  Bauteil, weil #013 und #014 sie wieder brauchen
- Personenseite mit Stammdaten, Geraeten, Vorgesetztem; was aus dem Verzeichnis
  kommt, ist gesperrt — der naechste Abgleich wuerde es ohnehin zurueckdrehen
- Abteilungen unter Admin: Ziel-OU, Standardgruppen, Mail-Domain, dazu die
  Namen, die bei Personen vorkommen, aber nicht gepflegt sind
- Geraete mit Suche ueber Hostname, Bezeichnung, Seriennummer und Tag-Code;
  CSV-Import mit einem Ergebnis je Zeile
- Neues Recht mitarbeiterBearbeiten; die Seitenleiste fuehrt Personen und
  Geraete unter einem Eintrag, im Modus Dienstleistung anders beschriftet

Der unabhaengige Review fand zwei Blocker. Der schwerere meldete Erfolg: ein
nicht geschlossenes Anfuehrungszeichen verschluckte im CSV-Import alle
Folgezeilen bis zum naechsten `"`, und der Import sagte „3 von 3 Zeilen
angelegt". Ein Wert reicht jetzt nicht mehr ueber die Zeile hinaus — fuer
Hostnamen und Seriennummern ist ein Umbruch praktisch immer ein vergessenes
Anfuehrungszeichen, und so endet der Schaden an seiner Zeile. Der zweite:
ungepflegte Abteilungen wurden ueber alle Kunden hinweg gruppiert.

Dazu sieben weitere Befunde, alle behoben — darunter eine Besitzerauswahl, die
Personen fremder Kunden anbot, und ein Techniker, der ein Geraet endgueltig
loeschen konnte.

610 Tests gruen, 18 E2E-Ablaeufe gegen den gebauten Stand.

Schliesst #011

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-25 12:51:54 +02:00
co-authored by Claude Opus 5
parent e3d80e5982
commit 86d55917f9
81 changed files with 6784 additions and 59 deletions
+153
View File
@@ -0,0 +1,153 @@
"use client";
import { useActionState } from "react";
import { Lock } from "lucide-react";
import { Button } from "@/components/ui/button";
import { mitarbeiterBearbeitenAktion, type AktionsErgebnis } from "@/server/actions/mitarbeiter";
import { FELD } from "@/lib/formular";
export type StammdatenPerson = {
readonly id: string;
readonly vorname: string;
readonly nachname: string;
readonly anzeigename: string;
readonly mail: string | null;
readonly upn: string | null;
readonly sam: string | null;
readonly abteilung: string | null;
readonly titel: string | null;
/** Bei `true` kommen die Stammdaten aus dem Verzeichnis und sind gesperrt. */
readonly ausVerzeichnis: boolean;
};
/**
* Stammdaten bearbeiten – oder ansehen, wenn sie aus dem Verzeichnis kommen.
*
* Bei `herkunft = ABGLEICH` ist **jedes** gepflegte Feld gesperrt, nicht nur
* einige: alles daran wird im Verzeichnis gepflegt, und der naechste Abgleich
* wuerde eine Aenderung ohnehin zurueckdrehen. Die Felder werden trotzdem
* gezeigt — man will die Werte sehen —, nur eben schreibgeschuetzt. Ein
* ausgeblendetes Feld waere eine schlechtere Auskunft als ein gesperrtes.
*/
export function MitarbeiterStammdaten({
person,
darfBearbeiten,
}: {
person: StammdatenPerson;
darfBearbeiten: boolean;
}) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(
mitarbeiterBearbeitenAktion,
{},
);
/*
* Zwei verschiedene Gruende, dasselbe Ergebnis — aber nicht dieselbe Meldung.
* Wer die Rolle nicht hat, bekommt keinen Satz ueber das Verzeichnis zu lesen,
* der fuer ihn gar nicht der Grund ist.
*/
const gesperrt = person.ausVerzeichnis || !darfBearbeiten;
return (
<form action={aktion} className="flex flex-col gap-4">
<input type="hidden" name="mitarbeiterId" value={person.id} />
{gesperrt ? (
<div className="flex items-start gap-3 rounded-karte border border-linie bg-flaeche-hoch p-4">
<Lock
className="mt-0.5 size-[18px] shrink-0 text-nebentext"
strokeWidth={1.75}
aria-hidden="true"
/>
<p className="max-w-prose text-sm text-nebentext">
{person.ausVerzeichnis
? "Diese Person kommt aus dem Verzeichnis. Die Stammdaten werden dort gepflegt — eine Änderung hier würde der nächste Abgleich zurücksetzen. Deaktivieren lässt sie sich trotzdem."
: "Zum Ändern der Stammdaten fehlt dir die Berechtigung. Dafür braucht es die Rolle Techniker, Redakteur oder Administrator."}
</p>
</div>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Vorname</span>
<input name="vorname" required defaultValue={person.vorname} readOnly={gesperrt} className={FELD} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Nachname</span>
<input
name="nachname"
required
defaultValue={person.nachname}
readOnly={gesperrt}
className={FELD}
/>
</label>
</div>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Anzeigename</span>
<input name="anzeigename" defaultValue={person.anzeigename} readOnly={gesperrt} className={FELD} />
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Mail-Adresse</span>
<input
name="mail"
type="email"
defaultValue={person.mail ?? ""}
readOnly={gesperrt}
className={FELD}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Abteilung</span>
<input
name="abteilung"
defaultValue={person.abteilung ?? ""}
readOnly={gesperrt}
className={FELD}
/>
</label>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">UPN</span>
<input name="upn" defaultValue={person.upn ?? ""} readOnly={gesperrt} className={FELD} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">sAMAccountName</span>
<input
name="sam"
maxLength={20}
defaultValue={person.sam ?? ""}
readOnly={gesperrt}
className={`${FELD} mono`}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Titel</span>
<input name="titel" defaultValue={person.titel ?? ""} readOnly={gesperrt} className={FELD} />
</label>
</div>
{ergebnis.fehler ? (
<p role="alert" className="max-w-prose text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
{ergebnis.erfolg ? (
<p role="status" className="text-sm text-erledigt">
{ergebnis.erfolg}
</p>
) : null}
{gesperrt ? null : (
<Button
type="submit"
disabled={laeuft}
className="h-11 self-start rounded-pille px-5 text-sm font-semibold"
>
{laeuft ? "Wird gespeichert …" : "Stammdaten speichern"}
</Button>
)}
</form>
);
}