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:
2026-09-15 22:35:47 +02:00
parent 1cbed1a8f5
commit 8d0c9b4b65
15 changed files with 426 additions and 48 deletions

View File

@@ -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}

View File

@@ -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)}

View File

@@ -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 },

View File

@@ -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()}&ldquo;. {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

View File

@@ -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>
); );

View File

@@ -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"}

View File

@@ -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
View 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;
}

View File

@@ -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" },
], ],
}, },

View File

@@ -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" },
{ {

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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();
} }
}); });

View File

@@ -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,
}; };
} }