Move the part-time arrangements out of the absence list

Bildungsteilzeit, Elternteilzeit, Pflegeteilzeit and
Wiedereingliederungsteilzeit were offered as kinds of long-term absence.
Recorded that way, the person counted as absent: they dropped out of
headcount, their reporting line fell to a stand-in, and reports stopped
counting them — while they were in the building every week, just for
fewer hours. A part-time arrangement is not an absence; it is a change
of hours.

They now sit where they belong. Wiedereingliederungs- and Elternteilzeit
appear when recording a return from absence, as the reason someone comes
back on reduced hours — both typically begin exactly when the absence
ends. Bildungs- and Pflegeteilzeit appear under "Daten ändern" beside
the hours, next to the ordinary contractual change.

The reason is recorded with the change, not as a state on the person. A
state would have to be maintained, and nobody goes back to note when a
Bildungsteilzeit ended; a field that quietly goes stale is worse than
none. In the history it stands next to the value it explains, and stays
readable for good.

The check constraint on absence_type is deliberately untouched. Three
people carry the old values right now — two Pflegeteilzeit, one
Wiedereingliederungsteilzeit. Forbidding them would make existing rows
illegal. They are gone from the list of choices; the history stays
readable. Those three are worth revisiting, but that is a data decision,
not a code one.

Rehearsed against real data: an hours change with a reason and one
without, a reduced return with a reason and an unchanged one — checked
by reading both new history rows rather than "the latest", since now()
stands still inside a transaction and made an earlier probe report a
false negative.

I also overwrote tests/unit/absence.test.ts instead of extending it. The
original cases are restored; the diff is 49 added lines and 3 changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 12:56:21 +02:00
parent 6681dcda77
commit e6554e7982
6 changed files with 402 additions and 18 deletions

View File

@@ -13,6 +13,7 @@ import { Field, SelectField, TextField } from "@/components/ui/Field";
import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast";
import { UN_COUNTRIES } from "@/lib/countries";
import { STUNDEN_GRUENDE } from "@/lib/absence";
import { fmtFullName, todayIso } from "@/lib/format";
import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr";
import { EMERGENCY_RELATIONS, type ContractType, type Database, type EmploymentType, type GenderType } from "@/lib/supabase/types";
@@ -64,6 +65,11 @@ export function DatenAendernPanel({
const [employmentType, setEmploymentType] = useState<EmploymentType>(employee.employment_type);
const [weeklyHours, setWeeklyHours] = useState(String(employee.weekly_hours));
// Der Grund wird erst gefragt, wenn sich die Stunden tatsächlich ändern —
// sonst stünde bei jeder Adressänderung eine Frage im Weg, die niemand
// gestellt hat.
const [stundenGrund, setStundenGrund] = useState<string>(STUNDEN_GRUENDE[0]);
const stundenGeaendert = Number(weeklyHours) !== Number(employee.weekly_hours);
const [contractType, setContractType] = useState<ContractType>(employee.contract_type);
const [contractEndDate, setContractEndDate] = useState(employee.contract_end_date ?? "");
@@ -134,6 +140,10 @@ export function DatenAendernPanel({
emergency_contact_phone: notfallTelefon.trim(),
emergency_contact_relation: notfallVerhaeltnis.trim(),
},
// Nur mitschicken, wenn sich die Stunden wirklich ändern — sonst
// stünde bei jeder Adressänderung ein Grund in der Historie, den
// niemand gemeint hat.
hours_reason: stundenGeaendert ? stundenGrund : undefined,
contract: {
employment_type: employmentType,
weekly_hours: Number(weeklyHours),
@@ -268,6 +278,16 @@ export function DatenAendernPanel({
disabled={employmentType === "Vollzeit"}
onChange={setWeeklyHours}
/>
{stundenGeaendert && (
<SelectField
label="Grund der Stundenänderung"
dense
value={stundenGrund}
onChange={setStundenGrund}
options={STUNDEN_GRUENDE.map((g) => ({ value: g, label: g }))}
hint="Steht danach in der Historie neben den Stunden."
/>
)}
<SelectField
label="Vertragsart"
dense

View File

@@ -8,7 +8,7 @@ import { SelectField, TextField, TextareaField } from "@/components/ui/Field";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast";
import { ABSENCE_TYPES, absenceLabel } from "@/lib/absence";
import { ABSENCE_TYPES, absenceLabel, RUECKKEHR_GRUENDE } from "@/lib/absence";
import { fmtDate, fmtName } from "@/lib/format";
import type { Database } from "@/lib/supabase/types";
@@ -33,6 +33,10 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
const [returnDate, setReturnDate] = useState("");
const [employmentMode, setEmploymentMode] = useState<EmploymentMode>("unverändert");
// Warum weniger Stunden: Wiedereingliederungs- oder Elternteilzeit. Beide
// beginnen typischerweise genau dann, wenn die Abwesenheit endet — deshalb
// steht die Frage hier und nicht in einem zweiten Vorgang danach.
const [reduktionsgrund, setReduktionsgrund] = useState<string>("");
// Die Stunden, die vor der Abwesenheit galten — mit Komma, wie man sie
// hierzulande schreibt.
const stundenText = String(employee.weekly_hours).replace(".", ",");
@@ -111,6 +115,7 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
return_date: returnDate,
employment_mode: employmentMode,
weekly_hours: employmentMode === "Teilzeit" ? Number(weeklyHours) : undefined,
reduction_reason: employmentMode === "Teilzeit" ? reduktionsgrund || undefined : undefined,
});
setPending(false);
if (result.success) {
@@ -211,17 +216,29 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
]}
/>
{employmentMode === "Teilzeit" && (
<TextField
label="Reduzierte Wochenstunden"
required
type="number"
step="0.5"
max="38"
value={weeklyHours}
onChange={setWeeklyHours}
placeholder={`weniger als ${stundenText}`}
hint="Muss unter 38,5 liegen."
/>
<>
<TextField
label="Reduzierte Wochenstunden"
required
type="number"
step="0.5"
max="38"
value={weeklyHours}
onChange={setWeeklyHours}
placeholder={`weniger als ${stundenText}`}
hint="Muss unter 38,5 liegen."
/>
<SelectField
label="Grund der Reduktion"
value={reduktionsgrund}
onChange={setReduktionsgrund}
options={[
{ value: "", label: "Ohne besonderen Grund" },
...RUECKKEHR_GRUENDE.map((g) => ({ value: g, label: g })),
]}
hint="Steht danach in der Historie, wo auch die Stunden stehen."
/>
</>
)}
</>
)}