Workshop-Anforderungen, erster Teil: was ohne Migration geht
Anforderung 1 — Freiwilliger vs unfreiwilliger Austritt. Die Liste der Beendigungsarten zieht aus TerminatePanel.tsx nach lib/beendigung.ts um: der Berichtemanager braucht sie ebenso, und zwei Listen liefen auseinander. Zwei neue Arten (Beendigung in der Probezeit, je Seite). Auf wessen Betreiben beendet wurde, wird aus der Art **abgeleitet** und nicht daneben gespeichert — als zweites freies Feld liesse sich "Entlassung, freiwillig" erfassen. Das Dropdown im Formular schraenkt die Auswahl darunter ein. Drei Gruppen statt zwei: Befristungsablauf geschieht auf niemandes Betreiben, ein Nichtantritt ist kein Austritt. Beide einer Seite zuzuschlagen wuerde jede Fluktuationsquote verfaelschen. Anforderung 2 — Namensfilter in "Anstehend", ab neun Eintraegen. Anforderung 3 — die zwei Unterschriftenfelder im gedruckten Blatt sind weg; "Firmenfahrzeug" steht in beiden Checklisten. has_dienstwagen sagt, ob eines zusteht, nicht ob es uebergeben wurde. Anforderung 4 — "+794 weitere" ist ein Knopf geworden; die Namen waren vorher nur ueber den Export erreichbar. Stammdatenaenderung und Gehaltsanpassung stehen nicht mehr zur Auswahl: die eine entsteht bei jeder geaenderten Telefonnummer, die andere ist ein totes Ereignis, seit das Gehalt in Loga liegt. Neu ist der Untertyp — Beendigungsart beim Austritt, Art der Abwesenheit bei der Langzeitabwesenheit, im Bericht und im Export. Anforderung 10 — zwei Kacheln. "Aktives Dienstverhaeltnis" ist nicht dasselbe wie "Aktive Mitarbeiter:innen": dort steht, wer heute arbeitet, hier, mit wem ein Vertrag laeuft. Sichtbar waren 806 und 10, addieren musste man selbst.
This commit is contained in:
86
lib/beendigung.ts
Normal file
86
lib/beendigung.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
// Wie ein Dienstverhältnis endet — und auf wessen Betreiben.
|
||||
//
|
||||
// Die Liste stand bisher in components/employees/panels/TerminatePanel.tsx und
|
||||
// damit an genau einer Stelle: dem Formular. Der Berichtemanager konnte
|
||||
// deshalb nach dem Ereignis „Austritt" fragen, aber nicht danach, *warum* —
|
||||
// und die Frage, die im Personalwesen zuerst kommt, ist die nach der
|
||||
// Fluktuation: wie viele sind gegangen, wie vielen wurde gekündigt.
|
||||
//
|
||||
// ── Warum die Zuordnung abgeleitet und nicht gespeichert wird ────────
|
||||
//
|
||||
// Aus dem Workshop kam „ein weiteres Dropdown: freiwilliger / unfreiwilliger
|
||||
// Austritt". Als zweites, frei wählbares Feld liesse es sich dem Grund
|
||||
// widersprechen — „Entlassung, freiwillig" wäre eine erfassbare Eingabe, und
|
||||
// zwei Auswertungen über dieselbe Person kämen zu verschiedenen Ergebnissen.
|
||||
// Auf wessen Betreiben beendet wird, steckt bereits in der Beendigungsart;
|
||||
// das Dropdown schränkt deshalb die Auswahl ein, statt eine zweite Wahrheit
|
||||
// daneben zu stellen. Gespeichert wird weiterhin nur `exit_reason`.
|
||||
//
|
||||
// ── Warum es drei Gruppen sind und nicht zwei ────────────────────────
|
||||
//
|
||||
// Zwei Gruppen decken die Liste nicht ab. Ein Befristungsablauf geschieht auf
|
||||
// niemandes Betreiben — er war von Anfang an vereinbart. Ein Nichtantritt ist
|
||||
// kein Austritt, sondern ein Verhältnis, das nie begonnen hat. Beide einer der
|
||||
// beiden Seiten zuzuschlagen würde jede Fluktuationsquote verfälschen, und
|
||||
// zwar in eine Richtung, die niemand mehr nachvollziehen kann. Sie stehen
|
||||
// deshalb als „Sonstige" für sich.
|
||||
//
|
||||
// Offen und im Begleitschreiben an den Kunden gestellt: ob die
|
||||
// einvernehmliche Auflösung — hier als freiwillig geführt, weil sie die
|
||||
// Zustimmung der Person voraussetzt — dort richtig steht. In der Praxis geht
|
||||
// ihr oft ein Wunsch des Dienstgebers voraus.
|
||||
|
||||
export const BEENDIGUNG_NO_SHOW = "No Show";
|
||||
|
||||
export type Austrittsart = "freiwillig" | "unfreiwillig" | "sonstige";
|
||||
|
||||
export const AUSTRITTSART_LABELS: Record<Austrittsart, string> = {
|
||||
freiwillig: "Freiwilliger Austritt",
|
||||
unfreiwillig: "Unfreiwilliger Austritt",
|
||||
sonstige: "Sonstige Beendigung",
|
||||
};
|
||||
|
||||
export type Beendigungsart = {
|
||||
/** Der gespeicherte Wert in `employees.exit_reason`. */
|
||||
wert: string;
|
||||
art: Austrittsart;
|
||||
};
|
||||
|
||||
export const BEENDIGUNGSARTEN: readonly Beendigungsart[] = [
|
||||
{ wert: "Einvernehmliche Auflösung", art: "freiwillig" },
|
||||
{ wert: "Kündigung AN", art: "freiwillig" },
|
||||
{ wert: "Pensionierung", art: "freiwillig" },
|
||||
// Aus dem Workshop nachgetragen (Anforderung 1). In der Probezeit kann jede
|
||||
// Seite ohne Frist und ohne Grund beenden; welche es war, ist genau die
|
||||
// Unterscheidung, die sonst verlorenginge.
|
||||
{ wert: "Dienstnehmerseitige Beendigung in Probezeit", art: "freiwillig" },
|
||||
{ wert: "Kündigung AG", art: "unfreiwillig" },
|
||||
{ wert: "Entlassung", art: "unfreiwillig" },
|
||||
{ wert: "Dienstgeberseitige Beendigung in Probezeit", art: "unfreiwillig" },
|
||||
{ wert: "Befristungsablauf", art: "sonstige" },
|
||||
// „No Show" steht am Ende und getrennt: es ist kein Austritt im gewohnten
|
||||
// Sinn, sondern der Fall, dass jemand nie angetreten ist.
|
||||
{ wert: BEENDIGUNG_NO_SHOW, art: "sonstige" },
|
||||
] as const;
|
||||
|
||||
export const BEENDIGUNGSART_WERTE: readonly string[] = BEENDIGUNGSARTEN.map((b) => b.wert);
|
||||
|
||||
const ART_ZU_WERT = new Map(BEENDIGUNGSARTEN.map((b) => [b.wert, b.art] as const));
|
||||
|
||||
/**
|
||||
* Auf wessen Betreiben beendet wurde — oder null, wenn der gespeicherte Grund
|
||||
* in keiner der Gruppen steht.
|
||||
*
|
||||
* Null ist kein Sonderfall, sondern der Normalfall für Altdaten: `exit_reason`
|
||||
* ist eine Textspalte ohne Prüfung, und der Massenimport schreibt hinein, was
|
||||
* in der Datei stand. Wer hier auf eine Gruppe rät, ordnet fremde Schreibweisen
|
||||
* still der falschen zu.
|
||||
*/
|
||||
export function austrittsartVon(grund: string | null | undefined): Austrittsart | null {
|
||||
return grund ? (ART_ZU_WERT.get(grund) ?? null) : null;
|
||||
}
|
||||
|
||||
/** Die Beendigungsarten einer Gruppe — für die abhängige Auswahl im Formular. */
|
||||
export function beendigungsartenZu(art: Austrittsart | null): readonly string[] {
|
||||
return art ? BEENDIGUNGSART_WERTE.filter((w) => ART_ZU_WERT.get(w) === art) : BEENDIGUNGSART_WERTE;
|
||||
}
|
||||
@@ -40,6 +40,9 @@ export const OFFBOARDING_GRUPPEN: readonly AufgabenGruppe[] = [
|
||||
{ key: "if6020_austritt", label: "IF 6020 Austritt", art: "haken" },
|
||||
{ key: "ueberleitung_salden", label: "Überleitung Salden für Auszahlung", art: "haken" },
|
||||
{ key: "endabrechnung", label: "Endabrechnung", art: "haken" },
|
||||
// Gegenstück zum gleichnamigen Punkt beim Onboarding: dort die Übergabe,
|
||||
// hier die Rückgabe. Aus dem Workshop nachgetragen.
|
||||
{ key: "firmenfahrzeug", label: "Firmenfahrzeug", art: "haken" },
|
||||
{ key: "offboarding_gestartet", label: "Offboarding gestartet", art: "haken" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -45,6 +45,11 @@ export const ONBOARDING_GRUPPEN: readonly AufgabenGruppe[] = [
|
||||
{ key: "eurest", label: "Anmeldung EUREST", art: "haken" },
|
||||
{ key: "oegk", label: "Anmeldung ÖGK", art: "haken" },
|
||||
{ key: "webclient", label: "WebClient", art: "haken" },
|
||||
// Aus dem Workshop nachgetragen: die Übergabe eines Firmenfahrzeugs ist
|
||||
// ein eigener Vorgang mit Papieren und Schlüsseln und stand bisher
|
||||
// nirgends. `employees.has_dienstwagen` sagt, ob eines zusteht — nicht,
|
||||
// ob es übergeben wurde. Das sind zwei verschiedene Fragen.
|
||||
{ key: "firmenfahrzeug", label: "Firmenfahrzeug", art: "haken" },
|
||||
{ key: "datensatzberechtigung", label: "Datensatzberechtigung Führungskraft", art: "haken" },
|
||||
{ key: "praemienanspruch", label: "Prämienanspruch", art: "janein" },
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ import { loadPlacements } from "./placement";
|
||||
import { LEERE_CRITERIA, passtImSpeicher, type Criteria } from "./report-criteria";
|
||||
import {
|
||||
deriveStatusAsOf,
|
||||
EREIGNIS_UNTERTYP,
|
||||
EVENT_DATE_OPEN,
|
||||
parseStatuses,
|
||||
todayIso,
|
||||
@@ -102,7 +103,15 @@ export function applyCriteria<O>(
|
||||
}
|
||||
|
||||
export type SnapshotFilters = ReportFilters & { asOf?: string };
|
||||
export type EventFilters = { eventType?: HistoryEventType; division?: string; location?: string; from?: string; to?: string };
|
||||
export type EventFilters = {
|
||||
eventType?: HistoryEventType;
|
||||
/** Beendigungsart bzw. Art der Langzeitabwesenheit — nur mit eventType sinnvoll. */
|
||||
eventSubtype?: string;
|
||||
division?: string;
|
||||
location?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Für jede Einheit vorberechnen, welcher Bereich, welche Abteilung und
|
||||
@@ -281,7 +290,14 @@ export async function loadEventHistory(tx: Tx, filters: EventFilters): Promise<R
|
||||
|
||||
const [history, employees, assignments, orgMaps] = await Promise.all([
|
||||
historyQuery().execute(),
|
||||
tx.selectFrom("employees").select(["id", "first_name", "last_name", "job_title", "location_id"]).orderBy("id").execute(),
|
||||
tx
|
||||
.selectFrom("employees")
|
||||
// exit_reason/absence_type für den Untertyp eines Ereignisses — siehe
|
||||
// ReportEvent.subtype in lib/reports.ts, dort steht auch, wo die Grenze
|
||||
// dieser Zuordnung liegt.
|
||||
.select(["id", "first_name", "last_name", "job_title", "location_id", "exit_reason", "absence_type"])
|
||||
.orderBy("id")
|
||||
.execute(),
|
||||
tx
|
||||
.selectFrom("position_assignments as a")
|
||||
.innerJoin("om_positions as p", "p.id", "a.position_id")
|
||||
@@ -311,6 +327,12 @@ export async function loadEventHistory(tx: Tx, filters: EventFilters): Promise<R
|
||||
spans.get(h.employee_id)?.find((s2) => s2.from <= h.event_date && (s2.to === null || s2.to > h.event_date))?.unitId ?? null;
|
||||
if (allowedUnits && (!unitId || !allowedUnits.has(unitId))) continue;
|
||||
|
||||
const subtype = untertypVon(h.event_type, emp);
|
||||
// Der Untertyp schränkt nur innerhalb seines Ereignistyps ein. Ohne die
|
||||
// erste Hälfte fielen bei „Alle Ereignistypen" sämtliche Zeilen ohne
|
||||
// Untertyp heraus — eine Versetzung hat keine Beendigungsart.
|
||||
if (filters.eventSubtype && EREIGNIS_UNTERTYP[h.event_type] && subtype !== filters.eventSubtype) continue;
|
||||
|
||||
events.push({
|
||||
employee_id: emp.id,
|
||||
first_name: emp.first_name,
|
||||
@@ -321,7 +343,24 @@ export async function loadEventHistory(tx: Tx, filters: EventFilters): Promise<R
|
||||
event_date: h.event_date,
|
||||
event_type: h.event_type,
|
||||
description: h.description,
|
||||
subtype,
|
||||
});
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Untertyp eines Ereignisses, gelesen von der Person.
|
||||
*
|
||||
* `Rückkehr` erbt die Art der Abwesenheit bewusst nicht: sie wird beim
|
||||
* Zurückkommen zurückgesetzt, und was dort heute steht, gehört zur nächsten
|
||||
* Abwesenheit, nicht zur beendeten.
|
||||
*/
|
||||
function untertypVon(
|
||||
typ: HistoryEventType,
|
||||
emp: { exit_reason: string | null; absence_type: string | null }
|
||||
): string | null {
|
||||
if (typ === "Austritt") return emp.exit_reason;
|
||||
if (typ === "Karenz") return emp.absence_type;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ABSENCE_TYPES } from "./absence";
|
||||
import { BEENDIGUNGSART_WERTE } from "./beendigung";
|
||||
import { fmtName, todayIso, yearsBetweenIso } from "./format";
|
||||
import type { EmploymentStatus, HistoryEventType, Weekday } from "./types";
|
||||
|
||||
@@ -392,6 +394,28 @@ export const EVENT_TYPE_LABELS: Record<HistoryEventType, string> = {
|
||||
Rückkehr: "Rückkehr aus Langzeitabwesenheit",
|
||||
};
|
||||
|
||||
// ── Was im Berichtemanager zur Auswahl steht ────────────────────────
|
||||
//
|
||||
// Nicht alle elf. EVENT_TYPE_LABELS bleibt vollständig — die Historie einer
|
||||
// Person zeigt jedes Ereignis, und jedes braucht seine Beschriftung und seine
|
||||
// Farbe. Die *Auswertung* fragt aber nach Bewegungen im Bestand, und zwei
|
||||
// Typen sind dort nur Rauschen:
|
||||
//
|
||||
// * „Stammdatenänderung" entsteht bei jeder geänderten Telefonnummer.
|
||||
// * „Gehaltsanpassung" ist ein totes Ereignis: das Gehalt liegt in Loga,
|
||||
// keine SQL-Funktion schreibt diesen Typ mehr.
|
||||
//
|
||||
// Beide standen in der Liste und lieferten Auswertungen, die niemand wollte.
|
||||
// Aus dem Workshop gestrichen (Anforderung 4).
|
||||
//
|
||||
// „Wiedereintritt" stand dagegen zu Recht schon hier und bekommt in
|
||||
// lib/colors.ts dieselbe grüne Kategorie wie der Eintritt — es ist einer.
|
||||
const NICHT_AUSWERTBAR: readonly HistoryEventType[] = ["Stammdatenänderung", "Gehaltsanpassung"];
|
||||
|
||||
export const EREIGNIS_AUSWAHL: readonly HistoryEventType[] = (
|
||||
Object.keys(EVENT_TYPE_LABELS) as HistoryEventType[]
|
||||
).filter((t) => !NICHT_AUSWERTBAR.includes(t));
|
||||
|
||||
export type ReportEvent = {
|
||||
employee_id: string;
|
||||
first_name: string;
|
||||
@@ -403,6 +427,25 @@ export type ReportEvent = {
|
||||
event_date: string;
|
||||
event_type: HistoryEventType;
|
||||
description: string;
|
||||
/**
|
||||
* Der Untertyp des Ereignisses — die Beendigungsart bei einem Austritt, die
|
||||
* Art der Abwesenheit bei einer Langzeitabwesenheit. Beides steht auf der
|
||||
* Person und nicht am Ereignis: `employee_history` hält den Verlauf, die
|
||||
* Einzelheiten stehen in `employees`.
|
||||
*
|
||||
* Daraus folgt die Grenze, und sie steht auch in der Oberfläche: nach einer
|
||||
* Wiedereinstellung setzt rehire_employee `exit_reason` auf null zurück, und
|
||||
* der Austritt von damals hat dann keinen Grund mehr. Für die Frage, die
|
||||
* gestellt wird — „wie viele sind dieses Jahr gegangen, und warum" —, ist
|
||||
* das ohne Belang; für eine Auswertung über fünf Jahre wäre es eine Lücke.
|
||||
*/
|
||||
subtype: string | null;
|
||||
};
|
||||
|
||||
/** Zu welchen Ereignistypen es überhaupt einen Untertyp gibt. */
|
||||
export const EREIGNIS_UNTERTYP: Partial<Record<HistoryEventType, { label: string; alle: string }>> = {
|
||||
Austritt: { label: "Beendigungsart", alle: "Alle Beendigungsarten" },
|
||||
Karenz: { label: "Art der Langzeitabwesenheit", alle: "Alle Arten" },
|
||||
};
|
||||
|
||||
function eventGroupKeyFor(e: ReportEvent, dim: EventGroupDimension, lookups: OrgLookups): string {
|
||||
@@ -518,6 +561,26 @@ export function parseEventType(value: string | null | undefined): HistoryEventTy
|
||||
return value && Object.hasOwn(EVENT_TYPE_LABELS, value) ? (value as HistoryEventType) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Untertyp aus der Adresszeile — geprüft gegen die Liste, die zum
|
||||
* gewählten Ereignistyp gehört.
|
||||
*
|
||||
* Ohne den Ereignistyp gibt es keinen gültigen Untertyp: „Wochenhilfe" zu
|
||||
* einem Austritt ist keine Einschränkung, sondern ein leeres Ergebnis mit
|
||||
* unklarer Ursache.
|
||||
*/
|
||||
export function parseEventSubtype(value: string | null | undefined, typ: HistoryEventType | null): string | null {
|
||||
if (!value || !typ || !EREIGNIS_UNTERTYP[typ]) return null;
|
||||
return untertypOptionen(typ).includes(value) ? value : null;
|
||||
}
|
||||
|
||||
/** Die erlaubten Untertypen eines Ereignistyps. */
|
||||
export function untertypOptionen(typ: HistoryEventType | null): readonly string[] {
|
||||
if (typ === "Austritt") return BEENDIGUNGSART_WERTE;
|
||||
if (typ === "Karenz") return ABSENCE_TYPES;
|
||||
return [];
|
||||
}
|
||||
|
||||
// Rejects anything that is not a real calendar date, so a Stichtag from the
|
||||
// URL can never reach a date comparison (or a column header) as free text.
|
||||
export function parseIsoDateParam(value: string | null | undefined): string | undefined {
|
||||
|
||||
Reference in New Issue
Block a user