Files
alpenwerk-hr/components/positions/EditPositionModal.tsx
Andrei Laas cf5ed5c6c6
Some checks failed
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
CI / Lint, Typen, Tests, Build (push) Successful in 11m17s
Vier Befunde, die still falsche Ergebnisse lieferten
C.08 -- die Suche mit Bindestrich fand nichts. Das Feld wird per translate an
"-/:.," in Leerzeichen zerlegt, die Eingabe aber nicht: "Mueller-Weiss" erzeugte
das Muster mueller-weiss%, waehrend im Heuhaufen "mueller weiss" stand. Ohne
Bindestrich fand man dieselbe Person. Die Trennzeichen stehen jetzt einmal in
lib/employee-search.ts und werden von beiden Seiten des Vergleichs benutzt.

C.09 -- der Einheitenfilter verlor jede Person mit vorgemerktem Austritt.
"Laufend" war valid_to is null, aber terminate_employee setzt das Ende schon
beim Erfassen, Monate vor dem Tag. Jetzt zaehlt auch, was noch laeuft
(valid_to > heute). Bewusst ohne valid_from <= heute: ein geplanter Eintritt
gehoert in die Liste, sonst fiele er aus dem Filter, obwohl der Status
"Geplant" ihn ausdruecklich fuehrt.

E.07 -- nur die Kostenstelle zu aendern war unmoeglich. update_position weist
einen Aufruf ohne Aenderung ab, und die Umkontierung lief erst bei dessen
Erfolg. Sie wird jetzt uebersprungen, wenn sich an den Stammangaben nichts
geaendert hat.

H.06 -- jedes Speichern erzeugte zusaetzlich "Wochenstunden 30.0 -> 30". Der
Vergleich laeuft ueber Text, die Spalte ist numeric(4,1), und das Formular
schickt 30. Der Kommentar an der Zeile nannte die Absicht richtig, nur reicht
::numeric dafuer nicht -- es muss auf die Genauigkeit der Spalte gehen.
2026-09-29 19:47:48 +02:00

223 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. 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 { useState } from "react";
import { setPositionCostCenter, updatePosition } from "@/actions/positions";
import { Button } from "@/components/ui/Button";
import { SelectField, TextField } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import type { PlanstelleZumAendern } from "@/lib/positions";
import type { UnitOption } from "./CreatePositionModal";
// Ändern statt löschen und neu anlegen.
//
// Die Planstellennummer steht in Ausschreibungen, Budgets und
// Protokolleinträgen. Wer wegen eines Tippfehlers in der Tätigkeit eine neue
// Nummer vergibt, macht die alten Bezüge wertlos — deshalb gibt es diesen
// Dialog.
/**
* Erwartet eine Planstelle, keine „vielleicht keine".
*
* Die aufrufende Seite hängt einen `key` mit der Kennung daran und rendert
* ihn nur, solange etwas bearbeitet wird. Dadurch baut React den Dialog je
* Planstelle neu auf, und die Felder lassen sich direkt aus den Eigenschaften
* vorbelegen — statt sie in einem Effekt nachzuziehen, der beim zweiten
* Öffnen kurz die Werte der vorigen Stelle zeigt.
*/
export function EditPositionModal({
position,
units,
kostenstellen,
heute,
onClose,
}: {
position: PlanstelleZumAendern;
units: UnitOption[];
kostenstellen: { id: string; code: string; name: string }[];
/** Vom Server, nicht aus new Date(): sonst rechnet der Browser mit seiner Zone. */
heute: string;
onClose: () => void;
}) {
const { showToast } = useToast();
const router = useRouter();
const [jobTitle, setJobTitle] = useState(position.title);
const [orgUnitId, setOrgUnitId] = useState(position.org_unit_id);
const [isChief, setIsChief] = useState(position.is_chief);
const [validFrom, setValidFrom] = useState(position.valid_from);
const [validTo, setValidTo] = useState(position.valid_to ?? "");
// Die Kontierung ist ein eigener Vorgang mit eigenem Stichtag, kein Feld
// unter anderen: die laufende wird beendet, die neue beginnt. Deshalb steht
// hier ein Datum daneben und kein stilles Überschreiben.
const [kostenstelleId, setKostenstelleId] = useState(
kostenstellen.find((k) => k.code === position.kostenstelle?.code)?.id ?? ""
);
const [kostenstelleAb, setKostenstelleAb] = useState(heute);
const [pending, setPending] = useState(false);
const kostenstelleGeaendert =
kostenstelleId !== "" && kostenstellen.find((k) => k.id === kostenstelleId)?.code !== position.kostenstelle?.code;
const unit = units.find((u) => u.id === orgUnitId);
// Die eigene Leitung zählt nicht als Hindernis für sich selbst.
const chiefTaken = Boolean(unit?.hasChief) && !(position.is_chief && orgUnitId === position.org_unit_id);
// Ob sich an den Stammangaben überhaupt etwas geändert hat.
//
// update_position weist einen Aufruf ohne Änderung mit „Es wurde nichts
// geändert." ab — und weil die Umkontierung erst bei dessen Erfolg lief,
// liess sich die Kostenstelle **allein** nicht setzen: die Meldung kam, und
// die neue Kostenstelle blieb liegen. Wer es trotzdem wollte, musste
// nebenbei ein anderes Feld verstellen. Gemeldet im Test vom 29.09. (E.07).
const stammGeaendert =
jobTitle.trim() !== position.title ||
orgUnitId !== position.org_unit_id ||
(isChief && !chiefTaken) !== position.is_chief ||
validFrom !== position.valid_from ||
(validTo || null) !== (position.valid_to ?? null);
async function handleSubmit() {
if (!jobTitle.trim() || !orgUnitId || !validFrom) {
showToast("Tätigkeit, Einheit und Gültigkeitsbeginn sind Pflicht.", "error");
return;
}
if (!stammGeaendert && !kostenstelleGeaendert) {
showToast("Es wurde nichts geändert.", "error");
return;
}
setPending(true);
// Nur aufrufen, wenn es etwas zu ändern gibt. Sonst ist der Fehlschlag
// dieses Schritts die einzige Antwort auf eine reine Umkontierung.
const result = stammGeaendert
? await updatePosition({
position_id: position.id,
org_unit_id: orgUnitId,
job_title: jobTitle.trim(),
is_chief: isChief && !chiefTaken,
valid_from: validFrom,
valid_to: validTo || null,
})
: { success: true as const };
if (result.success && kostenstelleGeaendert) {
// Nacheinander, nicht gemeinsam: die Umkontierung ist ein eigener
// Vorgang mit eigenem Stichtag und eigenem Protokolleintrag. Schlägt sie
// fehl, steht die übrige Änderung trotzdem — und die Meldung sagt, was
// offen blieb, statt beides stillschweigend zu verwerfen.
const kosten = await setPositionCostCenter({
position_id: position.id,
cost_center_id: kostenstelleId,
valid_from: kostenstelleAb,
});
if (!kosten.success) {
setPending(false);
showToast(kosten.error ?? "Die Kostenstelle konnte nicht geändert werden.", "error");
router.refresh();
return;
}
}
setPending(false);
if (result.success) {
showToast(
stammGeaendert && kostenstelleGeaendert
? "Planstelle geändert und umkontiert."
: kostenstelleGeaendert
? "Planstelle umkontiert."
: "Planstelle geändert."
);
router.refresh();
onClose();
} else {
// Die Meldungen der Datenbankfunktion sind für die Oberfläche
// geschrieben („Diese Planstelle ist vergeben …“) und werden gezeigt.
showToast(result.error ?? "Die Änderung war nicht möglich.", "error");
}
}
return (
<Modal
open
onClose={onClose}
title={`Planstelle ${position.position_number}`}
footer={
<>
<Button variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button onClick={handleSubmit} pending={pending}>
Speichern
</Button>
</>
}
>
<div className="flex flex-col gap-3">
<TextField label="Tätigkeit" required value={jobTitle} onChange={setJobTitle} />
<SelectField
label="Organisationseinheit"
required
value={orgUnitId}
onChange={setOrgUnitId}
placeholder="Bitte wählen…"
options={units.map((u) => ({
value: u.id,
label: `${"  ".repeat(u.depth)}${u.name} (${u.unit_type})`,
}))}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<TextField label="Gültig ab" required type="date" value={validFrom} onChange={setValidFrom} />
<TextField label="Gültig bis" type="date" value={validTo} onChange={setValidTo} />
</div>
<SelectField
label="Kostenstelle"
value={kostenstelleId}
onChange={setKostenstelleId}
placeholder="Keine"
options={kostenstellen.map((k) => ({ value: k.id, label: `${k.code} · ${k.name}` }))}
/>
{kostenstelleGeaendert && (
<div className="rounded border border-border bg-surface px-3 py-2">
<TextField
label="Umkontieren ab"
required
type="date"
value={kostenstelleAb}
onChange={setKostenstelleAb}
min={position.valid_from}
/>
<p className="mt-1.5 text-xs text-ink-muted">
Die bisherige Kontierung endet an diesem Tag, die neue beginnt. Was vorher gebucht wurde, bleibt, wo es
gebucht wurde.
</p>
</div>
)}
<label className="flex items-start gap-2 text-sm text-ink-body">
<input
type="checkbox"
className="mt-0.5 h-4 w-4 rounded border-border text-brand-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
checked={isChief && !chiefTaken}
disabled={chiefTaken}
onChange={(e) => setIsChief(e.target.checked)}
/>
<span>
Leitungsplanstelle
{chiefTaken && (
<span className="block text-xs text-ink-muted">
Für {unit?.name} besteht bereits eine Leitungsplanstelle.
</span>
)}
</span>
</label>
<p className="rounded-md bg-surface px-3 py-2 text-xs leading-relaxed text-ink-muted">
Die Planstellennummer bleibt. Ist die Stelle vergeben, lassen sich Einheit und Gültigkeitsende nicht ändern —
ein Wechsel der Einheit ist eine Versetzung und gehört zur Person, nicht zur Stelle.
</p>
</div>
</Modal>
);
}