There was none anywhere in the model, so no personnel-cost figure could be produced at all, and an open position could not say whose budget it would charge — which is the first question asked about a vacancy. It hangs on the position, not on the person: the seat costs money even when nobody sits on it. That is exactly the vacancy case. And not on the org unit either, although it usually follows from one — a single seat can be charged elsewhere (project, shared function) without the unit moving. As its own dated assignment table rather than a column, because reassigning is an event with a date. Last year's costs have to stay where they were incurred; as a column, every change would silently rewrite every past report. Half-open [valid_from, valid_to), like position_assignments and om_positions — in SAP OM this is A011. 25 cost centres seeded from the org tree: one per company, division and department, with teams charging to their department, because a team is a span of control and not a budget. All 823 positions were assigned from their own start date, none left over. The number is the first five digits of the org number, so it can be traced rather than looked up. Reassignment refuses three things, each checked: the same cost centre again, a switch on the day the current one started (that period would never have been in force, and the range constraint says so), and a date before the position exists. Verified against the real data, which turned up a defect worth keeping: a position that starts in the future is charged only from its start, so asked about today it had no cost centre — and future positions are exactly what the vacancy list is for. It is now read at the position's own start date. Two audit entries from the probe could not be deleted through the application (the log has no delete policy — correctly), so I removed them with the admin connection. Still open, and the reason this is only the first of the three fields I proposed: location and planned FTE. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
195 lines
7.4 KiB
TypeScript
195 lines
7.4 KiB
TypeScript
"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 { OpenPositionResolved } 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: OpenPositionResolved;
|
||
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>
|
||
);
|
||
}
|