Planstellen klonen — ausser den leitenden
Anforderung 11 aus dem Workshop. Migration 20260915160000. In der Fertigung sind Planstellen reihenweise gleich: zwoelf "Maschinenbediener:in" in derselben Abteilung auf derselben Kostenstelle. Von Hand angelegt sind das zwoelf Gelegenheiten, die Taetigkeit unterschiedlich zu schreiben — und ab der zweiten Schreibweise steht sie zweimal im Katalog und jede Auswertung nach Taetigkeit ist falsch. Der Klon nimmt Einheit, Taetigkeit (denselben Katalogeintrag) und die zum Stichtag geltende Kontierung. Nicht mit kommt die Besetzung: eine Planstelle ist ein Platz, keine Person, der Klon ist frei. Leitungsplanstellen sind ausgenommen, und zwar mit einer eigenen Meldung. Je Einheit gibt es genau eine, und ein Unique-Index sichert das ab — ohne die Pruefung waere ein Klonversuch entweder "duplicate key value violates unique constraint" oder, mit stillschweigend fallengelassenem is_chief, eine Planstelle, die anders ist als ihre Vorlage, ohne dass es jemand angefordert hat. In der Liste fehlt der Knopf dort; das ist Bequemlichkeit, die Regel steht in der Funktion.
This commit is contained in:
@@ -7,7 +7,7 @@ import { runMutation, type ActionResult } from "@/lib/db/rpc";
|
||||
const POSITION_PATHS = ["/positions", "/orgchart", "/"];
|
||||
|
||||
async function callRpc(
|
||||
fn: "create_position" | "update_position" | "delete_position" | "set_position_cost_center",
|
||||
fn: "create_position" | "clone_position" | "update_position" | "delete_position" | "set_position_cost_center",
|
||||
payload: Record<string, unknown>,
|
||||
revalidate: string[]
|
||||
): Promise<ActionResult> {
|
||||
@@ -26,6 +26,23 @@ export async function createPosition(payload: {
|
||||
return callRpc("create_position", payload, POSITION_PATHS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Planstelle als Kopie einer bestehenden anlegen.
|
||||
*
|
||||
* Kein eigenes Formular: Einheit, Tätigkeit und Kontierung kommen von der
|
||||
* Vorlage, getippt wird nichts. Genau das ist der Zweck — zwölf gleiche
|
||||
* Planstellen von Hand anzulegen erzeugt zwölf Gelegenheiten, die Tätigkeit
|
||||
* unterschiedlich zu schreiben, und ab der zweiten Schreibweise ist jede
|
||||
* Auswertung nach Tätigkeit falsch.
|
||||
*
|
||||
* Dass Leitungsplanstellen ausgenommen sind, entscheidet die SQL-Funktion und
|
||||
* nicht diese Stelle. Die Oberfläche blendet den Knopf dort aus, aber das ist
|
||||
* eine Bequemlichkeit, keine Regel.
|
||||
*/
|
||||
export async function clonePosition(payload: { position_id: string; valid_from?: string }): Promise<ActionResult> {
|
||||
return callRpc("clone_position", payload, POSITION_PATHS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ändert eine Planstelle.
|
||||
*
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { Copy, Plus, Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { deletePosition } from "@/actions/positions";
|
||||
import { clonePosition, deletePosition } from "@/actions/positions";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { fmtDate, todayIso } from "@/lib/format";
|
||||
@@ -25,6 +25,7 @@ export function PositionsPageClient({ openPositions, units, kostenstellen }: Pos
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<OpenPositionWithDays | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [cloningId, setCloningId] = useState<string | null>(null);
|
||||
|
||||
// Getrennt, weil es zwei verschiedene Aussagen sind: „hier fehlt jemand"
|
||||
// und „das entsteht erst". In einer Liste vermischt liest sich eine
|
||||
@@ -32,6 +33,18 @@ export function PositionsPageClient({ openPositions, units, kostenstellen }: Pos
|
||||
const offen = openPositions.filter((p) => !p.future);
|
||||
const kuenftig = openPositions.filter((p) => p.future);
|
||||
|
||||
async function handleClone(p: OpenPositionWithDays) {
|
||||
setCloningId(p.id);
|
||||
const result = await clonePosition({ position_id: p.id });
|
||||
setCloningId(null);
|
||||
if (result.success) {
|
||||
showToast(`Planstelle ${p.position_number} kopiert.`);
|
||||
router.refresh();
|
||||
} else {
|
||||
showToast(result.error ?? "Fehler beim Klonen.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
setDeletingId(id);
|
||||
const result = await deletePosition(id);
|
||||
@@ -77,15 +90,33 @@ export function PositionsPageClient({ openPositions, units, kostenstellen }: Pos
|
||||
)}
|
||||
</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>
|
||||
<span className="absolute right-2 top-2 flex items-center gap-0.5">
|
||||
{/* Nicht bei einer Leitung: je Einheit gibt es genau eine, ein Klon
|
||||
wäre also entweder ein Fehler aus dem Maschinenraum oder eine
|
||||
Planstelle, die stillschweigend anders ist als ihre Vorlage. Die
|
||||
SQL-Funktion weist es ohnehin ab — der fehlende Knopf erspart
|
||||
den Weg dorthin. */}
|
||||
{!p.is_chief && (
|
||||
<Button
|
||||
variant="icon"
|
||||
onClick={() => handleClone(p)}
|
||||
pending={cloningId === p.id}
|
||||
aria-label={`Planstelle ${p.position_number} (${p.title}) klonen`}
|
||||
title="Klonen — gleiche Einheit, Tätigkeit und Kostenstelle, neue Nummer"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="icon"
|
||||
onClick={() => handleDelete(p.id)}
|
||||
pending={deletingId === p.id}
|
||||
aria-label={`Planstelle ${p.position_number} (${p.title}) entfernen`}
|
||||
className="hover:!text-danger-solid"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
141
db/migrations/20260915160000_planstelle_klonen.sql
Normal file
141
db/migrations/20260915160000_planstelle_klonen.sql
Normal file
@@ -0,0 +1,141 @@
|
||||
-- Eine Planstelle klonen.
|
||||
--
|
||||
-- Anforderung 11 aus dem Workshop, beide Hälften:
|
||||
--
|
||||
-- Planstellen sollen geklont werden können
|
||||
-- Leitende Planstellen soll man nicht klonen können
|
||||
--
|
||||
-- ═══ Wofür das gut ist ═══
|
||||
--
|
||||
-- In der Fertigung sind Planstellen reihenweise gleich: zwölf
|
||||
-- „Maschinenbediener:in" in derselben Abteilung, auf derselben Kostenstelle.
|
||||
-- Über `create_position` ist jede davon dieselbe Eingabe von Hand, und bei
|
||||
-- zwölf Wiederholungen entstehen „Maschinenbediener:in" und
|
||||
-- „Maschinenbediener in" nebeneinander. Die Tätigkeit landet dann als zwei
|
||||
-- Einträge im Katalog, und jede Auswertung nach Tätigkeit ist ab da falsch.
|
||||
--
|
||||
-- Der Klon nimmt Einheit, Tätigkeit und Kontierung der Vorlage und vergibt
|
||||
-- nur eine neue Nummer. Getippt wird nichts.
|
||||
--
|
||||
-- ═══ Warum eine Leitung sich nicht klonen lässt ═══
|
||||
--
|
||||
-- Es gibt je Einheit genau eine, und ein Unique-Index sichert das ab
|
||||
-- (`is_chief and valid_to is null`). Ein Klon einer Leitungsplanstelle wäre
|
||||
-- also entweder ein Fehler aus dem Maschinenraum — „duplicate key value
|
||||
-- violates unique constraint" — oder, wenn man `is_chief` beim Klonen
|
||||
-- stillschweigend fallen liesse, eine Planstelle, die anders ist als die
|
||||
-- Vorlage, ohne dass es jemand angefordert hat.
|
||||
--
|
||||
-- Deshalb die ausdrückliche Prüfung mit einer Meldung, die den Grund nennt.
|
||||
-- Sie steht *vor* allem anderen, damit der Fall nicht erst an einem Index
|
||||
-- scheitert.
|
||||
--
|
||||
-- ═══ Was mitkommt und was nicht ═══
|
||||
--
|
||||
-- Mit: Organisationseinheit, Tätigkeit (derselbe Katalogeintrag, nicht eine
|
||||
-- neue Zeile mit demselben Text) und die zum Stichtag geltende Kontierung.
|
||||
-- Ohne Kontierung wäre der Klon eine Planstelle, die niemand bezahlt, und
|
||||
-- genau das Feld müsste man danach doch wieder von Hand nachtragen.
|
||||
--
|
||||
-- Nicht mit: die Besetzung. Eine Planstelle ist ein Platz, keine Person —
|
||||
-- der Klon ist per Definition frei.
|
||||
|
||||
create or replace function clone_position(payload jsonb)
|
||||
returns uuid language plpgsql
|
||||
set search_path = public, pg_temp
|
||||
as $$
|
||||
declare
|
||||
v_vorlage_id uuid := (payload->>'position_id')::uuid;
|
||||
v_valid_from date := coalesce(nullif(payload->>'valid_from','')::date, current_date);
|
||||
v_vorlage om_positions%rowtype;
|
||||
v_job_title text;
|
||||
v_unit_name text;
|
||||
v_neu_id uuid;
|
||||
v_kontierung integer := 0;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
|
||||
select * into v_vorlage from om_positions where id = v_vorlage_id;
|
||||
if v_vorlage.id is null then
|
||||
raise exception 'Die Planstelle existiert nicht.';
|
||||
end if;
|
||||
|
||||
if v_vorlage.is_chief then
|
||||
raise exception 'Leitungsplanstellen lassen sich nicht klonen. Je Organisationseinheit gibt es genau eine.';
|
||||
end if;
|
||||
|
||||
select j.title, u.name into v_job_title, v_unit_name
|
||||
from jobs j, org_units u
|
||||
where j.id = v_vorlage.job_id and u.id = v_vorlage.org_unit_id;
|
||||
|
||||
-- Eine Planstelle kann nicht vor ihrer Vorlage beginnen: sie ist deren
|
||||
-- Abbild, und ein Abbild, das früher gilt als das Original, ist keine
|
||||
-- Kopie mehr, sondern eine eigene Geschichte.
|
||||
if v_valid_from < v_vorlage.valid_from then
|
||||
raise exception 'Die Vorlage gilt erst ab %. Der Klon kann nicht früher beginnen.', v_vorlage.valid_from;
|
||||
end if;
|
||||
-- Und nicht nach ihrem Ende: sonst entstünde eine Planstelle, die es zu
|
||||
-- keinem Zeitpunkt gemeinsam mit ihrer Vorlage gab.
|
||||
if v_vorlage.valid_to is not null and v_valid_from >= v_vorlage.valid_to then
|
||||
raise exception 'Die Vorlage gilt nur bis %. Ein Klon ab % wäre nie gleichzeitig gültig.', v_vorlage.valid_to, v_valid_from;
|
||||
end if;
|
||||
|
||||
insert into om_positions (position_number, org_unit_id, job_id, is_chief, valid_from, valid_to)
|
||||
values (next_position_number(), v_vorlage.org_unit_id, v_vorlage.job_id, false, v_valid_from, v_vorlage.valid_to)
|
||||
returning id into v_neu_id;
|
||||
|
||||
-- Die zum Stichtag geltende Kontierung mitnehmen. Halboffenes Intervall
|
||||
-- wie überall: valid_from einschliessend, valid_to ausschliessend.
|
||||
insert into position_cost_centers (position_id, cost_center_id, valid_from, valid_to)
|
||||
select v_neu_id, k.cost_center_id, v_valid_from, k.valid_to
|
||||
from position_cost_centers k
|
||||
where k.position_id = v_vorlage_id
|
||||
and k.valid_from <= v_valid_from
|
||||
and (k.valid_to is null or k.valid_to > v_valid_from);
|
||||
get diagnostics v_kontierung = row_count;
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
|
||||
values (app_current_user_id(), current_actor_name(), 'Planstelle geklont',
|
||||
v_job_title || ' (' || v_unit_name || ')',
|
||||
'Kopie von ' || v_vorlage.position_number || ', gültig ab ' || v_valid_from ||
|
||||
case when v_kontierung > 0 then ', Kontierung übernommen' else ', ohne Kontierung' end);
|
||||
|
||||
return v_neu_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
comment on function clone_position(jsonb) is
|
||||
'Legt eine Planstelle als Kopie einer bestehenden an — gleiche Einheit, gleiche Tätigkeit, gleiche Kontierung, neue Nummer, unbesetzt. Leitungsplanstellen sind ausgenommen: je Einheit gibt es genau eine.';
|
||||
|
||||
grant execute on function clone_position(jsonb) to alpenwerk_app;
|
||||
|
||||
|
||||
-- Selbstprüfung.
|
||||
do $$
|
||||
declare
|
||||
v_def text;
|
||||
begin
|
||||
if not exists (select 1 from pg_proc where proname = 'clone_position') then
|
||||
raise exception 'clone_position fehlt';
|
||||
end if;
|
||||
v_def := pg_get_functiondef('public.clone_position(jsonb)'::regprocedure);
|
||||
|
||||
if v_def not like '%require_hr_admin%' then
|
||||
raise exception 'clone_position prüft die Rechte nicht';
|
||||
end if;
|
||||
if v_def not like '%is_chief%' then
|
||||
raise exception 'clone_position lässt Leitungsplanstellen durch';
|
||||
end if;
|
||||
if v_def not like '%position_cost_centers%' then
|
||||
raise exception 'clone_position nimmt die Kontierung nicht mit';
|
||||
end if;
|
||||
if v_def not like '%audit_log%' then
|
||||
raise exception 'clone_position schreibt nichts ins Protokoll';
|
||||
end if;
|
||||
-- Ohne search_path liesse sich der Funktion über einen eigenen Schemapfad
|
||||
-- eine andere Tabelle unterschieben. Hauskonvention für jede Funktion.
|
||||
if v_def not like '%search_path%' then
|
||||
raise exception 'clone_position hat keinen festen search_path';
|
||||
end if;
|
||||
end
|
||||
$$;
|
||||
@@ -702,6 +702,9 @@ 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 };
|
||||
// Kopie einer bestehenden Planstelle — gleiche Einheit, Tätigkeit und
|
||||
// Kontierung, neue Nummer, unbesetzt. Leitungsplanstellen ausgenommen.
|
||||
clone_position: { Args: { payload: Record<string, unknown> }; Returns: string };
|
||||
update_position: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
set_position_cost_center: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
set_onboarding_task: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
|
||||
Reference in New Issue
Block a user