Kontierung auch bei besetzten Planstellen -- aus dem Organigramm heraus
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m47s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m6s

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.
This commit is contained in:
2026-09-28 21:52:01 +02:00
parent ab50c21b8a
commit 2eda038c2e
6 changed files with 237 additions and 17 deletions

View File

@@ -2,7 +2,12 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { currentUserId } from "@/lib/auth/session"; 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 { runMutation, type ActionResult } from "@/lib/db/rpc";
import { fmtName, todayIso } from "@/lib/format";
import type { PlanstelleZumAendern } from "@/lib/positions";
const POSITION_PATHS = ["/positions", "/orgchart", "/"]; const POSITION_PATHS = ["/positions", "/orgchart", "/"];
@@ -82,3 +87,88 @@ export async function setPositionCostCenter(payload: {
}): Promise<ActionResult> { }): Promise<ActionResult> {
return callRpc("set_position_cost_center", payload, POSITION_PATHS); 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,
};
}

View File

@@ -2,6 +2,10 @@
import { Plus, Trash2 } from "lucide-react"; import { Plus, Trash2 } from "lucide-react";
import Link from "next/link"; 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 { Button } from "@/components/ui/Button";
import { SlideOver } from "@/components/ui/SlideOver"; import { SlideOver } from "@/components/ui/SlideOver";
import { fmtDate, fmtName } from "@/lib/format"; import { fmtDate, fmtName } from "@/lib/format";
@@ -26,6 +30,7 @@ function Zeile({ label, children }: { label: string; children: React.ReactNode }
export function OrgUnitPanel({ export function OrgUnitPanel({
unit, unit,
units, units,
unitOptions,
employees, employees,
vacancies, vacancies,
onAddUnit, onAddUnit,
@@ -34,12 +39,44 @@ export function OrgUnitPanel({
}: { }: {
unit: OrgUnitNode; unit: OrgUnitNode;
units: OrgUnitNode[]; units: OrgUnitNode[];
/** Für den Änderungsdialog: derselbe Baum, nur in seiner Form. */
unitOptions: UnitOption[];
employees: OrgEmployee[]; employees: OrgEmployee[];
vacancies: OrgVacancy[]; vacancies: OrgVacancy[];
onAddUnit: (unitId: string) => void; onAddUnit: (unitId: string) => void;
onDeleteUnit: (unitId: string) => void; onDeleteUnit: (unitId: string) => void;
onClose: () => void; onClose: () => void;
}) { }) {
// Erst beim Öffnen geladen, und nur für diese eine Einheit — siehe den Grund
// bei ladePlanstellenDerEinheit.
const [stellen, setStellen] = useState<PlanstelleDerEinheit[] | null>(null);
const [kostenstellen, setKostenstellen] = useState<{ id: string; code: string; name: string }[]>([]);
const [heute, setHeute] = useState("");
const [bearbeitet, setBearbeitet] = useState<PlanstelleDerEinheit | null>(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); const kinder = units.filter((u) => u.parent_id === unit.id);
// Die Kette nach oben, mit Schutz gegen einen Kreis in parent_id. Der ist // Die Kette nach oben, mit Schutz gegen einen Kreis in parent_id. Der ist
@@ -127,24 +164,45 @@ export function OrgUnitPanel({
</div> </div>
)} )}
{offene.length > 0 && ( <div>
<div> <h3 className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-ink-muted">
<h3 className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-ink-muted">Unbesetzt</h3> Planstellen{stellen ? ` (${stellen.length})` : ""}
<ul className="flex flex-col gap-1 text-sm text-ink-body"> </h3>
{offene.map((v) => ( {stellen === null ? (
<li key={v.position_id}> <p className="text-sm text-ink-muted">Lädt…</p>
<span className="tabular-nums text-ink-muted">{v.position_number}</span> · {v.job_title} ) : stellen.length === 0 ? (
{v.is_chief && <span className="text-xs text-ink-muted"> (Leitung)</span>} <p className="text-sm text-ink-muted">In dieser Einheit besteht keine Planstelle.</p>
) : (
<ul className="flex flex-col divide-y divide-border-subtle">
{stellen.map((s) => (
<li key={s.id}>
{/* 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. */}
<button
type="button"
onClick={() => setBearbeitet(s)}
className="flex w-full flex-col items-start rounded py-1.5 text-left hover:bg-surface
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
>
<span className="text-sm text-ink-body">
<span className="tabular-nums text-ink-muted">{s.position_number}</span> · {s.title}
{s.is_chief && <span className="text-xs text-ink-muted"> (Leitung)</span>}
</span>
<span className="text-xs text-ink-muted">
{s.besetztVon ?? <span className="font-semibold text-warning-text">unbesetzt</span>}
{s.kostenstelle ? ` · Kostenstelle ${s.kostenstelle.code}` : " · ohne Kostenstelle"}
</span>
</button>
</li> </li>
))} ))}
</ul> </ul>
{/* Kontierung, Tätigkeit und Gültigkeit einer Planstelle stehen auf )}
„Positionen"; von hier führt wenigstens der Weg dorthin. */} <Link href="/positions" className="mt-1.5 inline-block text-xs font-semibold text-brand-700 hover:underline">
<Link href="/positions" className="mt-1.5 inline-block text-xs font-semibold text-brand-700 hover:underline"> Alle unbesetzten Planstellen
Auf Positionen bearbeiten </Link>
</Link> </div>
</div>
)}
<div className="flex flex-wrap gap-2 border-t border-border pt-4"> <div className="flex flex-wrap gap-2 border-t border-border pt-4">
<Button variant="secondary" size="sm" onClick={() => onAddUnit(unit.id)}> <Button variant="secondary" size="sm" onClick={() => onAddUnit(unit.id)}>
@@ -159,6 +217,25 @@ export function OrgUnitPanel({
)} )}
</div> </div>
</div> </div>
{/* Derselbe Dialog wie auf der Seite Positionen. Ein zweiter, der
dasselbe tut, liefe beim ersten Zusatz auseinander. */}
{bearbeitet && (
<EditPositionModal
key={bearbeitet.id}
position={bearbeitet}
units={unitOptions}
kostenstellen={kostenstellen}
heute={heute}
onClose={() => {
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();
}}
/>
)}
</SlideOver> </SlideOver>
); );
} }

View File

@@ -83,6 +83,35 @@ export function PositionTree({ employees, units, vacancies }: PositionTreeProps)
const tree = useMemo(() => buildUnitTree(units, employees, vacancies), [units, employees, vacancies]); 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<string, number>();
const berechne = (u: OrgUnitNode, gesehen: Set<string>): 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 ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex justify-end"> <div className="flex justify-end">
@@ -127,6 +156,7 @@ export function PositionTree({ employees, units, vacancies }: PositionTreeProps)
<OrgUnitPanel <OrgUnitPanel
unit={einheit} unit={einheit}
units={units} units={units}
unitOptions={unitOptions}
employees={employees} employees={employees}
vacancies={vacancies} vacancies={vacancies}
onAddUnit={onAddUnit} onAddUnit={onAddUnit}

View File

@@ -7,7 +7,7 @@ import { Button } from "@/components/ui/Button";
import { SelectField, TextField } from "@/components/ui/Field"; import { SelectField, TextField } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal"; import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast"; import { useToast } from "@/components/ui/Toast";
import type { OpenPositionResolved } from "@/lib/positions"; import type { PlanstelleZumAendern } from "@/lib/positions";
import type { UnitOption } from "./CreatePositionModal"; import type { UnitOption } from "./CreatePositionModal";
// Ändern statt löschen und neu anlegen. // Ändern statt löschen und neu anlegen.
@@ -33,7 +33,7 @@ export function EditPositionModal({
heute, heute,
onClose, onClose,
}: { }: {
position: OpenPositionResolved; position: PlanstelleZumAendern;
units: UnitOption[]; units: UnitOption[];
kostenstellen: { id: string; code: string; name: string }[]; kostenstellen: { id: string; code: string; name: string }[];
/** Vom Server, nicht aus new Date(): sonst rechnet der Browser mit seiner Zone. */ /** Vom Server, nicht aus new Date(): sonst rechnet der Browser mit seiner Zone. */

View File

@@ -42,6 +42,20 @@ export type OpenPositionResolved = {
kostenstelle: { code: string; name: string } | null; kostenstelle: { code: string; name: string } | null;
}; };
/**
* Was der Änderungsdialog von einer Planstelle braucht — und nicht mehr.
*
* Er verlangte bisher eine `OpenPositionResolved`, also die *unbesetzte*
* Planstelle samt „seit wann offen" und „wer würde sie führen". Damit liess er
* sich nur dort öffnen, wo diese Angaben entstehen: auf der Seite Positionen,
* die ausschliesslich unbesetzte Stellen zeigt. Die Kontierung einer besetzten
* Planstelle war deshalb nirgends änderbar — bei Manner betraf das 784 von 788.
*/
export type PlanstelleZumAendern = Pick<
OpenPositionResolved,
"id" | "position_number" | "title" | "org_unit_id" | "is_chief" | "valid_from" | "valid_to" | "kostenstelle"
>;
/** /**
* Wer eine unbesetzte Planstelle führen würde: die Leitung der eigenen * Wer eine unbesetzte Planstelle führen würde: die Leitung der eigenen
* Einheit, für eine Leitungsplanstelle die der übergeordneten — dieselbe * Einheit, für eine Leitungsplanstelle die der übergeordneten — dieselbe

View File

@@ -9,6 +9,15 @@ vi.mock("@/actions/org", () => ({
createOrgUnit: vi.fn(async () => ({ success: true })), createOrgUnit: vi.fn(async () => ({ success: true })),
deleteOrgUnit: 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() }) })); vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) }));
function render(ui: React.ReactElement) { function render(ui: React.ReactElement) {