diff --git a/actions/positions.ts b/actions/positions.ts index f9da4c6..a1f32a9 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" | "update_position" | "delete_position" | "set_position_cost_center", + fn: "create_position" | "clone_position" | "update_position" | "delete_position" | "set_position_cost_center", payload: Record, revalidate: string[] ): Promise { @@ -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 { + return callRpc("clone_position", payload, POSITION_PATHS); +} + /** * Ändert eine Planstelle. * diff --git a/components/positions/PositionsPageClient.tsx b/components/positions/PositionsPageClient.tsx index e8bf5a5..38e7f88 100644 --- a/components/positions/PositionsPageClient.tsx +++ b/components/positions/PositionsPageClient.tsx @@ -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(null); const [deletingId, setDeletingId] = useState(null); + const [cloningId, setCloningId] = useState(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 )} - + + {/* 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 && ( + + )} + + ); } diff --git a/db/migrations/20260915160000_planstelle_klonen.sql b/db/migrations/20260915160000_planstelle_klonen.sql new file mode 100644 index 0000000..fbc7060 --- /dev/null +++ b/db/migrations/20260915160000_planstelle_klonen.sql @@ -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 +$$; diff --git a/lib/types.ts b/lib/types.ts index a459f0c..42e15a8 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -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 }; Returns: string }; + // Kopie einer bestehenden Planstelle — gleiche Einheit, Tätigkeit und + // Kontierung, neue Nummer, unbesetzt. Leitungsplanstellen ausgenommen. + clone_position: { Args: { payload: Record }; Returns: string }; update_position: { Args: { payload: Record }; Returns: void }; set_position_cost_center: { Args: { payload: Record }; Returns: void }; set_onboarding_task: { Args: { payload: Record }; Returns: void };