Die Kachel verwies auf eine Adresse, die die Liste nicht lesen konnte

Die neue Kachel "Aktives Dienstverhaeltnis" verlinkte auf
?status=Aktiv&status=Karenz. Die Mitarbeiterliste liest den Parameter aber
als *eine* Zeichenkette und trennt selbst an Kommas — zweimal uebergeben
macht Next daraus ein Array, und `.split(",")` lief dagegen. Sichtbar war
nur "Diese Ansicht konnte nicht geladen werden".

Die Kachel schreibt jetzt status=Aktiv,Karenz. Dazu glaettet die Seite alle
ihre Parameter: eine Adresse kommt nicht nur aus der eigenen Anwendung, sie
steht in Lesezeichen und in E-Mails, und ?q=a&q=b haette sie genauso
gefaellt.

Zwei Anmerkungen von Max:

  * Die Reihenfolge der Wochentage wurde beim Speichern mitgenommen — "Mo,
    Di" und "Di, Mo" waren zwei Werte fuer dieselbe Aussage. Da
    change_employee_data die Arbeitstage als zusammengefuegte Zeichenkette
    vergleicht, erzeugte jedes Nachsehen und Wiederherstellen eine
    Vertragsaenderung in der Akte und einen Protokolleintrag — ueber nichts.
    Jetzt sortiert gespeichert (lib/wochentage.ts, an einer Stelle statt in
    vier Kopien), auch im Massenimport. Der Bestand richtet sich beim
    naechsten Speichern von selbst.
  * "Beguenstigt behindert" steht jetzt als eingerueckter Unterpunkt des
    Kuendigungsschutzes statt als eigener Block daneben. In der Datenbank
    bleiben es getrennte Felder, und das mit Absicht: eine Kopplung liesse
    jede Korrektur am Personenkreis scheitern, solange der Grad noch
    dransteht.
This commit is contained in:
2026-09-16 22:06:38 +02:00
parent 05d56bf3b9
commit 4dc27bf212
8 changed files with 202 additions and 60 deletions

View File

@@ -26,6 +26,22 @@ import type { EmploymentStatus } from "@/lib/types";
const PAGE_SIZE = 15; const PAGE_SIZE = 15;
/**
* Die Parameter, nachdem sie geglättet sind — je einer, oder keiner.
*
* Was Next tatsächlich liefert, steht in RohParams: derselbe Name zweimal in
* der Adresse wird dort zu einem Array. Diese Seite hat das nicht erwartet
* und ist daran gescheitert — eine Kachel der Übersicht verwies auf
* `?status=Aktiv&status=Karenz`, und `params.status.split(",")` lief gegen
* ein Array. Sichtbar war davon nur „Diese Ansicht konnte nicht geladen
* werden" mit einer Fehlerkennung.
*
* Die Kachel ist berichtigt (sie schreibt jetzt `status=Aktiv,Karenz`), aber
* eine Adresse kommt nicht nur aus der eigenen Anwendung: sie steht in
* Lesezeichen, in Verknüpfungen, in E-Mails. Deshalb wird hier geglättet
* statt sich darauf zu verlassen, dass niemand zweimal denselben Namen
* schreibt.
*/
type SearchParams = { type SearchParams = {
q?: string; q?: string;
division?: string; division?: string;
@@ -36,8 +52,27 @@ type SearchParams = {
page?: string; page?: string;
}; };
type RohParams = Record<string, string | string[] | undefined>;
/** Der erste Wert eines Parameters — auch wenn er mehrfach in der Adresse steht. */
function einWert(wert: string | string[] | undefined): string | undefined {
return Array.isArray(wert) ? wert[0] : wert;
}
function glaetten(roh: RohParams): SearchParams {
return {
q: einWert(roh.q),
division: einWert(roh.division),
status: einWert(roh.status),
location: einWert(roh.location),
sort: einWert(roh.sort),
dir: einWert(roh.dir),
page: einWert(roh.page),
};
}
type EmployeesPageProps = { type EmployeesPageProps = {
searchParams: Promise<SearchParams>; searchParams: Promise<RohParams>;
}; };
function pageHref(params: SearchParams, page: number): string { function pageHref(params: SearchParams, page: number): string {
@@ -72,7 +107,7 @@ function sortHref(params: SearchParams, feld: Sortierfeld, aktuell: Sortierfeld,
} }
export default async function EmployeesPage({ searchParams }: EmployeesPageProps) { export default async function EmployeesPage({ searchParams }: EmployeesPageProps) {
const params = await searchParams; const params = glaetten(await searchParams);
const page = Math.max(1, Number(params.page ?? "1") || 1); const page = Math.max(1, Number(params.page ?? "1") || 1);
const sortFeld = parseFeld(params.sort); const sortFeld = parseFeld(params.sort);
const sortRichtung = parseRichtung(params.dir); const sortRichtung = parseRichtung(params.dir);

View File

@@ -184,7 +184,10 @@ export default async function DashboardPage({
label: "Aktives Dienstverhältnis", label: "Aktives Dienstverhältnis",
value: activeCount + karenzCount, value: activeCount + karenzCount,
tone: "default", tone: "default",
href: "/employees?status=Aktiv&status=Karenz", // Mit Komma, nicht zweimal `status=`: die Liste liest den Parameter als
// *eine* Zeichenkette und trennt selbst. Zweimal übergeben macht Next
// daraus ein Array, und die Seite scheiterte an `.split(",")`.
href: "/employees?status=Aktiv,Karenz",
}, },
// Geplante Eintritte ohne Zeitgrenze — anders als die Karte „Anstehend" // Geplante Eintritte ohne Zeitgrenze — anders als die Karte „Anstehend"
// darunter, die nur den eingestellten Vorschauzeitraum zeigt. Ein // darunter, die nur den eingestellten Vorschauzeitraum zeigt. Ein

View File

@@ -6,6 +6,7 @@ import { subtreeOf } from "@/lib/org";
import { loadPlacements, loadReportingLineMap } from "@/lib/placement"; import { loadPlacements, loadReportingLineMap } from "@/lib/placement";
import { LEERE_CRITERIA, parseCriteria, passtImSpeicher } from "@/lib/report-criteria"; import { LEERE_CRITERIA, parseCriteria, passtImSpeicher } from "@/lib/report-criteria";
import { derivedStatusFilter } from "@/lib/employee-status-filter"; import { derivedStatusFilter } from "@/lib/employee-status-filter";
import { sortiereWochentage } from "@/lib/wochentage";
import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "@/lib/reports"; import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "@/lib/reports";
import { applyCriteria, loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data"; import { applyCriteria, loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data";
import { requireHrUser } from "@/lib/auth/require-hr"; import { requireHrUser } from "@/lib/auth/require-hr";
@@ -114,8 +115,6 @@ export async function GET(request: NextRequest) {
return new NextResponse(new Blob([body as BlobPart]), { headers: exportResponseHeaders(filename, format) }); return new NextResponse(new Blob([body as BlobPart]), { headers: exportResponseHeaders(filename, format) });
} }
const WEEKDAY_ORDER: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
function employeeExportColumns( function employeeExportColumns(
lookups: OrgLookups, lookups: OrgLookups,
managerName: Map<string, string>, managerName: Map<string, string>,
@@ -148,9 +147,11 @@ function employeeExportColumns(
{ header: "Leitungsplanstelle", get: (e) => e.is_chief }, { header: "Leitungsplanstelle", get: (e) => e.is_chief },
{ header: "Beschäftigungsausmaß", get: (e) => e.employment_type }, { header: "Beschäftigungsausmaß", get: (e) => e.employment_type },
{ header: "Wochenstunden", get: (e) => e.weekly_hours }, { header: "Wochenstunden", get: (e) => e.weekly_hours },
// work_days is stored in click order (see RoleEmploymentFields), not // Seit lib/wochentage.ts wird sortiert gespeichert; der Bestand kann aber
// guaranteed chronological — re-sort Mo→So for the export. // noch unsortierte Zeilen aus der Zeit davor tragen. Hier bleibt es
{ header: "Arbeitstage", get: (e) => [...e.work_days].sort((a, b) => WEEKDAY_ORDER.indexOf(a as Weekday) - WEEKDAY_ORDER.indexOf(b as Weekday)).join(", ") }, // deshalb stehen — im Export kostet es nichts und macht die Spalte
// unabhängig davon, wann eine Zeile zuletzt gespeichert wurde.
{ header: "Arbeitstage", get: (e) => sortiereWochentage(e.work_days).join(", ") },
{ header: "Vertragsart", get: (e) => e.contract_type }, { header: "Vertragsart", get: (e) => e.contract_type },
{ header: "Befristet bis", get: (e) => e.contract_end_date, kind: "date" }, { header: "Befristet bis", get: (e) => e.contract_end_date, kind: "date" },
{ header: "Beschäftigtengruppe", get: (e) => e.worker_type }, { header: "Beschäftigtengruppe", get: (e) => e.worker_type },

View File

@@ -7,9 +7,9 @@ import {
KUENDIGUNGSSCHUTZ_GRUENDE, KUENDIGUNGSSCHUTZ_GRUENDE,
} from "@/lib/kuendigungsschutz"; } from "@/lib/kuendigungsschutz";
import { MITARBEITERARTEN } from "@/lib/mitarbeiterart"; import { MITARBEITERARTEN } from "@/lib/mitarbeiterart";
import { sortiereWochentage, WOCHENTAGE } from "@/lib/wochentage";
import type { CollectiveAgreement, DienstwagenArt, Mitarbeiterart, Weekday, WorkerType } from "@/lib/types"; import type { CollectiveAgreement, DienstwagenArt, Mitarbeiterart, Weekday, WorkerType } from "@/lib/types";
const WEEKDAYS: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
export type RoleEmploymentValue = { export type RoleEmploymentValue = {
workerType: WorkerType; workerType: WorkerType;
@@ -67,9 +67,21 @@ export type RoleEmploymentValue = {
// the same set of employees columns, just against different local state. // the same set of employees columns, just against different local state.
export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymentValue; onChange: (patch: Partial<RoleEmploymentValue>) => void }) { export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymentValue; onChange: (patch: Partial<RoleEmploymentValue>) => void }) {
function toggleWorkDay(day: Weekday) { function toggleWorkDay(day: Weekday) {
onChange({ workDays: value.workDays.includes(day) ? value.workDays.filter((d) => d !== day) : [...value.workDays, day] }); // Sortiert und nicht angehängt: sonst hinge die gespeicherte Reihenfolge
// davon ab, in welcher die Knöpfe angeklickt wurden. „Mo, Di" und „Di,
// Mo" waren damit zwei Werte für dieselbe Aussage, und wer die Tage nur
// nachsah und wieder herstellte, erzeugte eine Vertragsänderung in der
// Akte über nichts. Siehe lib/wochentage.ts.
const neu = value.workDays.includes(day) ? value.workDays.filter((d) => d !== day) : [...value.workDays, day];
onChange({ workDays: sortiereWochentage(neu) });
} }
// Der Personenkreis zieht das Kennzeichen mit: wer „Begünstigte behinderte
// ArbeitnehmerInnen" als Grund wählt, hat die Frage damit schon beantwortet.
// Eine Hilfe der Oberfläche, keine Bedingung der Datenbank — abwählen lässt
// es sich weiterhin.
const istBehindert = value.istBeguenstigtBehindert || value.kuendigungsschutzGrund === GRUND_BEGUENSTIGT_BEHINDERT;
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
@@ -116,7 +128,7 @@ export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymen
<fieldset> <fieldset>
<legend className="mb-1 block text-xs font-semibold text-ink-muted">Arbeitstage</legend> <legend className="mb-1 block text-xs font-semibold text-ink-muted">Arbeitstage</legend>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{WEEKDAYS.map((day) => ( {WOCHENTAGE.map((day) => (
<button <button
key={day} key={day}
type="button" type="button"
@@ -204,50 +216,56 @@ export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymen
onChange={(v) => onChange({ kuendigungsschutzBis: v })} onChange={(v) => onChange({ kuendigungsschutzBis: v })}
hint="Optional. Leer lassen, solange das Ende nicht feststeht." hint="Optional. Leer lassen, solange das Ende nicht feststeht."
/> />
</>
)}
</div>
{/* ── Begünstigte Behinderung ────────────────────────────────── {/* ── Begünstigte Behinderung ────────────────────────────
Eigener Block und nicht innerhalb des Kündigungsschutzes: die Als Unterpunkt des Kündigungsschutzes und eingerückt — sie ist
Begünstigung besteht für sich, auch wenn niemand sie als Schutz einer der zwölf Personenkreise und stand vorher als eigener
führt. Der Haken setzt sich mit, wenn der Personenkreis gewählt Block daneben, was sie wie ein zweites, unabhängiges Thema
wird — das ist eine Hilfe der Oberfläche, keine Bedingung der aussehen liess.
Datenbank, und er lässt sich danach wieder abwählen. */} *
<div className="grid grid-cols-1 items-end gap-3 sm:grid-cols-2"> In der Datenbank bleibt sie trotzdem ein eigenes Kennzeichen
<label className="flex items-center gap-2 text-sm text-ink"> mit eigenen Feldern, und das mit Absicht: eine Bedingung
<input „Grad nur bei diesem Personenkreis" liesse jede Korrektur am
type="checkbox" Personenkreis scheitern, solange der Grad noch dransteht —
checked={value.istBeguenstigtBehindert || value.kuendigungsschutzGrund === GRUND_BEGUENSTIGT_BEHINDERT} also genau beim Geradebiegen eines Fehlers. Was hier
onChange={(e) => onChange({ istBeguenstigtBehindert: e.target.checked })} zusammengehört, muss dort nicht aneinandergekettet sein. */}
/> <div className="ml-1 flex flex-col gap-2 border-l-2 border-border-subtle pl-3">
Begünstigt behindert <label className="flex items-center gap-2 text-sm text-ink-body">
</label> <input
{(value.istBeguenstigtBehindert || value.kuendigungsschutzGrund === GRUND_BEGUENSTIGT_BEHINDERT) && ( type="checkbox"
<> checked={istBehindert}
<TextField onChange={(e) => onChange({ istBeguenstigtBehindert: e.target.checked })}
label="Grad der Behinderung" />
dense Begünstigt behindert
type="number" </label>
value={value.behinderungGrad} {istBehindert && (
onChange={(v) => onChange({ behinderungGrad: v })} <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
hint={`In Prozent laut Bescheid. Die Begünstigung beginnt bei ${BEGUENSTIGUNG_AB_GRAD}%.`} <TextField
/> label="Grad der Behinderung"
<TextField dense
label="Bescheid ab" type="number"
dense value={value.behinderungGrad}
type="date" onChange={(v) => onChange({ behinderungGrad: v })}
value={value.behinderungAb} hint={`In Prozent laut Bescheid. Die Begünstigung beginnt bei ${BEGUENSTIGUNG_AB_GRAD}%.`}
onChange={(v) => onChange({ behinderungAb: v })} />
/> <TextField
<TextField label="Bescheid ab"
label="Bescheid bis" dense
dense type="date"
type="date" value={value.behinderungAb}
value={value.behinderungBis} onChange={(v) => onChange({ behinderungAb: v })}
onChange={(v) => onChange({ behinderungBis: v })} />
hint="Leer heisst unbefristet." <TextField
/> label="Bescheid bis"
dense
type="date"
value={value.behinderungBis}
onChange={(v) => onChange({ behinderungBis: v })}
hint="Leer heisst unbefristet."
/>
</div>
)}
</div>
</> </>
)} )}
</div> </div>

View File

@@ -3,6 +3,7 @@ import { sql, type Tx } from "@/lib/db";
import { todayIso } from "@/lib/format"; import { todayIso } from "@/lib/format";
import { deriveStatusAsOf } from "@/lib/reports"; import { deriveStatusAsOf } from "@/lib/reports";
import { normalizeSvnr } from "@/lib/svnr"; import { normalizeSvnr } from "@/lib/svnr";
import { sortiereWochentage } from "@/lib/wochentage";
import type { Bestand, Datensatz, Zeile } from "./validate"; import type { Bestand, Datensatz, Zeile } from "./validate";
// Schreiben einer geprüften Datei. // Schreiben einer geprüften Datei.
@@ -217,8 +218,11 @@ export async function laden(
weekly_hours: zahl(w.weekly_hours) ?? 38.5, weekly_hours: zahl(w.weekly_hours) ?? 38.5,
// Die Prüfung hat jeden Eintrag gegen die Wochentage abgeglichen und // Die Prüfung hat jeden Eintrag gegen die Wochentage abgeglichen und
// auf die Schreibweise der Datenbank gebracht; hier steht deshalb // auf die Schreibweise der Datenbank gebracht; hier steht deshalb
// sicher nur Mo…So. // sicher nur Mo…So. Sortiert wird trotzdem: die Reihenfolge in der
work_days: (liste(w.work_days) ?? ["Mo", "Di", "Mi", "Do", "Fr"]) as never, // Datei ist die der Datei, und „Di, Mo" ist derselbe Sachverhalt wie
// „Mo, Di". Ungeordnet gespeichert erzeugte die erste Änderung an so
// einer Person eine Vertragsänderung über nichts.
work_days: sortiereWochentage(liste(w.work_days) ?? ["Mo", "Di", "Mi", "Do", "Fr"]) as never,
contract_type: (txt(w.contract_type) ?? "unbefristet") as never, contract_type: (txt(w.contract_type) ?? "unbefristet") as never,
contract_end_date: txt(w.contract_end_date), contract_end_date: txt(w.contract_end_date),
paygrade: (txt(w.paygrade) ?? "B") as never, paygrade: (txt(w.paygrade) ?? "B") as never,

View File

@@ -1,6 +1,7 @@
import { ABSENCE_TYPES } from "./absence"; import { ABSENCE_TYPES } from "./absence";
import { BEENDIGUNGSART_WERTE } from "./beendigung"; import { BEENDIGUNGSART_WERTE } from "./beendigung";
import { fmtName, todayIso, yearsBetweenIso } from "./format"; import { fmtName, todayIso, yearsBetweenIso } from "./format";
import { WOCHENTAGE } from "./wochentage";
import type { EmploymentStatus, HistoryEventType, Weekday } from "./types"; import type { EmploymentStatus, HistoryEventType, Weekday } from "./types";
export { todayIso }; export { todayIso };
@@ -234,11 +235,9 @@ export function groupKeyFor(e: ReportEmployee, dim: GroupDimension, lookups: Org
} }
} }
const WEEKDAY_ORDER: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
function weekdayRank(key: string): number { function weekdayRank(key: string): number {
const i = WEEKDAY_ORDER.indexOf(key as Weekday); const i = WOCHENTAGE.indexOf(key as Weekday);
return i === -1 ? WEEKDAY_ORDER.length : i; return i === -1 ? WOCHENTAGE.length : i;
} }
function sortByWeekday<T extends { key: string }>(items: T[]): T[] { function sortByWeekday<T extends { key: string }>(items: T[]): T[] {

42
lib/wochentage.ts Normal file
View File

@@ -0,0 +1,42 @@
import type { Weekday } from "./types";
// Die Wochentage in ihrer natürlichen Reihenfolge — an einer Stelle.
//
// Sie stand bisher viermal im Baum: in RoleEmploymentFields, in lib/reports,
// im Mitarbeiter-Export und in lib/import/schema. Vier Kopien einer Liste,
// die sich nie ändert, sind für sich genommen harmlos; was nicht harmlos war,
// ist die fehlende fünfte Verwendung — das Sortieren beim Speichern.
//
// ── Warum sortiert gespeichert wird ─────────────────────────────────
//
// `work_days` wurde in **Klickreihenfolge** abgelegt (so stand es auch in
// docs/datenkatalog.md, als bewusste Entscheidung). Damit sind „Mo, Di" und
// „Di, Mo" zwei verschiedene Werte für dieselbe Aussage, und
// change_employee_data vergleicht die alte mit der neuen Fassung über
// `array_to_string(work_days, ', ')`. Wer die Tage nur noch einmal anklickte,
// um sie zu prüfen, erzeugte damit eine Vertragsänderung in der Personalakte
// und einen Eintrag im Protokoll — über nichts.
//
// Die Reihenfolge trägt keine Bedeutung: welche Tage jemand arbeitet, ist
// eine Menge, keine Folge. Sortiert gespeichert fällt der Scheinunterschied
// weg, ohne dass der Vergleich in SQL etwas davon wissen muss.
//
// Der Bestand kann noch unsortierte Zeilen enthalten (aus dem Massenimport
// oder von früher). Sie richten sich beim nächsten Speichern von selbst —
// und *dieser* eine Eintrag in der Historie ist dann keine Falschmeldung,
// sondern die Aufzeichnung genau dieser Berichtigung.
export const WOCHENTAGE: readonly Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"] as const;
const RANG = new Map(WOCHENTAGE.map((t, i) => [t, i] as const));
/**
* Die Tage in Wochenreihenfolge, ohne Dubletten.
*
* Unbekanntes wandert ans Ende statt verworfen zu werden: die Spalte ist ein
* `text[]` ohne Prüfung, und was der Massenimport einmal hineingeschrieben
* hat, soll eine Sortierung nicht stillschweigend löschen.
*/
export function sortiereWochentage<T extends string>(tage: readonly T[]): T[] {
return [...new Set(tage)].sort((a, b) => (RANG.get(a as Weekday) ?? 99) - (RANG.get(b as Weekday) ?? 99));
}

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { sortiereWochentage, WOCHENTAGE } from "@/lib/wochentage";
// Der Anlass: „Mo, Di" und „Di, Mo" waren zwei verschiedene gespeicherte
// Werte für dieselbe Aussage, weil die Klickreihenfolge mit abgelegt wurde.
// change_employee_data vergleicht die Arbeitstage als zusammengefügte
// Zeichenkette — wer die Tage nur nachsah und wiederherstellte, erzeugte
// damit eine Vertragsänderung in der Personalakte über nichts.
describe("sortiereWochentage", () => {
it("bringt die Tage in Wochenreihenfolge", () => {
expect(sortiereWochentage(["Di", "Mo"])).toEqual(["Mo", "Di"]);
expect(sortiereWochentage(["Fr", "Mi", "Mo"])).toEqual(["Mo", "Mi", "Fr"]);
});
it("liefert für dieselbe Menge immer dasselbe Ergebnis", () => {
// Das ist die eigentliche Zusicherung: die Reihenfolge der Eingabe darf
// am Ergebnis nichts ändern, sonst kommt der Scheinunterschied zurück.
expect(sortiereWochentage(["Di", "Mo", "Fr"])).toEqual(sortiereWochentage(["Fr", "Di", "Mo"]));
});
it("wirft Dubletten weg", () => {
expect(sortiereWochentage(["Mo", "Mo", "Di"])).toEqual(["Mo", "Di"]);
});
it("lässt die volle Woche unverändert", () => {
expect(sortiereWochentage([...WOCHENTAGE])).toEqual([...WOCHENTAGE]);
});
it("verschluckt Unbekanntes nicht, sondern hängt es an", () => {
// Die Spalte ist ein text[] ohne Prüfung. Was der Massenimport einmal
// hineingeschrieben hat, darf eine Sortierung nicht stillschweigend
// löschen — sichtbar falsch ist besser als unsichtbar weg.
expect(sortiereWochentage(["Fr", "Feiertag", "Mo"])).toEqual(["Mo", "Fr", "Feiertag"]);
});
it("kommt mit einer leeren Liste zurecht", () => {
expect(sortiereWochentage([])).toEqual([]);
});
});