Files
alpenwerk-hr/components/employees/panels/PromotePanel.tsx
Andrei Laas 779beb4478 Hay-Grade statt Verwendungsgruppe A--F
A--F stammte aus der Spezifikation, nicht vom Kunden: sechs erfundene Stufen
mit erfundenen Beschreibungen. Der Kunde bewertet nach Hay und hat die Liste
geschickt -- 13 Stufen plus einen Generic Grade.

Dieselbe Spalte fuellt im Cornerstone-Extrakt die Grade ID. Solange dort A--F
steht, ist die Datei in jeder Zeile falsch, ohne dass es beim Erzeugen
auffaellt: das Zielsystem kennt diese Kennungen nicht.

Der Typ heisst weiter paygrade_type, ist aber jetzt eine Domain ueber text mit
CHECK statt eines Aufzaehlungstyps. Ein Enum laesst sich nicht umschreiben --
Werte entfernen geht gar nicht, und add value darf im selben Vorgang, der den
neuen Wert schreibt, nicht benutzt werden. Wichtiger: die rund zehn
SQL-Funktionen, die den Wert nach paygrade_type umwandeln, bleiben unveraendert
gueltig. Jede von ihnen neu zu erzeugen hiesse, zehnmal die Gelegenheit zu
haben, aus einer veralteten Vorlage zu kopieren.

Zwei Funktionen muessen doch angefasst werden, beide per Punktaenderung an der
laufenden Definition statt per Kopie aus einer Datei: der Vorgabewert B in
hire_employee und die Beschriftung im Protokoll von promote_employee.

Der Bestand bekommt den Generic Grade. Aus A--F liesse sich kein Hay-Grade
ableiten: andere Einteilung, andere Anzahl. Geraten saehe im Extrakt genauso
aus wie erhoben.
2026-09-28 11:13:41 +02:00

177 lines
6.2 KiB
TypeScript

"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 { HAY_GRADES } from "@/lib/hay-grade";
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"];
// 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="Hay-Grade"
value={paygrade}
onChange={(v) => setPaygrade(v as PaygradeType)}
options={HAY_GRADES}
/>
</div>
</SlideOver>
);
}