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:
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
42
lib/wochentage.ts
Normal 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));
|
||||||
|
}
|
||||||
40
tests/unit/wochentage.test.ts
Normal file
40
tests/unit/wochentage.test.ts
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user