Kleinigkeiten zuerst: "Gelaufen" heisst jetzt "Vergangen", die Kachel "Langzeitabwesend" heisst "Langzeitabwesende", "Eintritte/Austritte (Jahr)" heissen "(YTD)" -- gezaehlt wurde ohnehin seit Jahresbeginn. "Personenkreis" heisst "Grund". Der Hinweis unter dem Grad der Behinderung und der Erklaertext ueber dem Honestly-Report sind weg. "Gehaltsanpassung" steht nicht mehr im Filter des Protokolls: das Gehalt ist aus dem Funktionsumfang, keine Funktion schreibt die Art mehr, und ein Filter, der immer leer ausgeht, sieht aus wie ein Fehler. Im Fenster fuer den Notfallkontakt faellt "Wirksam ab" weg -- eine Telefonnummer fuer den Ernstfall gilt ab sofort. In "Daten aendern" wandert der Block unter die Angehoerigen, dieselbe Reihenfolge wie im Reiter "Stammdaten". Auf der Uebersicht fuehren die Namen unter "Letzte Aktivitaeten" in die Akte; vorher war die Karte eine Sackgasse. "Eintrag berichtigen" bot fuer jedes Feld ein Textfeld an, auch fuer "Notfallkontakt Verhaeltnis", wo die Erfassung sonst eine Liste fuehrt. Wer dort "Gattin" statt "Gattin/Gatte" tippte, erzeugte einen Wert, den keine Auswertung mehr findet -- die Berichtigung schreibt in dieselbe Spalte wie das Formular, nur ohne dessen Pruefung. Welche Felder eine Liste bekommen, steht in lib/historie-felder.ts; ein Test prueft jeden Schluessel gegen app_feld_karte(), damit ein Tippfehler dort nicht still auf ein Textfeld zurueckfaellt. Felder mit Aufzaehlungstyp bleiben bewusst aussen vor: dort ist der gespeicherte Wert nicht die Anzeige. Und die Antwort auf "wo sieht man die Anmerkung beim Austritt?": nirgends. Das Formular sammelte sie ein, terminate_employee liess sie fallen. Bis 20260814100000 stand sie in der Beschreibung des Ereignisses; beim Umschreiben fuer den Nichtantritt ging sie verloren, und die Migration fuer die Austrittsart reichte die verkuerzte Fassung weiter. Sie steht jetzt wieder in der Personalakte und im Protokoll, und die Selbstpruefung faengt den naechsten Verlust ab.
340 lines
16 KiB
TypeScript
340 lines
16 KiB
TypeScript
import { render as rtlRender, screen, within } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { HistorieTab } from "@/components/employees/tabs/HistorieTab";
|
|
import { ToastProvider } from "@/components/ui/Toast";
|
|
import type { Database } from "@/lib/types";
|
|
|
|
// Der Löschknopf hängt an einer Server-Action, und die zieht über lib/db das
|
|
// Paket `server-only` nach — im Test ein Fehler beim Import. Ersetzt wird
|
|
// deshalb das Aktionsmodul, nicht die Komponente: geprüft wird hier, was die
|
|
// Historie *anzeigt*, nicht was die Datenbank tut. Das steht in
|
|
// tests/unit/history.test.ts und in der Migration selbst.
|
|
vi.mock("@/actions/employees", () => ({
|
|
deleteHistoryEntry: vi.fn(async () => ({ success: true })),
|
|
updateHistoryEntry: vi.fn(async () => ({ success: true })),
|
|
}));
|
|
|
|
// Ausserhalb von Next gibt es keinen Router; die Komponente ruft nach dem
|
|
// Löschen refresh() auf.
|
|
vi.mock("next/navigation", () => ({
|
|
useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }),
|
|
}));
|
|
|
|
function render(ui: React.ReactElement) {
|
|
return rtlRender(<ToastProvider>{ui}</ToastProvider>);
|
|
}
|
|
|
|
type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"];
|
|
|
|
// Der Anlass: eine Adressänderung war in der Historie der Person nur als
|
|
// „Geänderte Felder: Adresse" zu sehen. Die alte Anschrift stand allein im
|
|
// Protokoll — einer nach Zeitpunkt sortierten Seite, auf der man erst wissen
|
|
// muss, wonach man sucht. Damit war nicht nachvollziehbar, ob und wie sich
|
|
// eine Adresse je geändert hat.
|
|
|
|
function eintrag(teil: Partial<HistoryRow>): HistoryRow {
|
|
return {
|
|
id: "h1",
|
|
employee_id: "e1",
|
|
event_date: "2026-03-02",
|
|
event_type: "Stammdatenänderung",
|
|
description: "Geänderte Felder: Adresse, Ort, wirksam ab 2026-03-02",
|
|
changes: null,
|
|
pending_id: null,
|
|
created_at: "2026-03-02T08:00:00.000Z",
|
|
...teil,
|
|
};
|
|
}
|
|
|
|
const ADRESSWECHSEL = eintrag({
|
|
changes: [
|
|
{ feld: "Adresse", vorher: "Feldweg 66", nachher: "Bahnhofstrasse 5" },
|
|
{ feld: "Ort", vorher: "Brno", nachher: "Linz" },
|
|
],
|
|
});
|
|
|
|
describe("HistorieTab", () => {
|
|
it("zeigt die alte Anschrift, nicht nur die neue", () => {
|
|
render(<HistorieTab history={[ADRESSWECHSEL]} employeeId="e1" />);
|
|
expect(screen.getByText("Feldweg 66")).toBeInTheDocument();
|
|
expect(screen.getByText("Bahnhofstrasse 5")).toBeInTheDocument();
|
|
});
|
|
|
|
it("stellt Vorher und Nachher je Feld gegenüber", () => {
|
|
render(<HistorieTab history={[ADRESSWECHSEL]} employeeId="e1" />);
|
|
const zeile = screen.getByText("Adresse").closest("tr")!;
|
|
const zellen = within(zeile).getAllByRole("cell");
|
|
expect(zellen[1]).toHaveTextContent("Feldweg 66");
|
|
expect(zellen[2]).toHaveTextContent("Bahnhofstrasse 5");
|
|
});
|
|
|
|
it("nennt die Zahl der Felder am Aufklapper", () => {
|
|
render(<HistorieTab history={[ADRESSWECHSEL]} employeeId="e1" />);
|
|
expect(screen.getByText(/2 Felder im Detail/)).toBeInTheDocument();
|
|
});
|
|
|
|
it("benennt ein einzelnes Feld im Singular", () => {
|
|
render(<HistorieTab history={[eintrag({ changes: [{ feld: "Ort", vorher: "Brno", nachher: "Linz" }] })]} employeeId="e1" />);
|
|
expect(screen.getByText(/1 Feld im Detail/)).toBeInTheDocument();
|
|
});
|
|
|
|
it("macht aus einem leeren Vorher-Wert eine Aussage statt einer Lücke", () => {
|
|
render(<HistorieTab history={[eintrag({ changes: [{ feld: "Telefon", vorher: null, nachher: "0664 1234567" }] })]} employeeId="e1" />);
|
|
const zeile = screen.getByText("Telefon").closest("tr")!;
|
|
expect(within(zeile).getAllByRole("cell")[1]).toHaveTextContent("leer");
|
|
});
|
|
|
|
it("hängt keinen Aufklapper an Ereignisse ohne Feldwerte", () => {
|
|
// Eintritt, Austritt und die Zeilen von vor dieser Erweiterung haben
|
|
// keine Werte. Ein leerer Aufklapper würde einen Fehler vermuten lassen.
|
|
render(<HistorieTab history={[eintrag({ event_type: "Eintritt", description: "Eintritt als Elektrotechniker:in" })]} employeeId="e1" />);
|
|
expect(screen.getByText("Eintritt als Elektrotechniker:in")).toBeInTheDocument();
|
|
expect(screen.queryByText(/im Detail/)).not.toBeInTheDocument();
|
|
expect(document.querySelector("details")).toBeNull();
|
|
});
|
|
|
|
it("bietet das Löschen an einer irrtümlichen Änderung an", () => {
|
|
render(<HistorieTab history={[ADRESSWECHSEL]} employeeId="e1" />);
|
|
expect(screen.getByRole("button", { name: /Stammdatenänderung vom .* löschen/ })).toBeInTheDocument();
|
|
});
|
|
|
|
it("bietet es am Eintritt nicht an und sagt, warum", () => {
|
|
render(
|
|
<HistorieTab
|
|
history={[eintrag({ event_type: "Eintritt", changes: [{ feld: "Adresse", vorher: "a", nachher: "b" }] })]}
|
|
employeeId="e1"
|
|
/>
|
|
);
|
|
expect(screen.queryByRole("button", { name: /löschen/ })).not.toBeInTheDocument();
|
|
expect(screen.getByText(/Anfang der Zeitleiste/)).toBeInTheDocument();
|
|
});
|
|
|
|
it("bietet es an einer Versetzung nicht an", () => {
|
|
render(
|
|
<HistorieTab
|
|
history={[eintrag({ event_type: "Versetzung", changes: [{ feld: "Adresse", vorher: "a", nachher: "b" }] })]}
|
|
employeeId="e1"
|
|
/>
|
|
);
|
|
expect(screen.queryByRole("button", { name: /löschen/ })).not.toBeInTheDocument();
|
|
expect(screen.getByText(/Planstellen oder den Status/)).toBeInTheDocument();
|
|
});
|
|
|
|
it("bietet es an einer noch nicht wirksamen Änderung nicht an", () => {
|
|
render(<HistorieTab history={[eintrag({ event_date: "2099-01-01" })]} employeeId="e1" />);
|
|
expect(screen.queryByRole("button", { name: /löschen/ })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("sagt vor dem Löschen, was zurückgesetzt wird und was bleibt", async () => {
|
|
const user = userEvent.setup();
|
|
const alt = eintrag({
|
|
id: "alt",
|
|
event_date: "2026-08-01",
|
|
created_at: "2026-08-01T09:00:00.000Z",
|
|
changes: [
|
|
{ feld: "Adresse", vorher: "Feldweg 66", nachher: "Zwischenweg 1" },
|
|
{ feld: "Telefon", vorher: "0664 000", nachher: "0664 111" },
|
|
],
|
|
});
|
|
const neu = eintrag({
|
|
id: "neu",
|
|
event_date: "2026-08-10",
|
|
created_at: "2026-08-10T09:00:00.000Z",
|
|
changes: [{ feld: "Adresse", vorher: "Zwischenweg 1", nachher: "Endstrasse 9" }],
|
|
});
|
|
render(<HistorieTab history={[neu, alt]} employeeId="e1" />);
|
|
|
|
await user.click(screen.getByRole("button", { name: /Stammdatenänderung vom 01\.08\.2026 löschen/ }));
|
|
|
|
const dialog = screen.getByRole("dialog");
|
|
// Das Telefon geht zurück, die Adresse nicht — sie wurde später erneut
|
|
// geändert, und diese Änderung ist die schlagende.
|
|
const zurueck = within(dialog).getByRole("heading", { name: "Wird zurückgesetzt" }).parentElement!;
|
|
expect(within(zurueck).getByText("Telefon")).toBeInTheDocument();
|
|
expect(within(zurueck).queryByText("Adresse")).not.toBeInTheDocument();
|
|
|
|
const bleibt = within(dialog).getByRole("heading", { name: "Bleibt unverändert" }).parentElement!;
|
|
expect(within(bleibt).getByText("Adresse")).toBeInTheDocument();
|
|
});
|
|
|
|
it("löscht erst nach ausdrücklicher Bestätigung", async () => {
|
|
const user = userEvent.setup();
|
|
const { deleteHistoryEntry } = await import("@/actions/employees");
|
|
render(<HistorieTab history={[ADRESSWECHSEL]} employeeId="e1" />);
|
|
|
|
await user.click(screen.getByRole("button", { name: /löschen$/ }));
|
|
expect(deleteHistoryEntry).not.toHaveBeenCalled();
|
|
|
|
await user.click(screen.getByRole("button", { name: "Löschen und zurücksetzen" }));
|
|
expect(deleteHistoryEntry).toHaveBeenCalledWith({ history_id: "h1", employee_id: "e1" });
|
|
});
|
|
|
|
it("bietet neben dem Löschen auch das Berichtigen an", () => {
|
|
render(<HistorieTab history={[ADRESSWECHSEL]} employeeId="e1" />);
|
|
expect(screen.getByRole("button", { name: /Stammdatenänderung vom .* bearbeiten/ })).toBeInTheDocument();
|
|
});
|
|
|
|
it("bietet das Berichtigen dort nicht an, wo auch nicht gelöscht werden darf", () => {
|
|
render(<HistorieTab history={[eintrag({ event_type: "Versetzung" })]} employeeId="e1" />);
|
|
expect(screen.queryByRole("button", { name: /bearbeiten/ })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("zeigt beim Berichtigen das Vorher unveränderlich und das Nachher zum Tippen", async () => {
|
|
const user = userEvent.setup();
|
|
render(<HistorieTab history={[ADRESSWECHSEL]} employeeId="e1" />);
|
|
await user.click(screen.getByRole("button", { name: /bearbeiten/ }));
|
|
|
|
const dialog = screen.getByRole("dialog");
|
|
// Das Vorher ist Geschichte und steht nur da.
|
|
expect(within(dialog).getByText("Feldweg 66")).toBeInTheDocument();
|
|
expect(within(dialog).queryByDisplayValue("Feldweg 66")).not.toBeInTheDocument();
|
|
// Das Nachher lässt sich ändern.
|
|
expect(within(dialog).getByLabelText("Adresse — neuer Wert")).toHaveValue("Bahnhofstrasse 5");
|
|
});
|
|
|
|
it("schickt nur ab, wenn wirklich etwas anders ist", async () => {
|
|
const user = userEvent.setup();
|
|
const { updateHistoryEntry } = await import("@/actions/employees");
|
|
render(<HistorieTab history={[ADRESSWECHSEL]} employeeId="e1" />);
|
|
await user.click(screen.getByRole("button", { name: /bearbeiten/ }));
|
|
|
|
const knopf = screen.getByRole("button", { name: "Berichtigen" });
|
|
expect(knopf).toBeDisabled();
|
|
|
|
const feld = screen.getByLabelText("Adresse — neuer Wert");
|
|
await user.clear(feld);
|
|
await user.type(feld, "Bahnhofstraße 5");
|
|
expect(knopf).toBeEnabled();
|
|
|
|
await user.click(knopf);
|
|
expect(updateHistoryEntry).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
history_id: "h1",
|
|
employee_id: "e1",
|
|
werte: expect.arrayContaining([{ feld: "Adresse", nachher: "Bahnhofstraße 5" }]),
|
|
})
|
|
);
|
|
});
|
|
|
|
it("verwirft die Eingaben beim Abbrechen", async () => {
|
|
const user = userEvent.setup();
|
|
render(<HistorieTab history={[ADRESSWECHSEL]} employeeId="e1" />);
|
|
await user.click(screen.getByRole("button", { name: /bearbeiten/ }));
|
|
await user.clear(screen.getByLabelText("Adresse — neuer Wert"));
|
|
await user.type(screen.getByLabelText("Adresse — neuer Wert"), "Verworfen 1");
|
|
await user.click(screen.getByRole("button", { name: "Abbrechen" }));
|
|
|
|
await user.click(screen.getByRole("button", { name: /bearbeiten/ }));
|
|
expect(screen.getByLabelText("Adresse — neuer Wert")).toHaveValue("Bahnhofstrasse 5");
|
|
});
|
|
|
|
it("bleibt bei leerer Historie bei einem Satz", () => {
|
|
render(<HistorieTab history={[]} employeeId="e1" />);
|
|
expect(screen.getByText("Keine Historieneinträge vorhanden.")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
// Eine Akte wird meist aus einem von zwei Gründen aufgeschlagen: „was steht
|
|
// an" oder „was war damals". Ohne Filter muss man dafür durch alles scrollen,
|
|
// und je länger jemand da ist, desto weniger taugt das.
|
|
describe("HistorieTab — Filter", () => {
|
|
const VERGANGEN = eintrag({ id: "v", event_type: "Versetzung", event_date: "2020-05-01", description: "Versetzung damals" });
|
|
const HEUTE_EINTRAG = eintrag({ id: "h", event_type: "Stammdatenänderung", event_date: "2026-03-02", description: "Heute-Eintrag" });
|
|
const KUENFTIG = eintrag({ id: "z", event_type: "Versetzung", event_date: "2099-01-01", description: "Versetzung künftig" });
|
|
const ALLE = [KUENFTIG, HEUTE_EINTRAG, VERGANGEN];
|
|
|
|
it("zeigt ohne Filter alles", () => {
|
|
render(<HistorieTab history={ALLE} employeeId="e1" />);
|
|
expect(screen.getByText("Versetzung damals")).toBeInTheDocument();
|
|
expect(screen.getByText("Versetzung künftig")).toBeInTheDocument();
|
|
});
|
|
|
|
it("nennt die Zahl der anstehenden Punkte, auch ohne dass jemand filtert", () => {
|
|
render(<HistorieTab history={ALLE} employeeId="e1" />);
|
|
expect(screen.getByText("1 anstehend")).toBeInTheDocument();
|
|
});
|
|
|
|
it("blendet mit „Anstehend“ alles Gelaufene aus", async () => {
|
|
const user = userEvent.setup();
|
|
render(<HistorieTab history={ALLE} employeeId="e1" />);
|
|
await user.click(screen.getByRole("button", { name: "Anstehend" }));
|
|
expect(screen.getByText("Versetzung künftig")).toBeInTheDocument();
|
|
expect(screen.queryByText("Versetzung damals")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("blendet mit „Vergangen“ das Anstehende aus", async () => {
|
|
const user = userEvent.setup();
|
|
render(<HistorieTab history={ALLE} employeeId="e1" />);
|
|
await user.click(screen.getByRole("button", { name: "Vergangen" }));
|
|
expect(screen.queryByText("Versetzung künftig")).not.toBeInTheDocument();
|
|
expect(screen.getByText("Versetzung damals")).toBeInTheDocument();
|
|
});
|
|
|
|
it("grenzt über den Zeitraum ein", async () => {
|
|
const user = userEvent.setup();
|
|
render(<HistorieTab history={ALLE} employeeId="e1" />);
|
|
await user.type(screen.getByLabelText("Von"), "2026-01-01");
|
|
expect(screen.queryByText("Versetzung damals")).not.toBeInTheDocument();
|
|
expect(screen.getByText("Heute-Eintrag")).toBeInTheDocument();
|
|
});
|
|
|
|
it("filtert nach Ereignisart", async () => {
|
|
const user = userEvent.setup();
|
|
render(<HistorieTab history={ALLE} employeeId="e1" />);
|
|
await user.click(screen.getByRole("button", { name: "Versetzung", pressed: false }));
|
|
expect(screen.getByText("Versetzung damals")).toBeInTheDocument();
|
|
expect(screen.queryByText("Heute-Eintrag")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("sagt bei leerem Ergebnis, wie viele Einträge es überhaupt gibt", async () => {
|
|
const user = userEvent.setup();
|
|
render(<HistorieTab history={[VERGANGEN]} employeeId="e1" />);
|
|
await user.click(screen.getByRole("button", { name: "Anstehend" }));
|
|
expect(screen.getByText(/Kein Eintrag passt zu dieser Auswahl/)).toBeInTheDocument();
|
|
expect(screen.getByText(/1 Eintrag sind vorhanden|1 Eintrag/)).toBeInTheDocument();
|
|
});
|
|
|
|
it("bietet das Eintrittsdatum zum Bearbeiten an, aber nicht zum Löschen", () => {
|
|
// Der Eintritt ist der Anfang der Zeitleiste und bleibt — sein Datum kann
|
|
// trotzdem falsch erfasst sein.
|
|
render(<HistorieTab history={[eintrag({ event_type: "Eintritt", changes: null })]} employeeId="e1" />);
|
|
expect(screen.getByRole("button", { name: /Eintritt vom .* bearbeiten/ })).toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: /löschen/ })).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
// Eine geplante Abwesenheit oder Rückkehr hat keine einzelnen Felder, die
|
|
// sich herausnehmen liessen — sie fällt als Ganzes. Der Dialog muss das sagen,
|
|
// sonst steht unter „Wird nicht mehr passieren" eine leere Liste.
|
|
describe("HistorieTab — geplanten Vorgang abbrechen", () => {
|
|
const GEPLANT = eintrag({
|
|
event_type: "Rückkehr",
|
|
event_date: "2099-09-01",
|
|
description: "Rückkehr aus Elternkarenz am 2099-09-01",
|
|
changes: null,
|
|
pending_id: "p1",
|
|
});
|
|
|
|
it("bietet das Abbrechen an", () => {
|
|
render(<HistorieTab history={[GEPLANT]} employeeId="e1" />);
|
|
expect(screen.getByRole("button", { name: /Rückkehr vom 01\.09\.2099 löschen/ })).toBeInTheDocument();
|
|
});
|
|
|
|
it("nennt den Knopf beim richtigen Namen und sagt, was entfällt", async () => {
|
|
const user = userEvent.setup();
|
|
render(<HistorieTab history={[GEPLANT]} employeeId="e1" />);
|
|
await user.click(screen.getByRole("button", { name: /löschen/ }));
|
|
|
|
const dialog = screen.getByRole("dialog");
|
|
expect(within(dialog).getByText(/Der Vorgang entfällt ganz/)).toBeInTheDocument();
|
|
expect(within(dialog).getByRole("button", { name: "Vorgang abbrechen" })).toBeInTheDocument();
|
|
expect(within(dialog).queryByRole("button", { name: "Löschen und zurücksetzen" })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("bietet es ohne hinterlegten Vorgang nicht an", () => {
|
|
render(<HistorieTab history={[eintrag({ ...GEPLANT, pending_id: null })]} employeeId="e1" />);
|
|
expect(screen.queryByRole("button", { name: /löschen/ })).not.toBeInTheDocument();
|
|
});
|
|
});
|