Files
alpenwerk-hr/components/employees/panels/KarenzPanel.tsx
Maximilian Stubhan f5ace8af2e Make the part-time arrangement a state you can report on
Last step moved the four part-time arrangements out of the absence list
and recorded the reason in the history text. That answered "what
happened" but not "who is in one right now", and the profile showed
nothing at all. So it becomes a real field: teilzeit_art, with an
optional end date.

The objection I raised then still holds — a state goes stale, because
nobody goes back to note when a Bildungsteilzeit ended. teilzeit_bis is
the answer to it: with an end date a report decides for itself what is
still running instead of trusting that someone maintained the row. Left
empty it means "open end", which is an honest thing to say.

It runs through the ordinary change machinery rather than beside it. It
sits in app_feld_karte, so it shows up in the history as a field with
before and after, and can be corrected there like any other. The
description suffix from last step is gone — writing the same thing twice
is how two versions start disagreeing.

Reporting: filter by variant, by "in one at all", and by when it ends;
group headcount by variant, where the absence of one reads "Keine"
rather than a dash, because in a report that is an answer and not a gap.
Plus columns in the export and the import.

One gap found while rehearsing, and only because the probe happened to
pick a return date in the future: a scheduled return carries its payload
through pending_org_changes, and that payload did not include the
variant. Someone would have come back on reduced hours in April with the
reason gone. The daily run now carries it too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:07:41 +02:00

261 lines
10 KiB
TypeScript

"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { adjustKarenzReturn, recordKarenzReturn, startKarenz } from "@/actions/employees";
import { Button } from "@/components/ui/Button";
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, RUECKKEHR_GRUENDE } from "@/lib/absence";
import { fmtDate, fmtName } from "@/lib/format";
import type { Database } from "@/lib/supabase/types";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
type Mode = "adjust" | "return";
type EmploymentMode = "unverändert" | "Vollzeit" | "Teilzeit";
export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClose: () => void; employee: EmployeeRow }) {
const { showToast } = useToast();
const router = useRouter();
const isOnKarenz = employee.status === "Karenz";
const [mode, setMode] = useState<Mode>("adjust");
const [pending, setPending] = useState(false);
const [karenzStart, setKarenzStart] = useState("");
const [plannedReturn, setPlannedReturn] = useState("");
const [absenceType, setAbsenceType] = useState<string>(ABSENCE_TYPES[0]);
const [startNote, setStartNote] = useState("");
const [newReturnDate, setNewReturnDate] = useState(employee.karenz_return_date ?? "");
const [adjustNote, setAdjustNote] = useState("");
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>("");
const [teilzeitBis, setTeilzeitBis] = useState("");
// Die Stunden, die vor der Abwesenheit galten — mit Komma, wie man sie
// hierzulande schreibt.
const stundenText = String(employee.weekly_hours).replace(".", ",");
// Absichtlich leer statt vorbelegt: eine Zahl, die schon dasteht, wird
// bestätigt statt erfasst. Die reduzierten Stunden stehen in einer
// Vereinbarung, und die muss jemand ablesen.
const [weeklyHours, setWeeklyHours] = useState("");
const diffDays =
isOnKarenz && employee.karenz_return_date && newReturnDate
? Math.round((new Date(newReturnDate).getTime() - new Date(employee.karenz_return_date).getTime()) / 86400000)
: 0;
async function handleStart() {
if (!karenzStart || !plannedReturn) {
showToast("Bitte beide Daten angeben.", "error");
return;
}
setPending(true);
const result = await startKarenz({
employee_id: employee.id,
karenz_start_date: karenzStart,
planned_return_date: plannedReturn,
absence_type: absenceType,
note: startNote,
});
setPending(false);
if (result.success) {
showToast("Langzeitabwesenheit erfasst.");
router.refresh();
onClose();
} else {
showToast(result.error ?? "Fehler.", "error");
}
}
async function handleAdjust() {
if (!newReturnDate || newReturnDate === employee.karenz_return_date) {
showToast("Bitte ein neues Rückkehrdatum angeben.", "error");
return;
}
if (new Date(newReturnDate) <= new Date()) {
showToast("Rückkehrdatum muss in der Zukunft liegen.", "error");
return;
}
setPending(true);
const result = await adjustKarenzReturn({ employee_id: employee.id, new_return_date: newReturnDate, note: adjustNote });
setPending(false);
if (result.success) {
showToast("Rückkehrdatum angepasst.");
router.refresh();
onClose();
} else {
showToast(result.error ?? "Fehler.", "error");
}
}
async function handleReturn() {
if (!returnDate) {
showToast("Bitte Rückkehrdatum angeben.", "error");
return;
}
if (employmentMode === "Teilzeit") {
if (!weeklyHours.trim()) {
showToast("Bitte die reduzierten Wochenstunden erfassen.", "error");
return;
}
if (Number(weeklyHours) <= 0 || Number(weeklyHours) >= 38.5) {
showToast("Wochenstunden müssen zwischen 0 und 38,5 liegen.", "error");
return;
}
}
setPending(true);
const result = await recordKarenzReturn({
employee_id: employee.id,
return_date: returnDate,
employment_mode: employmentMode,
weekly_hours: employmentMode === "Teilzeit" ? Number(weeklyHours) : undefined,
reduction_reason: employmentMode === "Teilzeit" ? reduktionsgrund || undefined : undefined,
teilzeit_bis: employmentMode === "Teilzeit" && reduktionsgrund ? teilzeitBis || undefined : undefined,
});
setPending(false);
if (result.success) {
showToast("Wiedereintritt erfasst.");
router.refresh();
onClose();
} else {
showToast(result.error ?? "Fehler.", "error");
}
}
return (
<SlideOver
open={open}
onClose={onClose}
title={isOnKarenz ? "Langzeitabwesenheit verwalten" : "Langzeitabwesenheit erfassen"}
subtitle={`${fmtName(employee.first_name, employee.last_name)} · ${employee.job_title}`}
footer={
<>
<Button variant="ghost" onClick={onClose}>
Abbrechen
</Button>
{/* Long-term absence keeps the warning tone it uses elsewhere. */}
{!isOnKarenz && (
<Button onClick={handleStart} pending={pending} className="!bg-warning-text text-white hover:brightness-110">
Abwesenheit erfassen
</Button>
)}
{isOnKarenz && mode === "adjust" && (
<Button onClick={handleAdjust} pending={pending} className="!bg-warning-text text-white hover:brightness-110">
Speichern
</Button>
)}
{isOnKarenz && mode === "return" && (
<Button onClick={handleReturn} pending={pending} className="!bg-warning-text text-white hover:brightness-110">
Wiedereintritt erfassen
</Button>
)}
</>
}
>
{!isOnKarenz && (
<div className="flex flex-col gap-4">
<SelectField
label="Art der Langzeitabwesenheit"
required
value={absenceType}
onChange={setAbsenceType}
options={ABSENCE_TYPES.map((t) => ({ value: t, label: t }))}
/>
<TextField label="Beginn" required type="date" value={karenzStart} onChange={setKarenzStart} />
<TextField label="Geplante Rückkehr" required type="date" value={plannedReturn} onChange={setPlannedReturn} />
<TextareaField label="Anmerkung" rows={3} value={startNote} onChange={setStartNote} />
</div>
)}
{isOnKarenz && (
<div className="flex flex-col gap-4">
<SegmentedControl<Mode>
value={mode}
onChange={setMode}
options={[
{ value: "adjust", label: "Rückkehr anpassen" },
{ value: "return", label: "Wiedereintritt erfassen" },
]}
/>
{mode === "adjust" && (
<>
<p className="rounded bg-surface px-3 py-2 text-sm text-ink-body">
<span className="font-semibold">{absenceLabel(employee.status, employee.absence_type)}</span>
{" · Rückkehr "}
{fmtDate(employee.karenz_return_date)}
</p>
<TextField label="Neues Rückkehrdatum" required type="date" value={newReturnDate} onChange={setNewReturnDate} />
{diffDays !== 0 && (
<div className={`rounded px-3 py-2 text-sm ${diffDays > 0 ? "bg-warning-bg text-warning-text" : "bg-success-bg text-success-text"}`}>
{diffDays > 0 ? `Verlängerung um ${diffDays} Tage` : `Verkürzung um ${Math.abs(diffDays)} Tage`}
</div>
)}
<TextareaField label="Grund/Anmerkung" rows={3} value={adjustNote} onChange={setAdjustNote} />
</>
)}
{mode === "return" && (
<>
<TextField label="Rückkehrdatum" required type="date" value={returnDate} onChange={setReturnDate} />
<SelectField
label="Beschäftigungsausmaß"
value={employmentMode}
onChange={(v) => setEmploymentMode(v as EmploymentMode)}
options={[
// Die Stunden, die zuletzt gearbeitet wurden, stehen in der
// Beschriftung. „unverändert" allein zwang dazu, in der
// Akte nachzusehen, worauf man sich da einlässt.
{ value: "unverändert", label: `Wie vor Abwesenheit (${stundenText} h)` },
{ value: "Teilzeit", label: "Reduziert" },
]}
/>
{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."
/>
<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 am Profil und lässt sich auswerten."
/>
{reduktionsgrund && (
<TextField
label="Teilzeit bis"
type="date"
value={teilzeitBis}
onChange={setTeilzeitBis}
hint="Optional. Leer lassen, solange das Ende nicht feststeht."
/>
)}
</>
)}
</>
)}
</div>
)}
</SlideOver>
);
}