Files
alpenwerk-hr/actions/positions.ts
Andrei Laas 2eda038c2e
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m47s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m6s
Kontierung auch bei besetzten Planstellen -- aus dem Organigramm heraus
Die Kostenstelle liess sich an genau einer Stelle aendern: auf der Seite
Positionen, ueber die Karte einer Vakanz. Diese Seite zeigt aber nur unbesetzte
und kuenftige Planstellen. Bei einer besetzten war die Kontierung damit nirgends
zu erreichen -- im Bestand betrifft das 784 von 788, und dasselbe galt fuer ihre
Taetigkeit.

Die Angaben zu einer Einheit fuehren jetzt ihre Planstellen auf, besetzte
eingeschlossen, mit Inhaber:in und Kostenstelle. Ein Klick oeffnet denselben
Dialog wie auf Positionen, kein zweiter daneben. Was sich aendern laesst,
entscheidet weiterhin die Datenbank: bei einer besetzten Stelle bleiben Einheit
und Gueltigkeitsende gesperrt, weil ein Abteilungswechsel ueber eine Versetzung
gehoert und nicht ueber die Stelle.

Geladen wird erst beim Oeffnen und nur fuer diese eine Einheit. Alle 788
Planstellen in jede Zeichnung des Organigramms zu legen hiesse, fuer achtzig
Einheiten zu laden, was man fuer eine braucht.

EditPositionModal verlangt dafuer nicht mehr eine OpenPositionResolved, sondern
nur noch die Felder, die er tatsaechlich liest. Die alte Signatur war der
Grund, warum er nur dort aufzumachen war, wo unbesetzte Stellen entstehen.
2026-09-28 21:52:01 +02:00

175 lines
6.6 KiB
TypeScript

"use server";
import { revalidatePath } from "next/cache";
import { currentUserId } from "@/lib/auth/session";
import { kontierungZum, kostenstellenAbfrage, type KontierungsZeile } from "@/lib/cost-centers";
import { withUser } from "@/lib/db";
import { jsonArrayFrom } from "@/lib/db/json";
import { runMutation, type ActionResult } from "@/lib/db/rpc";
import { fmtName, todayIso } from "@/lib/format";
import type { PlanstelleZumAendern } from "@/lib/positions";
const POSITION_PATHS = ["/positions", "/orgchart", "/"];
async function callRpc(
fn: "create_position" | "clone_position" | "update_position" | "delete_position" | "set_position_cost_center",
payload: Record<string, unknown>,
revalidate: string[]
): Promise<ActionResult> {
const result = await runMutation(await currentUserId(), fn, payload);
if (!result.success) return result;
for (const path of revalidate) revalidatePath(path);
return result;
}
export async function createPosition(payload: {
org_unit_id: string;
job_title: string;
is_chief: boolean;
valid_from: string;
}): Promise<ActionResult> {
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.
*
* 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);
}
/**
* Kontiert eine Planstelle auf eine andere Kostenstelle — zum Stichtag.
*
* Kein Feld im Änderungsdialog, sondern ein eigener Vorgang, weil es einer
* ist: die laufende Kontierung wird beendet, die neue beginnt. Die alte
* einfach zu überschreiben hiesse, die Kosten der Vergangenheit nachträglich
* anderswohin zu buchen.
*/
export async function setPositionCostCenter(payload: {
position_id: string;
cost_center_id: string;
valid_from: string;
}): Promise<ActionResult> {
return callRpc("set_position_cost_center", payload, POSITION_PATHS);
}
export type PlanstelleDerEinheit = PlanstelleZumAendern & {
/** Wer am Stichtag darauf sitzt; null heisst unbesetzt. */
besetztVon: string | null;
};
/**
* Die Planstellen einer Organisationseinheit — **auch die besetzten**.
*
* Gelesen, nicht geschrieben, und erst beim Öffnen der Einheit: der Bestand
* zählt 788 Planstellen, und sie alle in jede Zeichnung des Organigramms zu
* legen hiesse, für achtzig Einheiten zu laden, was man für eine braucht.
*
* Der Grund, warum es diese Abfrage überhaupt gibt: die Seite Positionen zeigt
* nur unbesetzte Stellen, und der Änderungsdialog war allein von dort aus
* erreichbar. Die Kontierung einer besetzten Planstelle liess sich damit
* nirgends ändern.
*
* Kein Schutzgatter im Anwendungscode: die Zeilenschutz-Regeln geben einer
* Person ohne HR-Recht nichts zurück, und das ist die Grenze, die zählt.
*/
export async function ladePlanstellenDerEinheit(orgUnitId: string): Promise<{
planstellen: PlanstelleDerEinheit[];
kostenstellen: { id: string; code: string; name: string }[];
/** Vom Server: der Dialog rechnet sonst mit der Zone des Browsers. */
heute: string;
}> {
const heute = todayIso();
const g = await withUser(await currentUserId(), (tx) =>
tx
.selectNoFrom((eb) => [
jsonArrayFrom(
eb
.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", "p.valid_to", "j.title"])
.where("p.org_unit_id", "=", orgUnitId)
.orderBy("p.position_number")
).as("stellen"),
jsonArrayFrom(
eb
.selectFrom("position_assignments as a")
.innerJoin("om_positions as p", "p.id", "a.position_id")
.innerJoin("employees as e", "e.id", "a.employee_id")
.select(["a.position_id", "e.first_name", "e.last_name"])
.where("p.org_unit_id", "=", orgUnitId)
.where((x) => x.or([x("a.valid_to", "is", null), x("a.valid_to", ">", heute)]))
).as("besetzungen"),
// Nicht kontierungenAbfrage(): die filtert über eine Liste von
// Planstellen, die hier erst aus der ersten Teilabfrage käme — und das
// wäre eine zweite Rundreise für dieselbe Auskunft.
jsonArrayFrom(
eb
.selectFrom("position_cost_centers as z")
.innerJoin("cost_centers as k", "k.id", "z.cost_center_id")
.innerJoin("om_positions as p", "p.id", "z.position_id")
.select(["z.position_id", "z.cost_center_id", "k.code", "k.name", "z.valid_from", "z.valid_to"])
.where("p.org_unit_id", "=", orgUnitId)
.orderBy("z.valid_from")
).as("kontierungen"),
jsonArrayFrom(kostenstellenAbfrage(eb, heute)).as("kostenstellen"),
])
.executeTakeFirstOrThrow()
);
const kontierung = kontierungZum(g.kontierungen as KontierungsZeile[], heute);
const inhaber = new Map(g.besetzungen.map((b) => [b.position_id, fmtName(b.first_name, b.last_name)]));
return {
planstellen: g.stellen.map((p) => ({
id: p.id,
position_number: p.position_number,
title: p.title,
org_unit_id: p.org_unit_id,
is_chief: p.is_chief,
valid_from: p.valid_from,
valid_to: p.valid_to,
kostenstelle: kontierung.get(p.id) ?? null,
besetztVon: inhaber.get(p.id) ?? null,
})),
kostenstellen: g.kostenstellen,
heute,
};
}