Files
alpenwerk-hr/components/employees/panels/KarenzPanel.tsx
Maximilian Stubhan c23df08648 Ask for the optional things separately, and stop claiming numbers are issued
A round of interface corrections from use, plus one schema change behind
them.

The private email address is now optional. It was NOT NULL — the wrong
default for a private detail: someone without one had to invent one, and
invented data in a personnel file is worse than missing data. Both fields
are relabelled to say whose they are, "Private E-Mail" and "Private
Telefonnummer", because the company address does not exist until the person
starts. Uniqueness stays; several NULLs coexist in a Postgres unique index,
which is exactly what is wanted.

The summary step still promised that "Personalnummer und
Firmen-E-Mail-Adresse werden automatisch vergeben". Neither is true any
more. Removed rather than reworded — the step lists what was entered, and a
banner claiming otherwise is worse than no banner.

Dependents move into the wizard as step three, optional. They can only be
attached after the hire, because add_employee_dependent needs an id that
does not exist while the form is open, so they are collected in the draft
and written afterwards. That puts them outside the transaction the person is
created in: if one fails the person still exists, so the message names who
is missing instead of failing silently, and the SV number is checked in the
step rather than after.

The emergency contact gets its own step, second to last, and its
relationship is a dropdown of the common ones rather than free text —
otherwise "Gattin", "Ehefrau" and "Frau" end up side by side and nothing can
be counted. "Sonstige" is there because a closed list would otherwise be
presumptuous.

On the master-data tab it now sits below the dependents rather than above:
both are people around the employee, and this is the one you reach for in a
hurry.

Returning from a long absence: the choice read "unverändert", which made you
open the file to find out what you were agreeing to. It now reads "Wie vor
Abwesenheit (38,5 h)" with the hours actually worked, and the alternative is
"Reduziert" — whose hours field starts empty on purpose. A number already
filled in gets confirmed rather than read off the agreement it comes from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:47:04 +02:00

233 lines
8.8 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 } from "@/lib/absence";
import { fmtDate } 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");
// 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,
});
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={`${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."
/>
)}
</>
)}
</div>
)}
</SlideOver>
);
}