Compare commits

1 Commits

Author SHA1 Message Date
4c5bfc223f README.md aktualisiert
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m5s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m21s
2026-08-03 07:52:05 +00:00
121 changed files with 3114 additions and 10653 deletions

View File

@@ -1,42 +1,12 @@
# Direkter PostgreSQL-Zugang. Die Anwendung spricht künftig unmittelbar mit
# der Datenbank statt über eine API-Schicht — damit läuft sie auf jedem
# PostgreSQL ab 15 (Azure Flexible Server, RDS, Cloud SQL, eigenes Blech).
#
# Die Rolle in diesem String darf KEIN BYPASSRLS haben: fehlt der
# Sitzungskontext, sollen die Policies nichts zurückgeben statt alles.
#
# Hinter einem Verbindungspooler (Supabase Supavisor, PgBouncer) den
# TRANSAKTIONS-Modus nehmen, nicht den Sitzungs-Modus — bei Supabase Port
# 6543 statt 5432. Jede Abfrage dieser Anwendung läuft ohnehin in einer
# Transaktion, und der Sitzungskontext wird transaktionslokal gesetzt; beides
# passt genau dazu. Der Sitzungs-Modus belegt dagegen je Client eine feste
# Verbindung und ist bei Supabase auf 15 begrenzt — danach antwortet die
# Anwendung nur noch mit „max clients reached".
DATABASE_URL=
# Auf "false" setzen, wenn die Datenbank ohne TLS läuft (lokal, CI).
DATABASE_SSL=
# Verbindungen im Pool; Vorgabe 10.
DATABASE_POOL_MAX=
# Public: safe to expose to the browser (inlined into the client bundle at
# build time). Anon-key access is still fully gated by RLS server-side.
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
# ── Anmeldung (Auth.js + Microsoft Entra ID) ─────────────────────────
# Schlüssel, mit dem das Sitzungscookie signiert und verschlüsselt wird.
# Erzeugen mit `npx auth secret` oder `openssl rand -base64 32`. Ein Wechsel
# meldet alle ab — was im Ernstfall genau das gewünschte Mittel ist.
AUTH_SECRET=
# Aus der Anwendungsregistrierung im Entra-Portal: Anwendungs-ID (Client),
# ein Geheimnis daraus, und der Aussteller mit der Verzeichnis-ID (Mandant).
#
# Der Aussteller darf NICHT auf /common/ stehen bleiben — sonst könnte sich
# jedes Microsoft-Konto anmelden, auch ein privates.
AUTH_MICROSOFT_ENTRA_ID_ID=
AUTH_MICROSOFT_ENTRA_ID_SECRET=
AUTH_MICROSOFT_ENTRA_ID_ISSUER=https://login.microsoftonline.com/<verzeichnis-id>/v2.0
# Nur nötig, wenn die Anwendung hinter einem Reverse Proxy unter einer
# anderen Adresse erreichbar ist, als sie selbst sieht. Ohne diesen Wert baut
# Auth.js die Rückruf-Adresse aus den Request-Headern.
AUTH_URL=
# Server-only: bypasses Row Level Security entirely. Never prefix with
# NEXT_PUBLIC_, never import outside lib/supabase/admin.ts (guarded by
# `import "server-only"`), never log or return in an API response.
SUPABASE_SERVICE_ROLE_KEY=
# Shared secret Vercel Cron sends as `Authorization: Bearer <value>` when it
# calls /api/cron/apply-pending-changes (set the same value in the Vercel

View File

@@ -68,23 +68,16 @@ jobs:
- name: Supabase starten
run: supabase start
# `-o env` emits API_URL / ANON_KEY / SERVICE_ROLE_KEY / DB_URL; the app
# expects them under its own names. DATABASE_URL ist der direkte
# Postgres-Zugang — den braucht die neue Zugriffsschicht (lib/db) und
# vor allem der Nachweis zum Sitzungskontext.
# `-o env` emits API_URL / ANON_KEY / SERVICE_ROLE_KEY; the app expects
# them under its own names.
- name: Testumgebung schreiben
run: |
supabase status -o env \
--override-name api.url=NEXT_PUBLIC_SUPABASE_URL \
--override-name auth.anon_key=NEXT_PUBLIC_SUPABASE_ANON_KEY \
--override-name auth.service_role_key=SUPABASE_SERVICE_ROLE_KEY \
--override-name db.url=DATABASE_URL \
| grep -E '^(NEXT_PUBLIC_SUPABASE_URL|NEXT_PUBLIC_SUPABASE_ANON_KEY|SUPABASE_SERVICE_ROLE_KEY|DATABASE_URL)=' \
| grep -E '^(NEXT_PUBLIC_SUPABASE_URL|NEXT_PUBLIC_SUPABASE_ANON_KEY|SUPABASE_SERVICE_ROLE_KEY)=' \
| tr -d '"' > .env.test.local
# Lokal läuft Postgres ohne TLS; ohne das versucht `pg` es trotzdem.
echo "DATABASE_SSL=false" >> .env.test.local
grep -q '^DATABASE_URL=' .env.test.local \
|| { echo "DATABASE_URL wurde nicht geschrieben — der Sitzungskontext-Nachweis liefe ins Leere."; exit 1; }
- name: Seed
run: node --env-file=.env.test.local supabase/seed.ts

3
.gitignore vendored
View File

@@ -54,6 +54,3 @@ next-env.d.ts
# seeded DB — can carry HR data or use SUPABASE_SERVICE_ROLE_KEY; never commit)
.scratch_*
/.scratch_shots/
# Datenbank-Sicherungen (enthalten Personendaten) nie committen.
.backups/

View File

@@ -37,22 +37,21 @@ Werte eintragen:
| Variable | Woher |
|---|---|
| `DATABASE_URL` | Verbindungsstring der PostgreSQL-Instanz. Die Rolle darf **kein** `BYPASSRLS` haben; hinter einem Pooler den **Transaktions-Modus** (bei Supabase Port 6543) |
| `DATABASE_SSL` | nur setzen (`false`), wenn die Datenbank ohne TLS läuft |
| `AUTH_SECRET` | selbst generieren: `openssl rand -base64 32` |
| `AUTH_MICROSOFT_ENTRA_ID_ID` | Entra-Portal → App-Registrierung → Übersicht |
| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | Entra-Portal → Zertifikate & Geheimnisse (nur einmal sichtbar!) |
| `AUTH_MICROSOFT_ENTRA_ID_ISSUER` | `https://login.microsoftonline.com/<verzeichnis-id>/v2.0` |
| `NEXT_PUBLIC_SUPABASE_URL` | Supabase-Projekt → Settings → API |
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase-Projekt → Settings → API |
| `SUPABASE_SERVICE_ROLE_KEY` | Supabase-Projekt → Settings → API (geheim!) |
| `CRON_SECRET` | selbst generieren: `openssl rand -hex 32` |
Details zur Entra-Registrierung: [`docs/entra-sso.md`](docs/entra-sso.md).
Wichtig zum Verständnis:
- **Nichts davon wird in das Image eingebacken.** Es gibt keine
`NEXT_PUBLIC_*`-Variablen mehr; alle Werte liest die Anwendung zur Laufzeit
über `env_file`. Eine Änderung braucht deshalb nur einen Neustart, keinen
neuen Build — und dasselbe Image läuft in Test und Produktion.
- `NEXT_PUBLIC_*`-Variablen werden **beim Build** in das Browser-Bundle
eingebacken (Next.js-Verhalten, nicht Docker-spezifisch). Ändern sich
diese Werte, muss das Image **neu gebaut** werden ein reiner Container-
Neustart reicht nicht.
- `SUPABASE_SERVICE_ROLE_KEY` und `CRON_SECRET` sind Server-only-Secrets.
Sie werden bewusst **nicht** als Build-Arg übergeben (das würde sie im
Image-Layer-History sichtbar machen), sondern erst zur Laufzeit über
`env_file` injiziert.
- `.env` steht schon in `.gitignore` nicht committen.
## 2. Bauen und lokal testen
@@ -146,22 +145,12 @@ Single-Instance-Compose-Konfiguration ist das nicht nötig.
## Troubleshooting
- **Anmeldung endet auf `/login?error=…`:** die Umleitungs-URI in der
Entra-Registrierung muss exakt
`https://<host>/api/auth/callback/microsoft-entra-id` lauten. Steht die
Anwendung hinter einem Reverse Proxy unter einer anderen Adresse, als sie
selbst sieht, zusätzlich `AUTH_URL` setzen.
- **Angemeldet, aber sofort zurück auf `/login?error=no_hr_access`:** die
Anmeldung hat funktioniert, es fehlt die Freischaltung. Es braucht eine
`profiles`-Zeile mit `role = 'hr'` und `is_active = true` auf derselben
Kennung, die in `app_users` steht.
- **Login-Redirect-Loop / `proxy.ts` verhält sich falsch:** meist falsche
`NEXT_PUBLIC_SUPABASE_URL`/`ANON_KEY` Image neu bauen (siehe oben, diese
Werte sind eingebacken).
- **Cron läuft nicht:** `docker compose logs cron` prüft, ob
`/etc/crontabs/root` korrekt geschrieben wurde und ob `CRON_SECRET` in
`.env` gesetzt ist (leer/fehlend führt serverseitig zu `401`).
- **`max clients reached in session mode`:** der Verbindungsstring zeigt auf
den Sitzungs-Modus des Poolers. Auf den Transaktions-Modus wechseln (bei
Supabase Port 6543).
- **Healthcheck rot:** `docker compose logs app` meist `DATABASE_URL`
fehlend oder nicht erreichbar. Der Pool baut die Verbindung erst beim
ersten Zugriff auf, der Fehler steht deshalb im Log der Anfrage, nicht im
Start-Log.
- **Healthcheck rot:** `docker compose logs app` meist fehlende/falsche
Supabase-Env-Variablen zur Laufzeit (`SUPABASE_SERVICE_ROLE_KEY`,
Server-Komponenten).

View File

@@ -12,13 +12,13 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Keine Build-Argumente mehr: es gibt keine NEXT_PUBLIC_*-Werte mehr, die in
# das Browser-Bundle eingebacken würden. Datenbank und Anmeldung sprechen
# ausschliesslich den Server an, und dessen Zugangsdaten kommen zur Laufzeit.
#
# Dadurch ist dieses Abbild umgebungsneutral: einmal gebaut, in Test und
# Produktion dasselbe. Vorher hätte jede Umgebung ihr eigenes gebraucht — und
# eine Baustrecke, die die Zugangsdaten schon zum Bauen kennt.
# Public env vars are inlined into the client bundle at build time, so they
# must be available here, not just at runtime. Values are passed in via
# --build-arg (see DEPLOYMENT.md).
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

View File

@@ -1,4 +1,4 @@
# Manner HR Master
# Alpenwerk HR Master
Interne HR-Stammdatenverwaltung: Mitarbeiter:innen, Organisationsstruktur
(Bereich/Abteilung/Team), Planstellen, Neueinstellungen, Versetzungen/
@@ -54,18 +54,14 @@ Liste. Kurzfassung:
| Variable | Sichtbarkeit | Zweck |
|---|---|---|
| `DATABASE_URL` | Nur Server | PostgreSQL-Verbindung. Die Rolle darf **kein** `BYPASSRLS` haben |
| `DATABASE_SSL` | Nur Server | `false` für lokal/CI ohne TLS |
| `AUTH_SECRET` | Nur Server | Signiert und verschlüsselt das Sitzungscookie |
| `AUTH_MICROSOFT_ENTRA_ID_ID` | Nur Server | Anwendungs-ID der Entra-Registrierung |
| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | Nur Server | Client-Geheimnis dazu |
| `AUTH_MICROSOFT_ENTRA_ID_ISSUER` | Nur Server | Aussteller mit Mandanten-ID — nicht `common` |
| `NEXT_PUBLIC_SUPABASE_URL` | Browser + Server | Supabase-Projekt-URL |
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Browser + Server | Anon-Key, RLS-gebunden |
| `SUPABASE_SERVICE_ROLE_KEY` | **Nur Server** | Umgeht RLS vollständig — niemals im Browser-Bundle, niemals loggen |
| `CRON_SECRET` | Nur Server | Schützt `/api/cron/apply-pending-changes` |
**Es gibt keine `NEXT_PUBLIC_*`-Variablen mehr.** Nichts wird in das
Browser-Bundle eingebacken, weil der Browser mit nichts ausser der Anwendung
selbst spricht. Ein Docker-Abbild ist damit umgebungsneutral: einmal gebaut,
überall dasselbe — vorher brauchte jede Umgebung ihr eigenes.
`NEXT_PUBLIC_*`-Werte werden beim Build in das Client-Bundle eingebacken —
eine Änderung erfordert einen Rebuild, nicht nur einen Neustart (relevant
für Docker-Deployments, siehe unten).
## Scripts
@@ -89,15 +85,10 @@ selbst spricht. Ein Docker-Abbild ist damit umgebungsneutral: einmal gebaut,
- **Ein Rollenmodell:** `profiles.role = 'hr'` + `profiles.is_active = true`,
geprüft über die SQL-Funktion `is_hr_user()`. Kein Sub-Rollensystem —
siehe [`docs/data-model.md`](docs/data-model.md#zugriffsmodell).
- **Es gibt keinen privilegierten Zugang mehr.** Der Dienstschlüssel, der RLS
aushebelte, ist ersatzlos entfallen; auch der nächtliche Lauf benutzt
dieselbe Rolle ohne `BYPASSRLS`. Was ohne angemeldete Person laufen muss,
steht als `SECURITY DEFINER`-Funktion in der Datenbank und prüft dort
selbst, was es tut.
- **Jede Abfrage läuft in einer Transaktion mit gesetztem Sitzungskontext.**
Die Kysely-Instanz wird nicht exportiert — der einzige Weg an die Datenbank
ist `withUser()` (`lib/db/index.ts`), und eine ESLint-Regel verbietet den
Import von `pg` ausserhalb von `lib/db/`.
- **Service-Role-Key ist server-only.** Einzige Verwendung:
`lib/supabase/admin.ts`, geschützt durch `import "server-only"` (macht
einen versehentlichen Client-Import zu einem Build-Fehler statt einem
Laufzeitproblem).
- **Audit-Log ist transaktional in der Datenbank**, nicht im App-Code: jede
mutierende SQL-Funktion schreibt ihren `audit_log`-Eintrag in derselben
Transaktion wie die Änderung selbst. Details und Prüfung siehe

View File

@@ -1,22 +1,24 @@
"use server";
import { signIn, signOut } from "@/auth";
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
// Anmeldung ausschliesslich über Entra ID. Es gibt bewusst keinen
// Passwort-Pfad: ein zweiter Anmeldeweg neben dem Firmenkonto hebelt jede
// Vorgabe des Mandanten aus — Mehrfaktor, bedingten Zugriff, Sperrung beim
// Austritt.
//
// Die Herkunft muss hier nicht mehr aus dem Request geholt werden: Auth.js
// baut die Rückruf-Adresse selbst und akzeptiert nur Ziele auf demselben
// Host. Ein untergeschobener Host läuft also weiterhin ins Leere.
export async function login(formData: FormData) {
const email = String(formData.get("email") ?? "");
const password = String(formData.get("password") ?? "");
export async function signInWithEntra() {
// Kehrt nicht zurück: signIn löst eine Weiterleitung aus, und die wirft in
// Next.js.
await signIn("microsoft-entra-id", { redirectTo: "/" });
const supabase = await createClient();
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) {
redirect("/login?error=invalid_credentials");
}
redirect("/");
}
export async function logout() {
await signOut({ redirectTo: "/login" });
const supabase = await createClient();
await supabase.auth.signOut();
redirect("/login");
}

View File

@@ -1,16 +1,19 @@
"use server";
import { revalidatePath } from "next/cache";
import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
import { callFunction, runMutation, type ActionResult, type MutationFn } from "@/lib/db/rpc";
import type { CollectiveAgreement, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types";
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
import { createClient } from "@/lib/supabase/server";
import type { CollectiveAgreement, Database, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types";
type ActionResult = { success: boolean; error?: string };
type MutationFn = keyof Database["public"]["Functions"];
async function callRpc(fn: MutationFn, payload: Record<string, unknown>, revalidate: string[]): Promise<ActionResult> {
const result = await runMutation(await currentUserId(), fn, payload);
if (!result.success) return result;
const supabase = await createClient();
const { error } = await supabase.rpc(fn, { payload });
if (error) return { success: false, error: error.message };
for (const path of revalidate) revalidatePath(path);
return result;
return { success: true };
}
export async function hireEmployee(payload: {
@@ -21,15 +24,6 @@ export async function hireEmployee(payload: {
gender: "m" | "w";
birth_date: string;
sv_nummer?: string;
/**
* Pflicht, weil employees.email NOT NULL ist.
*
* Der Assistent hat die Adresse immer erhoben und in der Zusammenfassung
* angezeigt — sie fehlte nur in dieser Signatur und wurde deshalb
* stillschweigend verworfen. Jede Einstellung scheiterte danach an der
* Spaltenbedingung.
*/
email: string;
phone?: string;
position_id?: string;
team_id?: string;
@@ -50,19 +44,13 @@ export async function hireEmployee(payload: {
is_laterale_fuehrung?: boolean;
is_c_level?: boolean;
}): Promise<ActionResult & { employeeId?: string }> {
// Einzige Mutation, deren Rückgabewert gebraucht wird: die neue
// Personen-Kennung, damit die Oberfläche direkt auf die Akte springen kann.
try {
const employeeId = await withUser(await currentUserId(), (tx) =>
callFunction(tx, "hire_employee", payload as Record<string, unknown>)
);
const supabase = await createClient();
const { data, error } = await supabase.rpc("hire_employee", { payload });
if (error) return { success: false, error: error.message };
revalidatePath("/employees");
revalidatePath("/");
revalidatePath("/positions");
return { success: true, employeeId: employeeId as string };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
}
return { success: true, employeeId: data as string };
}
export async function terminateEmployee(payload: {
@@ -77,8 +65,8 @@ export async function terminateEmployee(payload: {
export async function transferEmployee(payload: {
employee_id: string;
effective_date: string;
/** Die Zielplanstelle; Bereich, Abteilung und Team ergeben sich aus ihrer Einheit. */
target_position_id: string;
new_team_id: string;
new_title?: string;
}): Promise<ActionResult> {
return callRpc("transfer_employee", payload, [`/employees/${payload.employee_id}`, "/employees"]);
}
@@ -165,3 +153,23 @@ export async function addEmployeeNote(payload: {
export async function completeEmployeeNote(payload: { note_id: string; employee_id: string }): Promise<ActionResult> {
return callRpc("complete_employee_note", payload, [`/employees/${payload.employee_id}`, "/"]);
}
export type EmployeeSearchResult = { id: string; first_name: string; last_name: string; job_title: string; team_id: string | null };
// Shared by "Position besetzen" (staff an open position) and the reorg
// workbench's "Mitarbeiter:in(nen)" multi-select — both search active/
// on-leave employees by name or title.
export async function searchActiveEmployees(query: string): Promise<EmployeeSearchResult[]> {
const supabase = await createClient();
let q = supabase
.from("employees")
.select("id, first_name, last_name, job_title, team_id")
.in("status", ["Aktiv", "Karenz"])
.limit(20);
if (query.trim()) {
const term = sanitizeIlikeTerm(query.trim());
q = q.or(`first_name.ilike.%${term}%,last_name.ilike.%${term}%,job_title.ilike.%${term}%`);
}
const { data } = await q;
return data ?? [];
}

View File

@@ -1,52 +1,45 @@
"use server";
import { revalidatePath } from "next/cache";
import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
import type { ActionResult } from "@/lib/db/rpc";
import { createClient } from "@/lib/supabase/server";
type ActionResult = { success: boolean; error?: string };
export async function saveHireDraft(payload: {
id?: string;
step: number;
data: Record<string, unknown>;
}): Promise<ActionResult & { id?: string }> {
const userId = await currentUserId();
if (!userId) return { success: false, error: "Nicht angemeldet." };
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return { success: false, error: "Nicht angemeldet." };
try {
const id = await withUser(userId, async (tx) => {
if (payload.id) {
// Ob die Zeile der aufrufenden Person gehört, entscheidet die
// Policy hire_drafts_owner — nicht eine Prüfung hier.
await tx
.updateTable("hire_drafts")
.set({ step: payload.step, payload: payload.data, updated_at: new Date().toISOString() })
.where("id", "=", payload.id)
.execute();
return payload.id;
}
const row = await tx
.insertInto("hire_drafts")
.values({ created_by: userId, step: payload.step, payload: payload.data })
.returning("id")
.executeTakeFirstOrThrow();
return row.id;
});
const { error } = await supabase
.from("hire_drafts")
.update({ step: payload.step, payload: payload.data, updated_at: new Date().toISOString() })
.eq("id", payload.id);
if (error) return { success: false, error: error.message };
revalidatePath("/");
return { success: true, id };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
return { success: true, id: payload.id };
}
const { data, error } = await supabase
.from("hire_drafts")
.insert({ created_by: user.id, step: payload.step, payload: payload.data })
.select("id")
.single();
if (error) return { success: false, error: error.message };
revalidatePath("/");
return { success: true, id: data.id };
}
export async function deleteHireDraft(id: string): Promise<ActionResult> {
try {
await withUser(await currentUserId(), (tx) => tx.deleteFrom("hire_drafts").where("id", "=", id).execute());
const supabase = await createClient();
const { error } = await supabase.from("hire_drafts").delete().eq("id", id);
if (error) return { success: false, error: error.message };
revalidatePath("/");
return { success: true };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
}
}

View File

@@ -1,51 +1,56 @@
"use server";
import { revalidatePath } from "next/cache";
import { currentUserId } from "@/lib/auth/session";
import { runMutation, type ActionResult } from "@/lib/db/rpc";
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
import { createClient } from "@/lib/supabase/server";
type ActionResult = { success: boolean; error?: string };
const POSITION_PATHS = ["/positions", "/orgchart", "/"];
async function callRpc(
fn: "create_position" | "update_position" | "delete_position",
fn: "create_position" | "delete_position",
payload: Record<string, unknown>,
revalidate: string[]
): Promise<ActionResult> {
const result = await runMutation(await currentUserId(), fn, payload);
if (!result.success) return result;
const supabase = await createClient();
const { error } = await supabase.rpc(fn, { payload });
if (error) return { success: false, error: error.message };
for (const path of revalidate) revalidatePath(path);
return result;
return { success: true };
}
export async function createPosition(payload: {
org_unit_id: string;
job_title: string;
is_chief: boolean;
title: string;
superior_employee_id: string;
is_lead: boolean;
team_id?: string;
valid_from: string;
}): Promise<ActionResult> {
return callRpc("create_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);
}
export type SuperiorSearchResult = { id: string; first_name: string; last_name: string; job_title: string; division_id: string };
// For "Position ausschreiben": superior lookup, filtered to team-leads when
// the new position is an IC role, or to division-heads/CEO when the new
// position is itself a team lead (§2).
export async function searchSuperiors(query: string, forLeadPosition: boolean): Promise<SuperiorSearchResult[]> {
const supabase = await createClient();
let q = supabase
.from("employees")
.select("id, first_name, last_name, job_title, division_id")
.eq("status", "Aktiv")
.limit(20);
q = forLeadPosition ? q.lte("org_level", 1) : q.eq("is_lead", true).eq("org_level", 2);
if (query.trim()) {
const term = sanitizeIlikeTerm(query.trim());
q = q.or(`first_name.ilike.%${term}%,last_name.ilike.%${term}%,job_title.ilike.%${term}%`);
}
const { data } = await q;
return data ?? [];
}

37
actions/reorg.ts Normal file
View File

@@ -0,0 +1,37 @@
"use server";
import { revalidatePath } from "next/cache";
import { createClient } from "@/lib/supabase/server";
type ActionResult = { success: boolean; error?: string };
export type ReorgMovePayload = {
kind: "emp" | "team" | "abt" | "dept";
label: string;
employee_ids: string[];
target_team_id: string;
};
export async function applyReorg(payload: {
name: string;
effective_date: string;
moves: ReorgMovePayload[];
}): Promise<ActionResult & { scenarioId?: string }> {
const supabase = await createClient();
const { data, error } = await supabase.rpc("apply_reorg", { payload });
if (error) return { success: false, error: error.message };
revalidatePath("/orgchart");
revalidatePath("/employees");
revalidatePath("/");
return { success: true, scenarioId: data as string };
}
export async function undoReorg(payload: { scenario_id: string }): Promise<ActionResult> {
const supabase = await createClient();
const { error } = await supabase.rpc("undo_reorg", { payload });
if (error) return { success: false, error: error.message };
revalidatePath("/orgchart");
revalidatePath("/employees");
revalidatePath("/");
return { success: true };
}

View File

@@ -1,31 +1,27 @@
"use server";
import { revalidatePath } from "next/cache";
import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
import type { ActionResult } from "@/lib/db/rpc";
import { createClient } from "@/lib/supabase/server";
type ActionResult = { success: boolean; error?: string };
export async function saveReport(payload: { name: string; config: Record<string, unknown> }): Promise<ActionResult> {
const userId = await currentUserId();
if (!userId) return { success: false, error: "Nicht angemeldet." };
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return { success: false, error: "Nicht angemeldet." };
try {
await withUser(userId, (tx) =>
tx.insertInto("saved_reports").values({ created_by: userId, name: payload.name, config: payload.config }).execute()
);
const { error } = await supabase.from("saved_reports").insert({ created_by: user.id, name: payload.name, config: payload.config });
if (error) return { success: false, error: error.message };
revalidatePath("/reports");
return { success: true };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
}
}
export async function deleteReport(id: string): Promise<ActionResult> {
try {
await withUser(await currentUserId(), (tx) => tx.deleteFrom("saved_reports").where("id", "=", id).execute());
const supabase = await createClient();
const { error } = await supabase.from("saved_reports").delete().eq("id", id);
if (error) return { success: false, error: error.message };
revalidatePath("/reports");
return { success: true };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
}
}

View File

@@ -1,12 +1,11 @@
import Link from "next/link";
import { Suspense } from "react";
import { AuditDetail } from "@/components/audit/AuditDetail";
import { AuditFilters } from "@/components/audit/AuditFilters";
import { CARD_CLASS } from "@/components/ui/Card";
import { Pagination } from "@/components/ui/Pagination";
import { actionBadgeStyle } from "@/lib/colors";
import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
import { createClient } from "@/lib/supabase/server";
const PAGE_SIZE = 25;
@@ -35,50 +34,33 @@ const dateTimeFormatter = new Intl.DateTimeFormat("de-AT", {
export default async function AuditPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams;
const supabase = await createClient();
const page = Math.max(1, Number(params.page ?? "1") || 1);
const from = (page - 1) * PAGE_SIZE;
const to = from + PAGE_SIZE - 1;
const { entries, count } = await withUser(await currentUserId(), async (tx) => {
const base = () => {
let q = tx.selectFrom("audit_log");
if (params.action) q = q.where("action", "=", params.action);
let query = supabase
.from("audit_log")
.select("id, occurred_at, actor_name, action, target_label, target_employee_id, details", { count: "exact" })
.order("occurred_at", { ascending: false })
.range(from, to);
if (params.action) query = query.eq("action", params.action);
if (params.q) {
// Als Parameter gebunden statt in die Abfrage geschrieben: die
// Zeichen, die in der alten Filtersyntax ausbrechen konnten, haben
// hier keine Bedeutung mehr.
const like = `%${params.q.trim()}%`;
q = q.where((eb) =>
eb.or([eb("target_label", "ilike", like), eb("details", "ilike", like), eb("actor_name", "ilike", like)])
);
const q = sanitizeIlikeTerm(params.q.trim());
query = query.or(`target_label.ilike.%${q}%,details.ilike.%${q}%,actor_name.ilike.%${q}%`);
}
return q;
};
const [entries, total] = await Promise.all([
base()
.select(["id", "occurred_at", "actor_name", "action", "target_label", "target_employee_id", "details", "changes"])
// Nach id als zweitem Kriterium: bei gleichem Zeitstempel wäre die
// Reihenfolge sonst unbestimmt und ein Eintrag könnte auf zwei Seiten
// erscheinen oder auf keiner.
.orderBy("occurred_at", "desc")
.orderBy("id", "desc")
.limit(PAGE_SIZE)
.offset((page - 1) * PAGE_SIZE)
.execute(),
base()
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
.executeTakeFirst(),
]);
return { entries, count: Number(total?.anzahl ?? 0) };
});
const totalPages = Math.max(1, Math.ceil(count / PAGE_SIZE));
const { data: entries, count } = await query;
const totalPages = Math.max(1, Math.ceil((count ?? 0) / PAGE_SIZE));
return (
<div className="flex flex-col gap-4">
<Suspense>
<AuditFilters />
</Suspense>
<p className="text-sm text-ink-muted">{count} Einträge</p>
<p className="text-sm text-ink-muted">{count ?? 0} Einträge</p>
<div className={`overflow-x-auto ${CARD_CLASS}`}>
<table className="w-full min-w-[800px] text-sm">
@@ -92,7 +74,7 @@ export default async function AuditPage({ searchParams }: { searchParams: Promis
</tr>
</thead>
<tbody>
{entries.map((entry) => {
{(entries ?? []).map((entry) => {
return (
<tr key={entry.id} className="border-b border-border-subtle transition-colors last:border-0 hover:bg-brand-50">
<td className="whitespace-nowrap px-4 py-2.5 tabular-nums text-ink-body">
@@ -116,13 +98,11 @@ export default async function AuditPage({ searchParams }: { searchParams: Promis
entry.target_label
)}
</td>
<td className="px-2 py-1.5">
<AuditDetail eintrag={entry} />
</td>
<td className="px-4 py-2.5 text-ink-muted">{entry.details ?? ""}</td>
</tr>
);
})}
{entries.length === 0 && (
{(entries ?? []).length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-sm text-ink-muted">
Keine Einträge gefunden.

View File

@@ -1,108 +1,74 @@
import { notFound } from "next/navigation";
import { EmployeeDetail } from "@/components/employees/EmployeeDetail";
import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
import { todayIso } from "@/lib/format";
import { breadcrumbLabel, loadOrgMaps } from "@/lib/org";
import { loadPlacements, loadReportingLines } from "@/lib/placement";
import { loadOpenPositions } from "@/lib/positions";
import { createClient } from "@/lib/supabase/server";
import type { Database } from "@/lib/supabase/types";
type PageProps = { params: Promise<{ id: string }> };
type EmployeeWithManager = Database["public"]["Tables"]["employees"]["Row"] & {
manager: { id: string; first_name: string; last_name: string; job_title: string } | null;
};
export default async function EmployeeDetailPage({ params }: PageProps) {
const { id } = await params;
const today = todayIso();
const supabase = await createClient();
const data = await withUser(await currentUserId(), async (tx) => {
// Vorgesetzte und direkte Berichte stehen nirgends als Spalte — sie
// kommen aus om_reporting_lines(). Beide Abfragen schränken *in* der
// Funktion ein, es wandern also neun Zeilen über die Leitung und nicht
// achthundert.
const [employee, ownLines, reports, history, dependents, notes, orgMaps, placements, openPositions] =
await Promise.all([
tx.selectFrom("employees").selectAll().where("id", "=", id).executeTakeFirst(),
loadReportingLines(tx, today, { employeeId: id }),
loadReportingLines(tx, today, { actingManagerId: id }),
tx
.selectFrom("employee_history")
.selectAll()
.where("employee_id", "=", id)
.orderBy("event_date", "desc")
.orderBy("created_at", "desc")
.execute(),
tx.selectFrom("employee_dependents").selectAll().where("employee_id", "=", id).orderBy("created_at").execute(),
tx.selectFrom("employee_notes").selectAll().where("employee_id", "=", id).orderBy("created_at", "desc").execute(),
loadOrgMaps(tx),
loadPlacements(tx, { asOf: today, employeeIds: [id] }),
loadOpenPositions(tx),
// Everything here keys off the id already in the URL, and the manager
// comes back as an embedded resource on the employee row rather than as a
// follow-up query — so the page is one round trip instead of two. Measured
// against the hosted database that halved the data time (120ms -> 62ms,
// median of five), because a round trip costs more than these queries do.
//
// The hand-written Database type carries no relationship metadata
// (NoRelationships), so the embed is typed at the destructure below.
const [
{ data: employeeRow },
{ data: directReports },
{ data: history },
{ data: dependents },
{ data: notes },
{ data: divisions },
{ data: departments },
{ data: teams },
{ data: locations },
{ data: openPositions },
] = await Promise.all([
supabase.from("employees").select("*, manager:manager_id(id, first_name, last_name, job_title)").eq("id", id).single(),
supabase.from("employees").select("id, first_name, last_name, job_title, status").eq("manager_id", id).order("last_name"),
supabase
.from("employee_history")
.select("*")
.eq("employee_id", id)
.order("event_date", { ascending: false })
.order("created_at", { ascending: false }),
supabase.from("employee_dependents").select("*").eq("employee_id", id).order("created_at"),
supabase.from("employee_notes").select("*").eq("employee_id", id).order("created_at", { ascending: false }),
supabase.from("divisions").select("*").order("name"),
supabase.from("departments").select("*"),
supabase.from("teams").select("*"),
supabase.from("locations").select("*").order("name"),
supabase.from("positions").select("id, position_number, title, team_id, is_lead").eq("status", "open"),
]);
if (!employee) return null;
const line = ownLines[0] ?? null;
if (!employeeRow) notFound();
// Namen für die beteiligten Personen in einem Zug: die Vertretung, die
// formal zuständige Leitung und die direkten Berichte.
const relatedIds = Array.from(
new Set(
[line?.acting_manager_id, line?.formal_manager_id, ...reports.map((r) => r.employee_id)].filter(
(x): x is string => Boolean(x)
)
)
);
const relatedRows = relatedIds.length
? await tx
.selectFrom("employees")
.select(["id", "first_name", "last_name", "job_title", "status"])
.where("id", "in", relatedIds)
.execute()
: [];
return {
employee,
line,
reports,
history,
dependents,
notes,
orgMaps,
placement: placements.get(id) ?? null,
openPositions,
byId: new Map(relatedRows.map((e) => [e.id, e])),
};
});
if (!data) notFound();
const { employee, line, reports, history, dependents, notes, orgMaps, placement, openPositions, byId } = data;
// Split the embedded manager back off so EmployeeDetail keeps receiving a
// plain employees row plus a separate manager, unchanged.
const { manager, ...employee } = employeeRow as EmployeeWithManager;
return (
<EmployeeDetail
employee={employee}
placement={
placement && {
positionNumber: placement.positionNumber,
jobTitle: placement.jobTitle,
isChief: placement.isChief,
current: placement.current,
}
}
breadcrumb={breadcrumbLabel(orgMaps, placement?.orgUnitId)}
manager={(line?.acting_manager_id ? byId.get(line.acting_manager_id) : null) ?? null}
// Nur wenn eine Vertretung im Spiel ist — sonst stünde dieselbe Person
// zweimal da.
formalManager={
line?.formal_manager_id && line.formal_manager_id !== line.acting_manager_id
? (byId.get(line.formal_manager_id) ?? null)
: null
}
directReports={reports.flatMap((r) => {
const e = byId.get(r.employee_id);
return e ? [e] : [];
})}
manager={manager ?? null}
directReports={directReports ?? []}
history={history ?? []}
dependents={dependents ?? []}
notes={notes ?? []}
locations={orgMaps.locationList}
openPositions={openPositions}
divisions={divisions ?? []}
departments={departments ?? []}
teams={teams ?? []}
locations={locations ?? []}
openPositions={openPositions ?? []}
/>
);
}

View File

@@ -5,12 +5,11 @@ import { Avatar } from "@/components/ui/Avatar";
import { CARD_CLASS } from "@/components/ui/Card";
import { Pagination } from "@/components/ui/Pagination";
import { StatusChip } from "@/components/ui/StatusChip";
import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
import { derivedStatusFilter } from "@/lib/employee-status-filter";
import { applyDerivedStatusFilter } from "@/lib/employee-status-filter";
import { fmtDate, todayIso } from "@/lib/format";
import { breadcrumbLabel, divisionOf, loadOrgMaps, subtreeOf, unitOf } from "@/lib/org";
import { loadPlacements } from "@/lib/placement";
import { breadcrumbFor, loadOrgMaps } from "@/lib/org";
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
import { createClient } from "@/lib/supabase/server";
import type { EmploymentStatus } from "@/lib/supabase/types";
const PAGE_SIZE = 15;
@@ -33,115 +32,53 @@ function pageHref(params: SearchParams, page: number): string {
export default async function EmployeesPage({ searchParams }: EmployeesPageProps) {
const params = await searchParams;
const page = Math.max(1, Number(params.page ?? "1") || 1);
const today = todayIso();
const supabase = await createClient();
const page = Math.max(1, Number(params.page ?? "1") || 1);
const from = (page - 1) * PAGE_SIZE;
const to = from + PAGE_SIZE - 1;
let query = supabase
.from("employees")
.select(
"id, first_name, last_name, personnel_number, job_title, team_id, division_id, location_id, entry_date, employment_type, weekly_hours, status, absence_type",
{ count: "exact" }
)
.order("last_name", { ascending: true })
.range(from, to);
if (params.q) {
const q = params.q.trim();
if (/^\d+$/.test(q)) {
query = query.eq("personnel_number", Number(q));
} else {
const term = sanitizeIlikeTerm(q);
query = query.or(`first_name.ilike.%${term}%,last_name.ilike.%${term}%,job_title.ilike.%${term}%`);
}
}
if (params.division) query = query.eq("division_id", params.division);
// Comma-separated, so a dashboard tile can link here with the same
// status set it counted rather than a narrower one.
const statuses = (params.status ?? "")
.split(",")
.map((s) => s.trim())
.filter((s): s is EmploymentStatus => (["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const).includes(s as EmploymentStatus));
const { orgMaps, employees, count, placements } = await withUser(await currentUserId(), async (tx) => {
// Die Referenzdaten zuerst: der Bereichsfilter braucht den Teilbaum.
// „Produktion" meint die Abteilungen und Teams darunter — in der Einheit
// selbst sitzt nur die Bereichsleitung.
const orgMaps = await loadOrgMaps(tx);
const unitFilter = params.division && orgMaps.units.has(params.division) ? params.division : null;
// Eine Filterkette, zwei Abfragen: eine für die Seite, eine für die
// Gesamtzahl. Am direkten Zugang teilen sie sich denselben Aufbau —
// vorher brauchte es zwei getrennte Select-Formen, weil der Typparser der
// API-Schicht einen bedingt zusammengesetzten Select-String nicht
// auflösen konnte.
const base = () => {
let q = tx.selectFrom("employees");
if (unitFilter) {
// Nach Organisationseinheit gefiltert wird über die *laufende*
// Besetzung. Als EXISTS, damit eine Person nicht mehrfach erscheint,
// wenn sie über die Zeit mehrere Zuordnungen hatte.
const units = subtreeOf(orgMaps, unitFilter);
q = q.where((eb) =>
eb.exists(
eb
.selectFrom("position_assignments as a")
.innerJoin("om_positions as p", "p.id", "a.position_id")
.select("a.id")
.whereRef("a.employee_id", "=", "employees.id")
.where("a.valid_to", "is", null)
.where("p.org_unit_id", "in", units)
)
);
}
if (params.q) {
const term = params.q.trim();
if (/^d+$/.test(term)) {
q = q.where("personnel_number", "=", Number(term));
} else {
// Als Parameter gebunden statt in die Abfrage geschrieben: die
// Zeichen, die in der alten Filtersyntax ausbrechen konnten, sind
// hier bedeutungslos.
const like = `%${term}%`;
q = q.where((eb) =>
eb.or([eb("first_name", "ilike", like), eb("last_name", "ilike", like), eb("job_title", "ilike", like)])
);
}
}
// Derived from the dates, not read off employees.status — see
// lib/employee-status-filter.ts for why the two can disagree.
if (statuses.length > 0) {
q = q.where((eb) => derivedStatusFilter(eb, statuses, today) ?? eb.val(true));
}
query = applyDerivedStatusFilter(query, statuses, todayIso());
if (params.location) query = query.eq("location_id", params.location);
if (params.location) q = q.where("location_id", "=", params.location);
return q;
};
const [rows, total] = await Promise.all([
base()
.select([
"id",
"first_name",
"last_name",
"personnel_number",
"job_title",
"location_id",
"entry_date",
"employment_type",
"weekly_hours",
"status",
"absence_type",
])
// Nach id als zweitem Kriterium: bei gleichem Nachnamen wäre die
// Reihenfolge sonst unbestimmt, und dieselbe Person könnte auf zwei
// Seiten erscheinen oder auf keiner.
.orderBy("last_name")
.orderBy("id")
.limit(PAGE_SIZE)
.offset((page - 1) * PAGE_SIZE)
.execute(),
base()
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
.executeTakeFirst(),
]);
// Die Einordnung kommt über die Planstelle — nur für die 15 Zeilen dieser
// Seite, nicht für den ganzen Bestand.
const placements = await loadPlacements(tx, { asOf: today, employeeIds: rows.map((e) => e.id) });
return { orgMaps, employees: rows, count: Number(total?.anzahl ?? 0), placements };
});
const totalPages = Math.max(1, Math.ceil(count / PAGE_SIZE));
// The org lookup tables are needed only to label the rows, so they load
// alongside the page of employees instead of before it — one round trip
// saved on a page that is otherwise two fast queries.
const [orgMaps, { data: employeesData, count }] = await Promise.all([loadOrgMaps(supabase), query]);
const employees = employeesData ?? [];
const totalPages = Math.max(1, Math.ceil((count ?? 0) / PAGE_SIZE));
return (
<div className="flex flex-col gap-4">
<Suspense>
<EmployeeFilters units={orgMaps.unitList} depthOf={orgMaps.depthOf} locations={orgMaps.locationList} />
<EmployeeFilters divisions={orgMaps.divisionList} locations={orgMaps.locationList} />
</Suspense>
<p className="text-sm text-ink-muted">{count ?? 0} Mitarbeiter:innen gefunden</p>
@@ -160,9 +97,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
</thead>
<tbody>
{employees.map((e) => {
const placement = placements.get(e.id);
const division = divisionOf(orgMaps, placement?.orgUnitId);
const unit = unitOf(orgMaps, placement?.orgUnitId);
const { division, team } = breadcrumbFor(orgMaps, e.division_id, e.team_id);
const location = e.location_id ? orgMaps.locations.get(e.location_id) : undefined;
return (
// border-subtle between rows: the full-strength border made
@@ -178,7 +113,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
<div className="truncate font-semibold text-ink">
{e.first_name} {e.last_name}
</div>
<div className="truncate text-xs text-ink-muted">{placement?.jobTitle ?? e.job_title}</div>
<div className="truncate text-xs text-ink-muted">{e.job_title}</div>
</div>
</Link>
</td>
@@ -187,12 +122,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
<td className="px-4 py-2.5 tabular-nums text-ink-body">{e.personnel_number}</td>
<td className="px-4 py-2.5 text-ink-body">
<div>{division?.name ?? ""}</div>
{/* Die eigene Einheit, egal auf welcher Ebene sie hängt —
eine Bereichsleitung sitzt am Bereich, nicht an einem
Team, und stand vorher deshalb ohne Zuordnung da. */}
<div className="text-xs text-ink-muted" title={breadcrumbLabel(orgMaps, placement?.orgUnitId)}>
{unit && unit.id !== division?.id ? unit.name : ""}
</div>
<div className="text-xs text-ink-muted">{team?.name ?? ""}</div>
</td>
<td className="px-4 py-2.5 text-ink-body">{location?.name ?? ""}</td>
<td className="px-4 py-2.5 tabular-nums text-ink-body">{fmtDate(e.entry_date)}</td>

View File

@@ -1,16 +0,0 @@
import { ImportWorkbench } from "@/components/import/ImportWorkbench";
export const metadata = { title: "Import" };
export default function ImportPage() {
return (
<div className="flex flex-col gap-4">
<p className="max-w-prose text-sm text-ink-muted">
Übernahme aus einer Datei Organisation, Planstellen, Personen, Historie und Angehörige. Angelegt wird nur;
bestehende Datensätze werden nie überschrieben. Geprüft wird vor dem Schreiben, und geschrieben wird alles
zusammen oder gar nichts.
</p>
<ImportWorkbench />
</div>
);
}

View File

@@ -2,55 +2,36 @@ import { redirect } from "next/navigation";
import type { ReactNode } from "react";
import { HireWizardProvider } from "@/components/hire/HireWizardContext";
import { AppShell } from "@/components/shell/AppShell";
import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
import { loadOpenNotes } from "@/lib/notes";
import { loadOpenPositions } from "@/lib/positions";
import { createClient } from "@/lib/supabase/server";
export default async function AppLayout({ children }: { children: ReactNode }) {
const userId = await currentUserId();
if (!userId) redirect("/login");
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect("/login");
// Alles in *einer* Transaktion, weil nur dort der Sitzungskontext gilt —
// und damit nebenbei auf einem einheitlichen Lesestand.
const data = await withUser(userId, async (tx) => {
// Hier — und nicht im Proxy — fällt die Entscheidung über den Zugang.
// Der Proxy prüft nur, ob überhaupt jemand angemeldet ist; er hat keine
// Datenbankverbindung. Diese Abfrage läuft bei jedem Aufbau frisch, eine
// entzogene Freischaltung wirkt also sofort statt erst mit dem nächsten
// Sitzungstoken. Die eigentliche Grenze bleibt darunter RLS.
const profile = await tx
.selectFrom("profiles")
.select(["full_name", "email", "role", "is_active"])
.where("id", "=", userId)
.executeTakeFirst();
if (profile?.role !== "hr" || profile?.is_active !== true) return null;
// Defense in depth: proxy.ts already redirects any non-active-HR session
// away before this layout ever renders. Re-checking here means a gap in
// the proxy matcher (or a future route added outside it) still fails
// closed instead of silently granting access — see docs/security.md.
const { data: profile } = await supabase.from("profiles").select("full_name, email, role, is_active").eq("id", user.id).maybeSingle();
if (profile?.role !== "hr" || profile?.is_active !== true) redirect("/login");
const [openPositions, locations, drafts, openNotes] = await Promise.all([
loadOpenPositions(tx),
tx.selectFrom("locations").select(["id", "name", "country"]).orderBy("name").execute(),
tx
.selectFrom("hire_drafts")
.select(["id", "step", "payload", "updated_at"])
.where("created_by", "=", userId)
.orderBy("updated_at", "desc")
.execute(),
loadOpenNotes(tx),
const userLabel = profile.full_name || profile.email || user.email || "";
const [openPositions, locationsRes, draftsRes, openNotes] = await Promise.all([
loadOpenPositions(supabase),
supabase.from("locations").select("id, name, country").order("name"),
supabase.from("hire_drafts").select("id, step, payload, updated_at").eq("created_by", user.id).order("updated_at", { ascending: false }),
loadOpenNotes(supabase),
]);
return { profile, openPositions, locations, drafts, openNotes };
});
// `data` ist null, wenn die Person angemeldet, aber nicht freigeschaltet
// ist. Ohne den Grund in der Adresse stünde sie vor einer wortlosen
// Anmeldeseite und versuchte es endlos erneut.
if (!data) redirect("/login?error=no_hr_access");
const userLabel = data.profile.full_name || data.profile.email || "";
return (
<HireWizardProvider openPositions={data.openPositions} locations={data.locations} drafts={data.drafts}>
<AppShell userLabel={userLabel} openNotes={data.openNotes}>
<HireWizardProvider openPositions={openPositions} locations={locationsRes.data ?? []} drafts={draftsRes.data ?? []}>
<AppShell userLabel={userLabel} openNotes={openNotes}>
{children}
</AppShell>
</HireWizardProvider>

View File

@@ -1,11 +1,10 @@
import { Suspense } from "react";
import { OrgChartClient } from "@/components/orgchart/OrgChartClient";
import type { OrgUnitNode } from "@/components/orgchart/types";
import { todayIso } from "@/lib/format";
import { loadOrgAsOf } from "@/lib/orgchart-data";
import { loadOpenPositions } from "@/lib/positions";
import { parseIsoDateParam } from "@/lib/reports";
import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
import { createClient } from "@/lib/supabase/server";
type SearchParams = { asOf?: string; focus?: string };
@@ -19,20 +18,32 @@ export default async function OrgChartPage({ searchParams }: { searchParams: Pro
// so a junk value can't reach the client as an arbitrary string.
const focusId = params.focus && UUID.test(params.focus) ? params.focus : null;
const { org, units } = await withUser(await currentUserId(), async (tx) => {
const [org, units] = await Promise.all([
loadOrgAsOf(tx, asOf),
tx.selectFrom("org_units").select(["id", "org_number", "name", "parent_id", "unit_type"]).orderBy("org_number").execute(),
const supabase = await createClient();
const [org, { data: divisions }, { data: departments }, { data: teams }, openPositions, { data: reorgScenarios }] =
await Promise.all([
loadOrgAsOf(supabase, asOf),
supabase.from("divisions").select("*").order("name"),
supabase.from("departments").select("*"),
supabase.from("teams").select("*"),
loadOpenPositions(supabase),
supabase
.from("reorg_scenarios")
.select("id, name, effective_date, applied, applied_at")
.eq("applied", true)
.order("applied_at", { ascending: false })
.limit(5),
]);
return { org, units };
});
return (
<Suspense>
<OrgChartClient
employees={org.employees}
units={units as OrgUnitNode[]}
vacancies={org.vacancies}
divisions={divisions ?? []}
departments={departments ?? []}
teams={teams ?? []}
openPositions={openPositions}
reorgScenarios={reorgScenarios ?? []}
asOf={asOf}
today={today}
projectedCount={org.projectedCount}

View File

@@ -4,13 +4,9 @@ import { DraftsCard } from "@/components/dashboard/DraftsCard";
import { Card, CARD_CLASS, CardTitle } from "@/components/ui/Card";
import { actionBadgeStyle } from "@/lib/colors";
import { addDaysIso, fmtDate, todayIso } from "@/lib/format";
import { divisionOf, loadOrgMaps } from "@/lib/org";
import { loadPlacements } from "@/lib/placement";
import { loadOpenPositions } from "@/lib/positions";
import { deriveStatusAsOf } from "@/lib/reports";
import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
import type { HistoryEventType } from "@/lib/supabase/types";
import { createClient } from "@/lib/supabase/server";
import { fetchAllRows } from "@/lib/supabase/query";
// Each KPI carries a colour already; the accent bar repeats it in a second
// channel so the tiles are scannable as a row rather than six identical
@@ -40,6 +36,18 @@ const DOT_STYLES: Record<string, string> = {
const KIND_LABEL = { hire: "Eintritt", exit: "Austritt", return: "Rückkehr aus Abwesenheit" } as const;
export default async function DashboardPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
const { data: drafts } = user
? await supabase
.from("hire_drafts")
.select("id, step, payload, updated_at")
.eq("created_by", user.id)
.order("updated_at", { ascending: false })
: { data: [] };
// Built as strings, not by round-tripping a local Date through
// toISOString(): in any positive-offset zone new Date(year, 0, 1) is still
// the previous year in UTC, which shifted the whole YTD window a day early
@@ -50,8 +58,6 @@ export default async function DashboardPage() {
const yearEnd = `${year}-12-31`;
const in60Iso = addDaysIso(today, 60);
const userId = await currentUserId();
// Headcount, FTE, Karenz and the division bars all come from one full read
// and the *derived* status, not from the `employees.status` column.
//
@@ -60,117 +66,69 @@ export default async function DashboardPage() {
// planned hire whose start date has passed, or a Karenz that ended without
// anyone recording the return, made the dashboard and the Berichte page
// disagree about the same headcount. Same derivation, same numbers.
const {
drafts,
staffRows,
hiresYtd,
exitsYtd,
openPositions,
orgMaps,
placements,
upcomingHires,
upcomingExits,
upcomingReturns,
history,
} = await withUser(userId, async (tx) => {
const countIn = (types: readonly HistoryEventType[]) =>
tx
.selectFrom("employee_history")
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
.where("event_type", "in", [...types])
.where("event_date", ">=", yearStart)
.where("event_date", "<=", yearEnd)
.executeTakeFirst();
// It also replaces four separate count queries with one.
const [
drafts,
staffRows,
hiresYtd,
exitsYtd,
openPositions,
orgMaps,
placements,
upcomingHires,
upcomingExits,
upcomingReturns,
history,
hiresYtdRes,
exitsYtdRes,
openPositionsRes,
divisionsRes,
upcomingHiresRes,
upcomingExitsRes,
upcomingReturnsRes,
historyRes,
] = await Promise.all([
userId
? tx
.selectFrom("hire_drafts")
.select(["id", "step", "payload", "updated_at"])
.where("created_by", "=", userId)
.orderBy("updated_at", "desc")
.execute()
: Promise.resolve([]),
tx
.selectFrom("employees")
.select(["id", "weekly_hours", "entry_date", "exit_date", "karenz_start_date", "karenz_return_date"])
.orderBy("id")
.execute(),
fetchAllRows(() =>
supabase
.from("employees")
.select("weekly_hours, division_id, entry_date, exit_date, karenz_start_date, karenz_return_date")
.order("id")
),
// Entries/exits count history events, which is what the linked report
// counts too. `entry_date` would also sweep up rehires, whose event is
// logged as 'Wiedereintritt' — the tile and its destination then showed
// different numbers for the same year.
countIn(["Eintritt", "Wiedereintritt"]),
countIn(["Austritt"]),
loadOpenPositions(tx),
loadOrgMaps(tx),
loadPlacements(tx, { asOf: today }),
tx
.selectFrom("employees")
.select(["id", "first_name", "last_name", "entry_date"])
.where("status", "=", "Geplant")
.where("entry_date", ">=", today)
.where("entry_date", "<=", in60Iso)
.execute(),
tx
.selectFrom("employees")
.select(["id", "first_name", "last_name", "exit_date"])
.where("exit_date", "is not", null)
.where("exit_date", ">=", today)
.where("exit_date", "<=", in60Iso)
.execute(),
tx
.selectFrom("employees")
.select(["id", "first_name", "last_name", "karenz_return_date"])
.where("status", "=", "Karenz")
.where("karenz_return_date", "is not", null)
.where("karenz_return_date", ">=", today)
.where("karenz_return_date", "<=", in60Iso)
.execute(),
tx
.selectFrom("employee_history as h")
.leftJoin("employees as e", "e.id", "h.employee_id")
.select(["h.id", "h.employee_id", "h.event_date", "h.event_type", "h.description", "e.first_name", "e.last_name"])
.orderBy("h.event_date", "desc")
.orderBy("h.created_at", "desc")
.limit(10)
.execute(),
supabase
.from("employee_history")
.select("id", { count: "exact", head: true })
.in("event_type", ["Eintritt", "Wiedereintritt"])
.gte("event_date", yearStart)
.lte("event_date", yearEnd),
supabase
.from("employee_history")
.select("id", { count: "exact", head: true })
.eq("event_type", "Austritt")
.gte("event_date", yearStart)
.lte("event_date", yearEnd),
supabase.from("positions").select("id", { count: "exact", head: true }).eq("status", "open"),
supabase.from("divisions").select("id, name"),
supabase
.from("employees")
.select("id, first_name, last_name, entry_date")
.eq("status", "Geplant")
.gte("entry_date", today)
.lte("entry_date", in60Iso),
supabase
.from("employees")
.select("id, first_name, last_name, exit_date")
.not("exit_date", "is", null)
.gte("exit_date", today)
.lte("exit_date", in60Iso),
supabase
.from("employees")
.select("id, first_name, last_name, karenz_return_date")
.eq("status", "Karenz")
.not("karenz_return_date", "is", null)
.gte("karenz_return_date", today)
.lte("karenz_return_date", in60Iso),
supabase
.from("employee_history")
.select("id, employee_id, event_date, event_type, description")
.order("event_date", { ascending: false })
.order("created_at", { ascending: false })
.limit(10),
]);
return {
drafts,
staffRows,
hiresYtd: Number(hiresYtd?.anzahl ?? 0),
exitsYtd: Number(exitsYtd?.anzahl ?? 0),
openPositions,
orgMaps,
placements,
upcomingHires,
upcomingExits,
upcomingReturns,
history,
};
});
// "Aktiv" means status Aktiv — somebody on Karenz is employed but not
// active, and is counted by its own tile instead. FTE follows the same
// set: Karenz contributes no capacity, so including it would overstate
@@ -185,37 +143,31 @@ export default async function DashboardPage() {
const karenzCount = staffRows.filter((row) => statusOf(row) === "Karenz").length;
const fte = activeStaff.reduce((sum, row) => sum + Number(row.weekly_hours), 0) / 38.5;
// Der Bereich einer Person steht nicht mehr auf ihr; er ergibt sich aus der
// Einheit ihrer Planstelle und deren Vorfahren. Die Bereichsleitung selbst
// sitzt *am* Bereich, ihre Leute darunter — beide landen über die
// Vorfahrenkette im selben Balken.
const headcountByDivision = new Map<string, number>();
for (const row of activeStaff) {
const division = divisionOf(orgMaps, placements.get(row.id)?.orgUnitId);
if (!division) continue;
headcountByDivision.set(division.id, (headcountByDivision.get(division.id) ?? 0) + 1);
if (!row.division_id) continue;
headcountByDivision.set(row.division_id, (headcountByDivision.get(row.division_id) ?? 0) + 1);
}
const divisionBars = orgMaps.unitList
.filter((u) => u.unit_type === "Bereich")
const divisionBars = (divisionsRes.data ?? [])
.map((d) => ({ name: d.name, count: headcountByDivision.get(d.id) ?? 0 }))
.sort((a, b) => b.count - a.count);
const maxDivisionCount = Math.max(1, ...divisionBars.map((d) => d.count));
type UpcomingItem = { id: string; label: string; date: string; kind: keyof typeof KIND_LABEL };
const upcoming: UpcomingItem[] = [
...(upcomingHires).map((e) => ({
...(upcomingHiresRes.data ?? []).map((e) => ({
id: e.id,
label: `${e.first_name} ${e.last_name}`,
date: e.entry_date,
kind: "hire" as const,
})),
...(upcomingExits).map((e) => ({
...(upcomingExitsRes.data ?? []).map((e) => ({
id: e.id,
label: `${e.first_name} ${e.last_name}`,
date: e.exit_date!,
kind: "exit" as const,
})),
...(upcomingReturns).map((e) => ({
...(upcomingReturnsRes.data ?? []).map((e) => ({
id: e.id,
label: `${e.first_name} ${e.last_name}`,
date: e.karenz_return_date!,
@@ -225,6 +177,12 @@ export default async function DashboardPage() {
.sort((a, b) => a.date.localeCompare(b.date))
.slice(0, 8);
const historyEmployeeIds = Array.from(new Set((historyRes.data ?? []).map((h) => h.employee_id)));
const historyEmployeesRes = historyEmployeeIds.length
? await supabase.from("employees").select("id, first_name, last_name").in("id", historyEmployeeIds)
: { data: [] as { id: string; first_name: string; last_name: string }[] };
const employeeNameById = new Map((historyEmployeesRes.data ?? []).map((e) => [e.id, `${e.first_name} ${e.last_name}`]));
// Each tile links to the view that shows what it counts, with the filters
// pre-applied.
//
@@ -245,18 +203,18 @@ export default async function DashboardPage() {
{ label: "FTE", value: fte.toFixed(1), tone: "default", href: "/reports?mode=snapshot&measure=fte&status=Aktiv" },
{
label: "Eintritte (Jahr)",
value: hiresYtd,
value: hiresYtdRes.count ?? 0,
tone: "success",
href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${yearEnd}`,
},
{
label: "Austritte (Jahr)",
value: exitsYtd,
value: exitsYtdRes.count ?? 0,
tone: "danger",
href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`,
},
{ label: "Langzeitabwesend", value: karenzCount, tone: "warning", href: "/employees?status=Karenz" },
{ label: "Offene Positionen", value: openPositions.length, tone: "brand", href: "/positions" },
{ label: "Offene Positionen", value: openPositionsRes.count ?? 0, tone: "brand", href: "/positions" },
];
return (
@@ -331,14 +289,14 @@ export default async function DashboardPage() {
<Card>
<CardTitle className="mb-1">Letzte Aktivitäten</CardTitle>
<ul className="flex flex-col divide-y divide-border-subtle">
{(history).map((h) => (
{(historyRes.data ?? []).map((h) => (
<li key={h.id} className="flex gap-2.5 py-2.5">
{/* Dot aligned to the first line of text, not centred on the
whole row, so it stays put as descriptions wrap. */}
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${DOT_STYLES[h.event_type] ?? "bg-ink-muted"}`} aria-hidden />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm font-semibold text-ink">{h.first_name && h.last_name ? `${h.first_name} ${h.last_name}` : "Unbekannt"}</span>
<span className="text-sm font-semibold text-ink">{employeeNameById.get(h.employee_id) ?? "Unbekannt"}</span>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${actionBadgeStyle(h.event_type)}`}>
{h.event_type}
</span>
@@ -347,7 +305,7 @@ export default async function DashboardPage() {
</div>
</li>
))}
{(history).length === 0 && <p className="py-2 text-sm text-ink-muted">Keine Aktivitäten vorhanden.</p>}
{(historyRes.data ?? []).length === 0 && <p className="py-2 text-sm text-ink-muted">Keine Aktivitäten vorhanden.</p>}
</ul>
</Card>
</div>

View File

@@ -1,34 +1,21 @@
import type { UnitOption } from "@/components/positions/CreatePositionModal";
import { PositionsPageClient } from "@/components/positions/PositionsPageClient";
import { daysBetweenIso } from "@/lib/format";
import { loadOrgMaps } from "@/lib/org";
import { daysBetweenIso, toIsoDate } from "@/lib/format";
import { loadOpenPositions } from "@/lib/positions";
import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
import { createClient } from "@/lib/supabase/server";
export default async function PositionsPage() {
const { openPositions, orgMaps, chiefRows } = await withUser(await currentUserId(), async (tx) => {
const [openPositions, orgMaps, chiefRows] = await Promise.all([
loadOpenPositions(tx),
loadOrgMaps(tx),
// Wo es schon eine gültige Leitungsplanstelle gibt, lässt der
// Unique-Index keine zweite zu — das gehört in den Dialog, nicht in eine
// Fehlermeldung nach dem Absenden.
tx.selectFrom("om_positions").select("org_unit_id").where("is_chief", "=", true).where("valid_to", "is", null).execute(),
const supabase = await createClient();
// Teams are only needed for the "Position ausschreiben" dialog's team
// select. The division/department/team headcount overview this page used
// to render was dropped, and with it the two employee-wide aggregation
// queries that fed it.
const [openPositions, { data: teams }] = await Promise.all([
loadOpenPositions(supabase),
supabase.from("teams").select("*").order("name"),
]);
return { openPositions, orgMaps, chiefRows };
});
const withChief = new Set(chiefRows.map((r) => r.org_unit_id));
const units: UnitOption[] = orgMaps.unitList.map((u) => ({
id: u.id,
name: u.name,
unit_type: u.unit_type,
depth: orgMaps.depthOf.get(u.id) ?? 0,
hasChief: withChief.has(u.id),
}));
const openPositionsWithDays = openPositions.map((p) => ({ ...p, daysOpen: daysBetweenIso(toIsoDate(p.created_at)) }));
const openPositionsWithDays = openPositions.map((p) => ({ ...p, daysOpen: daysBetweenIso(p.vacantSince) }));
return <PositionsPageClient openPositions={openPositionsWithDays} units={units} />;
return <PositionsPageClient openPositions={openPositionsWithDays} teams={teams ?? []} />;
}

View File

@@ -16,8 +16,7 @@ import {
totalForRows,
} from "@/lib/reports";
import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data";
import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
import { createClient } from "@/lib/supabase/server";
type SearchParams = {
mode?: string;
@@ -36,6 +35,7 @@ type SearchParams = {
export default async function ReportsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams;
const supabase = await createClient();
const mode = parseMode(params.mode);
// Both modes are parsed up front so the data load can start before
@@ -55,17 +55,11 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
// in, so all three go out together. Against a hosted database a round trip
// costs about as much as the query itself, which made this page's three
// sequential waves its dominant cost.
const userId = await currentUserId();
// Alles in einer Transaktion — dort gilt der Sitzungskontext, und der
// Lesestand ist über alle Abfragen hinweg derselbe. Vorher waren es drei
// Wellen nacheinander, was gegen eine entfernte Datenbank der teuerste
// Teil dieser Seite war.
const { lookups, divisions, locations, events, employees, savedReports } = await withUser(userId, async (tx) => {
const [{ lookups, divisions, locations }, events, employees, savedReports] = await Promise.all([
loadOrgLookups(tx),
const [{ lookups, divisions, locations }, { data: userRes }, events, employees] = await Promise.all([
loadOrgLookups(supabase),
supabase.auth.getUser(),
mode === "events"
? loadEventHistory(tx, {
? loadEventHistory(supabase, {
eventType: eventType ?? undefined,
division: params.division,
location: params.location,
@@ -74,7 +68,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
})
: Promise.resolve([]),
mode === "snapshot"
? loadSnapshotEmployees(tx, {
? loadSnapshotEmployees(supabase, {
division: params.division,
location: params.location,
status: params.status,
@@ -82,17 +76,13 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
asOf,
})
: Promise.resolve([]),
userId
? tx
.selectFrom("saved_reports")
.select(["id", "name", "config"])
.where("created_by", "=", userId)
.orderBy("created_at", "desc")
.execute()
: Promise.resolve([]),
]);
return { lookups, divisions, locations, events, employees, savedReports };
});
const user = userRes.user;
// Still a wave of its own: it needs the user id the call above resolves.
const { data: savedReports } = user
? await supabase.from("saved_reports").select("id, name, config").eq("created_by", user.id).order("created_at", { ascending: false })
: { data: [] };
if (mode === "events") {
const rows = aggregateEvents(events, eventGroup, eventSplit, lookups);
@@ -110,7 +100,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
recordCount={events.length}
divisions={divisions}
locations={locations}
savedReports={savedReports}
savedReports={savedReports ?? []}
/>
</Suspense>
);
@@ -137,7 +127,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
recordCount={employees.length}
divisions={divisions}
locations={locations}
savedReports={savedReports}
savedReports={savedReports ?? []}
/>
</Suspense>
);

View File

@@ -1,20 +1,14 @@
import { EntraSignInButton } from "@/components/auth/EntraSignInButton";
import { logout, signInWithEntra } from "@/actions/auth";
import { login, logout } from "@/actions/auth";
import { Button } from "@/components/ui/Button";
import { CONTROL_CLASS } from "@/components/ui/Field";
// The query string is attacker-controlled, so the login page renders a message
// looked up by code rather than whatever text ?error= carries. Reflecting the
// raw parameter let anyone put arbitrary wording ("Ihr Konto wurde gesperrt,
// rufen Sie …") on the real, correctly-branded sign-in screen. Dasselbe gilt
// für die Fehlertexte, die Entra im Rückweg mitschickt.
// rufen Sie …") on the real, correctly-branded sign-in screen.
const ERROR_MESSAGES = {
no_hr_access: {
title: "Kein HR-Zugriff",
body: "Ihr Firmenkonto ist bekannt, aber nicht für die Personalverwaltung freigeschaltet. Bitte wenden Sie sich an eine:n bestehende:n HR-Benutzer:in.",
},
sso_failed: {
title: "Anmeldung fehlgeschlagen",
body: "Die Anmeldung über das Firmenkonto konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut.",
},
no_hr_access: "Kein HR-Zugriff. Bitte wenden Sie sich an eine:n bestehende:n HR-Benutzer:in.",
invalid_credentials: "E-Mail oder Passwort ist falsch.",
} as const;
type ErrorCode = keyof typeof ERROR_MESSAGES;
@@ -25,119 +19,60 @@ type LoginPageProps = {
export default async function LoginPage({ searchParams }: LoginPageProps) {
const params = await searchParams;
// Ein unbekannter Code wird nicht verschluckt, sondern auf die allgemeine
// Meldung abgebildet: Auth.js schickt bei einem Fehlschlag seine eigenen
// Codes („Configuration", „AccessDenied", „OAuthCallbackError" …), und ohne
// diese Abbildung stünde man vor einer Anmeldeseite, die so tut, als wäre
// nichts gewesen. Angezeigt wird trotzdem nur eigener Text — der Parameter
// selbst kommt nie auf die Seite.
const code = params.error
? Object.hasOwn(ERROR_MESSAGES, params.error)
? (params.error as ErrorCode)
: "sso_failed"
: null;
const code = params.error && Object.hasOwn(ERROR_MESSAGES, params.error) ? (params.error as ErrorCode) : null;
const error = code ? ERROR_MESSAGES[code] : null;
return (
// dvh statt vh: auf iOS zählt vh die Adressleiste mit, wodurch die Karte
// im ersten Moment unter dem Faltenrand sitzt.
<div className="min-h-dvh lg:grid lg:grid-cols-[1.05fr_1fr]">
<BrandPanel />
<main className="flex items-center justify-center px-6 py-12 lg:py-6">
<div className="w-full max-w-sm">
<div className="lg:hidden">
<Wordmark className="text-ink" />
</div>
<h2 className="mt-8 text-2xl font-extrabold tracking-tight text-ink lg:mt-0">Anmelden</h2>
<p className="mt-1.5 text-sm text-ink-muted">
Der Zugang läuft über Ihr Firmenkonto. Ein eigenes Passwort gibt es nicht.
</p>
<div className="flex min-h-screen items-center justify-center bg-surface px-4">
<div className="w-full max-w-sm rounded border border-border bg-white p-8 shadow-sm">
<h1 className="text-xl font-extrabold text-ink">Alpenwerk HR</h1>
<p className="mt-1 text-sm text-ink-muted">Melden Sie sich mit Ihrem Firmenkonto an.</p>
{error && (
<div role="alert" className="mt-6 rounded-md border border-danger-text/20 bg-danger-bg px-4 py-3">
<p className="text-sm font-bold text-danger-text">{error.title}</p>
<p className="mt-1 text-sm text-danger-text/90">{error.body}</p>
<div role="alert" className="mt-4 rounded bg-danger-bg px-3 py-2 text-sm text-danger-text">
{error}
{code === "no_hr_access" && (
<form action={logout} className="mt-3">
<button
type="submit"
className="rounded text-xs font-semibold text-danger-text underline underline-offset-2 hover:no-underline
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-danger-text"
>
Abmelden und mit einem anderen Konto versuchen
<form action={logout} className="mt-2">
<button type="submit" className="text-xs font-semibold underline hover:no-underline">
Abmelden und mit anderem Konto versuchen
</button>
</form>
)}
</div>
)}
<form action={signInWithEntra} className="mt-7">
<EntraSignInButton />
</form>
<p className="mt-6 border-t border-border-subtle pt-5 text-xs leading-relaxed text-ink-muted">
Die Anmeldung allein erteilt keinen Zugriff. HR-Rechte vergibt die Personalabteilung bis dahin bleiben alle
Personaldaten verschlossen.
</p>
</div>
</main>
</div>
);
}
function BrandPanel() {
return (
<aside className="relative hidden overflow-hidden bg-brand-700 lg:flex lg:flex-col lg:justify-between lg:p-12">
{/* Zwei weiche Lichtpunkte und ein feines Raster — genug Struktur, damit
die Fläche nicht wie ein Farbfehler wirkt, aber ohne Bilddatei und
ohne von der einen Schaltfläche gegenüber abzulenken. */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0"
style={{
backgroundImage:
"radial-gradient(60rem 40rem at 15% 0%, rgb(255 255 255 / 0.16), transparent 60%)," +
"radial-gradient(40rem 40rem at 100% 100%, rgb(255 255 255 / 0.10), transparent 55%)," +
"linear-gradient(rgb(255 255 255 / 0.05) 1px, transparent 1px)," +
"linear-gradient(90deg, rgb(255 255 255 / 0.05) 1px, transparent 1px)",
backgroundSize: "auto, auto, 3rem 3rem, 3rem 3rem",
}}
<form action={login} className="mt-6 flex flex-col gap-4">
<div>
<label htmlFor="email" className="mb-1 block text-sm font-semibold text-ink">
E-Mail
</label>
<input
id="email"
name="email"
type="email"
required
autoComplete="username"
className={CONTROL_CLASS}
/>
<div className="relative">
<Wordmark className="text-white" />
</div>
<div className="relative max-w-md">
<p className="text-3xl font-extrabold leading-tight tracking-tight text-white">
Die Organisation, so wie sie heute wirklich aussieht.
</p>
<p className="mt-4 text-sm leading-relaxed text-white/70">
Stammdaten, Planstellen und Berichtslinien der Alpenwerk Industrie GmbH jederzeit auch zu einem beliebigen
Stichtag.
</p>
<div>
<label htmlFor="password" className="mb-1 block text-sm font-semibold text-ink">
Passwort
</label>
<input
id="password"
name="password"
type="password"
required
autoComplete="current-password"
className={CONTROL_CLASS}
/>
</div>
<Button type="submit" fullWidth className="mt-2">
Anmelden
</Button>
</form>
</div>
<p className="relative text-xs text-white/50">Interne Anwendung · Zugriff nur für die Personalabteilung</p>
</aside>
);
}
function Wordmark({ className = "" }: { className?: string }) {
return (
<div className={`flex items-center gap-2.5 ${className}`}>
{/* Vier Quadrate wie ein Organigramm-Ausschnitt: eine Wurzel, darunter
drei Einheiten. */}
<svg viewBox="0 0 24 24" className="h-7 w-7" aria-hidden="true">
<rect x="9" y="2" width="6" height="6" rx="1.5" fill="currentColor" />
<rect x="1" y="16" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.55" />
<rect x="9" y="16" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.75" />
<rect x="17" y="16" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.55" />
<path d="M12 8v4M4 16v-4h16v4" stroke="currentColor" strokeWidth="1.5" fill="none" opacity="0.5" />
</svg>
<span className="text-lg font-extrabold tracking-tight">Alpenwerk HR</span>
</div>
);
}

View File

@@ -1,9 +0,0 @@
import { handlers } from "@/auth";
// Der Rückweg aus Entra ID und die Endpunkte für An- und Abmeldung.
//
// Tritt an die Stelle von app/auth/callback/route.ts: den Tausch des
// Einmal-Codes gegen eine Sitzung, die Prüfung von `state` und `nonce` und
// das Setzen des Cookies macht jetzt Auth.js. Die Rückruf-Adresse in der
// Entra-Anwendungsregistrierung ändert sich dadurch — siehe docs/entra-sso.md.
export const { GET, POST } = handlers;

View File

@@ -1,6 +1,5 @@
import { NextResponse, type NextRequest } from "next/server";
import { asSystem } from "@/lib/db";
import { callFunction } from "@/lib/db/rpc";
import { createAdminClient } from "@/lib/supabase/admin";
// Applies effective-dated changes (Versetzung/Beförderung/Karenz/Reorg/Daten
// ändern with a future "Wirksam ab" date) once their date has arrived — see
@@ -14,15 +13,13 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "Nicht autorisiert." }, { status: 401 });
}
// Kein privilegierter Zugang mehr: derselbe Datenbankbenutzer ohne
// BYPASSRLS wie überall. apply_due_pending_changes ist SECURITY DEFINER
// und prüft selbst, was sie tut — der Dienstschlüssel, der RLS aushebelte,
// ist damit entfallen.
try {
const applied = await asSystem((tx) => callFunction(tx, "apply_due_pending_changes"));
return NextResponse.json({ applied });
} catch (err) {
console.error("apply_due_pending_changes failed:", err);
const supabase = createAdminClient();
const { data, error } = await supabase.rpc("apply_due_pending_changes");
if (error) {
console.error("apply_due_pending_changes failed:", error);
return NextResponse.json({ error: "Interner Fehler." }, { status: 500 });
}
return NextResponse.json({ applied: data });
}

View File

@@ -1,23 +1,14 @@
import { NextResponse, type NextRequest } from "next/server";
import { statusLabel } from "@/lib/absence";
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
import { todayIso } from "@/lib/format";
import { subtreeOf } from "@/lib/org";
import { loadPlacements, loadReportingLineMap } from "@/lib/placement";
import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "@/lib/reports";
import { loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data";
import { requireHrUser } from "@/lib/auth/require-hr";
import { withUser } from "@/lib/db";
import { requireHrUser } from "@/lib/supabase/auth";
import { fetchAllRows } from "@/lib/supabase/query";
import { createClient } from "@/lib/supabase/server";
import type { Database, EmploymentType, Weekday } from "@/lib/supabase/types";
// Die Rohzeile plus die Einordnung, die nicht mehr auf ihr steht: sie kommt
// über die Planstelle und die abgeleitete Berichtslinie.
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"] & {
org_unit_id: string | null;
position_number: string | null;
is_chief: boolean;
manager_id: string | null;
};
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
// Full raw data dump — every column on `employees`, not just the fields a
// pivot report groups by. Respects the same division/location/status/
@@ -25,8 +16,9 @@ type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"] & {
// filtering happens against the *derived* status as of that date rather
// than the live `status` column — see deriveStatusAsOf.
export async function GET(request: NextRequest) {
const gate = await requireHrUser();
if ("denied" in gate) return gate.denied;
const supabase = await createClient();
const denied = await requireHrUser(supabase);
if (denied) return denied;
const params = request.nextUrl.searchParams;
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
@@ -40,58 +32,24 @@ export async function GET(request: NextRequest) {
const statuses = parseStatuses(filters.status);
const stichtag = asOf ?? todayIso();
const { employees, lookups, orgMaps, allEmployees, dependentsCounts, placements, lines } = await withUser(
gate.userId,
async (tx) => {
function employeeQuery() {
let q = tx.selectFrom("employees").selectAll().orderBy("last_name").orderBy("id");
if (filters.location) q = q.where("location_id", "=", filters.location);
if (filters.employment) q = q.where("employment_type", "=", filters.employment as EmploymentType);
if (!asOf) q = q.where("status", "in", statuses);
return q;
let query = supabase.from("employees").select("*").order("last_name").order("id");
if (filters.division) query = query.eq("division_id", filters.division);
if (filters.location) query = query.eq("location_id", filters.location);
if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType);
if (!asOf) query = query.in("status", statuses);
return query;
}
const [employees, lookupResult, allEmployees, dependentsCounts, placements, lines] = await Promise.all([
employeeQuery().execute(),
loadOrgLookups(tx),
tx.selectFrom("employees").select(["id", "first_name", "last_name"]).orderBy("id").execute(),
loadDependentsCounts(tx),
loadPlacements(tx, { asOf: stichtag }),
loadReportingLineMap(tx, stichtag),
const [employees, { lookups }, allEmployees, dependentsCounts] = await Promise.all([
fetchAllRows(employeeQuery),
loadOrgLookups(supabase),
fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name").order("id")),
loadDependentsCounts(supabase),
]);
return {
employees,
lookups: lookupResult.lookups,
orgMaps: lookupResult.orgMaps,
allEmployees,
dependentsCounts,
placements,
lines,
};
}
);
const managerName = new Map(allEmployees.map((e) => [e.id, `${e.first_name} ${e.last_name}`]));
// Der Einheitenfilter meint den ganzen Teilbaum — sonst enthielte ein
// Export für "Produktion" nur die Bereichsleitung.
const allowedUnits = filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null;
const enriched: EmployeeRow[] = employees.flatMap((e) => {
const placement = placements.get(e.id);
const orgUnitId = placement?.current ? placement.orgUnitId : null;
if (allowedUnits && (!orgUnitId || !allowedUnits.has(orgUnitId))) return [];
return [{
...e,
org_unit_id: orgUnitId,
position_number: placement?.positionNumber ?? null,
is_chief: placement?.isChief ?? false,
manager_id: lines.get(e.id)?.acting_manager_id ?? null,
}];
});
const rows = asOf ? enriched.filter((e) => statuses.includes(deriveStatusAsOf(e, asOf))) : enriched;
const rows = asOf ? employees.filter((e) => statuses.includes(deriveStatusAsOf(e, asOf))) : employees;
const columns = employeeExportColumns(lookups, managerName, dependentsCounts, asOf);
const filename = exportFilename("mitarbeiter-export", format);
@@ -123,14 +81,14 @@ function employeeExportColumns(
{ header: "Wohnsitzland", get: (e) => e.address_country },
{ header: "E-Mail", get: (e) => e.email },
{ header: "Telefon", get: (e) => e.phone },
{ header: "Bereich", get: (e) => (e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "") : "") },
{ header: "Abteilung", get: (e) => (e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "") : "") },
{ header: "Team", get: (e) => (e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "") : "") },
{ header: "Bereich", get: (e) => lookups.divisionName.get(e.division_id) ?? "" },
{ header: "Abteilung", get: (e) => (e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "") : "") },
{ header: "Team", get: (e) => (e.team_id ? (lookups.teamName.get(e.team_id) ?? "") : "") },
{ header: "Standort", get: (e) => lookups.locationName.get(e.location_id) ?? "" },
{ header: "Position", get: (e) => e.job_title },
{ header: "Vorgesetzte:r", get: (e) => (e.manager_id ? (managerName.get(e.manager_id) ?? "") : "") },
{ header: "Planstelle", get: (e) => e.position_number },
{ header: "Leitungsplanstelle", get: (e) => e.is_chief },
{ header: "Führungskraft", get: (e) => e.is_lead },
{ header: "Org-Level", get: (e) => e.org_level },
{ header: "Beschäftigungsausmaß", get: (e) => e.employment_type },
{ header: "Wochenstunden", get: (e) => e.weekly_hours },
// work_days is stored in click order (see RoleEmploymentFields), not

View File

@@ -2,25 +2,25 @@ import { NextResponse, type NextRequest } from "next/server";
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
import { EVENT_TYPE_LABELS, parseEventDateParam, parseEventType, type OrgLookups, type ReportEvent } from "@/lib/reports";
import { loadEventHistory, loadOrgLookups } from "@/lib/reports-data";
import { requireHrUser } from "@/lib/auth/require-hr";
import { withUser } from "@/lib/db";
import { requireHrUser } from "@/lib/supabase/auth";
import { createClient } from "@/lib/supabase/server";
// Full raw event-log dump — one row per employee_history entry in the
// selected period (default: current year), every event type unless one is
// picked, org columns resolved from each affected employee's current
// placement (see loadEventHistory).
export async function GET(request: NextRequest) {
const gate = await requireHrUser();
if ("denied" in gate) return gate.denied;
const supabase = await createClient();
const denied = await requireHrUser(supabase);
if (denied) return denied;
const params = request.nextUrl.searchParams;
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
const eventType = parseEventType(params.get("eventType"));
const { lookups, events } = await withUser(gate.userId, async (tx) => {
const [{ lookups }, events] = await Promise.all([
loadOrgLookups(tx),
loadEventHistory(tx, {
loadOrgLookups(supabase),
loadEventHistory(supabase, {
eventType: eventType ?? undefined,
division: params.get("division") ?? undefined,
location: params.get("location") ?? undefined,
@@ -28,8 +28,6 @@ export async function GET(request: NextRequest) {
to: parseEventDateParam(params.get("to")),
}),
]);
return { lookups, events };
});
const columns = eventExportColumns(lookups);
const filename = exportFilename(`ereignisse-${eventType ?? "alle"}`, format);
@@ -46,9 +44,9 @@ function eventExportColumns(lookups: OrgLookups): ExportColumn<ReportEvent>[] {
{ header: "Vorname", get: (e) => e.first_name },
{ header: "Nachname", get: (e) => e.last_name },
{ header: "Position", get: (e) => e.job_title },
{ header: "Bereich", get: (e) => (e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "") : "") },
{ header: "Abteilung", get: (e) => (e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "") : "") },
{ header: "Team", get: (e) => (e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "") : "") },
{ header: "Bereich", get: (e) => lookups.divisionName.get(e.division_id) ?? "" },
{ header: "Abteilung", get: (e) => (e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "") : "") },
{ header: "Team", get: (e) => (e.team_id ? (lookups.teamName.get(e.team_id) ?? "") : "") },
{ header: "Standort", get: (e) => lookups.locationName.get(e.location_id) ?? "" },
{ header: "Beschreibung", get: (e) => e.description },
];

View File

@@ -24,24 +24,23 @@ import {
type ReportRow,
} from "@/lib/reports";
import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data";
import { requireHrUser } from "@/lib/auth/require-hr";
import { withUser } from "@/lib/db";
import { requireHrUser } from "@/lib/supabase/auth";
import { createClient } from "@/lib/supabase/server";
// Exports exactly the pivot table currently on screen (same mode/measure or
// event-type/group/split/filters, read from the query string the client
// already keeps in the URL) as a flat table — one row per group, one column
// per split value if a split is active.
export async function GET(request: NextRequest) {
const gate = await requireHrUser();
if ("denied" in gate) return gate.denied;
const supabase = await createClient();
const denied = await requireHrUser(supabase);
if (denied) return denied;
const params = request.nextUrl.searchParams;
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
const mode = parseMode(params.get("mode"));
// Eine Transaktion für Nachschlagewerte und Daten: dort gilt der
// Sitzungskontext, und beide sehen denselben Lesestand.
const { rows, columns, filenameBase } = await withUser(gate.userId, async (tx) => {
const { lookups } = await loadOrgLookups(tx);
const { lookups } = await loadOrgLookups(supabase);
let rows: ReportRow[];
let columns: ExportColumn<ReportRow>[];
let filenameBase: string;
@@ -50,7 +49,7 @@ export async function GET(request: NextRequest) {
const group = parseEventGroupDimension(params.get("group"));
const split = parseEventSplitDimension(params.get("split"));
const eventType = parseEventType(params.get("eventType"));
const events = await loadEventHistory(tx, {
const events = await loadEventHistory(supabase, {
eventType: eventType ?? undefined,
division: params.get("division") ?? undefined,
location: params.get("location") ?? undefined,
@@ -65,7 +64,7 @@ export async function GET(request: NextRequest) {
const group = parseGroupDimension(params.get("group"));
const split = parseSplitDimension(params.get("split"));
const asOf = parseIsoDateParam(params.get("asOf"));
const employees = await loadSnapshotEmployees(tx, {
const employees = await loadSnapshotEmployees(supabase, {
division: params.get("division") ?? undefined,
location: params.get("location") ?? undefined,
status: params.get("status") ?? undefined,
@@ -77,11 +76,7 @@ export async function GET(request: NextRequest) {
filenameBase = `bericht-${measure}-${group}`;
}
return { rows, columns, filenameBase };
});
const filename = exportFilename(filenameBase, format);
const body = format === "xlsx" ? await toXlsx(rows, columns, "Bericht") : toCsv(rows, columns);
// TS 5.9's Uint8Array<ArrayBufferLike> vs DOM's BlobPart/ArrayBuffer<> generic
// mismatch (microsoft/TypeScript#59417) — a real Uint8Array works fine here.

View File

@@ -1,131 +0,0 @@
import { NextResponse, type NextRequest } from "next/server";
import { requireHrUser } from "@/lib/auth/require-hr";
import { withUser } from "@/lib/db";
import { bestandLaden, laden, type Ladebericht } from "@/lib/import/load";
import { dateiLesen, type ImportSheet } from "@/lib/import/parse";
import { pruefe, type Befund } from "@/lib/import/validate";
// Massenimport — Prüflauf und Übernahme über denselben Weg.
//
// Es gibt bewusst **keinen** Zwischenspeicher zwischen beiden Schritten. Die
// Oberfläche schickt die Datei zweimal: einmal mit `pruefen=1`, um den
// Bericht zu zeigen, und nach der Bestätigung noch einmal zum Übernehmen.
// Das kostet eine Übertragung und erspart serverseitigen Zustand, der
// ablaufen, vollaufen oder zwischen zwei Personen verwechselt werden kann.
//
// Beide Läufe sehen denselben Bestand, weil Prüfung und Schreiben in
// derselben Transaktion stattfinden. Zwischen „geprüft" und „geschrieben"
// passt sonst eine fremde Änderung — etwa jemand, der dieselbe Planstelle
// besetzt.
/** Bricht die Transaktion ab, ohne einen Fehler zu sein. */
class Rueckabwicklung extends Error {
constructor(readonly nutzlast: unknown) {
super("Prüflauf");
}
}
export const maxDuration = 120;
type Antwort = {
ok: boolean;
geprueft: boolean;
blaetter: string[];
fehler: Befund[];
hinweise: Befund[];
anzahl: Record<string, number>;
bericht?: Ladebericht;
meldung?: string;
};
export async function POST(request: NextRequest) {
const gate = await requireHrUser();
if ("denied" in gate) return gate.denied;
const form = await request.formData();
const nurPruefen = form.get("pruefen") === "1";
const dateien = form.getAll("datei").filter((f): f is File => f instanceof File);
if (dateien.length === 0) {
return NextResponse.json({ ok: false, meldung: "Keine Datei erhalten." }, { status: 400 });
}
// Mehrere Dateien werden zusammengesetzt: eine Mappe mit allen Blättern
// oder eine CSV je Blatt sind derselbe Vorgang.
const blaetter: ImportSheet[] = [];
const lesefehler: string[] = [];
for (const datei of dateien) {
const ergebnis = await dateiLesen(datei.name, await datei.arrayBuffer());
blaetter.push(...ergebnis.blaetter);
lesefehler.push(...ergebnis.fehler);
}
if (lesefehler.length > 0) {
return NextResponse.json(
{
ok: false,
geprueft: true,
blaetter: blaetter.map((b) => b.name),
fehler: lesefehler.map((m) => ({ blatt: "Datei", zeile: null, spalte: null, meldung: m })),
hinweise: [],
anzahl: {},
} satisfies Antwort,
{ status: 422 }
);
}
const profil = await withUser(gate.userId, (tx) =>
tx.selectFrom("profiles").select(["full_name", "email"]).where("id", "=", gate.userId).executeTakeFirst()
);
try {
const antwort = await withUser(gate.userId, async (tx) => {
const bestand = await bestandLaden(tx);
const geprueft = pruefe(blaetter, bestand);
const basis: Antwort = {
ok: geprueft.fehler.length === 0,
geprueft: true,
blaetter: blaetter.map((b) => b.name),
fehler: geprueft.fehler,
hinweise: geprueft.hinweise,
anzahl: geprueft.anzahl,
};
// Fehler oder Prüflauf: die Transaktion wird zurückgerollt. Beim
// Prüflauf hat sie trotzdem echte Abfragen gemacht — der Bericht
// beruht also auf dem tatsächlichen Bestand, nicht auf einer Kopie.
if (!basis.ok || nurPruefen) throw new Rueckabwicklung({ ...basis, geprueft: nurPruefen || !basis.ok });
const bericht = await laden(tx, geprueft.datensatz, bestand, {
userId: gate.userId,
name: profil?.full_name || profil?.email || "Unbekannt",
});
return { ...basis, geprueft: false, bericht };
});
return NextResponse.json(antwort);
} catch (err) {
if (err instanceof Rueckabwicklung) {
const nutzlast = err.nutzlast as Antwort;
return NextResponse.json(nutzlast, { status: nutzlast.ok ? 200 : 422 });
}
// Ein echter Fehler beim Schreiben. Die Transaktion ist zurückgerollt,
// es steht also nichts Halbes in der Datenbank.
return NextResponse.json(
{
ok: false,
geprueft: false,
blaetter: blaetter.map((b) => b.name),
fehler: [],
hinweise: [],
anzahl: {},
meldung:
err instanceof Error
? `Der Import wurde vollständig zurückgenommen. Grund: ${err.message}`
: "Der Import wurde vollständig zurückgenommen.",
} satisfies Antwort,
{ status: 500 }
);
}
}

View File

@@ -1,96 +0,0 @@
import ExcelJS from "exceljs";
import { NextResponse } from "next/server";
import { requireHrUser } from "@/lib/auth/require-hr";
import { exportFilename, exportResponseHeaders } from "@/lib/export";
import { BLAETTER } from "@/lib/import/schema";
// Die Vorlage entsteht aus demselben Schema wie die Prüfung.
//
// Das ist der Punkt: eine von Hand gepflegte Beispieldatei läuft dem Code
// hinterher, und dann verlangt die Vorlage eine Spalte, die es nicht mehr
// gibt — oder umgekehrt. Hier kann das nicht passieren; kommt in schema.ts
// eine Spalte dazu, steht sie beim nächsten Herunterladen drin.
export async function GET() {
const gate = await requireHrUser();
if ("denied" in gate) return gate.denied;
const mappe = new ExcelJS.Workbook();
mappe.creator = "Alpenwerk HR";
mappe.created = new Date();
const hinweise = mappe.addWorksheet("Hinweise");
hinweise.columns = [
{ header: "Blatt", width: 16 },
{ header: "Spalte", width: 26 },
{ header: "Pflicht", width: 9 },
{ header: "Format", width: 30 },
{ header: "Hinweis", width: 70 },
];
hinweise.getRow(1).font = { bold: true };
const formatText = (typ: (typeof BLAETTER)[number]["spalten"][number]["typ"]): string => {
switch (typ.art) {
case "datum":
return "Datum (31.12.2026)";
case "zahl":
return "Zahl (38,5)";
case "ganzzahl":
return "Ganze Zahl";
case "janein":
return "ja / nein";
case "liste":
return typ.werte ? `Mehrere mit Semikolon aus: ${typ.werte.join(", ")}` : "Mehrere mit Semikolon";
case "auswahl":
return typ.werte.join(" | ");
default:
return "Text";
}
};
for (const schema of BLAETTER) {
hinweise.addRow([schema.name, "", "", "", schema.zweck]).font = { bold: true };
for (const s of schema.spalten) {
hinweise.addRow([schema.name, s.name, s.pflicht ? "ja" : "", formatText(s.typ), s.hinweis]);
}
hinweise.addRow([]);
}
for (const schema of BLAETTER) {
const blatt = mappe.addWorksheet(schema.name);
blatt.columns = schema.spalten.map((s) => ({
header: s.name,
width: Math.max(12, Math.min(28, s.name.length + 4)),
}));
const kopf = blatt.getRow(1);
kopf.font = { bold: true };
kopf.eachCell((zelle, i) => {
const spalte = schema.spalten[i - 1];
if (!spalte) return;
// Pflichtspalten sichtbar markieren — sonst ist die erste Rückmeldung
// eine Fehlerliste statt eines Hinweises beim Ausfüllen.
if (spalte.pflicht) {
zelle.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFFDE7EF" } };
}
const teile = [spalte.pflicht ? "Pflichtfeld." : "Optional.", formatText(spalte.typ), spalte.hinweis].filter(Boolean);
zelle.note = teile.join("\n");
});
// Eine Beispielzeile. Alles als Text, damit Excel nicht selbst
// interpretiert — der Leser deutet die Werte ohnehin.
const beispiel = schema.spalten.map((s) => s.beispiel);
if (beispiel.some(Boolean)) {
const zeile = blatt.addRow(beispiel);
zeile.font = { italic: true, color: { argb: "FF8A8A8A" } };
zeile.eachCell((z) => {
z.numFmt = "@";
});
}
blatt.views = [{ state: "frozen", ySplit: 1 }];
}
const puffer = await mappe.xlsx.writeBuffer();
const dateiname = exportFilename("import-vorlage", "xlsx");
return new NextResponse(new Blob([puffer as BlobPart]), { headers: exportResponseHeaders(dateiname, "xlsx") });
}

65
auth.ts
View File

@@ -1,65 +0,0 @@
import "server-only";
import NextAuth from "next-auth";
import { authConfig } from "@/lib/auth/config";
import { asSystem, sql } from "@/lib/db";
// Die vollständige Anmeldung — die Fassung, die die Datenbank kennt.
//
// Aufgeteilt ist sie, weil proxy.ts nur den Teil aus lib/auth/config.ts lädt.
// Hier kommt das dazu, was einmal pro Anmeldung passieren muss: aus der
// Kennung, die Entra ausstellt, eine Kennung machen, die diese Anwendung
// versteht.
/**
* Legt die app_users-Zeile an oder frischt sie auf und liefert die Kennung,
* die überall sonst als `userId` durchgereicht wird.
*
* Die Datenbankfunktion ist SECURITY DEFINER und darf genau dieses eine:
* app_users schreiben. Für den einen Schreibvorgang, für den es noch keinen
* Sitzungskontext geben kann, ist das der kleinstmögliche Hebel — früher lag
* hier ein Dienstschlüssel, der jede Zeile jeder Tabelle lesen konnte.
*/
async function upsertAppUser(externalId: string, email: string, fullName: string | null): Promise<string> {
const row = await asSystem(async (tx) => {
const result = await sql<{ id: string }>`
select app_upsert_user(${externalId}, ${email}, ${fullName}) as id
`.execute(tx);
return result.rows[0];
});
if (!row?.id) throw new Error("app_upsert_user() lieferte keine Kennung.");
return row.id;
}
export const { handlers, auth, signIn, signOut } = NextAuth(() => {
const base = authConfig();
return {
...base,
callbacks: {
// Die Rückrufe aus der Basis **behalten**, nicht ersetzen: dort liegt
// session(), das die Kennung aus dem Token auf die Sitzung legt. Ein
// schlichtes `callbacks: { jwt }` hätte es stillschweigend entfernt.
...base.callbacks,
async jwt({ token, profile }) {
// `profile` liegt nur beim ersten Durchlauf nach der Rückkehr von Entra
// vor. Danach wird das Token nur noch weitergereicht — die Datenbank
// wird also einmal pro Anmeldung befragt, nicht einmal pro Aufruf.
if (!profile) return token;
const externalId = typeof profile.oid === "string" ? profile.oid : null;
const email = [profile.email, profile.preferred_username, profile.upn].find(
(v): v is string => typeof v === "string" && v.length > 0
);
// Lieber abbrechen als eine Sitzung ohne Kennung ausstellen: die käme
// als `null` bei withUser() an, und die Policies gäben dann konsequent
// nichts zurück — was sich als „die Anwendung ist leer" zeigt statt als
// Anmeldefehler.
if (!externalId || !email) throw new Error("Entra lieferte weder oid noch E-Mail-Adresse.");
token.uid = await upsertAppUser(externalId, email, typeof profile.name === "string" ? profile.name : null);
return token;
},
},
};
});

View File

@@ -1,140 +0,0 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { SlideOver } from "@/components/ui/SlideOver";
import { actionBadgeStyle } from "@/lib/colors";
import type { AuditChange } from "@/lib/supabase/types";
// Eine Protokollzeile zum Aufklappen.
//
// Die Liste zeigt, *dass* etwas geändert wurde; hier steht, *was*. Beides in
// der Tabelle unterzubringen ginge nicht — bei sieben geänderten Feldern
// wäre die Zeile höher als der Bildschirm.
export type AuditEintrag = {
id: string;
occurred_at: string;
actor_name: string;
action: string;
target_label: string;
target_employee_id: string | null;
details: string | null;
changes: AuditChange[] | null;
};
const zeitFormat = new Intl.DateTimeFormat("de-AT", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
timeZone: "Europe/Vienna",
});
/** Leerer Wert heisst „war nicht gesetzt“ — und das ist eine Aussage. */
function Wert({ text, art }: { text: string | null; art: "vorher" | "nachher" }) {
if (text === null || text === "") {
return <span className="text-ink-muted italic">leer</span>;
}
return <span className={art === "vorher" ? "text-ink-muted line-through decoration-ink-muted/40" : "text-ink"}>{text}</span>;
}
export function AuditDetail({ eintrag }: { eintrag: AuditEintrag }) {
const [offen, setOffen] = useState(false);
const anzahl = eintrag.changes?.length ?? 0;
return (
<>
<button
type="button"
onClick={() => setOffen(true)}
aria-haspopup="dialog"
className="w-full rounded px-2 py-1 text-left text-ink-muted hover:bg-brand-50 hover:text-ink
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
<span>{eintrag.details ?? ""}</span>
{anzahl > 0 && (
<span className="ml-2 whitespace-nowrap rounded-full bg-brand-50 px-2 py-0.5 text-[11px] font-semibold text-brand-700">
{anzahl} {anzahl === 1 ? "Feld" : "Felder"}
</span>
)}
</button>
<SlideOver
open={offen}
onClose={() => setOffen(false)}
title={eintrag.target_label}
subtitle={`${eintrag.action} · ${zeitFormat.format(new Date(eintrag.occurred_at))}`}
>
<dl className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-2 text-sm">
<dt className="font-semibold text-ink-muted">Aktion</dt>
<dd>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${actionBadgeStyle(eintrag.action)}`}>
{eintrag.action}
</span>
</dd>
<dt className="font-semibold text-ink-muted">Benutzer:in</dt>
<dd className="text-ink">{eintrag.actor_name}</dd>
<dt className="font-semibold text-ink-muted">Zeitpunkt</dt>
<dd className="tabular-nums text-ink">{zeitFormat.format(new Date(eintrag.occurred_at))}</dd>
{eintrag.target_employee_id && (
<>
<dt className="font-semibold text-ink-muted">Objekt</dt>
<dd>
<Link
href={`/employees/${eintrag.target_employee_id}`}
className="rounded font-semibold text-brand-700 hover:underline
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
{eintrag.target_label}
</Link>
</dd>
</>
)}
</dl>
{eintrag.details && (
<p className="mt-5 rounded-md bg-surface px-3 py-2 text-sm text-ink-body">{eintrag.details}</p>
)}
<h3 className="mt-6 text-sm font-bold text-ink">Geänderte Felder</h3>
{anzahl > 0 ? (
<div className="mt-2 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-[11px] font-bold uppercase tracking-wider text-ink-muted">
<th className="py-2 pr-4">Feld</th>
<th className="py-2 pr-4">Vorher</th>
<th className="py-2">Nachher</th>
</tr>
</thead>
<tbody>
{eintrag.changes!.map((c, i) => (
<tr key={i} className="border-b border-border-subtle align-top last:border-0">
<td className="py-2 pr-4 font-semibold text-ink-body">{c.feld}</td>
<td className="py-2 pr-4">
<Wert text={c.vorher} art="vorher" />
</td>
<td className="py-2">
<Wert text={c.nachher} art="nachher" />
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
// Kein Aufzählungszeichen für „nichts da“: der Grund ist wichtig,
// damit niemand einen Fehler vermutet.
<p className="mt-2 max-w-prose text-sm text-ink-muted">
Für diesen Eintrag liegen keine Feldwerte vor. Vorgänge wie Eintritt, Austritt oder Import erfassen keine
Einzelfelder und Einträge von vor der Erweiterung des Protokolls haben nur die Feldnamen behalten, nicht
die Werte. Nachliefern lässt sich das nicht.
</p>
)}
</SlideOver>
</>
);
}

View File

@@ -1,32 +0,0 @@
"use client";
import { useFormStatus } from "react-dom";
// Eigene Schaltfläche statt der Button-Komponente: Microsoft gibt für „Sign in
// with Microsoft" Fläche, Schrift und Logo vor, und eine magentafarbene
// Variante wäre nicht nur regelwidrig, sondern auch irreführend — sie sähe aus
// wie eine Aktion *in* dieser Anwendung, während sie in Wirklichkeit auf eine
// fremde Anmeldeseite springt.
export function EntraSignInButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
aria-busy={pending || undefined}
className="inline-flex w-full items-center justify-center gap-3 rounded-md border border-[#8c8c8c] bg-white px-4 py-3
text-sm font-semibold text-[#5e5e5e] transition-colors hover:bg-[#f3f3f3]
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500
disabled:cursor-not-allowed disabled:opacity-60"
>
<svg viewBox="0 0 21 21" className="h-[18px] w-[18px] shrink-0" aria-hidden="true">
<rect x="1" y="1" width="9" height="9" fill="#f25022" />
<rect x="11" y="1" width="9" height="9" fill="#7fba00" />
<rect x="1" y="11" width="9" height="9" fill="#00a4ef" />
<rect x="11" y="11" width="9" height="9" fill="#ffb900" />
</svg>
{pending ? "Weiterleitung zu Microsoft…" : "Mit Firmenkonto anmelden"}
</button>
);
}

View File

@@ -7,7 +7,6 @@ import { Avatar } from "@/components/ui/Avatar";
import { Button } from "@/components/ui/Button";
import { StatusChip } from "@/components/ui/StatusChip";
import { fmtFullName, tenure } from "@/lib/format";
import type { OpenPositionResolved } from "@/lib/positions";
import type { Database } from "@/lib/supabase/types";
import { DatenAendernPanel } from "./panels/DatenAendernPanel";
import { KarenzPanel } from "./panels/KarenzPanel";
@@ -22,37 +21,42 @@ import { StammdatenTab } from "./tabs/StammdatenTab";
import { VertragTab } from "./tabs/VertragTab";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
type Division = Database["public"]["Tables"]["divisions"]["Row"];
type Department = Database["public"]["Tables"]["departments"]["Row"];
type Team = Database["public"]["Tables"]["teams"]["Row"];
type Location = Database["public"]["Tables"]["locations"]["Row"];
type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"];
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
type NoteRow = Database["public"]["Tables"]["employee_notes"]["Row"];
type MiniEmployee = { id: string; first_name: string; last_name: string; job_title: string; status?: string };
/** Die Planstelle, die die Person heute innehat. */
type PlacementInfo = { positionNumber: string; jobTitle: string; isChief: boolean; current: boolean };
type OpenPosition = { id: string; position_number: string; title: string; team_id: string; is_lead: boolean };
type EmployeeDetailProps = {
employee: EmployeeRow;
placement: PlacementInfo | null;
breadcrumb: string;
manager: MiniEmployee | null;
/** Nur gesetzt, wenn die zuständige Leitung abwesend ist und vertreten wird. */
formalManager: MiniEmployee | null;
directReports: MiniEmployee[];
history: HistoryRow[];
dependents: Dependent[];
notes: NoteRow[];
divisions: Division[];
departments: Department[];
teams: Team[];
locations: Location[];
openPositions: OpenPositionResolved[];
openPositions: OpenPosition[];
};
type PanelType = "transfer" | "promote" | "karenz" | "daten" | "terminate" | "rehire" | null;
const TABS = ["Stammdaten", "Vertrag", "Organisation", "Historie", "HR-Notizen"] as const;
export function EmployeeDetail(props: EmployeeDetailProps) {
const { employee, placement, breadcrumb, manager, formalManager, directReports, history, dependents, notes, locations, openPositions } = props;
const { employee, manager, directReports, history, dependents, notes, divisions, departments, teams, locations } = props;
const [tab, setTab] = useState<(typeof TABS)[number]>("Stammdaten");
const [panel, setPanel] = useState<PanelType>(null);
const division = divisions.find((d) => d.id === employee.division_id);
const team = employee.team_id ? teams.find((t) => t.id === employee.team_id) : undefined;
const department = team ? departments.find((d) => d.id === team.department_id) : undefined;
const breadcrumb = [division?.name, department?.name, team?.name].filter(Boolean).join(" ") || "";
const location = locations.find((l) => l.id === employee.location_id);
const isActive = employee.status === "Aktiv" || employee.status === "Karenz";
@@ -75,17 +79,8 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
</h2>
<StatusChip status={employee.status} entryDate={employee.entry_date} absenceType={employee.absence_type} />
</div>
<p className="text-sm text-ink-body">{placement?.jobTitle ?? employee.job_title}</p>
<p className="text-xs text-ink-muted">
{breadcrumb}
{placement && (
<>
{" · Planstelle "}
{placement.positionNumber}
{placement.isChief && " (Leitung)"}
</>
)}
</p>
<p className="text-sm text-ink-body">{employee.job_title}</p>
<p className="text-xs text-ink-muted">{breadcrumb}</p>
<p className="mt-1 text-xs text-ink-muted">
Pers.-Nr. {employee.personnel_number}
{employee.status !== "Geplant" && <> · Zugehörigkeit: {tenure(employee.entry_date, employee.exit_date)}</>}
@@ -147,13 +142,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
{tab === "Stammdaten" && <StammdatenTab employee={employee} location={location} dependents={dependents} />}
{tab === "Vertrag" && <VertragTab employee={employee} />}
{tab === "Organisation" && (
<OrganisationTab
employeeId={employee.id}
manager={manager}
formalManager={formalManager}
directReports={directReports}
breadcrumb={breadcrumb}
/>
<OrganisationTab employeeId={employee.id} manager={manager} directReports={directReports} breadcrumb={breadcrumb} />
)}
{tab === "Historie" && <HistorieTab history={history} />}
{tab === "HR-Notizen" && <NotizenTab employeeId={employee.id} notes={notes} />}
@@ -163,7 +152,10 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
open={panel === "transfer"}
onClose={() => setPanel(null)}
employee={employee}
openPositions={openPositions}
divisions={divisions}
departments={departments}
teams={teams}
currentTeamId={employee.team_id}
/>
<PromotePanel open={panel === "promote"} onClose={() => setPanel(null)} employee={employee} />
<KarenzPanel open={panel === "karenz"} onClose={() => setPanel(null)} employee={employee} />

View File

@@ -6,9 +6,7 @@ import { FILTER_SELECT_CLASS } from "@/components/ui/Field";
import { SearchInput } from "@/components/ui/SearchInput";
type EmployeeFiltersProps = {
/** Der ganze Baum, in Tiefensuche-Reihenfolge. */
units: { id: string; name: string; unit_type: string }[];
depthOf: Map<string, number>;
divisions: { id: string; name: string }[];
locations: { id: string; name: string }[];
};
@@ -24,7 +22,7 @@ const STATUS_OPTIONS = [
{ value: "Ausgetreten", label: "Ausgetreten" },
] as const;
export function EmployeeFilters({ units, depthOf, locations }: EmployeeFiltersProps) {
export function EmployeeFilters({ divisions, locations }: EmployeeFiltersProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -57,24 +55,16 @@ export function EmployeeFilters({ units, depthOf, locations }: EmployeeFiltersPr
{/* aria-label rather than a visible label: the filter bar is a single
horizontal row, and each select's first option already names it on
screen. */}
{/* Der ganze Baum, nicht nur die oberste Ebene: die Auswahl greift
jeweils auf die Einheit *und alles darunter*, weshalb sich damit
auch nach einer einzelnen Abteilung oder einem Team filtern lässt.
Eingerückt statt gruppiert, weil optgroup keine Verschachtelung
kennt und die Tiefe hier beliebig ist. */}
<select
aria-label="Nach Organisationseinheit filtern"
aria-label="Nach Bereich filtern"
defaultValue={searchParams.get("division") ?? ""}
onChange={(e) => updateParam("division", e.target.value)}
className={FILTER_SELECT_CLASS}
>
<option value="">Alle Einheiten</option>
{units
.filter((u) => u.unit_type !== "Gesellschaft")
.map((u) => (
<option key={u.id} value={u.id}>
{" ".repeat(Math.max(0, (depthOf.get(u.id) ?? 1) - 1) * 3)}
{u.name}
<option value="">Alle Bereiche</option>
{divisions.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>

View File

@@ -7,51 +7,48 @@ import { Button } from "@/components/ui/Button";
import { SelectField, TextField } from "@/components/ui/Field";
import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast";
import type { OpenPositionResolved } from "@/lib/positions";
import type { Database } from "@/lib/supabase/types";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
type Division = Database["public"]["Tables"]["divisions"]["Row"];
type Department = Database["public"]["Tables"]["departments"]["Row"];
type Team = Database["public"]["Tables"]["teams"]["Row"];
type TransferPanelProps = {
open: boolean;
onClose: () => void;
employee: EmployeeRow;
openPositions: OpenPositionResolved[];
divisions: Division[];
departments: Department[];
teams: Team[];
currentTeamId: string | null;
};
// Eine Versetzung ist der Wechsel auf eine andere Planstelle — nicht mehr die
// Angabe eines Zielteams samt frei getipptem Titel. Bereich, Abteilung und
// Team ergeben sich aus der Einheit der Zielplanstelle, die neue Tätigkeit aus
// ihrem Job. Damit kann eine Versetzung gar nicht erst irgendwo landen, wo es
// keine Stelle gibt.
export function TransferPanel({ open, onClose, employee, openPositions }: TransferPanelProps) {
export function TransferPanel({ open, onClose, employee, divisions, departments, teams, currentTeamId }: TransferPanelProps) {
const { showToast } = useToast();
const router = useRouter();
const [effectiveDate, setEffectiveDate] = useState("");
const [positionId, setPositionId] = useState("");
const [divisionId, setDivisionId] = useState(employee.division_id);
const [teamId, setTeamId] = useState(currentTeamId ?? "");
const [newTitle, setNewTitle] = useState("");
const [pending, setPending] = useState(false);
const options = useMemo(
() =>
openPositions
.slice()
.sort((a, b) => a.orgLabel.localeCompare(b.orgLabel, "de") || a.title.localeCompare(b.title, "de"))
.map((p) => ({ value: p.id, label: `${p.orgLabel} · ${p.title} (${p.position_number})` })),
[openPositions]
);
const selected = openPositions.find((p) => p.id === positionId);
const teamsInDivision = useMemo(() => {
const deptIds = new Set(departments.filter((d) => d.division_id === divisionId).map((d) => d.id));
return teams.filter((t) => deptIds.has(t.department_id));
}, [departments, teams, divisionId]);
async function handleSubmit() {
if (!effectiveDate || !positionId) {
showToast("Bitte Datum und Zielplanstelle angeben.", "error");
if (!effectiveDate || !teamId) {
showToast("Bitte Datum und Zielteam angeben.", "error");
return;
}
setPending(true);
const result = await transferEmployee({
employee_id: employee.id,
effective_date: effectiveDate,
target_position_id: positionId,
new_team_id: teamId,
new_title: newTitle || undefined,
});
setPending(false);
if (result.success) {
@@ -74,7 +71,7 @@ export function TransferPanel({ open, onClose, employee, openPositions }: Transf
<Button variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<Button onClick={handleSubmit} pending={pending} disabled={options.length === 0}>
<Button onClick={handleSubmit} pending={pending}>
Versetzen
</Button>
</>
@@ -82,33 +79,31 @@ export function TransferPanel({ open, onClose, employee, openPositions }: Transf
>
<div className="flex flex-col gap-4">
<TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
{options.length === 0 ? (
<p className="rounded border border-border bg-surface p-3 text-sm text-ink-body">
Es gibt derzeit keine unbesetzte Planstelle. Eine Versetzung setzt eine freie Zielplanstelle voraus legen Sie
zuerst unter Positionen eine an.
</p>
) : (
<>
<SelectField
label="Zielplanstelle"
label="Neuer Bereich"
required
value={positionId}
onChange={setPositionId}
placeholder="Bitte wählen…"
options={options}
value={divisionId}
onChange={(v) => {
setDivisionId(v);
setTeamId("");
}}
options={divisions.map((d) => ({ value: d.id, label: d.name }))}
/>
<SelectField
label="Neues Team"
required
value={teamId}
onChange={setTeamId}
placeholder="Bitte wählen…"
options={teamsInDivision.map((t) => ({ value: t.id, label: t.name }))}
/>
<TextField
label="Neuer Titel (optional)"
value={newTitle}
onChange={setNewTitle}
placeholder={employee.job_title}
hint="Die neue Führungskraft wird automatisch anhand des Zielteams bestimmt."
/>
{selected && (
<div className="rounded border border-border bg-surface p-3 text-sm text-ink-body">
<div className="font-semibold text-ink">{selected.title}</div>
<div className="text-xs text-ink-muted">{selected.orgLabel}</div>
<div className="mt-1 text-xs text-ink-muted">
{selected.is_chief ? "Leitungsplanstelle" : "Mitarbeiterplanstelle"}
{selected.managerName ? ` · berichtet an ${selected.managerName}` : ""}
</div>
</div>
)}
</>
)}
</div>
</SlideOver>
);

View File

@@ -8,13 +8,11 @@ type MiniEmployee = { id: string; first_name: string; last_name: string; job_tit
type OrganisationTabProps = {
employeeId: string;
manager: MiniEmployee | null;
/** Nur gesetzt, wenn die zuständige Leitung abwesend ist und vertreten wird. */
formalManager: MiniEmployee | null;
directReports: MiniEmployee[];
breadcrumb: string;
};
export function OrganisationTab({ employeeId, manager, formalManager, directReports, breadcrumb }: OrganisationTabProps) {
export function OrganisationTab({ employeeId, manager, directReports, breadcrumb }: OrganisationTabProps) {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-start justify-between gap-3">
@@ -32,15 +30,7 @@ export function OrganisationTab({ employeeId, manager, formalManager, directRepo
</div>
<div>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">
{formalManager ? "Führungskraft (Vertretung)" : "Führungskraft"}
</h3>
{formalManager && (
<p className="mb-2 text-xs text-ink-muted">
Zuständig ist {formalManager.first_name} {formalManager.last_name}; während der Abwesenheit übernimmt die nächste
besetzte Ebene.
</p>
)}
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Führungskraft</h3>
{manager ? (
<Link href={`/employees/${manager.id}`} className="flex w-fit items-center gap-3 rounded border border-border p-3 hover:bg-surface">
<Avatar firstName={manager.first_name} lastName={manager.last_name} />

View File

@@ -58,11 +58,7 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
isValidSvnr(draft.svNummer, draft.birthDate || null);
const stepValid = [
// E-Mail gehört zu den Pflichtfeldern, weil die Spalte NOT NULL ist. Ohne
// die Prüfung hier bricht erst die Datenbank ab — am Ende des vierten
// Schritts, nach allen Eingaben.
Boolean(draft.firstName && draft.lastName && draft.birthDate && draft.locationId && draft.email.trim()) &&
svNummerOk,
Boolean(draft.firstName && draft.lastName && draft.birthDate && draft.locationId) && svNummerOk,
Boolean(draft.positionId && draft.besetzung),
Boolean(draft.entryDate && draft.workDays.length > 0),
true,
@@ -90,7 +86,6 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
gender: draft.gender,
birth_date: draft.birthDate,
sv_nummer: draft.svNummer || undefined,
email: draft.email.trim(),
phone: draft.phone || undefined,
position_id: draft.positionId,
location_id: draft.locationId,

View File

@@ -37,7 +37,7 @@ export function StepPerson({ draft, update, locations }: StepPersonProps) {
birthDate={draft.birthDate || null}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<TextField label="E-Mail" required type="email" value={draft.email} onChange={(email) => update({ email })} />
<TextField label="E-Mail (privat)" type="email" value={draft.email} onChange={(email) => update({ email })} />
<TextField label="Telefon" type="tel" value={draft.phone} onChange={(phone) => update({ phone })} />
</div>
<SelectField

View File

@@ -1,210 +0,0 @@
"use client";
import { useRef, useState } from "react";
import { Button } from "@/components/ui/Button";
import { CARD_CLASS } from "@/components/ui/Card";
// Der Ablauf hat bewusst zwei Schritte: prüfen, dann übernehmen.
//
// Ein Import ist nicht rückgängig zu machen. Wer 800 Zeilen schickt, soll
// vorher sehen, was entstehen würde — und bei einem Fehler die Zeilennummer
// lesen, nicht „Import fehlgeschlagen".
type Befund = { blatt: string; zeile: number | null; spalte: string | null; wert?: string; meldung: string };
type Antwort = {
ok: boolean;
geprueft: boolean;
blaetter: string[];
fehler: Befund[];
hinweise: Befund[];
anzahl: Record<string, number>;
bericht?: Record<string, number>;
meldung?: string;
};
/** Mehr als das zeigt niemand durch; der Rest steht in der Anzahl. */
const MAX_ANZEIGE = 200;
export function ImportWorkbench() {
const [dateien, setDateien] = useState<File[]>([]);
const [antwort, setAntwort] = useState<Antwort | null>(null);
const [laeuft, setLaeuft] = useState<"pruefen" | "uebernehmen" | null>(null);
const [fehlschlag, setFehlschlag] = useState<string | null>(null);
const eingabe = useRef<HTMLInputElement>(null);
async function senden(nurPruefen: boolean) {
if (dateien.length === 0) return;
setLaeuft(nurPruefen ? "pruefen" : "uebernehmen");
setFehlschlag(null);
try {
const daten = new FormData();
for (const d of dateien) daten.append("datei", d);
if (nurPruefen) daten.append("pruefen", "1");
const antwort = await fetch("/api/import", { method: "POST", body: daten });
const inhalt = (await antwort.json()) as Antwort;
setAntwort(inhalt);
} catch {
setFehlschlag("Die Datei konnte nicht übertragen werden. Bitte erneut versuchen.");
} finally {
setLaeuft(null);
}
}
function neueAuswahl(liste: FileList | null) {
setDateien(liste ? Array.from(liste) : []);
// Ein alter Bericht zu einer neuen Datei ist schlimmer als keiner.
setAntwort(null);
setFehlschlag(null);
}
const uebernommen = antwort?.bericht !== undefined;
const bereit = antwort?.ok === true && antwort.geprueft && !uebernommen;
return (
<div className="flex flex-col gap-4">
<section className={`${CARD_CLASS} p-5`}>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="text-sm font-bold text-ink">Datei wählen</h2>
<p className="mt-1 max-w-prose text-sm text-ink-muted">
Eine Excel-Mappe mit den Blättern <strong>Standorte</strong>, <strong>Organisation</strong>,{" "}
<strong>Jobkatalog</strong>, <strong>Planstellen</strong>, <strong>Personen</strong>,{" "}
<strong>Historie</strong> und <strong>Angehörige</strong> oder je Blatt eine CSV-Datei, deren Name dem
Blatt entspricht. Nicht jedes Blatt muss dabei sein.
</p>
</div>
<a
href="/api/import/template"
className="shrink-0 rounded-md border border-border px-3 py-2 text-sm font-semibold text-ink hover:bg-brand-50
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
Vorlage herunterladen
</a>
</div>
<div className="mt-4 flex flex-wrap items-center gap-3">
<input
ref={eingabe}
type="file"
multiple
accept=".xlsx,.xlsm,.csv"
onChange={(e) => neueAuswahl(e.target.files)}
className="block w-full max-w-md text-sm text-ink-muted file:mr-3 file:rounded-md file:border file:border-border
file:bg-surface file:px-3 file:py-2 file:text-sm file:font-semibold file:text-ink hover:file:bg-brand-50"
/>
<Button onClick={() => senden(true)} disabled={dateien.length === 0 || laeuft !== null}>
{laeuft === "pruefen" ? "Wird geprüft…" : "Prüfen"}
</Button>
</div>
{dateien.length > 0 && (
<p className="mt-2 text-xs text-ink-muted">
{dateien.length === 1 ? dateien[0].name : `${dateien.length} Dateien`} ausgewählt
</p>
)}
</section>
{fehlschlag && (
<p role="alert" className="rounded-md border border-danger-text/20 bg-danger-bg px-4 py-3 text-sm text-danger-text">
{fehlschlag}
</p>
)}
{antwort?.meldung && (
<p role="alert" className="rounded-md border border-danger-text/20 bg-danger-bg px-4 py-3 text-sm text-danger-text">
{antwort.meldung}
</p>
)}
{antwort && !antwort.meldung && (
<section className={`${CARD_CLASS} p-5`}>
<h2 className="text-sm font-bold text-ink">{uebernommen ? "Übernommen" : "Ergebnis der Prüfung"}</h2>
<table className="mt-3 w-full max-w-md text-sm">
<tbody>
{Object.entries(uebernommen ? antwort.bericht! : antwort.anzahl)
.filter(([, n]) => n > 0)
.map(([blatt, n]) => (
<tr key={blatt} className="border-b border-border-subtle last:border-0">
<td className="py-1.5 text-ink-body">{blatt}</td>
<td className="py-1.5 text-right font-semibold tabular-nums text-ink">{n}</td>
</tr>
))}
</tbody>
</table>
{uebernommen ? (
<p className="mt-4 text-sm text-ink-muted">
Die Daten stehen jetzt in der Anwendung. Der Vorgang ist im Audit-Log vermerkt.
</p>
) : antwort.ok ? (
<>
<p className="mt-4 text-sm text-ink-body">
Keine Beanstandung. Beim Übernehmen entsteht genau das oben Gezeigte alles in einem Zug oder gar
nichts.
</p>
<div className="mt-3">
<Button onClick={() => senden(false)} disabled={laeuft !== null || !bereit}>
{laeuft === "uebernehmen" ? "Wird übernommen…" : "Übernehmen"}
</Button>
</div>
</>
) : (
<p className="mt-4 text-sm font-semibold text-danger-text">
{antwort.fehler.length} Beanstandung{antwort.fehler.length === 1 ? "" : "en"} es wurde nichts
geschrieben.
</p>
)}
</section>
)}
{antwort && antwort.fehler.length > 0 && (
<BefundListe titel="Zu beheben" befunde={antwort.fehler} art="fehler" />
)}
{antwort && antwort.hinweise.length > 0 && (
<BefundListe titel="Hinweise" befunde={antwort.hinweise} art="hinweis" />
)}
</div>
);
}
function BefundListe({ titel, befunde, art }: { titel: string; befunde: Befund[]; art: "fehler" | "hinweis" }) {
const sichtbar = befunde.slice(0, MAX_ANZEIGE);
return (
<section className={`overflow-x-auto ${CARD_CLASS}`}>
<h2 className="px-5 pt-5 text-sm font-bold text-ink">
{titel} <span className="font-normal text-ink-muted">({befunde.length})</span>
</h2>
<table className="mt-3 w-full min-w-[640px] text-sm">
<thead>
<tr className="border-y border-border bg-surface text-left text-[11px] font-bold uppercase tracking-wider text-ink-muted">
<th className="px-5 py-2">Blatt</th>
<th className="px-3 py-2">Zeile</th>
<th className="px-3 py-2">Spalte</th>
<th className="px-3 py-2">Wert</th>
<th className="px-5 py-2">Was zu tun ist</th>
</tr>
</thead>
<tbody>
{sichtbar.map((b, i) => (
<tr key={i} className="border-b border-border-subtle last:border-0">
<td className="whitespace-nowrap px-5 py-2 text-ink-body">{b.blatt}</td>
<td className="px-3 py-2 tabular-nums text-ink-muted">{b.zeile ?? ""}</td>
<td className="px-3 py-2 text-ink-body">{b.spalte ?? ""}</td>
<td className="max-w-[16rem] truncate px-3 py-2 text-ink-muted" title={b.wert}>
{b.wert ?? ""}
</td>
<td className={`px-5 py-2 ${art === "fehler" ? "text-danger-text" : "text-ink-muted"}`}>{b.meldung}</td>
</tr>
))}
</tbody>
</table>
{befunde.length > sichtbar.length && (
<p className="px-5 py-3 text-xs text-ink-muted">
Weitere {befunde.length - sichtbar.length} nicht angezeigt. Oft hängen viele Meldungen an einer Ursache
nach der Korrektur erneut prüfen.
</p>
)}
</section>
);
}

View File

@@ -2,17 +2,22 @@
import { useState } from "react";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import type { OpenPositionResolved } from "@/lib/positions";
import { AsOfPicker } from "./AsOfPicker";
import { EmployeeTree } from "./EmployeeTree";
import { PositionTree } from "./PositionTree";
import type { OrgEmployee, OrgUnitNode, OrgVacancy } from "./types";
import { ReorgWorkbench } from "./ReorgWorkbench";
import type { OrgDepartment, OrgDivision, OrgEmployee, OrgTeam, ReorgScenarioSummary } from "./types";
type View = "ma" | "pos";
type View = "ma" | "pos" | "reo";
type OrgChartClientProps = {
employees: OrgEmployee[];
units: OrgUnitNode[];
vacancies: OrgVacancy[];
divisions: OrgDivision[];
departments: OrgDepartment[];
teams: OrgTeam[];
openPositions: OpenPositionResolved[];
reorgScenarios: ReorgScenarioSummary[];
asOf: string;
today: string;
projectedCount: number;
@@ -23,8 +28,11 @@ type OrgChartClientProps = {
export function OrgChartClient({
employees,
units,
vacancies,
divisions,
departments,
teams,
openPositions,
reorgScenarios,
asOf,
today,
projectedCount,
@@ -32,6 +40,7 @@ export function OrgChartClient({
focusId,
}: OrgChartClientProps) {
const [view, setView] = useState<View>("ma");
const isToday = asOf === today;
return (
<div className="flex flex-col gap-4">
@@ -40,14 +49,35 @@ export function OrgChartClient({
onChange={setView}
options={[
{ value: "ma", label: "Mitarbeiter" },
{ value: "pos", label: "Organisation" },
{ value: "pos", label: "Positionen" },
{ value: "reo", label: "Reorganisation" },
]}
/>
{view !== "reo" && (
<AsOfPicker asOf={asOf} today={today} projectedCount={projectedCount} historyStartsAt={historyStartsAt} />
)}
{view === "ma" && <EmployeeTree employees={employees} focusId={focusId} />}
{view === "pos" && <PositionTree employees={employees} units={units} vacancies={vacancies} />}
{view === "pos" && (
<PositionTree employees={employees} divisions={divisions} departments={departments} teams={teams} openPositions={openPositions} />
)}
{view === "reo" &&
(isToday ? (
<ReorgWorkbench employees={employees} divisions={divisions} departments={departments} teams={teams} reorgScenarios={reorgScenarios} />
) : (
// A reorg planned against a past or projected roster would be
// applied to the *live* org anyway — better to send the user back
// to today than to let them assemble moves from a roster that is
// not the one the change would hit.
<div className="rounded border border-border bg-white p-6 text-sm text-ink-body">
<p className="font-semibold text-ink">Reorganisation nur zum heutigen Stand</p>
<p className="mt-1 text-ink-muted">
Es ist ein abweichender Stichtag gewählt. Reorganisationen wirken immer auf die aktuelle Struktur wechseln
Sie zurück auf Heute, um eine zu planen.
</p>
</div>
))}
</div>
);
}

View File

@@ -2,34 +2,56 @@
import { ChevronDown, ChevronRight } from "lucide-react";
import Link from "next/link";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo, useState, type ReactNode } from "react";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import type { OpenPositionResolved } from "@/lib/positions";
import { LazyGraphOrgChart } from "./LazyGraphOrgChart";
import type { ChartNode, OrgEmployee, OrgUnitNode, OrgVacancy } from "./types";
// Die Struktursicht. Sie folgt jetzt org_units.parent_id statt einer fest
// verdrahteten Abfolge Bereich → Abteilung → Team: eine fünfte Ebene ist
// damit eine Datenfrage und keine Änderung an dieser Datei.
//
// Liste und Grafik werden aus *einem* Baum gerendert. Vorher gab es dieselbe
// Hierarchie zweimal — einmal als JSX-Schachtelung, einmal als ChartNode —
// und die beiden konnten auseinanderlaufen, ohne dass es auffiel.
import type { ChartNode, OrgDepartment, OrgDivision, OrgEmployee, OrgTeam } from "./types";
type ViewMode = "list" | "graph";
function TreeRow({
depth,
expandable,
expandedNow,
onToggle,
children,
}: {
depth: number;
expandable: boolean;
expandedNow: boolean;
onToggle: () => void;
children: ReactNode;
}) {
return (
<div className="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface" style={{ paddingLeft: depth * 24 + 8 }}>
{expandable ? (
<button type="button" onClick={onToggle} className="shrink-0 text-ink-muted">
{expandedNow ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
) : (
<span className="w-4 shrink-0" />
)}
{children}
</div>
);
}
type PositionTreeProps = {
employees: OrgEmployee[];
units: OrgUnitNode[];
vacancies: OrgVacancy[];
divisions: OrgDivision[];
departments: OrgDepartment[];
teams: OrgTeam[];
openPositions: OpenPositionResolved[];
};
export function PositionTree({ employees, units, vacancies }: PositionTreeProps) {
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(units.filter((u) => u.parent_id === null).map((u) => `unit-${u.id}`)));
export function PositionTree({ employees, divisions, departments, teams, openPositions }: PositionTreeProps) {
const [expanded, setExpanded] = useState<Set<string>>(new Set(["root"]));
const [mode, setMode] = useState<ViewMode>("list");
// useCallback-stabil: das Layout-Memo von GraphOrgChart hängt an diesen
// Referenzen, instabile Funktionen erzwängen sonst bei jedem Re-Render ein
// neues Dagre-Layout.
// useCallback-stable: GraphOrgChart's layout memo depends on these
// references, so unstable functions would force a Dagre re-layout on
// every unrelated re-render.
const toggle = useCallback((id: string) => {
setExpanded((prev) => {
const next = new Set(prev);
@@ -38,178 +60,216 @@ export function PositionTree({ employees, units, vacancies }: PositionTreeProps)
return next;
});
}, []);
const isExpanded = useCallback((id: string) => expanded.has(id), [expanded]);
const tree = useMemo(() => buildUnitTree(units, employees, vacancies), [units, employees, vacancies]);
return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<SegmentedControl<ViewMode>
value={mode}
onChange={setMode}
options={[
{ value: "list", label: "Liste" },
{ value: "graph", label: "Grafisch" },
]}
/>
</div>
{mode === "graph" ? (
<LazyGraphOrgChart tree={tree} isExpanded={isExpanded} onToggle={toggle} />
) : (
<div className="rounded border border-border bg-white p-4">
{tree.map((node) => (
<ListNode key={node.id} node={node} depth={0} expanded={expanded} onToggle={toggle} />
))}
</div>
)}
</div>
);
}
function ListNode({
node,
depth,
expanded,
onToggle,
}: {
node: ChartNode;
depth: number;
expanded: Set<string>;
onToggle: (id: string) => void;
}) {
const expandable = node.children.length > 0;
const open = expanded.has(node.id);
return (
<div>
<div
className="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface"
style={{ paddingLeft: depth * 24 + 8 }}
>
{expandable ? (
<button
type="button"
onClick={() => onToggle(node.id)}
className="shrink-0 text-ink-muted"
aria-expanded={open}
aria-label={open ? `${node.label} zuklappen` : `${node.label} aufklappen`}
>
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
) : (
<span className="w-4 shrink-0" />
)}
{node.href ? (
<Link
href={node.href}
className={node.kind === "vacancy" ? "text-sm text-brand-700 hover:underline" : "text-sm text-ink-body hover:text-brand-700 hover:underline"}
>
{node.label}
</Link>
) : (
<span className={node.kind === "group" ? "text-sm text-ink" : "text-sm font-semibold text-ink"}>{node.label}</span>
)}
{node.sublabel && (
<span className={node.vacant ? "text-xs font-semibold text-warning-text" : "text-xs text-ink-muted"}>{node.sublabel}</span>
)}
</div>
{open && node.children.map((child) => <ListNode key={child.id} node={child} depth={depth + 1} expanded={expanded} onToggle={onToggle} />)}
</div>
);
}
/**
* Je Einheit: die Leitung als Kopfzeile, darunter die untergeordneten
* Einheiten, dann die eigenen Mitarbeitenden nach Tätigkeit gruppiert und
* zuletzt die unbesetzten Planstellen.
*/
export function buildUnitTree(units: OrgUnitNode[], employees: OrgEmployee[], vacancies: OrgVacancy[]): ChartNode[] {
const childUnits = new Map<string | null, OrgUnitNode[]>();
for (const u of units) {
const list = childUnits.get(u.parent_id) ?? [];
list.push(u);
childUnits.set(u.parent_id, list);
}
for (const list of childUnits.values()) list.sort((a, b) => a.org_number.localeCompare(b.org_number));
const chiefOf = new Map<string, OrgEmployee>();
const staffOf = new Map<string, OrgEmployee[]>();
const ceo = employees.find((e) => e.org_level === 0) ?? null;
const { divisionHeadByDivision, teamLeadByTeam, icsByTeamAndTitle } = useMemo(() => {
const divisionHeadByDivision = new Map<string, OrgEmployee>();
const teamLeadByTeam = new Map<string, OrgEmployee>();
const icsByTeamAndTitle = new Map<string, Map<string, OrgEmployee[]>>();
for (const e of employees) {
if (e.is_chief) chiefOf.set(e.org_unit_id, e);
else {
const list = staffOf.get(e.org_unit_id) ?? [];
list.push(e);
staffOf.set(e.org_unit_id, list);
if (e.org_level === 1 && e.division_id) divisionHeadByDivision.set(e.division_id, e);
if (e.is_lead && e.team_id) teamLeadByTeam.set(e.team_id, e);
if (!e.is_lead && e.org_level === 3 && e.team_id) {
if (!icsByTeamAndTitle.has(e.team_id)) icsByTeamAndTitle.set(e.team_id, new Map());
const byTitle = icsByTeamAndTitle.get(e.team_id)!;
if (!byTitle.has(e.job_title)) byTitle.set(e.job_title, []);
byTitle.get(e.job_title)!.push(e);
}
}
const vacantOf = new Map<string, OrgVacancy[]>();
for (const v of vacancies) {
const list = vacantOf.get(v.org_unit_id) ?? [];
list.push(v);
vacantOf.set(v.org_unit_id, list);
return { divisionHeadByDivision, teamLeadByTeam, icsByTeamAndTitle };
}, [employees]);
const openByTeam = useMemo(() => {
const map = new Map<string, OpenPositionResolved[]>();
for (const p of openPositions) {
if (!map.has(p.team_id)) map.set(p.team_id, []);
map.get(p.team_id)!.push(p);
}
return map;
}, [openPositions]);
function build(unit: OrgUnitNode): ChartNode {
const key = `unit-${unit.id}`;
const chief = chiefOf.get(unit.id);
// Mirrors the JSX walk below into the generic ChartNode shape for graph
// mode — same synthetic ids ("root", div-*, dept-*, team-*, teamKey-title)
// the list already uses as `expanded` keys, so one Set drives both.
const chartTree = useMemo<ChartNode[]>(() => {
if (!ceo) return [];
const subUnits = (childUnits.get(unit.id) ?? []).map(build);
function buildTeam(team: OrgTeam): ChartNode {
const teamKey = `team-${team.id}`;
const lead = teamLeadByTeam.get(team.id);
const icsByTitle = icsByTeamAndTitle.get(team.id) ?? new Map<string, OrgEmployee[]>();
const openForTeam = openByTeam.get(team.id) ?? [];
// Nach Tätigkeit gruppiert: dreissig Zeilen „Maschinenbediener:in" sagen
// weniger als eine Zeile „Maschinenbediener:in — 30x besetzt".
const byTitle = new Map<string, OrgEmployee[]>();
for (const e of staffOf.get(unit.id) ?? []) {
const list = byTitle.get(e.job_title) ?? [];
list.push(e);
byTitle.set(e.job_title, list);
}
const titleGroups: ChartNode[] = Array.from(byTitle.entries())
.sort(([a], [b]) => a.localeCompare(b, "de"))
.map(([title, people]) => ({
id: `${key}-job-${title}`,
kind: "group" as const,
const titleGroups: ChartNode[] = Array.from(icsByTitle.entries()).map(([title, people]) => ({
id: `${teamKey}-${title}`,
kind: "group",
label: title,
sublabel: `${people.length}x besetzt`,
children: people
.slice()
.sort((a, b) => a.last_name.localeCompare(b.last_name, "de"))
.map((p) => ({
children: people.map((p) => ({
id: p.id,
kind: "person" as const,
kind: "person",
label: `${p.first_name} ${p.last_name}`,
href: `/employees/${p.id}`,
avatar: { firstName: p.first_name, lastName: p.last_name },
absent: p.absent,
badge: p.absent ? (p.absence_type ?? "Abwesend") : undefined,
children: [],
})),
}));
const vacancyNodes: ChartNode[] = (vacantOf.get(unit.id) ?? [])
.filter((v) => !v.is_chief) // die Leitungsvakanz steht schon in der Kopfzeile
.sort((a, b) => a.position_number.localeCompare(b.position_number))
.map((v) => ({
id: `vac-${v.position_id}`,
kind: "vacancy" as const,
label: `+ ${v.position_number} · ${v.job_title}`,
const vacancyNodes: ChartNode[] = openForTeam.map((p) => ({
id: `vac-${p.id}`,
kind: "vacancy",
label: `${p.position_number} · ${p.title}`,
href: "/positions",
vacant: true,
children: [],
}));
return {
id: key,
id: teamKey,
kind: "role",
label: `${unit.org_number} · ${unit.name}`,
sublabel: chief
? `Leitung: ${chief.first_name} ${chief.last_name}${chief.absent ? " (abwesend)" : ""}`
: "Leitung vakant",
vacant: !chief,
children: [...subUnits, ...titleGroups, ...vacancyNodes],
label: `Teamleitung ${team.name}`,
sublabel: lead ? `besetzt: ${lead.first_name} ${lead.last_name}` : "vakant",
vacant: !lead,
children: [...titleGroups, ...vacancyNodes],
};
}
return (childUnits.get(null) ?? []).map(build);
function buildDept(dept: OrgDepartment): ChartNode {
return {
id: `dept-${dept.id}`,
kind: "group",
label: `${dept.org_number} · ${dept.name}`,
children: teams.filter((t) => t.department_id === dept.id).map(buildTeam),
};
}
function buildDivision(div: OrgDivision): ChartNode {
const head = divisionHeadByDivision.get(div.id);
return {
id: `div-${div.id}`,
kind: "role",
label: `Bereichsleitung ${div.name}`,
sublabel: head ? `besetzt: ${head.first_name} ${head.last_name}` : "vakant",
vacant: !head,
children: departments.filter((d) => d.division_id === div.id).map(buildDept),
};
}
return [
{
id: "root",
kind: "role",
label: "Geschäftsführung",
sublabel: `besetzt: ${ceo.first_name} ${ceo.last_name}`,
children: divisions.map(buildDivision),
},
];
}, [ceo, divisionHeadByDivision, teamLeadByTeam, icsByTeamAndTitle, openByTeam, divisions, departments, teams]);
return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<SegmentedControl<ViewMode> value={mode} onChange={setMode} options={[{ value: "list", label: "Liste" }, { value: "graph", label: "Grafisch" }]} />
</div>
{mode === "graph" ? (
<LazyGraphOrgChart tree={chartTree} isExpanded={isExpanded} onToggle={toggle} />
) : (
<div className="rounded border border-border bg-white p-4">
{ceo && (
<TreeRow depth={0} expandable expandedNow={expanded.has("root")} onToggle={() => toggle("root")}>
<span className="text-sm font-semibold text-ink">Geschäftsführung</span>
<span className="text-xs text-ink-muted">
besetzt: {ceo.first_name} {ceo.last_name}
</span>
</TreeRow>
)}
{expanded.has("root") &&
divisions.map((div) => {
const head = divisionHeadByDivision.get(div.id);
const divKey = `div-${div.id}`;
return (
<div key={div.id}>
<TreeRow depth={1} expandable expandedNow={expanded.has(divKey)} onToggle={() => toggle(divKey)}>
<span className="text-sm font-semibold text-ink">Bereichsleitung {div.name}</span>
<span className="text-xs text-ink-muted">{head ? `besetzt: ${head.first_name} ${head.last_name}` : "vakant"}</span>
</TreeRow>
{expanded.has(divKey) &&
departments
.filter((d) => d.division_id === div.id)
.map((dept) => {
const deptKey = `dept-${dept.id}`;
return (
<div key={dept.id}>
<TreeRow depth={2} expandable expandedNow={expanded.has(deptKey)} onToggle={() => toggle(deptKey)}>
<span className="text-sm text-ink">
{dept.org_number} · {dept.name}
</span>
</TreeRow>
{expanded.has(deptKey) &&
teams
.filter((t) => t.department_id === dept.id)
.map((team) => {
const teamKey = `team-${team.id}`;
const lead = teamLeadByTeam.get(team.id);
const icsByTitle = icsByTeamAndTitle.get(team.id) ?? new Map<string, OrgEmployee[]>();
const openForTeam = openByTeam.get(team.id) ?? [];
return (
<div key={team.id}>
<TreeRow depth={3} expandable expandedNow={expanded.has(teamKey)} onToggle={() => toggle(teamKey)}>
<span className="text-sm text-ink">Teamleitung {team.name}</span>
<span className="text-xs text-ink-muted">
{lead ? `besetzt: ${lead.first_name} ${lead.last_name}` : "vakant"}
</span>
</TreeRow>
{expanded.has(teamKey) && (
<>
{Array.from(icsByTitle.entries()).map(([title, people]) => {
const groupKey = `${teamKey}-${title}`;
return (
<div key={title}>
<TreeRow
depth={4}
expandable={people.length > 0}
expandedNow={expanded.has(groupKey)}
onToggle={() => toggle(groupKey)}
>
<span className="text-sm text-ink">{title}</span>
<span className="text-xs text-ink-muted">{people.length}x besetzt</span>
</TreeRow>
{expanded.has(groupKey) &&
people.map((p) => (
<div key={p.id} className="py-1" style={{ paddingLeft: 5 * 24 + 8 }}>
<Link href={`/employees/${p.id}`} className="text-sm text-ink-body hover:text-brand-700 hover:underline">
{p.first_name} {p.last_name}
</Link>
</div>
))}
</div>
);
})}
{openForTeam.map((p) => (
<Link
key={p.id}
href="/positions"
className="block border-l-2 border-dashed border-brand-200 py-1 text-sm text-brand-700 hover:underline"
style={{ paddingLeft: 4 * 24 + 8 }}
>
+ {p.position_number} · {p.title}
</Link>
))}
</>
)}
</div>
);
})}
</div>
);
})}
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,404 @@
"use client";
import { RotateCcw, X } from "lucide-react";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { applyReorg, undoReorg, type ReorgMovePayload } from "@/actions/reorg";
import { Button } from "@/components/ui/Button";
import { Field, SelectField, TextField } from "@/components/ui/Field";
import { Lookup } from "@/components/ui/Lookup";
import { useToast } from "@/components/ui/Toast";
import { fmtDate } from "@/lib/format";
import type { OrgDepartment, OrgDivision, OrgEmployee, OrgTeam, ReorgScenarioSummary } from "./types";
type ChangeKind = "emp" | "team" | "abt" | "dept";
type PendingMove = {
id: string;
kind: ChangeKind;
label: string;
employeeIds: string[];
targetTeamId: string;
targetTeamLabel: string;
};
const KIND_LABELS: Record<ChangeKind, string> = {
emp: "Mitarbeiter:in(nen)",
team: "Ganzes Team",
abt: "Ganze Abteilung",
dept: "Ganzer Bereich",
};
type ReorgWorkbenchProps = {
employees: OrgEmployee[];
divisions: OrgDivision[];
departments: OrgDepartment[];
teams: OrgTeam[];
reorgScenarios: ReorgScenarioSummary[];
};
export function ReorgWorkbench({ employees, divisions, departments, teams, reorgScenarios }: ReorgWorkbenchProps) {
const { showToast } = useToast();
const router = useRouter();
const [name, setName] = useState("");
const [effectiveDate, setEffectiveDate] = useState("");
const [changeType, setChangeType] = useState<ChangeKind>("emp");
const [selectedEmployees, setSelectedEmployees] = useState<OrgEmployee[]>([]);
const [sourceTeamId, setSourceTeamId] = useState("");
const [sourceDeptId, setSourceDeptId] = useState("");
const [sourceDivisionId, setSourceDivisionId] = useState("");
const [targetDivisionId, setTargetDivisionId] = useState("");
const [targetTeamId, setTargetTeamId] = useState("");
const [pendingMoves, setPendingMoves] = useState<PendingMove[]>([]);
const [applying, setApplying] = useState(false);
const [undoingId, setUndoingId] = useState<string | null>(null);
const divisionById = useMemo(() => new Map(divisions.map((d) => [d.id, d])), [divisions]);
const departmentById = useMemo(() => new Map(departments.map((d) => [d.id, d])), [departments]);
const teamById = useMemo(() => new Map(teams.map((t) => [t.id, t])), [teams]);
const teamDivisionId = useMemo(() => {
const deptDivision = new Map(departments.map((d) => [d.id, d.division_id]));
const m = new Map<string, string>();
for (const t of teams) m.set(t.id, deptDivision.get(t.department_id) ?? "");
return m;
}, [teams, departments]);
const teamsInTargetDivision = useMemo(
() => teams.filter((t) => teamDivisionId.get(t.id) === targetDivisionId),
[teams, teamDivisionId, targetDivisionId]
);
async function searchLocalEmployees(query: string): Promise<OrgEmployee[]> {
const q = query.trim().toLowerCase();
if (q.length < 2) return [];
return employees
.filter(
(e) =>
!selectedEmployees.some((s) => s.id === e.id) &&
(`${e.first_name} ${e.last_name}`.toLowerCase().includes(q) || e.job_title.toLowerCase().includes(q))
)
.slice(0, 20);
}
function resolveEmployeeIds(): string[] {
if (changeType === "emp") return selectedEmployees.map((e) => e.id);
if (changeType === "team") return employees.filter((e) => e.team_id === sourceTeamId).map((e) => e.id);
if (changeType === "abt") {
const teamIds = new Set(teams.filter((t) => t.department_id === sourceDeptId).map((t) => t.id));
return employees.filter((e) => e.team_id && teamIds.has(e.team_id)).map((e) => e.id);
}
return employees.filter((e) => e.division_id === sourceDivisionId).map((e) => e.id);
}
function sourceLabel(): string {
if (changeType === "emp") return `${selectedEmployees.length} Mitarbeiter:in(nen)`;
if (changeType === "team") return `Team ${teamById.get(sourceTeamId)?.name ?? ""}`;
if (changeType === "abt") return `Abteilung ${departmentById.get(sourceDeptId)?.name ?? ""}`;
return `Bereich ${divisionById.get(sourceDivisionId)?.name ?? ""}`;
}
function handleAddMove() {
const employeeIds = resolveEmployeeIds();
if (employeeIds.length === 0) {
showToast("Keine Mitarbeiter:innen in der Auswahl gefunden.", "error");
return;
}
if (!targetTeamId) {
showToast("Bitte ein Ziel-Team wählen.", "error");
return;
}
const targetTeam = teamById.get(targetTeamId);
setPendingMoves((prev) => [
...prev,
{
id: crypto.randomUUID(),
kind: changeType,
label: sourceLabel(),
employeeIds,
targetTeamId,
targetTeamLabel: targetTeam?.name ?? "",
},
]);
setSelectedEmployees([]);
setSourceTeamId("");
setSourceDeptId("");
setSourceDivisionId("");
}
function removeMove(id: string) {
setPendingMoves((prev) => prev.filter((m) => m.id !== id));
}
const divisionBefore = useMemo(() => {
const m = new Map<string, number>();
for (const e of employees) m.set(e.division_id, (m.get(e.division_id) ?? 0) + 1);
return m;
}, [employees]);
const divisionDelta = useMemo(() => {
const m = new Map<string, number>();
const employeeById = new Map(employees.map((e) => [e.id, e]));
for (const move of pendingMoves) {
const targetDivId = teamDivisionId.get(move.targetTeamId);
for (const empId of move.employeeIds) {
const emp = employeeById.get(empId);
if (!emp || !targetDivId || emp.division_id === targetDivId) continue;
m.set(emp.division_id, (m.get(emp.division_id) ?? 0) - 1);
m.set(targetDivId, (m.get(targetDivId) ?? 0) + 1);
}
}
return m;
}, [pendingMoves, employees, teamDivisionId]);
async function handleApply() {
if (!name || !effectiveDate || pendingMoves.length === 0) {
showToast("Bitte Name, Datum und mindestens eine Änderung angeben.", "error");
return;
}
setApplying(true);
const moves: ReorgMovePayload[] = pendingMoves.map((m) => ({
kind: m.kind,
label: m.label,
employee_ids: m.employeeIds,
target_team_id: m.targetTeamId,
}));
const result = await applyReorg({ name, effective_date: effectiveDate, moves });
setApplying(false);
if (result.success) {
showToast("Reorganisation durchgeführt.");
setPendingMoves([]);
setName("");
setEffectiveDate("");
router.refresh();
} else {
showToast(result.error ?? "Fehler bei der Reorganisation.", "error");
}
}
async function handleUndo(scenarioId: string) {
setUndoingId(scenarioId);
const result = await undoReorg({ scenario_id: scenarioId });
setUndoingId(null);
if (result.success) {
showToast("Reorganisation rückgängig gemacht.");
router.refresh();
} else {
showToast(result.error ?? "Fehler beim Rückgängigmachen.", "error");
}
}
return (
<div className="flex flex-col gap-4">
<div className="rounded border border-border bg-white p-4">
<h2 className="mb-3 text-sm font-bold text-ink">Neue Reorganisation</h2>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<TextField label="Name der Reorganisation" required value={name} onChange={setName} />
<TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
</div>
<fieldset className="mt-4">
<legend className="sr-only">Art der Änderung</legend>
<div className="flex flex-wrap gap-2">
{(Object.keys(KIND_LABELS) as ChangeKind[]).map((kind) => (
<button
key={kind}
type="button"
aria-pressed={changeType === kind}
onClick={() => setChangeType(kind)}
className={`rounded px-3 py-1.5 text-sm font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
changeType === kind ? "bg-brand-500 text-white" : "border border-border text-ink-body hover:bg-surface"
}`}
>
{KIND_LABELS[kind]}
</button>
))}
</div>
</fieldset>
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
{changeType === "emp" && (
<div className="flex flex-col gap-2">
<Field label="Quelle: Mitarbeiter:innen">
{(p) => (
<Lookup<OrgEmployee>
{...p}
placeholder="Mitarbeiter:in suchen…"
onSearch={searchLocalEmployees}
onSelect={(e) => setSelectedEmployees((prev) => [...prev, e])}
renderResult={(e) => (
<div>
<div className="font-semibold text-ink">
{e.first_name} {e.last_name}
</div>
<div className="text-xs text-ink-muted">{e.job_title}</div>
</div>
)}
/>
)}
</Field>
{selectedEmployees.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selectedEmployees.map((e) => (
<span key={e.id} className="flex items-center gap-1 rounded-full bg-brand-100 px-2.5 py-1 text-xs font-semibold text-brand-700">
{e.first_name} {e.last_name}
<button
type="button"
aria-label={`${e.first_name} ${e.last_name} aus der Auswahl entfernen`}
onClick={() => setSelectedEmployees((prev) => prev.filter((s) => s.id !== e.id))}
className="rounded focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
</div>
)}
{changeType === "team" && (
<SelectField
label="Quelle: Team"
value={sourceTeamId}
onChange={setSourceTeamId}
placeholder="Team wählen…"
options={teams.map((t) => ({ value: t.id, label: t.name }))}
/>
)}
{changeType === "abt" && (
<SelectField
label="Quelle: Abteilung"
value={sourceDeptId}
onChange={setSourceDeptId}
placeholder="Abteilung wählen…"
options={departments.map((d) => ({ value: d.id, label: d.name }))}
/>
)}
{changeType === "dept" && (
<SelectField
label="Quelle: Bereich"
value={sourceDivisionId}
onChange={setSourceDivisionId}
placeholder="Bereich wählen…"
options={divisions.map((d) => ({ value: d.id, label: d.name }))}
/>
)}
</div>
<div className="flex flex-col gap-2">
<SelectField
label="Ziel-Bereich"
required
value={targetDivisionId}
onChange={(v) => {
setTargetDivisionId(v);
setTargetTeamId("");
}}
placeholder="Ziel-Bereich wählen…"
options={divisions.map((d) => ({ value: d.id, label: d.name }))}
/>
<SelectField
label="Ziel-Team"
required
value={targetTeamId}
onChange={setTargetTeamId}
placeholder="Ziel-Team wählen…"
options={teamsInTargetDivision.map((t) => ({ value: t.id, label: t.name }))}
/>
</div>
</div>
<Button variant="secondary" onClick={handleAddMove} className="mt-4">
+ Zur Reorganisation hinzufügen
</Button>
</div>
{pendingMoves.length > 0 && (
<div className="rounded border border-border bg-white p-4">
<h2 className="mb-3 text-sm font-bold text-ink">Geplante Änderungen ({pendingMoves.length})</h2>
<ul className="mb-4 flex flex-col divide-y divide-border">
{pendingMoves.map((m) => (
<li key={m.id} className="flex items-center justify-between py-2 text-sm">
<div>
<span className="mr-2 rounded-full bg-purple-bg px-2 py-0.5 text-xs font-semibold text-purple-text">{KIND_LABELS[m.kind]}</span>
<span className="text-ink">
{m.label} {m.targetTeamLabel}
</span>
<span className="ml-2 text-xs text-ink-muted">({m.employeeIds.length} Mitarbeiter:innen)</span>
</div>
<Button
variant="icon"
onClick={() => removeMove(m.id)}
aria-label={`${m.label} aus der Reorganisation entfernen`}
className="hover:!text-danger-solid"
>
<X className="h-4 w-4" />
</Button>
</li>
))}
</ul>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Auswirkung auf Headcount</h3>
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-ink-muted">
<th className="py-1 pr-3">Bereich</th>
<th className="py-1 pr-3">Vorher</th>
<th className="py-1 pr-3">Nachher</th>
<th className="py-1 pr-3">Δ</th>
</tr>
</thead>
<tbody>
{Array.from(divisionDelta.entries())
.filter(([, delta]) => delta !== 0)
.map(([divId, delta]) => {
const before = divisionBefore.get(divId) ?? 0;
return (
<tr key={divId} className="border-t border-border">
<td className="py-1.5 pr-3 text-ink">{divisionById.get(divId)?.name}</td>
<td className="py-1.5 pr-3 text-ink-body">{before}</td>
<td className="py-1.5 pr-3 text-ink-body">{before + delta}</td>
<td className={`py-1.5 pr-3 font-semibold ${delta > 0 ? "text-success-text" : "text-danger-text"}`}>
{delta > 0 ? `+${delta}` : delta}
</td>
</tr>
);
})}
</tbody>
</table>
<div className="mt-4 flex flex-wrap gap-2">
<Button variant="secondary" onClick={() => setPendingMoves([])}>
Verwerfen
</Button>
<Button onClick={handleApply} pending={applying}>
Reorganisation durchführen
</Button>
</div>
</div>
)}
{reorgScenarios.length > 0 && (
<div className="rounded border border-border bg-white p-4">
<h2 className="mb-3 text-sm font-bold text-ink"> Durchgeführte Reorganisationen rückgängig machbar</h2>
<ul className="flex flex-col divide-y divide-border">
{reorgScenarios.map((s) => (
<li key={s.id} className="flex items-center justify-between py-2 text-sm">
<div>
<span className="font-semibold text-ink">{s.name}</span>
<span className="ml-2 text-xs text-ink-muted">
wirksam ab {fmtDate(s.effective_date)} · durchgeführt am {fmtDate(s.applied_at)}
</span>
</div>
<Button variant="secondary" size="sm" onClick={() => handleUndo(s.id)} pending={undoingId === s.id}>
<RotateCcw className="h-3.5 w-3.5" />
Rückgängig machen
</Button>
</li>
))}
</ul>
</div>
)}
</div>
);
}

View File

@@ -15,30 +15,16 @@ export type OrgEmployee = {
/** This person is on a long-term absence as of the chart's date. */
absent: boolean;
absence_type: string | null;
/** Die Einheit der Planstelle — die einzige Verortung, die es noch gibt. */
org_unit_id: string;
/** Diese Planstelle führt ihre Einheit (SAP-OM: A012). */
is_chief: boolean;
position_id: string;
position_number: string;
team_id: string | null;
division_id: string;
is_lead: boolean;
org_level: number;
};
/** Eine Planstelle, die am Stichtag niemand innehat. */
export type OrgVacancy = {
position_id: string;
position_number: string;
job_title: string;
org_unit_id: string;
is_chief: boolean;
};
export type OrgUnitNode = {
id: string;
org_number: string;
name: string;
parent_id: string | null;
unit_type: "Gesellschaft" | "Bereich" | "Abteilung" | "Team";
};
export type OrgDivision = { id: string; org_number: string; name: string };
export type OrgDepartment = { id: string; org_number: string; name: string; division_id: string };
export type OrgTeam = { id: string; org_number: string; name: string; department_id: string };
export type ReorgScenarioSummary = { id: string; name: string; effective_date: string; applied: boolean; applied_at: string | null };
// Generic tree shape both EmployeeTree and PositionTree map their own data
// into for the graphical (React Flow + Dagre) view — see GraphOrgChart.

View File

@@ -2,64 +2,51 @@
import { useRouter } from "next/navigation";
import { useState } from "react";
import { createPosition } from "@/actions/positions";
import { createPosition, searchSuperiors, type SuperiorSearchResult } from "@/actions/positions";
import { Button } from "@/components/ui/Button";
import { SelectField, TextField } from "@/components/ui/Field";
import { Field, SelectField, TextField } from "@/components/ui/Field";
import { Lookup } from "@/components/ui/Lookup";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import { todayIso } from "@/lib/format";
import type { Database } from "@/lib/supabase/types";
export type UnitOption = { id: string; name: string; unit_type: string; depth: number; hasChief: boolean };
type Team = Database["public"]["Tables"]["teams"]["Row"];
// Eine Planstelle gehört zu genau einer Organisationseinheit — mehr braucht
// es nicht. Vorher musste hier eine vorgesetzte Person gesucht werden; die
// ergibt sich jetzt aus der Einheit, und die Frage kann gar nicht mehr falsch
// beantwortet werden.
export function CreatePositionModal({
open,
onClose,
units,
}: {
open: boolean;
onClose: () => void;
units: UnitOption[];
}) {
export function CreatePositionModal({ open, onClose, teams }: { open: boolean; onClose: () => void; teams: Team[] }) {
const { showToast } = useToast();
const router = useRouter();
const [jobTitle, setJobTitle] = useState("");
const [orgUnitId, setOrgUnitId] = useState("");
const [isChief, setIsChief] = useState(false);
const [title, setTitle] = useState("");
const [isLead, setIsLead] = useState(false);
const [superior, setSuperior] = useState<SuperiorSearchResult | null>(null);
const [teamId, setTeamId] = useState("");
const [validFrom, setValidFrom] = useState(todayIso);
const [pending, setPending] = useState(false);
const unit = units.find((u) => u.id === orgUnitId);
// Der Unique-Index lässt nur eine gültige Leitung je Einheit zu. Das hier
// vorwegzunehmen erspart eine Fehlermeldung, die in der Oberfläche nichts
// erklärt.
const chiefTaken = Boolean(unit?.hasChief);
function reset() {
setJobTitle("");
setOrgUnitId("");
setIsChief(false);
setTitle("");
setIsLead(false);
setSuperior(null);
setTeamId("");
setValidFrom(todayIso());
}
async function handleSubmit() {
if (!jobTitle || !orgUnitId || !validFrom) {
if (!title || !superior || !validFrom || (isLead && !teamId)) {
showToast("Bitte alle Pflichtfelder ausfüllen.", "error");
return;
}
setPending(true);
const result = await createPosition({
org_unit_id: orgUnitId,
job_title: jobTitle,
is_chief: isChief && !chiefTaken,
title,
superior_employee_id: superior.id,
is_lead: isLead,
team_id: isLead ? teamId : undefined,
valid_from: validFrom,
});
setPending(false);
if (result.success) {
showToast("Planstelle angelegt.");
showToast("Position ausgeschrieben.");
router.refresh();
onClose();
reset();
@@ -72,46 +59,77 @@ export function CreatePositionModal({
<Modal
open={open}
onClose={onClose}
title="Planstelle anlegen"
title="Position ausschreiben"
footer={
<>
<Button variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<Button onClick={handleSubmit} pending={pending}>
Anlegen
Ausschreiben
</Button>
</>
}
>
<div className="flex flex-col gap-4">
<SelectField
label="Organisationseinheit"
required
value={orgUnitId}
onChange={(v) => {
setOrgUnitId(v);
setIsChief(false);
}}
placeholder="Bitte wählen…"
options={units.map((u) => ({
value: u.id,
label: `${" ".repeat(u.depth * 3)}${u.name} (${u.unit_type})`,
}))}
/>
<TextField
label="Tätigkeit"
required
value={jobTitle}
onChange={setJobTitle}
hint="Bestehende Tätigkeiten werden wiederverwendet; ein neuer Name legt einen Katalogeintrag an."
/>
<TextField label="Titel" required value={title} onChange={setTitle} />
<label className="flex items-center gap-2 text-sm text-ink-body">
<input type="checkbox" checked={isChief} disabled={!unit || chiefTaken} onChange={(e) => setIsChief(e.target.checked)} />
Leitungsplanstelle für diese Einheit
<input
type="checkbox"
checked={isLead}
onChange={(e) => {
setIsLead(e.target.checked);
setSuperior(null);
}}
/>
Führungsposition (Teamleitung)
</label>
{chiefTaken && (
<p className="-mt-2 text-xs text-ink-muted">Für {unit?.name} besteht bereits eine Leitungsplanstelle.</p>
{!superior ? (
<Field label={isLead ? "Übergeordnete Bereichsleitung" : "Übergeordnete Teamleitung"} required>
{(p) => (
<Lookup<SuperiorSearchResult>
{...p}
placeholder="Name oder Titel…"
onSearch={(q) => searchSuperiors(q, isLead)}
onSelect={setSuperior}
renderResult={(r) => (
<div>
<div className="font-semibold text-ink">
{r.first_name} {r.last_name}
</div>
<div className="text-xs text-ink-muted">{r.job_title}</div>
</div>
)}
/>
)}
</Field>
) : (
<div>
<p className="mb-1 block text-sm font-semibold text-ink">
{isLead ? "Übergeordnete Bereichsleitung" : "Übergeordnete Teamleitung"}
</p>
<div className="flex items-center justify-between rounded border border-border bg-surface p-3">
<div>
<div className="text-sm font-semibold text-ink">
{superior.first_name} {superior.last_name}
</div>
<div className="text-xs text-ink-muted">{superior.job_title}</div>
</div>
<Button variant="ghost" size="sm" onClick={() => setSuperior(null)}>
Ändern
</Button>
</div>
</div>
)}
{isLead && (
<SelectField
label="Zu leitendes Team"
required
value={teamId}
onChange={setTeamId}
placeholder="Bitte wählen…"
options={teams.map((t) => ({ value: t.id, label: t.name }))}
/>
)}
<TextField label="Gültig ab" required type="date" value={validFrom} onChange={setValidFrom} />
</div>

View File

@@ -1,138 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { updatePosition } from "@/actions/positions";
import { Button } from "@/components/ui/Button";
import { SelectField, TextField } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import type { OpenPositionResolved } from "@/lib/positions";
import type { UnitOption } from "./CreatePositionModal";
// Ändern statt löschen und neu anlegen.
//
// Die Planstellennummer steht in Ausschreibungen, Budgets und
// Protokolleinträgen. Wer wegen eines Tippfehlers in der Tätigkeit eine neue
// Nummer vergibt, macht die alten Bezüge wertlos — deshalb gibt es diesen
// Dialog.
/**
* Erwartet eine Planstelle, keine „vielleicht keine".
*
* Die aufrufende Seite hängt einen `key` mit der Kennung daran und rendert
* ihn nur, solange etwas bearbeitet wird. Dadurch baut React den Dialog je
* Planstelle neu auf, und die Felder lassen sich direkt aus den Eigenschaften
* vorbelegen — statt sie in einem Effekt nachzuziehen, der beim zweiten
* Öffnen kurz die Werte der vorigen Stelle zeigt.
*/
export function EditPositionModal({
position,
units,
onClose,
}: {
position: OpenPositionResolved;
units: UnitOption[];
onClose: () => void;
}) {
const { showToast } = useToast();
const router = useRouter();
const [jobTitle, setJobTitle] = useState(position.title);
const [orgUnitId, setOrgUnitId] = useState(position.org_unit_id);
const [isChief, setIsChief] = useState(position.is_chief);
const [validFrom, setValidFrom] = useState(position.valid_from);
const [validTo, setValidTo] = useState(position.valid_to ?? "");
const [pending, setPending] = useState(false);
const unit = units.find((u) => u.id === orgUnitId);
// Die eigene Leitung zählt nicht als Hindernis für sich selbst.
const chiefTaken = Boolean(unit?.hasChief) && !(position.is_chief && orgUnitId === position.org_unit_id);
async function handleSubmit() {
if (!jobTitle.trim() || !orgUnitId || !validFrom) {
showToast("Tätigkeit, Einheit und Gültigkeitsbeginn sind Pflicht.", "error");
return;
}
setPending(true);
const result = await updatePosition({
position_id: position.id,
org_unit_id: orgUnitId,
job_title: jobTitle.trim(),
is_chief: isChief && !chiefTaken,
valid_from: validFrom,
valid_to: validTo || null,
});
setPending(false);
if (result.success) {
showToast("Planstelle geändert.");
router.refresh();
onClose();
} else {
// Die Meldungen der Datenbankfunktion sind für die Oberfläche
// geschrieben („Diese Planstelle ist vergeben …“) und werden gezeigt.
showToast(result.error ?? "Die Änderung war nicht möglich.", "error");
}
}
return (
<Modal
open
onClose={onClose}
title={`Planstelle ${position.position_number}`}
footer={
<>
<Button variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button onClick={handleSubmit} pending={pending}>
Speichern
</Button>
</>
}
>
<div className="flex flex-col gap-3">
<TextField label="Tätigkeit" required value={jobTitle} onChange={setJobTitle} />
<SelectField
label="Organisationseinheit"
required
value={orgUnitId}
onChange={setOrgUnitId}
placeholder="Bitte wählen…"
options={units.map((u) => ({
value: u.id,
label: `${"  ".repeat(u.depth)}${u.name} (${u.unit_type})`,
}))}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<TextField label="Gültig ab" required type="date" value={validFrom} onChange={setValidFrom} />
<TextField label="Gültig bis" type="date" value={validTo} onChange={setValidTo} />
</div>
<label className="flex items-start gap-2 text-sm text-ink-body">
<input
type="checkbox"
className="mt-0.5 h-4 w-4 rounded border-border text-brand-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
checked={isChief && !chiefTaken}
disabled={chiefTaken}
onChange={(e) => setIsChief(e.target.checked)}
/>
<span>
Leitungsplanstelle
{chiefTaken && (
<span className="block text-xs text-ink-muted">
Für {unit?.name} besteht bereits eine Leitungsplanstelle.
</span>
)}
</span>
</label>
<p className="rounded-md bg-surface px-3 py-2 text-xs leading-relaxed text-ink-muted">
Die Planstellennummer bleibt. Ist die Stelle vergeben, lassen sich Einheit und Gültigkeitsende nicht ändern
ein Wechsel der Einheit ist eine Versetzung und gehört zur Person, nicht zur Stelle.
</p>
</div>
</Modal>
);
}

View File

@@ -6,123 +6,79 @@ import { useState } from "react";
import { deletePosition } from "@/actions/positions";
import { Button } from "@/components/ui/Button";
import { useToast } from "@/components/ui/Toast";
import { fmtDate } from "@/lib/format";
import { fmtDate, todayIso } from "@/lib/format";
import type { OpenPositionResolved } from "@/lib/positions";
import { CreatePositionModal, type UnitOption } from "./CreatePositionModal";
import { EditPositionModal } from "./EditPositionModal";
import { CreatePositionModal } from "./CreatePositionModal";
type OpenPositionWithDays = OpenPositionResolved & { daysOpen: number };
type PositionsPageClientProps = {
openPositions: OpenPositionWithDays[];
units: UnitOption[];
teams: { id: string; org_number: string; name: string; department_id: string }[];
};
export function PositionsPageClient({ openPositions, units }: PositionsPageClientProps) {
export function PositionsPageClient({ openPositions, teams }: PositionsPageClientProps) {
const { showToast } = useToast();
const router = useRouter();
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<OpenPositionWithDays | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
// Getrennt, weil es zwei verschiedene Aussagen sind: „hier fehlt jemand"
// und „das entsteht erst". In einer Liste vermischt liest sich eine
// Planstelle zum 01.10. wie eine Vakanz, um die sich niemand kümmert.
const offen = openPositions.filter((p) => !p.future);
const kuenftig = openPositions.filter((p) => p.future);
const today = todayIso();
async function handleDelete(id: string) {
setDeletingId(id);
const result = await deletePosition(id);
setDeletingId(null);
if (result.success) {
showToast("Planstelle entfernt.");
showToast("Position gelöscht.");
router.refresh();
} else {
showToast(result.error ?? "Fehler beim Löschen.", "error");
}
}
function Karte({ p }: { p: OpenPositionWithDays }) {
return (
<div className="relative flex flex-col rounded border border-border transition-colors focus-within:border-brand-500 hover:border-brand-500">
{/* Die ganze Karte öffnet den Änderungsdialog; der Papierkorb liegt
absolut darüber. Ein Knopf im Knopf wäre ungültiges HTML und mit
der Tastatur nicht erreichbar. */}
<button
type="button"
onClick={() => setEditing(p)}
aria-label={`Planstelle ${p.position_number} (${p.title}) ändern`}
className="flex flex-col items-start rounded p-3 pr-10 text-left
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
<span className="text-sm font-semibold text-ink">{p.title}</span>
<span className="text-xs text-ink-muted">
{p.position_number} · {p.orgLabel}
</span>
<span className="mt-1 text-xs text-ink-muted">
{p.is_chief ? "Leitungsplanstelle · " : ""}
{p.future ? `gültig ab ${fmtDate(p.valid_from)}` : `seit ${p.daysOpen} Tagen unbesetzt`}
</span>
{p.valid_to && <span className="text-xs font-semibold text-warning-text">endet am {fmtDate(p.valid_to)}</span>}
{p.managerName && <span className="text-xs text-ink-muted">berichtet an {p.managerName}</span>}
</button>
<div className="flex flex-col gap-6">
<div className="rounded border border-border bg-white p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-sm font-bold text-ink">Offene Positionen ({openPositions.length})</h2>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
Position ausschreiben
</Button>
</div>
{openPositions.length === 0 ? (
<p className="text-sm text-ink-muted">Derzeit keine offenen Positionen.</p>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{openPositions.map((p) => {
const notYetValid = p.valid_from > today;
return (
<div key={p.id} className="flex flex-col rounded border border-border p-3">
<div className="flex items-start justify-between gap-2">
<div className="text-sm font-semibold text-ink">{p.title}</div>
<Button
variant="icon"
onClick={() => handleDelete(p.id)}
pending={deletingId === p.id}
aria-label={`Planstelle ${p.position_number} (${p.title}) entfernen`}
className="absolute right-2 top-2 hover:!text-danger-solid"
aria-label={`Position ${p.title} löschen`}
className="-mr-1 -mt-1 hover:!text-danger-solid"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="text-xs text-ink-muted">
{p.position_number} · {p.orgLabel}
</div>
<div className="mt-1 text-xs text-ink-muted">seit {p.daysOpen} Tagen offen</div>
{notYetValid && <div className="mt-1 text-xs font-semibold text-warning-text">Gültig ab {fmtDate(p.valid_from)}</div>}
</div>
);
}
return (
<div className="flex flex-col gap-6">
<section className="rounded border border-border bg-white p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-sm font-bold text-ink">Unbesetzte Planstellen ({offen.length})</h2>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
Planstelle anlegen
</Button>
</div>
{offen.length === 0 ? (
<p className="text-sm text-ink-muted">Derzeit ist jede geltende Planstelle besetzt oder vergeben.</p>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{offen.map((p) => (
<Karte key={p.id} p={p} />
))}
})}
</div>
)}
</section>
{kuenftig.length > 0 && (
<section className="rounded border border-border bg-white p-4">
<h2 className="text-sm font-bold text-ink">Künftige Planstellen ({kuenftig.length})</h2>
<p className="mb-3 mt-1 text-xs text-ink-muted">
Beschlossen, aber noch nicht gültig. Sie zählen nicht als Vakanz und lassen sich bis zum Beginn ändern.
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{kuenftig.map((p) => (
<Karte key={p.id} p={p} />
))}
</div>
</section>
)}
<CreatePositionModal open={createOpen} onClose={() => setCreateOpen(false)} units={units} />
{/* `key` sorgt dafür, dass React je Planstelle einen frischen Dialog
baut — sonst blieben beim zweiten Öffnen die Werte des ersten
stehen. */}
{editing && (
<EditPositionModal key={editing.id} position={editing} units={units} onClose={() => setEditing(null)} />
)}
<CreatePositionModal open={createOpen} onClose={() => setCreateOpen(false)} teams={teams} />
</div>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { BarChart3, Building2, History, LayoutGrid, Network, Upload, Users, X } from "lucide-react";
import { BarChart3, Building2, History, LayoutGrid, Network, Users, X } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect } from "react";
@@ -12,7 +12,6 @@ const NAV_ITEMS = [
{ href: "/positions", label: "Positionen", icon: Building2 },
{ href: "/reports", label: "Berichte", icon: BarChart3 },
{ href: "/audit", label: "Audit-Log", icon: History },
{ href: "/import", label: "Import", icon: Upload },
] as const;
export function isActiveRoute(pathname: string, href: string): boolean {

View File

@@ -1,10 +1,12 @@
services:
app:
# Ohne Build-Argumente: alles, was die Anwendung braucht — DATABASE_URL,
# AUTH_* — liest sie zur Laufzeit aus .env. Das Abbild ist damit für jede
# Umgebung dasselbe.
build:
context: .
args:
# NEXT_PUBLIC_* vars are inlined into the browser bundle at build
# time, so they have to be passed here, not just in env_file below.
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
restart: unless-stopped
ports:
- "3000:3000"

View File

@@ -1,148 +0,0 @@
# Umstieg auf Azure — Sicherheitsarchitektur
Entwurf zur Abnahme. **Noch kein Code umgestellt.**
Ziel: Azure Database for PostgreSQL (Flexible Server), Anmeldung über Entra ID,
Supabase als Abhängigkeit entfernt.
## Ausgangslage, gemessen
| | Anzahl |
|---|---|
| RLS-Policies | 58 |
| `auth.uid()` / `auth.users` in Migrationen | 79 |
| Fremdschlüssel auf `auth.users` | 9 |
| Datenzugriffe in der App (`.from()`, `.rpc()`) | 50 |
| Dateien mit Supabase-Import | 10 |
Die Anwendung läuft mit dem **anon-Key** (`lib/supabase/server.ts`,
`client.ts`); der Service-Role-Key kommt nur in `lib/supabase/admin.ts` vor.
Die RLS-Policies sind damit die tatsächliche Sicherheitsgrenze — nicht der
Proxy und nicht der Anwendungscode.
## Der entscheidende Befund
`auth.uid()` erscheint 70-mal, aber für die Absicherung zählt genau **eine**
Stelle:
```sql
create or replace function is_hr_user() returns boolean
language sql security definer stable as $$
select exists (
select 1 from profiles p
where p.id = auth.uid() and p.role = 'hr' and p.is_active = true
);
$$;
```
Alle 58 Policies rufen `is_hr_user()` auf. Wird hier die Herkunft der
Benutzerkennung ausgetauscht, **bleiben alle Policies unverändert gültig**.
Die Sicherheitsarchitektur wandert also *nicht* in den Anwendungscode — das
war meine Sorge bei Variante B, und sie ist ausgeräumt.
Die übrigen ~55 Vorkommen stehen in Mutations-RPCs (`insert into audit_log
values (auth.uid(), …)`) und sind eine mechanische Ersetzung.
## Zielarchitektur
### 1. Benutzertabelle statt `auth.users`
```sql
create table app_users (
id uuid primary key default gen_random_uuid(),
entra_object_id uuid not null unique, -- oid aus dem Entra-Token
email text not null,
created_at timestamptz not null default now()
);
```
Die neun Fremdschlüssel zeigen künftig hierauf. `profiles.id` bleibt der
Schlüssel, an dem `role` und `is_active` hängen — die HR-Freischaltung
funktioniert unverändert.
### 2. Sitzungskontext statt `auth.uid()`
```sql
create or replace function current_app_user() returns uuid
language sql stable as $$
select nullif(current_setting('app.user_id', true), '')::uuid;
$$;
```
`auth.uid()``current_app_user()`, überall. `is_hr_user()` bleibt sonst
Wort für Wort gleich.
### 3. Der kritische Punkt: wie der Kontext gesetzt wird
**Hier entscheidet sich, ob die Migration sicher ist.**
Jeder Datenbankzugriff muss in einer Transaktion laufen, die zuerst
`set local app.user_id` ausführt:
```ts
await db.transaction(async (tx) => {
await tx.execute(sql`select set_config('app.user_id', ${userId}, true)`);
return tx.select();
});
```
Das dritte Argument `true` bedeutet *transaktionslokal*. Ohne Transaktion
bliebe die Einstellung an der Verbindung hängen — und die nächste Anfrage,
die dieselbe Verbindung aus dem Pool zieht, liefe **mit der Kennung des
vorherigen Benutzers**. Das ist genau die Art Fehler, die in einem Test nie
auffällt und im Betrieb Personaldaten quer über Benutzer hinweg preisgibt.
Deshalb: **kein direkter Zugriff auf den Pool.** Es gibt eine einzige
Zugriffsfunktion, die die Transaktion und `set_config` erzwingt, und eine
Lint-Regel, die den Import des Pools außerhalb dieser Datei verbietet.
Das muss strukturell unmöglich sein, nicht per Konvention.
Zusätzlich verbindet sich die Anwendung mit einer Datenbankrolle **ohne**
`BYPASSRLS`. Selbst wenn der Kontext fehlt, liefern die Policies dann nichts
zurück — statt alles.
### 4. Anmeldung
Entra ID über NextAuth (Azure-AD-Provider) oder MSAL. Nach der Validierung
des Tokens wird die `oid` auf `app_users.entra_object_id` abgebildet; existiert
kein Eintrag, wird einer angelegt — **ohne** `profiles`-Zeile, also ohne
Zugriff. Die Freischaltung bleibt ein bewusster Schritt, wie heute
(`is_active` ist per Vorgabe `false`).
Damit erledigt sich die SSO-Frage aus der IT-Liste mit.
### 5. Datenzugriff
PostgREST entfällt; die 50 Aufrufe werden auf Drizzle umgestellt. Die sechs
`.rpc()`-Aufrufe sind trivial (direkter Funktionsaufruf), die 44
`.from()`-Aufrufe sind Query-Builder-Umschreibungen.
Das handgeschriebene `lib/supabase/types.ts` entfällt: Drizzle erzeugt die
Typen aus dem Schema, womit auch der Schema-Drift-Prüfer überflüssig wird.
## Was bewusst gleich bleibt
- **Alle 58 RLS-Policies**, unverändert
- Das gesamte Schema samt Enums, Arrays, `jsonb`, PL/pgSQL, partiellen Indizes
- `pgcrypto` und `pg_trgm` (beide auf Azure freigegeben)
- Die Geschäftslogik in den RPCs
## Reihenfolge
1. `app_users`, `current_app_user()`, Fremdschlüssel umhängen — additiv, gegen die bestehende Datenbank testbar
2. Zugriffsschicht mit erzwungener Transaktion + `set_config`, plus Test, der den Kontextverlust nachweist
3. Entra-ID-Anmeldung
4. Die 50 Datenzugriffe umstellen
5. Supabase-Pakete entfernen
6. Umzug der Datenbank per `pg_dump`/`pg_restore`
Schritt 2 ist der einzige, bei dem ein Fehler still bleibt. Dafür braucht es
einen Test, der zwei Anfragen über dieselbe gepoolte Verbindung schickt und
prüft, dass die zweite die erste nicht sieht.
## Offene Fragen an die Kunden-IT
- Welcher Entra-Mandant, und wer legt die App-Registrierung an?
- Gruppenbasierte Freischaltung (Entra-Gruppe „HR") oder weiter manuell über `profiles.is_active`?
- Flexible Server: Version, Region, Netzwerkzugang (Private Endpoint oder Firewall-Regeln)?
- Wer betreibt und patcht?

View File

@@ -1,137 +0,0 @@
# Anmeldung über Entra ID
Die Anwendung meldet ausschliesslich über Microsoft Entra ID an. Die
Sitzungsverwaltung macht **Auth.js** (`auth.ts`, `lib/auth/config.ts`) — es gibt
keinen Anmeldedienst eines Anbieters mehr dazwischen.
**Warum das trotzdem eine kleine Änderung ist:** die Anmeldung liefert nach wie
vor nur eine UUID. `profiles.id` trägt weiterhin `role` und `is_active`, und
damit bleiben `is_hr_user()` und alle 58 RLS-Policies unverändert gültig. Die
Sicherheitsgrenze wandert nicht in den Anwendungscode.
## Einrichtung im Entra-Mandanten
App-Registrierung, einmalig — angelegt im Mandanten *loudspring management GmbH*:
| | |
|---|---|
| Name | Alpenwerk HR |
| Kontotypen | Nur ein Mandant |
| Umleitungs-URI (Web) | `https://<produktion>/api/auth/callback/microsoft-entra-id` |
| Anwendungs-ID (Client) | `<client-id>` |
| Verzeichnis-ID (Mandant) | `<tenant-id>` |
Die konkreten Werte stehen bewusst nicht hier, sondern in der
Übergabedokumentation. Sie sind zwar keine Geheimnisse — ohne Client-Geheimnis
gibt eine ID nichts her, und RLS greift ohnehin —, aber sie zeigen auf die echte
Umgebung, und dieses Repository wandert weiter als sie.
Die Umleitungs-URI zeigt jetzt auf die **Anwendung selbst**. Für die lokale
Entwicklung kommt `http://localhost:3000/api/auth/callback/microsoft-entra-id`
als zweite URI dazu; Entra erlaubt `http` nur für `localhost`.
Danach:
1. **Zertifikate & Geheimnisse** → neues Client-Geheimnis. Der *Wert* wird
gebraucht, nicht die Geheimnis-ID, und er ist nur einmal sichtbar.
2. **API-Berechtigungen**`openid`, `profile`, `email` (Microsoft Graph,
delegiert), Administratorzustimmung erteilen. `User.Read` wird **nicht**
gebraucht: der eingebaute Anbieter von Auth.js fordert es an, um das
Profilbild aus dem Graph zu holen — `lib/auth/config.ts` schaltet beides ab.
3. **Tokenkonfiguration** → Gruppenanspruch, siehe unten.
## Konfiguration der Anwendung
Vier Werte, alle server-seitig — nichts davon landet im Browser-Bundle:
| Variable | Wert |
|---|---|
| `AUTH_SECRET` | `npx auth secret` oder `openssl rand -base64 32` |
| `AUTH_MICROSOFT_ENTRA_ID_ID` | Anwendungs-ID (Client) |
| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | der Wert aus „Zertifikate & Geheimnisse" |
| `AUTH_MICROSOFT_ENTRA_ID_ISSUER` | `https://login.microsoftonline.com/<tenant-id>/v2.0` |
Der Aussteller ist bei „Nur ein Mandant" **nicht optional**. Bleibt er leer,
benutzt Auth.js `common` — dann dürfte sich jedes Microsoft-Konto anmelden, auch
ein privates Outlook-Konto. Die Freischaltung über `profiles` fängt das zwar ab,
aber die Eingangstür soll erst gar nicht so weit offenstehen.
`AUTH_SECRET` verschlüsselt das Sitzungscookie. Ein Wechsel meldet alle ab — im
Ernstfall genau das gewünschte Mittel.
## Was bei der ersten Anmeldung passiert
1. Auth.js prüft das Token von Entra (`state`, `nonce`, Signatur, Aussteller).
2. Der Rückruf in `auth.ts` nimmt daraus die **`oid`** — nicht `sub`, nicht die
E-Mail. Die `oid` identifiziert dieselbe Person über Anwendungen hinweg und
überlebt Namens- und Adressänderungen.
3. `app_upsert_user(oid, email, name)` legt die `app_users`-Zeile an und liefert
die Kennung, die von da an in jeder Transaktion als `app.user_id` steht.
4. **Gibt es zu der Adresse bereits ein `profiles`-Eintrag, übernimmt die
Funktion dessen Kennung** statt eine neue zu vergeben. Das ist der Grund,
warum bestehende Zugänge nach der Umstellung weiterlaufen: Notizen,
Entwürfe und Protokolleinträge hängen an dieser ID.
Der Abgleich über die Adresse ist genau hier vertretbar und sonst nirgends: sie
kommt aus einem von Entra ausgestellten Token, nicht aus einem Formular. Wer sie
behauptet, hat sie bereits bewiesen.
## Freischaltung über die Entra-Gruppe
Wer sich anmeldet, hat damit **noch keinen Zugriff**. Zugriff hat, wer eine
`profiles`-Zeile mit `role = 'hr'` und `is_active = true` besitzt.
### Woher der Gruppen-Anspruch kommt
Entra schickt Gruppen nur mit, wenn es in der Tokenkonfiguration eingestellt
ist. Zwei Varianten:
| Variante | Lizenz | Haken |
|---|---|---|
| Sicherheitsgruppen | frei | Schickt *alle* Sicherheitsgruppen mit. Ab etwa 200 Gruppen liefert Entra statt der Liste einen Verweis, und die Auswertung greift ins Leere. |
| Der Anwendung zugewiesene Gruppen | Entra ID P1 | Nur die zugewiesene Gruppe steht im Token. |
### Wo die Auswertung hingehört
In den `jwt`-Rückruf in `auth.ts`, neben `app_upsert_user()` — dort liegt
`profile.groups` aus dem ID-Token vor.
Das ist belastbar, und der Grund ist wichtig: das ID-Token ist von Entra
signiert und wurde von Auth.js gegen den Aussteller geprüft. Die angemeldete
Person kann seinen Inhalt nicht beeinflussen. (Unter GoTrue war dieselbe Stelle
eine Falle: `auth.users.raw_user_meta_data` war von der Person selbst
beschreibbar, und eine Freischaltung, die von dort gelesen hätte, wäre
selbstbedienbar gewesen.)
### Reihenfolge
Gebaut wird das erst, wenn feststeht, wie der Anspruch tatsächlich ankommt — das
hängt an der gewählten Variante und an der Konfiguration des Mandanten. Ablauf:
1. SSO in Betrieb nehmen, einmal anmelden.
2. Im `jwt`-Rückruf einmalig `console.log(profile)` — das zeigt die Ansprüche so,
wie der Mandant sie tatsächlich schickt.
3. Erst dann die Auswertung mit der konkreten Gruppen-ID schreiben.
Ohne Schritt 2 wäre sie geraten. Bis dahin wird `profiles` von Hand gepflegt.
### Was die Gruppe nicht kann
Die Mitgliedschaft steht im Token. Wer aus der Gruppe entfernt wird, verliert den
Zugriff deshalb **bei der nächsten Anmeldung**, nicht sofort. Für den sofortigen
Entzug bleibt `profiles.is_active = false` das Mittel — das wirkt beim nächsten
Datenbankzugriff, weil `is_hr_user()` die Spalte je Abfrage liest.
## Wer prüft was
| Stelle | Prüft | Wann |
|---|---|---|
| `proxy.ts` | Gibt es überhaupt eine Sitzung? | jede Anfrage |
| `app/(app)/layout.tsx` | `profiles.role` / `is_active` | jeder Seitenaufbau |
| `lib/auth/require-hr.ts` | dasselbe, für `/api/export/*` | jeder Aufruf |
| RLS-Policies | `is_hr_user()` | jede einzelne Abfrage |
Der Proxy prüft die HR-Rechte **nicht** — er hat keine Datenbankverbindung. Sie
in das Sitzungstoken zu schreiben wäre schneller gewesen und hätte eine
Behauptung eingefroren: eine entzogene Freischaltung wirkte dann erst mit dem
nächsten Token. Bei einer Personalanwendung ist das die falsche Richtung.

View File

@@ -13,50 +13,6 @@ const eslintConfig = defineConfig([
"build/**",
"next-env.d.ts",
]),
// ── Der Datenbankzugriff bleibt in einer Hand ───────────────────
//
// Die Zugriffsrechte hängen an einer Sitzungsvariablen, die nur innerhalb
// einer Transaktion gesetzt werden darf (siehe lib/db/index.ts). Wer den
// Pool direkt benutzt, umgeht das — und die Abfrage läuft dann mit dem
// Kontext, den die vorherige Anfrage auf derselben gepoolten Verbindung
// hinterlassen hat.
//
// Das muss strukturell unmöglich sein, nicht per Konvention: eine
// Vereinbarung überlebt den nächsten Termindruck nicht.
{
files: ["**/*.ts", "**/*.tsx"],
// Tests dürfen: sie werden nicht ausgeliefert, und einige prüfen gerade
// die Einstellungen des Pools — das geht nicht, ohne ihn anzusehen.
ignores: ["lib/db/**", "tests/**", "supabase/**", "scripts/**"],
rules: {
"no-restricted-imports": [
"error",
{
paths: [
{
name: "pg",
message:
"Kein direkter Pool-Zugriff. Abfragen laufen über withUser() aus lib/db — nur dort wird der Sitzungskontext transaktionslokal gesetzt.",
},
{
name: "kysely",
importNames: ["Kysely"],
message:
"Keine zweite Kysely-Instanz. lib/db exportiert withUser(); die Instanz selbst bleibt privat, damit keine Abfrage ohne Kontext möglich ist.",
},
],
patterns: [
{
group: ["**/lib/db/pool", "**/db/pool"],
message:
"Der Pool ist absichtlich nicht exportiert. Über lib/db gehen — withUser() erzwingt die Transaktion.",
},
],
},
],
},
},
]);
export default eslintConfig;

View File

@@ -1,139 +0,0 @@
import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id";
import type { NextAuthConfig } from "next-auth";
// Der Teil der Anmeldung, der **ohne Datenbank** auskommt.
//
// Das ist keine Stilfrage: proxy.ts läuft je nach Betriebsart in einer
// Umgebung ohne Node-Module — dort gibt es kein `pg` und keine Verbindung.
// Würde der Proxy die vollständige Konfiguration laden, zöge er die
// Zugriffsschicht mit hinein und liesse sich nicht mehr ausliefern. Deshalb
// hier nur Anbieter und Sitzungsregeln; alles, was die Datenbank berührt,
// steht in auth.ts.
/** Wie lange eine Anmeldung ohne erneuten Besuch bei Entra gilt. */
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 9; // ein Arbeitstag
/**
* Der Aussteller — mit Abbruch statt Rückfall.
*
* Ohne diese Prüfung setzt Auth.js bei fehlender Variablen stillschweigend
* `https://login.microsoftonline.com/common/v2.0` ein. Das ist beim ersten
* Ausprobieren aufgefallen: die Weiterleitung ging tatsächlich nach
* `/common/oauth2/v2.0/authorize`, und damit hätte sich **jedes**
* Microsoft-Konto anmelden dürfen, auch ein privates.
*
* Aufgefallen wäre das im Betrieb sonst nicht — die Anmeldung funktioniert
* ja, nur eben für zu viele. Ein vergessener Wert in der Deployment-Umgebung
* muss deshalb den Start verhindern, nicht die Tür aufmachen.
*
* In der Entwicklung bleibt es bei einer Warnung: dort ist nichts
* konfiguriert, und ein Abbruch beim Laden des Moduls nähme auch die
* Anmeldeseite mit.
*/
function tenantIssuer(): string | undefined {
const issuer = process.env.AUTH_MICROSOFT_ENTRA_ID_ISSUER;
if (issuer) return issuer;
const hinweis =
"AUTH_MICROSOFT_ENTRA_ID_ISSUER fehlt. Ohne Mandanten-Aussteller fiele die " +
"Anmeldung auf /common/ zurück und stünde jedem Microsoft-Konto offen.";
if (process.env.NODE_ENV === "production") throw new Error(hinweis);
console.warn(`[auth] ${hinweis}`);
return undefined;
}
/**
* Die Konfiguration als **Funktion**, nicht als Objekt.
*
* Auth.js wertet die Funktionsform pro Anfrage aus. Das ist hier nötig, weil
* tenantIssuer() in der Produktion abbricht: als Objekt gebaut liefe die
* Prüfung schon beim Import — und `next build` importiert jedes Route-Modul,
* um die Seitendaten einzusammeln. Der Bau bräuchte dann die
* Anmeldekonfiguration der Zielumgebung, und ein Abbild liesse sich nicht
* mehr einmal bauen und überall ausliefern.
*/
export function authConfig(): NextAuthConfig {
return {
providers: [
MicrosoftEntraID({
clientId: process.env.AUTH_MICROSOFT_ENTRA_ID_ID,
clientSecret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET,
issuer: tenantIssuer(),
// Der eingebaute Anbieter fordert zusätzlich `User.Read` an und holt
// damit das Profilbild aus dem Graph. Beides ist hier unerwünscht: eine
// Berechtigung, die niemand braucht, muss die Mandantenverwaltung
// trotzdem genehmigen — und das Bild landete base64-kodiert im
// Sitzungscookie, das dann in Teile zerfällt.
authorization: { params: { scope: "openid profile email" } },
profile(profile) {
return {
// Die `oid`, nicht `sub`: `sub` ist pro Anwendung verschieden, die
// `oid` identifiziert dieselbe Person über Anwendungen hinweg und
// überlebt Namens- und Adressänderungen.
id: profile.oid,
name: profile.name ?? null,
// `email` ist im Token ein optionaler Anspruch — je nach Mandant
// fehlt er. `preferred_username` bzw. `upn` tragen dann dieselbe
// Adresse. Ohne diesen Rückfall scheitert die Anmeldung in genau
// den Mandanten, die den Anspruch nicht ausdrücklich konfiguriert
// haben.
email: profile.email ?? profile.preferred_username ?? profile.upn ?? null,
image: null,
};
},
}),
],
// Eigene Seite statt der von Auth.js mitgelieferten: die Anmeldung ist die
// erste Seite, die jemand sieht, und soll aussehen wie die Anwendung.
pages: { signIn: "/login", error: "/login" },
session: { strategy: "jwt", maxAge: SESSION_MAX_AGE_SECONDS },
callbacks: {
// Muss **hier** stehen und nicht in auth.ts, obwohl es nur eine
// Zuweisung ist.
//
// proxy.ts baut eine eigene Auth.js-Instanz aus genau dieser Datei. Lag
// die Zuordnung in auth.ts, bekäme der Proxy die Standard-Sitzung ohne
// `id`, hielte jede angemeldete Person für nicht angemeldet und
// schickte sie zurück auf /login — obwohl das Cookie längst gesetzt
// ist. Genau so ist die erste echte Anmeldung in einer Schleife
// gelandet: Konto angelegt, Sitzung gültig, und trotzdem kam man nicht
// hinein.
//
// Datenbank braucht das nicht, es liest nur aus dem entschlüsselten
// Token — deshalb darf es am Rand laufen.
session({ session, token }) {
if (token.uid) session.user.id = token.uid;
return session;
},
},
// Hinter Reverse Proxy und Container-Netzwerk kommt der Host aus dem
// Header. Ohne das verweigert Auth.js in der Produktion den Dienst, weil
// es die Herkunft nicht bestätigen kann.
trustHost: true,
// Auf der Anmeldeseite steht bewusst nur eine allgemeine Meldung — der
// Grund ist fremdbestimmt und gehört nicht auf eine Seite, die echt
// aussieht. Im Serverprotokoll gehört er dagegen hin, und zwar
// vollständig: ohne das scheitert die Anmeldung lautlos, der Browser
// springt auf /login zurück, und es gibt nichts zu lesen ausser einer
// 302. Genau so ist die erste Anmeldung hier fehlgeschlagen.
logger: {
error(err) {
console.error("[auth] Fehler:", err);
const cause = (err as { cause?: unknown }).cause;
// Der eigentliche Grund steckt oft eine Ebene tiefer — bei einem
// Fehler in einem Rückruf ist die äussere Meldung nur „Read more at
// …/errors#callback-route-error".
if (cause) console.error("[auth] Ursache:", cause);
},
warn(code) {
console.warn("[auth] Warnung:", code);
},
},
};
}

View File

@@ -1,26 +0,0 @@
import "server-only";
import { NextResponse } from "next/server";
import { withUser } from "@/lib/db";
import { currentUserId } from "./session";
// Route Handlers under /api/export/* are outside the App Router layout tree,
// so app/(app)/layout.tsx's HR gate never runs for them — each one has to
// re-establish that the caller is an active HR user itself. RLS is still the
// real boundary (an unauthorized session simply reads nothing); this exists
// so those routes answer 401/403 instead of handing back an empty workbook.
export type HrGate = { denied: NextResponse } | { userId: string };
export async function requireHrUser(): Promise<HrGate> {
const userId = await currentUserId();
if (!userId) return { denied: NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }) };
const profile = await withUser(userId, (tx) =>
tx.selectFrom("profiles").select(["role", "is_active"]).where("id", "=", userId).executeTakeFirst()
);
if (profile?.role !== "hr" || profile.is_active !== true) {
return { denied: NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }) };
}
return { userId };
}

View File

@@ -1,32 +0,0 @@
import "server-only";
import { auth } from "@/auth";
// Der einzige Ort, an dem die Kennung der angemeldeten Person herkommt.
//
// Dass der Wechsel von GoTrue auf Auth.js eine Änderung an dieser Datei war
// und nicht an fünfzig Aufrufstellen, lag genau an dieser Bündelung: alles
// andere ruft `currentUserId()` auf und reicht den Wert an withUser() weiter.
//
// Der Wert ist app_users.id — nicht die `oid` von Entra. Die Zuordnung
// zwischen beiden macht app_upsert_user() bei der Anmeldung, und sie
// übernimmt für eine bereits bekannte Adresse die vorhandene profiles.id.
// Deshalb passt die Kennung weiterhin auf das, was app_current_user_id() in
// der Datenbank erwartet, und die 58 RLS-Policies merken vom Wechsel nichts.
export async function currentUserId(): Promise<string | null> {
const session = await auth();
return session?.user?.id ?? null;
}
/**
* Wie currentUserId(), bricht aber ab, statt null zu liefern.
*
* Für Stellen, die ohne angemeldete Person keinen Sinn ergeben. Die
* Absicherung hängt trotzdem nicht daran: ohne Kontext geben die
* RLS-Policies nichts zurück, unabhängig davon, was der Anwendungscode tut.
*/
export async function requireUserId(): Promise<string> {
const id = await currentUserId();
if (!id) throw new Error("Nicht angemeldet.");
return id;
}

View File

@@ -1,77 +0,0 @@
import "server-only";
import { Kysely, PostgresDialect, sql, type Transaction } from "kysely";
import { getPool } from "./pool";
import type { Schema } from "./schema";
// Der einzige Weg an die Datenbank.
//
// ═══ Warum das keine gewöhnliche Datenbankschicht ist ═══
//
// Die Zugriffsrechte liegen in der Datenbank: 58 RLS-Policies rufen
// is_hr_user() auf, und das fragt seit der Umstellung nicht mehr Supabase,
// sondern `current_setting('app.user_id')` — eine Sitzungsvariable.
//
// Sitzungsvariablen hängen an der *Verbindung*, nicht an der Anfrage. Und
// Verbindungen kommen aus einem Pool. Wird die Variable ohne Transaktion
// gesetzt, bleibt sie an der Verbindung kleben, und die nächste Anfrage, die
// dieselbe Verbindung zieht, läuft mit der Kennung der vorherigen Person —
// quer über Benutzer hinweg, in einer Personaldatenbank.
//
// Das ist die Art Fehler, die in keinem Test auffällt, den man nicht
// absichtlich dafür schreibt (tests/integration/session-context.test.ts tut
// genau das). Deshalb:
//
// 1. Die Kysely-Instanz wird **nicht exportiert**. Wer abfragen will, muss
// durch withUser() — und das öffnet immer eine Transaktion.
// 2. `set_config(..., true)` — das dritte Argument bedeutet
// transaktionslokal. Mit `false` wäre die ganze Vorsichtsmassnahme
// wirkungslos.
// 3. Eine ESLint-Regel verbietet den Import von `pg` und `./pool`
// ausserhalb dieses Verzeichnisses.
//
// Zusätzlich verbindet sich die Anwendung mit einer Datenbankrolle **ohne**
// BYPASSRLS. Fehlt der Kontext trotz allem, liefern die Policies nichts
// zurück — nicht alles.
// Der Pool wird als Funktion übergeben, nicht als fertige Instanz: Kysely
// ruft sie erst bei der ersten Abfrage auf. So verlangt der Import dieses
// Moduls noch keine Zugangsdaten — siehe getPool().
const db = new Kysely<Schema>({
dialect: new PostgresDialect({ pool: async () => getPool() }),
});
export type Tx = Transaction<Schema>;
/**
* Führt `fn` im Namen der angegebenen Person aus.
*
* `userId` ist die app_users.id. Für nicht angemeldete Zugriffe null — dann
* greift keine Policy und es kommt nichts zurück, was auch richtig ist.
*/
export async function withUser<T>(userId: string | null, fn: (tx: Tx) => Promise<T>): Promise<T> {
return db.transaction().execute(async (tx) => {
// Erste Anweisung der Transaktion, vor allem anderen.
await sql`select set_config('app.user_id', ${userId ?? ""}, true)`.execute(tx);
return fn(tx);
});
}
/**
* Für Abläufe ohne angemeldete Person — heute nur der nächtliche Lauf für
* fällige Änderungen.
*
* Bewusst kein privilegierter Zugang: die Verbindung benutzt dieselbe Rolle
* ohne BYPASSRLS. Was hier laufen darf, muss als SECURITY-DEFINER-Funktion
* in der Datenbank stehen und dort selbst prüfen, was es tut. Ein
* Dienstschlüssel, der RLS aushebelt, existiert nicht mehr.
*/
export async function asSystem<T>(fn: (tx: Tx) => Promise<T>): Promise<T> {
return withUser(null, fn);
}
/** Für Migrations- und Wartungsskripte, die ausserhalb einer Anfrage laufen. */
export async function closeDb(): Promise<void> {
await db.destroy();
}
export { sql };

View File

@@ -1,102 +0,0 @@
import "server-only";
import { Pool, types } from "pg";
// ═══ Wie Werte aus der Datenbank ankommen ════════════════════════
//
// Der Wechsel des Zugriffswegs hat hier eine Falle hinterlassen, die kein
// Typprüfer und keiner der Tests fangen konnte.
//
// Die alte API-Schicht lieferte JSON: ein `date` kam als "2026-08-03" an,
// ein `numeric` als Zahl. Genau so steht es in lib/supabase/types.ts, und
// darauf baut die gesamte Anwendung — Sortierungen mit localeCompare,
// Vergleiche wie `entry_date <= stichtag`, das Ableiten des Status.
//
// Der `pg`-Treiber macht es anders herum: aus `date` wird ein Date-Objekt,
// aus `numeric` eine Zeichenkette. Die Deklarationen blieben dabei
// unverändert gültig — sie beschreiben ja nur, was der Code *glaubt*. Der
// Fehler zeigt sich erst zur Laufzeit, und im günstigen Fall als Absturz
// („a.date.localeCompare is not a function"). Im ungünstigen Fall gar
// nicht: ein Datumsvergleich zwischen Date und Zeichenkette wirft nicht, er
// liefert bloss das falsche Ergebnis.
//
// Deshalb wird der Treiber hier auf die Form zurückgestellt, die die Typen
// beschreiben. Das ist die kleinere und ehrlichere Änderung, als 49
// Abfragestellen umzuschreiben.
//
// Nebenbei löst es ein zweites Problem: `date` ist ein Kalendertag ohne
// Zeitzone. Als Date-Objekt bekäme er eine — Mitternacht in der Zone des
// Servers —, und ein Geburtsdatum verschöbe sich beim Formatieren um einen
// Tag. Dieselbe Klasse von Fehler wie im Seed.
types.setTypeParser(1082, (v) => v); // date → "YYYY-MM-DD", unverändert
types.setTypeParser(1184, (v) => new Date(v).toISOString()); // timestamptz → ISO-8601 mit Z
types.setTypeParser(1114, (v) => new Date(v + "Z").toISOString()); // timestamp ohne Zone
types.setTypeParser(1700, (v) => Number(v)); // numeric → Zahl
// Bewusst *nicht* umgestellt: int8 (bigint). Es kommt nur aus count() und
// wird überall mit Number() gelesen; als Zahl geparst verlöre es jenseits
// von 2^53 stillschweigend an Genauigkeit.
// Die einzige Stelle im Projekt, die `pg` importieren darf.
//
// Der Grund steht in lib/db/index.ts: eine Abfrage ausserhalb von withUser()
// läuft ohne Sitzungskontext und damit — je nachdem, was die vorherige
// Anfrage auf derselben gepoolten Verbindung hinterlassen hat — im Namen
// einer fremden Person. Deshalb wird der Pool nicht exportiert, sondern nur
// die Kysely-Instanz, die ihn benutzt, und eine ESLint-Regel verbietet den
// Import von `pg` und von dieser Datei überall sonst.
// Der Pool hängt am globalen Objekt, nicht nur am Modul.
//
// Im Entwicklungsbetrieb lädt Next.js geänderte Module neu. Ein modul-lokales
// `let` wäre danach leer, der alte Pool aber weiterhin am Leben — mit seinen
// Verbindungen. Nach ein paar Bearbeitungen ist das Kontingent des Anbieters
// aufgebraucht, und die Anwendung antwortet nur noch mit „max clients
// reached". Genau so ist sie hier stehengeblieben.
//
// In der Produktion gibt es kein Neuladen; dort ist die Zeile wirkungslos.
const globalForPool = globalThis as typeof globalThis & { __alpenwerkPool?: Pool };
let instance: Pool | undefined = globalForPool.__alpenwerkPool;
/**
* Der Verbindungspool — erst beim ersten Zugriff angelegt, nicht beim Import.
*
* Der Unterschied ist nicht kosmetisch: `next build` importiert jedes Route-
* Modul, um die Seitendaten einzusammeln. Entstünde der Pool dabei, bräuchte
* schon der Bau Zugangsdaten zur Datenbank — ein Container-Abbild liesse sich
* in einer Baustrecke ohne Produktionsgeheimnisse nicht mehr erzeugen.
*/
export function getPool(): Pool {
if (instance) return instance;
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error(
"DATABASE_URL fehlt. Erwartet wird ein PostgreSQL-Verbindungsstring — " +
"die Anwendung spricht direkt mit der Datenbank, nicht über eine API-Schicht."
);
}
instance = new Pool({
connectionString,
// Der Standard sind 10; bei serverseitigem Rendering hängt an jeder
// Anfrage genau eine Transaktion, und mehr Verbindungen als die Datenbank
// zulässt bringen nur Wartezeit an einer anderen Stelle.
max: Number(process.env.DATABASE_POOL_MAX ?? 10),
// Eine Anfrage, die länger braucht, ist kaputt und soll das melden statt
// eine Verbindung zu belegen.
statement_timeout: 20_000,
idle_in_transaction_session_timeout: 20_000,
connectionTimeoutMillis: 10_000,
// Verwaltete Anbieter (Azure, RDS, Supabase) verlangen TLS; lokal nicht.
ssl: process.env.DATABASE_SSL === "false" ? undefined : { rejectUnauthorized: false },
});
// Ein Fehler auf einer Leerlaufverbindung beendet sonst den Prozess.
instance.on("error", (err) => {
console.error("Unerwarteter Fehler auf einer Leerlaufverbindung:", err);
});
globalForPool.__alpenwerkPool = instance;
return instance;
}

View File

@@ -1,56 +0,0 @@
import "server-only";
import { sql, withUser, type Tx } from "./index";
import type { Database } from "@/lib/supabase/types";
// Aufruf einer Datenbankfunktion.
//
// Die Geschäftslogik liegt in PL/pgSQL — Eintritt, Versetzung, Austritt und
// die übrigen zehn Mutationen. Daran ändert der Wechsel des Zugriffswegs
// nichts: es fällt nur die API-Schicht dazwischen weg. Aufgerufen wird die
// Funktion jetzt unmittelbar, innerhalb der Transaktion, in der auch der
// Sitzungskontext gilt — ohne den würde require_hr_admin() darin abweisen.
export type MutationFn = keyof Database["public"]["Functions"];
/**
* Ruft `fn(payload)` innerhalb der laufenden Transaktion auf.
*
* `payload` weglassen für die Funktionen ohne Argument —
* apply_due_pending_changes() ist die einzige. Mit einem jsonb-Argument
* aufgerufen fände Postgres keine passende Signatur.
*/
export async function callFunction(tx: Tx, fn: MutationFn, payload?: Record<string, unknown>): Promise<unknown> {
// Der Funktionsname stammt aus einer geschlossenen Aufzählung, nie aus
// einer Eingabe — sonst wäre die Verkettung hier eine Einladung.
const name = sql.raw(`"${fn}"`);
const query =
payload === undefined
? sql<{ result: unknown }>`select ${name}() as result`
: sql<{ result: unknown }>`select ${name}(${sql.val(JSON.stringify(payload))}::jsonb) as result`;
const result = await query.execute(tx);
return result.rows[0]?.result;
}
export type ActionResult = { success: boolean; error?: string };
/**
* Eine Mutation im Namen der angemeldeten Person, mit der üblichen
* Fehlerbehandlung für Server Actions.
*
* Die Prüfung der Berechtigung passiert in der Funktion selbst
* (require_hr_admin) und unabhängig davon in den RLS-Policies — nicht hier.
*/
export async function runMutation(
userId: string | null,
fn: MutationFn,
payload: Record<string, unknown>
): Promise<ActionResult> {
try {
await withUser(userId, (tx) => callFunction(tx, fn, payload));
return { success: true };
} catch (err) {
// Die Meldungen der Funktionen sind für die Oberfläche geschrieben
// („Diese Planstelle ist bereits besetzt.") und werden durchgereicht.
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
}
}

View File

@@ -1,49 +0,0 @@
import type { ColumnType } from "kysely";
import type { Database } from "@/lib/supabase/types";
// Die Tabellenform für Kysely, abgeleitet aus der bestehenden
// Schemabeschreibung — nicht daneben gestellt.
//
// Zwei Beschreibungen desselben Schemas driften auseinander, und die eine
// hier ist bereits gegen die Migrationen abgesichert: `npm run types:check`
// vergleicht lib/supabase/types.ts Spalte für Spalte mit
// supabase/migrations/*.sql und schlägt in der CI fehl, wenn etwas fehlt.
// Diese Ableitung erbt diese Absicherung.
//
// Die Datei heisst noch lib/supabase/types.ts, weil sie aus der Zeit stammt,
// als PostgREST der Zugriffsweg war. Sie beschreibt reines PostgreSQL und
// wird beim Entfernen der Supabase-Pakete lediglich umbenannt.
type Tables = Database["public"]["Tables"];
/**
* Row/Insert/Update einer Tabelle in Kyselys ColumnType-Form.
*
* Kysely braucht die drei Richtungen getrennt: was beim Lesen herauskommt,
* was beim Einfügen erlaubt ist (Spalten mit Vorgabewert dürfen fehlen) und
* was beim Aktualisieren erlaubt ist.
*/
type Columns<T extends keyof Tables> = {
[K in keyof Tables[T]["Row"]]: K extends keyof Tables[T]["Insert"]
? ColumnType<
Tables[T]["Row"][K],
Tables[T]["Insert"][K],
K extends keyof Tables[T]["Update"] ? Tables[T]["Update"][K] : never
>
: // Spalten, die es nur beim Lesen gibt (von Triggern gesetzt).
ColumnType<Tables[T]["Row"][K], never, never>;
};
export type DB = { [T in keyof Tables]: Columns<T> };
/** Die Tabelle, die auth.users ablöst. Noch nicht in der Alt-Beschreibung. */
export type AppUsersTable = {
id: ColumnType<string, string | undefined, never>;
external_id: string;
email: string;
full_name: ColumnType<string | null, string | null | undefined, string | null>;
created_at: ColumnType<string, string | undefined, never>;
last_seen_at: ColumnType<string | null, string | null | undefined, string | null>;
};
export type Schema = DB & { app_users: AppUsersTable };

View File

@@ -1,5 +1,3 @@
import type { Expression, ExpressionBuilder, SqlBool } from "kysely";
import type { Schema } from "./db/schema";
import type { EmploymentStatus } from "./supabase/types";
// The SQL counterpart of deriveStatusAsOf() in lib/reports.ts.
@@ -21,59 +19,58 @@ import type { EmploymentStatus } from "./supabase/types";
// tests/integration/employee-status-filter.test.ts asserts the two agree
// against a real database, which is the only place that can prove it.
type Eb = ExpressionBuilder<Schema, "employees">;
type Filterable = {
gt: (column: string, value: string) => Filterable;
lte: (column: string, value: string) => Filterable;
gte: (column: string, value: string) => Filterable;
or: (filters: string) => Filterable;
is: (column: string, value: null) => Filterable;
not: (column: string, operator: string, value: null) => Filterable;
};
/** True once the person has started and has not left yet. */
function employed(eb: Eb, asOf: string): Expression<SqlBool> {
return eb.and([eb("entry_date", "<=", asOf), eb.or([eb("exit_date", "is", null), eb("exit_date", ">", asOf)])]);
function employed<Q extends Filterable>(query: Q, asOf: string): Q {
return query.lte("entry_date", asOf).or(`exit_date.is.null,exit_date.gt.${asOf}`) as Q;
}
/**
* Die Bedingung für die Menge, deren *abgeleiteter* Status am Stichtag einer
* der genannten ist — oder null, wenn nicht eingeschränkt werden soll.
*
* Nur die Kombinationen, die die Oberfläche anbietet, sind abgedeckt. Für
* alles andere kommt null zurück: lieber nicht filtern als falsch filtern.
* Narrows a PostgREST query to the employees whose *derived* status on
* `asOf` is one of `statuses`. Only the combinations the UI offers are
* supported; anything else is left unfiltered rather than silently applying
* a wrong one.
*/
export function derivedStatusFilter(eb: Eb, statuses: EmploymentStatus[], asOf: string): Expression<SqlBool> | null {
export function applyDerivedStatusFilter<Q extends Filterable>(query: Q, statuses: EmploymentStatus[], asOf: string): Q {
const wanted = new Set(statuses);
if (wanted.size === 0) return null;
if (wanted.size === 0) return query;
// A single non-employed status is a straight date comparison.
if (wanted.size === 1 && wanted.has("Geplant")) return eb("entry_date", ">", asOf);
if (wanted.size === 1 && wanted.has("Ausgetreten")) {
return eb.and([eb("exit_date", "is not", null), eb("exit_date", "<=", asOf)]);
}
if (wanted.size === 1 && wanted.has("Geplant")) return query.gt("entry_date", asOf) as Q;
if (wanted.size === 1 && wanted.has("Ausgetreten")) return query.not("exit_date", "is", null).lte("exit_date", asOf) as Q;
const wantsAktiv = wanted.has("Aktiv");
const wantsKarenz = wanted.has("Karenz");
if (wantsAktiv && wantsKarenz && wanted.size === 2) {
// Everyone employed today, whether or not they are on leave.
if (wantsAktiv && wantsKarenz && wanted.size === 2) return employed(eb, asOf);
return employed(query, asOf);
}
if (wantsKarenz && !wantsAktiv && wanted.size === 1) {
return eb.and([
employed(eb, asOf),
eb("karenz_start_date", "is not", null),
eb("karenz_start_date", "<=", asOf),
eb.or([eb("karenz_return_date", "is", null), eb("karenz_return_date", ">", asOf)]),
]);
return employed(query, asOf)
.not("karenz_start_date", "is", null)
.lte("karenz_start_date", asOf)
.or(`karenz_return_date.is.null,karenz_return_date.gt.${asOf}`) as Q;
}
if (wantsAktiv && !wantsKarenz && wanted.size === 1) {
// Employed but *not* inside a karenz window: either no start date, a
// start still ahead, or a return that has already happened.
return eb.and([
employed(eb, asOf),
eb.or([
eb("karenz_start_date", "is", null),
eb("karenz_start_date", ">", asOf),
eb("karenz_return_date", "<=", asOf),
]),
]);
return employed(query, asOf).or(
`karenz_start_date.is.null,karenz_start_date.gt.${asOf},karenz_return_date.lte.${asOf}`
) as Q;
}
// Mixed selections spanning employed and non-employed states have no UI
// path today; filtering on a guess would be worse than not filtering.
return null;
return query;
}

View File

@@ -1,335 +0,0 @@
import "server-only";
import { sql, type Tx } from "@/lib/db";
import { todayIso } from "@/lib/format";
import { deriveStatusAsOf } from "@/lib/reports";
import { normalizeSvnr } from "@/lib/svnr";
import type { Bestand, Datensatz, Zeile } from "./validate";
// Schreiben einer geprüften Datei.
//
// Der Aufruf steckt in **einer** Transaktion (siehe die Route). Das ist keine
// Vorsicht, sondern die Bedingung: eine halb geladene Organisation — Bereiche
// ohne Abteilungen, Planstellen ohne Personen — ist schlimmer als gar keine,
// weil sie nach Daten aussieht. Bricht irgendetwas ab, war nichts.
//
// Angelegt wird nur; aktualisiert wird nie. Was es schon gibt, hat die
// Prüfung vorher abgewiesen. Damit kann ein Tippfehler in einer Datei keine
// bestehenden Personaldaten überschreiben.
/** Wie viele Zeilen je INSERT. Postgres verträgt 65535 Parameter je Anweisung. */
const PORTION = 200;
export type Ladebericht = Record<string, number>;
const txt = (v: unknown): string | null => (typeof v === "string" && v !== "" ? v : null);
const zahl = (v: unknown): number | null => (typeof v === "number" ? v : null);
const bool = (v: unknown, ersatz: boolean): boolean => (typeof v === "boolean" ? v : ersatz);
const liste = (v: unknown): string[] | null => (Array.isArray(v) ? (v as string[]) : null);
async function einfuegen<T>(tx: Tx, tabelle: string, zeilen: T[]): Promise<void> {
for (let i = 0; i < zeilen.length; i += PORTION) {
await tx
// Die Tabellennamen stammen aus einer geschlossenen Aufzählung in
// diesem Modul, nie aus der Datei.
.insertInto(tabelle as never)
.values(zeilen.slice(i, i + PORTION) as never)
.execute();
}
}
/**
* Was bereits im System steht.
*
* Läuft in derselben Transaktion wie das Schreiben. Zwischen Prüfung und
* Schreiben könnte sonst jemand dieselbe Planstelle besetzen, und der Import
* liefe in den Teilindex statt in eine verständliche Meldung.
*/
export async function bestandLaden(tx: Tx): Promise<Bestand> {
const heute = todayIso();
const [standorte, einheiten, jobs, stellen, besetzungen, personen] = await Promise.all([
tx.selectFrom("locations").select(["id", "name"]).execute(),
tx.selectFrom("org_units").select(["id", "org_number"]).execute(),
tx.selectFrom("jobs").select(["id", "code"]).execute(),
tx.selectFrom("om_positions").select(["id", "position_number"]).execute(),
tx
.selectFrom("position_assignments")
.select(["position_id"])
.where((eb) => eb.or([eb("valid_to", "is", null), eb("valid_to", ">=", heute)]))
.execute(),
tx.selectFrom("employees").select(["id", "personnel_number", "email", "sv_nummer"]).execute(),
]);
const besetzt = new Set(besetzungen.map((b) => b.position_id));
return {
standorte: new Map(standorte.map((l) => [l.name, l.id])),
orgNummern: new Map(einheiten.map((o) => [o.org_number, o.id])),
jobCodes: new Map(jobs.map((j) => [j.code, j.id])),
planstellen: new Map(stellen.map((p) => [p.position_number, { id: p.id, besetzt: besetzt.has(p.id) }])),
personalnummern: new Map(personen.map((e) => [e.personnel_number, e.id])),
emails: new Set(personen.map((e) => e.email.toLowerCase())),
svNummern: new Set(personen.filter((e) => e.sv_nummer).map((e) => normalizeSvnr(e.sv_nummer!))),
};
}
/**
* Ordnet Einheiten so, dass jede nach ihrer übergeordneten kommt.
*
* Der Fremdschlüssel auf parent_id wird je Zeile geprüft, und eine Datei
* darf ihre Zeilen in beliebiger Reihenfolge führen — eine Mappe, die nach
* Bezeichnung sortiert ist, hätte sonst Pech.
*/
function elternZuerst(zeilen: Zeile[], bekannt: Set<string>): { sortiert: Zeile[]; ungeloest: Zeile[] } {
const offen = [...zeilen];
const sortiert: Zeile[] = [];
const erledigt = new Set(bekannt);
let fortschritt = true;
while (offen.length > 0 && fortschritt) {
fortschritt = false;
for (let i = offen.length - 1; i >= 0; i--) {
const eltern = txt(offen[i].werte.parent_org_number);
if (!eltern || erledigt.has(eltern)) {
const nummer = txt(offen[i].werte.org_number);
if (nummer) erledigt.add(nummer);
sortiert.push(offen[i]);
offen.splice(i, 1);
fortschritt = true;
}
}
}
// Was übrig bleibt, hängt in einem Kreis — die Prüfung fängt den einfachen
// Fall (sich selbst übergeordnet), längere Ketten fallen hier auf.
return { sortiert, ungeloest: offen };
}
export async function laden(
tx: Tx,
datensatz: Datensatz,
bestand: Bestand,
akteur: { userId: string; name: string }
): Promise<Ladebericht> {
const hole = (name: string) => datensatz[name] ?? [];
const bericht: Ladebericht = {};
const heute = todayIso();
// ── Standorte ─────────────────────────────────────────────────
const standortId = new Map(bestand.standorte);
const neueStandorte = hole("Standorte").map((z) => ({
name: txt(z.werte.name)!,
country: txt(z.werte.country)!,
}));
if (neueStandorte.length) {
const zurueck = await tx.insertInto("locations").values(neueStandorte).returning(["id", "name"]).execute();
for (const r of zurueck) standortId.set(r.name, r.id);
}
bericht.Standorte = neueStandorte.length;
// ── Organisation ──────────────────────────────────────────────
const orgId = new Map(bestand.orgNummern);
const { sortiert, ungeloest } = elternZuerst(hole("Organisation"), new Set(orgId.keys()));
if (ungeloest.length) {
throw new Error(
`Die übergeordneten Einheiten von ${ungeloest.length} Zeile(n) lassen sich nicht auflösen — vermutlich ein Kreis in der Spalte „Übergeordnet“.`
);
}
for (const z of sortiert) {
const eltern = txt(z.werte.parent_org_number);
const r = await tx
.insertInto("org_units")
.values({
org_number: txt(z.werte.org_number)!,
name: txt(z.werte.name)!,
unit_type: txt(z.werte.unit_type) as never,
parent_id: eltern ? orgId.get(eltern)! : null,
valid_from: txt(z.werte.valid_from) ?? heute,
valid_to: txt(z.werte.valid_to),
})
.returning(["id", "org_number"])
.execute();
orgId.set(r[0].org_number, r[0].id);
}
bericht.Organisation = sortiert.length;
// ── Jobkatalog ────────────────────────────────────────────────
const jobId = new Map(bestand.jobCodes);
const neueJobs = hole("Jobkatalog").map((z) => ({ code: txt(z.werte.code)!, title: txt(z.werte.title)! }));
if (neueJobs.length) {
const zurueck = await tx.insertInto("jobs").values(neueJobs).returning(["id", "code"]).execute();
for (const r of zurueck) jobId.set(r.code, r.id);
}
bericht.Jobkatalog = neueJobs.length;
// ── Planstellen ───────────────────────────────────────────────
const stellenId = new Map([...bestand.planstellen].map(([nr, p]) => [nr, p.id]));
const neueStellen = hole("Planstellen").map((z) => ({
position_number: txt(z.werte.position_number)!,
org_unit_id: orgId.get(txt(z.werte.org_number)!)!,
job_id: jobId.get(txt(z.werte.job_code)!)!,
is_chief: bool(z.werte.is_chief, false),
valid_from: txt(z.werte.valid_from) ?? heute,
valid_to: txt(z.werte.valid_to),
}));
if (neueStellen.length) {
const zurueck = await tx
.insertInto("om_positions")
.values(neueStellen)
.returning(["id", "position_number"])
.execute();
for (const r of zurueck) stellenId.set(r.position_number, r.id);
}
bericht.Planstellen = neueStellen.length;
// ── Personen ──────────────────────────────────────────────────
const personId = new Map(bestand.personalnummern);
const personenZeilen = hole("Personen");
const besetzungen: { position_id: string; employee_id: string; valid_from: string; valid_to: string | null }[] = [];
for (const z of personenZeilen) {
const w = z.werte;
const eintritt = txt(w.entry_date)!;
const austritt = txt(w.exit_date);
const karenzVon = txt(w.karenz_start_date);
const karenzBis = txt(w.karenz_return_date);
const werte = {
personnel_number: zahl(w.personnel_number)!,
first_name: txt(w.first_name)!,
last_name: txt(w.last_name)!,
gender: txt(w.gender) as never,
birth_date: txt(w.birth_date)!,
sv_nummer: txt(w.sv_nummer) ? normalizeSvnr(txt(w.sv_nummer)!) : null,
nationality: txt(w.nationality) ?? "Österreich",
address: txt(w.address),
postal_code: txt(w.postal_code),
city: txt(w.city),
address_country: txt(w.address_country),
email: txt(w.email)!,
phone: txt(w.phone),
title_prefix: liste(w.title_prefix) ?? [],
title_suffix: liste(w.title_suffix) ?? [],
job_title: txt(w.job_title)!,
location_id: standortId.get(txt(w.location)!)!,
employment_type: (txt(w.employment_type) ?? "Vollzeit") as never,
weekly_hours: zahl(w.weekly_hours) ?? 38.5,
// Die Prüfung hat jeden Eintrag gegen die Wochentage abgeglichen und
// auf die Schreibweise der Datenbank gebracht; hier steht deshalb
// sicher nur Mo…So.
work_days: (liste(w.work_days) ?? ["Mo", "Di", "Mi", "Do", "Fr"]) as never,
contract_type: (txt(w.contract_type) ?? "unbefristet") as never,
contract_end_date: txt(w.contract_end_date),
paygrade: (txt(w.paygrade) ?? "B") as never,
collective_agreement: (txt(w.collective_agreement) ?? "Handel") as never,
worker_type: (txt(w.worker_type) ?? "Angestellte:r") as never,
monthly_salary_gross: zahl(w.monthly_salary_gross),
source: (txt(w.source) ?? "Extern") as never,
is_betriebsrat: bool(w.is_betriebsrat, false),
has_dienstwagen: bool(w.has_dienstwagen, false),
is_laterale_fuehrung: bool(w.is_laterale_fuehrung, false),
is_c_level: bool(w.is_c_level, false),
entry_date: eintritt,
exit_date: austritt,
exit_reason: txt(w.exit_reason),
karenz_start_date: karenzVon,
karenz_return_date: karenzBis,
absence_type: txt(w.absence_type),
// Der Status wird **abgeleitet**, nicht importiert. Stünde er in der
// Datei, könnte er den Daten widersprechen — jemand mit Austritt und
// Status „Aktiv“ —, und die Anwendung leitet ihn ohnehin überall aus
// denselben Datumsangaben ab.
status: deriveStatusAsOf(
{ entry_date: eintritt, exit_date: austritt, karenz_start_date: karenzVon, karenz_return_date: karenzBis },
heute
),
};
// Von Hand geschrieben statt über den Abfragebauer, wegen genau eines
// Wortes: OVERRIDING SYSTEM VALUE.
//
// personnel_number ist GENERATED ALWAYS AS IDENTITY — die Datenbank
// vergibt sie und weist einen eigenen Wert sonst ab. Für eine Übernahme
// aus einem Altsystem ist das die falsche Richtung: die Nummer steht auf
// Lohnzetteln, in Akten und auf Ausweisen. Ein Import, der sie neu
// würfelt, ist keine Übernahme.
const spalten = Object.keys(werte);
const r = await sql<{ id: string; personnel_number: number }>`
insert into employees (${sql.raw(spalten.map((s) => `"${s}"`).join(", "))})
overriding system value
values (${sql.join(Object.values(werte).map((v) => sql.val(v)))})
returning id, personnel_number
`.execute(tx);
personId.set(r.rows[0].personnel_number, r.rows[0].id);
const stelle = txt(w.position_number);
if (stelle) {
besetzungen.push({
position_id: stellenId.get(stelle)!,
employee_id: r.rows[0].id,
valid_from: eintritt,
// Beim Austritt endet die Besetzung — sonst gälte die Planstelle als
// belegt und liesse sich nicht nachbesetzen.
valid_to: austritt,
});
}
}
bericht.Personen = personenZeilen.length;
// Den Zähler nachziehen. Ohne das vergibt die Datenbank für die nächste
// Neueinstellung eine Nummer, die der Import bereits verbraucht hat — und
// der eindeutige Index weist sie ab. Der Fehler träte erst Wochen später
// auf, beim ersten Eintritt nach der Übernahme.
if (personenZeilen.length > 0) {
await sql`
select setval(
pg_get_serial_sequence('employees', 'personnel_number'),
(select max(personnel_number) from employees)
)
`.execute(tx);
}
await einfuegen(tx, "position_assignments", besetzungen);
// ── Historie und Angehörige ───────────────────────────────────
const historie = hole("Historie").map((z) => ({
employee_id: personId.get(zahl(z.werte.personnel_number)!)!,
event_date: txt(z.werte.event_date)!,
event_type: txt(z.werte.event_type)! as never,
description: txt(z.werte.description)!,
}));
await einfuegen(tx, "employee_history", historie);
bericht.Historie = historie.length;
const angehoerige = hole("Angehörige").map((z) => ({
employee_id: personId.get(zahl(z.werte.personnel_number)!)!,
first_name: txt(z.werte.first_name)!,
last_name: txt(z.werte.last_name)!,
relationship: txt(z.werte.relationship)!,
birth_date: txt(z.werte.birth_date)!,
sv_nummer: txt(z.werte.sv_nummer) ? normalizeSvnr(txt(z.werte.sv_nummer)!) : null,
}));
await einfuegen(tx, "employee_dependents", angehoerige);
bericht.Angehörige = angehoerige.length;
// ── Protokoll ─────────────────────────────────────────────────
// Ein Eintrag für den ganzen Vorgang, nicht einer je Zeile: 800 Zeilen
// würden das Protokoll unlesbar machen, und der Vorgang ist ohnehin
// untrennbar — er ist eine Transaktion.
const zusammenfassung = Object.entries(bericht)
.filter(([, n]) => n > 0)
.map(([blatt, n]) => `${n} ${blatt}`)
.join(", ");
await tx
.insertInto("audit_log")
.values({
actor_user_id: akteur.userId,
actor_name: akteur.name,
action: "Import",
target_label: "Massenimport",
details: zusammenfassung || "nichts angelegt",
})
.execute();
return bericht;
}

View File

@@ -1,242 +0,0 @@
// Einlesen einer Importdatei — XLSX oder CSV.
//
// Zwei Dinge macht diese Datei und sonst nichts: aus Bytes werden benannte
// Blätter mit Zeilen, und aus Zellen werden verlässliche Rohwerte. Was die
// Werte *bedeuten* dürfen, steht in schema.ts; ob sie stimmen, entscheidet
// validate.ts. Diese Trennung ist der Grund, warum sich das Format testen
// lässt, ohne eine Datenbank oder eine Tabellenkalkulation zu brauchen.
import ExcelJS from "exceljs";
/** Eine Zeile, mit der Nummer aus der Datei — ohne die ist ein Fehler nutzlos. */
export type ImportRow = {
/** Zeilennummer wie in Excel angezeigt, Kopfzeile ist 1. */
zeile: number;
werte: Record<string, string>;
};
export type ImportSheet = {
name: string;
spalten: string[];
zeilen: ImportRow[];
};
export type ParseResult = {
blaetter: ImportSheet[];
/** Probleme beim Lesen selbst — kaputte Datei, leeres Blatt, doppelte Spalte. */
fehler: string[];
};
/**
* Trennzeichen einer CSV-Datei bestimmen.
*
* Deutschsprachiges Excel schreibt Semikolon, weil das Komma das
* Dezimaltrennzeichen ist. Eine feste Annahme auf `,` liest solche Dateien
* als eine einzige Spalte ein — und das sieht dann aus wie „die Datei hat
* keine der erwarteten Spalten", was in die Irre führt.
*
* Gezählt wird nur in der Kopfzeile, und nur ausserhalb von Anführungszeichen.
*/
export function trennzeichenErkennen(kopfzeile: string): string {
const kandidaten = [";", ",", "\t", "|"];
let bestes = ";";
let meiste = -1;
for (const k of kandidaten) {
let anzahl = 0;
let inAnfuehrung = false;
for (let i = 0; i < kopfzeile.length; i++) {
const c = kopfzeile[i];
if (c === '"') inAnfuehrung = !inAnfuehrung;
else if (c === k && !inAnfuehrung) anzahl++;
}
if (anzahl > meiste) {
meiste = anzahl;
bestes = k;
}
}
return bestes;
}
/**
* CSV nach RFC 4180, mit den Abweichungen, die in der Praxis vorkommen:
* Zeilenumbrüche innerhalb von Anführungszeichen, verdoppelte
* Anführungszeichen als Escape, CRLF wie LF.
*
* Eine eigene Zerlegung statt einer Bibliothek, weil genau diese drei Fälle
* das sind, woran naive Zerlegungen scheitern — und weil ein Feld mit einem
* Semikolon darin (eine Adresse, eine Beschreibung) sonst still die Spalten
* verschiebt und die Zeile plausibel falsch importiert wird.
*/
export function csvZerlegen(text: string, trennzeichen?: string): string[][] {
const ohneBom = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
const trenner = trennzeichen ?? trennzeichenErkennen(ohneBom.split(/\r?\n/, 1)[0] ?? "");
const zeilen: string[][] = [];
let feld = "";
let zeile: string[] = [];
let inAnfuehrung = false;
for (let i = 0; i < ohneBom.length; i++) {
const c = ohneBom[i];
if (inAnfuehrung) {
if (c === '"') {
if (ohneBom[i + 1] === '"') {
feld += '"';
i++;
} else {
inAnfuehrung = false;
}
} else {
feld += c;
}
continue;
}
if (c === '"') {
inAnfuehrung = true;
} else if (c === trenner) {
zeile.push(feld);
feld = "";
} else if (c === "\n") {
zeile.push(feld);
zeilen.push(zeile);
zeile = [];
feld = "";
} else if (c !== "\r") {
feld += c;
}
}
// Letzte Zeile ohne abschliessenden Umbruch.
if (feld !== "" || zeile.length > 0) {
zeile.push(feld);
zeilen.push(zeile);
}
return zeilen;
}
/** Leerzeichen weg, doppelte innen zusammenziehen — Kopfzeilen sind selten sauber. */
function spaltenName(roh: unknown): string {
return String(roh ?? "")
.replace(/\s+/g, " ")
.trim();
}
/**
* Zellwert als Zeichenkette.
*
* Datumswerte werden hier **nicht** interpretiert, sondern als ISO-Tag
* ausgegeben, wenn Excel sie bereits als Datum führt. Der Rest bleibt Text
* und wird erst in schema.ts gedeutet — dort weiss man, ob eine Spalte ein
* Datum sein soll, und kann einen Fehler melden statt zu raten.
*/
function zellText(wert: ExcelJS.CellValue): string {
if (wert === null || wert === undefined) return "";
if (wert instanceof Date) {
// Excel führt Datumswerte ohne Zeitzone; toISOString() würde sie über UTC
// schieben und in Österreich einen Tag zu früh ausgeben.
const m = String(wert.getUTCMonth() + 1).padStart(2, "0");
const t = String(wert.getUTCDate()).padStart(2, "0");
return `${wert.getUTCFullYear()}-${m}-${t}`;
}
if (typeof wert === "object") {
const o = wert as { text?: unknown; result?: unknown; richText?: { text: string }[]; error?: unknown };
if (Array.isArray(o.richText)) return o.richText.map((t) => t.text).join("");
// Formelzellen: das Ergebnis zählt, nicht die Formel. Eine Fehlerzelle
// (#NV, #WERT!) wird als Text durchgereicht und fällt in der Prüfung auf.
if (o.error !== undefined) return String(o.error);
if (o.result !== undefined) return zellText(o.result as ExcelJS.CellValue);
if (o.text !== undefined) return String(o.text);
return "";
}
return String(wert);
}
function zeilenAusMatrix(name: string, matrix: string[][]): { blatt: ImportSheet; fehler: string[] } {
const fehler: string[] = [];
const kopf = (matrix[0] ?? []).map(spaltenName);
// Doppelte Spaltennamen: die zweite überschriebe die erste stillschweigend.
const gesehen = new Set<string>();
for (const s of kopf) {
if (!s) continue;
if (gesehen.has(s)) fehler.push(`Blatt „${name}“: Spalte „${s}“ kommt mehrfach vor.`);
gesehen.add(s);
}
const zeilen: ImportRow[] = [];
for (let i = 1; i < matrix.length; i++) {
const roh = matrix[i];
const werte: Record<string, string> = {};
let leer = true;
for (let j = 0; j < kopf.length; j++) {
const spalte = kopf[j];
if (!spalte) continue;
const wert = (roh[j] ?? "").trim();
werte[spalte] = wert;
if (wert !== "") leer = false;
}
// Leerzeilen kommen in gepflegten Dateien ständig vor (Abstand, gelöschte
// Einträge) und sind keine Fehler.
if (!leer) zeilen.push({ zeile: i + 1, werte });
}
return { blatt: { name, spalten: kopf.filter(Boolean), zeilen }, fehler };
}
/** Liest eine XLSX-Mappe; jedes Arbeitsblatt wird ein Blatt. */
export async function xlsxLesen(daten: ArrayBuffer): Promise<ParseResult> {
const mappe = new ExcelJS.Workbook();
try {
await mappe.xlsx.load(daten);
} catch {
return { blaetter: [], fehler: ["Die Datei liess sich nicht als Excel-Mappe lesen."] };
}
const blaetter: ImportSheet[] = [];
const fehler: string[] = [];
mappe.eachSheet((arbeitsblatt) => {
const matrix: string[][] = [];
arbeitsblatt.eachRow({ includeEmpty: true }, (zeile) => {
const werte: string[] = [];
// `values` ist 1-basiert und hat an Position 0 eine Lücke.
const roh = zeile.values as ExcelJS.CellValue[];
for (let i = 1; i < roh.length; i++) werte.push(zellText(roh[i]));
matrix.push(werte);
});
if (matrix.length === 0) return;
const { blatt, fehler: f } = zeilenAusMatrix(arbeitsblatt.name.trim(), matrix);
blaetter.push(blatt);
fehler.push(...f);
});
return { blaetter, fehler };
}
/**
* Liest eine CSV-Datei als *ein* Blatt.
*
* Den Blattnamen liefert der Dateiname, weil eine CSV keinen kennt: aus
* „Personen.csv" wird das Blatt „Personen". Damit lassen sich mehrere CSVs
* genau wie die Blätter einer Mappe zusammensetzen.
*/
export function csvLesen(text: string, dateiname: string): ParseResult {
const matrix = csvZerlegen(text);
if (matrix.length === 0) return { blaetter: [], fehler: [`${dateiname}“ ist leer.`] };
const name = dateiname.replace(/\.[^.]+$/, "").trim();
const { blatt, fehler } = zeilenAusMatrix(name, matrix);
return { blaetter: [blatt], fehler };
}
/** Erkennt am Dateinamen, welcher Leser zuständig ist. */
export async function dateiLesen(dateiname: string, daten: ArrayBuffer): Promise<ParseResult> {
const endung = dateiname.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] ?? "";
if (endung === "xlsx" || endung === "xlsm") return xlsxLesen(daten);
if (endung === "csv" || endung === "txt") return csvLesen(new TextDecoder("utf-8").decode(daten), dateiname);
return {
blaetter: [],
fehler: [`${dateiname}“: unbekannte Dateiendung. Erwartet werden .xlsx oder .csv.`],
};
}

View File

@@ -1,449 +0,0 @@
import { ABSENCE_TYPES } from "@/lib/absence";
import { TITLE_PREFIXES, TITLE_SUFFIXES } from "@/lib/titles";
// Was in einer Importdatei stehen darf.
//
// Diese Datei ist die einzige Quelle für drei Dinge, die sonst
// auseinanderlaufen: die Prüfung beim Import, die Vorlage zum Herunterladen
// und die Hilfetexte in der Oberfläche. Eine Spalte, die hier nicht steht,
// gibt es nirgends — und eine, die hier dazukommt, taucht überall auf.
//
// Die Blattnamen sind zugleich die Dateinamen für den CSV-Weg: „Personen.csv"
// wird als Blatt „Personen" gelesen.
export type FeldArt =
| { art: "text" }
| { art: "datum" }
| { art: "zahl" }
| { art: "ganzzahl" }
| { art: "janein" }
| { art: "liste"; werte?: readonly string[] }
| { art: "auswahl"; werte: readonly string[] };
export type Spalte = {
/** Überschrift in der Datei. */
name: string;
/** Feld im Datensatz, den der Lader schreibt. */
ziel: string;
pflicht: boolean;
typ: FeldArt;
hinweis: string;
beispiel: string;
};
export type BlattSchema = {
name: string;
zweck: string;
/**
* Spalte, an der eine Zeile erkennbar ist. Doppelte Werte darin sind ein
* Fehler — sonst überschriebe die zweite Zeile die erste, und welche
* gewinnt, hinge an der Reihenfolge in der Datei.
*/
schluessel: string | null;
spalten: Spalte[];
};
const WOCHENTAGE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"] as const;
const VERHAELTNIS = ["Ehepartner:in", "Lebenspartner:in", "Kind", "Sonstige"] as const;
const EREIGNIS = [
"Eintritt",
"Beförderung",
"Versetzung",
"Karenz",
"Vertragsänderung",
"Stammdatenänderung",
"Austritt",
"Wiedereintritt",
"Reorganisation",
"Gehaltsanpassung",
"Rückkehr",
] as const;
export const BLATT_STANDORTE: BlattSchema = {
name: "Standorte",
zweck: "Betriebsstätten, auf die sich Personen beziehen.",
schluessel: "Bezeichnung",
spalten: [
{ name: "Bezeichnung", ziel: "name", pflicht: true, typ: { art: "text" }, hinweis: "Eindeutig.", beispiel: "Wien" },
{
name: "Land",
ziel: "country",
pflicht: true,
typ: { art: "text" },
hinweis: "Ausgeschrieben. Steuert unter anderem, ob eine SV-Nummer verlangt wird.",
beispiel: "Österreich",
},
],
};
export const BLATT_ORGANISATION: BlattSchema = {
name: "Organisation",
zweck: "Der Aufbau: Gesellschaft, Bereiche, Abteilungen, Teams.",
schluessel: "Orgnummer",
spalten: [
{
name: "Orgnummer",
ziel: "org_number",
pflicht: true,
typ: { art: "text" },
hinweis: "Eindeutig. Wird von Planstellen als Verweis benutzt.",
beispiel: "10000001",
},
{ name: "Bezeichnung", ziel: "name", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Produktion" },
{
name: "Art",
ziel: "unit_type",
pflicht: true,
typ: { art: "auswahl", werte: ["Gesellschaft", "Bereich", "Abteilung", "Team"] },
hinweis: "Genau eine Zeile darf „Gesellschaft“ sein — sie ist die Wurzel.",
beispiel: "Bereich",
},
{
name: "Übergeordnet",
ziel: "parent_org_number",
pflicht: false,
typ: { art: "text" },
hinweis: "Orgnummer der übergeordneten Einheit. Nur bei „Gesellschaft“ leer.",
beispiel: "10000000",
},
{
name: "Gültig ab",
ziel: "valid_from",
pflicht: false,
typ: { art: "datum" },
hinweis: "Leer = heute.",
beispiel: "01.01.2020",
},
{
name: "Gültig bis",
ziel: "valid_to",
pflicht: false,
typ: { art: "datum" },
hinweis: "Leer = unbefristet.",
beispiel: "",
},
],
};
export const BLATT_JOBKATALOG: BlattSchema = {
name: "Jobkatalog",
zweck: "Die Tätigkeiten, auf die Planstellen verweisen.",
schluessel: "Jobcode",
spalten: [
{ name: "Jobcode", ziel: "code", pflicht: true, typ: { art: "text" }, hinweis: "Eindeutig.", beispiel: "50000123" },
{
name: "Bezeichnung",
ziel: "title",
pflicht: true,
typ: { art: "text" },
hinweis: "",
beispiel: "Maschinenführer:in",
},
],
};
export const BLATT_PLANSTELLEN: BlattSchema = {
name: "Planstellen",
zweck: "Die Stellen selbst — unabhängig davon, wer sie besetzt.",
schluessel: "Planstellennummer",
spalten: [
{
name: "Planstellennummer",
ziel: "position_number",
pflicht: true,
typ: { art: "text" },
hinweis: "Eindeutig. Personen verweisen darauf.",
beispiel: "60000124",
},
{
name: "Orgnummer",
ziel: "org_number",
pflicht: true,
typ: { art: "text" },
hinweis: "Muss im Blatt „Organisation“ oder bereits im System stehen.",
beispiel: "10000001",
},
{
name: "Jobcode",
ziel: "job_code",
pflicht: true,
typ: { art: "text" },
hinweis: "Muss im Blatt „Jobkatalog“ oder bereits im System stehen.",
beispiel: "50000123",
},
{
name: "Leitung",
ziel: "is_chief",
pflicht: false,
typ: { art: "janein" },
hinweis: "Ja = Leitungsstelle der Einheit. Höchstens eine je Einheit.",
beispiel: "nein",
},
{ name: "Gültig ab", ziel: "valid_from", pflicht: false, typ: { art: "datum" }, hinweis: "Leer = heute.", beispiel: "01.01.2020" },
{ name: "Gültig bis", ziel: "valid_to", pflicht: false, typ: { art: "datum" }, hinweis: "", beispiel: "" },
],
};
export const BLATT_PERSONEN: BlattSchema = {
name: "Personen",
zweck: "Stammdaten, Vertrag und die Planstelle, auf der die Person sitzt.",
schluessel: "Personalnummer",
spalten: [
{
name: "Personalnummer",
ziel: "personnel_number",
pflicht: true,
typ: { art: "ganzzahl" },
hinweis: "Eindeutig. Historie und Angehörige verweisen darauf.",
beispiel: "2219",
},
{ name: "Vorname", ziel: "first_name", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Sabine" },
{ name: "Nachname", ziel: "last_name", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Aigner" },
{
name: "Geschlecht",
ziel: "gender",
pflicht: true,
typ: { art: "auswahl", werte: ["m", "w"] },
hinweis: "Wird für die SV-Nummer und die Anrede gebraucht.",
beispiel: "w",
},
{ name: "Geburtsdatum", ziel: "birth_date", pflicht: true, typ: { art: "datum" }, hinweis: "", beispiel: "15.08.1968" },
{
name: "SV-Nummer",
ziel: "sv_nummer",
pflicht: false,
typ: { art: "text" },
hinweis: "Zehnstellig. Prüfziffer und Geburtsdatum müssen zusammenpassen.",
beispiel: "7960 150868",
},
{
name: "Staatsbürgerschaft",
ziel: "nationality",
pflicht: false,
typ: { art: "text" },
hinweis: "Leer = Österreich.",
beispiel: "Österreich",
},
{ name: "E-Mail", ziel: "email", pflicht: true, typ: { art: "text" }, hinweis: "Eindeutig.", beispiel: "s.aigner@example.at" },
{ name: "Telefon", ziel: "phone", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "+43 660 1234567" },
{ name: "Adresse", ziel: "address", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "Hauptstraße 1" },
{ name: "PLZ", ziel: "postal_code", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "1010" },
{ name: "Ort", ziel: "city", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "Wien" },
{ name: "Land", ziel: "address_country", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "Österreich" },
{
name: "Titel vorangestellt",
ziel: "title_prefix",
pflicht: false,
// Feste Liste, weil die Datenbank eine CHECK-Bedingung darauf hat.
// Ohne die Aufzählung hier bräche der Import erst beim Schreiben ab.
typ: { art: "liste", werte: TITLE_PREFIXES },
hinweis: "Mehrere mit Semikolon. In einer CSV die Zelle in Anführungszeichen setzen.",
beispiel: "Mag.",
},
{
name: "Titel nachgestellt",
ziel: "title_suffix",
pflicht: false,
typ: { art: "liste", werte: TITLE_SUFFIXES },
hinweis: "Mehrere mit Semikolon.",
beispiel: "MSc",
},
{
name: "Tätigkeit",
ziel: "job_title",
pflicht: true,
typ: { art: "text" },
hinweis: "Die Bezeichnung der Person; kann von der Planstelle abweichen.",
beispiel: "Projektingenieur:in",
},
{
name: "Standort",
ziel: "location",
pflicht: true,
typ: { art: "text" },
hinweis: "Muss im Blatt „Standorte“ oder bereits im System stehen.",
beispiel: "Wien",
},
{
name: "Planstellennummer",
ziel: "position_number",
pflicht: true,
typ: { art: "text" },
hinweis: "Muss im Blatt „Planstellen“ oder bereits im System stehen und frei sein.",
beispiel: "60000124",
},
{ name: "Eintritt", ziel: "entry_date", pflicht: true, typ: { art: "datum" }, hinweis: "", beispiel: "01.03.2015" },
{
name: "Austritt",
ziel: "exit_date",
pflicht: false,
typ: { art: "datum" },
hinweis: "Leer = weiterhin beschäftigt. Muss nach dem Eintritt liegen.",
beispiel: "",
},
{ name: "Austrittsgrund", ziel: "exit_reason", pflicht: false, typ: { art: "text" }, hinweis: "Pflicht, wenn ein Austritt steht.", beispiel: "" },
{
name: "Beschäftigung",
ziel: "employment_type",
pflicht: false,
typ: { art: "auswahl", werte: ["Vollzeit", "Teilzeit"] },
hinweis: "Leer = Vollzeit.",
beispiel: "Vollzeit",
},
{ name: "Wochenstunden", ziel: "weekly_hours", pflicht: false, typ: { art: "zahl" }, hinweis: "Leer = 38,5.", beispiel: "38,5" },
{
name: "Arbeitstage",
ziel: "work_days",
pflicht: false,
typ: { art: "liste", werte: WOCHENTAGE },
hinweis: "Leer = Mo;Di;Mi;Do;Fr.",
beispiel: "Mo;Di;Mi;Do;Fr",
},
{
name: "Vertragsart",
ziel: "contract_type",
pflicht: false,
typ: { art: "auswahl", werte: ["unbefristet", "befristet"] },
hinweis: "Leer = unbefristet.",
beispiel: "unbefristet",
},
{
name: "Befristet bis",
ziel: "contract_end_date",
pflicht: false,
typ: { art: "datum" },
hinweis: "Pflicht bei „befristet“, sonst leer.",
beispiel: "",
},
{
name: "Verwendungsgruppe",
ziel: "paygrade",
pflicht: false,
typ: { art: "auswahl", werte: ["A", "B", "C", "D", "E", "F"] },
hinweis: "Leer = B.",
beispiel: "C",
},
{
name: "Kollektivvertrag",
ziel: "collective_agreement",
pflicht: false,
typ: { art: "auswahl", werte: ["Handel", "Süßwaren"] },
hinweis: "Leer = Handel.",
beispiel: "Handel",
},
{
name: "Beschäftigtengruppe",
ziel: "worker_type",
pflicht: false,
typ: { art: "auswahl", werte: ["Angestellte:r", "Arbeiter:in"] },
hinweis: "Leer = Angestellte:r.",
beispiel: "Angestellte:r",
},
{ name: "Monatsgehalt brutto", ziel: "monthly_salary_gross", pflicht: false, typ: { art: "zahl" }, hinweis: "", beispiel: "3450,00" },
{
name: "Herkunft",
ziel: "source",
pflicht: false,
typ: { art: "auswahl", werte: ["Intern", "Extern"] },
hinweis: "Leer = Extern.",
beispiel: "Extern",
},
{ name: "Betriebsrat", ziel: "is_betriebsrat", pflicht: false, typ: { art: "janein" }, hinweis: "Leer = nein.", beispiel: "nein" },
{ name: "Dienstwagen", ziel: "has_dienstwagen", pflicht: false, typ: { art: "janein" }, hinweis: "", beispiel: "nein" },
{ name: "Laterale Führung", ziel: "is_laterale_fuehrung", pflicht: false, typ: { art: "janein" }, hinweis: "", beispiel: "nein" },
{ name: "C-Level", ziel: "is_c_level", pflicht: false, typ: { art: "janein" }, hinweis: "", beispiel: "nein" },
{
name: "Abwesenheit ab",
ziel: "karenz_start_date",
pflicht: false,
typ: { art: "datum" },
hinweis: "Beginn einer Langzeitabwesenheit.",
beispiel: "",
},
{
name: "Rückkehr geplant",
ziel: "karenz_return_date",
pflicht: false,
typ: { art: "datum" },
hinweis: "Pflicht, wenn „Abwesenheit ab“ steht.",
beispiel: "",
},
{
name: "Abwesenheitsart",
ziel: "absence_type",
pflicht: false,
typ: { art: "auswahl", werte: ABSENCE_TYPES },
hinweis: "Pflicht, wenn „Abwesenheit ab“ steht.",
beispiel: "",
},
],
};
export const BLATT_HISTORIE: BlattSchema = {
name: "Historie",
zweck: "Ereignisse je Person. Alles, was vor dem Eintritt liegt, wird abgewiesen.",
schluessel: null,
spalten: [
{
name: "Personalnummer",
ziel: "personnel_number",
pflicht: true,
typ: { art: "ganzzahl" },
hinweis: "Muss im Blatt „Personen“ oder bereits im System stehen.",
beispiel: "2219",
},
{ name: "Datum", ziel: "event_date", pflicht: true, typ: { art: "datum" }, hinweis: "", beispiel: "01.03.2015" },
{
name: "Ereignis",
ziel: "event_type",
pflicht: true,
typ: { art: "auswahl", werte: EREIGNIS },
hinweis: "",
beispiel: "Eintritt",
},
{ name: "Beschreibung", ziel: "description", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Eintritt als Projektingenieur:in" },
],
};
export const BLATT_ANGEHOERIGE: BlattSchema = {
name: "Angehörige",
zweck: "Angehörige je Person.",
schluessel: null,
spalten: [
{ name: "Personalnummer", ziel: "personnel_number", pflicht: true, typ: { art: "ganzzahl" }, hinweis: "", beispiel: "2219" },
{ name: "Vorname", ziel: "first_name", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Julian" },
{ name: "Nachname", ziel: "last_name", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Aigner" },
{
name: "Verhältnis",
ziel: "relationship",
pflicht: true,
typ: { art: "auswahl", werte: VERHAELTNIS },
hinweis: "",
beispiel: "Kind",
},
{ name: "Geburtsdatum", ziel: "birth_date", pflicht: true, typ: { art: "datum" }, hinweis: "", beispiel: "04.06.2014" },
{ name: "SV-Nummer", ziel: "sv_nummer", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "" },
],
};
/**
* Reihenfolge = Ladereihenfolge.
*
* Standorte, Organisation und Jobkatalog müssen stehen, bevor Planstellen
* darauf verweisen können; Personen brauchen Planstellen; Historie und
* Angehörige brauchen Personen.
*/
export const BLAETTER: BlattSchema[] = [
BLATT_STANDORTE,
BLATT_ORGANISATION,
BLATT_JOBKATALOG,
BLATT_PLANSTELLEN,
BLATT_PERSONEN,
BLATT_HISTORIE,
BLATT_ANGEHOERIGE,
];
export function blattSchema(name: string): BlattSchema | undefined {
const gesucht = name.trim().toLowerCase();
return BLAETTER.find((b) => b.name.toLowerCase() === gesucht);
}

View File

@@ -1,405 +0,0 @@
import { todayIso } from "@/lib/format";
import { normalizeSvnr, svnrErrorMessage, validateSvnr } from "@/lib/svnr";
import type { ImportSheet } from "./parse";
import { BLAETTER, blattSchema, type BlattSchema, type Spalte } from "./schema";
import { alsAufzaehlung, alsDatum, alsGanzzahl, alsJaNein, alsListe, alsZahl } from "./werte";
// Prüfung einer eingelesenen Datei.
//
// Zwei Grundsätze, beide bewusst:
//
// 1. **Es wird alles gemeldet, nicht das erste.** Wer eine Datei mit 800
// Zeilen hochlädt, will nicht achthundertmal hochladen. Deshalb sammelt
// jede Prüfung weiter, statt abzubrechen.
//
// 2. **Der Bestand kommt als Parameter, nicht aus der Datenbank.** Damit
// bleibt diese Datei rein und ohne Verbindung testbar — und die
// Abfragen stehen an einer Stelle, wo man sie sieht (load.ts).
export type Befund = {
blatt: string;
/** Zeilennummer wie in Excel; null für Probleme am ganzen Blatt. */
zeile: number | null;
spalte: string | null;
wert?: string;
meldung: string;
};
/** Was bereits in der Datenbank steht — für Verweise und Doppelprüfungen. */
export type Bestand = {
standorte: Map<string, string>;
orgNummern: Map<string, string>;
jobCodes: Map<string, string>;
/** Planstellennummer → { id, heuteBesetzt } */
planstellen: Map<string, { id: string; besetzt: boolean }>;
personalnummern: Map<number, string>;
emails: Set<string>;
svNummern: Set<string>;
};
export const LEERER_BESTAND: Bestand = {
standorte: new Map(),
orgNummern: new Map(),
jobCodes: new Map(),
planstellen: new Map(),
personalnummern: new Map(),
emails: new Set(),
svNummern: new Set(),
};
export type Zeile = { zeile: number; werte: Record<string, unknown> };
export type Datensatz = Record<string, Zeile[]>;
export type Pruefergebnis = {
fehler: Befund[];
hinweise: Befund[];
datensatz: Datensatz;
/** Was angelegt würde, je Blatt. */
anzahl: Record<string, number>;
};
function leseFeld(spalte: Spalte, roh: string): { wert: unknown; fehler: string | null } {
if (roh === "") return { wert: null, fehler: null };
switch (spalte.typ.art) {
case "text":
return { wert: roh, fehler: null };
case "datum": {
const d = alsDatum(roh);
return d ? { wert: d, fehler: null } : { wert: null, fehler: "Kein gültiges Datum. Erwartet: 31.12.2026 oder 2026-12-31." };
}
case "zahl": {
const n = alsZahl(roh);
return n !== null ? { wert: n, fehler: null } : { wert: null, fehler: "Keine Zahl." };
}
case "ganzzahl": {
const n = alsGanzzahl(roh);
return n !== null ? { wert: n, fehler: null } : { wert: null, fehler: "Keine ganze Zahl." };
}
case "janein": {
const b = alsJaNein(roh);
return b !== null ? { wert: b, fehler: null } : { wert: null, fehler: "Erwartet: ja oder nein." };
}
case "liste": {
const l = alsListe(roh);
if (spalte.typ.werte) {
const erlaubt = spalte.typ.werte;
const treffer: string[] = [];
for (const t of l) {
const k = alsAufzaehlung(t, erlaubt);
if (!k) return { wert: null, fehler: `${t}“ ist unbekannt. Erlaubt: ${erlaubt.join(", ")}.` };
treffer.push(k);
}
return { wert: treffer, fehler: null };
}
return { wert: l, fehler: null };
}
case "auswahl": {
const w = alsAufzaehlung(roh, spalte.typ.werte);
return w ? { wert: w, fehler: null } : { wert: null, fehler: `Unbekannter Wert. Erlaubt: ${spalte.typ.werte.join(", ")}.` };
}
}
}
function blattLesen(schema: BlattSchema, blatt: ImportSheet, fehler: Befund[]): Zeile[] {
const vorhanden = new Set(blatt.spalten);
for (const s of schema.spalten) {
if (s.pflicht && !vorhanden.has(s.name)) {
fehler.push({ blatt: schema.name, zeile: null, spalte: s.name, meldung: "Pflichtspalte fehlt." });
}
}
for (const s of blatt.spalten) {
if (!schema.spalten.some((x) => x.name === s)) {
fehler.push({ blatt: schema.name, zeile: 1, spalte: s, meldung: "Unbekannte Spalte — wird nicht übernommen." });
}
}
const zeilen: Zeile[] = [];
const schluesselGesehen = new Map<string, number>();
for (const r of blatt.zeilen) {
const werte: Record<string, unknown> = {};
for (const s of schema.spalten) {
const roh = (r.werte[s.name] ?? "").trim();
if (roh === "" && s.pflicht) {
fehler.push({ blatt: schema.name, zeile: r.zeile, spalte: s.name, meldung: "Pflichtfeld ist leer." });
continue;
}
const { wert, fehler: f } = leseFeld(s, roh);
if (f) fehler.push({ blatt: schema.name, zeile: r.zeile, spalte: s.name, wert: roh, meldung: f });
else werte[s.ziel] = wert;
}
if (schema.schluessel) {
const sp = schema.spalten.find((x) => x.name === schema.schluessel)!;
const k = werte[sp.ziel];
if (k !== undefined && k !== null) {
const schluessel = String(k);
const zuvor = schluesselGesehen.get(schluessel);
if (zuvor !== undefined) {
fehler.push({
blatt: schema.name,
zeile: r.zeile,
spalte: schema.schluessel,
wert: schluessel,
meldung: `Kommt bereits in Zeile ${zuvor} vor. Welche Zeile gälte, hinge an der Reihenfolge in der Datei.`,
});
} else {
schluesselGesehen.set(schluessel, r.zeile);
}
}
}
zeilen.push({ zeile: r.zeile, werte });
}
return zeilen;
}
const s = (v: unknown): string | null => (typeof v === "string" && v ? v : null);
const n = (v: unknown): number | null => (typeof v === "number" ? v : null);
export function pruefe(blaetter: ImportSheet[], bestand: Bestand = LEERER_BESTAND): Pruefergebnis {
const fehler: Befund[] = [];
const hinweise: Befund[] = [];
const datensatz: Datensatz = {};
for (const blatt of blaetter) {
const schema = blattSchema(blatt.name);
if (!schema) {
hinweise.push({
blatt: blatt.name,
zeile: null,
spalte: null,
meldung: `Unbekanntes Blatt — wird übergangen. Erwartet: ${BLAETTER.map((b) => b.name).join(", ")}.`,
});
continue;
}
datensatz[schema.name] = blattLesen(schema, blatt, fehler);
}
const hole = (name: string) => datensatz[name] ?? [];
const melde = (blatt: string, zeile: number, spalte: string | null, meldung: string, wert?: string) =>
fehler.push({ blatt, zeile, spalte, wert, meldung });
// ── Standorte und Organisation ────────────────────────────────
const standorte = new Set([...bestand.standorte.keys()]);
for (const z of hole("Standorte")) {
const name = s(z.werte.name);
if (name) {
if (bestand.standorte.has(name)) melde("Standorte", z.zeile, "Bezeichnung", "Gibt es bereits.", name);
standorte.add(name);
}
}
const orgNummern = new Set([...bestand.orgNummern.keys()]);
const orgArten = new Map<string, string>();
let wurzeln = 0;
for (const z of hole("Organisation")) {
const nr = s(z.werte.org_number);
const art = s(z.werte.unit_type);
if (nr) {
if (bestand.orgNummern.has(nr)) melde("Organisation", z.zeile, "Orgnummer", "Gibt es bereits.", nr);
orgNummern.add(nr);
if (art) orgArten.set(nr, art);
}
if (art === "Gesellschaft") wurzeln++;
}
for (const z of hole("Organisation")) {
const eltern = s(z.werte.parent_org_number);
const art = s(z.werte.unit_type);
if (art === "Gesellschaft") {
if (eltern) melde("Organisation", z.zeile, "Übergeordnet", "Die Gesellschaft ist die Wurzel und hat nichts über sich.");
} else if (!eltern) {
melde("Organisation", z.zeile, "Übergeordnet", "Pflicht für alles ausser der Gesellschaft.");
} else if (!orgNummern.has(eltern)) {
melde("Organisation", z.zeile, "Übergeordnet", "Steht weder in der Datei noch im System.", eltern);
} else if (eltern === s(z.werte.org_number)) {
melde("Organisation", z.zeile, "Übergeordnet", "Eine Einheit kann sich nicht selbst übergeordnet sein.");
}
}
if (wurzeln > 1) {
fehler.push({ blatt: "Organisation", zeile: null, spalte: "Art", meldung: `Es gibt ${wurzeln} Zeilen „Gesellschaft“; genau eine ist erlaubt.` });
}
if (wurzeln === 0 && bestand.orgNummern.size === 0 && hole("Organisation").length > 0) {
fehler.push({ blatt: "Organisation", zeile: null, spalte: "Art", meldung: "Keine Zeile „Gesellschaft“ — der Aufbau hätte keine Wurzel." });
}
// ── Jobkatalog und Planstellen ────────────────────────────────
const jobCodes = new Set([...bestand.jobCodes.keys()]);
for (const z of hole("Jobkatalog")) {
const code = s(z.werte.code);
if (code) {
if (bestand.jobCodes.has(code)) melde("Jobkatalog", z.zeile, "Jobcode", "Gibt es bereits.", code);
jobCodes.add(code);
}
}
const planstellen = new Set([...bestand.planstellen.keys()]);
const leitungJeEinheit = new Map<string, number>();
for (const z of hole("Planstellen")) {
const nr = s(z.werte.position_number);
const org = s(z.werte.org_number);
const job = s(z.werte.job_code);
if (nr) {
if (bestand.planstellen.has(nr)) melde("Planstellen", z.zeile, "Planstellennummer", "Gibt es bereits.", nr);
planstellen.add(nr);
}
if (org && !orgNummern.has(org)) melde("Planstellen", z.zeile, "Orgnummer", "Steht weder in der Datei noch im System.", org);
if (job && !jobCodes.has(job)) melde("Planstellen", z.zeile, "Jobcode", "Steht weder in der Datei noch im System.", job);
if (z.werte.is_chief === true && org) {
const anzahl = (leitungJeEinheit.get(org) ?? 0) + 1;
leitungJeEinheit.set(org, anzahl);
if (anzahl === 2) {
melde("Planstellen", z.zeile, "Leitung", `Die Einheit ${org} hätte damit zwei Leitungsstellen.`);
}
}
const von = s(z.werte.valid_from);
const bis = s(z.werte.valid_to);
if (von && bis && bis < von) melde("Planstellen", z.zeile, "Gültig bis", "Liegt vor „Gültig ab“.");
}
// ── Personen ──────────────────────────────────────────────────
const heute = todayIso();
const personalnummern = new Set([...bestand.personalnummern.keys()]);
const emails = new Set([...bestand.emails]);
const svNummern = new Set([...bestand.svNummern]);
const belegtePlanstellen = new Map<string, number>();
const eintritte = new Map<number, string>();
for (const z of hole("Personen")) {
const pnr = n(z.werte.personnel_number);
const w = z.werte;
if (pnr !== null) {
if (bestand.personalnummern.has(pnr)) melde("Personen", z.zeile, "Personalnummer", "Gibt es bereits im System.", String(pnr));
personalnummern.add(pnr);
}
const email = s(w.email)?.toLowerCase();
if (email) {
if (emails.has(email)) melde("Personen", z.zeile, "E-Mail", "Kommt bereits vor.", email);
emails.add(email);
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) melde("Personen", z.zeile, "E-Mail", "Sieht nicht wie eine Adresse aus.", email);
}
const geburt = s(w.birth_date);
const svRoh = s(w.sv_nummer);
if (svRoh) {
const f = validateSvnr(svRoh, geburt);
if (f) melde("Personen", z.zeile, "SV-Nummer", svnrErrorMessage(f), svRoh);
else {
const norm = normalizeSvnr(svRoh);
if (svNummern.has(norm)) melde("Personen", z.zeile, "SV-Nummer", "Kommt bereits vor.", svRoh);
svNummern.add(norm);
}
}
if (geburt && geburt > heute) melde("Personen", z.zeile, "Geburtsdatum", "Liegt in der Zukunft.", geburt);
const standort = s(w.location);
if (standort && !standorte.has(standort)) melde("Personen", z.zeile, "Standort", "Steht weder in der Datei noch im System.", standort);
const stelle = s(w.position_number);
if (stelle) {
if (!planstellen.has(stelle)) {
melde("Personen", z.zeile, "Planstellennummer", "Steht weder in der Datei noch im System.", stelle);
} else if (bestand.planstellen.get(stelle)?.besetzt) {
melde("Personen", z.zeile, "Planstellennummer", "Diese Planstelle ist bereits besetzt.", stelle);
}
const zuvor = belegtePlanstellen.get(stelle);
// Doppelbesetzung ist in diesem Modell nicht bloss unsauber, sondern
// verboten — die Datenbank hat dafür einen Teilindex.
if (zuvor !== undefined) melde("Personen", z.zeile, "Planstellennummer", `Wird bereits in Zeile ${zuvor} besetzt.`, stelle);
else belegtePlanstellen.set(stelle, z.zeile);
}
const eintritt = s(w.entry_date);
const austritt = s(w.exit_date);
if (pnr !== null && eintritt) eintritte.set(pnr, eintritt);
if (eintritt && geburt && eintritt <= geburt) melde("Personen", z.zeile, "Eintritt", "Liegt vor dem Geburtsdatum.", eintritt);
// Gleicher Tag ist erlaubt — jemand, der den Dienst nicht antritt, tritt
// am selben Tag ein und aus. Die Datenbank sieht das genauso.
if (austritt && eintritt && austritt < eintritt) melde("Personen", z.zeile, "Austritt", "Liegt vor dem Eintritt.", austritt);
if (austritt && !s(w.exit_reason)) melde("Personen", z.zeile, "Austrittsgrund", "Pflicht, sobald ein Austritt steht.");
if (s(w.contract_type) === "befristet" && !s(w.contract_end_date)) {
melde("Personen", z.zeile, "Befristet bis", "Pflicht bei einem befristeten Vertrag.");
}
if (s(w.contract_type) !== "befristet" && s(w.contract_end_date)) {
melde("Personen", z.zeile, "Befristet bis", "Nur bei „befristet“ erlaubt.");
}
const abVon = s(w.karenz_start_date);
if (abVon) {
if (!s(w.karenz_return_date)) melde("Personen", z.zeile, "Rückkehr geplant", "Pflicht, sobald eine Abwesenheit beginnt.");
if (!s(w.absence_type)) melde("Personen", z.zeile, "Abwesenheitsart", "Pflicht, sobald eine Abwesenheit beginnt.");
if (eintritt && abVon < eintritt) melde("Personen", z.zeile, "Abwesenheit ab", "Liegt vor dem Eintritt.", abVon);
}
if (abVon && s(w.karenz_return_date) && s(w.karenz_return_date)! < (eintritt ?? "")) {
melde("Personen", z.zeile, "Rückkehr geplant", "Liegt vor dem Eintritt.", s(w.karenz_return_date)!);
}
// Vollzeit bedeutet in diesem Kollektivvertrag genau 38,5 Stunden, und
// Teilzeit alles darunter über null. Die Datenbank hat dafür eine
// CHECK-Bedingung; ohne diese Prüfung bräche der Import erst beim
// Schreiben ab — mit einer Meldung ohne Zeilennummer. Genau so ist der
// erste Durchstich gescheitert.
const stunden = n(w.weekly_hours);
const beschaeftigung = s(w.employment_type) ?? "Vollzeit";
if (stunden !== null) {
if (beschaeftigung === "Vollzeit" && stunden !== 38.5) {
melde("Personen", z.zeile, "Wochenstunden", "Vollzeit sind genau 38,5 Stunden. Für weniger „Teilzeit“ eintragen.", String(stunden));
}
if (beschaeftigung === "Teilzeit" && (stunden <= 0 || stunden >= 38.5)) {
melde("Personen", z.zeile, "Wochenstunden", "Teilzeit liegt zwischen 0 und 38,5 Stunden.", String(stunden));
}
} else if (beschaeftigung === "Teilzeit") {
melde("Personen", z.zeile, "Wochenstunden", "Pflicht bei Teilzeit — sonst gälten 38,5 und damit Vollzeit.");
}
const tage = w.work_days;
if (Array.isArray(tage) && tage.length === 0) {
melde("Personen", z.zeile, "Arbeitstage", "Mindestens ein Tag. Leer lassen für MoFr.");
}
}
// ── Historie und Angehörige ───────────────────────────────────
for (const z of hole("Historie")) {
const pnr = n(z.werte.personnel_number);
if (pnr === null) continue;
if (!personalnummern.has(pnr)) {
melde("Historie", z.zeile, "Personalnummer", "Steht weder in der Datei noch im System.", String(pnr));
continue;
}
// Nur für Personen aus derselben Datei; für bereits vorhandene kennt
// diese Funktion das Eintrittsdatum nicht, und die Datenbank prüft es
// ohnehin ein zweites Mal.
const eintritt = eintritte.get(pnr) ?? null;
const datum = s(z.werte.event_date);
// Die Datenbank weist das ohnehin ab (trg_history_not_before_entry) —
// aber mitten im Einfügen und mit einer Meldung ohne Zeilennummer.
if (eintritt && datum && datum < eintritt) {
melde("Historie", z.zeile, "Datum", `Liegt vor dem Eintritt am ${eintritt}.`, datum);
}
}
for (const z of hole("Angehörige")) {
const pnr = n(z.werte.personnel_number);
if (pnr === null) continue;
if (!personalnummern.has(pnr)) {
melde("Angehörige", z.zeile, "Personalnummer", "Steht weder in der Datei noch im System.", String(pnr));
continue;
}
const sv = s(z.werte.sv_nummer);
const geb = s(z.werte.birth_date);
if (sv) {
const f = validateSvnr(sv, geb);
if (f) melde("Angehörige", z.zeile, "SV-Nummer", svnrErrorMessage(f), sv);
}
}
const anzahl: Record<string, number> = {};
for (const b of BLAETTER) anzahl[b.name] = (datensatz[b.name] ?? []).length;
return { fehler, hinweise, datensatz, anzahl };
}

View File

@@ -1,125 +0,0 @@
// Aus einer Zelle wird ein Wert.
//
// Jede Funktion hier liefert entweder den Wert oder `null` — nie eine
// Näherung. Was sich nicht eindeutig lesen lässt, ist ein Fehler, den die
// Person in der Datei korrigieren soll. Raten wäre hier besonders teuer: aus
// „03.08.26" könnte 2026-08-03 oder 2003-08-26 werden, und beides sähe im
// Ergebnis unauffällig aus.
/** Kalendertag als „JJJJ-MM-TT". Ohne Zeitzone, weil ein Geburtstag keine hat. */
export type IsoTag = string;
function tagAusTeilen(jahr: number, monat: number, tag: number): IsoTag | null {
if (monat < 1 || monat > 12 || tag < 1 || tag > 31) return null;
const d = new Date(Date.UTC(jahr, monat - 1, tag));
// Fängt den 31. Februar: Date rechnet ihn stillschweigend in den 3. März um.
if (d.getUTCFullYear() !== jahr || d.getUTCMonth() !== monat - 1 || d.getUTCDate() !== tag) return null;
const m = String(monat).padStart(2, "0");
const t = String(tag).padStart(2, "0");
return `${jahr}-${m}-${t}`;
}
/**
* Datum aus einer Zelle.
*
* Angenommen werden ISO (2026-08-03), österreichisch (3.8.2026, 03.08.2026)
* und mit Schrägstrich (03/08/2026). Zweistellige Jahre werden **abgelehnt**:
* bei Geburtsdaten liegt das Jahrhundert nicht fest, und eine Regel wie
* „unter 30 heisst 20xx" produziert lautlos Personen, die noch nicht geboren
* sind.
*/
export function alsDatum(roh: string): IsoTag | null {
const s = roh.trim();
if (!s) return null;
const iso = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
if (iso) return tagAusTeilen(Number(iso[1]), Number(iso[2]), Number(iso[3]));
const deutsch = s.match(/^(\d{1,2})[.\/](\d{1,2})[.\/](\d{4})\.?$/);
if (deutsch) return tagAusTeilen(Number(deutsch[3]), Number(deutsch[2]), Number(deutsch[1]));
// Excel-Serienzahl — kommt vor, wenn die Spalte nicht als Datum formatiert
// ist. Tag 1 ist der 01.01.1900, und Excel kennt einen 29.02.1900, den es
// nie gab; ab Serie 60 muss deshalb ein Tag abgezogen werden.
if (/^\d{1,6}$/.test(s)) {
const serie = Number(s);
if (serie >= 1 && serie < 100000) {
const tage = serie >= 60 ? serie - 1 : serie;
const d = new Date(Date.UTC(1899, 11, 31) + tage * 86400000);
return tagAusTeilen(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate());
}
}
return null;
}
/**
* Zahl aus einer Zelle, mit Komma **oder** Punkt als Dezimaltrenner.
*
* „1.234,5" und „1,234.5" sind beide gebräuchlich und bedeuten dasselbe. Das
* letzte Trennzeichen entscheidet, die übrigen sind Tausenderpunkte.
*/
export function alsZahl(roh: string): number | null {
let s = roh.trim().replace(/\s/g, "");
if (!s) return null;
s = s.replace(/€|EUR/gi, "");
const letztesKomma = s.lastIndexOf(",");
const letzterPunkt = s.lastIndexOf(".");
if (letztesKomma >= 0 && letzterPunkt >= 0) {
const dezimal = letztesKomma > letzterPunkt ? "," : ".";
const tausender = dezimal === "," ? "." : ",";
s = s.split(tausender).join("").replace(dezimal, ".");
} else if (letztesKomma >= 0) {
// Ein einzelnes Komma ist im deutschsprachigen Raum ein Dezimaltrenner,
// auch bei „1,234" — das sind 1,234 und nicht 1234.
s = s.replace(",", ".");
}
if (!/^-?\d*\.?\d+$/.test(s)) return null;
const n = Number(s);
return Number.isFinite(n) ? n : null;
}
export function alsGanzzahl(roh: string): number | null {
const n = alsZahl(roh);
return n !== null && Number.isInteger(n) ? n : null;
}
const JA = new Set(["ja", "j", "x", "wahr", "true", "1", "y", "yes"]);
const NEIN = new Set(["nein", "n", "falsch", "false", "0", "no", "-"]);
/** Ja/Nein aus einer Zelle. Alles andere ist ein Fehler, nicht „nein". */
export function alsJaNein(roh: string): boolean | null {
const s = roh.trim().toLowerCase();
if (!s) return null;
if (JA.has(s)) return true;
if (NEIN.has(s)) return false;
return null;
}
/**
* Liste aus einer Zelle — für Titel und Arbeitstage.
*
* Getrennt wird an Semikolon oder Komma. Leere Glieder fallen weg, damit
* „Mo, Di, " nicht in einem leeren Arbeitstag endet.
*/
export function alsListe(roh: string): string[] {
return roh
.split(/[;,]/)
.map((t) => t.trim())
.filter(Boolean);
}
/**
* Einen Wert einer Aufzählung zuordnen, unabhängig von Gross- und
* Kleinschreibung und von Leerzeichen.
*
* Zurück kommt die **kanonische** Schreibweise aus der Datenbank, nicht die
* aus der Datei: „vollzeit" wird zu „Vollzeit", weil die Spalte ein enum ist
* und alles andere die Zeile beim Einfügen abweisen würde.
*/
export function alsAufzaehlung<T extends string>(roh: string, erlaubt: readonly T[]): T | null {
const s = roh.trim().toLowerCase();
if (!s) return null;
return erlaubt.find((w) => w.toLowerCase() === s) ?? null;
}

View File

@@ -1,32 +1,26 @@
import type { Tx } from "./db";
import type { SupabaseClient } from "@supabase/supabase-js";
import { fetchAllRows } from "./supabase/query";
import type { Database } from "./supabase/types";
export type OpenNote = Database["public"]["Tables"]["employee_notes"]["Row"] & {
employeeName: string;
};
// Meine Notizen" (Topbar-Glocke): das geteilte, mitarbeiterübergreifende
// Postfach aller noch nicht erledigten HR-Notizen — unabhängig davon, wer sie
// verfasst hat oder zu wem sie gehören (mit Nutzer abgestimmt).
//
// Früher zwei Abfragen, in JavaScript zusammengeführt, weil die API-Schicht
// für eine einzelne verschachtelte Abfrage keine Verknüpfung anbot. Am
// direkten Zugang ist es schlicht ein Join.
export async function loadOpenNotes(tx: Tx): Promise<OpenNote[]> {
const rows = await tx
.selectFrom("employee_notes as n")
.leftJoin("employees as e", "e.id", "n.employee_id")
.selectAll("n")
.select(["e.first_name", "e.last_name"])
.where("n.done", "=", false)
.orderBy("n.created_at", "desc")
.execute();
// "Meine Notizen" (Topbar-Glocke): das geteilte, mitarbeiterübergreifende
// Postfach aller noch nicht erledigten HR-Notizen — unabhängig davon wer
// sie verfasst hat oder zu wem sie gehören (mit Nutzer abgestimmt). Zwei
// einfache Queries, in JS gemerged — gleiches Muster wie loadEventHistory
// in lib/reports-data.ts, da der handgeschriebene Database-Typ keine
// relationalen Embeddings für eine einzelne verschachtelte Query kennt.
export async function loadOpenNotes(supabase: SupabaseClient<Database>): Promise<OpenNote[]> {
const [{ data: notes }, employees] = await Promise.all([
supabase.from("employee_notes").select("*").eq("done", false).order("created_at", { ascending: false }),
fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name").order("id")),
]);
return rows.map((row) => {
const { first_name, last_name, ...note } = row;
return {
...(note as Database["public"]["Tables"]["employee_notes"]["Row"]),
employeeName: first_name && last_name ? `${first_name} ${last_name}` : "Unbekannt",
};
const employeeById = new Map(employees.map((e) => [e.id, e]));
return (notes ?? []).map((n) => {
const emp = employeeById.get(n.employee_id);
return { ...n, employeeName: emp ? `${emp.first_name} ${emp.last_name}` : "Unbekannt" };
});
}

View File

@@ -1,85 +0,0 @@
// Die SAP-OM-Berichtslinie, abgeleitet aus dem Organisationsbaum.
//
// Dieselbe Regel steckt als om_reporting_lines() in der Datenbank. Zwei
// Fassungen derselben Regel driften auseinander, deshalb prüft
// tests/integration/om-reporting.test.ts beide gegen denselben Bestand.
// Hier liegt sie zusätzlich, weil das Organigramm zu einem Stichtag ohnehin
// im Anwendungscode gerechnet wird und ein Datenbank-Roundtrip je
// Stichtagswechsel nichts brächte.
//
// Regel:
// Wer eine gewöhnliche Planstelle innehat, berichtet an die Leitung der
// eigenen Einheit. Wer selbst die Leitung innehat, an die Leitung der
// übergeordneten Einheit.
//
// Aufwärtsregel: Ist diese Leitung unbesetzt oder langzeitabwesend, geht es
// weiter nach oben. Eine unbesetzte Abteilungsleitung braucht damit keine
// Sonderbehandlung — sie wird übersprungen.
export type OmUnit = { id: string; parentId: string | null };
export type OmHolder = {
employeeId: string;
positionId: string;
orgUnitId: string;
isChief: boolean;
/** Langzeitabwesend am Stichtag. */
absent: boolean;
};
export type OmReportingLine = {
employeeId: string;
positionId: string;
orgUnitId: string;
isChief: boolean;
/** Zuständige Leitung, auch wenn abwesend. Null, wenn es keine gibt. */
formalManagerId: string | null;
/** Nächste besetzte und anwesende Leitung ab der zuständigen Einheit aufwärts. */
actingManagerId: string | null;
};
/**
* `units` und `holders` beschreiben den Stand zu genau einem Stichtag —
* gültige Einheiten und laufende Besetzungen. Die Zeitlogik bleibt bewusst
* draußen, damit diese Funktion nur eine Sache tut.
*/
export function resolveReportingLines(units: OmUnit[], holders: OmHolder[]): OmReportingLine[] {
const parentOf = new Map(units.map((u) => [u.id, u.parentId]));
const chiefOfUnit = new Map<string, OmHolder>();
for (const h of holders) {
if (h.isChief) chiefOfUnit.set(h.orgUnitId, h);
}
return holders.map((h) => {
// Leitungen suchen ab der übergeordneten Einheit, alle anderen ab der
// eigenen — sonst berichtete eine Leitung an sich selbst.
const baseUnitId = h.isChief ? (parentOf.get(h.orgUnitId) ?? null) : h.orgUnitId;
const formal = baseUnitId ? (chiefOfUnit.get(baseUnitId) ?? null) : null;
// Aufwärts, bis eine besetzte und anwesende Leitung gefunden ist. Der
// Zyklusschutz ist kein Selbstzweck: parent_id ist eine gewöhnliche
// Spalte, und ein fehlerhafter Import kann einen Ring erzeugen.
let actingManagerId: string | null = null;
const seen = new Set<string>();
let unitId: string | null = baseUnitId;
while (unitId && !seen.has(unitId)) {
seen.add(unitId);
const chief = chiefOfUnit.get(unitId);
if (chief && !chief.absent && chief.employeeId !== h.employeeId) {
actingManagerId = chief.employeeId;
break;
}
unitId = parentOf.get(unitId) ?? null;
}
return {
employeeId: h.employeeId,
positionId: h.positionId,
orgUnitId: h.orgUnitId,
isChief: h.isChief,
formalManagerId: formal?.employeeId ?? null,
actingManagerId,
};
});
}

View File

@@ -1,123 +1,48 @@
import type { Tx } from "./db";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "./supabase/types";
// Die Organisation ist ein Baum, keine drei Tabellen mehr. Alles, was früher
// aus divisions/departments/teams zusammengesteckt wurde, ergibt sich jetzt
// aus org_units.parent_id — und damit funktioniert es auch für eine fünfte
// Ebene, ohne dass hier etwas zu ändern wäre.
export type OrgUnitType = "Gesellschaft" | "Bereich" | "Abteilung" | "Team";
export type OrgUnit = {
id: string;
org_number: string;
name: string;
parent_id: string | null;
unit_type: OrgUnitType;
};
type Division = Database["public"]["Tables"]["divisions"]["Row"];
type Department = Database["public"]["Tables"]["departments"]["Row"];
type Team = Database["public"]["Tables"]["teams"]["Row"];
type Location = Database["public"]["Tables"]["locations"]["Row"];
export type OrgMaps = {
units: Map<string, OrgUnit>;
/** Tiefensuche ab der Wurzel: eine Einheit steht immer hinter ihrem Elternteil. */
unitList: OrgUnit[];
/** Abstand zur Wurzel; die Wurzel selbst hat 0. */
depthOf: Map<string, number>;
childrenOf: Map<string | null, OrgUnit[]>;
divisions: Map<string, Division>;
departments: Map<string, Department>;
teams: Map<string, Team>;
locations: Map<string, Location>;
divisionList: Division[];
locationList: Location[];
};
// Die Referenzdaten sind winzig (60 Einheiten, 5 Standorte) — sie werden
// ganz geladen und im Speicher verknüpft, statt je Zeile nachzuschlagen.
export async function loadOrgMaps(tx: Tx): Promise<OrgMaps> {
const [units, locations] = await Promise.all([
tx
.selectFrom("org_units")
.select(["id", "org_number", "name", "parent_id", "unit_type"])
.orderBy("org_number")
.execute(),
tx.selectFrom("locations").selectAll().orderBy("name").execute(),
// Org reference data is tiny (9 divisions / 16 departments / 35 teams / 5
// locations) — fetched whole and joined client-side rather than per-row.
export async function loadOrgMaps(supabase: SupabaseClient<Database>): Promise<OrgMaps> {
const [{ data: divisions }, { data: departments }, { data: teams }, { data: locations }] = await Promise.all([
supabase.from("divisions").select("*").order("name"),
supabase.from("departments").select("*"),
supabase.from("teams").select("*"),
supabase.from("locations").select("*").order("name"),
]);
return buildOrgMaps(units as OrgUnit[], locations as Location[]);
}
/** Der reine Teil: aus den Zeilen den Baum bauen, ohne Datenbank. */
export function buildOrgMaps(units: OrgUnit[], locations: Location[]): OrgMaps {
const childrenOf = new Map<string | null, OrgUnit[]>();
for (const u of units) {
const list = childrenOf.get(u.parent_id) ?? [];
list.push(u);
childrenOf.set(u.parent_id, list);
}
for (const list of childrenOf.values()) list.sort((a, b) => a.name.localeCompare(b.name, "de"));
const unitList: OrgUnit[] = [];
const depthOf = new Map<string, number>();
const walk = (parentId: string | null, depth: number) => {
for (const u of childrenOf.get(parentId) ?? []) {
unitList.push(u);
depthOf.set(u.id, depth);
walk(u.id, depth + 1);
}
};
walk(null, 0);
return {
units: new Map(units.map((u) => [u.id, u])),
unitList,
depthOf,
childrenOf,
locations: new Map(locations.map((l) => [l.id, l])),
locationList: locations,
divisions: new Map((divisions ?? []).map((d) => [d.id, d])),
departments: new Map((departments ?? []).map((d) => [d.id, d])),
teams: new Map((teams ?? []).map((t) => [t.id, t])),
locations: new Map((locations ?? []).map((l) => [l.id, l])),
divisionList: divisions ?? [],
locationList: locations ?? [],
};
}
/** Wurzel zuerst, die Einheit selbst zuletzt. */
export function ancestorsOf(maps: OrgMaps, unitId: string | null | undefined): OrgUnit[] {
const chain: OrgUnit[] = [];
const seen = new Set<string>();
let current = unitId ? maps.units.get(unitId) : undefined;
while (current && !seen.has(current.id)) {
seen.add(current.id);
chain.unshift(current);
current = current.parent_id ? maps.units.get(current.parent_id) : undefined;
}
return chain;
export function breadcrumbFor(orgMaps: OrgMaps, divisionId: string | null, teamId: string | null) {
const division = divisionId ? orgMaps.divisions.get(divisionId) : undefined;
const team = teamId ? orgMaps.teams.get(teamId) : undefined;
const department = team ? orgMaps.departments.get(team.department_id) : undefined;
return { division, department, team };
}
/** Die Einheit und alles darunter — die Menge, die ein Filter „Bereich X" meint. */
export function subtreeOf(maps: OrgMaps, unitId: string): string[] {
const out: string[] = [];
const queue = [unitId];
const seen = new Set<string>();
while (queue.length > 0) {
const id = queue.shift()!;
if (seen.has(id)) continue;
seen.add(id);
out.push(id);
for (const child of maps.childrenOf.get(id) ?? []) queue.push(child.id);
}
return out;
}
/**
* „Produktion Fertigung Montage". Die Gesellschaft bleibt weg: sie steht
* über allem und trägt in einer Zeile nichts bei.
*/
export function breadcrumbLabel(maps: OrgMaps, unitId: string | null | undefined): string {
const chain = ancestorsOf(maps, unitId).filter((u) => u.unit_type !== "Gesellschaft");
return chain.map((u) => u.name).join(" ") || "";
}
/** Die oberste Einheit unterhalb der Gesellschaft — das, was früher „Bereich" hiess. */
export function divisionOf(maps: OrgMaps, unitId: string | null | undefined): OrgUnit | undefined {
return ancestorsOf(maps, unitId).find((u) => u.unit_type !== "Gesellschaft");
}
/** Die Einheit selbst, wenn sie nicht die Gesellschaft ist. */
export function unitOf(maps: OrgMaps, unitId: string | null | undefined): OrgUnit | undefined {
return unitId ? maps.units.get(unitId) : undefined;
export function breadcrumbLabel(orgMaps: OrgMaps, divisionId: string | null, teamId: string | null): string {
const { division, department, team } = breadcrumbFor(orgMaps, divisionId, teamId);
return [division?.name, department?.name, team?.name].filter(Boolean).join(" ") || "";
}

View File

@@ -1,33 +1,32 @@
import type { OrgEmployee, OrgVacancy } from "@/components/orgchart/types";
import type { Tx } from "./db";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { OrgEmployee } from "@/components/orgchart/types";
import { resolveActingManagers } from "./acting-manager";
import { todayIso } from "./format";
import { resolveReportingLines, type OmHolder, type OmUnit } from "./om-reporting";
import { deriveStatusAsOf } from "./reports";
import { fetchAllRows } from "./supabase/query";
import type { Database } from "./supabase/types";
// Das Organigramm, wie es an einem Stichtag stand oder stehen wird.
// The Organigramm as it stood (or will stand) on a given date. Three sources
// have to be reconciled, because no single one covers the whole timeline:
//
// Im Altmodell mussten dafür drei Quellen versöhnt werden, weil keine den
// ganzen Zeitstrahl abdeckte: eine mitgeschriebene Zuordnungshistorie für die
// Vergangenheit, vorgemerkte Änderungen für die Zukunft und die
// Ein-/Austrittsdaten für die Frage, wer überhaupt dazuzählte.
// past/today employee_assignments — the interval covering `asOf`
// future pending_org_changes — effective-dated moves not yet applied
// membership entry/exit/karenz — who counted as staff on that date
//
// Im OM-Modell fällt das zusammen. position_assignments ist zeitabhängig, also
// beantwortet eine einzige Abfrage „wer besetzte am Stichtag welche
// Planstelle" — für Vergangenheit und Zukunft gleichermassen. Wer zu dem
// Zeitpunkt keine Planstelle innehatte, war nicht da; eine zweite
// Zugehörigkeitsregel braucht es nicht mehr.
//
// Übrig bleibt die Projektion vorgemerkter Versetzungen: die stehen noch nicht
// in position_assignments, weil sie erst am Stichtag geschrieben werden.
// See supabase/migrations/*_employee_assignment_history.sql for why the
// placement timeline is captured by a trigger rather than per-RPC.
/** Änderungsarten, die jemanden in der Organisation verschieben. */
const PLACEMENT_CHANGES = ["transfer"] as const;
const ORG_COLUMNS =
"id, personnel_number, first_name, last_name, job_title, manager_id, team_id, division_id, is_lead, org_level, entry_date, exit_date, karenz_start_date, karenz_return_date, absence_type";
/** Change types that move someone in the org; the rest only affect status or contract. */
const PLACEMENT_CHANGES = ["transfer", "reorg", "promotion"] as const;
export type OrgAsOfResult = {
employees: OrgEmployee[];
vacancies: OrgVacancy[];
/** Wie viele Platzierungen aus noch nicht angewandten Änderungen stammen. */
/** How many placements were projected from not-yet-applied changes. */
projectedCount: number;
/** Frühester Tag, den die Besetzungshistorie tatsächlich abdeckt. */
/** Earliest date the assignment history actually covers. */
historyStartsAt: string | null;
};
@@ -37,193 +36,196 @@ type EmployeeRow = {
first_name: string;
last_name: string;
job_title: string;
manager_id: string | null;
team_id: string | null;
division_id: string;
is_lead: boolean;
org_level: number;
entry_date: string;
exit_date: string | null;
karenz_start_date: string | null;
karenz_return_date: string | null;
absence_type: string | null;
};
type PositionRow = {
id: string;
position_number: string;
org_unit_id: string;
is_chief: boolean;
jobs: { title: string };
type AssignmentRow = {
employee_id: string;
manager_id: string | null;
team_id: string | null;
division_id: string;
job_title: string;
is_lead: boolean;
org_level: number;
valid_from: string;
};
type AssignmentRow = { employee_id: string; position_id: string };
type PendingRow = { employee_id: string; effective_date: string; payload: Record<string, unknown> };
export async function loadOrgAsOf(tx: Tx, asOf: string): Promise<OrgAsOfResult> {
type Placement = { team_id: string | null; division_id: string; job_title: string; is_lead: boolean; org_level: number };
// Mirrors resolve_manager_for() in supabase/migrations: an IC reports to
// their team's lead, a team lead to the division head, and anyone without a
// team to the CEO. Only used for employees a pending change actually moves —
// everyone else keeps the manager recorded on their assignment, so existing
// data that deviates from the rule is never silently "corrected".
function resolveManagerFor(placement: Placement, all: { id: string; placement: Placement }[]): string | null {
if (placement.team_id && !placement.is_lead) {
return all.find((e) => e.placement.team_id === placement.team_id && e.placement.is_lead)?.id ?? null;
}
if (placement.team_id && placement.is_lead) {
return (
all.find((e) => e.placement.division_id === placement.division_id && !e.placement.team_id && e.placement.org_level === 1)?.id ??
null
);
}
return all.find((e) => e.placement.org_level === 0)?.id ?? null;
}
export async function loadOrgAsOf(supabase: SupabaseClient<Database>, asOf: string): Promise<OrgAsOfResult> {
const today = todayIso();
// Ohne die 1000-Zeilen-Grenze der API-Schicht fällt das seitenweise
// Nachladen weg: sechs Abfragen, jede vollständig.
const [units, positions, assignments, employees, pending, earliest] = await Promise.all([
tx.selectFrom("org_units").select(["id", "parent_id"]).orderBy("id").execute(),
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", "j.title"])
.where("p.valid_from", "<=", asOf)
.where((eb) => eb.or([eb("p.valid_to", "is", null), eb("p.valid_to", ">", asOf)]))
.orderBy("p.id")
.execute(),
tx
.selectFrom("position_assignments")
.select(["employee_id", "position_id"])
.where("valid_from", "<=", asOf)
.where((eb) => eb.or([eb("valid_to", "is", null), eb("valid_to", ">", asOf)]))
.orderBy("employee_id")
.execute(),
tx
.selectFrom("employees")
.select([
"id",
"personnel_number",
"first_name",
"last_name",
"job_title",
"karenz_start_date",
"karenz_return_date",
"absence_type",
])
.orderBy("id")
.execute(),
const [allEmployees, assignments, teams, departments, pending] = await Promise.all([
fetchAllRows(() => supabase.from("employees").select(ORG_COLUMNS).order("id")),
fetchAllRows(() =>
supabase
.from("employee_assignments")
.select("employee_id, manager_id, team_id, division_id, job_title, is_lead, org_level, valid_from")
.lte("valid_from", asOf)
.or(`valid_to.is.null,valid_to.gt.${asOf}`)
.order("employee_id")
),
fetchAllRows(() => supabase.from("teams").select("id, department_id").order("id")),
fetchAllRows(() => supabase.from("departments").select("id, division_id").order("id")),
asOf > today
? tx
.selectFrom("pending_org_changes")
.select(["employee_id", "effective_date", "payload"])
.where("status", "=", "pending")
.where("effective_date", "<=", asOf)
.where("change_type", "in", [...PLACEMENT_CHANGES])
.orderBy("effective_date")
.execute()
? fetchAllRows(() =>
supabase
.from("pending_org_changes")
.select("employee_id, change_type, effective_date, payload")
.eq("status", "pending")
.lte("effective_date", asOf)
.in("change_type", [...PLACEMENT_CHANGES])
.order("effective_date")
)
: Promise.resolve([]),
tx.selectFrom("position_assignments").select("valid_from").orderBy("valid_from").limit(1).executeTakeFirst(),
]);
return resolveOrgSnapshot({
asOf,
units: units.map((u) => ({ id: u.id, parentId: u.parent_id })),
// Der Join liefert den Jobtitel flach; die reine Funktion erwartet ihn
// verschachtelt, weil sie so auch aus einem Testbestand gefüttert wird.
positions: positions.map((p) => ({
id: p.id,
position_number: p.position_number,
org_unit_id: p.org_unit_id,
is_chief: p.is_chief,
jobs: { title: p.title },
})),
assignments: assignments as AssignmentRow[],
employees: employees as EmployeeRow[],
pending: pending as PendingRow[],
historyStartsAt: earliest?.valid_from ?? null,
});
return resolveOrgSnapshot({ asOf, employees: allEmployees, assignments, teams, departments, pending });
}
/**
* Der reine Teil: aus den Zeilen den Stand machen, ohne Datenbank, damit die
* Regeln direkt prüfbar sind.
*/
// The pure half of the above: everything that turns the four row sets into a
// snapshot, with no Supabase client in sight, so the reconciliation rules can
// be tested directly.
export function resolveOrgSnapshot({
asOf,
units,
positions,
assignments,
employees: allEmployees,
assignments,
teams,
departments,
pending,
historyStartsAt,
}: {
asOf: string;
units: OmUnit[];
positions: PositionRow[];
assignments: AssignmentRow[];
employees: EmployeeRow[];
assignments: AssignmentRow[];
teams: { id: string; department_id: string }[];
departments: { id: string; division_id: string }[];
pending: PendingRow[];
historyStartsAt: string | null;
}): OrgAsOfResult {
const positionById = new Map(positions.map((p) => [p.id, p]));
const employeeById = new Map(allEmployees.map((e) => [e.id, e]));
const assignmentByEmployee = new Map(assignments.map((a) => [a.employee_id, a]));
// A projected move names only the target team; its division follows from
// the team's department, the same way the DB trigger derives it.
const departmentDivision = new Map(departments.map((d) => [d.id, d.division_id]));
const teamDivision = new Map(
teams.flatMap((t) => {
const divisionId = departmentDivision.get(t.department_id);
return divisionId ? [[t.id, divisionId] as const] : [];
})
);
// Dieselbe Ableitung wie in deriveStatusAsOf() und in om_reporting_lines(),
// damit die drei nie auseinanderlaufen können.
const isAbsent = (e: EmployeeRow) =>
e.karenz_start_date !== null &&
e.karenz_start_date <= asOf &&
(e.karenz_return_date === null || asOf < e.karenz_return_date);
const positionOf = new Map<string, string>();
for (const a of assignments) {
if (positionById.has(a.position_id) && employeeById.has(a.employee_id)) positionOf.set(a.employee_id, a.position_id);
}
// Die Zukunft projizieren: nach effective_date sortiert, eine spätere
// Versetzung gewinnt.
const moved = new Set<string>();
for (const change of pending) {
if (!positionOf.has(change.employee_id)) continue;
const targetId = (change.payload as { target_position_id?: string }).target_position_id;
if (!targetId || !positionById.has(targetId)) continue;
positionOf.set(change.employee_id, targetId);
moved.add(change.employee_id);
}
const holders: OmHolder[] = [];
for (const [employeeId, positionId] of positionOf) {
const position = positionById.get(positionId)!;
holders.push({
employeeId,
positionId,
orgUnitId: position.org_unit_id,
isChief: position.is_chief,
absent: isAbsent(employeeById.get(employeeId)!),
// Employed (or on leave) on that date — the same derivation the Berichte
// page uses, so the two can never disagree on who counted when.
const staff = allEmployees.filter((e) => {
const status = deriveStatusAsOf(e, asOf);
return status === "Aktiv" || status === "Karenz";
});
}
const lines = resolveReportingLines(units, holders);
const employees: OrgEmployee[] = lines.map((l) => {
const e = employeeById.get(l.employeeId)!;
const position = positionById.get(l.positionId)!;
const resolved = staff.map((e) => {
const a = assignmentByEmployee.get(e.id);
return {
id: e.id,
personnel_number: e.personnel_number,
first_name: e.first_name,
last_name: e.last_name,
// Die Tätigkeit der Planstelle, nicht das Freitextfeld auf der Person:
// bei einer projizierten Versetzung ist nur die erste schon richtig.
job_title: position.jobs.title,
manager_id: l.actingManagerId,
// Nur setzen, wenn eine Vertretung im Spiel ist — sonst zeigt die
// Oberfläche zweimal dieselbe Person an.
formal_manager_id: l.formalManagerId === l.actingManagerId ? null : l.formalManagerId,
absent: isAbsent(e),
absence_type: e.absence_type,
org_unit_id: l.orgUnitId,
is_chief: l.isChief,
position_id: l.positionId,
position_number: position.position_number,
employee: e,
managerId: a ? a.manager_id : e.manager_id,
placement: {
team_id: a ? a.team_id : e.team_id,
division_id: a ? a.division_id : e.division_id,
job_title: a ? a.job_title : e.job_title,
is_lead: a ? a.is_lead : e.is_lead,
org_level: a ? a.org_level : e.org_level,
} satisfies Placement,
};
});
// Unbesetzte Planstellen. Im Altmodell waren offene Stellen eine eigene
// Tabelle neben der Organisation; hier sind sie schlicht das Komplement.
const besetzt = new Set(positionOf.values());
const vacancies: OrgVacancy[] = positions
.filter((p) => !besetzt.has(p.id))
.map((p) => ({
position_id: p.id,
position_number: p.position_number,
job_title: p.jobs.title,
org_unit_id: p.org_unit_id,
is_chief: p.is_chief,
}));
return { employees, vacancies, projectedCount: moved.size, historyStartsAt };
// Project the future. Ordered by effective_date, so a later move wins.
const byId = new Map(resolved.map((r) => [r.employee.id, r]));
const moved = new Set<string>();
for (const change of pending) {
const target = byId.get(change.employee_id);
if (!target) continue;
const payload = change.payload as { new_team_id?: string; target_team_id?: string; new_title?: string };
const newTeamId = payload.new_team_id ?? payload.target_team_id ?? null;
if (newTeamId) {
target.placement.team_id = newTeamId;
const divisionId = teamDivision.get(newTeamId);
if (divisionId) target.placement.division_id = divisionId;
moved.add(target.employee.id);
}
if (payload.new_title) target.placement.job_title = payload.new_title;
}
// Second pass: a moved employee's manager follows from the *projected*
// org, not the one they left — and the lead of their new team may itself
// have moved in this same batch.
for (const r of resolved) {
if (moved.has(r.employee.id)) r.managerId = resolveManagerFor(r.placement, resolved.map((x) => ({ id: x.employee.id, placement: x.placement })));
}
// A manager who had not joined yet, or had already left, is not in this
// set — without re-rooting, their whole reporting line would silently
// vanish from the chart rather than showing up one level higher.
const presentIds = new Set(resolved.map((r) => r.employee.id));
const recordedManagerOf = (r: (typeof resolved)[number]) =>
r.managerId && presentIds.has(r.managerId) ? r.managerId : null;
// While somebody is on a long-term absence their reports roll up to the
// next present level. Derived here rather than written to the database:
// the absent person stays formally in charge, and the stand-in is only a
// stand-in — which is why both ids travel to the UI.
const absentIds = new Set(resolved.filter((r) => deriveStatusAsOf(r.employee, asOf) === "Karenz").map((r) => r.employee.id));
const acting = resolveActingManagers(
resolved.map((r) => ({ id: r.employee.id, managerId: recordedManagerOf(r), absent: absentIds.has(r.employee.id) }))
);
const employees: OrgEmployee[] = resolved.map((r) => {
const { actingManagerId, coveredForId } = acting.get(r.employee.id) ?? { actingManagerId: null, coveredForId: null };
return {
id: r.employee.id,
personnel_number: r.employee.personnel_number,
first_name: r.employee.first_name,
last_name: r.employee.last_name,
job_title: r.placement.job_title,
manager_id: actingManagerId,
formal_manager_id: coveredForId,
absent: absentIds.has(r.employee.id),
absence_type: r.employee.absence_type,
team_id: r.placement.team_id,
division_id: r.placement.division_id,
is_lead: r.placement.is_lead,
org_level: r.placement.org_level,
};
});
const historyStartsAt = assignments.reduce<string | null>(
(min, a) => (min === null || a.valid_from < min ? a.valid_from : min),
null
);
return { employees, projectedCount: moved.size, historyStartsAt };
}

View File

@@ -1,143 +0,0 @@
import { sql, type Tx } from "./db";
// Wo jemand in der Organisation steht, steht nicht mehr auf der Person. Es
// ergibt sich aus der Planstelle, die sie zum Stichtag innehat:
//
// employees ──A008──> position_assignments ──> om_positions ──> org_units
// └────────> jobs
//
// Das ist der Grund, warum es diese Datei gibt: die Verkettung braucht es an
// einem Dutzend Stellen, und sie zeitrichtig aufzulösen ist die Arbeit.
export type Placement = {
employeeId: string;
positionId: string;
positionNumber: string;
orgUnitId: string;
isChief: boolean;
jobTitle: string;
validFrom: string;
validTo: string | null;
/** Die Besetzung läuft am Stichtag; sonst ist es die zuletzt beendete. */
current: boolean;
};
type Row = {
employee_id: string;
valid_from: string;
valid_to: string | null;
position_id: string;
position_number: string;
org_unit_id: string;
is_chief: boolean;
job_title: string;
};
function toPlacement(row: Row, asOf: string): Placement {
return {
employeeId: row.employee_id,
positionId: row.position_id,
positionNumber: row.position_number,
orgUnitId: row.org_unit_id,
isChief: row.is_chief,
jobTitle: row.job_title,
validFrom: row.valid_from,
validTo: row.valid_to,
current: row.valid_from <= asOf && (row.valid_to === null || row.valid_to > asOf),
};
}
/**
* Die am Stichtag laufende Besetzung je Person — und für alle, die zu dem
* Zeitpunkt keine hatten, die zuletzt beendete. Ohne diesen Rückfall stünde
* bei jeder ausgetretenen Person „–" statt der Stelle, die sie innehatte.
*/
export function pickPlacements(rows: Row[], asOf: string): Map<string, Placement> {
const byEmployee = new Map<string, Placement>();
for (const row of rows) {
const p = toPlacement(row, asOf);
const best = byEmployee.get(p.employeeId);
if (!best) {
byEmployee.set(p.employeeId, p);
continue;
}
// Laufend schlägt beendet; unter beendeten gewinnt die jüngste.
if (p.current && !best.current) byEmployee.set(p.employeeId, p);
else if (p.current === best.current && p.validFrom > best.validFrom) byEmployee.set(p.employeeId, p);
}
return byEmployee;
}
export async function loadPlacements(
tx: Tx,
{ asOf, employeeIds }: { asOf: string; employeeIds?: string[] }
): Promise<Map<string, Placement>> {
if (employeeIds?.length === 0) return new Map();
// Ein Join statt einer eingebetteten Ressource. Und ohne die
// 1000-Zeilen-Grenze von PostgREST fällt das seitenweise Nachladen weg,
// das es dafür brauchte.
let q = tx
.selectFrom("position_assignments as pa")
.innerJoin("om_positions as p", "p.id", "pa.position_id")
.innerJoin("jobs as j", "j.id", "p.job_id")
.select([
"pa.employee_id",
"pa.valid_from",
"pa.valid_to",
"p.id as position_id",
"p.position_number",
"p.org_unit_id",
"p.is_chief",
"j.title as job_title",
])
.orderBy("pa.employee_id");
if (employeeIds) q = q.where("pa.employee_id", "in", employeeIds);
return pickPlacements((await q.execute()) as Row[], asOf);
}
// ── Abgeleitete Berichtslinie ──────────────────────────────────────
// Sie steht nirgends als Spalte; om_reporting_lines() rechnet sie aus dem
// Baum aus. formal_manager_id ist die zuständige Leitung, acting_manager_id
// die nächste besetzte und anwesende darüber — beides, damit sich in der
// Oberfläche zeigen lässt, dass eine Vertretung im Spiel ist, statt sie
// stillschweigend als die echte Führungskraft auszugeben.
export type ReportingLine = {
employee_id: string;
position_id: string;
org_unit_id: string;
is_chief: boolean;
formal_manager_id: string | null;
acting_manager_id: string | null;
};
/**
* `filter` schränkt die Funktion selbst ein, nicht das Ergebnis im Speicher —
* bei der Detailseite wandern damit neun Zeilen über die Leitung statt
* achthundert.
*/
export async function loadReportingLines(
tx: Tx,
asOf: string,
filter?: { employeeId?: string; actingManagerId?: string }
): Promise<ReportingLine[]> {
const conditions = [sql`true`];
if (filter?.employeeId) conditions.push(sql`employee_id = ${filter.employeeId}::uuid`);
if (filter?.actingManagerId) conditions.push(sql`acting_manager_id = ${filter.actingManagerId}::uuid`);
const result = await sql<ReportingLine>`
select * from om_reporting_lines(${asOf}::date)
where ${sql.join(conditions, sql` and `)}
`.execute(tx);
return result.rows;
}
/** Wie loadReportingLines, aber als Karte über die Personen-Kennung. */
export async function loadReportingLineMap(tx: Tx, asOf: string): Promise<Map<string, ReportingLine>> {
const lines = await loadReportingLines(tx, asOf);
return new Map(lines.map((l) => [l.employee_id, l]));
}

View File

@@ -1,150 +1,41 @@
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.
import type { SupabaseClient } from "@supabase/supabase-js";
import { breadcrumbLabel, loadOrgMaps } from "./org";
import type { Database } from "./supabase/types";
export type OpenPositionResolved = {
id: string;
position_number: string;
title: string;
org_unit_id: string;
is_chief: boolean;
team_id: string;
division_id: string;
is_lead: boolean;
reports_to_employee_id: string | null;
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. */
created_at: string;
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;
}
// Shared by the Hire Wizard (position lookup) and the Positions & Bereiche page.
export async function loadOpenPositions(supabase: SupabaseClient<Database>): Promise<OpenPositionResolved[]> {
const orgMaps = await loadOrgMaps(supabase);
const { data: positions } = await supabase
.from("positions")
.select("id, position_number, title, team_id, division_id, is_lead, reports_to_employee_id, valid_from, created_at")
.eq("status", "open")
.order("created_at", { ascending: false });
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))
)
const managerIds = Array.from(
new Set((positions ?? []).map((p) => p.reports_to_employee_id).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 { data: managers } = managerIds.length
? await supabase.from("employees").select("id, first_name, last_name").in("id", managerIds)
: { data: [] as { id: string; first_name: string; last_name: string }[] };
const managerNameById = new Map((managers ?? []).map((m) => [m.id, `${m.first_name} ${m.last_name}`]));
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,
};
});
return (positions ?? []).map((p) => ({
...p,
managerName: p.reports_to_employee_id ? (managerNameById.get(p.reports_to_employee_id) ?? null) : null,
orgLabel: breadcrumbLabel(orgMaps, p.division_id, p.team_id),
}));
}

View File

@@ -1,14 +1,12 @@
import type { Tx } from "./db";
import { ancestorsOf, loadOrgMaps, subtreeOf, type OrgMaps } from "./org";
import { loadPlacements } from "./placement";
import type { SupabaseClient } from "@supabase/supabase-js";
import { deriveStatusAsOf, EVENT_DATE_OPEN, parseStatuses, todayIso, type OrgLookups, type ReportEmployee, type ReportEvent } from "./reports";
import type { EmploymentType, HistoryEventType } from "./supabase/types";
import { fetchAllRows } from "./supabase/query";
import type { Database, EmploymentType, HistoryEventType } from "./supabase/types";
// Shared by the Berichte page and /api/export/* so they can never drift on
// what "the current view" means — same filters, same stichtag/event-window
// rules.
export type ReportFilters = {
/** Id einer Organisationseinheit; wirkt auf die Einheit *und alles darunter*. */
division?: string;
location?: string;
status?: string;
@@ -18,123 +16,69 @@ export type ReportFilters = {
export type SnapshotFilters = ReportFilters & { asOf?: string };
export type EventFilters = { eventType?: HistoryEventType; division?: string; location?: string; from?: string; to?: string };
/**
* Für jede Einheit vorberechnen, welcher Bereich, welche Abteilung und
* welches Team über ihr liegen. Ein Bericht gruppiert dann über einen
* Kartenzugriff statt über einen Aufstieg im Baum je Zeile.
*/
export function lookupsFromOrgMaps(orgMaps: OrgMaps, locations: { id: string; name: string }[]): OrgLookups {
const divisionName = new Map<string, string>();
const departmentName = new Map<string, string>();
const teamName = new Map<string, string>();
for (const unit of orgMaps.unitList) {
for (const a of ancestorsOf(orgMaps, unit.id)) {
if (a.unit_type === "Bereich") divisionName.set(unit.id, a.name);
else if (a.unit_type === "Abteilung") departmentName.set(unit.id, a.name);
else if (a.unit_type === "Team") teamName.set(unit.id, a.name);
}
}
return { divisionName, departmentName, teamName, locationName: new Map(locations.map((l) => [l.id, l.name])) };
}
export async function loadOrgLookups(tx: Tx): Promise<{
export async function loadOrgLookups(supabase: SupabaseClient<Database>): Promise<{
lookups: OrgLookups;
orgMaps: OrgMaps;
divisions: { id: string; name: string }[];
locations: { id: string; name: string }[];
}> {
const orgMaps = await loadOrgMaps(tx);
const locations = orgMaps.locationList.map((l) => ({ id: l.id, name: l.name }));
const [{ data: divisions }, { data: departments }, { data: teams }, { data: locations }] = await Promise.all([
supabase.from("divisions").select("id, name").order("name"),
supabase.from("departments").select("id, name"),
supabase.from("teams").select("id, name, department_id"),
supabase.from("locations").select("id, name").order("name"),
]);
const departmentNameById = new Map((departments ?? []).map((d) => [d.id, d.name]));
return {
lookups: lookupsFromOrgMaps(orgMaps, locations),
orgMaps,
// Als Filter angeboten wird die oberste Ebene unter der Gesellschaft —
// das, was im Altmodell „Bereich" hiess. Der Filter greift auf den
// ganzen Teilbaum.
divisions: orgMaps.unitList.filter((u) => u.unit_type === "Bereich").map((u) => ({ id: u.id, name: u.name })),
locations,
lookups: {
divisionName: new Map((divisions ?? []).map((d) => [d.id, d.name])),
departmentNameByTeam: new Map((teams ?? []).map((t) => [t.id, departmentNameById.get(t.department_id) ?? "Unbekannt"])),
teamName: new Map((teams ?? []).map((t) => [t.id, t.name])),
locationName: new Map((locations ?? []).map((l) => [l.id, l.name])),
},
divisions: divisions ?? [],
locations: locations ?? [],
};
}
const SNAPSHOT_EMPLOYEE_COLUMNS = [
"id",
"first_name",
"last_name",
"job_title",
"location_id",
"employment_type",
"contract_type",
"entry_date",
"exit_date",
"weekly_hours",
"source",
"paygrade",
"birth_date",
"gender",
"karenz_start_date",
"karenz_return_date",
"worker_type",
"collective_agreement",
"work_days",
"is_betriebsrat",
"has_dienstwagen",
"is_laterale_fuehrung",
"is_c_level",
] as const;
const SNAPSHOT_EMPLOYEE_COLUMNS =
"id, first_name, last_name, job_title, division_id, team_id, location_id, employment_type, contract_type, entry_date, exit_date, weekly_hours, source, paygrade, birth_date, gender, karenz_start_date, karenz_return_date, worker_type, collective_agreement, work_days, is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level";
// Anzahl der Angehörigen je Person. Nur der Fremdschlüssel wird gelesen —
// für die Berichtsdimensionen zählt die Anzahl, nicht wer es ist.
export async function loadDependentsCounts(tx: Tx): Promise<Map<string, number>> {
// Am direkten Zugang zählt die Datenbank, statt dass die Anwendung alle
// Zeilen holt und sie selbst durchgeht.
const rows = await tx
.selectFrom("employee_dependents")
.select(({ fn }) => ["employee_id", fn.countAll<string>().as("anzahl")])
.groupBy("employee_id")
.execute();
return new Map(rows.map((r) => [r.employee_id, Number(r.anzahl)]));
// employee_id -> number of employee_dependents rows. Selects only the FK
// column (no dependent PII needed) since only per-employee counts feed the
// has_dependents/avg_dependents report dimensions; counted client-side
// since the Supabase JS client has no `count(*) group by employee_id`
// shorthand. Shared by the Bestand pivot and the full employees export.
export async function loadDependentsCounts(supabase: SupabaseClient<Database>): Promise<Map<string, number>> {
const rows = await fetchAllRows(() => supabase.from("employee_dependents").select("employee_id").order("employee_id"));
const counts = new Map<string, number>();
for (const d of rows) counts.set(d.employee_id, (counts.get(d.employee_id) ?? 0) + 1);
return counts;
}
// Bestand zum Stichtag: Status *und* Einordnung werden auf `asOf` aufgelöst.
export async function loadSnapshotEmployees(tx: Tx, filters: SnapshotFilters): Promise<ReportEmployee[]> {
// Bestand zum Stichtag: reconstructs each employee's status as of `asOf`
// (defaults to today) from entry/exit/Karenz dates — see deriveStatusAsOf.
// division/team/location still reflect the employee's *current* assignment.
export async function loadSnapshotEmployees(supabase: SupabaseClient<Database>, filters: SnapshotFilters): Promise<ReportEmployee[]> {
const asOf = filters.asOf || todayIso();
function snapshotQuery() {
let q = tx.selectFrom("employees").select([...SNAPSHOT_EMPLOYEE_COLUMNS]).orderBy("id");
if (filters.location) q = q.where("location_id", "=", filters.location);
if (filters.employment) q = q.where("employment_type", "=", filters.employment as EmploymentType);
return q;
let query = supabase.from("employees").select(SNAPSHOT_EMPLOYEE_COLUMNS).order("id");
if (filters.division) query = query.eq("division_id", filters.division);
if (filters.location) query = query.eq("location_id", filters.location);
if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType);
return query;
}
const [data, dependentsCounts, placements, orgMaps] = await Promise.all([
snapshotQuery().execute(),
loadDependentsCounts(tx),
loadPlacements(tx, { asOf }),
filters.division ? loadOrgMaps(tx) : Promise.resolve(null),
]);
const [data, dependentsCounts] = await Promise.all([fetchAllRows(snapshotQuery), loadDependentsCounts(supabase)]);
// Der Bereichsfilter meint den ganzen Teilbaum: „Produktion" schliesst
// deren Abteilungen und Teams ein, sonst käme null heraus, weil unter dem
// Bereich selbst nur die Bereichsleitung sitzt.
const allowedUnits = orgMaps && filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null;
const withDerivedStatus: ReportEmployee[] = [];
for (const e of data) {
const placement = placements.get(e.id);
// Zum Stichtag laufend? Sonst zählt die Person zwar noch im Bestand,
// sitzt aber auf keiner Planstelle mehr.
const orgUnitId = placement?.current ? placement.orgUnitId : null;
if (allowedUnits && (!orgUnitId || !allowedUnits.has(orgUnitId))) continue;
withDerivedStatus.push({
const withDerivedStatus: ReportEmployee[] = data.map((e) => ({
id: e.id,
first_name: e.first_name,
last_name: e.last_name,
job_title: placement?.jobTitle ?? e.job_title,
org_unit_id: orgUnitId,
job_title: e.job_title,
division_id: e.division_id,
team_id: e.team_id,
location_id: e.location_id,
status: deriveStatusAsOf(e, asOf),
employment_type: e.employment_type,
@@ -154,75 +98,52 @@ export async function loadSnapshotEmployees(tx: Tx, filters: SnapshotFilters): P
is_laterale_fuehrung: e.is_laterale_fuehrung,
is_c_level: e.is_c_level,
dependents_count: dependentsCounts.get(e.id) ?? 0,
});
}
}));
const statuses = parseStatuses(filters.status);
return withDerivedStatus.filter((e) => statuses.includes(e.status as (typeof statuses)[number]));
}
// Ereignisse: employee_history trägt selbst keine Organisationszuordnung, sie
// kommt über die Planstelle, die die Person *am Tag des Ereignisses* innehatte.
// Vorher war es die heutige — womit ein Austritt von vor zwei Jahren unter dem
// Team stand, in das die Person nie versetzt worden war.
// Ereignisse: employee_history has no division_id/team_id of its own, so
// this joins in the affected employee's *current* org placement (two plain
// queries, merged in JS — the hand-written Database type has no relational
// embedding metadata for a single nested-select query).
//
// from/to: "" (unset) falls back to the current calendar year; the literal
// sentinel EVENT_DATE_OPEN means that side of the interval is intentionally
// unbounded (e.g. "alle Ereignisse bis heute", no start date).
export async function loadEventHistory(tx: Tx, filters: EventFilters): Promise<ReportEvent[]> {
export async function loadEventHistory(supabase: SupabaseClient<Database>, filters: EventFilters): Promise<ReportEvent[]> {
const currentYear = new Date().getFullYear();
const from = filters.from === EVENT_DATE_OPEN ? undefined : filters.from || `${currentYear}-01-01`;
const to = filters.to === EVENT_DATE_OPEN ? undefined : filters.to || `${currentYear}-12-31`;
function historyQuery() {
let q = tx
.selectFrom("employee_history")
.select(["employee_id", "event_date", "event_type", "description"])
.orderBy("id");
if (from) q = q.where("event_date", ">=", from);
if (to) q = q.where("event_date", "<=", to);
if (filters.eventType) q = q.where("event_type", "=", filters.eventType);
return q;
let query = supabase.from("employee_history").select("employee_id, event_date, event_type, description").order("id");
if (from) query = query.gte("event_date", from);
if (to) query = query.lte("event_date", to);
if (filters.eventType) query = query.eq("event_type", filters.eventType);
return query;
}
const [history, employees, assignments, orgMaps] = await Promise.all([
historyQuery().execute(),
tx.selectFrom("employees").select(["id", "first_name", "last_name", "job_title", "location_id"]).orderBy("id").execute(),
tx
.selectFrom("position_assignments as a")
.innerJoin("om_positions as p", "p.id", "a.position_id")
.select(["a.employee_id", "a.valid_from", "a.valid_to", "p.org_unit_id"])
.orderBy("a.employee_id")
.execute(),
filters.division ? loadOrgMaps(tx) : Promise.resolve(null),
const [history, employees] = await Promise.all([
fetchAllRows(historyQuery),
fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name, job_title, division_id, team_id, location_id").order("id")),
]);
const spans = new Map<string, { from: string; to: string | null; unitId: string }[]>();
for (const a of assignments) {
const list = spans.get(a.employee_id) ?? [];
list.push({ from: a.valid_from, to: a.valid_to, unitId: a.org_unit_id });
spans.set(a.employee_id, list);
}
const allowedUnits = orgMaps && filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null;
const employeeById = new Map(employees.map((e) => [e.id, e]));
const events: ReportEvent[] = [];
for (const h of history) {
const emp = employeeById.get(h.employee_id);
if (!emp) continue;
if (filters.division && emp.division_id !== filters.division) continue;
if (filters.location && emp.location_id !== filters.location) continue;
const unitId =
spans.get(h.employee_id)?.find((s2) => s2.from <= h.event_date && (s2.to === null || s2.to > h.event_date))?.unitId ?? null;
if (allowedUnits && (!unitId || !allowedUnits.has(unitId))) continue;
events.push({
employee_id: emp.id,
first_name: emp.first_name,
last_name: emp.last_name,
job_title: emp.job_title,
org_unit_id: unitId,
division_id: emp.division_id,
team_id: emp.team_id,
location_id: emp.location_id,
event_date: h.event_date,
event_type: h.event_type,

View File

@@ -81,8 +81,8 @@ export type ReportEmployee = {
first_name: string;
last_name: string;
job_title: string;
/** Die Einheit der Planstelle; null, wenn zum Stichtag keine besetzt war. */
org_unit_id: string | null;
division_id: string;
team_id: string | null;
location_id: string;
status: string;
employment_type: string;
@@ -104,26 +104,20 @@ export type ReportEmployee = {
dependents_count: number;
};
// Alle drei sind über die *Einheit* der Planstelle geschlüsselt, nicht über
// drei verschiedene Fremdschlüssel: welcher Bereich, welche Abteilung und
// welches Team zu einer Einheit gehören, ergibt sich aus ihrer Vorfahrenkette
// und wird einmal vorberechnet.
export type OrgLookups = {
divisionName: Map<string, string>;
departmentName: Map<string, string>;
departmentNameByTeam: Map<string, string>;
teamName: Map<string, string>;
locationName: Map<string, string>;
};
// Reconstructs status as of any date from the columns that actually carry a
// timeline (entry/exit/Karenz), rather than trusting `employees.status`,
// which only ever reflects *today*.
//
// Die Einordnung in die Organisation wird zum selben Stichtag aufgelöst: seit
// dem OM-Modell ist position_assignments zeitabhängig, eine Auswertung
// gruppiert also nach der Einheit von damals. Vorher gab es diese Historie
// nicht, und ein Stichtagsbericht gruppierte nach der heutigen Zuordnung —
// was in der Oberfläche vermerkt werden musste, statt still falsch zu sein.
// which only ever reflects *today*. Division/team/location still reflect the
// employee's *current* assignment — the schema has no history of org-unit
// changes over time, only free-text employee_history descriptions — so a
// stichtag report groups by today's org placement, not the placement as of
// that date. Documented in the UI rather than silently wrong.
export function deriveStatusAsOf(
e: { entry_date: string; exit_date: string | null; karenz_start_date: string | null; karenz_return_date: string | null },
asOf: string
@@ -143,11 +137,11 @@ function tenureYearsAsOf(entryDate: string, exitDate: string | null, asOf: strin
export function groupKeyFor(e: ReportEmployee, dim: GroupDimension, lookups: OrgLookups): string {
switch (dim) {
case "division":
return e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "Unbekannt") : "";
return lookups.divisionName.get(e.division_id) ?? "Unbekannt";
case "department":
return e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "") : "";
return e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "Unbekannt") : "";
case "team":
return e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "") : "";
return e.team_id ? (lookups.teamName.get(e.team_id) ?? "Unbekannt") : "";
case "location":
return lookups.locationName.get(e.location_id) ?? "Unbekannt";
case "status":
@@ -262,7 +256,7 @@ export function aggregateReport(
id: e.id,
name: `${e.first_name} ${e.last_name}`,
title: e.job_title,
team: e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "") : "",
team: e.team_id ? (lookups.teamName.get(e.team_id) ?? "") : "",
entry_date: e.entry_date,
}));
const row: ReportRow = { key, value, count: rowsForGroup.length, people };
@@ -351,8 +345,8 @@ export type ReportEvent = {
first_name: string;
last_name: string;
job_title: string;
/** Die Einheit der Planstelle; null, wenn zum Stichtag keine besetzt war. */
org_unit_id: string | null;
division_id: string;
team_id: string | null;
location_id: string;
event_date: string;
event_type: HistoryEventType;
@@ -364,11 +358,11 @@ function eventGroupKeyFor(e: ReportEvent, dim: EventGroupDimension, lookups: Org
case "event_type":
return EVENT_TYPE_LABELS[e.event_type] ?? e.event_type;
case "division":
return e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "Unbekannt") : "";
return lookups.divisionName.get(e.division_id) ?? "Unbekannt";
case "department":
return e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "") : "";
return e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "Unbekannt") : "";
case "team":
return e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "") : "";
return e.team_id ? (lookups.teamName.get(e.team_id) ?? "Unbekannt") : "";
case "location":
return lookups.locationName.get(e.location_id) ?? "Unbekannt";
case "event_year":
@@ -400,7 +394,7 @@ export function aggregateEvents(
id: e.employee_id,
name: `${e.first_name} ${e.last_name}`,
title: e.description,
team: e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "") : "",
team: e.team_id ? (lookups.teamName.get(e.team_id) ?? "") : "",
entry_date: e.event_date,
}));
const row: ReportRow = { key, value: rowsForGroup.length, count: rowsForGroup.length, people };

23
lib/supabase/admin.ts Normal file
View File

@@ -0,0 +1,23 @@
import "server-only";
import { createClient as createSupabaseClient } from "@supabase/supabase-js";
import type { Database } from "./types";
// Service-role client: bypasses RLS entirely. Server-only — never import this
// from a Client Component or anything bundled for the browser. The
// "server-only" import makes an accidental client-side import a build error
// instead of a runtime one.
export function createAdminClient() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl) {
throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL");
}
if (!serviceRoleKey) {
throw new Error("Missing SUPABASE_SERVICE_ROLE_KEY");
}
return createSupabaseClient<Database>(supabaseUrl, serviceRoleKey, {
auth: { autoRefreshToken: false, persistSession: false },
});
}

21
lib/supabase/auth.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { NextResponse } from "next/server";
import type { Database } from "./types";
// Route Handlers under /api/export/* are outside the App Router layout tree,
// so app/(app)/layout.tsx's HR gate never runs for them — each one has to
// re-establish that the caller is an active HR user itself. RLS is still the
// real boundary (an unauthorized session simply reads nothing); this exists
// so those routes answer 401/403 instead of handing back an empty workbook.
export async function requireHrUser(supabase: SupabaseClient<Database>): Promise<NextResponse | null> {
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
const { data: profile } = await supabase.from("profiles").select("role, is_active").eq("id", user.id).maybeSingle();
if (profile?.role !== "hr" || profile.is_active !== true) {
return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
}
return null;
}

19
lib/supabase/client.ts Normal file
View File

@@ -0,0 +1,19 @@
import { createBrowserClient } from "@supabase/ssr";
import type { Database } from "./types";
// For use in Client Components that need interactivity (filters, live
// hints, etc). Server Components/Actions should use lib/supabase/server.ts.
// Only ever reads NEXT_PUBLIC_* vars — this file is bundled for the browser.
export function createClient() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl) {
throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL");
}
if (!supabaseAnonKey) {
throw new Error("Missing NEXT_PUBLIC_SUPABASE_ANON_KEY");
}
return createBrowserClient<Database>(supabaseUrl, supabaseAnonKey);
}

33
lib/supabase/query.ts Normal file
View File

@@ -0,0 +1,33 @@
// PostgREST's .or() filter syntax treats "," "(" and ")" as structural
// delimiters between conditions. A raw user-supplied search term containing
// them (e.g. from a search box or ?q= param) can break out of the intended
// column conditions and append arbitrary extra filters to the query. Strip
// them before interpolating — harmless for real name/title searches, which
// never legitimately contain them.
export function sanitizeIlikeTerm(term: string): string {
return term.replace(/[,()]/g, "");
}
// PostgREST caps every response at db.max_rows (1000, see
// supabase/config.toml) and does so *silently* — a query over ~800 employees
// or the employee_history log just stops returning rows, and a report or
// export built from it is quietly wrong rather than failing. Anything that
// aggregates a whole table has to page explicitly; anything that renders a
// bounded list (an employee page, the audit log) uses .range() directly and
// does not need this.
const PAGE_SIZE = 1000;
type PagedQuery<Row> = {
range: (from: number, to: number) => PromiseLike<{ data: Row[] | null; error: unknown }>;
};
export async function fetchAllRows<Row>(buildQuery: () => PagedQuery<Row>): Promise<Row[]> {
const rows: Row[] = [];
for (let page = 0; ; page++) {
const { data, error } = await buildQuery().range(page * PAGE_SIZE, (page + 1) * PAGE_SIZE - 1);
if (error || !data) break;
rows.push(...data);
if (data.length < PAGE_SIZE) break;
}
return rows;
}

40
lib/supabase/server.ts Normal file
View File

@@ -0,0 +1,40 @@
import "server-only";
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import type { Database } from "./types";
// For use in Server Components and Server Actions. Respects the signed-in
// user's session, so all reads/writes go through RLS as that user. Uses only
// the anon key (never the service role key) — the user's own session cookie
// is what determines access, via RLS.
export async function createClient() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl) {
throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL");
}
if (!supabaseAnonKey) {
throw new Error("Missing NEXT_PUBLIC_SUPABASE_ANON_KEY");
}
const cookieStore = await cookies();
return createServerClient<Database>(supabaseUrl, supabaseAnonKey, {
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// Called from a Server Component during render — safe to ignore
// because proxy.ts refreshes the session cookie on every request.
}
},
},
});
}

View File

@@ -12,24 +12,12 @@ export type GenderType = "m" | "w";
export type WorkerType = "Angestellte:r" | "Arbeiter:in";
export type CollectiveAgreement = "Handel" | "Süßwaren";
export type Weekday = "Mo" | "Di" | "Mi" | "Do" | "Fr" | "Sa" | "So";
/**
* Eine einzelne Feldänderung im Protokoll.
*
* `vorher` und `nachher` sind bewusst Text: die Datenbank stellt jeden Typ
* über ::text dar, damit ein Datum, eine Zahl und eine Liste von Arbeitstagen
* in derselben Spalte nebeneinander stehen können. Für die Anzeige reicht
* das; gerechnet wird damit nicht.
*/
export type AuditChange = { feld: string; vorher: string | null; nachher: string | null };
export type RelationshipType = "Ehepartner:in" | "Lebenspartner:in" | "Kind" | "Sonstige";
export type NoteCategory = "Allgemein" | "Vertraulich" | "Personalgespräch" | "Wiedervorlage" | "Lob / Anerkennung";
// Single HR-only role (see docs/decisions/0001-hr-only-access.md). Kept as a
// union (not a string literal) so a future hr_admin/hr_user split, if ever
// technically required, is a type-level addition, not a rewrite.
export type ProfileRole = "hr";
/** Etikett einer Organisationseinheit; die Struktur steckt in parent_id. */
export type OrgUnitType = "Gesellschaft" | "Bereich" | "Abteilung" | "Team";
export type HistoryEventType =
| "Eintritt"
| "Beförderung"
@@ -42,6 +30,8 @@ export type HistoryEventType =
| "Reorganisation"
| "Gehaltsanpassung"
| "Rückkehr";
export type PositionStatus = "open" | "filled";
export type ReorgMoveKind = "emp" | "team" | "abt" | "dept";
export type PendingChangeType =
| "transfer"
| "promotion"
@@ -59,6 +49,21 @@ type NoRelationships = { Relationships: [] };
export type Database = {
public: {
Tables: {
divisions: NoRelationships & {
Row: { id: string; org_number: string; name: string };
Insert: { id?: string; org_number: string; name: string };
Update: Partial<{ id: string; org_number: string; name: string }>;
};
departments: NoRelationships & {
Row: { id: string; org_number: string; name: string; division_id: string };
Insert: { id?: string; org_number: string; name: string; division_id: string };
Update: Partial<{ id: string; org_number: string; name: string; division_id: string }>;
};
teams: NoRelationships & {
Row: { id: string; org_number: string; name: string; department_id: string };
Insert: { id?: string; org_number: string; name: string; department_id: string };
Update: Partial<{ id: string; org_number: string; name: string; department_id: string }>;
};
locations: NoRelationships & {
Row: { id: string; name: string; country: string };
Insert: { id?: string; name: string; country: string };
@@ -112,8 +117,13 @@ export type Database = {
address_country: string | null;
email: string;
phone: string | null;
team_id: string | null;
division_id: string;
job_title: string;
location_id: string;
manager_id: string | null;
org_level: number;
is_lead: boolean;
employment_type: EmploymentType;
weekly_hours: number;
/** @deprecated Salary is out of MVP scope; column kept only for pre-existing data. */
@@ -156,8 +166,13 @@ export type Database = {
address_country?: string | null;
email: string;
phone?: string | null;
team_id?: string | null;
division_id?: string;
job_title: string;
location_id: string;
manager_id?: string | null;
org_level?: number;
is_lead?: boolean;
employment_type?: EmploymentType;
weekly_hours?: number;
contract_type?: ContractType;
@@ -193,6 +208,7 @@ export type Database = {
event_date: string;
event_type: HistoryEventType;
description: string;
reorg_scenario_id: string | null;
created_at: string;
};
Insert: {
@@ -201,6 +217,7 @@ export type Database = {
event_date: string;
event_type: HistoryEventType;
description: string;
reorg_scenario_id?: string | null;
created_at?: string;
};
Update: Partial<Database["public"]["Tables"]["employee_history"]["Insert"]>;
@@ -257,6 +274,37 @@ export type Database = {
};
Update: Partial<Database["public"]["Tables"]["employee_notes"]["Insert"]>;
};
positions: NoRelationships & {
Row: {
id: string;
position_number: string;
title: string;
team_id: string;
division_id: string;
is_lead: boolean;
reports_to_employee_id: string | null;
status: PositionStatus;
valid_from: string;
created_at: string;
filled_at: string | null;
filled_by_employee_id: string | null;
};
Insert: {
id?: string;
position_number?: string;
title: string;
team_id: string;
division_id?: string;
is_lead?: boolean;
reports_to_employee_id?: string | null;
status?: PositionStatus;
valid_from?: string;
created_at?: string;
filled_at?: string | null;
filled_by_employee_id?: string | null;
};
Update: Partial<Database["public"]["Tables"]["positions"]["Insert"]>;
};
hire_drafts: NoRelationships & {
Row: { id: string; created_by: string | null; step: number; payload: Record<string, unknown>; updated_at: string };
Insert: { id?: string; created_by?: string | null; step?: number; payload: Record<string, unknown>; updated_at?: string };
@@ -277,13 +325,6 @@ export type Database = {
target_label: string;
target_employee_id: string | null;
details: string | null;
/**
* Feldweise Änderungen. Null bei Einträgen aus der Zeit vor
* 20260803140000_audit_changes_detail.sql — dort wurden nur die
* Feldnamen behalten, nicht die Werte, und das lässt sich nicht
* nachliefern.
*/
changes: AuditChange[] | null;
};
Insert: {
id?: string;
@@ -294,10 +335,37 @@ export type Database = {
target_label: string;
target_employee_id?: string | null;
details?: string | null;
changes?: AuditChange[] | null;
};
Update: Partial<Database["public"]["Tables"]["audit_log"]["Insert"]>;
};
reorg_scenarios: NoRelationships & {
Row: {
id: string;
name: string;
effective_date: string;
created_by: string | null;
applied: boolean;
applied_at: string | null;
undo_snapshot: Record<string, unknown> | null;
created_at: string;
};
Insert: {
id?: string;
name: string;
effective_date: string;
created_by?: string | null;
applied?: boolean;
applied_at?: string | null;
undo_snapshot?: Record<string, unknown> | null;
created_at?: string;
};
Update: Partial<Database["public"]["Tables"]["reorg_scenarios"]["Insert"]>;
};
reorg_moves: NoRelationships & {
Row: { id: string; scenario_id: string; kind: ReorgMoveKind; payload: Record<string, unknown> };
Insert: { id?: string; scenario_id: string; kind: ReorgMoveKind; payload: Record<string, unknown> };
Update: Partial<Database["public"]["Tables"]["reorg_moves"]["Insert"]>;
};
pending_org_changes: NoRelationships & {
Row: {
id: string;
@@ -305,6 +373,7 @@ export type Database = {
change_type: PendingChangeType;
effective_date: string;
payload: Record<string, unknown>;
reorg_scenario_id: string | null;
status: PendingChangeStatus;
created_by: string | null;
created_at: string;
@@ -316,6 +385,7 @@ export type Database = {
change_type: PendingChangeType;
effective_date: string;
payload: Record<string, unknown>;
reorg_scenario_id?: string | null;
status?: PendingChangeStatus;
created_by?: string | null;
created_at?: string;
@@ -323,120 +393,24 @@ export type Database = {
};
Update: Partial<Database["public"]["Tables"]["pending_org_changes"]["Insert"]>;
};
// ── SAP-OM-Modell ──────────────────────────────────────────
// O: rekursiv über parent_id, unit_type ist nur ein Etikett.
org_units: {
Relationships: [
{
foreignKeyName: "org_units_parent_id_fkey";
columns: ["parent_id"];
referencedRelation: "org_units";
referencedColumns: ["id"];
},
];
// Written exclusively by trg_track_employee_assignment; RLS grants HR
// read access only, hence no Insert/Update shapes worth modelling.
employee_assignments: NoRelationships & {
Row: {
id: string;
org_number: string;
name: string;
parent_id: string | null;
unit_type: OrgUnitType;
valid_from: string;
valid_to: string | null;
created_at: string;
};
Insert: {
id?: string;
org_number: string;
name: string;
parent_id?: string | null;
unit_type: OrgUnitType;
valid_from?: string;
valid_to?: string | null;
created_at?: string;
};
Update: Partial<Database["public"]["Tables"]["org_units"]["Insert"]>;
};
// C: Katalog der Tätigkeiten.
jobs: NoRelationships & {
Row: { id: string; code: string; title: string; created_at: string };
Insert: { id?: string; code: string; title: string; created_at?: string };
Update: Partial<Database["public"]["Tables"]["jobs"]["Insert"]>;
};
// S: Planstelle. Der Name om_positions stammt aus der Zeit, in der die
// alte positions-Tabelle noch danebenstand; sie ist inzwischen weg.
om_positions: {
Relationships: [
{
foreignKeyName: "om_positions_org_unit_id_fkey";
columns: ["org_unit_id"];
referencedRelation: "org_units";
referencedColumns: ["id"];
},
{
foreignKeyName: "om_positions_job_id_fkey";
columns: ["job_id"];
referencedRelation: "jobs";
referencedColumns: ["id"];
},
];
Row: {
id: string;
position_number: string;
org_unit_id: string;
job_id: string;
is_chief: boolean;
valid_from: string;
valid_to: string | null;
created_at: string;
};
Insert: {
id?: string;
position_number: string;
org_unit_id: string;
job_id: string;
is_chief?: boolean;
valid_from?: string;
valid_to?: string | null;
created_at?: string;
};
Update: Partial<Database["public"]["Tables"]["om_positions"]["Insert"]>;
};
// A008: Person besetzt Planstelle, zeitabhängig.
position_assignments: {
Row: {
id: string;
position_id: string;
employee_id: string;
manager_id: string | null;
team_id: string | null;
division_id: string;
job_title: string;
is_lead: boolean;
org_level: number;
valid_from: string;
valid_to: string | null;
created_at: string;
};
Insert: {
id?: string;
position_id: string;
employee_id: string;
valid_from: string;
valid_to?: string | null;
created_at?: string;
};
Update: Partial<Database["public"]["Tables"]["position_assignments"]["Insert"]>;
// Beide Richtungen: über die Planstelle hängt die Verortung in der
// Organisation, über die Person die Verortung in der Akte. Die
// Einbettung erspart an einem Dutzend Stellen eine zweite Abfrage.
Relationships: [
{
foreignKeyName: "position_assignments_position_id_fkey";
columns: ["position_id"];
referencedRelation: "om_positions";
referencedColumns: ["id"];
},
{
foreignKeyName: "position_assignments_employee_id_fkey";
columns: ["employee_id"];
referencedRelation: "employees";
referencedColumns: ["id"];
},
];
Insert: never;
Update: never;
};
};
Views: Record<string, never>;
@@ -454,24 +428,13 @@ export type Database = {
delete_employee_dependent: { Args: { payload: Record<string, unknown> }; Returns: void };
add_employee_note: { Args: { payload: Record<string, unknown> }; Returns: string };
complete_employee_note: { Args: { payload: Record<string, unknown> }; Returns: void };
// Planstelle anlegen bzw. schliessen — im OM-Modell Operationen auf
// om_positions, nicht mehr auf einer eigenen Ausschreibungstabelle.
create_position: { Args: { payload: Record<string, unknown> }; Returns: string };
update_position: { Args: { payload: Record<string, unknown> }; Returns: void };
delete_position: { Args: { payload: Record<string, unknown> }; Returns: void };
staff_position_internally: { Args: { payload: Record<string, unknown> }; Returns: void };
is_valid_svnr: { Args: { p_svnr: string; p_birth_date?: string | null }; Returns: boolean };
apply_reorg: { Args: { payload: Record<string, unknown> }; Returns: string };
undo_reorg: { Args: { payload: Record<string, unknown> }; Returns: void };
apply_due_pending_changes: { Args: Record<string, never>; Returns: number };
om_reporting_lines: {
Args: { p_as_of?: string };
Returns: {
employee_id: string;
position_id: string;
org_unit_id: string;
is_chief: boolean;
formal_manager_id: string | null;
acting_manager_id: string | null;
}[];
};
};
};
};

View File

@@ -2,28 +2,25 @@ import type { NextConfig } from "next";
// Report-only rather than enforcing, deliberately: the policy is derived from
// what this app is known to load — its own bundle, the self-hosted Nunito
// files from next/font, and nothing else — but an unenforced policy that logs
// violations is worth more than a guessed one that blanks the app for every
// HR user. Promote it to `Content-Security-Policy` once the reports come back
// clean.
// files from next/font, and the Supabase project from
// NEXT_PUBLIC_SUPABASE_URL — but an unenforced policy that logs violations is
// worth more than a guessed one that blanks the app for every HR user.
// Promote it to `Content-Security-Policy` once the reports come back clean.
//
// 'unsafe-inline' on script-src is not laziness: Next.js inlines its
// bootstrap and hydration payload as inline <script> tags, so a nonce-based
// policy means threading a per-request nonce through proxy.ts — a separate
// change, and the reason this starts in report-only.
function contentSecurityPolicy(): string {
// Die Anmeldung schickt ein Formular an /api/auth/signin/…, und von dort
// geht es per Weiterleitung zum Anmeldeserver. `form-action` gilt auch für
// die Weiterleitungen nach einem Formularversand — steht der Aussteller
// nicht darin, bricht der Browser die Anmeldung ab. Genau hier wäre die
// Nur-Bericht-Fassung später eine böse Überraschung.
let issuerOrigin = "";
let supabaseOrigin = "";
try {
issuerOrigin = new URL(process.env.AUTH_MICROSOFT_ENTRA_ID_ISSUER ?? "").origin;
supabaseOrigin = new URL(process.env.NEXT_PUBLIC_SUPABASE_URL ?? "").origin;
} catch {
issuerOrigin = "";
supabaseOrigin = "";
}
const formAction = ["'self'", issuerOrigin].filter(Boolean).join(" ");
// Supabase Auth refreshes tokens over https and Realtime opens a websocket
// against the same host.
const connect = ["'self'", supabaseOrigin, supabaseOrigin.replace(/^http/, "ws")].filter(Boolean).join(" ");
return [
"default-src 'self'",
@@ -31,13 +28,10 @@ function contentSecurityPolicy(): string {
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self'",
// Die Anwendung spricht im Browser mit niemandem ausser sich selbst: die
// Datenbank erreicht nur der Server, und seit die API-Schicht weg ist,
// gibt es keine Gegenstelle mehr, die von aussen angesprochen würde.
"connect-src 'self'",
`connect-src ${connect}`,
"frame-ancestors 'self'",
"base-uri 'self'",
`form-action ${formAction}`,
"form-action 'self'",
"object-src 'none'",
].join("; ");
}

268
package-lock.json generated
View File

@@ -13,11 +13,8 @@
"@supabase/supabase-js": "^2.110.8",
"@xyflow/react": "^12.11.2",
"exceljs": "^4.4.0",
"kysely": "^0.29.4",
"lucide-react": "^1.26.0",
"next": "^16.2.11",
"next-auth": "^5.0.0-beta.32",
"pg": "^8.22.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"server-only": "^0.0.1"
@@ -28,7 +25,6 @@
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.13.3",
"@types/pg": "^8.20.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitest/coverage-v8": "^4.1.10",
@@ -40,9 +36,6 @@
"tailwindcss": "^4.3.3",
"typescript": "^5.9.3",
"vitest": "^4.1.10"
},
"engines": {
"node": ">=22 <25"
}
},
"node_modules/@adobe/css-tools": {
@@ -116,35 +109,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@auth/core": {
"version": "0.41.3",
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.3.tgz",
"integrity": "sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw==",
"license": "ISC",
"dependencies": {
"@panva/hkdf": "^1.2.1",
"jose": "^6.0.6",
"oauth4webapi": "^3.3.0",
"preact": "10.24.3",
"preact-render-to-string": "6.5.11"
},
"peerDependencies": {
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.2",
"nodemailer": "^7.0.7 || ^8.0.5"
},
"peerDependenciesMeta": {
"@simplewebauthn/browser": {
"optional": true
},
"@simplewebauthn/server": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -1804,15 +1768,6 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@panva/hkdf": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
@@ -2876,18 +2831,6 @@
"undici-types": "~7.18.0"
}
},
"node_modules/@types/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/@types/react": {
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
@@ -6949,6 +6892,7 @@
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
@@ -7169,15 +7113,6 @@
"json-buffer": "3.0.1"
}
},
"node_modules/kysely": {
"version": "0.29.4",
"resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.4.tgz",
"integrity": "sha512-y5mVgQNkMbs1eK9Xyc0pmNdabN2wHhRYY/5r4W5HrUT1rYCEPeVNSj1RUJeSDKT3U0p+mXCvLgkrFuIafYI6BA==",
"license": "MIT",
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/language-subtag-registry": {
"version": "0.3.23",
"resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz",
@@ -7933,33 +7868,6 @@
}
}
},
"node_modules/next-auth": {
"version": "5.0.0-beta.32",
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.32.tgz",
"integrity": "sha512-CGlChIEWZ6LltNVxrE5yiySMID+Idpmry47JYA5lLwgD8Sx02a8M65VL0TWVz9nbnOioS/tCW/rP/0+mE7Qp4Q==",
"license": "ISC",
"dependencies": {
"@auth/core": "0.41.3"
},
"peerDependencies": {
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.2",
"next": "^14.0.0-0 || ^15.0.0 || ^16.0.0",
"nodemailer": "^7.0.7 || ^8.0.5",
"react": "^18.2.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@simplewebauthn/browser": {
"optional": true
},
"@simplewebauthn/server": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/node-exports-info": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz",
@@ -7998,15 +7906,6 @@
"node": ">=0.10.0"
}
},
"node_modules/oauth4webapi": {
"version": "3.8.6",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz",
"integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -8296,95 +8195,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/pg": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.15.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -8442,64 +8252,6 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/preact": {
"version": "10.24.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
"integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/preact-render-to-string": {
"version": "6.5.11",
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz",
"integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==",
"license": "MIT",
"peerDependencies": {
"preact": ">=10"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -9206,15 +8958,6 @@
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/stable-hash": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -10442,15 +10185,6 @@
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"license": "MIT"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -24,11 +24,8 @@
"@supabase/supabase-js": "^2.110.8",
"@xyflow/react": "^12.11.2",
"exceljs": "^4.4.0",
"kysely": "^0.29.4",
"lucide-react": "^1.26.0",
"next": "^16.2.11",
"next-auth": "^5.0.0-beta.32",
"pg": "^8.22.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"server-only": "^0.0.1"
@@ -39,7 +36,6 @@
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.13.3",
"@types/pg": "^8.20.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitest/coverage-v8": "^4.1.10",

111
proxy.ts
View File

@@ -1,81 +1,68 @@
import NextAuth from "next-auth";
import { NextResponse } from "next/server";
import { authConfig } from "@/lib/auth/config";
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
// Next.js 16 renamed Middleware to Proxy (same mechanism, new filename and
// export). Das hier ist die vorderste Tür: wer keine Sitzung hat, landet auf
// /login statt in der Anwendung.
//
// Gebaut wird dafür eine *zweite*, absichtlich unvollständige Auth.js-Instanz
// — nur aus lib/auth/config.ts, ohne die Rückrufe aus auth.ts. Der Grund ist
// handfest: auth.ts spricht mit der Datenbank, und der Proxy läuft je nach
// Betriebsart in einer Umgebung ohne Node-Module. Die Instanz hier liest das
// Sitzungscookie und sonst nichts.
//
// Deshalb prüft der Proxy auch nur, *ob* jemand angemeldet ist — nicht mehr,
// ob die Person HR-Rechte hat. Das ist keine Lücke, sondern eine Verschiebung
// an die Stellen, die es wahrheitsgemäss beantworten können:
//
// • app/(app)/layout.tsx fragt profiles bei jedem Aufbau frisch ab und
// leitet auf /login?error=no_hr_access um,
// • die Route Handler unter /api/export/* tun dasselbe über requireHrUser(),
// • und darunter, unabhängig von allem Anwendungscode, entscheiden die
// RLS-Policies über is_hr_user().
//
// Die Alternative — role und is_active ins Sitzungstoken schreiben — hätte
// den Proxy schneller gemacht und dafür eine Behauptung eingefroren: eine
// entzogene Freischaltung wirkte erst mit dem nächsten Token. Bei einer
// Personalanwendung ist das die falsche Richtung.
// Als Funktion übergeben, nicht als Objekt: siehe authConfig().
const { auth } = NextAuth(() => authConfig());
// Next.js 16 renamed Middleware to Proxy (same mechanism, new filename/export).
// This is the app's single entry-point gate (spec
// §2.2): unauthenticated users are sent to /login, and — this is the part
// that used to be missing — authenticated users who are NOT an active,
// explicitly-provisioned HR user are sent to /login too, with an error
// message, instead of being let through. Previously this only checked for
// a Supabase Auth session at all, which meant any signed-in user (even one
// with no profile row, or the old "manager" role) could open the app.
// This UI-layer gate is defense in depth, not the real boundary — every
// table is independently RLS-gated on is_hr_user() regardless of what this
// proxy does.
export async function proxy(request: NextRequest) {
let response = NextResponse.next({ request });
const gate = auth((request) => {
const { pathname } = request.nextUrl;
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value));
response = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) => response.cookies.set(name, value, options));
},
},
}
);
// Auth.js' eigene Endpunkte müssen durch, bevor es eine Sitzung gibt —
// dort entsteht sie ja erst. Ohne diese Ausnahme leitet der Proxy den
// Rückweg aus Entra nach /login um und die Anmeldung kommt nie zustande.
if (pathname.startsWith("/api/auth")) return NextResponse.next();
const {
data: { user },
} = await supabase.auth.getUser();
const isLoginRoute = pathname.startsWith("/login");
const isSignedIn = Boolean(request.auth?.user?.id);
const isLoginRoute = request.nextUrl.pathname.startsWith("/login");
if (!isSignedIn) {
if (isLoginRoute) return NextResponse.next();
if (!user) {
if (isLoginRoute) return response;
const url = request.nextUrl.clone();
url.pathname = "/login";
url.search = "";
return NextResponse.redirect(url);
}
const { data: profile } = await supabase.from("profiles").select("role, is_active").eq("id", user.id).maybeSingle();
const isActiveHr = profile?.role === "hr" && profile?.is_active === true;
if (!isActiveHr) {
if (isLoginRoute) return response;
const url = request.nextUrl.clone();
url.pathname = "/login";
url.searchParams.set("error", "no_hr_access");
return NextResponse.redirect(url);
}
if (isLoginRoute) {
// Angemeldet und trotzdem auf /login: nur weiterschicken, wenn keine
// Meldung ansteht. Sonst geriete jemand ohne HR-Freischaltung in eine
// Schleife — das Layout leitet nach /login?error=no_hr_access, der Proxy
// zurück auf /, das Layout wieder … und der Grund wäre nie zu lesen.
if (request.nextUrl.searchParams.has("error")) return NextResponse.next();
const url = request.nextUrl.clone();
url.pathname = "/";
url.search = "";
return NextResponse.redirect(url);
}
return NextResponse.next();
});
// Zwei Eigenheiten auf einmal, beide erst beim Ausprobieren aufgefallen:
//
// 1. Next.js sucht hier eine *Funktionsdeklaration* namens `proxy` (oder
// einen Default-Export) und erkennt `export const proxy = auth(…)` nicht.
// Jede Anfrage lief in einen 404 — und `next build` meldete Erfolg und
// listete den Proxy sogar auf.
//
// 2. In der Funktionsform liefert `auth(handler)` den Handler erst als
// Zusage. Ohne `await` steht hier ein Promise, und der Aufruf scheitert
// mit „gate is not a function". Auf einem gewöhnlichen Funktionswert ist
// `await` wirkungslos, das `await` ist also in beiden Fällen richtig.
export async function proxy(...args: Parameters<Awaited<typeof gate>>) {
return (await gate)(...args);
return response;
}
export const config = {

View File

@@ -1,149 +0,0 @@
// Baut den Organisationsbaum im SAP-OM-Modell aus der fachlichen
// Bereichsdefinition des Seeds.
//
// Bewusst frei von Zufall, Datenbank und IDs aus der Umgebung: die
// Konstruktion ist die Stelle, an der sich Nummernkreise, Leitungsplanstellen
// und die Elternbeziehung falsch verdrahten lassen, ohne dass es jemandem
// auffällt — ein Team unter dem falschen Bereich sieht im Organigramm
// plausibel aus. Deshalb ist sie eine reine Funktion mit Tests.
export type OrgUnitType = "Gesellschaft" | "Bereich" | "Abteilung" | "Team";
export type TeamDef = { name: string; leadTitle: string; icTitles: string[]; baseSize: number };
export type DeptDef = { name: string; leadTitle: string; teams: TeamDef[] };
export type DivisionDef = { name: string; headTitle: string; departments: DeptDef[] };
export type BuiltUnit = {
id: string;
org_number: string;
name: string;
parent_id: string | null;
unit_type: OrgUnitType;
};
export type BuiltJob = { id: string; code: string; title: string };
export type BuiltPosition = {
id: string;
position_number: string;
org_unit_id: string;
job_id: string;
is_chief: boolean;
};
export type BuiltOrg = {
units: BuiltUnit[];
jobs: BuiltJob[];
positions: BuiltPosition[];
/** Für jedes Team die Planstellen der Mitarbeitenden, in Reihenfolge. */
icPositionsByTeam: Map<string, BuiltPosition[]>;
};
// Nummernkreise wie im Altmodell, damit die Nummern der Einheiten über den
// Umstieg hinweg wiedererkennbar bleiben.
const PREFIX: Record<OrgUnitType, string> = {
Gesellschaft: "10",
Bereich: "20",
Abteilung: "21",
Team: "22",
};
function orgNumber(type: OrgUnitType, counter: number): string {
return `${PREFIX[type]}${String(counter).padStart(6, "0")}`;
}
/** Planstellennummern folgen dem bestehenden Muster ^6\d{7}$. */
function positionNumber(counter: number): string {
return `6${String(counter).padStart(7, "0")}`;
}
function jobCode(counter: number): string {
return `J${String(counter).padStart(4, "0")}`;
}
/**
* `newId` wird hereingereicht, damit die Funktion in Tests deterministisch
* bleibt und im Seed randomUUID benutzt.
*/
export function buildOrg(
companyName: string,
divisions: DivisionDef[],
newId: () => string
): BuiltOrg {
const units: BuiltUnit[] = [];
const positions: BuiltPosition[] = [];
const icPositionsByTeam = new Map<string, BuiltPosition[]>();
// Job-Katalog: gleiche Tätigkeit, ein Eintrag. Vorher war job_title
// Freitext je Person, weshalb sich Tätigkeiten nicht auswerten liessen.
const jobIdByTitle = new Map<string, string>();
const jobs: BuiltJob[] = [];
function jobFor(title: string): string {
const existing = jobIdByTitle.get(title);
if (existing) return existing;
const job = { id: newId(), code: jobCode(jobs.length + 1), title };
jobs.push(job);
jobIdByTitle.set(title, job.id);
return job.id;
}
let unitCounter = { Gesellschaft: 0, Bereich: 0, Abteilung: 0, Team: 0 };
let positionCounter = 0;
function addUnit(name: string, type: OrgUnitType, parentId: string | null): BuiltUnit {
unitCounter = { ...unitCounter, [type]: unitCounter[type] + 1 };
const unit: BuiltUnit = {
id: newId(),
org_number: orgNumber(type, unitCounter[type] * (type === "Team" ? 1000 : type === "Abteilung" ? 10000 : 100000)),
name,
parent_id: parentId,
unit_type: type,
};
units.push(unit);
return unit;
}
function addPosition(unitId: string, title: string, isChief: boolean): BuiltPosition {
positionCounter += 1;
const position: BuiltPosition = {
id: newId(),
position_number: positionNumber(positionCounter),
org_unit_id: unitId,
job_id: jobFor(title),
is_chief: isChief,
};
positions.push(position);
return position;
}
const company = addUnit(companyName, "Gesellschaft", null);
addPosition(company.id, "Geschäftsführer:in", true);
// Die Assistenz hängt an der Gesellschaft, führt sie aber nicht — genau
// die Unterscheidung, die es im Altmodell nicht gab.
addPosition(company.id, "Assistenz der Geschäftsführung", false);
for (const div of divisions) {
const bereich = addUnit(div.name, "Bereich", company.id);
addPosition(bereich.id, div.headTitle, true);
for (const dept of div.departments) {
const abteilung = addUnit(dept.name, "Abteilung", bereich.id);
// Die Ebene, die im Altmodell gefehlt hat: eine Abteilung hat jetzt
// eine eigene Leitungsplanstelle.
addPosition(abteilung.id, dept.leadTitle, true);
for (const team of dept.teams) {
const teamUnit = addUnit(team.name, "Team", abteilung.id);
addPosition(teamUnit.id, team.leadTitle, true);
const size = Math.max(1, team.baseSize);
const icPositions = Array.from({ length: size }, (_, i) =>
addPosition(teamUnit.id, team.icTitles[i % team.icTitles.length], false)
);
icPositionsByTeam.set(teamUnit.id, icPositions);
}
}
}
return { units, jobs, positions, icPositionsByTeam };
}

View File

@@ -1,123 +0,0 @@
-- Organisationsmanagement nach SAP-OM-Vorbild.
--
-- Bisher: drei feste Tabellen (divisions -> departments -> teams) und
-- Personen, die direkt daran hängen (employees.division_id/team_id) mit
-- einer frei gepflegten manager_id. Damit ist die Hierarchie in ihrer Tiefe
-- fest verdrahtet — eine Abteilungsleitung liess sich nicht abbilden, ohne
-- das Schema zu ändern, und ein Team direkt unter einem Bereich gar nicht.
--
-- SAP OM löst das über wenige Objekttypen und Verknüpfungen dazwischen:
--
-- O Organisationseinheit org_units (rekursiv über parent_id)
-- C Stelle / Job jobs (Katalog)
-- S Planstelle positions (gehört zu genau einer O)
-- P Person employees (besetzt eine S)
--
-- A003 "gehört zu" positions.org_unit_id
-- A012 "ist Leiter von" positions.is_chief
-- A008 "Inhaber ist" position_assignments
--
-- Die Berichtslinie wird daraus abgeleitet statt gepflegt, siehe die
-- folgende Migration. Ebenen sind nur noch ein Etikett (unit_type), keine
-- Struktur — eine fünfte Ebene ist damit eine Datenfrage, keine Migration.
-- ── O: Organisationseinheit ────────────────────────────────────────
create type org_unit_type as enum ('Gesellschaft', 'Bereich', 'Abteilung', 'Team');
create table org_units (
id uuid primary key default gen_random_uuid(),
org_number text not null unique,
name text not null,
-- Die Hierarchie selbst. Null nur für die Wurzel.
parent_id uuid references org_units(id),
-- Nur Beschriftung und Nummernkreis-Konvention; die Struktur steckt in
-- parent_id. Eine Abteilung unter einer Abteilung wäre technisch möglich
-- und ist bewusst nicht verboten.
unit_type org_unit_type not null,
valid_from date not null default current_date,
valid_to date,
created_at timestamptz not null default now(),
constraint chk_org_unit_range check (valid_to is null or valid_to > valid_from),
constraint chk_org_unit_not_own_parent check (parent_id is null or parent_id <> id)
);
create index on org_units (parent_id);
create index on org_units (unit_type);
-- Genau eine Wurzel: ohne das kann ein Fehlgriff beim Import einen zweiten
-- Baum aufmachen, und die Ableitung der Berichtslinie liefe ins Leere.
create unique index org_units_single_root on org_units ((parent_id is null)) where parent_id is null;
comment on table org_units is 'SAP-OM-Objekttyp O. Rekursiv über parent_id; unit_type ist nur ein Etikett.';
-- ── C: Stelle / Job-Katalog ────────────────────────────────────────
-- Trennt die Tätigkeitsbeschreibung von der einzelnen Planstelle: viele
-- Planstellen teilen sich einen Job. Bisher war job_title Freitext je
-- Person, weshalb "Schlosser:in" und "Schlosser" nebeneinander existieren
-- konnten und keine Auswertung über Tätigkeiten möglich war.
create table jobs (
id uuid primary key default gen_random_uuid(),
code text not null unique,
title text not null unique,
created_at timestamptz not null default now()
);
comment on table jobs is 'SAP-OM-Objekttyp C. Katalog der Tätigkeiten; Planstellen verweisen darauf.';
-- ── S: Planstelle ──────────────────────────────────────────────────
-- Anders als die bisherige positions-Tabelle, die nur *offene* Stellen
-- führte: hier bekommt jede Person eine Planstelle. Eine offene Stelle ist
-- schlicht eine Planstelle ohne laufende Besetzung — Vakanz ist damit eine
-- Eigenschaft der Planstelle, kein eigenes Objekt.
create table om_positions (
id uuid primary key default gen_random_uuid(),
position_number text not null unique,
org_unit_id uuid not null references org_units(id),
job_id uuid not null references jobs(id),
-- A012 "ist Leiter von": diese Planstelle führt ihre Organisationseinheit.
is_chief boolean not null default false,
valid_from date not null default current_date,
valid_to date,
created_at timestamptz not null default now(),
constraint chk_om_position_range check (valid_to is null or valid_to > valid_from)
);
create index on om_positions (org_unit_id);
create index on om_positions (job_id);
-- Höchstens eine Leitungsplanstelle je Einheit, solange sie gültig ist.
create unique index om_positions_one_chief on om_positions (org_unit_id) where is_chief and valid_to is null;
comment on table om_positions is 'SAP-OM-Objekttyp S. is_chief entspricht der Verknüpfung A012 "ist Leiter von".';
-- ── A008: Person besetzt Planstelle ────────────────────────────────
create table position_assignments (
id uuid primary key default gen_random_uuid(),
position_id uuid not null references om_positions(id) on delete cascade,
employee_id uuid not null references employees(id) on delete cascade,
valid_from date not null,
valid_to date,
created_at timestamptz not null default now(),
constraint chk_assignment_range check (valid_to is null or valid_to > valid_from)
);
create index on position_assignments (position_id);
create index on position_assignments (employee_id);
-- Eine Planstelle ist zu einem Zeitpunkt von höchstens einer Person besetzt,
-- und eine Person hat höchstens eine laufende Planstelle. Beides sind die
-- Invarianten, auf die sich die Ableitung der Berichtslinie stützt.
create unique index position_assignments_one_holder on position_assignments (position_id) where valid_to is null;
create unique index position_assignments_one_position on position_assignments (employee_id) where valid_to is null;
comment on table position_assignments is 'SAP-OM-Verknüpfung A008 "Inhaber ist", zeitabhängig.';
-- ── RLS, wie bei allen anderen Tabellen ────────────────────────────
alter table org_units enable row level security;
alter table jobs enable row level security;
alter table om_positions enable row level security;
alter table position_assignments enable row level security;
create policy "org_units_hr_all" on org_units for all using (is_hr_user()) with check (is_hr_user());
create policy "jobs_hr_all" on jobs for all using (is_hr_user()) with check (is_hr_user());
create policy "om_positions_hr_all" on om_positions for all using (is_hr_user()) with check (is_hr_user());
create policy "position_assignments_hr_all" on position_assignments for all using (is_hr_user()) with check (is_hr_user());
grant all on table org_units, jobs, om_positions, position_assignments to anon, authenticated, service_role;

View File

@@ -1,101 +0,0 @@
-- Die Berichtslinie wird abgeleitet, nicht gepflegt.
--
-- Bisher stand sie als employees.manager_id in der Tabelle und wurde von
-- resolve_manager_for() bei jeder Mutation neu geraten. Damit konnte sie von
-- der Organisationsstruktur abweichen, und tat es auch.
--
-- SAP-OM-Regel, hier eins zu eins:
--
-- Wer eine gewöhnliche Planstelle innehat, berichtet an die Leitung der
-- eigenen Organisationseinheit. Wer selbst die Leitung innehat, berichtet
-- an die Leitung der übergeordneten Einheit.
--
-- Dazu kommt die Aufwärtsregel: Ist diese Leitungsplanstelle unbesetzt oder
-- ihre Inhaberin langzeitabwesend, geht es weiter nach oben, bis eine
-- besetzte und anwesende Leitung gefunden ist. Genau deshalb braucht eine
-- unbesetzte Abteilungsleitung keine Sonderbehandlung — sie wird schlicht
-- übersprungen.
--
-- Beides wird zurückgegeben: die formale Leitung (auch wenn abwesend) und
-- die tatsächliche. Nur so lässt sich in der Oberfläche zeigen, dass eine
-- Vertretung im Spiel ist, statt sie stillschweigend als die echte
-- Führungskraft auszugeben.
create or replace function om_reporting_lines(p_as_of date default current_date)
returns table (
employee_id uuid,
position_id uuid,
org_unit_id uuid,
is_chief boolean,
formal_manager_id uuid,
acting_manager_id uuid
)
language sql
stable
as $$
with recursive
-- Laufende Besetzungen: Planstelle und Zuordnung müssen beide am Stichtag
-- gültig sein.
holder as (
select pa.employee_id, pa.position_id, p.org_unit_id, p.is_chief
from position_assignments pa
join om_positions p on p.id = pa.position_id
where pa.valid_from <= p_as_of and (pa.valid_to is null or pa.valid_to > p_as_of)
and p.valid_from <= p_as_of and (p.valid_to is null or p.valid_to > p_as_of)
),
-- Leitung je Einheit, samt Abwesenheit am Stichtag. Die Ableitung ist
-- dieselbe wie in deriveStatusAsOf() auf der Anwendungsseite.
chief as (
select h.org_unit_id, h.employee_id,
(e.karenz_start_date is not null
and e.karenz_start_date <= p_as_of
and (e.karenz_return_date is null or p_as_of < e.karenz_return_date)) as absent
from holder h
join employees e on e.id = h.employee_id
where h.is_chief
),
-- Vorfahrenkette je Einheit; Tiefe 0 ist die Einheit selbst. Bei rund
-- sechzig Einheiten ist das billig, und es macht die Suche nach der
-- nächsten geeigneten Leitung zu einem einfachen "erster Treffer".
ancestry as (
select u.id as unit_id, u.id as ancestor_id, u.parent_id, 0 as depth
from org_units u
union all
select a.unit_id, p.id, p.parent_id, a.depth + 1
from ancestry a
join org_units p on p.id = a.parent_id
),
-- Die Einheit, ab der gesucht wird: für eine Leitung die übergeordnete,
-- sonst die eigene.
base as (
select h.employee_id, h.position_id, h.org_unit_id, h.is_chief,
case when h.is_chief then u.parent_id else h.org_unit_id end as base_unit_id
from holder h
join org_units u on u.id = h.org_unit_id
)
select
b.employee_id,
b.position_id,
b.org_unit_id,
b.is_chief,
-- Formale Leitung: die der Ausgangseinheit, unabhängig von Abwesenheit.
(select c.employee_id from chief c where c.org_unit_id = b.base_unit_id) as formal_manager_id,
-- Tatsächliche Leitung: die nächste besetzte und anwesende oberhalb,
-- die Ausgangseinheit eingeschlossen.
(
select c.employee_id
from ancestry a
join chief c on c.org_unit_id = a.ancestor_id
where a.unit_id = b.base_unit_id
and not c.absent
and c.employee_id <> b.employee_id
order by a.depth
limit 1
) as acting_manager_id
from base b;
$$;
comment on function om_reporting_lines(date) is
'Leitet die Berichtslinie zum Stichtag aus dem Organisationsbaum ab. formal_manager_id ist die zuständige Leitung, acting_manager_id die nächste besetzte und anwesende darüber.';
grant execute on function om_reporting_lines(date) to anon, authenticated, service_role;

View File

@@ -1,539 +0,0 @@
-- Umstieg auf das SAP-OM-Modell: Altbestand überführen, Altmodell entfernen.
--
-- Läuft nach 20260727120000 (Tabellen) und 20260727120100 (Berichtslinie).
--
-- Warum ein Schnitt und keine schrittweise Migration: Sobald eine
-- Abteilungsleitung besetzt ist, liefert das alte resolve_manager_for()
-- falsche Ergebnisse. Es sucht die Bereichsleitung über
-- "division_id = X and team_id is null and org_level = 1" — eine
-- Abteilungsleitung erfüllt dieselbe Bedingung, und das LIMIT 1 greift dann
-- willkürlich eine von beiden.
--
-- Der Bestand wird überführt, nicht gelöscht. Direkt nach dem Ausführen ist
-- das Organigramm gefüllt.
--
-- Die Abteilungsleitungen entstehen als *unbesetzte* Planstellen: es gibt
-- niemanden, der sie innehat, und erfundene Zuordnungen wären schlechter als
-- eine sichtbare Lücke. Die Aufwärtsregel überspringt sie, bis sie besetzt
-- sind — die Berichtslinie bleibt durchgängig.
begin;
-- ═══ 1. Organisationseinheiten ═══════════════════════════════════
-- Wurzel. Die bisherige Pseudo-Division "Geschäftsführung" wird sie, damit
-- die Personen, die daran hingen, ihre Einheit behalten.
insert into org_units (id, org_number, name, parent_id, unit_type, valid_from)
select id, '10000000', 'Alpenwerk Industrie GmbH', null, 'Gesellschaft', '2000-01-01'
from divisions where name = 'Geschäftsführung';
-- Falls es sie nicht gab, eine neue Wurzel anlegen.
insert into org_units (org_number, name, parent_id, unit_type, valid_from)
select '10000000', 'Alpenwerk Industrie GmbH', null, 'Gesellschaft', '2000-01-01'
where not exists (select 1 from org_units where unit_type = 'Gesellschaft');
insert into org_units (id, org_number, name, parent_id, unit_type, valid_from)
select d.id, d.org_number, d.name,
(select id from org_units where unit_type = 'Gesellschaft'),
'Bereich', '2000-01-01'
from divisions d
where d.name <> 'Geschäftsführung';
insert into org_units (id, org_number, name, parent_id, unit_type, valid_from)
select dep.id, dep.org_number, dep.name,
coalesce((select u.id from org_units u where u.id = dep.division_id),
(select id from org_units where unit_type = 'Gesellschaft')),
'Abteilung', '2000-01-01'
from departments dep;
insert into org_units (id, org_number, name, parent_id, unit_type, valid_from)
select t.id, t.org_number, t.name, t.department_id, 'Team', '2000-01-01'
from teams t;
-- ═══ 2. Job-Katalog ══════════════════════════════════════════════
-- Vollständig *vor* den Planstellen, die darauf verweisen. Enthält auch die
-- Titel der neuen Abteilungsleitungen.
insert into jobs (code, title)
select 'J' || lpad(row_number() over (order by title)::text, 4, '0'), title
from (
select distinct job_title as title from employees where job_title is not null
union
select distinct title from positions where title is not null
union
select distinct 'Abteilungsleitung ' || name from org_units where unit_type = 'Abteilung'
) t
on conflict (title) do nothing;
-- ═══ 3. Planstellen und Besetzungen ══════════════════════════════
-- Die Zuordnung Person -> Planstelle wird einmal festgelegt und dann von
-- beiden Inserts benutzt. Zwei unabhängig berechnete Fensterfunktionen
-- wären hier die klassische Fehlerquelle: sie sehen gleich aus und ordnen
-- doch verschieden.
-- Kein "on commit drop": im SQL-Editor hängt es vom Transaktionsverhalten
-- ab, wann das greift, und eine zu früh verschwundene Zuordnungstabelle
-- wäre schwer zu diagnostizieren. Wird am Ende explizit entfernt.
create temporary table om_pos_map (
employee_id uuid primary key,
position_id uuid not null default gen_random_uuid(),
seq bigint
);
insert into om_pos_map (employee_id, seq)
select id, row_number() over (order by org_level, personnel_number) from employees;
insert into om_positions (id, position_number, org_unit_id, job_id, is_chief, valid_from)
select
m.position_id,
'6' || lpad(m.seq::text, 7, '0'),
case
when e.org_level = 0 then (select id from org_units where unit_type = 'Gesellschaft')
when e.org_level = 1 then coalesce(e.division_id, (select id from org_units where unit_type = 'Gesellschaft'))
else coalesce(e.team_id, (select id from org_units where unit_type = 'Gesellschaft'))
end,
(select j.id from jobs j where j.title = e.job_title),
(e.org_level <= 1 or (e.org_level = 2 and e.is_lead)),
e.entry_date
from employees e
join om_pos_map m on m.employee_id = e.id;
-- Wer ausgetreten ist, hat eine beendete Besetzung: die Planstelle ist
-- wieder frei, die Historie bleibt.
--
-- greatest(): employees erlaubt exit_date = entry_date, die Prüfregel auf
-- position_assignments verlangt aber valid_to > valid_from. Ein
-- gleichtägiger Ein- und Austritt würde die Migration sonst abbrechen.
insert into position_assignments (position_id, employee_id, valid_from, valid_to)
select m.position_id, e.id, e.entry_date,
case when e.exit_date is null then null else greatest(e.exit_date, e.entry_date + 1) end
from employees e
join om_pos_map m on m.employee_id = e.id;
-- Unbesetzte Abteilungsleitungen — die Ebene, die im Altmodell fehlte.
insert into om_positions (position_number, org_unit_id, job_id, is_chief, valid_from)
select '69' || lpad(row_number() over (order by u.org_number)::text, 6, '0'),
u.id,
(select id from jobs where title = 'Abteilungsleitung ' || u.name),
true,
current_date
from org_units u
where u.unit_type = 'Abteilung'
and not exists (select 1 from om_positions p where p.org_unit_id = u.id and p.is_chief);
-- Bisher offene Stellen werden unbesetzte Planstellen. is_chief nur, wenn
-- die Einheit noch keine Leitung hat — der Unique-Index liesse es sonst
-- ohnehin nicht zu.
insert into om_positions (position_number, org_unit_id, job_id, is_chief, valid_from)
select p.position_number, p.team_id,
(select id from jobs j where j.title = p.title),
p.is_lead and not exists (select 1 from om_positions o where o.org_unit_id = p.team_id and o.is_chief),
p.valid_from
from positions p
where p.status = 'open'
and exists (select 1 from org_units u where u.id = p.team_id);
-- Abbruch, bevor das Altmodell fällt: lieber eine gescheiterte Migration
-- als ein halb überführter Bestand ohne Rückweg.
do $$
declare v_fehlend int;
begin
select count(*) into v_fehlend
from employees e
where not exists (select 1 from position_assignments pa where pa.employee_id = e.id);
if v_fehlend > 0 then
raise exception 'Abbruch: % Mitarbeitende ohne Planstelle.', v_fehlend;
end if;
select count(*) into v_fehlend from om_positions where job_id is null;
if v_fehlend > 0 then
raise exception 'Abbruch: % Planstellen ohne Job.', v_fehlend;
end if;
select count(*) into v_fehlend
from org_units u
where u.parent_id is null and u.unit_type <> 'Gesellschaft';
if v_fehlend > 0 then
raise exception 'Abbruch: % Einheiten ohne Elternteil.', v_fehlend;
end if;
end $$;
drop table om_pos_map;
-- ═══ 4. Altmodell entfernen ══════════════════════════════════════
-- Vorgemerkte Änderungen verweisen über team_id auf das Altmodell. Sie sind
-- transient; halb übersetzt wären sie schlimmer als verworfen.
delete from pending_org_changes where status = 'pending';
drop trigger if exists trg_track_employee_assignment on employees;
drop function if exists fn_track_employee_assignment();
drop table if exists employee_assignments;
-- Leitet division_id aus team_id ab und haengt damit an einer Spalte, die
-- gleich faellt. Im neuen Modell uebernimmt die Einheit der Planstelle
-- diese Rolle, der Trigger wird ersatzlos entfernt.
drop trigger if exists trg_employees_set_org_unit on employees;
drop function if exists fn_set_employee_org_unit();
-- Die View selektiert team_id/division_id/manager_id/org_level/is_lead und
-- blockiert damit das Entfernen dieser Spalten. Sie wird von der Anwendung
-- nirgends benutzt und ersatzlos entfernt; die Ableitung über
-- om_reporting_lines() tritt an ihre Stelle.
drop view if exists employees_directory;
-- Funktionen auf den Alt-Spalten. Die weiterhin benötigten werden in
-- Abschnitt 5 neu angelegt; Reorganisation und Ausschreibung folgen mit der
-- Umstellung der Oberfläche.
drop function if exists resolve_manager_for(uuid, boolean, uuid);
drop function if exists staff_position_internally(jsonb);
drop function if exists create_position(jsonb);
drop function if exists delete_position(uuid);
drop function if exists apply_reorg(jsonb);
drop function if exists undo_reorg(uuid);
drop function if exists hire_employee(jsonb);
drop function if exists terminate_employee(jsonb);
drop function if exists transfer_employee(jsonb);
drop function if exists rehire_employee(jsonb);
drop function if exists record_karenz_return(jsonb);
drop function if exists apply_due_pending_changes();
alter table employees
drop column if exists division_id,
drop column if exists team_id,
drop column if exists manager_id,
drop column if exists org_level,
drop column if exists is_lead;
drop table if exists positions;
drop table if exists teams;
drop table if exists departments;
drop table if exists divisions;
-- Mit der positions-Tabelle verschwindet ihr Trigger, nicht aber dessen
-- Funktion und die Nummernvergabe, die nur als Spaltenvorgabe dort benutzt
-- wurde. om_positions vergibt seine Nummern selbst.
drop function if exists fn_set_position_org_unit();
drop function if exists generate_position_number();
-- ═══ 5. Mutationen im neuen Modell ═══════════════════════════════
-- Die Berichtslinie wird nicht mehr mitgeschrieben, sondern abgeleitet.
-- Das entfernt aus jeder dieser Funktionen die Manager-Nachführung — beim
-- Austritt etwa entfällt das Umhängen der direkten Berichte vollständig,
-- weil sie ohnehin auf die nächste besetzte Ebene hochrutschen.
create or replace function hire_employee(payload jsonb)
returns uuid language plpgsql as $$
declare
v_id uuid;
v_position_id uuid := (payload->>'position_id')::uuid;
v_entry date := (payload->>'entry_date')::date;
v_besetzt uuid;
begin
perform require_hr_admin();
if v_position_id is null then
raise exception 'Es muss eine Planstelle angegeben werden.';
end if;
select pa.employee_id into v_besetzt
from position_assignments pa
where pa.position_id = v_position_id and pa.valid_to is null;
if v_besetzt is not null then
raise exception 'Diese Planstelle ist bereits besetzt.';
end if;
insert into employees (
first_name, last_name, gender, birth_date, sv_nummer, nationality, email, phone,
address, postal_code, city, address_country, location_id, job_title,
employment_type, weekly_hours, contract_type, contract_end_date, paygrade,
source, status, entry_date, title_prefix, title_suffix,
worker_type, collective_agreement, work_days,
is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level
)
values (
payload->>'first_name', payload->>'last_name', (payload->>'gender')::gender_type,
(payload->>'birth_date')::date, payload->>'sv_nummer',
coalesce(payload->>'nationality', 'Österreich'), payload->>'email', payload->>'phone',
payload->>'address', payload->>'postal_code', payload->>'city',
coalesce(payload->>'address_country', 'Österreich'),
(payload->>'location_id')::uuid,
(select j.title from om_positions p join jobs j on j.id = p.job_id where p.id = v_position_id),
coalesce((payload->>'employment_type')::employment_type, 'Vollzeit'),
coalesce((payload->>'weekly_hours')::numeric, 38.5),
coalesce((payload->>'contract_type')::contract_type, 'unbefristet'),
nullif(payload->>'contract_end_date', '')::date,
coalesce((payload->>'paygrade')::paygrade_type, 'B'),
coalesce((payload->>'source')::source_type, 'Extern'),
case when v_entry > current_date then 'Geplant' else 'Aktiv' end::employment_status,
v_entry,
coalesce(array(select jsonb_array_elements_text(payload->'title_prefix')), '{}'),
coalesce(array(select jsonb_array_elements_text(payload->'title_suffix')), '{}'),
coalesce((payload->>'worker_type')::worker_type, 'Angestellte:r'),
coalesce((payload->>'collective_agreement')::collective_agreement, 'Süßwaren'),
coalesce(array(select jsonb_array_elements_text(payload->'work_days'))::weekday[], '{Mo,Di,Mi,Do,Fr}'),
coalesce((payload->>'is_betriebsrat')::boolean, false),
coalesce((payload->>'has_dienstwagen')::boolean, false),
coalesce((payload->>'is_laterale_fuehrung')::boolean, false),
coalesce((payload->>'is_c_level')::boolean, false)
)
returning id into v_id;
insert into position_assignments (position_id, employee_id, valid_from)
values (v_position_id, v_id, v_entry);
insert into employee_history (employee_id, event_date, event_type, description)
values (v_id, v_entry, 'Eintritt', 'Eintritt auf Planstelle ' ||
(select position_number from om_positions where id = v_position_id));
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Neueinstellung',
payload->>'first_name' || ' ' || payload->>'last_name', v_id, 'Eintritt am ' || v_entry);
return v_id;
end;
$$;
create or replace function terminate_employee(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_exit date := (payload->>'exit_date')::date;
v_name text;
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
update employees set
status = case when v_exit <= current_date then 'Ausgetreten' else status end,
exit_date = v_exit,
exit_reason = payload->>'exit_reason'
where id = v_employee_id;
-- Die Planstelle wird frei. Direkte Berichte müssen nicht umgehängt
-- werden: die Berichtslinie wird abgeleitet und rutscht von selbst auf
-- die nächste besetzte Ebene.
update position_assignments set valid_to = v_exit
where employee_id = v_employee_id and valid_to is null;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_exit, 'Austritt', 'Austritt (' || coalesce(payload->>'exit_reason', '-') || ')');
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Austritt', v_name, v_employee_id, 'Austritt am ' || v_exit);
end;
$$;
-- Versetzung ist im OM-Modell ein Wechsel der Planstelle: die alte
-- Besetzung endet, die neue beginnt. Bereich, Abteilung und Team ergeben
-- sich aus der Einheit der Zielplanstelle und werden nicht mehr mitgeführt.
create or replace function transfer_employee(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_target_position uuid := (payload->>'target_position_id')::uuid;
v_effective date := coalesce(nullif(payload->>'effective_date','')::date, current_date);
v_name text;
v_besetzt uuid;
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
select pa.employee_id into v_besetzt
from position_assignments pa
where pa.position_id = v_target_position and pa.valid_to is null;
if v_besetzt is not null and v_besetzt <> v_employee_id then
raise exception 'Die Zielplanstelle ist bereits besetzt.';
end if;
if v_effective <= current_date then
update position_assignments set valid_to = v_effective
where employee_id = v_employee_id and valid_to is null;
insert into position_assignments (position_id, employee_id, valid_from)
values (v_target_position, v_employee_id, v_effective);
update employees set job_title =
(select j.title from om_positions p join jobs j on j.id = p.job_id where p.id = v_target_position)
where id = v_employee_id;
else
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'transfer', v_effective,
jsonb_build_object('target_position_id', v_target_position));
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective, 'Versetzung', 'Versetzung auf Planstelle ' ||
(select position_number from om_positions where id = v_target_position));
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Versetzung', v_name, v_employee_id, 'Wirksam ab ' || v_effective);
end;
$$;
create or replace function rehire_employee(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_date date := (payload->>'rehire_date')::date;
v_position_id uuid := (payload->>'position_id')::uuid;
v_name text;
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
if v_position_id is null then
raise exception 'Für die Wiedereinstellung muss eine Planstelle angegeben werden.';
end if;
update employees set
status = case when v_date <= current_date then 'Aktiv' else 'Geplant' end,
entry_date = v_date,
exit_date = null,
exit_reason = null
where id = v_employee_id;
insert into position_assignments (position_id, employee_id, valid_from)
values (v_position_id, v_employee_id, v_date);
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_date, 'Wiedereintritt', 'Wiedereinstellung zum ' || v_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Wiedereinstellung', v_name, v_employee_id, 'Wiedereintritt am ' || v_date);
end;
$$;
create or replace function record_karenz_return(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_return_date date := (payload->>'return_date')::date;
v_name text;
v_employment_type employment_type;
v_weekly_hours numeric;
v_karenz_start date;
v_absence_type text;
begin
perform require_hr_admin();
select first_name || ' ' || last_name, karenz_start_date, absence_type
into v_name, v_karenz_start, v_absence_type
from employees where id = v_employee_id;
if v_karenz_start is not null and v_return_date <= v_karenz_start then
raise exception 'Das Rückkehrdatum muss nach dem Beginn der Langzeitabwesenheit (%) liegen.', v_karenz_start;
end if;
if payload->>'employment_mode' = 'Vollzeit' then
v_employment_type := 'Vollzeit'; v_weekly_hours := 38.5;
elsif payload->>'employment_mode' = 'Teilzeit' then
v_employment_type := 'Teilzeit'; v_weekly_hours := (payload->>'weekly_hours')::numeric;
end if;
if v_return_date <= current_date then
-- Keine Manager-Nachführung mehr nötig: wer aus der Abwesenheit
-- zurückkehrt, ist wieder anwesend, und die abgeleitete Berichtslinie
-- fällt automatisch von der Vertretung auf ihn zurück.
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
absence_type = null,
employment_type = coalesce(v_employment_type, employment_type),
weekly_hours = coalesce(v_weekly_hours, weekly_hours)
where id = v_employee_id;
else
update employees set karenz_return_date = v_return_date where id = v_employee_id;
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'karenz_return', v_return_date,
jsonb_build_object('employment_type', v_employment_type, 'weekly_hours', v_weekly_hours));
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_return_date, 'Rückkehr',
'Rückkehr aus ' || coalesce(v_absence_type, 'Langzeitabwesenheit') || ' am ' || v_return_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Rückkehr', v_name, v_employee_id, 'Rückkehr am ' || v_return_date);
end;
$$;
create or replace function apply_due_pending_changes()
returns int language plpgsql security definer set search_path = public as $$
declare
v_rec record;
v_count int := 0;
begin
for v_rec in
select * from pending_org_changes
where status = 'pending' and effective_date <= current_date
order by effective_date, created_at
loop
if v_rec.change_type = 'transfer' then
update position_assignments set valid_to = v_rec.effective_date
where employee_id = v_rec.employee_id and valid_to is null;
insert into position_assignments (position_id, employee_id, valid_from)
values ((v_rec.payload->>'target_position_id')::uuid, v_rec.employee_id, v_rec.effective_date);
update employees set job_title = (
select j.title from om_positions p join jobs j on j.id = p.job_id
where p.id = (v_rec.payload->>'target_position_id')::uuid
) where id = v_rec.employee_id;
elsif v_rec.change_type = 'promotion' then
update employees set
job_title = coalesce(v_rec.payload->>'new_title', job_title),
paygrade = coalesce((v_rec.payload->>'new_paygrade')::paygrade_type, paygrade)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'karenz_start' then
update employees set
status = 'Karenz',
karenz_return_date = (v_rec.payload->>'planned_return_date')::date,
absence_type = coalesce(nullif(v_rec.payload->>'absence_type', ''), absence_type)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'karenz_return' then
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
absence_type = null,
employment_type = coalesce((v_rec.payload->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_rec.payload->>'weekly_hours')::numeric, weekly_hours)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'contract_change' then
update employees set
first_name = coalesce(v_rec.payload->'person'->>'first_name', first_name),
last_name = coalesce(v_rec.payload->'person'->>'last_name', last_name),
gender = coalesce((v_rec.payload->'person'->>'gender')::gender_type, gender),
birth_date = coalesce((v_rec.payload->'person'->>'birth_date')::date, birth_date),
sv_nummer = coalesce(v_rec.payload->'person'->>'sv_nummer', sv_nummer),
nationality = coalesce(v_rec.payload->'person'->>'nationality', nationality),
address = coalesce(v_rec.payload->'person'->>'address', address),
postal_code = coalesce(v_rec.payload->'person'->>'postal_code', postal_code),
city = coalesce(v_rec.payload->'person'->>'city', city),
address_country = coalesce(v_rec.payload->'person'->>'address_country', address_country),
email = coalesce(v_rec.payload->'person'->>'email', email),
phone = coalesce(v_rec.payload->'person'->>'phone', phone),
employment_type = coalesce((v_rec.payload->'contract'->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_rec.payload->'contract'->>'weekly_hours')::numeric, weekly_hours),
contract_type = coalesce((v_rec.payload->'contract'->>'contract_type')::contract_type, contract_type)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'dependent_add' then
insert into employee_dependents (employee_id, first_name, last_name, relationship, sv_nummer, birth_date)
values (v_rec.employee_id, v_rec.payload->>'first_name', v_rec.payload->>'last_name',
(v_rec.payload->>'relationship')::relationship_type,
nullif(v_rec.payload->>'sv_nummer', ''), (v_rec.payload->>'birth_date')::date);
elsif v_rec.change_type = 'dependent_remove' then
delete from employee_dependents where id = (v_rec.payload->>'dependent_id')::uuid;
end if;
update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id;
v_count := v_count + 1;
end loop;
return v_count;
end;
$$;
commit;

View File

@@ -1,150 +0,0 @@
-- Reste des Altmodells entfernen und die Planstellenpflege im OM-Modell
-- nachziehen.
--
-- Die Cut-over-Migration hat die Funktionen des Altmodells mit ihren damals
-- bekannten Signaturen entfernt. Ein Teil davon existierte zusätzlich in
-- einer jsonb-Variante und ist deshalb stehen geblieben — sichtbar daran,
-- dass delete_position und undo_reorg weiterhin in der PostgREST-Schnittstelle
-- auftauchen, obwohl die Tabellen, auf denen sie arbeiten, weg sind. Ein
-- Aufruf würde erst zur Laufzeit scheitern.
-- ═══ 1. Übriggebliebene Funktionen des Altmodells ════════════════
drop function if exists create_position(jsonb);
drop function if exists delete_position(jsonb);
drop function if exists delete_position(uuid);
drop function if exists staff_position_internally(jsonb);
drop function if exists apply_reorg(jsonb);
drop function if exists undo_reorg(jsonb);
drop function if exists undo_reorg(uuid);
-- ═══ 2. Reorganisations-Werkbank ═════════════════════════════════
-- Sie hat Teams und Abteilungen zwischen Bereichen verschoben — Objekte, die
-- es nicht mehr gibt. Im OM-Modell ist eine Reorganisation das Umhängen von
-- org_units.parent_id und braucht kein eigenes Szenario-Modell mehr.
alter table employee_history drop column if exists reorg_scenario_id;
alter table pending_org_changes drop column if exists reorg_scenario_id;
drop table if exists reorg_moves;
drop table if exists reorg_scenarios;
-- ═══ 3. Planstellen pflegen ══════════════════════════════════════
-- Die alte positions-Tabelle führte nur *offene* Stellen und war damit ein
-- eigenes Objekt neben der Person. Im OM-Modell hat jede Person eine
-- Planstelle, und eine offene Stelle ist schlicht eine unbesetzte. Anlegen
-- und Schliessen sind deshalb Operationen auf om_positions.
-- set search_path bei jeder Funktion: siehe die folgende Migration, dort steht
-- warum. Kurz: heute sind das INVOKER-Funktionen und der Pfad ist harmlos,
-- aber sobald eine davon einmal SECURITY DEFINER wird, wäre er es nicht mehr —
-- und daran denkt dann niemand.
create or replace function next_position_number()
returns text language sql stable
set search_path = public, pg_temp
as $$
select '6' || lpad((coalesce(max(substring(position_number from 2)::bigint), 0) + 1)::text, 7, '0')
from om_positions
where position_number ~ '^6[0-9]{7}$';
$$;
comment on function next_position_number() is
'Nächste freie Planstellennummer im Nummernkreis 6xxxxxxx.';
create or replace function create_position(payload jsonb)
returns uuid language plpgsql
set search_path = public, pg_temp
as $$
declare
v_org_unit_id uuid := (payload->>'org_unit_id')::uuid;
v_job_title text := nullif(trim(payload->>'job_title'), '');
v_is_chief boolean := coalesce((payload->>'is_chief')::boolean, false);
v_valid_from date := coalesce(nullif(payload->>'valid_from','')::date, current_date);
v_job_id uuid;
v_position_id uuid;
v_unit_name text;
begin
perform require_hr_admin();
select name into v_unit_name from org_units where id = v_org_unit_id;
if v_unit_name is null then
raise exception 'Die Organisationseinheit existiert nicht.';
end if;
if v_job_title is null then
raise exception 'Es muss eine Tätigkeit angegeben werden.';
end if;
-- Der Unique-Index würde das ebenfalls abfangen, aber mit einer Meldung,
-- die in der Oberfläche nichts erklärt.
if v_is_chief and exists (
select 1 from om_positions
where org_unit_id = v_org_unit_id and is_chief and valid_to is null
) then
raise exception 'Für % besteht bereits eine Leitungsplanstelle.', v_unit_name;
end if;
-- Gleiche Tätigkeit, ein Katalogeintrag: sonst stehen "Schlosser:in" und
-- "Schlosser" nebeneinander und jede Auswertung nach Tätigkeit ist wertlos.
select id into v_job_id from jobs where lower(title) = lower(v_job_title);
if v_job_id is null then
insert into jobs (code, title)
values ('J' || lpad((select count(*) + 1 from jobs)::text, 4, '0'), v_job_title)
returning id into v_job_id;
end if;
insert into om_positions (position_number, org_unit_id, job_id, is_chief, valid_from)
values (next_position_number(), v_org_unit_id, v_job_id, v_is_chief, v_valid_from)
returning id into v_position_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
values (auth.uid(), current_actor_name(), 'Planstelle angelegt',
v_job_title || ' (' || v_unit_name || ')',
'Gültig ab ' || v_valid_from || case when v_is_chief then ', Leitung' else '' end);
return v_position_id;
end;
$$;
create or replace function delete_position(payload jsonb)
returns void language plpgsql
set search_path = public, pg_temp
as $$
declare
v_position_id uuid := (payload->>'position_id')::uuid;
v_label text;
v_hat_historie boolean;
begin
perform require_hr_admin();
select j.title || ' (' || u.name || ')' into v_label
from om_positions p
join jobs j on j.id = p.job_id
join org_units u on u.id = p.org_unit_id
where p.id = v_position_id;
if v_label is null then
raise exception 'Die Planstelle existiert nicht.';
end if;
if exists (select 1 from position_assignments where position_id = v_position_id and valid_to is null) then
raise exception 'Die Planstelle ist besetzt und kann nicht entfernt werden.';
end if;
select exists (select 1 from position_assignments where position_id = v_position_id)
into v_hat_historie;
-- Eine Planstelle, auf der einmal jemand sass, wird geschlossen statt
-- gelöscht: sonst verschwindet mit ihr die Besetzungshistorie, und in der
-- Personalakte klafft eine Lücke.
if v_hat_historie then
update om_positions set valid_to = current_date where id = v_position_id;
else
delete from om_positions where id = v_position_id;
end if;
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
values (auth.uid(), current_actor_name(),
case when v_hat_historie then 'Planstelle geschlossen' else 'Planstelle gelöscht' end,
v_label, null);
end;
$$;
grant execute on function next_position_number() to anon, authenticated, service_role;
grant execute on function create_position(jsonb) to anon, authenticated, service_role;
grant execute on function delete_position(jsonb) to anon, authenticated, service_role;

View File

@@ -1,77 +0,0 @@
-- search_path für alle eigenen Funktionen festnageln.
--
-- Der Supabase-Linter meldet 34 Funktionen mit „Function Search Path Mutable".
-- Nachgezählt sind das alles SECURITY-INVOKER-Funktionen; die vier
-- SECURITY-DEFINER-Funktionen (is_hr_user, current_hr_user_id,
-- apply_due_pending_changes, fn_track_employee_assignment) setzen den Pfad
-- längst. Deshalb steht im Advisor auch 0 errors.
--
-- Warum das trotzdem behoben wird:
--
-- Der Angriff braucht SECURITY DEFINER. Wer in einem Schema, das im
-- search_path früher liegt, eine eigene Tabelle `employees` anlegt, bringt
-- eine unqualifiziert schreibende Funktion dazu, auf die untergeschobene
-- zuzugreifen — mit den Rechten der Eigentümerin der Funktion. Bei INVOKER
-- läuft alles mit den Rechten der aufrufenden Person, es gibt also nichts zu
-- gewinnen, und die RLS-Policies greifen unverändert.
--
-- Zur Lücke wird die Warnung erst, wenn eine dieser Funktionen später auf
-- SECURITY DEFINER umgestellt wird, etwa weil eine Mutation an RLS vorbei
-- schreiben muss. In dem Moment denkt niemand mehr an den search_path.
-- Einmal festnageln räumt die Falle weg und ändert kein Verhalten.
--
-- `pg_temp` steht ausdrücklich am Ende: ohne die Angabe durchsucht Postgres
-- das temporäre Schema *zuerst*, und dort darf jede Sitzung anlegen, was sie
-- will.
do $$
declare
v_func record;
v_count int := 0;
begin
for v_func in
select p.oid::regprocedure as signature
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
-- Nur Funktionen, keine Prozeduren oder Aggregate.
and p.prokind = 'f'
-- Erweiterungen gehören uns nicht: pg_trgm legt show_trgm und show_limit
-- in public ab. Daran zu drehen bricht bei der nächsten Aktualisierung
-- der Erweiterung oder wird stillschweigend zurückgesetzt.
and not exists (
select 1 from pg_depend d where d.objid = p.oid and d.deptype = 'e'
)
-- Bereits gesetzte nicht anfassen: die vier DEFINER-Funktionen stehen
-- auf `search_path = public` und sollen so bleiben.
and not exists (
select 1 from unnest(coalesce(p.proconfig, '{}')) c where c like 'search_path=%'
)
loop
execute format('alter function %s set search_path = public, pg_temp', v_func.signature);
v_count := v_count + 1;
end loop;
raise notice 'search_path festgenagelt für % Funktion(en)', v_count;
end;
$$;
-- Gegenprobe: danach darf in public keine eigene Funktion ohne search_path
-- mehr stehen. Schlägt das an, hat die Schleife oben etwas übersehen — besser
-- hier, als es im Advisor stehen zu lassen.
do $$
declare v_offen int;
begin
select count(*) into v_offen
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
and p.prokind = 'f'
and not exists (select 1 from pg_depend d where d.objid = p.oid and d.deptype = 'e')
and not exists (select 1 from unnest(coalesce(p.proconfig, '{}')) c where c like 'search_path=%');
if v_offen > 0 then
raise exception 'Es stehen noch % Funktion(en) ohne search_path in public.', v_offen;
end if;
end;
$$;

View File

@@ -1,72 +0,0 @@
-- Ausführungsrechte auf den SECURITY-DEFINER-Funktionen zurechtrücken.
--
-- Der Advisor meldet alle vier als „Public Can Execute" und „Signed-In Users
-- Can Execute". Das ist nicht bei allen vieren dasselbe Problem — nachgemessen
-- mit dem anon-Schlüssel gegen die laufende Datenbank:
--
-- anon.rpc(is_hr_user) -> false
-- anon.rpc(current_hr_user_id) -> null
-- anon.rpc(apply_due_pending_changes) -> 0 ← das ist der Befund
--
-- Nur der dritte ist einer.
-- ── Bleibt offen, und zwar mit Absicht ───────────────────────────
--
-- is_hr_user() und current_hr_user_id() werden *aus den RLS-Policies heraus*
-- aufgerufen. Ein Policy-Ausdruck wird mit den Rechten der abfragenden Rolle
-- ausgewertet; ohne EXECUTE für anon und authenticated scheitert damit jede
-- Abfrage auf jeder Tabelle mit „permission denied for function". Der Entzug
-- würde die Anwendung vollständig lahmlegen.
--
-- Preisgegeben wird dabei nichts: beide nehmen keine Argumente und beantworten
-- ausschliesslich eine Frage über die aufrufende Person selbst. Wer nicht
-- angemeldet ist, bekommt false beziehungsweise null — siehe Messung oben.
-- ── Wird entzogen ────────────────────────────────────────────────
--
-- apply_due_pending_changes() wendet vorgemerkte Versetzungen, Beförderungen
-- und Abwesenheiten an, sobald ihr Datum erreicht ist. Es ist SECURITY
-- DEFINER, umgeht also RLS, und war bis hierher ohne Anmeldung aufrufbar — der
-- anon-Schlüssel steht im ausgelieferten Browser-Bündel.
--
-- Der Schaden wäre begrenzt, weil nur ohnehin fällige Änderungen angewandt
-- werden. Aber es ist ein Schreibpfad, den Fremde auslösen können, und er
-- macht das Geheimnis der Cron-Route (app/api/cron/apply-pending-changes)
-- wirkungslos.
--
-- Diese Route ist der einzige Aufrufer und benutzt createAdminClient(), also
-- die service_role — der Entzug für anon und authenticated bricht sie nicht.
revoke execute on function apply_due_pending_changes() from anon, authenticated;
-- rls_auto_enable() stammt nicht aus diesen Migrationen und wird von der
-- Anwendung nirgends aufgerufen. Was sie tut, ist von hier aus nicht
-- feststellbar; eine Funktion, die RLS umschaltet und ohne Anmeldung
-- aufrufbar ist, wäre allerdings ernst. Der Entzug ist risikolos, weil kein
-- Aufrufer existiert — und falls doch jemand sie braucht, meldet er sich mit
-- einer klaren Fehlermeldung statt still etwas zu verstellen.
do $$
begin
if exists (
select 1 from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'rls_auto_enable'
) then
execute 'revoke execute on function public.rls_auto_enable() from anon, authenticated';
end if;
end;
$$;
-- ── Was bewusst *nicht* passiert ─────────────────────────────────
--
-- „Extension in Public" (pg_trgm) bleibt stehen. Die Erweiterung trägt die
-- Operatorklasse gin_trgm_ops, auf der zwei GIN-Indizes auf employees liegen
-- (20260714120400_performance_indexes.sql). Ein Schemawechsel müsste die
-- Indizes und jeden search_path mitziehen, der sie erreichen soll — gerade
-- jetzt, wo jede Funktion auf `public, pg_temp` festgenagelt ist. Das ist
-- Aufwand und Risiko für einen Hinweis, der keine Rechteausweitung beschreibt,
-- sondern eine Konvention.
--
-- „Leaked Password Protection Disabled" ist gegenstandslos: die
-- Passwort-Anmeldung ist abgeschaltet. Eine Anmeldung mit E-Mail und Passwort
-- gegen die API antwortet mit `email_provider_disabled` (422). Es gibt kein
-- Passwort, dessen Kompromittierung geprüft werden könnte.

View File

@@ -1,191 +0,0 @@
-- Schritt 1 auf dem Weg weg von Supabase: eigene Benutzertabelle und ein
-- eigener Sitzungskontext.
--
-- Ziel ist ein Schema, das auf jedem PostgreSQL ab 15 läuft — Azure Flexible
-- Server, RDS, Cloud SQL, eigenes Blech. Heute hängt genau eine Sache an
-- Supabase: `auth.uid()`, die Kennung der angemeldeten Person. Sie steckt in
-- 72 Zeilen SQL, aber für die Absicherung zählt nur eine Stelle —
-- is_hr_user(), das alle 58 RLS-Policies aufrufen.
--
-- Diese Migration ist bewusst **additiv und beidseitig lauffähig**: die
-- Anwendung läuft danach unverändert auf Supabase weiter, während die neue
-- Zugriffsschicht daneben entsteht. Ein Umbau, der beide Enden gleichzeitig
-- bewegt, lässt sich nicht testen.
-- ═══ 1. Sitzungskontext ══════════════════════════════════════════
-- Wer gerade angemeldet ist, kommt künftig aus einer Sitzungsvariablen, die
-- die Zugriffsschicht **transaktionslokal** setzt (siehe lib/db).
--
-- Warum plpgsql und nicht `language sql`: eine SQL-Funktion wird beim Anlegen
-- geparst, und `auth.uid()` existiert auf einem gewöhnlichen PostgreSQL
-- nicht — die Funktion liesse sich dort gar nicht erst erzeugen. plpgsql löst
-- den Aufruf erst zur Laufzeit auf, und der Ausnahmeblock fängt die fehlende
-- Funktion ab. Genau das macht diese Migration auf beiden Systemen anwendbar.
create or replace function app_current_user_id()
returns uuid
language plpgsql
stable
security definer
set search_path = public, pg_temp
as $$
declare
v_id uuid;
begin
-- Vorrang hat der eigene Kontext. `true` als zweites Argument heisst:
-- fehlt die Variable, kommt null statt eines Fehlers.
v_id := nullif(current_setting('app.user_id', true), '')::uuid;
if v_id is not null then
return v_id;
end if;
-- Übergangsweise: solange die Anmeldung noch über GoTrue läuft. Fällt in
-- der Abschlussmigration weg, zusammen mit den Fremdschlüsseln auf
-- auth.users.
begin
execute 'select auth.uid()' into v_id;
exception
when undefined_function or invalid_schema_name or undefined_table then
v_id := null;
end;
return v_id;
end;
$$;
comment on function app_current_user_id() is
'Kennung der angemeldeten Person: erst app.user_id aus der Sitzung, ersatzweise auth.uid(). Der zweite Zweig ist Übergang.';
-- Zugeteilt wird nur an Rollen, die es auch gibt. Auf einem gewöhnlichen
-- PostgreSQL existieren anon/authenticated/service_role nicht, und ein
-- `grant` auf eine unbekannte Rolle bricht die Migration ab — dieselbe Datei
-- liefe dort also nicht. Genau das soll sie aber.
do $$
declare r text;
begin
foreach r in array array['anon', 'authenticated', 'service_role'] loop
if exists (select 1 from pg_roles where rolname = r) then
execute format('grant execute on function app_current_user_id() to %I', r);
end if;
end loop;
end;
$$;
-- ═══ 2. Benutzertabelle ══════════════════════════════════════════
-- Tritt an die Stelle von auth.users. Die neun Fremdschlüssel, die heute
-- dorthin zeigen, wandern in der Abschlussmigration hierher.
create table if not exists app_users (
id uuid primary key default gen_random_uuid(),
-- Die `oid` aus dem Entra-Token. Unveränderlich, anders als die E-Mail:
-- eine Namensänderung darf nicht zu einem neuen Konto führen.
external_id text not null unique,
email text not null,
full_name text,
created_at timestamptz not null default now(),
last_seen_at timestamptz
);
comment on table app_users is
'Ersetzt auth.users. external_id ist die oid des Identitätsanbieters, nicht die E-Mail.';
create index if not exists app_users_email_idx on app_users (lower(email));
alter table app_users enable row level security;
-- Sich selbst sehen darf jede:r Angemeldete; alles andere ist HR-Sache.
-- Ohne diese Policy käme die Anmeldung nicht an die eigene Zeile.
drop policy if exists "app_users_select_own" on app_users;
create policy "app_users_select_own" on app_users
for select using (id = app_current_user_id() or is_hr_user());
do $$
begin
if exists (select 1 from pg_roles where rolname = 'anon') then
execute 'grant select on table app_users to anon';
end if;
if exists (select 1 from pg_roles where rolname = 'authenticated') then
execute 'grant select on table app_users to authenticated';
end if;
if exists (select 1 from pg_roles where rolname = 'service_role') then
execute 'grant all on table app_users to service_role';
end if;
end;
$$;
-- ═══ 3. Die eine Brücke umlegen ══════════════════════════════════
-- Ab hier fragt die Absicherung nicht mehr Supabase, sondern den eigenen
-- Kontext. Die 58 Policies bleiben Wort für Wort unverändert — sie rufen
-- weiterhin is_hr_user() auf und merken davon nichts.
create or replace function is_hr_user()
returns boolean
language sql
security definer
set search_path = public, pg_temp
stable
as $$
select exists (
select 1 from profiles p
where p.id = app_current_user_id() and p.role = 'hr' and p.is_active = true
);
$$;
create or replace function current_hr_user_id()
returns uuid
language sql
security definer
set search_path = public, pg_temp
stable
as $$
select p.id from profiles p
where p.id = app_current_user_id() and p.role = 'hr' and p.is_active = true;
$$;
create or replace function current_actor_name()
returns text
language sql
stable
set search_path = public, pg_temp
as $$
select coalesce(p.full_name, p.email, 'Unbekannt')
from profiles p where p.id = app_current_user_id();
$$;
-- ═══ 4. Die fünf Policies mit direktem auth.uid() ════════════════
-- Die übrigen 53 laufen über is_hr_user() und brauchen nichts.
drop policy if exists "profiles_select_own" on profiles;
create policy "profiles_select_own" on profiles
for select using (app_current_user_id() = id);
-- Die Namen stammen aus 20260714120000_hr_only_access.sql: „_owner", nicht
-- „_own". Mit dem falschen Namen bricht die Migration bei create policy ab.
drop policy if exists "hire_drafts_owner" on hire_drafts;
create policy "hire_drafts_owner" on hire_drafts
for all
using (created_by = app_current_user_id() and is_hr_user())
with check (created_by = app_current_user_id() and is_hr_user());
drop policy if exists "saved_reports_owner" on saved_reports;
create policy "saved_reports_owner" on saved_reports
for all
using (created_by = app_current_user_id() and is_hr_user())
with check (created_by = app_current_user_id() and is_hr_user());
-- ═══ 5. Gegenprobe ═══════════════════════════════════════════════
-- Ohne Kontext und ohne Anmeldung darf is_hr_user() nicht wahr sein. Das
-- klingt selbstverständlich und ist genau der Fehler, der eine ganze
-- Datenbank öffnet.
do $$
begin
perform set_config('app.user_id', '', true);
if is_hr_user() then
raise exception 'is_hr_user() liefert ohne Sitzungskontext true — Abbruch.';
end if;
perform set_config('app.user_id', gen_random_uuid()::text, true);
if is_hr_user() then
raise exception 'is_hr_user() liefert für eine unbekannte Kennung true — Abbruch.';
end if;
-- Aufräumen: die Einstellung gilt bis zum Ende dieser Transaktion, und
-- was danach in derselben Sitzung läuft, soll sie nicht erben.
perform set_config('app.user_id', '', true);
end;
$$;

View File

@@ -1,117 +0,0 @@
-- Schritt 2: die Anmeldung braucht einen Weg, ihre Zeile in app_users
-- anzulegen — bevor es einen Sitzungskontext gibt.
--
-- Das ist das Henne-Ei-Problem jeder eigenen Anmeldung: app.user_id kann erst
-- gesetzt werden, wenn die Kennung feststeht, und die entsteht genau hier.
-- Bisher löste ein Dienstschlüssel mit BYPASSRLS solche Fälle. Den gibt es
-- nicht mehr, und er soll auch nicht zurückkommen — eine Verbindung, die
-- alles darf, ist für einen einzigen Schreibvorgang ein zu grosser Hebel.
--
-- Stattdessen: eine SECURITY-DEFINER-Funktion mit genau einer Befugnis.
-- Sie schreibt in app_users und liest lesend in profiles — sonst nichts. Wer
-- sie aufruft, bekommt eine UUID zurück und sonst keine Auskunft.
create or replace function app_upsert_user(
p_external_id text,
p_email text,
p_full_name text
)
returns uuid
language plpgsql
security definer
set search_path = public, pg_temp
as $$
declare
v_id uuid;
begin
if p_external_id is null or btrim(p_external_id) = '' then
raise exception 'Externe Kennung fehlt.';
end if;
if p_email is null or btrim(p_email) = '' then
raise exception 'E-Mail-Adresse fehlt.';
end if;
-- Bekanntes Konto: nur nachziehen, was sich beim Anbieter geändert haben
-- kann. Die Kennung bleibt, auch wenn Name oder Adresse wechseln — daran
-- hängen Notizen, Entwürfe und Protokolleinträge.
update app_users
set email = p_email,
full_name = coalesce(p_full_name, full_name),
last_seen_at = now()
where external_id = p_external_id
returning id into v_id;
if v_id is not null then
return v_id;
end if;
-- Erstanmeldung. Gibt es zu dieser Adresse bereits ein Profil, wird dessen
-- Kennung übernommen statt einer neuen: profiles.id ist heute die
-- auth.users.id, und neun Fremdschlüssel zeigen darauf. Eine frisch
-- vergebene UUID würde die Person von ihrer eigenen Vorgeschichte trennen
-- — sie wäre angemeldet, hätte aber weder Rolle noch Freischaltung.
--
-- Der Abgleich über die Adresse ist hier vertretbar und sonst nirgends:
-- die Adresse kommt aus einem von Entra ausgestellten Token, nicht aus
-- einem Formular. Wer sie behauptet, hat sie bereits bewiesen.
select p.id into v_id
from profiles p
where lower(p.email) = lower(p_email)
limit 1;
v_id := coalesce(v_id, gen_random_uuid());
begin
insert into app_users (id, external_id, email, full_name, last_seen_at)
values (v_id, p_external_id, p_email, p_full_name, now());
exception
when unique_violation then
-- Zwei gleichzeitige Erstanmeldungen desselben Kontos. Die zweite
-- findet die Zeile, die die erste gerade angelegt hat.
select id into v_id from app_users where external_id = p_external_id;
if v_id is null then
raise;
end if;
end;
return v_id;
end;
$$;
comment on function app_upsert_user(text, text, text) is
'Legt die app_users-Zeile zur Erstanmeldung an und liefert die Kennung. Übernimmt bei bekannter E-Mail die vorhandene profiles.id.';
-- app_users trägt RLS und hat bewusst keine Schreib-Policy: die Anwendung
-- kommt an die Tabelle nur durch diese Funktion. Ein Fehler im Anwendungscode
-- kann dort also nichts anlegen, ändern oder löschen.
do $$
declare r text;
begin
foreach r in array array['anon', 'authenticated', 'service_role'] loop
if exists (select 1 from pg_roles where rolname = r) then
execute format('grant execute on function app_upsert_user(text, text, text) to %I', r);
end if;
end loop;
end;
$$;
-- ═══ Gegenprobe ══════════════════════════════════════════════════
-- Zweimal dieselbe externe Kennung muss dieselbe UUID ergeben. Wäre es nicht
-- so, bekäme jede Anmeldung ein neues Konto und niemand behielte seine
-- Rolle — ein Fehler, der sich erst Wochen später als „meine Notizen sind
-- weg" zeigt.
do $$
declare
v_first uuid;
v_second uuid;
v_probe text := 'probe-' || gen_random_uuid()::text;
begin
v_first := app_upsert_user(v_probe, v_probe || '@example.invalid', 'Probe');
v_second := app_upsert_user(v_probe, v_probe || '@example.invalid', 'Probe');
if v_first is distinct from v_second then
raise exception 'app_upsert_user() vergibt bei zweiter Anmeldung eine neue Kennung — Abbruch.';
end if;
delete from app_users where id = v_first;
end;
$$;

View File

@@ -1,42 +0,0 @@
-- Die Anwendungsrolle muss die Personalnummern-Sequenz fortschreiben dürfen.
--
-- Hintergrund: employees.personnel_number ist GENERATED ALWAYS AS IDENTITY.
-- Der Massenimport übernimmt die Nummern aus der Quelldatei — sie stehen auf
-- Lohnzetteln, in Akten und auf Ausweisen, ein Import darf sie nicht neu
-- vergeben — und schreibt danach den Zähler auf das neue Maximum.
--
-- Ohne diesen Schritt vergäbe die Datenbank bei der nächsten Neueinstellung
-- eine Nummer, die der Import bereits verbraucht hat. Der eindeutige Index
-- weist sie ab, und zwar erst Wochen später beim ersten Eintritt nach der
-- Übernahme — weit weg von der Ursache.
--
-- `setval()` verlangt UPDATE auf der Sequenz. `usage, select` reicht nicht;
-- genau daran ist der erste Durchstich gescheitert.
do $$
declare
v_sequenz text := pg_get_serial_sequence('public.employees', 'personnel_number');
r text;
begin
if v_sequenz is null then
raise exception 'Sequenz zu employees.personnel_number nicht gefunden.';
end if;
-- Nur an Rollen, die es gibt: dieselbe Datei soll auf einem gewöhnlichen
-- PostgreSQL ohne die Supabase-Rollen laufen.
foreach r in array array['alpenwerk_app', 'authenticated', 'service_role'] loop
if exists (select 1 from pg_roles where rolname = r) then
execute format('grant usage, select, update on sequence %s to %I', v_sequenz, r);
end if;
end loop;
end;
$$;
-- Damit künftige Sequenzen in diesem Schema dieselben Rechte bekommen und
-- der nächste Import nicht an derselben Stelle stehenbleibt.
do $$
begin
if exists (select 1 from pg_roles where rolname = 'alpenwerk_app') then
execute 'alter default privileges in schema public grant usage, select, update on sequences to alpenwerk_app';
end if;
end;
$$;

View File

@@ -1,193 +0,0 @@
-- Das Protokoll soll sagen, *was* sich geändert hat, nicht nur *welches Feld*.
--
-- Bisher stand im Audit-Log „Adresse, wirksam ab 30.07.2026". Damit lässt
-- sich nicht nachvollziehen, was vorher dort stand — und genau das ist die
-- Frage, die man einem Personalprotokoll stellt.
--
-- Die Werte sind im Moment der Änderung beide vorhanden: v_old trägt den
-- alten Datensatz, die Nutzlast den neuen. Sie wurden nur nicht behalten.
--
-- **Rückwirkend geht das nicht.** Für die bestehenden Einträge wurde
-- Vorher/Nachher nie erfasst; sie bleiben, wie sie sind.
alter table audit_log add column if not exists changes jsonb;
comment on column audit_log.changes is
'Feldweise Änderungen als [{feld, vorher, nachher}]. Null bei Einträgen von vor dieser Migration.';
-- ═══ Hilfsfunktion ═══════════════════════════════════════════════
-- Hängt eine Änderung an, wenn sich der Wert wirklich unterscheidet.
--
-- Verglichen wird über coalesce auf Leerstring: null und '' sind in diesem
-- Schema beide „nicht gesetzt", und ein Wechsel zwischen beiden ist keine
-- Änderung, die jemanden interessiert.
create or replace function app_aenderung(p_liste jsonb, p_feld text, p_vorher text, p_nachher text)
returns jsonb
language sql
immutable
set search_path = public, pg_temp
as $$
select case
when coalesce(p_vorher, '') = coalesce(p_nachher, '') then p_liste
else p_liste || jsonb_build_object('feld', p_feld, 'vorher', p_vorher, 'nachher', p_nachher)
end;
$$;
-- Die Feldnamen aus einer Änderungsliste — für die Kurzfassung in `details`,
-- damit bestehende Ansichten unverändert weiterlaufen.
create or replace function app_aenderungsfelder(p_liste jsonb)
returns text
language sql
immutable
set search_path = public, pg_temp
as $$
select string_agg(x->>'feld', ', ') from jsonb_array_elements(coalesce(p_liste, '[]'::jsonb)) x;
$$;
-- ═══ Stammdaten- und Vertragsänderung ════════════════════════════
create or replace function change_employee_data(payload jsonb)
returns void
language plpgsql
set search_path = public, pg_temp
as $function$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
v_old employees%rowtype;
v_name text;
v_person_changes jsonb := '[]'::jsonb;
v_contract_changes jsonb := '[]'::jsonb;
v_person jsonb := payload->'person';
v_contract jsonb := payload->'contract';
v_role jsonb := payload->'role';
v_immediate boolean;
v_new_work_days text[];
v_new_title_prefix text[];
v_new_title_suffix text[];
begin
perform require_hr_admin();
select * into v_old from employees where id = v_employee_id;
v_name := v_old.first_name || ' ' || v_old.last_name;
v_immediate := v_effective_date <= current_date;
-- Der `?`-Test bleibt: ein fehlender Schlüssel heisst „nicht übermittelt",
-- nicht „geleert". Ohne ihn würde jedes nicht gesendete Feld als Änderung
-- auf null gemeldet.
if v_person ? 'first_name' then v_person_changes := app_aenderung(v_person_changes, 'Vorname', v_old.first_name, v_person->>'first_name'); end if;
if v_person ? 'last_name' then v_person_changes := app_aenderung(v_person_changes, 'Nachname', v_old.last_name, v_person->>'last_name'); end if;
if v_person ? 'gender' then v_person_changes := app_aenderung(v_person_changes, 'Geschlecht', v_old.gender::text, v_person->>'gender'); end if;
-- Datumswerte über ::date::text vergleichen, damit „2026-8-3" und
-- „2026-08-03" nicht als Änderung gelten.
if v_person ? 'birth_date' then v_person_changes := app_aenderung(v_person_changes, 'Geburtsdatum', v_old.birth_date::text, (nullif(v_person->>'birth_date','')::date)::text); end if;
if v_person ? 'sv_nummer' then v_person_changes := app_aenderung(v_person_changes, 'SV-Nummer', v_old.sv_nummer, v_person->>'sv_nummer'); end if;
if v_person ? 'nationality' then v_person_changes := app_aenderung(v_person_changes, 'Staatsbürgerschaft', v_old.nationality, v_person->>'nationality'); end if;
if v_person ? 'address' then v_person_changes := app_aenderung(v_person_changes, 'Adresse', v_old.address, v_person->>'address'); end if;
if v_person ? 'postal_code' then v_person_changes := app_aenderung(v_person_changes, 'Postleitzahl', v_old.postal_code, v_person->>'postal_code'); end if;
if v_person ? 'city' then v_person_changes := app_aenderung(v_person_changes, 'Ort', v_old.city, v_person->>'city'); end if;
if v_person ? 'address_country' then v_person_changes := app_aenderung(v_person_changes, 'Land', v_old.address_country, v_person->>'address_country'); end if;
if v_person ? 'email' then v_person_changes := app_aenderung(v_person_changes, 'E-Mail', v_old.email, v_person->>'email'); end if;
if v_person ? 'phone' then v_person_changes := app_aenderung(v_person_changes, 'Telefon', v_old.phone, v_person->>'phone'); end if;
if v_person ? 'title_prefix' then
v_new_title_prefix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_prefix') elem), '{}');
v_person_changes := app_aenderung(v_person_changes, 'Titel (vorangestellt)',
array_to_string(v_old.title_prefix, ', '), array_to_string(v_new_title_prefix, ', '));
end if;
if v_person ? 'title_suffix' then
v_new_title_suffix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_suffix') elem), '{}');
v_person_changes := app_aenderung(v_person_changes, 'Titel (nachgestellt)',
array_to_string(v_old.title_suffix, ', '), array_to_string(v_new_title_suffix, ', '));
end if;
if v_contract ? 'employment_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Beschäftigungsausmaß', v_old.employment_type::text, v_contract->>'employment_type'); end if;
-- Über ::numeric::text, damit „38.50" und „38.5" gleich zählen.
if v_contract ? 'weekly_hours' then v_contract_changes := app_aenderung(v_contract_changes, 'Wochenstunden', v_old.weekly_hours::text, (nullif(v_contract->>'weekly_hours','')::numeric)::text); end if;
if v_contract ? 'contract_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Vertragsart', v_old.contract_type::text, v_contract->>'contract_type'); end if;
if v_contract ? 'contract_end_date' then v_contract_changes := app_aenderung(v_contract_changes, 'Befristet bis', v_old.contract_end_date::text, (nullif(v_contract->>'contract_end_date','')::date)::text); end if;
if v_role ? 'worker_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Angestellte:r/Arbeiter:in', v_old.worker_type::text, v_role->>'worker_type'); end if;
if v_role ? 'collective_agreement' then v_contract_changes := app_aenderung(v_contract_changes, 'Kollektivvertrag', v_old.collective_agreement::text, v_role->>'collective_agreement'); end if;
if v_role ? 'work_days' then
v_new_work_days := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_role->'work_days') elem), '{}');
v_contract_changes := app_aenderung(v_contract_changes, 'Arbeitstage',
array_to_string(v_old.work_days, ', '), array_to_string(v_new_work_days, ', '));
end if;
if v_role ? 'is_betriebsrat' then v_contract_changes := app_aenderung(v_contract_changes, 'Betriebsrat', v_old.is_betriebsrat::text, v_role->>'is_betriebsrat'); end if;
if v_role ? 'has_dienstwagen' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen', v_old.has_dienstwagen::text, v_role->>'has_dienstwagen'); end if;
if v_role ? 'is_laterale_fuehrung' then v_contract_changes := app_aenderung(v_contract_changes, 'Laterale Führung', v_old.is_laterale_fuehrung::text, v_role->>'is_laterale_fuehrung'); end if;
if v_role ? 'is_c_level' then v_contract_changes := app_aenderung(v_contract_changes, 'C-Level', v_old.is_c_level::text, v_role->>'is_c_level'); end if;
if v_immediate then
update employees set
first_name = coalesce(v_person->>'first_name', first_name),
last_name = coalesce(v_person->>'last_name', last_name),
gender = coalesce((v_person->>'gender')::gender_type, gender),
birth_date = coalesce((v_person->>'birth_date')::date, birth_date),
sv_nummer = coalesce(v_person->>'sv_nummer', sv_nummer),
nationality = coalesce(v_person->>'nationality', nationality),
address = coalesce(v_person->>'address', address),
postal_code = coalesce(v_person->>'postal_code', postal_code),
city = coalesce(v_person->>'city', city),
address_country = coalesce(v_person->>'address_country', address_country),
email = coalesce(v_person->>'email', email),
phone = coalesce(v_person->>'phone', phone),
title_prefix = case when v_person ? 'title_prefix' then v_new_title_prefix else title_prefix end,
title_suffix = case when v_person ? 'title_suffix' then v_new_title_suffix else title_suffix end,
employment_type = coalesce((v_contract->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_contract->>'weekly_hours')::numeric, weekly_hours),
contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),
contract_end_date = case when v_contract ? 'contract_end_date' then nullif(v_contract->>'contract_end_date','')::date else contract_end_date end,
worker_type = coalesce((v_role->>'worker_type')::worker_type, worker_type),
collective_agreement = coalesce((v_role->>'collective_agreement')::collective_agreement, collective_agreement),
work_days = case when v_role ? 'work_days' then v_new_work_days else work_days end,
is_betriebsrat = coalesce((v_role->>'is_betriebsrat')::boolean, is_betriebsrat),
has_dienstwagen = coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen),
is_laterale_fuehrung = coalesce((v_role->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
is_c_level = coalesce((v_role->>'is_c_level')::boolean, is_c_level)
where id = v_employee_id;
elsif jsonb_array_length(v_person_changes) > 0 or jsonb_array_length(v_contract_changes) > 0 then
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'contract_change', v_effective_date, payload);
end if;
if jsonb_array_length(v_person_changes) > 0 then
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Stammdatenänderung',
'Geänderte Felder: ' || app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (app_current_user_id(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id,
app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes);
end if;
if jsonb_array_length(v_contract_changes) > 0 then
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Vertragsänderung',
'Geänderte Felder: ' || app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (app_current_user_id(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id,
app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes);
end if;
end;
$function$;
-- ═══ Gegenprobe ══════════════════════════════════════════════════
-- Die Hilfsfunktion muss Gleiches übergehen und Ungleiches behalten —
-- inklusive des Falls null gegen Leerstring, der sonst als Änderung im
-- Protokoll landet und niemandem etwas sagt.
do $$
declare v jsonb;
begin
v := app_aenderung('[]'::jsonb, 'Ort', 'Wien', 'Wien');
if jsonb_array_length(v) <> 0 then raise exception 'app_aenderung() meldet eine Änderung, wo keine ist.'; end if;
v := app_aenderung('[]'::jsonb, 'Ort', null, '');
if jsonb_array_length(v) <> 0 then raise exception 'app_aenderung() wertet null gegen Leerstring als Änderung.'; end if;
v := app_aenderung('[]'::jsonb, 'Ort', 'Wien', 'Graz');
if jsonb_array_length(v) <> 1 or v->0->>'vorher' <> 'Wien' or v->0->>'nachher' <> 'Graz' then
raise exception 'app_aenderung() hält Vorher/Nachher nicht fest.';
end if;
if app_aenderungsfelder(v) <> 'Ort' then raise exception 'app_aenderungsfelder() liefert die Feldnamen nicht.'; end if;
end;
$$;

View File

@@ -1,84 +0,0 @@
-- Zwei Funktionen casten auf Typen, die es nicht mehr gibt.
--
-- hire_employee ::weekday[]
-- apply_due_pending_changes ::relationship_type
--
-- Beide Typen waren einmal Aufzählungen und wurden später durch `text` mit
-- einer CHECK-Bedingung ersetzt (chk_work_days_valid). Die Funktionen wurden
-- dabei nicht nachgezogen.
--
-- PL/pgSQL löst Typen in eingebetteten SQL-Anweisungen erst beim **Ausführen**
-- auf. Die Funktionen liessen sich deshalb anlegen und scheitern erst im
-- Betrieb — jede Neueinstellung mit „type weekday[] does not exist", und der
-- nächtliche Lauf, sobald eine fällige Angehörigen-Änderung darin vorkommt.
-- Genau deshalb hat es niemand beim Einspielen gemerkt.
--
-- Ersetzt wird gezielt der Cast an der **aktuellen** Definition, statt beide
-- Funktionen abzuschreiben: 165 Zeilen fremden Code neu zu tippen, um zwei
-- Wörter zu ändern, ist die grössere Fehlerquelle.
create or replace function app_cast_ersetzen(p_funktion text, p_alt text, p_neu text)
returns void
language plpgsql
set search_path = public, pg_temp
as $$
declare
v_def text;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = p_funktion
limit 1;
if v_def is null then
raise exception 'Funktion %() nicht gefunden.', p_funktion;
end if;
if position(p_alt in v_def) = 0 then
raise notice '%(): % kommt nicht (mehr) vor — nichts zu tun.', p_funktion, p_alt;
return;
end if;
execute replace(v_def, p_alt, p_neu);
raise notice '%(): % -> %', p_funktion, p_alt, p_neu;
end;
$$;
select app_cast_ersetzen('hire_employee', '::weekday[]', '::text[]');
select app_cast_ersetzen('apply_due_pending_changes', '::relationship_type', '::text');
-- Das Werkzeug wird nicht aufbewahrt: eine Funktion, die beliebigen Text in
-- eine Funktionsdefinition schreibt und ausführt, soll nicht dauerhaft im
-- Schema stehen.
drop function app_cast_ersetzen(text, text, text);
-- ═══ Gegenprobe ══════════════════════════════════════════════════
-- Keine Funktion im Schema darf mehr auf einen Typ casten, den es nicht
-- gibt. Das prüft nicht nur die zwei bekannten Stellen, sondern schliesst
-- aus, dass beim Ersetzen eine dritte übersehen wurde.
do $$
declare
r record;
v_treffer text;
begin
for r in
select p.proname, pg_get_functiondef(p.oid) as def
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.prokind = 'f'
loop
for v_treffer in
select m[1] from regexp_matches(r.def, '::\s*([a-z_][a-z0-9_]*)\s*(?:\[\])?', 'gi') m
loop
if not exists (select 1 from pg_type where typname = lower(v_treffer))
and lower(v_treffer) not in (
'text', 'int', 'integer', 'boolean', 'bool', 'date', 'uuid', 'jsonb', 'json',
'numeric', 'timestamptz', 'varchar', 'bigint', 'smallint', 'real', 'interval', 'time'
) then
raise exception 'Funktion %() castet auf unbekannten Typ „%".', r.proname, v_treffer;
end if;
end loop;
end loop;
end;
$$;

View File

@@ -1,113 +0,0 @@
-- auth.uid() aus den Geschäftsfunktionen entfernen.
--
-- Die Anwendung verbindet sich als eigene Rolle ohne BYPASSRLS. Diese Rolle
-- hat kein Recht auf das Schema `auth` — und jede Funktion, die dort etwas
-- aufruft, scheitert mit „permission denied for schema auth".
--
-- Das trifft **jede schreibende Aktion**: Einstellen, Versetzen, Befördern,
-- Austritt, Notizen, Planstellen. Aufgefallen ist es beim Nachstellen einer
-- Neueinstellung; zuvor lief alles über eine Rolle, die das Schema sehen
-- durfte.
--
-- app_current_user_id() liefert dasselbe und funktioniert auf jedem
-- PostgreSQL — es liest den transaktionslokalen Sitzungskontext, den lib/db
-- setzt.
create or replace function app_uid_ersetzen(p_funktion text)
returns void
language plpgsql
set search_path = public, pg_temp
as $$
declare
v_def text;
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = p_funktion
limit 1;
if v_def is null then
raise exception 'Funktion %() nicht gefunden.', p_funktion;
end if;
if position('auth.uid()' in v_def) = 0 then
raise notice '%(): bereits umgestellt.', p_funktion;
return;
end if;
execute replace(v_def, 'auth.uid()', 'app_current_user_id()');
raise notice '%(): umgestellt.', p_funktion;
end;
$$;
select app_uid_ersetzen('add_employee_dependent');
select app_uid_ersetzen('add_employee_note');
select app_uid_ersetzen('adjust_karenz_return');
select app_uid_ersetzen('complete_employee_note');
select app_uid_ersetzen('create_position');
select app_uid_ersetzen('delete_employee_dependent');
select app_uid_ersetzen('delete_position');
select app_uid_ersetzen('hire_employee');
select app_uid_ersetzen('promote_employee');
select app_uid_ersetzen('record_karenz_return');
select app_uid_ersetzen('rehire_employee');
select app_uid_ersetzen('start_karenz');
select app_uid_ersetzen('terminate_employee');
select app_uid_ersetzen('transfer_employee');
drop function app_uid_ersetzen(text);
-- ═══ Den Rückfall selbst absichern ═══════════════════════════════
-- app_current_user_id() behält seinen Übergangszweig auf auth.uid(), fängt
-- aber bisher nur „Funktion fehlt". Für eine Rolle ohne Recht auf das Schema
-- kommt stattdessen insufficient_privilege — und die Funktion warf, statt
-- null zu liefern. Das fiel nicht auf, weil der Zweig nur ohne
-- Sitzungskontext erreicht wird; genau dann soll aber „niemand angemeldet"
-- herauskommen und kein Fehler.
create or replace function app_current_user_id()
returns uuid
language plpgsql
stable
security definer
set search_path = public, pg_temp
as $$
declare
v_id uuid;
begin
v_id := nullif(current_setting('app.user_id', true), '')::uuid;
if v_id is not null then
return v_id;
end if;
begin
execute 'select auth.uid()' into v_id;
exception
when undefined_function or invalid_schema_name or undefined_table or insufficient_privilege then
v_id := null;
end;
return v_id;
end;
$$;
-- ═══ Gegenprobe ══════════════════════════════════════════════════
do $$
declare r record;
begin
for r in
select p.proname
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.prokind = 'f'
and p.proname <> 'app_current_user_id'
and pg_get_functiondef(p.oid) like '%auth.uid()%'
loop
raise exception 'Funktion %() ruft weiterhin auth.uid() auf.', r.proname;
end loop;
-- Ohne Kontext muss die Kennung null sein und darf nicht werfen.
perform set_config('app.user_id', '', true);
if app_current_user_id() is not null then
raise exception 'app_current_user_id() liefert ohne Kontext eine Kennung.';
end if;
end;
$$;

View File

@@ -1,66 +0,0 @@
-- Klammern in hire_employee.
--
-- Der Protokolleintrag baute den Namen so:
--
-- payload->>'first_name' || ' ' || payload->>'last_name'
--
-- In PostgreSQL bindet `||` **stärker** als `->>`. Gelesen wird also
--
-- payload ->> ('first_name' || ' ' || payload) ->> 'last_name'
--
-- und das endet in „operator does not exist: text ->> unknown". Jede
-- Neueinstellung scheiterte daran.
--
-- Warum es niemandem auffiel: davor stand in derselben Anweisung ein Aufruf
-- von auth.uid(). Die Rechteprüfung auf das Schema `auth` schlug schon
-- während der Analyse fehl, und der Parser kam nie bis zu diesem Ausdruck.
-- Ein Fehler hat den anderen verdeckt — beide mussten weg, damit eine
-- Einstellung durchläuft.
do $$
declare
v_def text;
v_alt constant text := 'payload->>''first_name'' || '' '' || payload->>''last_name''';
v_neu constant text := '(payload->>''first_name'') || '' '' || (payload->>''last_name'')';
begin
select pg_get_functiondef(p.oid) into v_def
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'hire_employee'
limit 1;
if v_def is null then
raise exception 'hire_employee() nicht gefunden.';
end if;
if position(v_alt in v_def) = 0 then
raise notice 'hire_employee(): bereits geklammert.';
else
execute replace(v_def, v_alt, v_neu);
raise notice 'hire_employee(): geklammert.';
end if;
end;
$$;
-- ═══ Gegenprobe ══════════════════════════════════════════════════
-- Der Ausdruck selbst, in beiden Lesarten. Ohne Klammern wirft er; mit
-- Klammern kommt der Name heraus. Das hält die Regel fest, damit sie beim
-- nächsten Mal nicht neu entdeckt werden muss.
do $$
declare
v_payload jsonb := '{"first_name": "Anna", "last_name": "Berger"}'::jsonb;
v_name text;
begin
v_name := (v_payload->>'first_name') || ' ' || (v_payload->>'last_name');
if v_name <> 'Anna Berger' then
raise exception 'Geklammert ergibt „%" statt „Anna Berger".', v_name;
end if;
begin
execute $probe$ select ('{"a":"x"}'::jsonb)->>'a' || ' ' || ('{"a":"x"}'::jsonb)->>'a' $probe$;
raise exception 'Ungeklammert wirft nicht mehr — die Vorrangregel hat sich geändert, die Prüfung ist wertlos geworden.';
exception
when undefined_function then null; -- erwartet: text ->> unknown
end;
end;
$$;

View File

@@ -1,132 +0,0 @@
-- Eine Planstelle ändern.
--
-- Bisher liess sie sich nur anlegen und löschen. Ein Tippfehler in der
-- Tätigkeit oder ein falsches Gültigkeitsdatum bedeutete: löschen und neu —
-- mit neuer Planstellennummer. Die Nummer steht aber in Stellenausschreibungen
-- und Budgets, und die Protokollspur reisst ab.
--
-- Was hier bewusst **nicht** geht, steht als Sperre drin, nicht als
-- Anmerkung — siehe die drei Prüfungen unten.
create or replace function update_position(payload jsonb)
returns void
language plpgsql
set search_path = public, pg_temp
as $function$
declare
v_id uuid := (payload->>'position_id')::uuid;
v_alt om_positions%rowtype;
v_alt_titel text;
v_alt_einheit text;
v_org_unit_id uuid;
v_job_title text := nullif(trim(payload->>'job_title'), '');
v_is_chief boolean;
v_valid_from date;
v_valid_to date;
v_job_id uuid;
v_unit_name text;
v_besetzt boolean;
v_changes jsonb := '[]'::jsonb;
begin
perform require_hr_admin();
select * into v_alt from om_positions where id = v_id;
if v_alt.id is null then
raise exception 'Die Planstelle existiert nicht.';
end if;
select title into v_alt_titel from jobs where id = v_alt.job_id;
select name into v_alt_einheit from org_units where id = v_alt.org_unit_id;
-- Fehlende Schlüssel heissen „unverändert", nicht „leeren".
v_org_unit_id := coalesce(nullif(payload->>'org_unit_id','')::uuid, v_alt.org_unit_id);
v_job_title := coalesce(v_job_title, v_alt_titel);
v_is_chief := coalesce((payload->>'is_chief')::boolean, v_alt.is_chief);
v_valid_from := coalesce(nullif(payload->>'valid_from','')::date, v_alt.valid_from);
v_valid_to := case when payload ? 'valid_to' then nullif(payload->>'valid_to','')::date else v_alt.valid_to end;
select name into v_unit_name from org_units where id = v_org_unit_id;
if v_unit_name is null then
raise exception 'Die Organisationseinheit existiert nicht.';
end if;
if v_valid_to is not null and v_valid_to < v_valid_from then
raise exception 'Das Ende der Gültigkeit liegt vor ihrem Beginn.';
end if;
select exists (
select 1 from position_assignments
where position_id = v_id and (valid_to is null or valid_to > current_date)
) into v_besetzt;
-- (1) Die Einheit einer vergebenen Planstelle zu wechseln wäre eine
-- Versetzung — mit allem, was dazugehört: Historie, Berichtslinie,
-- Protokoll. Das gehört in transfer_employee und nicht hierher, sonst
-- wandert jemand lautlos in eine andere Abteilung.
if v_besetzt and v_org_unit_id is distinct from v_alt.org_unit_id then
raise exception 'Diese Planstelle ist vergeben. Für einen Wechsel der Einheit die Versetzung benutzen.';
end if;
-- (2) Ein Ende, während noch jemand darauf sitzt, hinterlässt eine
-- Besetzung ohne Planstelle.
if v_besetzt and v_valid_to is not null then
raise exception 'Diese Planstelle ist vergeben und kann kein Ende der Gültigkeit bekommen.';
end if;
-- (3) Je Einheit nur eine gültige Leitung. Der Unique-Index fängt das auch,
-- aber mit einer Meldung, die in der Oberfläche nichts erklärt.
if v_is_chief and exists (
select 1 from om_positions
where org_unit_id = v_org_unit_id and is_chief and valid_to is null and id <> v_id
) then
raise exception 'Für % besteht bereits eine Leitungsplanstelle.', v_unit_name;
end if;
-- Gleiche Tätigkeit, ein Katalogeintrag — dieselbe Regel wie beim Anlegen.
select id into v_job_id from jobs where lower(title) = lower(v_job_title);
if v_job_id is null then
insert into jobs (code, title)
values ('J' || lpad((select count(*) + 1 from jobs)::text, 4, '0'), v_job_title)
returning id into v_job_id;
end if;
v_changes := app_aenderung(v_changes, 'Tätigkeit', v_alt_titel, v_job_title);
v_changes := app_aenderung(v_changes, 'Organisationseinheit', v_alt_einheit, v_unit_name);
v_changes := app_aenderung(v_changes, 'Leitungsplanstelle', v_alt.is_chief::text, v_is_chief::text);
v_changes := app_aenderung(v_changes, 'Gültig ab', v_alt.valid_from::text, v_valid_from::text);
v_changes := app_aenderung(v_changes, 'Gültig bis', v_alt.valid_to::text, v_valid_to::text);
-- Nichts geändert ist ein Ergebnis, kein Erfolg.
--
-- Vorher kehrte die Funktion hier stumm zurück, und die Oberfläche meldete
-- „Planstelle geändert." Wer etwas eingetragen hatte, das unterwegs
-- verworfen wurde — etwa das Leitungshäkchen, das bei bereits vergebener
-- Leitung nicht durchkommt —, sah eine Erfolgsmeldung und eine unveränderte
-- Liste. Das ist genau die Rückmeldung, die einen suchen lässt.
if jsonb_array_length(v_changes) = 0 then
raise exception 'Es wurde nichts geändert.';
end if;
update om_positions
set org_unit_id = v_org_unit_id,
job_id = v_job_id,
is_chief = v_is_chief,
valid_from = v_valid_from,
valid_to = v_valid_to
where id = v_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, details, changes)
values (app_current_user_id(), current_actor_name(), 'Planstelle geändert',
v_job_title || ' (' || v_unit_name || ')',
app_aenderungsfelder(v_changes), v_changes);
end;
$function$;
do $$
declare r text;
begin
foreach r in array array['anon', 'authenticated', 'service_role', 'alpenwerk_app'] loop
if exists (select 1 from pg_roles where rolname = r) then
execute format('grant execute on function update_position(jsonb) to %I', r);
end if;
end loop;
end;
$$;

View File

@@ -10,9 +10,8 @@ import { createClient } from "@supabase/supabase-js";
import { randomUUID } from "node:crypto";
// Explicit .ts extension: this file is run directly by Node (type-stripping,
// ESM), where an extensionless relative import does not resolve.
import { svnrCheckDigit, svnrErrorMessage, validateSvnr } from "../lib/svnr.ts";
import { svnrCheckDigit } from "../lib/svnr.ts";
import { ABSENCE_TYPES } from "../lib/absence.ts";
import { buildOrg, type BuiltUnit, type DivisionDef } from "./build-org.ts";
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
@@ -50,15 +49,8 @@ function addDays(d: Date, days: number): Date {
r.setDate(r.getDate() + days);
return r;
}
// Bewusst *nicht* über toISOString(): alle Daten hier entstehen aus lokalen
// Bestandteilen (new Date(jahr, monat, tag), addDays), und toISOString rechnet
// nach UTC um. In Österreich verschiebt das jedes Datum um einen Tag nach
// hinten — womit das gespeicherte Geburtsdatum nicht mehr zu dem passt, das
// makeSvNummer aus denselben lokalen Bestandteilen in die SV-Nummer schreibt.
function isoDate(d: Date): string {
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${d.getFullYear()}-${m}-${day}`;
return d.toISOString().slice(0, 10);
}
function randomDateBetween(start: Date, end: Date): Date {
const t = start.getTime() + Math.random() * (end.getTime() - start.getTime());
@@ -149,8 +141,9 @@ const LOCATION_WEIGHTS: readonly (readonly [(typeof LOCATIONS)[number], number])
];
// ── Org structure ────────────────────────────────────────────
// TeamDef/DeptDef/DivisionDef kommen aus build-org.ts — dort steht auch, was
// daraus gebaut wird.
type TeamDef = { name: string; leadTitle: string; icTitles: string[]; baseSize: number };
type DeptDef = { name: string; teams: TeamDef[] };
type DivisionDef = { name: string; headTitle: string; departments: DeptDef[] };
const SCALE = 1.44; // brings the ~556-person base roster up to ~800
@@ -161,7 +154,6 @@ const DIVISIONS: DivisionDef[] = [
departments: [
{
name: "Fertigung",
leadTitle: "Abteilungsleitung Fertigung",
teams: [
{ name: "Montage", leadTitle: "Teamleitung Montage", icTitles: ["Maschinenbediener:in", "Montagemitarbeiter:in", "Anlagenführer:in"], baseSize: 45 },
{ name: "CNC-Fertigung", leadTitle: "Teamleitung CNC-Fertigung", icTitles: ["CNC-Fräser:in", "CNC-Dreher:in", "Zerspanungstechniker:in"], baseSize: 35 },
@@ -170,7 +162,6 @@ const DIVISIONS: DivisionDef[] = [
},
{
name: "Instandhaltung",
leadTitle: "Abteilungsleitung Instandhaltung",
teams: [
{ name: "Elektrotechnik", leadTitle: "Teamleitung Elektrotechnik", icTitles: ["Elektrotechniker:in", "Automatisierungstechniker:in"], baseSize: 24 },
{ name: "Mechanik", leadTitle: "Teamleitung Mechanik", icTitles: ["Industriemechaniker:in", "Schlosser:in"], baseSize: 22 },
@@ -184,7 +175,6 @@ const DIVISIONS: DivisionDef[] = [
departments: [
{
name: "Logistik",
leadTitle: "Abteilungsleitung Logistik",
teams: [
{ name: "Lager", leadTitle: "Teamleitung Lager", icTitles: ["Lagerlogistiker:in", "Staplerfahrer:in", "Kommissionierer:in"], baseSize: 30 },
{ name: "Versand", leadTitle: "Teamleitung Versand", icTitles: ["Versandmitarbeiter:in", "Speditionskaufmann/-frau"], baseSize: 20 },
@@ -193,7 +183,6 @@ const DIVISIONS: DivisionDef[] = [
},
{
name: "Einkauf",
leadTitle: "Abteilungsleitung Einkauf",
teams: [
{ name: "Strategischer Einkauf", leadTitle: "Teamleitung Strategischer Einkauf", icTitles: ["Einkäufer:in", "Category Manager:in"], baseSize: 14 },
{ name: "Operativer Einkauf", leadTitle: "Teamleitung Operativer Einkauf", icTitles: ["Operative:r Einkäufer:in", "Bestelldisponent:in"], baseSize: 14 },
@@ -207,7 +196,6 @@ const DIVISIONS: DivisionDef[] = [
departments: [
{
name: "Vertrieb",
leadTitle: "Abteilungsleitung Vertrieb",
teams: [
{ name: "Key Account Management", leadTitle: "Teamleitung Key Account Management", icTitles: ["Key Account Manager:in", "Sales Manager:in"], baseSize: 16 },
{ name: "Außendienst", leadTitle: "Teamleitung Außendienst", icTitles: ["Außendienstmitarbeiter:in", "Gebietsverkaufsleiter:in"], baseSize: 22 },
@@ -216,7 +204,6 @@ const DIVISIONS: DivisionDef[] = [
},
{
name: "Marketing",
leadTitle: "Abteilungsleitung Marketing",
teams: [
{ name: "Brand Marketing", leadTitle: "Teamleitung Brand Marketing", icTitles: ["Brand Manager:in", "Produktmanager:in"], baseSize: 12 },
{ name: "Digital Marketing", leadTitle: "Teamleitung Digital Marketing", icTitles: ["Digital Marketing Manager:in", "Social-Media-Manager:in"], baseSize: 12 },
@@ -230,7 +217,6 @@ const DIVISIONS: DivisionDef[] = [
departments: [
{
name: "Produktentwicklung",
leadTitle: "Abteilungsleitung Produktentwicklung",
teams: [
{ name: "Rezeptur & Sensorik", leadTitle: "Teamleitung Rezeptur & Sensorik", icTitles: ["Lebensmitteltechniker:in", "Sensoriker:in"], baseSize: 16 },
{ name: "Verpackungsentwicklung", leadTitle: "Teamleitung Verpackungsentwicklung", icTitles: ["Verpackungstechniker:in", "Packmittelentwickler:in"], baseSize: 12 },
@@ -238,7 +224,6 @@ const DIVISIONS: DivisionDef[] = [
},
{
name: "Verfahrenstechnik",
leadTitle: "Abteilungsleitung Verfahrenstechnik",
teams: [
{ name: "Prozessoptimierung", leadTitle: "Teamleitung Prozessoptimierung", icTitles: ["Verfahrenstechniker:in", "Prozessingenieur:in"], baseSize: 14 },
{ name: "Anlagentechnik", leadTitle: "Teamleitung Anlagentechnik", icTitles: ["Anlagentechniker:in", "Projektingenieur:in"], baseSize: 12 },
@@ -252,7 +237,6 @@ const DIVISIONS: DivisionDef[] = [
departments: [
{
name: "Qualitätssicherung",
leadTitle: "Abteilungsleitung Qualitätssicherung",
teams: [
{ name: "Wareneingangsprüfung", leadTitle: "Teamleitung Wareneingangsprüfung", icTitles: ["Qualitätsprüfer:in", "Wareneingangskontrolleur:in"], baseSize: 14 },
{ name: "Prozessaudit", leadTitle: "Teamleitung Prozessaudit", icTitles: ["Qualitätsauditor:in", "QM-Beauftragte:r"], baseSize: 10 },
@@ -260,7 +244,6 @@ const DIVISIONS: DivisionDef[] = [
},
{
name: "Lebensmittelsicherheit",
leadTitle: "Abteilungsleitung Lebensmittelsicherheit",
teams: [
{ name: "Hygienemanagement", leadTitle: "Teamleitung Hygienemanagement", icTitles: ["Hygienebeauftragte:r", "Lebensmittelsicherheitsbeauftragte:r"], baseSize: 12 },
{ name: "Zertifizierung", leadTitle: "Teamleitung Zertifizierung", icTitles: ["Zertifizierungsmanager:in", "QM-Sachbearbeiter:in"], baseSize: 10 },
@@ -274,7 +257,6 @@ const DIVISIONS: DivisionDef[] = [
departments: [
{
name: "Business Applications",
leadTitle: "Abteilungsleitung Business Applications",
teams: [
{ name: "SAP-Team", leadTitle: "Teamleitung SAP-Team", icTitles: ["SAP-Consultant", "SAP-Entwickler:in"], baseSize: 12 },
{ name: "Power Platform & Automatisierung", leadTitle: "Teamleitung Power Platform & Automatisierung", icTitles: ["Power Platform Developer:in", "Prozessautomatisierer:in"], baseSize: 10 },
@@ -282,7 +264,6 @@ const DIVISIONS: DivisionDef[] = [
},
{
name: "Infrastruktur",
leadTitle: "Abteilungsleitung Infrastruktur",
teams: [
{ name: "Netzwerk & Security", leadTitle: "Teamleitung Netzwerk & Security", icTitles: ["Netzwerktechniker:in", "IT-Security-Spezialist:in"], baseSize: 12 },
{ name: "IT-Support", leadTitle: "Teamleitung IT-Support", icTitles: ["IT-Support-Mitarbeiter:in", "Systemadministrator:in"], baseSize: 14 },
@@ -296,7 +277,6 @@ const DIVISIONS: DivisionDef[] = [
departments: [
{
name: "Finanzen",
leadTitle: "Abteilungsleitung Finanzen",
teams: [
{ name: "Buchhaltung", leadTitle: "Teamleitung Buchhaltung", icTitles: ["Buchhalter:in", "Bilanzbuchhalter:in"], baseSize: 16 },
{ name: "Treasury", leadTitle: "Teamleitung Treasury", icTitles: ["Treasury-Manager:in", "Finanzanalyst:in"], baseSize: 10 },
@@ -304,7 +284,6 @@ const DIVISIONS: DivisionDef[] = [
},
{
name: "Controlling",
leadTitle: "Abteilungsleitung Controlling",
teams: [
{ name: "Konzerncontrolling", leadTitle: "Teamleitung Konzerncontrolling", icTitles: ["Controller:in", "Financial Analyst:in"], baseSize: 12 },
{ name: "Werkscontrolling", leadTitle: "Teamleitung Werkscontrolling", icTitles: ["Werkscontroller:in", "Kostenrechner:in"], baseSize: 12 },
@@ -318,7 +297,6 @@ const DIVISIONS: DivisionDef[] = [
departments: [
{
name: "HR Business Partner",
leadTitle: "Abteilungsleitung HR Business Partner",
teams: [
{ name: "Recruiting", leadTitle: "Teamleitung Recruiting", icTitles: ["Recruiter:in", "Talent Acquisition Manager:in"], baseSize: 10 },
{ name: "Personalentwicklung", leadTitle: "Teamleitung Personalentwicklung", icTitles: ["Personalentwickler:in", "Trainer:in"], baseSize: 8 },
@@ -326,7 +304,6 @@ const DIVISIONS: DivisionDef[] = [
},
{
name: "Personaladministration",
leadTitle: "Abteilungsleitung Personaladministration",
teams: [
{ name: "Gehaltsabrechnung", leadTitle: "Teamleitung Gehaltsabrechnung", icTitles: ["Payroll-Spezialist:in", "Personalverrechner:in"], baseSize: 10 },
{ name: "HR-Systeme", leadTitle: "Teamleitung HR-Systeme", icTitles: ["HR-IT-Spezialist:in", "HRIS Manager:in"], baseSize: 8 },
@@ -351,11 +328,13 @@ type EmployeeRow = {
address_country: string;
email: string;
phone: string;
// Die Einordnung in die Organisation steckt jetzt ausschliesslich in der
// Planstelle (position_assignments -> om_positions -> org_units). Keine
// division_id/team_id/manager_id mehr auf der Person.
team_id: string | null;
division_id: string;
job_title: string;
location_id: string;
manager_id: string | null;
org_level: number;
is_lead: boolean;
employment_type: "Vollzeit" | "Teilzeit";
weekly_hours: number;
contract_type: "unbefristet" | "befristet";
@@ -424,7 +403,7 @@ function paygradeForIc(): EmployeeRow["paygrade"] {
]);
}
function newHireBase(jobTitle: string) {
function newHireBase(jobTitle: string, orgLevel: number, isLead: boolean, teamId: string | null, divisionId: string, managerId: string | null) {
const gender: "m" | "w" = chance(0.48) ? "m" : "w";
const firstName = pick(gender === "m" ? MALE_FIRST_NAMES : FEMALE_FIRST_NAMES);
const lastName = pick(LAST_NAMES);
@@ -444,35 +423,25 @@ function newHireBase(jobTitle: string) {
address_country: addressCountryFor(nationality),
email: makeEmail(firstName, lastName),
phone: `+43 664 ${randInt(1000000, 9999999)}`,
team_id: teamId,
division_id: divisionId,
job_title: jobTitle,
location_id: location.id,
manager_id: managerId,
org_level: orgLevel,
is_lead: isLead,
};
}
const employees: EmployeeRow[] = [];
const history: HistoryRow[] = [];
const icPoolForStatusAssignment: EmployeeRow[] = [];
function finalizeEmployee(
base: ReturnType<typeof newHireBase>,
opts: { paygrade: EmployeeRow["paygrade"]; entryDate?: Date }
): EmployeeRow {
// Alter und Eintritt hängen zusammen: sonst entstehen Beschäftigte, die mit
// sechs Jahren angefangen haben. Ist der Eintritt vorgegeben (ausgetretene
// Vorgänger:innen, geplante Eintritte), richtet sich das Alter danach —
// sonst umgekehrt.
let age: number;
let entryDate: Date;
if (opts.entryDate) {
entryDate = opts.entryDate;
const tenureYears = Math.max(0, Math.floor((TODAY.getTime() - entryDate.getTime()) / (365.25 * 864e5)));
const minAge = Math.min(Math.max(22, 20 + tenureYears), 55);
age = randInt(minAge, 62);
} else {
age = randInt(22, 60);
const maxTenureYears = Math.min(15, age - 20);
entryDate = randomDateBetween(addDays(TODAY, -maxTenureYears * 365), addDays(TODAY, -30));
}
function finalizeEmployee(base: ReturnType<typeof newHireBase>, opts: { paygrade: EmployeeRow["paygrade"] }): EmployeeRow {
const age = randInt(22, 60);
const birthDate = birthDateForAge(age);
const maxTenureYears = Math.min(15, age - 20);
const entryDate = randomDateBetween(addDays(TODAY, -maxTenureYears * 365), addDays(TODAY, -30));
const employmentType: "Vollzeit" | "Teilzeit" = chance(0.8) ? "Vollzeit" : "Teilzeit";
const weeklyHours = employmentType === "Vollzeit" ? 38.5 : pick([15, 18, 20, 25, 28, 30, 32, 35]);
@@ -518,45 +487,80 @@ function finalizeEmployee(
return row;
}
// ── Organisation im OM-Modell ────────────────────────────────
// Der Baum kommt aus buildOrg(): reine Funktion, eigene Tests
// (tests/unit/build-org.test.ts). Der Seed entscheidet hier nur noch, *wer*
// welche Planstelle besetzt — die Struktur selbst ist nicht mehr seine Sache.
const COMPANY_NAME = "Alpenwerk Industrie GmbH";
type TeamRef = { id: string; org_number: string; name: string; department_id: string };
type DeptRef = { id: string; org_number: string; name: string; division_id: string };
type DivisionRef = { id: string; org_number: string; name: string };
const scaledDivisions: DivisionDef[] = DIVISIONS.map((div) => ({
...div,
departments: div.departments.map((dept) => ({
...dept,
// -1, weil die Teamleitung im Altmodell Teil der Teamgrösse war und
// buildOrg sie zusätzlich zu icTitles anlegt.
teams: dept.teams.map((t) => ({ ...t, baseSize: Math.max(1, Math.round(t.baseSize * SCALE) - 1) })),
})),
}));
const divisionRows: DivisionRef[] = [];
const departmentRows: DeptRef[] = [];
const teamRows: TeamRef[] = [];
const org = buildOrg(COMPANY_NAME, scaledDivisions, randomUUID);
const unitById = new Map(org.units.map((u) => [u.id, u]));
const jobTitleById = new Map(org.jobs.map((j) => [j.id, j.title]));
// Geschäftsführung: small division, no departments/teams — CEO and their
// assistant sit directly under it (§5).
const gfDivisionId = randomUUID();
divisionRows.push({ id: gfDivisionId, org_number: "20900000", name: "Geschäftsführung" });
type AssignmentRow = {
position_id: string;
employee_id: string;
valid_from: string;
valid_to: string | null;
};
const assignments: AssignmentRow[] = [];
const ceo = finalizeEmployee(
newHireBase("Geschäftsführer:in", 0, true, null, gfDivisionId, null),
{ paygrade: "F" }
);
const gfAssistant = finalizeEmployee(
newHireBase("Assistenz der Geschäftsführung", 3, false, null, gfDivisionId, ceo.id),
{ paygrade: "C" }
);
employees.push(ceo, gfAssistant);
// Wer welche Planstelle besetzt, wird bewusst nicht überall besetzt: Vakanz
// ist im OM-Modell keine eigene Tabelle mehr, sondern eine Planstelle ohne
// laufende Besetzung. Ein paar davon braucht es, damit "offene Stellen" und
// die Vertretungsregel bei fehlender Leitung überhaupt Daten haben.
const VAKANT_IC = 8; // offene Stellen ohne Nachfolge
const VAKANT_GEPLANT = 3; // offene Stellen mit Eintritt in der Zukunft
const VAKANT_LEITUNG = 3; // unbesetzte Leitungen -> Berichtslinie rollt hoch
const AUSGETRETEN = 40; // Vorgänger:innen auf heute besetzten Planstellen
const LANGZEITABWESEND = 12;
const GEPLANTER_AUSTRITT = 3;
let divisionCounter = 0;
let deptCounter = 0;
let teamCounter = 0;
for (const div of DIVISIONS) {
divisionCounter += 1;
const divisionId = randomUUID();
const divisionOrgNumber = `20${String(divisionCounter * 100000).padStart(6, "0")}`;
divisionRows.push({ id: divisionId, org_number: divisionOrgNumber, name: div.name });
const divisionHead = finalizeEmployee(
newHireBase(div.headTitle, 1, true, null, divisionId, ceo.id),
{ paygrade: "F" }
);
employees.push(divisionHead);
for (const dept of div.departments) {
deptCounter += 1;
const departmentId = randomUUID();
const deptOrgNumber = `21${String(deptCounter * 10000).padStart(6, "0")}`;
departmentRows.push({ id: departmentId, org_number: deptOrgNumber, name: dept.name, division_id: divisionId });
for (const team of dept.teams) {
teamCounter += 1;
const teamId = randomUUID();
const teamOrgNumber = `22${String(teamCounter * 1000).padStart(6, "0")}`;
teamRows.push({ id: teamId, org_number: teamOrgNumber, name: team.name, department_id: departmentId });
const size = Math.max(2, Math.round(team.baseSize * SCALE));
const teamLead = finalizeEmployee(
newHireBase(team.leadTitle, 2, true, teamId, divisionId, divisionHead.id),
{ paygrade: "E" }
);
employees.push(teamLead);
for (let i = 0; i < size - 1; i++) {
const jobTitle = pick(team.icTitles);
const paygrade = paygradeForIc();
const ic = finalizeEmployee(
newHireBase(jobTitle, 3, false, teamId, divisionId, teamLead.id),
{ paygrade }
);
employees.push(ic);
icPoolForStatusAssignment.push(ic);
}
}
}
}
// ── Apply the target status distribution (§5) across the IC pool ────────
function shuffle<T>(arr: T[]): T[] {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
@@ -565,74 +569,24 @@ function shuffle<T>(arr: T[]): T[] {
}
return a;
}
function paygradeForPosition(p: (typeof org.positions)[number], title: string): EmployeeRow["paygrade"] {
if (!p.is_chief) return title.startsWith("Assistenz") ? "C" : paygradeForIc();
const type = unitById.get(p.org_unit_id)!.unit_type;
return type === "Gesellschaft" || type === "Bereich" ? "F" : "E";
}
/** Besetzt eine Planstelle laufend und legt die Person an. */
function occupy(p: (typeof org.positions)[number]): EmployeeRow {
const title = jobTitleById.get(p.job_id)!;
const e = finalizeEmployee(newHireBase(title), { paygrade: paygradeForPosition(p, title) });
employees.push(e);
assignments.push({ position_id: p.id, employee_id: e.id, valid_from: e.entry_date, valid_to: null });
return e;
}
const chiefPositions = org.positions.filter((p) => p.is_chief);
const icPositions = org.positions.filter((p) => !p.is_chief);
// Leitungen: alle besetzen bis auf ein paar Teamleitungen, damit die
// Hochrollen-Regel im Organigramm sichtbar wird.
const vakanteLeitungen = new Set(
shuffle(chiefPositions.filter((p) => unitById.get(p.org_unit_id)!.unit_type === "Team"))
.slice(0, VAKANT_LEITUNG)
.map((p) => p.id)
);
const leadEmployees: EmployeeRow[] = [];
for (const p of chiefPositions) {
if (vakanteLeitungen.has(p.id)) continue;
leadEmployees.push(occupy(p));
}
// Mitarbeiter-Planstellen: der Rest wird besetzt, ein Teil bleibt offen.
const shuffledIc = shuffle(icPositions);
const offeneStellen = shuffledIc.slice(0, VAKANT_IC);
const geplanteStellen = shuffledIc.slice(VAKANT_IC, VAKANT_IC + VAKANT_GEPLANT);
const besetzteIc = shuffledIc.slice(VAKANT_IC + VAKANT_GEPLANT);
const icEmployees = besetzteIc.map((p) => occupy(p));
void offeneStellen; // bleiben unbesetzt — genau das macht sie zu offenen Stellen
// ── Statusverteilung (§5) ────────────────────────────────────
const statusPool = shuffle(icEmployees);
const shuffledIcs = shuffle(icPoolForStatusAssignment);
let cursor = 0;
// Eintritt in der Zukunft: die Person ist angelegt, die Planstelle heute noch
// vakant, die Besetzung beginnt erst. Genau der Fall, für den die Planstellen
// zeitabhängig sind.
for (const p of geplanteStellen) {
const futureEntry = addDays(TODAY, randInt(10, 90));
const title = jobTitleById.get(p.job_id)!;
const e = finalizeEmployee(newHireBase(title), { paygrade: paygradeForIc(), entryDate: futureEntry });
e.status = "Geplant";
employees.push(e);
assignments.push({ position_id: p.id, employee_id: e.id, valid_from: e.entry_date, valid_to: null });
// ~40 Ausgetreten
for (let i = 0; i < 40 && cursor < shuffledIcs.length; i++, cursor++) {
const e = shuffledIcs[cursor];
const entryDate = new Date(e.entry_date);
const exitDate = randomDateBetween(addDays(entryDate, 90), TODAY);
e.status = "Ausgetreten";
e.exit_date = isoDate(exitDate);
e.exit_reason = pick(EXIT_REASONS);
history.push({ employee_id: e.id, event_date: e.exit_date, event_type: "Austritt", description: `Austritt (${e.exit_reason})` });
}
// Langzeitabwesenheit, über die Arten gestreut statt alle als Karenz — sonst
// ist die Auswertung nach Art nicht zu sehen. Zwei davon treffen bewusst eine
// Teamleitung, damit die Vertretungsregel auch mit *abwesender* (nicht nur
// unbesetzter) Leitung Daten hat.
const abwesende: EmployeeRow[] = [
...shuffle(leadEmployees.filter((e) => e.job_title.startsWith("Teamleitung"))).slice(0, 2),
];
while (abwesende.length < LANGZEITABWESEND && cursor < statusPool.length) {
abwesende.push(statusPool[cursor++]);
}
for (const e of abwesende) {
// ~12 Langzeitabwesenheiten, über die Arten gestreut statt alle als Karenz —
// die Auswertung nach Art ist sonst nicht zu sehen.
for (let i = 0; i < 12 && cursor < shuffledIcs.length; i++, cursor++) {
const e = shuffledIcs[cursor];
const entryDate = new Date(e.entry_date);
const karenzStart = randomDateBetween(addDays(entryDate, 180), addDays(TODAY, -10));
const returnDate = addDays(TODAY, randInt(10, 300));
@@ -649,89 +603,65 @@ for (const e of abwesende) {
});
}
// Geplante Austritte: noch aktiv, die Besetzung endet an einem Datum in der
// Zukunft.
for (let i = 0; i < GEPLANTER_AUSTRITT && cursor < statusPool.length; i++, cursor++) {
const e = statusPool[cursor];
// ~3 Geplant (future entry). A person who hasn't started yet can't already
// have a Beförderung or other history predating that future entry date —
// found during the consolidation review that reassigning an already-
// finalized IC to Geplant only patched their Eintritt row's date, leaving
// any earlier-generated history (e.g. a Beförderung) still on the record
// with a date before the (now future) entry_date. Fixed by dropping every
// history row for that employee except Eintritt, then moving Eintritt to
// the new future date.
for (let i = 0; i < 3 && cursor < shuffledIcs.length; i++, cursor++) {
const e = shuffledIcs[cursor];
const futureEntry = addDays(TODAY, randInt(10, 90));
e.status = "Geplant";
e.entry_date = isoDate(futureEntry);
for (let hi = history.length - 1; hi >= 0; hi--) {
if (history[hi].employee_id === e.id && history[hi].event_type !== "Eintritt") history.splice(hi, 1);
}
const historyEntry = history.find((h) => h.employee_id === e.id && h.event_type === "Eintritt");
if (historyEntry) historyEntry.event_date = e.entry_date;
}
// ~3 planned future exits (still Aktiv until the exit date arrives)
for (let i = 0; i < 3 && cursor < shuffledIcs.length; i++, cursor++) {
const e = shuffledIcs[cursor];
const futureExit = addDays(TODAY, randInt(10, 90));
e.exit_date = isoDate(futureExit);
e.exit_reason = pick(EXIT_REASONS);
const a = assignments.find((x) => x.employee_id === e.id)!;
a.valid_to = e.exit_date;
}
// ── Ausgetretene als Vorgänger:innen auf besetzten Planstellen ───────
// Im Altmodell hingen Ausgetretene weiter an einem Team und liessen dessen
// Planstellen als vakant erscheinen. Im OM-Modell hat eine Planstelle eine
// Besetzungshistorie: die vorherige Besetzung ist beendet, die heutige läuft.
// Voraussetzung ist, dass der Austritt vor dem Eintritt der heutigen
// Besetzung liegt — sonst wäre die Planstelle zweimal gleichzeitig besetzt.
// ── Open positions (§4.6 / §5) ───────────────────────────────
type PositionRow = {
title: string;
team_id: string;
is_lead: boolean;
reports_to_employee_id: string | null;
status: "open";
created_at: string;
};
const positions: PositionRow[] = [];
{
const holderOf = new Map(icEmployees.map((e) => [e.id, e]));
const uebernehmbar = shuffle(
assignments.filter((a) => {
const holder = holderOf.get(a.employee_id);
// Genug Vorlauf, damit vor der heutigen Besetzung noch eine ganze
// Beschäftigung Platz hat.
return holder && new Date(holder.entry_date) > addDays(TODAY, -8 * 365) && holder.status === "Aktiv";
})
).slice(0, AUSGETRETEN);
for (const a of uebernehmbar) {
const nachfolgerEintritt = new Date(a.valid_from);
const exitDate = addDays(nachfolgerEintritt, -randInt(1, 60));
const entryDate = addDays(exitDate, -randInt(400, 3000));
const p = org.positions.find((x) => x.id === a.position_id)!;
const title = jobTitleById.get(p.job_id)!;
const e = finalizeEmployee(newHireBase(title), { paygrade: paygradeForIc(), entryDate });
e.status = "Ausgetreten";
e.exit_date = isoDate(exitDate);
e.exit_reason = pick(EXIT_REASONS);
// Ein befristeter Vertrag, der nach dem Austritt endet, wäre Unsinn; und
// finalizeEmployee kann eine Beförderung bis heute gestreut haben, die
// hier nach dem Austritt läge.
e.contract_type = "unbefristet";
e.contract_end_date = null;
for (let i = history.length - 1; i >= 0; i--) {
if (history[i].employee_id === e.id && history[i].event_date > e.exit_date) history.splice(i, 1);
}
employees.push(e);
history.push({
employee_id: e.id,
event_date: e.exit_date,
event_type: "Austritt",
description: `Austritt (${e.exit_reason})`,
});
assignments.push({
position_id: p.id,
employee_id: e.id,
valid_from: e.entry_date,
valid_to: e.exit_date,
const pool = shuffle([...teamRows]);
for (let i = 0; i < 8; i++) {
const team = pool[i % pool.length];
const leadOfTeam = employees.find((e) => e.team_id === team.id && e.is_lead);
const isLeadPosition = i < 2; // first two are leadership requisitions
const reportsTo = isLeadPosition
? (employees.find((e) => e.division_id === leadOfTeam?.division_id && e.org_level === 1)?.id ?? null)
: (leadOfTeam?.id ?? null);
positions.push({
title: isLeadPosition ? `Teamleitung ${team.name}` : "Neue Position",
team_id: team.id,
is_lead: isLeadPosition,
reports_to_employee_id: reportsTo,
status: "open",
created_at: new Date(addDays(TODAY, -randInt(1, 45))).toISOString(),
});
}
}
// ── Insert helpers ───────────────────────────────────────────
/** Eltern vor Kindern, damit parent_id beim Einfügen schon existiert. */
function sortParentsFirst(units: BuiltUnit[]): BuiltUnit[] {
const byParent = new Map<string | null, BuiltUnit[]>();
for (const u of units) {
const list = byParent.get(u.parent_id) ?? [];
list.push(u);
byParent.set(u.parent_id, list);
}
const out: BuiltUnit[] = [];
const queue = [...(byParent.get(null) ?? [])];
while (queue.length > 0) {
const u = queue.shift()!;
out.push(u);
queue.push(...(byParent.get(u.id) ?? []));
}
if (out.length !== units.length) throw new Error("Org-Baum hat abgehängte Einheiten");
return out;
}
async function insertInChunks(table: string, rows: Record<string, unknown>[], chunkSize = 200) {
for (let i = 0; i < rows.length; i += chunkSize) {
const chunk = rows.slice(i, i + chunkSize);
@@ -741,161 +671,34 @@ async function insertInChunks(table: string, rows: Record<string, unknown>[], ch
console.log(` inserted ${rows.length} row(s) into ${table}`);
}
// Alles ausser den Anmeldekonten. profiles und auth.users bleiben stehen —
// sonst sperrt sich der Seed selbst aus der Anwendung aus.
//
// Reihenfolge: Kinder vor Eltern. org_units verweist auf sich selbst; ein
// einzelnes DELETE über alle Zeilen geht trotzdem durch, weil Postgres die
// Fremdschlüsselprüfung erst nach dem Statement auswertet.
const WIPE_ORDER = [
"pending_org_changes",
"hire_drafts",
"employee_notes",
"employee_dependents",
"employee_history",
"position_assignments",
"audit_log",
"saved_reports",
"employees",
"om_positions",
"jobs",
"org_units",
"locations",
];
async function wipe() {
for (const table of WIPE_ORDER) {
// PostgREST verlangt einen Filter; "id ist nicht null" trifft alles.
const { error } = await supabase.from(table).delete().not("id", "is", null);
if (error) throw new Error(`Delete from ${table} failed: ${error.message}`);
const { count } = await supabase.from(table).select("*", { count: "exact", head: true });
if (count) throw new Error(`${table} ist nach dem Löschen nicht leer (${count} Zeilen)`);
console.log(` geleert: ${table}`);
}
}
/**
* Prüft die Zusagen, die der Seed der Datenbank gegenüber macht, bevor er sie
* löscht. Die Unique-Indizes fangen das Meiste ab — aber erst nach dem
* Löschen, und dann steht die Datenbank leer da.
*/
function pruefeInvarianten() {
const laufend = assignments.filter((a) => a.valid_to === null);
const jeStelle = new Map<string, number>();
for (const a of laufend) jeStelle.set(a.position_id, (jeStelle.get(a.position_id) ?? 0) + 1);
for (const [id, n] of jeStelle) if (n > 1) throw new Error(`Planstelle ${id} ist ${n}-fach laufend besetzt`);
const jePerson = new Map<string, number>();
for (const a of laufend) jePerson.set(a.employee_id, (jePerson.get(a.employee_id) ?? 0) + 1);
for (const [id, n] of jePerson) if (n > 1) throw new Error(`Person ${id} hat ${n} laufende Planstellen`);
// Überlappende Besetzungen derselben Planstelle: der Unique-Index deckt nur
// die laufende ab, die Historie könnte sich also unbemerkt überschneiden.
const nachStelle = new Map<string, AssignmentRow[]>();
for (const a of assignments) {
const list = nachStelle.get(a.position_id) ?? [];
list.push(a);
nachStelle.set(a.position_id, list);
}
for (const [id, list] of nachStelle) {
const sortiert = [...list].sort((x, y) => x.valid_from.localeCompare(y.valid_from));
for (let i = 1; i < sortiert.length; i++) {
const vorher = sortiert[i - 1];
if (vorher.valid_to === null || vorher.valid_to > sortiert[i].valid_from) {
throw new Error(`Planstelle ${id}: Besetzungen überschneiden sich (${vorher.valid_from}${vorher.valid_to})`);
}
}
}
for (const a of assignments) {
if (a.valid_to !== null && a.valid_to <= a.valid_from) throw new Error(`Besetzung ${a.position_id}: valid_to <= valid_from`);
}
const personen = new Set(employees.map((e) => e.id));
for (const a of assignments) if (!personen.has(a.employee_id)) throw new Error("Besetzung ohne Person");
for (const h of history) if (!personen.has(h.employee_id)) throw new Error("Historie ohne Person");
// Jede Person genau eine Planstelle — auch die ausgetretenen, sonst hinge
// sie ausserhalb der Organisation.
const mitStelle = new Set(assignments.map((a) => a.employee_id));
for (const e of employees) if (!mitStelle.has(e.id)) throw new Error(`${e.first_name} ${e.last_name} hat keine Planstelle`);
// Die SV-Nummer trägt das Geburtsdatum in sich; weichen die beiden
// voneinander ab, weist der Trigger die Zeile zurück — mitten im Einfügen,
// wenn die Datenbank bereits leergeräumt ist.
for (const e of employees) {
const fehler = validateSvnr(e.sv_nummer, e.birth_date);
if (fehler) throw new Error(`${e.email}: SV-Nummer ${e.sv_nummer} zu Geburtsdatum ${e.birth_date}${svnrErrorMessage(fehler)}`);
}
for (const e of employees) {
if (e.exit_date && e.exit_date <= e.entry_date) throw new Error(`${e.email}: Austritt vor Eintritt`);
}
for (const h of history) {
const e = employees.find((x) => x.id === h.employee_id)!;
if (e.exit_date && h.event_date > e.exit_date) throw new Error(`${e.email}: Ereignis ${h.event_type} nach dem Austritt`);
// Die Gegenrichtung — und die hat gefehlt.
//
// trg_history_not_before_entry weist jede Zeile ab, deren event_date vor
// dem Eintritt liegt. insertInChunks bricht beim ersten Fehler ab, und
// employee_history ist die *letzte* Tabelle im Seed: alles davor war
// bereits geschrieben. Ergebnis war eine Datenbank mit 852 Personen und
// null Historie — und das sah nicht nach einem Abbruch aus, sondern nach
// einer Anwendung, die eben wenig Historie zeigt.
if (h.event_date < e.entry_date) {
throw new Error(`${e.email}: Ereignis ${h.event_type} am ${h.event_date} liegt vor dem Eintritt am ${e.entry_date}`);
}
}
}
async function main() {
pruefeInvarianten();
if (process.argv.includes("--dry-run")) {
console.log("Trockenlauf — es wird nichts geschrieben.");
berichte();
return;
}
console.log("Lösche alle Daten (Anmeldekonten bleiben)...");
await wipe();
console.log("\nSeeding locations...");
console.log("Seeding locations...");
await insertInChunks("locations", LOCATIONS.map((l) => ({ ...l })));
console.log(`Seeding ${org.units.length} org_units...`);
// Eltern vor Kindern: der Fremdschlüssel auf parent_id wird pro Zeile
// geprüft, und insertInChunks zerlegt in mehrere Statements. buildOrg
// liefert die Einheiten bereits in dieser Reihenfolge, aber darauf soll
// sich der Seed nicht verlassen.
await insertInChunks("org_units", sortParentsFirst(org.units));
console.log("Seeding divisions...");
await insertInChunks("divisions", divisionRows);
console.log(`Seeding ${org.jobs.length} jobs...`);
await insertInChunks("jobs", org.jobs);
console.log("Seeding departments...");
await insertInChunks("departments", departmentRows);
console.log(`Seeding ${org.positions.length} Planstellen...`);
await insertInChunks("om_positions", org.positions);
console.log("Seeding teams...");
await insertInChunks("teams", teamRows);
console.log(`Seeding ${employees.length} employees...`);
await insertInChunks("employees", employees);
console.log(`Seeding ${assignments.length} Besetzungen...`);
await insertInChunks("position_assignments", assignments);
console.log(`Seeding ${history.length} employee_history rows...`);
await insertInChunks("employee_history", history);
// Das HR-Konto wird nicht neu angelegt: die Auth-Konten überstehen den
// Seed, und ein zweites Konto auf dieselbe Adresse liesse sich gar nicht
// anlegen. Fehlt es, wird es einmalig erzeugt — das ist die eine bewusste
// Freischaltung, jede weitere profiles-Zeile startet mit is_active = false
// und muss von HR freigeschaltet werden (§2.3).
const { data: existing } = await supabase.from("profiles").select("id, email").eq("email", ADMIN_EMAIL).maybeSingle();
if (existing) {
console.log(`\nHR-Konto ${ADMIN_EMAIL} besteht weiter — Passwort unverändert.`);
} else {
console.log("\nLege HR-Konto an...");
console.log(`Seeding ${positions.length} open positions...`);
await insertInChunks("positions", positions);
// The app is HR-only now (see docs/decisions/0001-hr-only-access.md) — no
// second "manager" role exists to seed a test account for. This is the
// one deliberate, explicit bootstrap grant of HR access (not an automatic
// one): every other new profile row defaults to is_active = false and
// must be activated by an existing HR user (§2.3).
console.log("Creating initial HR account...");
const hrPassword = randomUUID().slice(0, 12) + "!Aa1";
const { data: hrUser, error: hrErr } = await supabase.auth.admin.createUser({
email: ADMIN_EMAIL,
@@ -910,40 +713,12 @@ async function main() {
role: "hr",
is_active: true,
});
console.log("\nDone.");
console.log(`HR login: ${ADMIN_EMAIL} / ${hrPassword}`);
console.log("(Password is shown once here only — store it somewhere safe.)");
}
// Gegenprobe an der Datenbank selbst: die Berichtslinie wird nicht mehr
// gepflegt, sondern abgeleitet. Wenn der Seed den Baum falsch verdrahtet
// hat, fällt das hier auf und nicht erst im Organigramm.
const { data: linien, error: linienErr } = await supabase.rpc("om_reporting_lines", { p_as_of: isoDate(TODAY) });
if (linienErr) throw new Error(`om_reporting_lines failed: ${linienErr.message}`);
const ohneVorgesetzte = (linien ?? []).filter(
(l: { acting_manager_id: string | null }) => l.acting_manager_id === null
);
console.log(`\nBerichtslinie: ${linien?.length} Zeilen, ${ohneVorgesetzte.length} ohne Vorgesetzte (erwartet: 1, die Geschäftsführung)`);
console.log("\nFertig.");
berichte();
}
function berichte() {
const heute = isoDate(TODAY);
const laufendHeute = assignments.filter((a) => a.valid_from <= heute && (a.valid_to === null || a.valid_to > heute));
const zahl = (t: string) => org.units.filter((u) => u.unit_type === t).length;
console.log(` Organisation: ${zahl("Gesellschaft")} Gesellschaft, ${zahl("Bereich")} Bereiche, ${zahl("Abteilung")} Abteilungen, ${zahl("Team")} Teams`);
console.log(` Jobkatalog: ${org.jobs.length} Tätigkeiten`);
console.log(` Planstellen: ${org.positions.length}, davon ${org.positions.length - laufendHeute.length} heute unbesetzt`);
console.log(` Personen: ${employees.length}`);
for (const s of ["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const) {
console.log(` ${s.padEnd(12)} ${employees.filter((e) => e.status === s).length}`);
}
console.log(` Besetzungen: ${assignments.length} (${assignments.filter((a) => a.valid_to !== null).length} beendet)`);
console.log(` Historie: ${history.length} Ereignisse`);
}
main().catch((err) => {
console.error(err);
process.exit(1);

View File

@@ -0,0 +1,140 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { Database } from "@/lib/supabase/types";
import {
adminClient,
createHrUser,
deleteTestEmployee,
deleteTestUser,
hireTestEmployee,
isoDateOffset,
pickSeededTeam,
signInAs,
type TestUser,
} from "./helpers";
// Org-assignment history (supabase/migrations/20260724120000_employee_
// assignment_history.sql). The point of capturing this with a trigger rather
// than inside each RPC is that it holds for *every* write path — so these
// tests drive the real RPCs and assert on the timeline they leave behind.
describe("employee_assignments history", () => {
let hrUser: TestUser;
let hrClient: SupabaseClient<Database>;
let teamA: { id: string };
let teamB: { id: string };
const employeeIds: string[] = [];
beforeAll(async () => {
hrUser = await createHrUser({ active: true });
hrClient = await signInAs(hrUser);
teamA = await pickSeededTeam();
teamB = await pickSeededTeam(teamA.id);
});
afterAll(async () => {
for (const id of employeeIds) await deleteTestEmployee(id);
await deleteTestUser(hrUser);
});
async function freshEmployee(teamId: string): Promise<string> {
const id = await hireTestEmployee(hrClient, teamId);
employeeIds.push(id);
return id;
}
async function assignmentsFor(employeeId: string) {
const { data } = await adminClient
.from("employee_assignments")
.select("team_id, job_title, valid_from, valid_to")
.eq("employee_id", employeeId)
.order("valid_from");
return data ?? [];
}
it("opens an interval when an employee is hired", async () => {
const employeeId = await freshEmployee(teamA.id);
const rows = await assignmentsFor(employeeId);
expect(rows).toHaveLength(1);
expect(rows[0].team_id).toBe(teamA.id);
expect(rows[0].valid_to).toBeNull();
});
it("closes the old interval and opens a new one on transfer", async () => {
const employeeId = await freshEmployee(teamA.id);
const { error } = await hrClient.rpc("transfer_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamB.id },
});
expect(error).toBeNull();
const rows = await assignmentsFor(employeeId);
expect(rows).toHaveLength(2);
expect(rows[0].team_id).toBe(teamA.id);
expect(rows[0].valid_to).toBe(isoDateOffset(0));
expect(rows[1].team_id).toBe(teamB.id);
expect(rows[1].valid_to).toBeNull();
// Intervals must abut exactly, or an as-of query lands in a gap.
expect(rows[1].valid_from).toBe(rows[0].valid_to);
});
it("rewrites in place rather than leaving a zero-length interval for a same-day second move", async () => {
const employeeId = await freshEmployee(teamA.id);
await hrClient.rpc("transfer_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamB.id },
});
await hrClient.rpc("transfer_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamA.id },
});
const rows = await assignmentsFor(employeeId);
expect(rows.every((r) => r.valid_to === null || r.valid_to > r.valid_from)).toBe(true);
expect(rows.filter((r) => r.valid_to === null)).toHaveLength(1);
expect(rows.at(-1)?.team_id).toBe(teamA.id);
});
it("records a promotion's new title as its own interval", async () => {
const employeeId = await freshEmployee(teamA.id);
const { error } = await hrClient.rpc("promote_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_title: "Senior Testtitel" },
});
expect(error).toBeNull();
const rows = await assignmentsFor(employeeId);
expect(rows.at(-1)?.job_title).toBe("Senior Testtitel");
expect(rows.at(-1)?.valid_to).toBeNull();
});
it("writes no new interval when nothing about the placement changed", async () => {
const employeeId = await freshEmployee(teamA.id);
const before = await assignmentsFor(employeeId);
const { error } = await hrClient.rpc("change_employee_data", {
payload: {
employee_id: employeeId,
effective_date: isoDateOffset(0),
person: { phone: "+43 1 2345678" },
contract: {},
role: {},
},
});
expect(error).toBeNull();
expect(await assignmentsFor(employeeId)).toHaveLength(before.length);
});
it("keeps exactly one open interval per employee", async () => {
const employeeId = await freshEmployee(teamA.id);
await hrClient.rpc("transfer_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamB.id },
});
const rows = await assignmentsFor(employeeId);
expect(rows.filter((r) => r.valid_to === null)).toHaveLength(1);
});
it("is not readable without an active HR session", async () => {
const outsider = await createHrUser({ active: false });
const outsiderClient = await signInAs(outsider);
const { data } = await outsiderClient.from("employee_assignments").select("id").limit(1);
expect(data ?? []).toHaveLength(0);
await deleteTestUser(outsider);
});
});

View File

@@ -36,9 +36,7 @@ describe("HR-only access (is_hr_user gate)", () => {
createdUsers.push(user);
const client = await signInAs(user);
const { error } = await client
.from("org_units")
.insert({ org_number: "20999999", name: `Test-${user.id}`, unit_type: "Bereich" });
const { error } = await client.from("divisions").insert({ org_number: "20999999", name: `Test-${user.id}` });
expect(error).not.toBeNull();
});

Some files were not shown because too many files have changed in this diff Show More