Last step moved the four part-time arrangements out of the absence list and recorded the reason in the history text. That answered "what happened" but not "who is in one right now", and the profile showed nothing at all. So it becomes a real field: teilzeit_art, with an optional end date. The objection I raised then still holds — a state goes stale, because nobody goes back to note when a Bildungsteilzeit ended. teilzeit_bis is the answer to it: with an end date a report decides for itself what is still running instead of trusting that someone maintained the row. Left empty it means "open end", which is an honest thing to say. It runs through the ordinary change machinery rather than beside it. It sits in app_feld_karte, so it shows up in the history as a field with before and after, and can be corrected there like any other. The description suffix from last step is gone — writing the same thing twice is how two versions start disagreeing. Reporting: filter by variant, by "in one at all", and by when it ends; group headcount by variant, where the absence of one reads "Keine" rather than a dash, because in a report that is an answer and not a gap. Plus columns in the export and the import. One gap found while rehearsing, and only because the probe happened to pick a return date in the future: a scheduled return carries its payload through pending_org_changes, and that payload did not include the variant. Someone would have come back on reduced hours in April with the reason gone. The daily run now carries it too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
180 lines
7.2 KiB
TypeScript
180 lines
7.2 KiB
TypeScript
import { ABSENCE_TYPES } from "./absence";
|
|
import { parseIsoDateParam } from "./reports";
|
|
import type { Weekday } from "./supabase/types";
|
|
|
|
// Ein Verzeichnis aller Auswahlkriterien — für die Oberfläche, die Abfrage
|
|
// und den Export dasselbe.
|
|
//
|
|
// Ein Export, der weniger filtern kann als der Bericht daneben, ist der
|
|
// Grund, warum Leute Daten nach Excel kippen und dort weiterarbeiten: was
|
|
// die Anwendung nicht hergibt, wird eben von Hand nachgebaut, und ab da
|
|
// stimmt es mit nichts mehr überein. Deshalb steht hier jedes Kriterium
|
|
// einmal, mit seinen erlaubten Werten, und alle drei Seiten lesen aus
|
|
// derselben Liste:
|
|
//
|
|
// * die Oberfläche baut ihre Felder daraus,
|
|
// * `parseCriteria` prüft die Adresszeile dagegen,
|
|
// * `applyCriteria` (serverseitig) macht Bedingungen daraus.
|
|
//
|
|
// Ein neues Kriterium ist damit ein Eintrag in dieser Datei und sonst
|
|
// nichts — und es kann nicht passieren, dass es im Bericht wirkt, im Export
|
|
// aber stillschweigend ignoriert wird.
|
|
//
|
|
// Die Prüfung ist nicht bloss Ordnungsliebe: die Werte landen in
|
|
// SQL-Vergleichen und im Dateinamen des Downloads, also in einem
|
|
// Content-Disposition-Header. Was nicht in der Liste steht, kommt nicht
|
|
// durch.
|
|
|
|
export type JaNein = "ja" | "nein";
|
|
|
|
/** Ein Kriterium mit fester Werteliste. */
|
|
export type AuswahlKriterium = {
|
|
key: string;
|
|
label: string;
|
|
/** Beschriftung für „keine Einschränkung". */
|
|
alle: string;
|
|
optionen: readonly { wert: string; label: string }[];
|
|
};
|
|
|
|
function werte(...w: string[]): { wert: string; label: string }[] {
|
|
return w.map((x) => ({ wert: x, label: x }));
|
|
}
|
|
|
|
export const AUSWAHL_KRITERIEN: readonly AuswahlKriterium[] = [
|
|
{ key: "employment", label: "Beschäftigungsart", alle: "Alle Beschäftigungsarten", optionen: werte("Vollzeit", "Teilzeit") },
|
|
{ key: "contract", label: "Vertragsart", alle: "Alle Vertragsarten", optionen: werte("unbefristet", "befristet") },
|
|
{ key: "worker", label: "Angestellte:r / Arbeiter:in", alle: "Alle", optionen: werte("Angestellte:r", "Arbeiter:in") },
|
|
{ key: "kv", label: "Kollektivvertrag", alle: "Alle Kollektivverträge", optionen: werte("Handel", "Süßwaren") },
|
|
{ key: "paygrade", label: "Paygrade", alle: "Alle Paygrades", optionen: werte("A", "B", "C", "D", "E", "F") },
|
|
{ key: "source", label: "Intern/Extern", alle: "Alle", optionen: werte("Intern", "Extern") },
|
|
{
|
|
key: "gender",
|
|
label: "Geschlecht",
|
|
alle: "Alle",
|
|
optionen: [
|
|
{ wert: "m", label: "männlich" },
|
|
{ wert: "w", label: "weiblich" },
|
|
],
|
|
},
|
|
{ key: "dienstwagenArt", label: "Antriebsart Dienstwagen", alle: "Alle Antriebsarten", optionen: werte("Verbrenner", "Elektro") },
|
|
{ key: "absence", label: "Art der Langzeitabwesenheit", alle: "Alle Arten", optionen: werte(...ABSENCE_TYPES) },
|
|
{
|
|
key: "teilzeitArt",
|
|
label: "Teilzeitvariante",
|
|
alle: "Alle Teilzeitvarianten",
|
|
optionen: werte("Bildungsteilzeit", "Elternteilzeit", "Pflegeteilzeit", "Wiedereingliederungsteilzeit"),
|
|
},
|
|
{ key: "weekday", label: "Arbeitet am", alle: "Beliebiger Wochentag", optionen: werte("Mo", "Di", "Mi", "Do", "Fr", "Sa", "So") },
|
|
] as const;
|
|
|
|
/** Kriterien über ein Ja/Nein-Feld. */
|
|
export type JaNeinKriterium = { key: string; label: string };
|
|
|
|
export const JANEIN_KRITERIEN: readonly JaNeinKriterium[] = [
|
|
{ key: "dienstwagen", label: "Dienstwagen" },
|
|
{ key: "betriebsrat", label: "Betriebsrat" },
|
|
{ key: "lateral", label: "Laterale Führung" },
|
|
{ key: "clevel", label: "C-Level" },
|
|
{ key: "kuendigungsschutz", label: "Besonderer Kündigungsschutz" },
|
|
{ key: "teilzeit", label: "In einer Teilzeitvariante" },
|
|
{ key: "dependents", label: "Angehörige erfasst" },
|
|
] as const;
|
|
|
|
/** Zeiträume: je ein Parameter `<key>From` und `<key>To`. */
|
|
export type ZeitraumKriterium = { key: string; label: string };
|
|
|
|
export const ZEITRAUM_KRITERIEN: readonly ZeitraumKriterium[] = [
|
|
{ key: "entry", label: "Eintritt" },
|
|
{ key: "exit", label: "Austritt" },
|
|
{ key: "birth", label: "Geburtsdatum" },
|
|
{ key: "schutz", label: "Kündigungsschutz bis" },
|
|
{ key: "teilzeitEnde", label: "Teilzeit endet" },
|
|
] as const;
|
|
|
|
export type Criteria = {
|
|
/** Werteliste je Kriteriumsschlüssel aus AUSWAHL_KRITERIEN. */
|
|
auswahl: Record<string, string>;
|
|
/** Ja/Nein je Schlüssel aus JANEIN_KRITERIEN. */
|
|
jaNein: Record<string, JaNein>;
|
|
/** ISO-Daten je Schlüssel aus ZEITRAUM_KRITERIEN, jeweils von/bis. */
|
|
zeitraum: Record<string, { von?: string; bis?: string }>;
|
|
/** Wochenstunden von/bis. */
|
|
stundenVon?: number;
|
|
stundenBis?: number;
|
|
};
|
|
|
|
export const LEERE_CRITERIA: Criteria = { auswahl: {}, jaNein: {}, zeitraum: {} };
|
|
|
|
function parseZahl(value: string | null | undefined): number | undefined {
|
|
if (!value) return undefined;
|
|
const n = Number(value.replace(",", "."));
|
|
// Negative Stunden oder eine Woche mit 200 Stunden sind keine Eingabe,
|
|
// sondern ein Tippfehler oder ein Versuch.
|
|
return Number.isFinite(n) && n >= 0 && n <= 168 ? n : undefined;
|
|
}
|
|
|
|
/**
|
|
* Liest die Kriterien aus der Adresszeile und verwirft alles, was nicht in
|
|
* den Listen oben steht.
|
|
*/
|
|
export function parseCriteria(get: (key: string) => string | null | undefined): Criteria {
|
|
const auswahl: Record<string, string> = {};
|
|
for (const k of AUSWAHL_KRITERIEN) {
|
|
const roh = get(k.key);
|
|
if (roh && k.optionen.some((o) => o.wert === roh)) auswahl[k.key] = roh;
|
|
}
|
|
|
|
const jaNein: Record<string, JaNein> = {};
|
|
for (const k of JANEIN_KRITERIEN) {
|
|
const roh = get(k.key);
|
|
if (roh === "ja" || roh === "nein") jaNein[k.key] = roh;
|
|
}
|
|
|
|
const zeitraum: Record<string, { von?: string; bis?: string }> = {};
|
|
for (const k of ZEITRAUM_KRITERIEN) {
|
|
const von = parseIsoDateParam(get(`${k.key}From`));
|
|
const bis = parseIsoDateParam(get(`${k.key}To`));
|
|
if (von || bis) zeitraum[k.key] = { von, bis };
|
|
}
|
|
|
|
return { auswahl, jaNein, zeitraum, stundenVon: parseZahl(get("hoursFrom")), stundenBis: parseZahl(get("hoursTo")) };
|
|
}
|
|
|
|
/** Wie viele Kriterien gesetzt sind — für die Anzeige „(3)" am Aufklapper. */
|
|
export function anzahlKriterien(c: Criteria): number {
|
|
let n = Object.keys(c.auswahl).length + Object.keys(c.jaNein).length;
|
|
for (const z of Object.values(c.zeitraum)) n += (z.von ? 1 : 0) + (z.bis ? 1 : 0);
|
|
if (c.stundenVon !== undefined) n += 1;
|
|
if (c.stundenBis !== undefined) n += 1;
|
|
return n;
|
|
}
|
|
|
|
/** Alle Parameternamen, die zu den Kriterien gehören — zum Zurücksetzen. */
|
|
export function kriterienParameter(): string[] {
|
|
return [
|
|
...AUSWAHL_KRITERIEN.map((k) => k.key),
|
|
...JANEIN_KRITERIEN.map((k) => k.key),
|
|
...ZEITRAUM_KRITERIEN.flatMap((k) => [`${k.key}From`, `${k.key}To`]),
|
|
"hoursFrom",
|
|
"hoursTo",
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Die zwei Kriterien, die keine Spalte sind: der Wochentag steckt in einem
|
|
* Array, die Angehörigen in einer anderen Tabelle. Beide werden an den
|
|
* geladenen Zeilen geprüft, statt den Enum-Vergleich bzw. einen Join in die
|
|
* Abfrage zu zwingen.
|
|
*/
|
|
export function passtImSpeicher(
|
|
zeile: { work_days: Weekday[] | string[]; dependentsCount: number },
|
|
c: Criteria
|
|
): boolean {
|
|
const tag = c.auswahl.weekday;
|
|
if (tag && !zeile.work_days.includes(tag as Weekday)) return false;
|
|
const angehoerige = c.jaNein.dependents;
|
|
if (angehoerige === "ja" && zeile.dependentsCount === 0) return false;
|
|
if (angehoerige === "nein" && zeile.dependentsCount > 0) return false;
|
|
return true;
|
|
}
|