Befoerderung wahlweise auf eine neue Planstelle -- und der Nachtlauf, der sie ausfuehrt
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m9s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled

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().
This commit is contained in:
2026-09-23 18:41:41 +02:00
parent 93cb1c1699
commit 37650ee9a4
5 changed files with 451 additions and 10 deletions

View File

@@ -387,11 +387,13 @@ export function DatenAendernPanel({
<AngehoerigeSection employeeId={employee.id} dependents={dependents} effectiveDate={effectiveDate} />
{/* Unterhalb der Angehörigen und ganz am Schluss — dieselbe Reihenfolge
wie im Reiter „Stammdaten". Vorher stand der Block mitten im
Abschnitt „Person", zwischen den Adressfeldern; beides sind
Personen im Umfeld, und die beiden gehören nebeneinander. */}
<fieldset className="rounded-md border border-border-subtle p-3">
<legend className="px-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Notfallkontakt</legend>
wie im Reiter „Stammdaten". Aufgebaut wie „Person", „Vertrag" und
„Rolle & Anstellung" darüber: eine Überschrift, darunter die
Felder. Hier stand ein umrahmtes fieldset mit eigener
Beschriftung; das hob den Abschnitt gegenüber allen anderen
heraus, ohne dass er wichtiger wäre. */}
<div>
<h3 className="mb-3 text-sm font-bold text-ink">Notfallkontakt</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<TextField label="Name" dense value={notfallName} onChange={setNotfallName} />
<TextField label="Telefon" dense type="tel" value={notfallTelefon} onChange={setNotfallTelefon} />
@@ -404,7 +406,7 @@ export function DatenAendernPanel({
options={EMERGENCY_RELATIONS.map((r) => ({ value: r, label: r }))}
/>
</div>
</fieldset>
</div>
</div>
</SlideOver>
);

View File

@@ -1,12 +1,13 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
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";
@@ -21,25 +22,73 @@ const PAYGRADES: { value: PaygradeType; label: string }[] = [
{ value: "F", label: "F – Bereichsleitung / GF" },
];
export function PromotePanel({ open, onClose, employee }: { open: boolean; onClose: () => void; employee: EmployeeRow }) {
// 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) {
@@ -72,9 +121,59 @@ export function PromotePanel({ open, onClose, employee }: { open: boolean; onClo
>
<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="Paygrade"
label="Verwendungsgruppe"
value={paygrade}
onChange={(v) => setPaygrade(v as PaygradeType)}
options={PAYGRADES}