Files
alpenwerk-hr/components/employees/panels/PromotePanel.tsx
Andrei Laas 37650ee9a4
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m9s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Befoerderung wahlweise auf eine neue Planstelle -- und der Nachtlauf, der sie ausfuehrt
Bei "Befoerdern" steht jetzt die Wahl: selbe Planstelle oder eine neue.
Bei "neu" wird sie aus den freien gewaehlt, dieselbe Auswahl wie die
Zielplanstelle bei der Versetzung, und die Stelle schlaegt ihre
Taetigkeit als neue Bezeichnung vor -- nur als Vorschlag, denn
job_title darf von der Planstelle abweichen.

Die Zielstelle muss frei sein, mit derselben Meldung wie bei der
Versetzung. Ausgenommen ist die eigene: darauf sitzt die Person schon,
und "bereits besetzt" waere eine Falschaussage ueber sie selbst.

Dabei ist ein aelterer Fehler aufgefallen. transfer_employee legt fuer
ein kuenftiges Datum einen Eintrag in pending_org_changes mit
target_position_id an. Der Nachtlauf hat die Besetzung dort zur
Umstellung auf Planstellen (20260727120200) umgehaengt; zuletzt stand in
diesem Zweig nur noch

    update employees set job_title = coalesce(payload->>'new_title', ...)

also die Fassung von *vor* jener Umstellung -- und new_title kommt in
diesem payload gar nicht vor. Eine auf spaeter datierte Versetzung wurde
am Stichtag auf "applied" gesetzt und bewegte niemanden: die Person blieb
auf ihrer alten Planstelle, im Organigramm unveraendert, ohne Meldung.
Eine der Migrationen dazwischen hat die Funktion aus einer alten Vorlage
neu geschrieben -- dieselbe Falle wie bei der Gruppe `role` und bei der
Anmerkung zum Austritt.

Beide Zweige haengen die Besetzung jetzt um, und die Selbstpruefung
zaehlt nach, dass es zwei sind. Nebenbei bekommt promote_employee den
festen search_path und app_current_user_id() statt auth.uid().
2026-09-23 18:41:41 +02:00

185 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { promoteEmployee } from "@/actions/employees";
import { Button } from "@/components/ui/Button";
import { SelectField, TextField } from "@/components/ui/Field";
import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast";
import type { OpenPositionResolved } from "@/lib/positions";
import type { Database, PaygradeType } from "@/lib/types";
import { fmtName } from "@/lib/format";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
const PAYGRADES: { value: PaygradeType; label: string }[] = [
{ value: "A", label: "A – Einstieg" },
{ value: "B", label: "B – Qualifiziert" },
{ value: "C", label: "C – Erfahren" },
{ value: "D", label: "D – Spezialist:in" },
{ value: "E", label: "E – Teamleitung" },
{ value: "F", label: "F – Bereichsleitung / GF" },
];
// Eine Beförderung kann auf derselben Planstelle stattfinden oder auf eine
// andere führen. Beides kommt vor: jemand wächst auf seiner Stelle, oder er
// rückt auf eine höhere — und dann ist die Zielstelle dieselbe Auswahl wie
// bei der Versetzung.
type Stellenwahl = "selbe" | "neue";
export function PromotePanel({
open,
onClose,
employee,
openPositions,
}: {
open: boolean;
onClose: () => void;
employee: EmployeeRow;
openPositions: OpenPositionResolved[];
}) {
const { showToast } = useToast();
const router = useRouter();
const [effectiveDate, setEffectiveDate] = useState("");
const [newTitle, setNewTitle] = useState(employee.job_title);
const [paygrade, setPaygrade] = useState<PaygradeType>(employee.paygrade);
const [stellenwahl, setStellenwahl] = useState<Stellenwahl>("selbe");
const [positionId, setPositionId] = useState("");
const [pending, setPending] = useState(false);
const options = useMemo(
() =>
openPositions
.slice()
.sort((a, b) => a.orgLabel.localeCompare(b.orgLabel, "de") || a.title.localeCompare(b.title, "de"))
.map((p) => ({ value: p.id, label: `${p.orgLabel} · ${p.title} (${p.position_number})` })),
[openPositions]
);
const selected = openPositions.find((p) => p.id === positionId);
/**
* Die Stelle wählen schlägt ihre Tätigkeit als neue Bezeichnung vor.
*
* Nur ein Vorschlag: `employees.job_title` darf von der Planstelle
* abweichen, und wer eine eigene Bezeichnung tippt, soll sie behalten.
* Deshalb wird nur überschrieben, solange das Feld unverändert auf dem
* alten Titel steht.
*/
function stelleWaehlen(id: string) {
setPositionId(id);
const stelle = openPositions.find((p) => p.id === id);
if (stelle && newTitle === employee.job_title) setNewTitle(stelle.title);
}
async function handleSubmit() {
if (!effectiveDate || !newTitle) {
showToast("Bitte alle Pflichtfelder ausfüllen.", "error");
return;
}
if (stellenwahl === "neue" && !positionId) {
showToast("Bitte die neue Planstelle auswählen.", "error");
return;
}
setPending(true);
const result = await promoteEmployee({
employee_id: employee.id,
effective_date: effectiveDate,
new_title: newTitle,
new_paygrade: paygrade,
target_position_id: stellenwahl === "neue" ? positionId : undefined,
});
setPending(false);
if (result.success) {
showToast(`${employee.first_name} ${employee.last_name} wurde befördert.`);
router.refresh();
onClose();
} else {
showToast(result.error ?? "Fehler beim Speichern.", "error");
}
}
return (
<SlideOver
open={open}
onClose={onClose}
title="Beförderung"
subtitle={`${fmtName(employee.first_name, employee.last_name)} · ${employee.job_title}`}
footer={
<>
<Button variant="ghost" onClick={onClose}>
Abbrechen
</Button>
{/* Promotion keeps its own purple, matching the Beförderung badge
used in the history tab. */}
<Button onClick={handleSubmit} pending={pending} className="!bg-purple-text text-white hover:brightness-110">
Befördern
</Button>
</>
}
>
<div className="flex flex-col gap-4">
<TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
<fieldset>
<legend className="mb-1.5 text-sm font-semibold text-ink-body">Planstelle</legend>
<div className="flex flex-col gap-1.5">
<label className="flex items-center gap-2 text-sm text-ink-body">
<input
type="radio"
name="stellenwahl"
checked={stellenwahl === "selbe"}
onChange={() => setStellenwahl("selbe")}
/>
Selbe Planstelle
</label>
<label className="flex items-center gap-2 text-sm text-ink-body">
<input
type="radio"
name="stellenwahl"
checked={stellenwahl === "neue"}
onChange={() => setStellenwahl("neue")}
disabled={options.length === 0}
/>
Neue Planstelle
{options.length === 0 && <span className="text-xs text-ink-muted">(derzeit keine freie)</span>}
</label>
</div>
</fieldset>
{stellenwahl === "neue" && (
<>
<SelectField
label="Neue Planstelle"
required
value={positionId}
onChange={stelleWaehlen}
placeholder="Bitte wählen…"
options={options}
/>
{selected && (
<div className="rounded border border-border bg-surface p-3 text-sm text-ink-body">
<div className="font-semibold text-ink">{selected.title}</div>
<div className="text-xs text-ink-muted">{selected.orgLabel}</div>
<div className="mt-1 text-xs text-ink-muted">
{selected.is_chief ? "Leitungsplanstelle" : "Mitarbeiterplanstelle"}
{selected.managerName ? ` · berichtet an ${selected.managerName}` : ""}
</div>
</div>
)}
</>
)}
<TextField label="Neue Position" required value={newTitle} onChange={setNewTitle} />
<SelectField
label="Verwendungsgruppe"
value={paygrade}
onChange={(v) => setPaygrade(v as PaygradeType)}
options={PAYGRADES}
/>
</div>
</SlideOver>
);
}