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.
223 lines
8.5 KiB
TypeScript
223 lines
8.5 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 { 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>
|
||
);
|
||
}
|