From a87c688c0ab1713d3a6567cdbb34c74192495df5 Mon Sep 17 00:00:00 2001 From: Maximilian Stubhan Date: Wed, 5 Aug 2026 12:26:12 +0200 Subject: [PATCH] Show positions that do not exist yet, and let them be corrected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- actions/positions.ts | 21 ++- components/positions/EditPositionModal.tsx | 138 ++++++++++++++++++ components/positions/PositionsPageClient.tsx | 110 +++++++++----- lib/positions.ts | 21 ++- lib/supabase/types.ts | 1 + .../20260805130000_update_position.sql | 125 ++++++++++++++++ 6 files changed, 378 insertions(+), 38 deletions(-) create mode 100644 components/positions/EditPositionModal.tsx create mode 100644 supabase/migrations/20260805130000_update_position.sql diff --git a/actions/positions.ts b/actions/positions.ts index 35df7fb..571bd13 100644 --- a/actions/positions.ts +++ b/actions/positions.ts @@ -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, revalidate: string[] ): Promise { @@ -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 { + return callRpc("update_position", payload, POSITION_PATHS); +} + export async function deletePosition(positionId: string): Promise { return callRpc("delete_position", { position_id: positionId }, POSITION_PATHS); } diff --git a/components/positions/EditPositionModal.tsx b/components/positions/EditPositionModal.tsx new file mode 100644 index 0000000..5c57ce9 --- /dev/null +++ b/components/positions/EditPositionModal.tsx @@ -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 ( + + + + + } + > +
+ + + ({ + value: u.id, + label: `${"  ".repeat(u.depth)}${u.name} (${u.unit_type})`, + }))} + /> + +
+ + +
+ + + +

+ 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. +

+
+
+ ); +} diff --git a/components/positions/PositionsPageClient.tsx b/components/positions/PositionsPageClient.tsx index aa59535..47b072e 100644 --- a/components/positions/PositionsPageClient.tsx +++ b/components/positions/PositionsPageClient.tsx @@ -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(null); const [deletingId, setDeletingId] = useState(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 ( +
+ {/* 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. */} + + + +
+ ); + } + return (
-
+
-

Unbesetzte Planstellen ({openPositions.length})

+

Unbesetzte Planstellen ({offen.length})

- {openPositions.length === 0 ? ( -

Derzeit ist jede Planstelle besetzt.

+ {offen.length === 0 ? ( +

Derzeit ist jede geltende Planstelle besetzt oder vergeben.

) : (
- {openPositions.map((p) => { - const notYetValid = p.valid_from > today; - return ( -
-
-
{p.title}
- -
-
- {p.position_number} · {p.orgLabel} -
-
- {p.is_chief ? "Leitungsplanstelle · " : ""} - seit {p.daysOpen} Tagen unbesetzt -
- {p.managerName &&
berichtet an {p.managerName}
} - {notYetValid &&
Gültig ab {fmtDate(p.valid_from)}
} -
- ); - })} + {offen.map((p) => ( + + ))}
)} -
+ + + {kuenftig.length > 0 && ( +
+

Künftige Planstellen ({kuenftig.length})

+

+ Beschlossen, aber noch nicht gültig. Sie zählen nicht als Vakanz und lassen sich bis zum Beginn ändern. +

+
+ {kuenftig.map((p) => ( + + ))} +
+
+ )} 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 && ( + setEditing(null)} /> + )}
); } diff --git a/lib/positions.ts b/lib/positions.ts index d7b18fc..9eb13cd 100644 --- a/lib/positions.ts +++ b/lib/positions.ts @@ -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 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 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, diff --git a/lib/supabase/types.ts b/lib/supabase/types.ts index deac10c..cf8995d 100644 --- a/lib/supabase/types.ts +++ b/lib/supabase/types.ts @@ -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 }; Returns: string }; + update_position: { Args: { payload: Record }; Returns: void }; delete_position: { Args: { payload: Record }; Returns: void }; is_valid_svnr: { Args: { p_svnr: string; p_birth_date?: string | null }; Returns: boolean }; apply_due_pending_changes: { Args: Record; Returns: number }; diff --git a/supabase/migrations/20260805130000_update_position.sql b/supabase/migrations/20260805130000_update_position.sql new file mode 100644 index 0000000..9bda378 --- /dev/null +++ b/supabase/migrations/20260805130000_update_position.sql @@ -0,0 +1,125 @@ +-- 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); + + if jsonb_array_length(v_changes) = 0 then + return; + 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; +$$;