Two gaps in the positions view, both reported from use.
A position dated into the future was invisible. loadOpenPositions required
valid_from <= today, so a position decided now and effective at the quarter
boundary appeared nowhere until the day it began. The database already held
one — 60000824 "Neue Position", effective 01.09. — created through the
application and shown on no screen since.
Future positions now have their own section rather than joining the vacancy
list. They are a different statement: "nobody is here" and "this does not
exist yet" should not be counted together, and a position starting 01.10.
read as a vacancy nobody was filling.
Positions could only be created and deleted. Fixing a typo in the job title
meant deleting and recreating — with a new position number, which appears in
job postings, budgets and audit entries, and whose trail then breaks.
update_position keeps the number and records old and new values per field,
using the audit detail added earlier today.
Three things it refuses, as guards rather than remarks:
- Moving an occupied position to another unit. That is a transfer, with
history and reporting line, and belongs to the person — otherwise
someone changes department silently.
- Ending an occupied position, which would leave an assignment without
one.
- A second chief position in a unit, or an end before the start.
Verified against the live database, all rolled back: each guard fires with
its own message, the permitted edits go through, the audit entry carries the
changed fields. Open positions stay at 9 and the future one now appears in
its own section.
ESLint caught me priming the dialog's fields from an effect. Replaced by a
key on the component, so React rebuilds it per position and the fields
initialise from props — which also removes the flash of the previous
position's values on second open.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
151 lines
5.8 KiB
TypeScript
151 lines
5.8 KiB
TypeScript
import type { Tx } from "./db";
|
|
import { todayIso } from "./format";
|
|
import { breadcrumbLabel, loadOrgMaps, type OrgMaps } from "./org";
|
|
|
|
// Eine offene Stelle ist keine eigene Sache mehr. Sie ist eine Planstelle
|
|
// ohne laufende Besetzung — Vakanz ist eine Eigenschaft der Planstelle, kein
|
|
// zweites Objekt daneben, das mit der Organisation synchron gehalten werden
|
|
// müsste.
|
|
|
|
export type OpenPositionResolved = {
|
|
id: string;
|
|
position_number: string;
|
|
title: string;
|
|
org_unit_id: string;
|
|
is_chief: boolean;
|
|
valid_from: string;
|
|
valid_to: string | null;
|
|
/** Die Einheit selbst — für den Änderungsdialog, der sie vorbelegt. */
|
|
org_unit_name: string;
|
|
/**
|
|
* Ob die Planstelle heute schon gilt.
|
|
*
|
|
* Eine, die erst zum 01.10. entsteht, ist nicht „seit 2 Tagen unbesetzt" —
|
|
* sie ist geplant. Beides in einer Liste zu zeigen ist richtig, beides
|
|
* gleich zu benennen wäre falsch.
|
|
*/
|
|
future: boolean;
|
|
/** Wer die Stelle nach der Berichtslinie führen wird. */
|
|
managerName: string | null;
|
|
orgLabel: string;
|
|
/** Seit wann die Stelle unbesetzt ist: Ende der letzten Besetzung, sonst ihr Beginn. */
|
|
vacantSince: string;
|
|
};
|
|
|
|
/**
|
|
* Wer eine unbesetzte Planstelle führen würde: die Leitung der eigenen
|
|
* Einheit, für eine Leitungsplanstelle die der übergeordneten — dieselbe
|
|
* Regel wie in om_reporting_lines(), nur ohne Inhaber:in, für die sie gälte.
|
|
*/
|
|
function managerUnitFor(maps: OrgMaps, orgUnitId: string, isChief: boolean): string | null {
|
|
if (!isChief) return orgUnitId;
|
|
return maps.units.get(orgUnitId)?.parent_id ?? null;
|
|
}
|
|
|
|
export async function loadOpenPositions(tx: Tx): Promise<OpenPositionResolved[]> {
|
|
const asOf = todayIso();
|
|
|
|
const [orgMaps, open] = await Promise.all([
|
|
loadOrgMaps(tx),
|
|
// Unbesetzt heisst: keine Zuordnung, die noch gilt — **auch keine, die
|
|
// erst beginnt.**
|
|
//
|
|
// Der Unterschied ist kein Feinschliff. Wer unterschrieben hat und am
|
|
// 24.09. anfängt, belegt die Planstelle heute schon; sie steht nur noch
|
|
// nicht besetzt da. Die frühere Fassung fragte „sitzt heute jemand
|
|
// darauf?" und listete solche Stellen als offen — mit „seit 2 Tagen
|
|
// unbesetzt" daneben. Aus dieser Liste speist sich auch die Auswahl im
|
|
// Einstellungsassistenten, also lud sie dazu ein, dieselbe Stelle ein
|
|
// zweites Mal zu besetzen. Aufgefallen wäre das erst am Teilindex der
|
|
// Datenbank, nach dem Gespräch mit der zweiten Person.
|
|
//
|
|
// Eine beendete Zuordnung (valid_to in der Vergangenheit) gibt die Stelle
|
|
// dagegen wieder frei — deshalb bleibt die Bedingung auf valid_to.
|
|
//
|
|
// Als NOT EXISTS in der Datenbank statt als Filter über alle Planstellen
|
|
// im Speicher.
|
|
tx
|
|
.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"])
|
|
// Künftige Planstellen bleiben drin. Sie sind der Grund, warum diese
|
|
// Ansicht existiert: eine Stelle, die zum Quartalswechsel entsteht,
|
|
// muss vorher sichtbar und planbar sein. Vorher fielen sie durch das
|
|
// Raster und tauchten am Stichtag unangekündigt auf.
|
|
.where((eb) => eb.or([eb("p.valid_to", "is", null), eb("p.valid_to", ">", asOf)]))
|
|
.where((eb) =>
|
|
eb.not(
|
|
eb.exists(
|
|
eb
|
|
.selectFrom("position_assignments as a")
|
|
.select("a.id")
|
|
.whereRef("a.position_id", "=", "p.id")
|
|
.where((e2) => e2.or([e2("a.valid_to", "is", null), e2("a.valid_to", ">", asOf)]))
|
|
)
|
|
)
|
|
)
|
|
.orderBy("p.position_number")
|
|
.execute(),
|
|
]);
|
|
|
|
if (open.length === 0) return [];
|
|
|
|
const positionIds = open.map((p) => p.id);
|
|
|
|
// Zwei Nachschläge: seit wann die Stelle leer steht, und wer sie führen
|
|
// würde.
|
|
const [ended, chiefs] = await Promise.all([
|
|
tx
|
|
.selectFrom("position_assignments")
|
|
.select(["position_id", "valid_to"])
|
|
.where("position_id", "in", positionIds)
|
|
.where("valid_to", "is not", null)
|
|
.execute(),
|
|
(async () => {
|
|
const chiefUnitIds = Array.from(
|
|
new Set(
|
|
open
|
|
.map((p) => managerUnitFor(orgMaps, p.org_unit_id, p.is_chief))
|
|
.filter((id): id is string => Boolean(id))
|
|
)
|
|
);
|
|
if (chiefUnitIds.length === 0) return [];
|
|
return tx
|
|
.selectFrom("om_positions as p")
|
|
.innerJoin("position_assignments as a", "a.position_id", "p.id")
|
|
.innerJoin("employees as e", "e.id", "a.employee_id")
|
|
.select(["p.org_unit_id", "e.first_name", "e.last_name"])
|
|
.where("p.is_chief", "=", true)
|
|
.where("p.valid_to", "is", null)
|
|
.where("a.valid_to", "is", null)
|
|
.where("p.org_unit_id", "in", chiefUnitIds)
|
|
.execute();
|
|
})(),
|
|
]);
|
|
|
|
const lastEndByPosition = new Map<string, string>();
|
|
for (const e of ended) {
|
|
const prev = lastEndByPosition.get(e.position_id);
|
|
if (e.valid_to && (!prev || e.valid_to > prev)) lastEndByPosition.set(e.position_id, e.valid_to);
|
|
}
|
|
const chiefNameByUnit = new Map(chiefs.map((c) => [c.org_unit_id, `${c.first_name} ${c.last_name}`]));
|
|
|
|
return open.map((p) => {
|
|
const managerUnit = managerUnitFor(orgMaps, p.org_unit_id, p.is_chief);
|
|
return {
|
|
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,
|
|
org_unit_name: orgMaps.units.get(p.org_unit_id)?.name ?? "",
|
|
future: p.valid_from > asOf,
|
|
managerName: managerUnit ? (chiefNameByUnit.get(managerUnit) ?? null) : null,
|
|
orgLabel: breadcrumbLabel(orgMaps, p.org_unit_id),
|
|
vacantSince: lastEndByPosition.get(p.id) ?? p.valid_from,
|
|
};
|
|
});
|
|
}
|