diff --git a/app/(app)/employees/page.tsx b/app/(app)/employees/page.tsx index 2602d6d..ef0ff80 100644 --- a/app/(app)/employees/page.tsx +++ b/app/(app)/employees/page.tsx @@ -26,6 +26,22 @@ import type { EmploymentStatus } from "@/lib/types"; 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 = { q?: string; division?: string; @@ -36,8 +52,27 @@ type SearchParams = { page?: string; }; +type RohParams = Record; + +/** 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 = { - searchParams: Promise; + searchParams: Promise; }; 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) { - const params = await searchParams; + const params = glaetten(await searchParams); const page = Math.max(1, Number(params.page ?? "1") || 1); const sortFeld = parseFeld(params.sort); const sortRichtung = parseRichtung(params.dir); diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx index 807ab9f..d0effda 100644 --- a/app/(app)/page.tsx +++ b/app/(app)/page.tsx @@ -184,7 +184,10 @@ export default async function DashboardPage({ label: "Aktives Dienstverhältnis", value: activeCount + karenzCount, 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" // darunter, die nur den eingestellten Vorschauzeitraum zeigt. Ein diff --git a/app/api/export/employees/route.ts b/app/api/export/employees/route.ts index b993922..0d6ce2e 100644 --- a/app/api/export/employees/route.ts +++ b/app/api/export/employees/route.ts @@ -6,6 +6,7 @@ import { subtreeOf } from "@/lib/org"; import { loadPlacements, loadReportingLineMap } from "@/lib/placement"; import { LEERE_CRITERIA, parseCriteria, passtImSpeicher } from "@/lib/report-criteria"; import { derivedStatusFilter } from "@/lib/employee-status-filter"; +import { sortiereWochentage } from "@/lib/wochentage"; import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "@/lib/reports"; import { applyCriteria, loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data"; 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) }); } -const WEEKDAY_ORDER: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; - function employeeExportColumns( lookups: OrgLookups, managerName: Map, @@ -148,9 +147,11 @@ function employeeExportColumns( { header: "Leitungsplanstelle", get: (e) => e.is_chief }, { header: "Beschäftigungsausmaß", get: (e) => e.employment_type }, { header: "Wochenstunden", get: (e) => e.weekly_hours }, - // work_days is stored in click order (see RoleEmploymentFields), not - // guaranteed chronological — re-sort Mo→So for the export. - { header: "Arbeitstage", get: (e) => [...e.work_days].sort((a, b) => WEEKDAY_ORDER.indexOf(a as Weekday) - WEEKDAY_ORDER.indexOf(b as Weekday)).join(", ") }, + // Seit lib/wochentage.ts wird sortiert gespeichert; der Bestand kann aber + // noch unsortierte Zeilen aus der Zeit davor tragen. Hier bleibt es + // 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: "Befristet bis", get: (e) => e.contract_end_date, kind: "date" }, { header: "Beschäftigtengruppe", get: (e) => e.worker_type }, diff --git a/components/employees/RoleEmploymentFields.tsx b/components/employees/RoleEmploymentFields.tsx index a625cd8..08e3aa6 100644 --- a/components/employees/RoleEmploymentFields.tsx +++ b/components/employees/RoleEmploymentFields.tsx @@ -7,9 +7,9 @@ import { KUENDIGUNGSSCHUTZ_GRUENDE, } from "@/lib/kuendigungsschutz"; import { MITARBEITERARTEN } from "@/lib/mitarbeiterart"; +import { sortiereWochentage, WOCHENTAGE } from "@/lib/wochentage"; import type { CollectiveAgreement, DienstwagenArt, Mitarbeiterart, Weekday, WorkerType } from "@/lib/types"; -const WEEKDAYS: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; export type RoleEmploymentValue = { workerType: WorkerType; @@ -67,9 +67,21 @@ export type RoleEmploymentValue = { // the same set of employees columns, just against different local state. export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymentValue; onChange: (patch: Partial) => void }) { 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 (
@@ -116,7 +128,7 @@ export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymen
Arbeitstage
- {WEEKDAYS.map((day) => ( + {WOCHENTAGE.map((day) => (
- {/* ── Begünstigte Behinderung ────────────────────────────────── - Eigener Block und nicht innerhalb des Kündigungsschutzes: die - Begünstigung besteht für sich, auch wenn niemand sie als Schutz - führt. Der Haken setzt sich mit, wenn der Personenkreis gewählt - wird — das ist eine Hilfe der Oberfläche, keine Bedingung der - Datenbank, und er lässt sich danach wieder abwählen. */} -
- - {(value.istBeguenstigtBehindert || value.kuendigungsschutzGrund === GRUND_BEGUENSTIGT_BEHINDERT) && ( - <> - onChange({ behinderungGrad: v })} - hint={`In Prozent laut Bescheid. Die Begünstigung beginnt bei ${BEGUENSTIGUNG_AB_GRAD}%.`} - /> - onChange({ behinderungAb: v })} - /> - onChange({ behinderungBis: v })} - hint="Leer heisst unbefristet." - /> + {/* ── Begünstigte Behinderung ──────────────────────────── + Als Unterpunkt des Kündigungsschutzes und eingerückt — sie ist + einer der zwölf Personenkreise und stand vorher als eigener + Block daneben, was sie wie ein zweites, unabhängiges Thema + aussehen liess. + * + In der Datenbank bleibt sie trotzdem ein eigenes Kennzeichen + mit eigenen Feldern, und das mit Absicht: eine Bedingung + „Grad nur bei diesem Personenkreis" liesse jede Korrektur am + Personenkreis scheitern, solange der Grad noch dransteht — + also genau beim Geradebiegen eines Fehlers. Was hier + zusammengehört, muss dort nicht aneinandergekettet sein. */} +
+ + {istBehindert && ( +
+ onChange({ behinderungGrad: v })} + hint={`In Prozent laut Bescheid. Die Begünstigung beginnt bei ${BEGUENSTIGUNG_AB_GRAD}%.`} + /> + onChange({ behinderungAb: v })} + /> + onChange({ behinderungBis: v })} + hint="Leer heisst unbefristet." + /> +
+ )} +
)}
diff --git a/lib/import/load.ts b/lib/import/load.ts index 784adf5..9dc7140 100644 --- a/lib/import/load.ts +++ b/lib/import/load.ts @@ -3,6 +3,7 @@ import { sql, type Tx } from "@/lib/db"; import { todayIso } from "@/lib/format"; import { deriveStatusAsOf } from "@/lib/reports"; import { normalizeSvnr } from "@/lib/svnr"; +import { sortiereWochentage } from "@/lib/wochentage"; import type { Bestand, Datensatz, Zeile } from "./validate"; // Schreiben einer geprüften Datei. @@ -217,8 +218,11 @@ export async function laden( weekly_hours: zahl(w.weekly_hours) ?? 38.5, // Die Prüfung hat jeden Eintrag gegen die Wochentage abgeglichen und // auf die Schreibweise der Datenbank gebracht; hier steht deshalb - // sicher nur Mo…So. - work_days: (liste(w.work_days) ?? ["Mo", "Di", "Mi", "Do", "Fr"]) as never, + // sicher nur Mo…So. Sortiert wird trotzdem: die Reihenfolge in der + // 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_end_date: txt(w.contract_end_date), paygrade: (txt(w.paygrade) ?? "B") as never, diff --git a/lib/reports.ts b/lib/reports.ts index 8828bbd..513d923 100644 --- a/lib/reports.ts +++ b/lib/reports.ts @@ -1,6 +1,7 @@ import { ABSENCE_TYPES } from "./absence"; import { BEENDIGUNGSART_WERTE } from "./beendigung"; import { fmtName, todayIso, yearsBetweenIso } from "./format"; +import { WOCHENTAGE } from "./wochentage"; import type { EmploymentStatus, HistoryEventType, Weekday } from "./types"; 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 { - const i = WEEKDAY_ORDER.indexOf(key as Weekday); - return i === -1 ? WEEKDAY_ORDER.length : i; + const i = WOCHENTAGE.indexOf(key as Weekday); + return i === -1 ? WOCHENTAGE.length : i; } function sortByWeekday(items: T[]): T[] { diff --git a/lib/wochentage.ts b/lib/wochentage.ts new file mode 100644 index 0000000..a11351c --- /dev/null +++ b/lib/wochentage.ts @@ -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(tage: readonly T[]): T[] { + return [...new Set(tage)].sort((a, b) => (RANG.get(a as Weekday) ?? 99) - (RANG.get(b as Weekday) ?? 99)); +} diff --git a/tests/unit/wochentage.test.ts b/tests/unit/wochentage.test.ts new file mode 100644 index 0000000..e2824bb --- /dev/null +++ b/tests/unit/wochentage.test.ts @@ -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([]); + }); +});