"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, revalidate: string[] ): Promise { 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 { 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. * * 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); } /** * 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 { 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, }; }