diff --git a/actions/positions.ts b/actions/positions.ts index a1f32a9..cd44d37 100644 --- a/actions/positions.ts +++ b/actions/positions.ts @@ -2,7 +2,12 @@ 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", "/"]; @@ -82,3 +87,88 @@ export async function setPositionCostCenter(payload: { }): 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, + }; +} diff --git a/components/orgchart/OrgUnitPanel.tsx b/components/orgchart/OrgUnitPanel.tsx index 544c1a9..8815352 100644 --- a/components/orgchart/OrgUnitPanel.tsx +++ b/components/orgchart/OrgUnitPanel.tsx @@ -2,6 +2,10 @@ import { Plus, Trash2 } from "lucide-react"; import Link from "next/link"; +import { useCallback, useEffect, useState } from "react"; +import { ladePlanstellenDerEinheit, type PlanstelleDerEinheit } from "@/actions/positions"; +import { EditPositionModal } from "@/components/positions/EditPositionModal"; +import type { UnitOption } from "@/components/positions/CreatePositionModal"; import { Button } from "@/components/ui/Button"; import { SlideOver } from "@/components/ui/SlideOver"; import { fmtDate, fmtName } from "@/lib/format"; @@ -26,6 +30,7 @@ function Zeile({ label, children }: { label: string; children: React.ReactNode } export function OrgUnitPanel({ unit, units, + unitOptions, employees, vacancies, onAddUnit, @@ -34,12 +39,44 @@ export function OrgUnitPanel({ }: { unit: OrgUnitNode; units: OrgUnitNode[]; + /** Für den Änderungsdialog: derselbe Baum, nur in seiner Form. */ + unitOptions: UnitOption[]; employees: OrgEmployee[]; vacancies: OrgVacancy[]; onAddUnit: (unitId: string) => void; onDeleteUnit: (unitId: string) => void; onClose: () => void; }) { + // Erst beim Öffnen geladen, und nur für diese eine Einheit — siehe den Grund + // bei ladePlanstellenDerEinheit. + const [stellen, setStellen] = useState(null); + const [kostenstellen, setKostenstellen] = useState<{ id: string; code: string; name: string }[]>([]); + const [heute, setHeute] = useState(""); + const [bearbeitet, setBearbeitet] = useState(null); + + const laden = useCallback(async () => { + const daten = await ladePlanstellenDerEinheit(unit.id); + setStellen(daten.planstellen); + setKostenstellen(daten.kostenstellen); + setHeute(daten.heute); + }, [unit.id]); + + useEffect(() => { + let verworfen = false; + // Der Nachtrag einer Antwort, die zu einer anderen Einheit gehört, wäre + // hier besonders tückisch: die Leiste sähe richtig aus und zeigte fremde + // Planstellen. + void ladePlanstellenDerEinheit(unit.id).then((daten) => { + if (verworfen) return; + setStellen(daten.planstellen); + setKostenstellen(daten.kostenstellen); + setHeute(daten.heute); + }); + return () => { + verworfen = true; + }; + }, [unit.id]); + const kinder = units.filter((u) => u.parent_id === unit.id); // Die Kette nach oben, mit Schutz gegen einen Kreis in parent_id. Der ist @@ -127,24 +164,45 @@ export function OrgUnitPanel({ )} - {offene.length > 0 && ( -
-

Unbesetzt

-
    - {offene.map((v) => ( -
  • - {v.position_number} · {v.job_title} - {v.is_chief && (Leitung)} +
    +

    + Planstellen{stellen ? ` (${stellen.length})` : ""} +

    + {stellen === null ? ( +

    Lädt…

    + ) : stellen.length === 0 ? ( +

    In dieser Einheit besteht keine Planstelle.

    + ) : ( +
      + {stellen.map((s) => ( +
    • + {/* Auch die besetzten sind anklickbar. Was sich daran ändern + lässt, entscheidet die Datenbankfunktion: Einheit und + Gültigkeitsende bleiben bei einer besetzten Stelle + gesperrt, Tätigkeit und Kontierung nicht. */} +
    • ))}
    - {/* Kontierung, Tätigkeit und Gültigkeit einer Planstelle stehen auf - „Positionen"; von hier führt wenigstens der Weg dorthin. */} - - Auf Positionen bearbeiten - -
    - )} + )} + + Alle unbesetzten Planstellen + +
+ + {/* Derselbe Dialog wie auf der Seite Positionen. Ein zweiter, der + dasselbe tut, liefe beim ersten Zusatz auseinander. */} + {bearbeitet && ( + { + setBearbeitet(null); + // Die Liste stammt aus dem Zustand dieser Leiste; ohne erneutes + // Laden stünde nach dem Umkontieren weiter die alte Kostenstelle + // da, und der Dialog daneben zeigte die neue. + void laden(); + }} + /> + )} ); } diff --git a/components/orgchart/PositionTree.tsx b/components/orgchart/PositionTree.tsx index bbbeee8..bd9cb8c 100644 --- a/components/orgchart/PositionTree.tsx +++ b/components/orgchart/PositionTree.tsx @@ -83,6 +83,35 @@ export function PositionTree({ employees, units, vacancies }: PositionTreeProps) const tree = useMemo(() => buildUnitTree(units, employees, vacancies), [units, employees, vacancies]); + // Der Baum in der Form, die der Änderungsdialog einer Planstelle erwartet. + // Aus denselben Daten gerechnet statt zusätzlich geladen: `hasChief` heisst + // dort „diese Einheit hat schon eine Leitungsplanstelle", und das ist genau + // die Leitung im Baum — besetzt oder als Vakanz. + const unitOptions = useMemo(() => { + const tiefe = new Map(); + const berechne = (u: OrgUnitNode, gesehen: Set): number => { + const bekannt = tiefe.get(u.id); + if (bekannt !== undefined) return bekannt; + if (!u.parent_id || gesehen.has(u.id)) return 0; + gesehen.add(u.id); + const eltern = units.find((e) => e.id === u.parent_id); + const d = eltern ? berechne(eltern, gesehen) + 1 : 0; + tiefe.set(u.id, d); + return d; + }; + const mitLeitung = new Set([ + ...employees.filter((e) => e.is_chief).map((e) => e.org_unit_id), + ...vacancies.filter((v) => v.is_chief).map((v) => v.org_unit_id), + ]); + return units.map((u) => ({ + id: u.id, + name: u.name, + unit_type: u.unit_type, + depth: berechne(u, new Set()), + hasChief: mitLeitung.has(u.id), + })); + }, [units, employees, vacancies]); + return (
@@ -127,6 +156,7 @@ export function PositionTree({ employees, units, vacancies }: PositionTreeProps) ; + /** * Wer eine unbesetzte Planstelle führen würde: die Leitung der eigenen * Einheit, für eine Leitungsplanstelle die der übergeordneten — dieselbe diff --git a/tests/components/PositionTree.test.tsx b/tests/components/PositionTree.test.tsx index 945228f..d909b3d 100644 --- a/tests/components/PositionTree.test.tsx +++ b/tests/components/PositionTree.test.tsx @@ -9,6 +9,15 @@ vi.mock("@/actions/org", () => ({ createOrgUnit: vi.fn(async () => ({ success: true })), deleteOrgUnit: vi.fn(async () => ({ success: true })), })); +// Die Angaben zu einer Einheit laden ihre Planstellen nach; das Aktionsmodul +// zieht über lib/db das Paket `server-only` nach und ist im Test ein +// Importfehler. Geprüft wird hier, was die Ansicht anbietet, nicht was die +// Datenbank liefert. +vi.mock("@/actions/positions", () => ({ + ladePlanstellenDerEinheit: vi.fn(async () => ({ planstellen: [], kostenstellen: [], heute: "2026-09-29" })), + updatePosition: vi.fn(async () => ({ success: true })), + setPositionCostCenter: vi.fn(async () => ({ success: true })), +})); vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) })); function render(ui: React.ReactElement) {