Files
alpenwerk-hr/components/positions/EditPositionModal.tsx
Andrei Laas 2eda038c2e
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m47s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m6s
Kontierung auch bei besetzten Planstellen -- aus dem Organigramm heraus
Die Kostenstelle liess sich an genau einer Stelle aendern: auf der Seite
Positionen, ueber die Karte einer Vakanz. Diese Seite zeigt aber nur unbesetzte
und kuenftige Planstellen. Bei einer besetzten war die Kontierung damit nirgends
zu erreichen -- im Bestand betrifft das 784 von 788, und dasselbe galt fuer ihre
Taetigkeit.

Die Angaben zu einer Einheit fuehren jetzt ihre Planstellen auf, besetzte
eingeschlossen, mit Inhaber:in und Kostenstelle. Ein Klick oeffnet denselben
Dialog wie auf Positionen, kein zweiter daneben. Was sich aendern laesst,
entscheidet weiterhin die Datenbank: bei einer besetzten Stelle bleiben Einheit
und Gueltigkeitsende gesperrt, weil ein Abteilungswechsel ueber eine Versetzung
gehoert und nicht ueber die Stelle.

Geladen wird erst beim Oeffnen und nur fuer diese eine Einheit. Alle 788
Planstellen in jede Zeichnung des Organigramms zu legen hiesse, fuer achtzig
Einheiten zu laden, was man fuer eine braucht.

EditPositionModal verlangt dafuer nicht mehr eine OpenPositionResolved, sondern
nur noch die Felder, die er tatsaechlich liest. Die alte Signatur war der
Grund, warum er nur dort aufzumachen war, wo unbesetzte Stellen entstehen.
2026-09-28 21:52:01 +02:00

195 lines
7.4 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);
async function handleSubmit() {
if (!jobTitle.trim() || !orgUnitId || !validFrom) {
showToast("Tätigkeit, Einheit und Gültigkeitsbeginn sind Pflicht.", "error");
return;
}
setPending(true);
const result = 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,
});
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(kostenstelleGeaendert ? "Planstelle geändert und 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>
);
}