Compare commits

..

2 Commits

Author SHA1 Message Date
0d8af7cdf0 Refuse a save that changes nothing instead of reporting success
update_position returned quietly when no field differed, and the interface
answered "Planstelle geändert." — a confirmation for something that had not
happened. It now raises, and the message says so.

This is reachable without the user doing anything wrong: the chief checkbox
is dropped on the way out when the unit already has a chief position, so a
save consisting only of that tick arrives as an empty change set. The reply
was a green toast and an unchanged list, which sends someone looking in the
wrong place.

It also separates the two explanations for "I saved and nothing happened",
which is why it went in now: an empty change set is refused in red, so a
green confirmation with a stale card can only mean the page did not reload.

Verified against the live database: an unchanged payload is refused, a
changed one goes through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 14:57:31 +02:00
a87c688c0a Show positions that do not exist yet, and let them be corrected
Two gaps in the positions view, both reported from use.

A position dated into the future was invisible. loadOpenPositions required
valid_from <= today, so a position decided now and effective at the quarter
boundary appeared nowhere until the day it began. The database already held
one — 60000824 "Neue Position", effective 01.09. — created through the
application and shown on no screen since.

Future positions now have their own section rather than joining the vacancy
list. They are a different statement: "nobody is here" and "this does not
exist yet" should not be counted together, and a position starting 01.10.
read as a vacancy nobody was filling.

Positions could only be created and deleted. Fixing a typo in the job title
meant deleting and recreating — with a new position number, which appears in
job postings, budgets and audit entries, and whose trail then breaks.
update_position keeps the number and records old and new values per field,
using the audit detail added earlier today.

Three things it refuses, as guards rather than remarks:

  - Moving an occupied position to another unit. That is a transfer, with
    history and reporting line, and belongs to the person — otherwise
    someone changes department silently.
  - Ending an occupied position, which would leave an assignment without
    one.
  - A second chief position in a unit, or an end before the start.

Verified against the live database, all rolled back: each guard fires with
its own message, the permitted edits go through, the audit entry carries the
changed fields. Open positions stay at 9 and the future one now appears in
its own section.

ESLint caught me priming the dialog's fields from an effect. Replaced by a
key on the component, so React rebuilds it per position and the fields
initialise from props — which also removes the flash of the previous
position's values on second open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 12:26:12 +02:00
6 changed files with 385 additions and 38 deletions

View File

@@ -7,7 +7,7 @@ import { runMutation, type ActionResult } from "@/lib/db/rpc";
const POSITION_PATHS = ["/positions", "/orgchart", "/"];
async function callRpc(
fn: "create_position" | "delete_position",
fn: "create_position" | "update_position" | "delete_position",
payload: Record<string, unknown>,
revalidate: string[]
): Promise<ActionResult> {
@@ -26,6 +26,25 @@ export async function createPosition(payload: {
return callRpc("create_position", payload, POSITION_PATHS);
}
/**
* Ändert eine Planstelle.
*
* Weggelassene Felder bleiben, wie sie sind. `valid_to: null` beendet die
* Befristung ausdrücklich — deshalb ist es hier `string | null` und nicht
* optional: „nicht mitgeschickt" und „auf leer setzen" müssen unterscheidbar
* bleiben.
*/
export async function updatePosition(payload: {
position_id: string;
org_unit_id?: string;
job_title?: string;
is_chief?: boolean;
valid_from?: string;
valid_to?: string | null;
}): Promise<ActionResult> {
return callRpc("update_position", payload, POSITION_PATHS);
}
export async function deletePosition(positionId: string): Promise<ActionResult> {
return callRpc("delete_position", { position_id: positionId }, POSITION_PATHS);
}

View File

@@ -0,0 +1,138 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { 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,
onClose,
}: {
position: OpenPositionResolved;
units: UnitOption[];
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 ?? "");
const [pending, setPending] = useState(false);
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,
});
setPending(false);
if (result.success) {
showToast("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>
<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>
);
}

View File

@@ -6,9 +6,10 @@ import { useState } from "react";
import { deletePosition } from "@/actions/positions";
import { Button } from "@/components/ui/Button";
import { useToast } from "@/components/ui/Toast";
import { fmtDate, todayIso } from "@/lib/format";
import { fmtDate } from "@/lib/format";
import type { OpenPositionResolved } from "@/lib/positions";
import { CreatePositionModal, type UnitOption } from "./CreatePositionModal";
import { EditPositionModal } from "./EditPositionModal";
type OpenPositionWithDays = OpenPositionResolved & { daysOpen: number };
@@ -21,8 +22,14 @@ export function PositionsPageClient({ openPositions, units }: PositionsPageClien
const { showToast } = useToast();
const router = useRouter();
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<OpenPositionWithDays | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const today = todayIso();
// Getrennt, weil es zwei verschiedene Aussagen sind: „hier fehlt jemand"
// und „das entsteht erst". In einer Liste vermischt liest sich eine
// Planstelle zum 01.10. wie eine Vakanz, um die sich niemand kümmert.
const offen = openPositions.filter((p) => !p.future);
const kuenftig = openPositions.filter((p) => p.future);
async function handleDelete(id: string) {
setDeletingId(id);
@@ -36,53 +43,86 @@ export function PositionsPageClient({ openPositions, units }: PositionsPageClien
}
}
function Karte({ p }: { p: OpenPositionWithDays }) {
return (
<div className="relative flex flex-col rounded border border-border transition-colors focus-within:border-brand-500 hover:border-brand-500">
{/* Die ganze Karte öffnet den Änderungsdialog; der Papierkorb liegt
absolut darüber. Ein Knopf im Knopf wäre ungültiges HTML und mit
der Tastatur nicht erreichbar. */}
<button
type="button"
onClick={() => setEditing(p)}
aria-label={`Planstelle ${p.position_number} (${p.title}) ändern`}
className="flex flex-col items-start rounded p-3 pr-10 text-left
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
<span className="text-sm font-semibold text-ink">{p.title}</span>
<span className="text-xs text-ink-muted">
{p.position_number} · {p.orgLabel}
</span>
<span className="mt-1 text-xs text-ink-muted">
{p.is_chief ? "Leitungsplanstelle · " : ""}
{p.future ? `gültig ab ${fmtDate(p.valid_from)}` : `seit ${p.daysOpen} Tagen unbesetzt`}
</span>
{p.valid_to && <span className="text-xs font-semibold text-warning-text">endet am {fmtDate(p.valid_to)}</span>}
{p.managerName && <span className="text-xs text-ink-muted">berichtet an {p.managerName}</span>}
</button>
<Button
variant="icon"
onClick={() => handleDelete(p.id)}
pending={deletingId === p.id}
aria-label={`Planstelle ${p.position_number} (${p.title}) entfernen`}
className="absolute right-2 top-2 hover:!text-danger-solid"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
);
}
return (
<div className="flex flex-col gap-6">
<div className="rounded border border-border bg-white p-4">
<section className="rounded border border-border bg-white p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-sm font-bold text-ink">Unbesetzte Planstellen ({openPositions.length})</h2>
<h2 className="text-sm font-bold text-ink">Unbesetzte Planstellen ({offen.length})</h2>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
Planstelle anlegen
</Button>
</div>
{openPositions.length === 0 ? (
<p className="text-sm text-ink-muted">Derzeit ist jede Planstelle besetzt.</p>
{offen.length === 0 ? (
<p className="text-sm text-ink-muted">Derzeit ist jede geltende Planstelle besetzt oder vergeben.</p>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{openPositions.map((p) => {
const notYetValid = p.valid_from > today;
return (
<div key={p.id} className="flex flex-col rounded border border-border p-3">
<div className="flex items-start justify-between gap-2">
<div className="text-sm font-semibold text-ink">{p.title}</div>
<Button
variant="icon"
onClick={() => handleDelete(p.id)}
pending={deletingId === p.id}
aria-label={`Planstelle ${p.position_number} (${p.title}) entfernen`}
className="-mr-1 -mt-1 hover:!text-danger-solid"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="text-xs text-ink-muted">
{p.position_number} · {p.orgLabel}
</div>
<div className="mt-1 text-xs text-ink-muted">
{p.is_chief ? "Leitungsplanstelle · " : ""}
seit {p.daysOpen} Tagen unbesetzt
</div>
{p.managerName && <div className="text-xs text-ink-muted">berichtet an {p.managerName}</div>}
{notYetValid && <div className="mt-1 text-xs font-semibold text-warning-text">Gültig ab {fmtDate(p.valid_from)}</div>}
</div>
);
})}
{offen.map((p) => (
<Karte key={p.id} p={p} />
))}
</div>
)}
</div>
</section>
{kuenftig.length > 0 && (
<section className="rounded border border-border bg-white p-4">
<h2 className="text-sm font-bold text-ink">Künftige Planstellen ({kuenftig.length})</h2>
<p className="mb-3 mt-1 text-xs text-ink-muted">
Beschlossen, aber noch nicht gültig. Sie zählen nicht als Vakanz und lassen sich bis zum Beginn ändern.
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{kuenftig.map((p) => (
<Karte key={p.id} p={p} />
))}
</div>
</section>
)}
<CreatePositionModal open={createOpen} onClose={() => setCreateOpen(false)} units={units} />
{/* `key` sorgt dafür, dass React je Planstelle einen frischen Dialog
baut — sonst blieben beim zweiten Öffnen die Werte des ersten
stehen. */}
{editing && (
<EditPositionModal key={editing.id} position={editing} units={units} onClose={() => setEditing(null)} />
)}
</div>
);
}

View File

@@ -14,6 +14,17 @@ export type OpenPositionResolved = {
org_unit_id: string;
is_chief: boolean;
valid_from: string;
valid_to: string | null;
/** Die Einheit selbst — für den Änderungsdialog, der sie vorbelegt. */
org_unit_name: string;
/**
* Ob die Planstelle heute schon gilt.
*
* Eine, die erst zum 01.10. entsteht, ist nicht „seit 2 Tagen unbesetzt" —
* sie ist geplant. Beides in einer Liste zu zeigen ist richtig, beides
* gleich zu benennen wäre falsch.
*/
future: boolean;
/** Wer die Stelle nach der Berichtslinie führen wird. */
managerName: string | null;
orgLabel: string;
@@ -56,8 +67,11 @@ export async function loadOpenPositions(tx: Tx): Promise<OpenPositionResolved[]>
tx
.selectFrom("om_positions as p")
.innerJoin("jobs as j", "j.id", "p.job_id")
.select(["p.id", "p.position_number", "p.org_unit_id", "p.is_chief", "p.valid_from", "j.title"])
.where("p.valid_from", "<=", asOf)
.select(["p.id", "p.position_number", "p.org_unit_id", "p.is_chief", "p.valid_from", "p.valid_to", "j.title"])
// Künftige Planstellen bleiben drin. Sie sind der Grund, warum diese
// Ansicht existiert: eine Stelle, die zum Quartalswechsel entsteht,
// muss vorher sichtbar und planbar sein. Vorher fielen sie durch das
// Raster und tauchten am Stichtag unangekündigt auf.
.where((eb) => eb.or([eb("p.valid_to", "is", null), eb("p.valid_to", ">", asOf)]))
.where((eb) =>
eb.not(
@@ -125,6 +139,9 @@ export async function loadOpenPositions(tx: Tx): Promise<OpenPositionResolved[]>
org_unit_id: p.org_unit_id,
is_chief: p.is_chief,
valid_from: p.valid_from,
valid_to: p.valid_to,
org_unit_name: orgMaps.units.get(p.org_unit_id)?.name ?? "",
future: p.valid_from > asOf,
managerName: managerUnit ? (chiefNameByUnit.get(managerUnit) ?? null) : null,
orgLabel: breadcrumbLabel(orgMaps, p.org_unit_id),
vacantSince: lastEndByPosition.get(p.id) ?? p.valid_from,

View File

@@ -457,6 +457,7 @@ export type Database = {
// Planstelle anlegen bzw. schliessen — im OM-Modell Operationen auf
// om_positions, nicht mehr auf einer eigenen Ausschreibungstabelle.
create_position: { Args: { payload: Record<string, unknown> }; Returns: string };
update_position: { Args: { payload: Record<string, unknown> }; Returns: void };
delete_position: { Args: { payload: Record<string, unknown> }; Returns: void };
is_valid_svnr: { Args: { p_svnr: string; p_birth_date?: string | null }; Returns: boolean };
apply_due_pending_changes: { Args: Record<string, never>; Returns: number };

View File

@@ -0,0 +1,132 @@
-- Eine Planstelle ändern.
--
-- Bisher liess sie sich nur anlegen und löschen. Ein Tippfehler in der
-- Tätigkeit oder ein falsches Gültigkeitsdatum bedeutete: löschen und neu —
-- mit neuer Planstellennummer. Die Nummer steht aber in Stellenausschreibungen
-- und Budgets, und die Protokollspur reisst ab.
--
-- Was hier bewusst **nicht** geht, steht als Sperre drin, nicht als
-- Anmerkung — siehe die drei Prüfungen unten.
create or replace function update_position(payload jsonb)
returns void
language plpgsql
set search_path = public, pg_temp
as $function$
declare
v_id uuid := (payload->>'position_id')::uuid;
v_alt om_positions%rowtype;
v_alt_titel text;
v_alt_einheit text;
v_org_unit_id uuid;
v_job_title text := nullif(trim(payload->>'job_title'), '');
v_is_chief boolean;
v_valid_from date;
v_valid_to date;
v_job_id uuid;
v_unit_name text;
v_besetzt boolean;
v_changes jsonb := '[]'::jsonb;
begin
perform require_hr_admin();
select * into v_alt from om_positions where id = v_id;
if v_alt.id is null then
raise exception 'Die Planstelle existiert nicht.';
end if;
select title into v_alt_titel from jobs where id = v_alt.job_id;
select name into v_alt_einheit from org_units where id = v_alt.org_unit_id;
-- Fehlende Schlüssel heissen „unverändert", nicht „leeren".
v_org_unit_id := coalesce(nullif(payload->>'org_unit_id','')::uuid, v_alt.org_unit_id);
v_job_title := coalesce(v_job_title, v_alt_titel);
v_is_chief := coalesce((payload->>'is_chief')::boolean, v_alt.is_chief);
v_valid_from := coalesce(nullif(payload->>'valid_from','')::date, v_alt.valid_from);
v_valid_to := case when payload ? 'valid_to' then nullif(payload->>'valid_to','')::date else v_alt.valid_to end;
select name into v_unit_name from org_units where id = v_org_unit_id;
if v_unit_name is null then
raise exception 'Die Organisationseinheit existiert nicht.';
end if;
if v_valid_to is not null and v_valid_to < v_valid_from then
raise exception 'Das Ende der Gültigkeit liegt vor ihrem Beginn.';
end if;
select exists (
select 1 from position_assignments
where position_id = v_id and (valid_to is null or valid_to > current_date)
) into v_besetzt;
-- (1) Die Einheit einer vergebenen Planstelle zu wechseln wäre eine
-- Versetzung — mit allem, was dazugehört: Historie, Berichtslinie,
-- Protokoll. Das gehört in transfer_employee und nicht hierher, sonst
-- wandert jemand lautlos in eine andere Abteilung.
if v_besetzt and v_org_unit_id is distinct from v_alt.org_unit_id then
raise exception 'Diese Planstelle ist vergeben. Für einen Wechsel der Einheit die Versetzung benutzen.';
end if;
-- (2) Ein Ende, während noch jemand darauf sitzt, hinterlässt eine
-- Besetzung ohne Planstelle.
if v_besetzt and v_valid_to is not null then
raise exception 'Diese Planstelle ist vergeben und kann kein Ende der Gültigkeit bekommen.';
end if;
-- (3) Je Einheit nur eine gültige Leitung. Der Unique-Index fängt das auch,
-- aber mit einer Meldung, die in der Oberfläche nichts erklärt.
if v_is_chief and exists (
select 1 from om_positions
where org_unit_id = v_org_unit_id and is_chief and valid_to is null and id <> v_id
) then
raise exception 'Für % besteht bereits eine Leitungsplanstelle.', v_unit_name;
end if;
-- Gleiche Tätigkeit, ein Katalogeintrag — dieselbe Regel wie beim Anlegen.
select id into v_job_id from jobs where lower(title) = lower(v_job_title);
if v_job_id is null then
insert into jobs (code, title)
values ('J' || lpad((select count(*) + 1 from jobs)::text, 4, '0'), v_job_title)
returning id into v_job_id;
end if;
v_changes := app_aenderung(v_changes, 'Tätigkeit', v_alt_titel, v_job_title);
v_changes := app_aenderung(v_changes, 'Organisationseinheit', v_alt_einheit, v_unit_name);
v_changes := app_aenderung(v_changes, 'Leitungsplanstelle', v_alt.is_chief::text, v_is_chief::text);
v_changes := app_aenderung(v_changes, 'Gültig ab', v_alt.valid_from::text, v_valid_from::text);
v_changes := app_aenderung(v_changes, 'Gültig bis', v_alt.valid_to::text, v_valid_to::text);
-- Nichts geändert ist ein Ergebnis, kein Erfolg.
--
-- Vorher kehrte die Funktion hier stumm zurück, und die Oberfläche meldete
-- „Planstelle geändert." Wer etwas eingetragen hatte, das unterwegs
-- verworfen wurde — etwa das Leitungshäkchen, das bei bereits vergebener
-- Leitung nicht durchkommt —, sah eine Erfolgsmeldung und eine unveränderte
-- Liste. Das ist genau die Rückmeldung, die einen suchen lässt.
if jsonb_array_length(v_changes) = 0 then
raise exception 'Es wurde nichts geändert.';
end if;
update om_positions
set org_unit_id = v_org_unit_id,
job_id = v_job_id,
is_chief = v_is_chief,
valid_from = v_valid_from,
valid_to = v_valid_to
where id = v_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, details, changes)
values (app_current_user_id(), current_actor_name(), 'Planstelle geändert',
v_job_title || ' (' || v_unit_name || ')',
app_aenderungsfelder(v_changes), v_changes);
end;
$function$;
do $$
declare r text;
begin
foreach r in array array['anon', 'authenticated', 'service_role', 'alpenwerk_app'] loop
if exists (select 1 from pg_roles where rolname = r) then
execute format('grant execute on function update_position(jsonb) to %I', r);
end if;
end loop;
end;
$$;