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:
@@ -23,6 +23,9 @@ const TONE: Record<string, { text: string; bar: string }> = {
|
|||||||
danger: { text: "text-danger-text", bar: "bg-danger-text" },
|
danger: { text: "text-danger-text", bar: "bg-danger-text" },
|
||||||
warning: { text: "text-warning-text", bar: "bg-warning-text" },
|
warning: { text: "text-warning-text", bar: "bg-warning-text" },
|
||||||
brand: { text: "text-brand-700", bar: "bg-brand-500" },
|
brand: { text: "text-brand-700", bar: "bg-brand-500" },
|
||||||
|
// Geplante Eintritte: eine Ankündigung, kein Befund. „info" ist der Ton,
|
||||||
|
// den die Statuschips für „Geplant" ohnehin tragen.
|
||||||
|
info: { text: "text-info-text", bar: "bg-info-text" },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Die Tabelle, die hier stand, ist nach lib/colors.ts gewandert: Punkt und
|
// Die Tabelle, die hier stand, ist nach lib/colors.ts gewandert: Punkt und
|
||||||
@@ -169,13 +172,39 @@ export default async function DashboardPage({
|
|||||||
href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`,
|
href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`,
|
||||||
},
|
},
|
||||||
{ label: "Langzeitabwesend", value: karenzCount, tone: "warning", href: "/employees?status=Karenz" },
|
{ label: "Langzeitabwesend", value: karenzCount, tone: "warning", href: "/employees?status=Karenz" },
|
||||||
|
// ── Zwei Kacheln aus dem Workshop (Anforderung 10) ──────────────
|
||||||
|
//
|
||||||
|
// „Aktives Dienstverhältnis" ist nicht dasselbe wie „Aktive
|
||||||
|
// Mitarbeiter:innen" ganz links. Dort steht, wer heute arbeitet; hier,
|
||||||
|
// mit wem ein Vertrag läuft — Langzeitabwesende eingeschlossen. Genau
|
||||||
|
// diese Zahl ist die Bezugsgrösse fast jeder Personalkennzahl, und sie
|
||||||
|
// war bisher nirgends zu sehen: sichtbar waren 806 und 10, addieren
|
||||||
|
// musste man selbst.
|
||||||
|
{
|
||||||
|
label: "Aktives Dienstverhältnis",
|
||||||
|
value: activeCount + karenzCount,
|
||||||
|
tone: "default",
|
||||||
|
href: "/employees?status=Aktiv&status=Karenz",
|
||||||
|
},
|
||||||
|
// Geplante Eintritte ohne Zeitgrenze — anders als die Karte „Anstehend"
|
||||||
|
// darunter, die nur den eingestellten Vorschauzeitraum zeigt. Ein
|
||||||
|
// Eintritt in vier Monaten ist vereinbart und zählt, auch wenn er in der
|
||||||
|
// Vorschau noch nicht auftaucht.
|
||||||
|
{
|
||||||
|
label: "Geplante Eintritte",
|
||||||
|
value: staffRows.filter((row) => statusOf(row) === "Geplant").length,
|
||||||
|
tone: "info",
|
||||||
|
href: "/employees?status=Geplant",
|
||||||
|
},
|
||||||
{ label: "Offene Positionen", value: openPositions.length, tone: "brand", href: "/positions" },
|
{ label: "Offene Positionen", value: openPositions.length, tone: "brand", href: "/positions" },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{drafts.length > 0 && <DraftsCard drafts={drafts} />}
|
{drafts.length > 0 && <DraftsCard drafts={drafts} />}
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
{/* Acht Kacheln: vier je Zeile ab der grossen Breite, damit keine
|
||||||
|
Zeile mit zwei Resten endet. */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-4 xl:grid-cols-8">
|
||||||
{kpis.map((kpi) => (
|
{kpis.map((kpi) => (
|
||||||
<Link
|
<Link
|
||||||
key={kpi.label}
|
key={kpi.label}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
parseEventDateParam,
|
parseEventDateParam,
|
||||||
parseEventGroupDimension,
|
parseEventGroupDimension,
|
||||||
parseEventSplitDimension,
|
parseEventSplitDimension,
|
||||||
|
parseEventSubtype,
|
||||||
parseEventType,
|
parseEventType,
|
||||||
parseGroupDimension,
|
parseGroupDimension,
|
||||||
parseIsoDateParam,
|
parseIsoDateParam,
|
||||||
@@ -33,6 +34,7 @@ type SearchParams = Record<string, string | string[] | undefined> & {
|
|||||||
status?: string;
|
status?: string;
|
||||||
asOf?: string;
|
asOf?: string;
|
||||||
eventType?: string;
|
eventType?: string;
|
||||||
|
eventSubtype?: string;
|
||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
};
|
};
|
||||||
@@ -48,6 +50,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
|||||||
const eventGroup = parseEventGroupDimension(params.group);
|
const eventGroup = parseEventGroupDimension(params.group);
|
||||||
const eventSplit = parseEventSplitDimension(params.split);
|
const eventSplit = parseEventSplitDimension(params.split);
|
||||||
const eventType = parseEventType(params.eventType);
|
const eventType = parseEventType(params.eventType);
|
||||||
|
const eventSubtype = parseEventSubtype(params.eventSubtype, eventType);
|
||||||
const from = parseEventDateParam(params.from);
|
const from = parseEventDateParam(params.from);
|
||||||
const to = parseEventDateParam(params.to);
|
const to = parseEventDateParam(params.to);
|
||||||
const measure = parseMeasure(params.measure);
|
const measure = parseMeasure(params.measure);
|
||||||
@@ -71,6 +74,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
|||||||
mode === "events"
|
mode === "events"
|
||||||
? loadEventHistory(tx, {
|
? loadEventHistory(tx, {
|
||||||
eventType: eventType ?? undefined,
|
eventType: eventType ?? undefined,
|
||||||
|
eventSubtype: eventSubtype ?? undefined,
|
||||||
division: params.division,
|
division: params.division,
|
||||||
location: params.location,
|
location: params.location,
|
||||||
from,
|
from,
|
||||||
@@ -108,6 +112,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
|||||||
eventGroup={eventGroup}
|
eventGroup={eventGroup}
|
||||||
eventSplit={eventSplit ?? ""}
|
eventSplit={eventSplit ?? ""}
|
||||||
eventType={eventType ?? ""}
|
eventType={eventType ?? ""}
|
||||||
|
eventSubtype={eventSubtype ?? ""}
|
||||||
eventFilters={{ division: params.division ?? "", location: params.location ?? "", from: from ?? "", to: to ?? "" }}
|
eventFilters={{ division: params.division ?? "", location: params.location ?? "", from: from ?? "", to: to ?? "" }}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
total={sumValues(rows)}
|
total={sumValues(rows)}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { NextResponse, type NextRequest } from "next/server";
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
|
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
|
||||||
import { EVENT_TYPE_LABELS, parseEventDateParam, parseEventType, type OrgLookups, type ReportEvent } from "@/lib/reports";
|
import {
|
||||||
|
EVENT_TYPE_LABELS,
|
||||||
|
parseEventDateParam,
|
||||||
|
parseEventSubtype,
|
||||||
|
parseEventType,
|
||||||
|
type OrgLookups,
|
||||||
|
type ReportEvent,
|
||||||
|
} from "@/lib/reports";
|
||||||
import { loadEventHistory, loadOrgLookups } from "@/lib/reports-data";
|
import { loadEventHistory, loadOrgLookups } from "@/lib/reports-data";
|
||||||
import { requireHrUser } from "@/lib/auth/require-hr";
|
import { requireHrUser } from "@/lib/auth/require-hr";
|
||||||
import { withUser } from "@/lib/db";
|
import { withUser } from "@/lib/db";
|
||||||
@@ -22,6 +29,10 @@ export async function GET(request: NextRequest) {
|
|||||||
loadOrgLookups(tx),
|
loadOrgLookups(tx),
|
||||||
loadEventHistory(tx, {
|
loadEventHistory(tx, {
|
||||||
eventType: eventType ?? undefined,
|
eventType: eventType ?? undefined,
|
||||||
|
// Ohne diese Zeile exportierte die Schaltfläche neben der Auswertung
|
||||||
|
// etwas anderes, als auf dem Bildschirm steht — die Einschränkung auf
|
||||||
|
// die Beendigungsart fiele beim Export still weg.
|
||||||
|
eventSubtype: parseEventSubtype(params.get("eventSubtype"), eventType) ?? undefined,
|
||||||
division: params.get("division") ?? undefined,
|
division: params.get("division") ?? undefined,
|
||||||
location: params.get("location") ?? undefined,
|
location: params.get("location") ?? undefined,
|
||||||
from: parseEventDateParam(params.get("from")),
|
from: parseEventDateParam(params.get("from")),
|
||||||
@@ -43,6 +54,9 @@ function eventExportColumns(lookups: OrgLookups): ExportColumn<ReportEvent>[] {
|
|||||||
return [
|
return [
|
||||||
{ header: "Datum", get: (e) => e.event_date, kind: "date" },
|
{ header: "Datum", get: (e) => e.event_date, kind: "date" },
|
||||||
{ header: "Ereignistyp", get: (e) => EVENT_TYPE_LABELS[e.event_type] ?? e.event_type },
|
{ header: "Ereignistyp", get: (e) => EVENT_TYPE_LABELS[e.event_type] ?? e.event_type },
|
||||||
|
// Beendigungsart bzw. Art der Langzeitabwesenheit. Leer bei allen
|
||||||
|
// Ereignissen, die keinen Untertyp kennen — eine Versetzung hat keinen.
|
||||||
|
{ header: "Untertyp", get: (e) => e.subtype ?? "" },
|
||||||
{ header: "Vorname", get: (e) => e.first_name },
|
{ header: "Vorname", get: (e) => e.first_name },
|
||||||
{ header: "Nachname", get: (e) => e.last_name },
|
{ header: "Nachname", get: (e) => e.last_name },
|
||||||
{ header: "Position", get: (e) => e.job_title },
|
{ header: "Position", get: (e) => e.job_title },
|
||||||
|
|||||||
@@ -47,11 +47,40 @@ export function AnstehendListe({
|
|||||||
leerText: string;
|
leerText: string;
|
||||||
}) {
|
}) {
|
||||||
const [alle, setAlle] = useState(false);
|
const [alle, setAlle] = useState(false);
|
||||||
const sichtbar = alle ? eintraege : eintraege.slice(0, VORSCHAU);
|
// Namensfilter aus dem Workshop (Anforderung 2). Im Browser und nicht über
|
||||||
const weitere = eintraege.length - sichtbar.length;
|
// die Adresse, aus demselben Grund wie das Aufklappen: die Einträge sind
|
||||||
|
// vollständig geladen, es gibt nichts nachzuholen.
|
||||||
|
const [suche, setSuche] = useState("");
|
||||||
|
const begriff = suche.trim().toLowerCase();
|
||||||
|
// Wortweise und am Wortanfang — dieselbe Regel wie in der Mitarbeiterliste,
|
||||||
|
// damit „gru" Gruber findet und nicht auch jede Notiz mit „Arbeitsgruppe".
|
||||||
|
// Der Hinweis wird bewusst nicht durchsucht: gesucht wird nach Personen.
|
||||||
|
const gefiltert = begriff
|
||||||
|
? eintraege.filter((e) =>
|
||||||
|
e.label
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/[\s,;/-]+/)
|
||||||
|
.some((wort) => wort.startsWith(begriff))
|
||||||
|
)
|
||||||
|
: eintraege;
|
||||||
|
const sichtbar = alle ? gefiltert : gefiltert.slice(0, VORSCHAU);
|
||||||
|
const weitere = gefiltert.length - sichtbar.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* Erst ab neun Einträgen: darunter sieht man die ganze Liste ohnehin,
|
||||||
|
und ein Suchfeld über acht Zeilen ist mehr Bedienelement als Hilfe. */}
|
||||||
|
{eintraege.length > VORSCHAU && (
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={suche}
|
||||||
|
onChange={(e) => setSuche(e.target.value)}
|
||||||
|
placeholder="Name filtern…"
|
||||||
|
aria-label="Anstehende Ereignisse nach Name filtern"
|
||||||
|
className="mb-2 w-full rounded border border-border-strong bg-white px-2.5 py-1.5 text-sm text-ink
|
||||||
|
placeholder:text-ink-muted focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ul className="flex flex-col divide-y divide-border-subtle">
|
<ul className="flex flex-col divide-y divide-border-subtle">
|
||||||
{sichtbar.map((item) => {
|
{sichtbar.map((item) => {
|
||||||
// Überfällig gibt es nur bei Wiedervorlagen: die anderen Arten
|
// Überfällig gibt es nur bei Wiedervorlagen: die anderen Arten
|
||||||
@@ -83,6 +112,14 @@ export function AnstehendListe({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{eintraege.length === 0 && <p className="py-2 text-sm text-ink-muted">{leerText}</p>}
|
{eintraege.length === 0 && <p className="py-2 text-sm text-ink-muted">{leerText}</p>}
|
||||||
|
{/* Ein leeres Ergebnis mit Suchbegriff ist etwas anderes als eine
|
||||||
|
leere Karte: der Zeitraum stimmt, nur der Name passt nicht. */}
|
||||||
|
{eintraege.length > 0 && gefiltert.length === 0 && (
|
||||||
|
<p className="py-2 text-sm text-ink-muted">
|
||||||
|
Kein Name passt zu „{suche.trim()}“. {eintraege.length}{" "}
|
||||||
|
{eintraege.length === 1 ? "Ereignis steht" : "Ereignisse stehen"} an.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{/* Der Knopf steht nur da, wenn es etwas zu holen gibt. Ist die Liste
|
{/* Der Knopf steht nur da, wenn es etwas zu holen gibt. Ist die Liste
|
||||||
|
|||||||
@@ -183,11 +183,12 @@ export function PrintChecklist({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Fuss: Platz für Unterschriften ─────────────────── */}
|
{/* Hier standen zwei Unterschriftenfelder — „Datum, Unterschrift
|
||||||
<div className="mt-6 flex gap-10 text-[8pt] text-ink-muted">
|
Mitarbeiter:in" und „… Personalabteilung". Der Kunde hat sie
|
||||||
<div className="flex-1 border-t border-ink pt-1">Datum, Unterschrift Mitarbeiter:in</div>
|
gestrichen (Anforderung 3 aus dem Workshop): das Blatt ist eine
|
||||||
<div className="flex-1 border-t border-ink pt-1">Datum, Unterschrift Personalabteilung</div>
|
Arbeitsliste für die Personalabteilung und kein Dokument, das
|
||||||
</div>
|
jemand gegenzeichnet. Wer eine Aufgabe abgehakt hat, steht ohnehin
|
||||||
|
an der Aufgabe selbst. */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,23 +7,23 @@ import { Button } from "@/components/ui/Button";
|
|||||||
import { SelectField, TextField, TextareaField } from "@/components/ui/Field";
|
import { SelectField, TextField, TextareaField } from "@/components/ui/Field";
|
||||||
import { SlideOver } from "@/components/ui/SlideOver";
|
import { SlideOver } from "@/components/ui/SlideOver";
|
||||||
import { useToast } from "@/components/ui/Toast";
|
import { useToast } from "@/components/ui/Toast";
|
||||||
|
import {
|
||||||
|
AUSTRITTSART_LABELS,
|
||||||
|
BEENDIGUNG_NO_SHOW,
|
||||||
|
BEENDIGUNGSART_WERTE,
|
||||||
|
austrittsartVon,
|
||||||
|
beendigungsartenZu,
|
||||||
|
type Austrittsart,
|
||||||
|
} from "@/lib/beendigung";
|
||||||
import type { Database } from "@/lib/types";
|
import type { Database } from "@/lib/types";
|
||||||
import { fmtDate, fmtName } from "@/lib/format";
|
import { fmtDate, fmtName } from "@/lib/format";
|
||||||
|
|
||||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||||
|
|
||||||
// „No Show" steht am Ende und getrennt: es ist kein Austritt im gewohnten
|
// Die Liste steht in lib/beendigung.ts, nicht mehr hier: der Berichtemanager
|
||||||
// Sinn, sondern der Fall, dass jemand nie angetreten ist.
|
// braucht sie ebenso, und zwei Listen liefen auseinander.
|
||||||
const NO_SHOW = "No Show";
|
const NO_SHOW = BEENDIGUNG_NO_SHOW;
|
||||||
const EXIT_REASONS = [
|
|
||||||
"Einvernehmliche Auflösung",
|
|
||||||
"Kündigung AN",
|
|
||||||
"Kündigung AG",
|
|
||||||
"Befristungsablauf",
|
|
||||||
"Pensionierung",
|
|
||||||
"Entlassung",
|
|
||||||
NO_SHOW,
|
|
||||||
];
|
|
||||||
type TerminatePanelProps = {
|
type TerminatePanelProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -38,7 +38,12 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
|
|||||||
// Wer noch gar nicht angefangen hat, tritt fast nie aus einem anderen Grund
|
// Wer noch gar nicht angefangen hat, tritt fast nie aus einem anderen Grund
|
||||||
// aus. Die Vorbelegung nimmt den wahrscheinlichen Fall vorweg, ohne die
|
// aus. Die Vorbelegung nimmt den wahrscheinlichen Fall vorweg, ohne die
|
||||||
// übrigen zu verstellen.
|
// übrigen zu verstellen.
|
||||||
const [reason, setReason] = useState(employee.status === "Geplant" ? NO_SHOW : EXIT_REASONS[0]);
|
const [reason, setReason] = useState(employee.status === "Geplant" ? NO_SHOW : BEENDIGUNGSART_WERTE[0]);
|
||||||
|
// „Alle" statt einer Vorauswahl: die Einschränkung ist eine Hilfe beim
|
||||||
|
// Suchen, keine Pflichtangabe. Wer die gewünschte Art auswendig kennt,
|
||||||
|
// soll sie unmittelbar wählen können.
|
||||||
|
const [austrittsart, setAustrittsart] = useState<Austrittsart | "">("");
|
||||||
|
const waehlbar = beendigungsartenZu(austrittsart || null);
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
@@ -113,11 +118,36 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
|
|||||||
{directReportCount} direkte Berichte werden automatisch der nächsthöheren Führungskraft zugeordnet.
|
{directReportCount} direkte Berichte werden automatisch der nächsthöheren Führungskraft zugeordnet.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Die Austrittsart wählt nicht mit, sie engt ein. Gespeichert wird
|
||||||
|
allein die Beendigungsart; auf wessen Betreiben beendet wurde,
|
||||||
|
leitet lib/beendigung.ts daraus ab. Als zweites freies Feld liesse
|
||||||
|
sich „Entlassung, freiwillig" erfassen, und zwei Auswertungen über
|
||||||
|
dieselbe Person kämen zu verschiedenen Ergebnissen. */}
|
||||||
|
<SelectField
|
||||||
|
label="Austrittsart"
|
||||||
|
value={austrittsart}
|
||||||
|
onChange={(v) => {
|
||||||
|
const neu = (v || "") as Austrittsart | "";
|
||||||
|
setAustrittsart(neu);
|
||||||
|
// Passt der bisher gewählte Grund nicht mehr zur Einschränkung,
|
||||||
|
// wandert die Auswahl auf den ersten passenden. Stehenzulassen,
|
||||||
|
// was in der Liste nicht mehr sichtbar ist, wäre ein Formular,
|
||||||
|
// das etwas anderes absendet, als es zeigt.
|
||||||
|
const erlaubt = beendigungsartenZu(neu || null);
|
||||||
|
if (!erlaubt.includes(reason)) setReason(erlaubt[0]);
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "Alle Beendigungsarten" },
|
||||||
|
...(Object.keys(AUSTRITTSART_LABELS) as Austrittsart[]).map((a) => ({ value: a, label: AUSTRITTSART_LABELS[a] })),
|
||||||
|
]}
|
||||||
|
hint="Schränkt die Auswahl darunter ein. Gespeichert wird die Beendigungsart."
|
||||||
|
/>
|
||||||
<SelectField
|
<SelectField
|
||||||
label="Beendigungsart"
|
label="Beendigungsart"
|
||||||
value={reason}
|
value={reason}
|
||||||
onChange={setReason}
|
onChange={setReason}
|
||||||
options={EXIT_REASONS.map((r) => ({ value: r, label: r === NO_SHOW ? "No Show (nicht angetreten)" : r }))}
|
options={waehlbar.map((r) => ({ value: r, label: r === NO_SHOW ? "No Show (nicht angetreten)" : r }))}
|
||||||
|
hint={austrittsartVon(reason) ? AUSTRITTSART_LABELS[austrittsartVon(reason)!] : undefined}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label={istNoShow ? "Wirksam am (Eintrittstag)" : "Austrittsdatum"}
|
label={istNoShow ? "Wirksam am (Eintrittstag)" : "Austrittsdatum"}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import {
|
|||||||
AVERAGE_MEASURES,
|
AVERAGE_MEASURES,
|
||||||
EVENT_DATE_OPEN,
|
EVENT_DATE_OPEN,
|
||||||
EVENT_GROUP_LABELS,
|
EVENT_GROUP_LABELS,
|
||||||
|
EREIGNIS_AUSWAHL,
|
||||||
|
EREIGNIS_UNTERTYP,
|
||||||
EVENT_REPORT_PRESETS,
|
EVENT_REPORT_PRESETS,
|
||||||
EVENT_TYPE_LABELS,
|
EVENT_TYPE_LABELS,
|
||||||
GROUP_LABELS,
|
GROUP_LABELS,
|
||||||
@@ -31,6 +33,7 @@ import {
|
|||||||
sortKeysForDimension,
|
sortKeysForDimension,
|
||||||
STATUS_OPTIONS,
|
STATUS_OPTIONS,
|
||||||
todayIso,
|
todayIso,
|
||||||
|
untertypOptionen,
|
||||||
type EventGroupDimension,
|
type EventGroupDimension,
|
||||||
type GroupDimension,
|
type GroupDimension,
|
||||||
type Measure,
|
type Measure,
|
||||||
@@ -40,7 +43,10 @@ import {
|
|||||||
import type { HistoryEventType } from "@/lib/types";
|
import type { HistoryEventType } from "@/lib/types";
|
||||||
|
|
||||||
const SPLIT_COLORS = ["bg-brand-500", "bg-info-text", "bg-purple-text", "bg-warning-text", "bg-success-text", "bg-danger-solid"];
|
const SPLIT_COLORS = ["bg-brand-500", "bg-info-text", "bg-purple-text", "bg-warning-text", "bg-success-text", "bg-danger-solid"];
|
||||||
const EVENT_TYPES = Object.keys(EVENT_TYPE_LABELS) as HistoryEventType[];
|
const EVENT_TYPES = EREIGNIS_AUSWAHL;
|
||||||
|
|
||||||
|
/** Wie viele Namen eine aufgeklappte Zeile zeigt, bevor der Rest auf Klick kommt. */
|
||||||
|
const VORSCHAU_NAMEN = 12;
|
||||||
|
|
||||||
type SavedReport = { id: string; name: string; config: Record<string, unknown> };
|
type SavedReport = { id: string; name: string; config: Record<string, unknown> };
|
||||||
type OrgOption = { id: string; name: string };
|
type OrgOption = { id: string; name: string };
|
||||||
@@ -69,6 +75,8 @@ type EventsProps = CommonProps & {
|
|||||||
eventGroup: EventGroupDimension;
|
eventGroup: EventGroupDimension;
|
||||||
eventSplit: EventGroupDimension | "";
|
eventSplit: EventGroupDimension | "";
|
||||||
eventType: string;
|
eventType: string;
|
||||||
|
/** Beendigungsart bzw. Art der Langzeitabwesenheit; leer heisst „alle". */
|
||||||
|
eventSubtype: string;
|
||||||
eventFilters: { division: string; location: string; from: string; to: string };
|
eventFilters: { division: string; location: string; from: string; to: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -227,6 +235,10 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [expandedKey, setExpandedKey] = useState<string | null>(null);
|
const [expandedKey, setExpandedKey] = useState<string | null>(null);
|
||||||
|
// Welche aufgeklappte Zeile ihre Namen **vollständig** zeigt. Vorher stand
|
||||||
|
// dort „+794 weitere" als blosser Text: die Auskunft, dass es mehr gibt,
|
||||||
|
// ohne einen Weg dorthin. Wer die Liste brauchte, musste sie exportieren.
|
||||||
|
const [volleListeKey, setVolleListeKey] = useState<string | null>(null);
|
||||||
const [saveModalOpen, setSaveModalOpen] = useState(false);
|
const [saveModalOpen, setSaveModalOpen] = useState(false);
|
||||||
const [newReportName, setNewReportName] = useState("");
|
const [newReportName, setNewReportName] = useState("");
|
||||||
const [savingReport, setSavingReport] = useState(false);
|
const [savingReport, setSavingReport] = useState(false);
|
||||||
@@ -439,12 +451,35 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
|
|||||||
label="Ereignistyp"
|
label="Ereignistyp"
|
||||||
dense
|
dense
|
||||||
value={props.eventType}
|
value={props.eventType}
|
||||||
onChange={(v) => updateParams({ eventType: v || undefined })}
|
// Der Untertyp gehört zum Ereignistyp und fällt mit ihm weg.
|
||||||
|
// Bliebe er in der Adresse stehen, filterte eine Auswahl
|
||||||
|
// weiter, die auf dem Bildschirm nicht mehr zu sehen ist.
|
||||||
|
onChange={(v) => updateParams({ eventType: v || undefined, eventSubtype: undefined })}
|
||||||
options={[
|
options={[
|
||||||
{ value: "", label: "Alle Ereignistypen" },
|
{ value: "", label: "Alle Ereignistypen" },
|
||||||
...EVENT_TYPES.map((t) => ({ value: t, label: EVENT_TYPE_LABELS[t] })),
|
...EVENT_TYPES.map((t) => ({ value: t, label: EVENT_TYPE_LABELS[t] })),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
{/* Nur dort, wo es einen gibt: eine Versetzung hat keine
|
||||||
|
Beendigungsart, und ein leeres zweites Feld wäre eine Frage
|
||||||
|
ohne Antwortmöglichkeit. */}
|
||||||
|
{(() => {
|
||||||
|
const typ = props.eventType ? (props.eventType as HistoryEventType) : null;
|
||||||
|
const untertyp = typ ? EREIGNIS_UNTERTYP[typ] : undefined;
|
||||||
|
if (!untertyp) return null;
|
||||||
|
return (
|
||||||
|
<SelectField
|
||||||
|
label={untertyp.label}
|
||||||
|
dense
|
||||||
|
value={props.eventSubtype}
|
||||||
|
onChange={(v) => updateParams({ eventSubtype: v || undefined })}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: untertyp.alle },
|
||||||
|
...untertypOptionen(typ).map((o) => ({ value: o, label: o })),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<SelectField
|
<SelectField
|
||||||
label="Gruppieren nach"
|
label="Gruppieren nach"
|
||||||
dense
|
dense
|
||||||
@@ -677,7 +712,16 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
|
|||||||
const isExpanded = expandedKey === row.key;
|
const isExpanded = expandedKey === row.key;
|
||||||
return (
|
return (
|
||||||
<div key={row.key}>
|
<div key={row.key}>
|
||||||
<button type="button" onClick={() => setExpandedKey(isExpanded ? null : row.key)} className="w-full text-left">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setExpandedKey(isExpanded ? null : row.key);
|
||||||
|
// Zuklappen setzt auch die volle Liste zurück: sonst
|
||||||
|
// stünde sie beim nächsten Aufklappen unerwartet offen.
|
||||||
|
setVolleListeKey(null);
|
||||||
|
}}
|
||||||
|
className="w-full text-left"
|
||||||
|
>
|
||||||
<div className="mb-1 flex items-center justify-between text-sm">
|
<div className="mb-1 flex items-center justify-between text-sm">
|
||||||
<span className="font-semibold text-ink">{row.key}</span>
|
<span className="font-semibold text-ink">{row.key}</span>
|
||||||
<span className="text-ink-body">
|
<span className="text-ink-body">
|
||||||
@@ -695,23 +739,41 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{isExpanded && (
|
{isExpanded &&
|
||||||
<div className="mt-2 rounded border border-border bg-surface p-3">
|
(() => {
|
||||||
<ul className="flex flex-col divide-y divide-border">
|
const alle = volleListeKey === row.key;
|
||||||
{row.people.slice(0, 12).map((p, i) => (
|
const sichtbar = alle ? row.people : row.people.slice(0, VORSCHAU_NAMEN);
|
||||||
<li key={`${p.id}-${i}`} className="flex items-center justify-between py-1.5 text-sm">
|
const rest = row.people.length - VORSCHAU_NAMEN;
|
||||||
<Link href={`/employees/${p.id}`} className="font-semibold text-ink hover:text-brand-700 hover:underline">
|
return (
|
||||||
{p.name}
|
<div className="mt-2 rounded border border-border bg-surface p-3">
|
||||||
</Link>
|
{/* Bei achthundert Namen scrollt die Liste in sich,
|
||||||
<span className="text-xs text-ink-muted">
|
statt die Auswertung darüber aus dem Bild zu
|
||||||
{p.title} · {p.team} · {fmtDate(p.entry_date)}
|
schieben. */}
|
||||||
</span>
|
<ul className={`flex flex-col divide-y divide-border ${alle ? "max-h-[28rem] overflow-y-auto" : ""}`}>
|
||||||
</li>
|
{sichtbar.map((p, i) => (
|
||||||
))}
|
<li key={`${p.id}-${i}`} className="flex items-center justify-between gap-3 py-1.5 text-sm">
|
||||||
</ul>
|
<Link href={`/employees/${p.id}`} className="font-semibold text-ink hover:text-brand-700 hover:underline">
|
||||||
{row.people.length > 12 && <p className="mt-2 text-xs text-ink-muted">+{row.people.length - 12} weitere</p>}
|
{p.name}
|
||||||
</div>
|
</Link>
|
||||||
)}
|
<span className="text-right text-xs text-ink-muted">
|
||||||
|
{p.title} · {p.team} · {fmtDate(p.entry_date)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{rest > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVolleListeKey(alle ? null : row.key)}
|
||||||
|
className="mt-2 rounded text-xs font-semibold text-brand-700 hover:underline
|
||||||
|
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||||
|
>
|
||||||
|
{alle ? `Nur die ersten ${VORSCHAU_NAMEN} zeigen` : `+${rest} weitere anzeigen`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
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: "if6020_austritt", label: "IF 6020 Austritt", art: "haken" },
|
||||||
{ key: "ueberleitung_salden", label: "Überleitung Salden für Auszahlung", art: "haken" },
|
{ key: "ueberleitung_salden", label: "Überleitung Salden für Auszahlung", art: "haken" },
|
||||||
{ key: "endabrechnung", label: "Endabrechnung", 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" },
|
{ 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: "eurest", label: "Anmeldung EUREST", art: "haken" },
|
||||||
{ key: "oegk", label: "Anmeldung ÖGK", art: "haken" },
|
{ key: "oegk", label: "Anmeldung ÖGK", art: "haken" },
|
||||||
{ key: "webclient", label: "WebClient", 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: "datensatzberechtigung", label: "Datensatzberechtigung Führungskraft", art: "haken" },
|
||||||
{ key: "praemienanspruch", label: "Prämienanspruch", art: "janein" },
|
{ 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 { LEERE_CRITERIA, passtImSpeicher, type Criteria } from "./report-criteria";
|
||||||
import {
|
import {
|
||||||
deriveStatusAsOf,
|
deriveStatusAsOf,
|
||||||
|
EREIGNIS_UNTERTYP,
|
||||||
EVENT_DATE_OPEN,
|
EVENT_DATE_OPEN,
|
||||||
parseStatuses,
|
parseStatuses,
|
||||||
todayIso,
|
todayIso,
|
||||||
@@ -102,7 +103,15 @@ export function applyCriteria<O>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type SnapshotFilters = ReportFilters & { asOf?: string };
|
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
|
* 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([
|
const [history, employees, assignments, orgMaps] = await Promise.all([
|
||||||
historyQuery().execute(),
|
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
|
tx
|
||||||
.selectFrom("position_assignments as a")
|
.selectFrom("position_assignments as a")
|
||||||
.innerJoin("om_positions as p", "p.id", "a.position_id")
|
.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;
|
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;
|
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({
|
events.push({
|
||||||
employee_id: emp.id,
|
employee_id: emp.id,
|
||||||
first_name: emp.first_name,
|
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_date: h.event_date,
|
||||||
event_type: h.event_type,
|
event_type: h.event_type,
|
||||||
description: h.description,
|
description: h.description,
|
||||||
|
subtype,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return events;
|
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 { fmtName, todayIso, yearsBetweenIso } from "./format";
|
||||||
import type { EmploymentStatus, HistoryEventType, Weekday } from "./types";
|
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",
|
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 = {
|
export type ReportEvent = {
|
||||||
employee_id: string;
|
employee_id: string;
|
||||||
first_name: string;
|
first_name: string;
|
||||||
@@ -403,6 +427,25 @@ export type ReportEvent = {
|
|||||||
event_date: string;
|
event_date: string;
|
||||||
event_type: HistoryEventType;
|
event_type: HistoryEventType;
|
||||||
description: string;
|
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 {
|
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;
|
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
|
// 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.
|
// URL can never reach a date comparison (or a column header) as free text.
|
||||||
export function parseIsoDateParam(value: string | null | undefined): string | undefined {
|
export function parseIsoDateParam(value: string | null | undefined): string | undefined {
|
||||||
|
|||||||
@@ -25,8 +25,10 @@ describe("Das Verzeichnis", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("bildet jeden Punkt aus dem Blatt ab", () => {
|
it("bildet jeden Punkt aus dem Blatt ab", () => {
|
||||||
expect(OFFBOARDING_PUNKTE).toHaveLength(11);
|
// 11 vom Blatt, dazu „Firmenfahrzeug" aus dem Workshop.
|
||||||
|
expect(OFFBOARDING_PUNKTE).toHaveLength(12);
|
||||||
for (const key of [
|
for (const key of [
|
||||||
|
"firmenfahrzeug",
|
||||||
"br_verstaendigung",
|
"br_verstaendigung",
|
||||||
"kuendigungsschreiben",
|
"kuendigungsschreiben",
|
||||||
"bestaetigung_kdn_bei",
|
"bestaetigung_kdn_bei",
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ describe("Das Verzeichnis", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("bildet jeden Punkt aus dem Blatt ab", () => {
|
it("bildet jeden Punkt aus dem Blatt ab", () => {
|
||||||
expect(ONBOARDING_PUNKTE).toHaveLength(25);
|
// 25 vom Blatt, dazu „Firmenfahrzeug" aus dem Workshop.
|
||||||
for (const key of ["dienstzettel", "meldezettel", "salmonellenbefund", "willkommensmappe", "webreise"]) {
|
expect(ONBOARDING_PUNKTE).toHaveLength(26);
|
||||||
|
for (const key of ["dienstzettel", "meldezettel", "salmonellenbefund", "willkommensmappe", "webreise", "firmenfahrzeug"]) {
|
||||||
expect(punktZu(key), key).toBeDefined();
|
expect(punktZu(key), key).toBeDefined();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -314,6 +314,7 @@ describe("aggregateEvents", () => {
|
|||||||
event_date: "2026-03-01",
|
event_date: "2026-03-01",
|
||||||
event_type: "Eintritt",
|
event_type: "Eintritt",
|
||||||
description: "Eintritt als Maschinenbediener:in",
|
description: "Eintritt als Maschinenbediener:in",
|
||||||
|
subtype: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user