Merge branch 'feat/sap-om-org-model'
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 6m9s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m20s

This commit is contained in:
2026-08-17 12:42:38 +02:00
200 changed files with 23108 additions and 3371 deletions

View File

@@ -1,12 +1,42 @@
# 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=
# 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=
# 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=
# ── 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=
# 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,16 +68,23 @@ jobs:
- name: Supabase starten
run: supabase start
# `-o env` emits API_URL / ANON_KEY / SERVICE_ROLE_KEY; the app expects
# them under its own names.
# `-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.
- 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 \
| grep -E '^(NEXT_PUBLIC_SUPABASE_URL|NEXT_PUBLIC_SUPABASE_ANON_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)=' \
| 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,3 +54,6 @@ 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

@@ -1,17 +1,83 @@
# Deployment mit Docker
# Deployment
Dieser Guide beschreibt, wie die App (bisher auf Vercel deployed, siehe
`vercel.json`) stattdessen als Docker-Container auf einem beliebigen Server
läuft.
Zwei Wege, beide unterstützt. Das Abbild ist umgebungsneutral — es gibt keine
Werte mehr, die beim Bauen eingebacken werden —, ein Wechsel ist also
jederzeit möglich.
| | passt, wenn |
|---|---|
| [Vercel](#vercel) | ihr nichts betreiben wollt; schnellster Weg |
| [Docker](#deployment-mit-docker) | es in eure eigene Infrastruktur soll |
**In beiden Fällen gleich:** die Umgebungsvariablen aus [Abschnitt 1](#1-env-anlegen),
die Umleitungs-URI in der Entra-Registrierung, und dass eine neue Person nach
ihrer ersten Anmeldung eine `profiles`-Zeile braucht (siehe
[docs/entra-sso.md](docs/entra-sso.md)).
## Vercel
Das Repository liegt auf `git.elycon.solutions` — einem selbst betriebenen
Git. **Vercels Git-Anbindung kann nur GitHub, GitLab und Bitbucket**, dieses
Repository lässt sich dort also nicht verknüpfen. Zwei Möglichkeiten:
### a) Von der Arbeitsstation ausrollen (ohne GitHub)
```bash
npx vercel login
npx vercel link
npx vercel --prod
```
Funktioniert mit jedem Repository. Der Preis: kein automatisches Ausrollen
bei einem Push — jede Veröffentlichung ist ein bewusster Befehl. Für zwei
Personen ist das eher Vorteil als Nachteil.
### b) Zusätzlich nach GitHub spiegeln
```bash
git remote add github git@github.com:<konto>/alpenwerk-hr.git
git push github feat/sap-om-org-model
```
Danach das GitHub-Repository in Vercel verbinden. Ab dann rollt jeder Push
aus. Zwei Fernziele bedeuten aber auch: beide müssen gepflegt werden.
### Danach
1. **Umgebungsvariablen** im Vercel-Projekt setzen (Settings → Environment
Variables), dieselben wie in [Abschnitt 1](#1-env-anlegen). `AUTH_URL` ist
nicht nötig, Vercel setzt den Host selbst.
2. **Umleitungs-URI** in der Entra-Registrierung ergänzen:
`https://<projekt>.vercel.app/api/auth/callback/microsoft-entra-id`
3. Der nächtliche Lauf ist über `vercel.json` bereits eingerichtet.
Zwei Eigenheiten der Plattform, die im Code berücksichtigt sind:
`output: "standalone"` entfällt dort automatisch (Vercel baut selbst), und
`/api/import` ist auf 60 Sekunden begrenzt — die Obergrenze des kostenlosen
Tarifs. Im Pro-Tarif liessen sich 300 setzen, falls eine Importdatei mit
vielen tausend Zeilen ansteht.
Der Verbindungspool passt zu serverlosen Aufrufen, **weil** `DATABASE_URL`
auf den Transaktions-Modus zeigt (Port 6543). Mit dem Sitzungs-Modus wären
die 15 Verbindungen des Tarifs nach wenigen gleichzeitigen Aufrufen
verbraucht.
## Deployment mit Docker
Dieser Guide beschreibt, wie die App stattdessen als Docker-Container auf
einem eigenen Linux-Server läuft.
## Was wird containerisiert – und was nicht
- **Containerisiert:** nur die Next.js-App selbst (`Dockerfile`).
- **Nicht containerisiert:** Supabase (Datenbank + Auth). Die App verbindet
sich per URL/Key zu einem bestehenden Supabase-Projekt (Cloud oder
selbst gehostet) – das bleibt unverändert. `supabase/` in diesem Repo ist
nur die lokale Dev-/Migrations-Umgebung (`supabase start`), kein Teil des
Deployments.
- **Nicht containerisiert:** die Datenbank. Die App verbindet sich über
`DATABASE_URL` zu einem beliebigen PostgreSQL ab 15 — heute ein
Supabase-Projekt, genauso möglich sind Azure Flexible Server, RDS,
Cloud SQL oder eigenes Blech. `supabase/` in diesem Repo ist die
Migrations- und Entwicklungsumgebung, kein Teil des Deployments.
- **Ebenfalls nicht containerisiert:** die Anmeldung. Sie läuft über
Microsoft Entra ID; die App hält nur das Sitzungscookie (Auth.js). Es gibt
keinen Anmeldedienst, der mit ausgerollt werden müsste.
- **Ersetzt:** der Vercel-Cron-Job aus `vercel.json` (täglich 03:00 Uhr,
ruft `/api/cron/apply-pending-changes` auf, um fällige Versetzungen/
Beförderungen/Karenz/Reorg-Änderungen zu übernehmen). Da es außerhalb von
@@ -19,6 +85,39 @@ läuft.
enthaltene `cron`-Sidecar-Container diese Aufgabe mit demselben Schema
und demselben Bearer-Secret, das die Route bereits erwartet.
## Betrieb im Firmennetz — zwei Dinge vorab
Die App läuft intern, aber sie ist **nicht** von der Aussenwelt unabhängig.
Beides vor der Installation klären, sonst scheitert es am Ende an der
Firewall:
**1. Der Server braucht ausgehenden Zugang.** Eingehend aus dem Internet
nichts, ausgehend zwingend:
| Ziel | Wofür | Ohne das |
|---|---|---|
| `login.microsoftonline.com` (443) | Auth.js tauscht den Anmeldecode **serverseitig** gegen ein Token und lädt die Konfiguration des Ausstellers | keine Anmeldung möglich |
| Die Datenbank (Supabase: `*.pooler.supabase.com`, 6543) | jede Abfrage | die App startet, zeigt aber nichts |
Dass die Anmeldung im Browser der Person stattfindet, genügt **nicht** — der
Tausch von Code gegen Token läuft vom Server aus. Liegt die VM in einem
abgeschotteten Netz, ist entweder ein Proxy nötig oder eine PostgreSQL-
Instanz im selben Netz statt Supabase.
**2. HTTPS ist Pflicht, auch intern.** Entra ID akzeptiert `http` nur für
`localhost`. Der praktikable Weg ohne öffentliche Erreichbarkeit: ein
**öffentlicher DNS-Name, der auf die private Adresse zeigt** (z. B.
`hr.elycon.solutions` → `10.x.x.x`) und ein Zertifikat über die
DNS-Challenge. Das ist zulässig, verbreitet, und liefert ein regulär
vertrauenswürdiges Zertifikat, ohne dass der Server je aus dem Internet
erreichbar ist.
Beispielkonfiguration: [`deploy/Caddyfile`](deploy/Caddyfile).
Die Alternative — selbst signiertes Zertifikat — bedeutet, es auf jedem
Arbeitsplatz als vertrauenswürdig zu hinterlegen. Bei zwei Personen machbar,
bei zwanzig nicht.
## Voraussetzungen
- Docker + Docker Compose (v2, das im Docker Desktop/Docker Engine
@@ -37,21 +136,22 @@ Werte eintragen:
| Variable | Woher |
|---|---|
| `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!) |
| `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` |
| `CRON_SECRET` | selbst generieren: `openssl rand -hex 32` |
Details zur Entra-Registrierung: [`docs/entra-sso.md`](docs/entra-sso.md).
Wichtig zum Verständnis:
- `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.
- **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.
- `.env` steht schon in `.gitignore` – nicht committen.
## 2. Bauen und lokal testen
@@ -82,30 +182,42 @@ docker compose up -d --build
```
Alternative für CI/CD (Image einmal bauen, überall pullen): Image in einer
Registry (GHCR, Docker Hub, …) bauen und pushen, auf dem Server nur
Registry bauen und pushen, auf dem Server nur
`docker compose pull && docker compose up -d` ausführen. Dafür in
`docker-compose.yml` zusätzlich `image: <registry>/<name>:<tag>` setzen und
den Build in der CI-Pipeline mit den `--build-arg`-Werten für
`NEXT_PUBLIC_*` laufen lassen.
`docker-compose.yml` zusätzlich `image: <registry>/<name>:<tag>` setzen.
Build-Argumente braucht es dabei **keine**: das Abbild enthält keine
umgebungsabhängigen Werte mehr, alles kommt zur Laufzeit aus `.env`.
Dasselbe Abbild läuft damit in Test und Produktion.
## 4. Reverse Proxy + HTTPS
Next.js selbst sollte laut den offiziellen Docs **nicht** direkt exponiert
werden – ein Reverse Proxy übernimmt TLS, Rate-Limiting und Request-
Validierung. Beispiel mit [Caddy](https://caddyserver.com/) (automatisches
HTTPS via Let's Encrypt):
Validierung.
```caddyfile
# /etc/caddy/Caddyfile
hr.example.com {
reverse_proxy localhost:3000
}
Fertige Konfiguration: [`deploy/Caddyfile`](deploy/Caddyfile) — mit
DNS-Challenge, weil der Server aus dem Internet nicht erreichbar ist (siehe
[oben](#betrieb-im-firmennetz--zwei-dinge-vorab)).
```bash
sudo cp deploy/Caddyfile /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
`docker-compose.yml` published Port 3000 aktuell auf den Host – bei
Verwendung eines Reverse Proxys auf demselben Host kann das Publishing auf
`127.0.0.1:3000:3000` eingeschränkt werden, damit der Container-Port nicht
direkt von außen erreichbar ist.
`docker-compose.yml` veröffentlicht Port 3000 bewusst nur auf
`127.0.0.1` — die App ist also ausschliesslich über den Proxy erreichbar,
nicht daneben unverschlüsselt.
Zusätzlich in die `.env`:
```
AUTH_URL=https://hr.elycon.solutions
```
Ohne diesen Wert baut Auth.js seine Rückruf-Adresse aus dem, was der
Container sieht — und das ist hinter dem Proxy nicht der Name, den der
Browser benutzt hat.
## 5. Updates ausrollen
@@ -145,12 +257,22 @@ Single-Instance-Compose-Konfiguration ist das nicht nötig.
## Troubleshooting
- **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).
- **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.
- **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`).
- **Healthcheck rot:** `docker compose logs app` – meist fehlende/falsche
Supabase-Env-Variablen zur Laufzeit (`SUPABASE_SERVICE_ROLE_KEY`,
Server-Komponenten).
- **`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.

View File

@@ -12,13 +12,13 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# 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
# 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.
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

View File

@@ -54,14 +54,18 @@ Liste. Kurzfassung:
| Variable | Sichtbarkeit | Zweck |
|---|---|---|
| `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 |
| `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` |
| `CRON_SECRET` | Nur Server | Schützt `/api/cron/apply-pending-changes` |
`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).
**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.
## Scripts
@@ -84,11 +88,16 @@ für Docker-Deployments, siehe unten).
Ersatzkontrolle.
- **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).
- **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).
siehe [`docs/datenkatalog.md`](docs/datenkatalog.md#zugriffsschutz).
- **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/`.
- **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
@@ -113,7 +122,8 @@ für Docker-Deployments, siehe unten).
## Supabase-Hinweise
- Schema-Quelle der Wahrheit: `supabase/migrations/`. Menschlich lesbare
Zusammenfassung: [`docs/data-model.md`](docs/data-model.md).
Fassung, aus der laufenden Datenbank erzeugt:
[`docs/datenkatalog.md`](docs/datenkatalog.md).
- Migrationen einspielen: `supabase db push` (gegen das verlinkte Projekt)
bzw. `supabase start` + automatische Anwendung für lokale Entwicklung.
- `supabase/seed.ts` und `.env.test.local` sind nur für lokale

View File

@@ -1,24 +1,22 @@
"use server";
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { signIn, signOut } from "@/auth";
export async function login(formData: FormData) {
const email = String(formData.get("email") ?? "");
const password = String(formData.get("password") ?? "");
// 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.
const supabase = await createClient();
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) {
redirect("/login?error=invalid_credentials");
}
redirect("/");
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: "/" });
}
export async function logout() {
const supabase = await createClient();
await supabase.auth.signOut();
redirect("/login");
await signOut({ redirectTo: "/login" });
}

View File

@@ -1,22 +1,27 @@
"use server";
import { revalidatePath } from "next/cache";
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"];
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, DienstwagenArt, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types";
async function callRpc(fn: MutationFn, payload: Record<string, unknown>, revalidate: string[]): Promise<ActionResult> {
const supabase = await createClient();
const { error } = await supabase.rpc(fn, { payload });
if (error) return { success: false, error: error.message };
const result = await runMutation(await currentUserId(), fn, payload);
if (!result.success) return result;
for (const path of revalidate) revalidatePath(path);
return { success: true };
return result;
}
export async function hireEmployee(payload: {
/**
* Wird eingegeben, nicht vergeben.
*
* Sie muss mit Loga und Interflex übereinstimmen; eine hier selbst gezogene
* Nummer wäre dort unbekannt und die Person hätte in drei Systemen zwei
* Nummern. Die Datenbank weist eine bereits vergebene Nummer ab.
*/
personnel_number: number;
first_name: string;
last_name: string;
title_prefix?: string[];
@@ -24,6 +29,14 @@ export async function hireEmployee(payload: {
gender: "m" | "w";
birth_date: string;
sv_nummer?: string;
/**
* Die **private** Adresse, freiwillig.
*
* Sie war einmal Pflicht, weil die Spalte NOT NULL war — für eine private
* Angabe die falsche Vorgabe: wer keine hat, musste eine erfinden. Bleibt
* eindeutig, wenn angegeben.
*/
email?: string;
phone?: string;
position_id?: string;
team_id?: string;
@@ -41,16 +54,29 @@ export async function hireEmployee(payload: {
work_days?: Weekday[];
is_betriebsrat?: boolean;
has_dienstwagen?: boolean;
dienstwagen_art?: DienstwagenArt | null;
emergency_contact_name?: string;
emergency_contact_phone?: string;
emergency_contact_relation?: string;
is_laterale_fuehrung?: boolean;
is_c_level?: boolean;
has_kuendigungsschutz?: boolean;
/** Nur mit dem Kennzeichen zusammen — so verlangt es chk_kuendigungsschutz_bis. */
kuendigungsschutz_bis?: string | null;
}): Promise<ActionResult & { employeeId?: string }> {
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: data as 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>)
);
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." };
}
}
export async function terminateEmployee(payload: {
@@ -65,8 +91,8 @@ export async function terminateEmployee(payload: {
export async function transferEmployee(payload: {
employee_id: string;
effective_date: string;
new_team_id: string;
new_title?: string;
/** Die Zielplanstelle; Bereich, Abteilung und Team ergeben sich aus ihrer Einheit. */
target_position_id: string;
}): Promise<ActionResult> {
return callRpc("transfer_employee", payload, [`/employees/${payload.employee_id}`, "/employees"]);
}
@@ -103,6 +129,10 @@ export async function recordKarenzReturn(payload: {
return_date: string;
employment_mode: "unverändert" | "Vollzeit" | "Teilzeit";
weekly_hours?: number;
/** Wiedereingliederungs- oder Elternteilzeit, wenn reduziert zurückgekehrt wird. */
reduction_reason?: string;
/** Ende der Teilzeit, falls bekannt — nur mit einem Grund zusammen. */
teilzeit_bis?: string;
}): Promise<ActionResult> {
return callRpc("record_karenz_return", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]);
}
@@ -117,7 +147,19 @@ export async function changeEmployeeData(payload: {
return callRpc("change_employee_data", payload, [`/employees/${payload.employee_id}`, "/employees"]);
}
export async function rehireEmployee(payload: { employee_id: string; rehire_date: string }): Promise<ActionResult> {
/**
* `position_id` ist Pflicht — die Datenbankfunktion verlangt sie seit jeher.
*
* Die alte Stelle taugt nicht als stille Vorgabe: sie kann inzwischen besetzt
* oder ausgelaufen sein. Sie fehlte hier nur in der Signatur, weshalb jede
* Wiedereinstellung an einer Meldung scheiterte, die im Dialog nicht zu
* beheben war.
*/
export async function rehireEmployee(payload: {
employee_id: string;
rehire_date: string;
position_id: string;
}): Promise<ActionResult> {
return callRpc("rehire_employee", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]);
}
@@ -141,6 +183,41 @@ export async function deleteEmployeeDependent(payload: {
return callRpc("delete_employee_dependent", payload, [`/employees/${payload.employee_id}`]);
}
/**
* Nimmt eine irrtümlich erfasste Stammdaten- oder Vertragsänderung zurück.
*
* Was zurückgesetzt wird und was stehen bleibt, entscheidet die Datenbank —
* sie prüft dabei erneut, ob der Eintrag überhaupt gelöscht werden darf. Die
* Oberfläche zeigt den Knopf nur dort, wo es geht (siehe lib/history.ts);
* kommt trotzdem eine Ablehnung zurück, wird deren Begründung angezeigt.
*/
export async function deleteHistoryEntry(payload: {
history_id: string;
employee_id: string;
}): Promise<ActionResult> {
return callRpc("delete_history_entry", { history_id: payload.history_id }, [`/employees/${payload.employee_id}`, "/audit"]);
}
/**
* Berichtigt Wert und/oder Datum eines Historieneintrags.
*
* Geändert wird nur das „Nachher" — was vor der Änderung galt, ist nicht
* nachträglich beschliessbar. Welcher Wert danach in den Stammdaten steht,
* leitet die Datenbank je Feld aus dem jüngsten Eintrag ab, der es trägt.
*/
export async function updateHistoryEntry(payload: {
history_id: string;
employee_id: string;
event_date?: string;
werte: { feld: string; nachher: string | null }[];
}): Promise<ActionResult> {
return callRpc(
"update_history_entry",
{ history_id: payload.history_id, event_date: payload.event_date, werte: payload.werte },
[`/employees/${payload.employee_id}`, "/audit"]
);
}
export async function addEmployeeNote(payload: {
employee_id: string;
category: NoteCategory;
@@ -153,23 +230,3 @@ 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,45 +1,52 @@
"use server";
import { revalidatePath } from "next/cache";
import { createClient } from "@/lib/supabase/server";
type ActionResult = { success: boolean; error?: string };
import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
import type { ActionResult } from "@/lib/db/rpc";
export async function saveHireDraft(payload: {
id?: string;
step: number;
data: Record<string, unknown>;
}): Promise<ActionResult & { id?: string }> {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return { success: false, error: "Nicht angemeldet." };
const userId = await currentUserId();
if (!userId) 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;
});
if (payload.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: payload.id };
return { success: true, id };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
}
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> {
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 };
try {
await withUser(await currentUserId(), (tx) => tx.deleteFrom("hire_drafts").where("id", "=", id).execute());
revalidatePath("/");
return { success: true };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
}
}

View File

@@ -1,56 +1,67 @@
"use server";
import { revalidatePath } from "next/cache";
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
import { createClient } from "@/lib/supabase/server";
type ActionResult = { success: boolean; error?: string };
import { currentUserId } from "@/lib/auth/session";
import { runMutation, type ActionResult } from "@/lib/db/rpc";
const POSITION_PATHS = ["/positions", "/orgchart", "/"];
async function callRpc(
fn: "create_position" | "delete_position",
fn: "create_position" | "update_position" | "delete_position" | "set_position_cost_center",
payload: Record<string, unknown>,
revalidate: string[]
): Promise<ActionResult> {
const supabase = await createClient();
const { error } = await supabase.rpc(fn, { payload });
if (error) return { success: false, error: error.message };
const result = await runMutation(await currentUserId(), fn, payload);
if (!result.success) return result;
for (const path of revalidate) revalidatePath(path);
return { success: true };
return result;
}
export async function createPosition(payload: {
title: string;
superior_employee_id: string;
is_lead: boolean;
team_id?: string;
org_unit_id: string;
job_title: string;
is_chief: boolean;
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 ?? [];
/**
* Kontiert eine Planstelle auf eine andere Kostenstelle — zum Stichtag.
*
* Kein Feld im Änderungsdialog, sondern ein eigener Vorgang, weil es einer
* ist: die laufende Kontierung wird beendet, die neue beginnt. Die alte
* einfach zu überschreiben hiesse, die Kosten der Vergangenheit nachträglich
* anderswohin zu buchen.
*/
export async function setPositionCostCenter(payload: {
position_id: string;
cost_center_id: string;
valid_from: string;
}): Promise<ActionResult> {
return callRpc("set_position_cost_center", payload, POSITION_PATHS);
}

View File

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

View File

@@ -1,11 +1,12 @@
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 { sanitizeIlikeTerm } from "@/lib/supabase/query";
import { createClient } from "@/lib/supabase/server";
import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
const PAGE_SIZE = 25;
@@ -34,33 +35,50 @@ 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;
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);
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);
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)])
);
}
return q;
};
if (params.action) query = query.eq("action", params.action);
if (params.q) {
const q = sanitizeIlikeTerm(params.q.trim());
query = query.or(`target_label.ilike.%${q}%,details.ilike.%${q}%,actor_name.ilike.%${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 { data: entries, count } = await query;
const totalPages = Math.max(1, Math.ceil((count ?? 0) / PAGE_SIZE));
const totalPages = Math.max(1, Math.ceil(count / PAGE_SIZE));
return (
<div className="flex flex-col gap-4">
<Suspense>
<AuditFilters />
</Suspense>
<p className="text-sm text-ink-muted">{count ?? 0} Einträge</p>
<p className="text-sm text-ink-muted">{count} Einträge</p>
<div className={`overflow-x-auto ${CARD_CLASS}`}>
<table className="w-full min-w-[800px] text-sm">
@@ -74,7 +92,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">
@@ -98,11 +116,13 @@ export default async function AuditPage({ searchParams }: { searchParams: Promis
entry.target_label
)}
</td>
<td className="px-4 py-2.5 text-ink-muted">{entry.details ?? "–"}</td>
<td className="px-2 py-1.5">
<AuditDetail eintrag={entry} />
</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,74 +1,54 @@
import { notFound } from "next/navigation";
import { EmployeeDetail } from "@/components/employees/EmployeeDetail";
import { createClient } from "@/lib/supabase/server";
import type { Database } from "@/lib/supabase/types";
import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
import { todayIso } from "@/lib/format";
import { breadcrumbLabel } from "@/lib/org";
import { loadEmployeeDetail } from "@/lib/employee-detail-data";
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 supabase = await createClient();
const today = todayIso();
// 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"),
]);
// Was die Akte liest und in wie vielen Rundreisen, steht in
// lib/employee-detail-data.ts.
const data = await withUser(await currentUserId(), (tx) => loadEmployeeDetail(tx, id, today));
if (!employeeRow) notFound();
// 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;
if (!data) notFound();
const { employee, line, reports, history, dependents, notes, orgMaps, placement, kostenstelle, openPositions, byId } = data;
return (
<EmployeeDetail
employee={employee}
manager={manager ?? null}
directReports={directReports ?? []}
placement={
placement && {
positionNumber: placement.positionNumber,
jobTitle: placement.jobTitle,
isChief: placement.isChief,
current: placement.current,
}
}
breadcrumb={breadcrumbLabel(orgMaps, placement?.orgUnitId)}
kostenstelle={kostenstelle}
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] : [];
})}
history={history ?? []}
dependents={dependents ?? []}
notes={notes ?? []}
divisions={divisions ?? []}
departments={departments ?? []}
teams={teams ?? []}
locations={locations ?? []}
openPositions={openPositions ?? []}
locations={orgMaps.locationList}
openPositions={openPositions}
/>
);
}

View File

@@ -5,16 +5,25 @@ 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 { applyDerivedStatusFilter } from "@/lib/employee-status-filter";
import { fmtDate, todayIso } from "@/lib/format";
import { breadcrumbFor, loadOrgMaps } from "@/lib/org";
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
import { createClient } from "@/lib/supabase/server";
import { currentUserId } from "@/lib/auth/session";
import { sql, withUser } from "@/lib/db";
import { jsonArrayFrom, jsonObjectFrom } from "@/lib/db/json";
import { istPersonalnummer, suchMuster } from "@/lib/employee-search";
import { derivedStatusFilter } from "@/lib/employee-status-filter";
import { fmtDate, fmtName, todayIso } from "@/lib/format";
import { breadcrumbLabel, divisionOf, loadOrgMaps, subtreeOf, unitOf, type OrgEb } from "@/lib/org";
import { loadPlacements } from "@/lib/placement";
import type { EmploymentStatus } from "@/lib/supabase/types";
const PAGE_SIZE = 15;
type SearchParams = { q?: string; division?: string; status?: string; location?: string; page?: string };
type SearchParams = {
q?: string;
division?: string;
status?: string;
location?: string;
page?: string;
};
type EmployeesPageProps = {
searchParams: Promise<SearchParams>;
@@ -32,55 +41,181 @@ function pageHref(params: SearchParams, page: number): string {
export default async function EmployeesPage({ searchParams }: EmployeesPageProps) {
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 today = todayIso();
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));
// Derived from the dates, not read off employees.status — see
// lib/employee-status-filter.ts for why the two can disagree.
query = applyDerivedStatusFilter(query, statuses, todayIso());
if (params.location) query = query.eq("location_id", params.location);
// 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));
const { orgMaps, employees, count, placements, ueberPosition } = 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.
// Der Ausdrucksbauer wird durchgereicht, damit dieselbe Filterkette
// einmal für die Seite und einmal für die Zählung in *einer* Abfrage
// stehen kann.
const base = (nurNamen: boolean, eb: OrgEb = tx as never) => {
let q = eb.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();
// `\d`, nicht `d`: der fehlende Backslash liess die Ziffernerkennung
// nie greifen — „1590" wurde als Name gesucht und fand nichts,
// während das Muster auf „ddd" ansprang.
if (istPersonalnummer(term)) {
q = q.where("personnel_number", "=", Number(term));
} else {
// ── Wie hier gesucht wird ──────────────────────────────────
//
// **Wortweise, Reihenfolge egal.** Jedes Wort muss treffen, aber
// nicht in einer bestimmten Ordnung: „Winkler micha" und „micha
// Winkler" führen beide zu Michaela Winkler. Am Stück gesucht stand
// „Michael Winkler" in keinem einzelnen Feld und ergab null Treffer,
// obwohl es vierzehn Winkler gibt.
//
// **Am Wortanfang, nicht mittendrin.** Als Teilzeichenkette traf ein
// „H" auf T-h-omas und Kat-h-arina — bei „Winkler H" kamen alle
// sieben Winkler zurück. Trennzeichen zählen als Wortgrenze, damit
// „dreher" auch „CNC-Dreher:in" findet.
//
// **Namen vor Positionen.** Die Position mitzudurchsuchen ist
// nützlich („dreher"), darf aber eine Namenssuche nicht verwässern:
// bei „Winkler M" tauchten sonst Karin Winkler (Montagemitarbeiterin)
// und Katharina Winkler (Maschinenbedienerin) auf, weil ihre
// Position mit M beginnt. Deshalb wird zuerst nur über die Namen
// gesucht; nur wenn das *nichts* findet, kommt die Position dazu.
// Eine feste Mindestlänge fürs Wort wäre die einfachere Regel, aber
// jede Grenze wäre geraten — diese hier ergibt sich aus den Daten.
//
// Der Trigramm-Index auf dem zusammengesetzten Namen greift bei
// diesem Ausdruck nicht mehr. Bei knapp neunhundert Zeilen liest
// Postgres die Tabelle in wenigen Millisekunden; ein Index auf
// demselben Ausdruck holt das zurück, sobald das nicht mehr stimmt.
const heuhaufen = nurNamen
? sql<string>`translate(lower(first_name || ' ' || last_name), '-/:.,', ' ')`
: sql<string>`translate(lower(first_name || ' ' || last_name || ' ' || job_title), '-/:.,', ' ')`;
q = q.where((eb) =>
eb.and(
// Als Parameter gebunden, nicht in die Abfrage geschrieben.
suchMuster(term).map(([amAnfang, nachLeerzeichen]) =>
eb.or([eb(heuhaufen, "like", amAnfang), eb(heuhaufen, "like", nachLeerzeichen)])
)
)
);
}
}
// 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));
}
if (params.location) q = q.where("location_id", "=", params.location);
return q;
};
// Erst nachsehen, ob die Namen allein etwas hergeben. Nur wenn nicht,
// wird die Position mitgesucht — eine zusätzliche, sehr kleine Abfrage,
// und nur bei einer Textsuche.
const sucheNachNamen = Boolean(params.q) && !istPersonalnummer(params.q!.trim());
const namensTreffer = sucheNachNamen
? Number((await base(true).select(({ fn }) => fn.countAll<string>().as("anzahl")).executeTakeFirst())?.anzahl ?? 0)
: 0;
const nurNamen = sucheNachNamen && namensTreffer > 0;
// Seite und Gesamtzahl in *einer* Rundreise. Als Promise.all sah das nach
// Gleichzeitigkeit aus und war keine: eine Transaktion hängt an einer
// Verbindung, und darüber laufen Abfragen nacheinander (lib/db/json.ts).
const { rows, total } = await tx
.selectNoFrom((eb) => [
jsonArrayFrom(
base(nurNamen, eb)
.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)
).as("rows"),
jsonObjectFrom(base(nurNamen, eb).select(({ fn }) => fn.countAll<string>().as("anzahl"))).as("total"),
])
.executeTakeFirstOrThrow();
// 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,
// Für den Hinweis über der Liste: wurde nach Namen gesucht, und hat es
// gereicht?
ueberPosition: sucheNachNamen && !nurNamen,
};
});
const totalPages = Math.max(1, Math.ceil(count / PAGE_SIZE));
return (
<div className="flex flex-col gap-4">
<Suspense>
<EmployeeFilters divisions={orgMaps.divisionList} locations={orgMaps.locationList} />
<EmployeeFilters units={orgMaps.unitList} depthOf={orgMaps.depthOf} locations={orgMaps.locationList} />
</Suspense>
<p className="text-sm text-ink-muted">{count ?? 0} Mitarbeiter:innen gefunden</p>
<p className="text-sm text-ink-muted">
{count ?? 0} Mitarbeiter:innen gefunden
{/* Wenn kein Name passte, wurde nach der Position gesucht. Ohne diesen
Hinweis wirkt das Ergebnis, als hätte die Suche etwas erfunden. */}
{ueberPosition && (count ?? 0) > 0 && (
<span className="text-ink-muted"> · kein Namenstreffer, gesucht nach Position</span>
)}
</p>
<div className={`overflow-x-auto ${CARD_CLASS}`}>
<table className="w-full min-w-[800px] text-sm">
@@ -97,7 +232,9 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
</thead>
<tbody>
{employees.map((e) => {
const { division, team } = breadcrumbFor(orgMaps, e.division_id, e.team_id);
const placement = placements.get(e.id);
const division = divisionOf(orgMaps, placement?.orgUnitId);
const unit = unitOf(orgMaps, placement?.orgUnitId);
const location = e.location_id ? orgMaps.locations.get(e.location_id) : undefined;
return (
// border-subtle between rows: the full-strength border made
@@ -111,9 +248,9 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
<Avatar firstName={e.first_name} lastName={e.last_name} />
<div className="min-w-0">
<div className="truncate font-semibold text-ink">
{e.first_name} {e.last_name}
{fmtName(e.first_name, e.last_name)}
</div>
<div className="truncate text-xs text-ink-muted">{e.job_title}</div>
<div className="truncate text-xs text-ink-muted">{placement?.jobTitle ?? e.job_title}</div>
</div>
</Link>
</td>
@@ -122,7 +259,12 @@ 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>
<div className="text-xs text-ink-muted">{team?.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>
</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>

16
app/(app)/import/page.tsx Normal file
View File

@@ -0,0 +1,16 @@
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,36 +2,32 @@ import { redirect } from "next/navigation";
import type { ReactNode } from "react";
import { HireWizardProvider } from "@/components/hire/HireWizardContext";
import { AppShell } from "@/components/shell/AppShell";
import { loadOpenNotes } from "@/lib/notes";
import { loadOpenPositions } from "@/lib/positions";
import { createClient } from "@/lib/supabase/server";
import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
import { loadShellData } from "@/lib/shell-data";
export default async function AppLayout({ children }: { children: ReactNode }) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect("/login");
const userId = await currentUserId();
if (!userId) redirect("/login");
// 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");
// Alles in *einer* Transaktion, weil nur dort der Sitzungskontext gilt —
// und damit nebenbei auf einem einheitlichen Lesestand. Was dabei in wie
// vielen Rundreisen gelesen wird, steht in lib/shell-data.ts.
const data = await withUser(userId, (tx) => loadShellData(tx, userId));
const userLabel = profile.full_name || profile.email || user.email || "";
// `data` ist null, wenn die Person angemeldet, aber nicht freigeschaltet
// ist. Hier — und nicht im Proxy — fällt diese Entscheidung: der Proxy hat
// keine Datenbankverbindung. Sie wird bei jedem Aufbau frisch gestellt, eine
// entzogene Freischaltung wirkt also sofort statt erst mit dem nächsten
// Sitzungstoken. Ohne den Grund in der Adresse stünde die Person vor einer
// wortlosen Anmeldeseite und versuchte es endlos erneut.
if (!data) redirect("/login?error=no_hr_access");
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),
]);
const userLabel = data.profile.full_name || data.profile.email || "";
return (
<HireWizardProvider openPositions={openPositions} locations={locationsRes.data ?? []} drafts={draftsRes.data ?? []}>
<AppShell userLabel={userLabel} openNotes={openNotes}>
<HireWizardProvider openPositions={data.openPositions} locations={data.locations} drafts={data.drafts}>
<AppShell userLabel={userLabel} openNotes={data.openNotes}>
{children}
</AppShell>
</HireWizardProvider>

View File

@@ -1,10 +1,11 @@
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 { createClient } from "@/lib/supabase/server";
import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
type SearchParams = { asOf?: string; focus?: string };
@@ -18,32 +19,20 @@ 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 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),
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(),
]);
return { org, units };
});
return (
<Suspense>
<OrgChartClient
employees={org.employees}
divisions={divisions ?? []}
departments={departments ?? []}
teams={teams ?? []}
openPositions={openPositions}
reorgScenarios={reorgScenarios ?? []}
units={units as OrgUnitNode[]}
vacancies={org.vacancies}
asOf={asOf}
today={today}
projectedCount={org.projectedCount}

View File

@@ -0,0 +1,43 @@
import { PrintChart } from "@/components/orgchart/PrintChart";
import type { OrgUnitNode } from "@/components/orgchart/types";
import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
import { todayIso } from "@/lib/format";
import { loadOrgAsOf } from "@/lib/orgchart-data";
import { buildPrintModel } from "@/lib/orgchart-print";
import { parseIsoDateParam } from "@/lib/reports";
export const metadata = { title: "Organigramm drucken" };
type SearchParams = { asOf?: string };
// Eigene Route statt eines Druckstils auf der Organigramm-Seite.
//
// Die interaktive Ansicht ist eine Leinwand mit Zoom und Verschiebung; was
// davon auf Papier landet, hängt vom Zufallsstand der Ansicht ab. Hier wird
// stattdessen aus denselben Daten eine Seitenfolge gebaut, und der Stichtag
// wird aus der Adresse übernommen — wer im Organigramm einen Stichtag
// eingestellt hat, druckt genau den.
export default async function OrgChartPrintPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams;
const today = todayIso();
const asOf = parseIsoDateParam(params.asOf) ?? today;
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"])
.where((eb) => eb.or([eb("valid_to", "is", null), eb("valid_to", ">", asOf)]))
.where("valid_from", "<=", asOf)
.orderBy("org_number")
.execute(),
]);
return { org, units };
});
const model = buildPrintModel(units as OrgUnitNode[], org.employees, org.vacancies);
return <PrintChart model={model} asOf={asOf} today={today} />;
}

View File

@@ -1,12 +1,17 @@
import { ChevronRight } from "lucide-react";
import Link from "next/link";
import { Suspense } from "react";
import { AnstehendFilter } from "@/components/dashboard/AnstehendFilter";
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 { istEingeschraenkt, parseArten, parseZeitraum } from "@/lib/dashboard-filter";
import { loadDashboardData } from "@/lib/dashboard-data";
import { addDaysIso, fmtDate, fmtName, todayIso } from "@/lib/format";
import { divisionOf } from "@/lib/org";
import { deriveStatusAsOf } from "@/lib/reports";
import { createClient } from "@/lib/supabase/server";
import { fetchAllRows } from "@/lib/supabase/query";
import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
// 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
@@ -33,21 +38,18 @@ const DOT_STYLES: Record<string, string> = {
Gehaltsanpassung: "bg-warning-text",
};
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: [] };
const KIND_LABEL = {
hire: "Eintritt",
exit: "Austritt",
return: "Rückkehr aus Abwesenheit",
note: "Wiedervorlage",
} as const;
export default async function DashboardPage({
searchParams,
}: {
searchParams: Promise<{ tage?: string; arten?: string }>;
}) {
// 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
@@ -56,7 +58,16 @@ export default async function DashboardPage() {
const year = today.slice(0, 4);
const yearStart = `${year}-01-01`;
const yearEnd = `${year}-12-31`;
const in60Iso = addDaysIso(today, 60);
// Der Vorschauzeitraum ist einstellbar, und mit ihm, was überhaupt geladen
// wird. Deshalb steht die Auswahl in der Adresse und nicht im Browser: 90
// statt 60 Tage bringt Zeilen ins Spiel, die sonst nirgends lägen.
const params = await searchParams;
const zeitraum = parseZeitraum(params.tage);
const arten = parseArten(params.arten);
const bisIso = addDaysIso(today, zeitraum);
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.
@@ -66,69 +77,20 @@ 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.
// It also replaces four separate count queries with one.
const [
const {
drafts,
staffRows,
hiresYtdRes,
exitsYtdRes,
openPositionsRes,
divisionsRes,
upcomingHiresRes,
upcomingExitsRes,
upcomingReturnsRes,
historyRes,
] = await Promise.all([
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.
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),
]);
hiresYtd,
exitsYtd,
openPositions,
orgMaps,
placements,
upcomingHires,
upcomingExits,
upcomingReturns,
upcomingNotes,
history,
} = await withUser(userId, (tx) => loadDashboardData(tx, { userId, today, yearStart, yearEnd, bisIso, arten }));
// "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
@@ -143,45 +105,69 @@ 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) {
if (!row.division_id) continue;
headcountByDivision.set(row.division_id, (headcountByDivision.get(row.division_id) ?? 0) + 1);
const division = divisionOf(orgMaps, placements.get(row.id)?.orgUnitId);
if (!division) continue;
headcountByDivision.set(division.id, (headcountByDivision.get(division.id) ?? 0) + 1);
}
const divisionBars = (divisionsRes.data ?? [])
const divisionBars = orgMaps.unitList
.filter((u) => u.unit_type === "Bereich")
.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[] = [
...(upcomingHiresRes.data ?? []).map((e) => ({
type UpcomingItem = {
id: string;
/** Ziel des Klicks — bei einer Wiedervorlage die Akte, nicht die Notiz. */
employeeId: string;
label: string;
/** Zweite Zeile: bei einer Wiedervorlage der Notiztext statt der Art. */
hinweis?: string;
date: string;
kind: keyof typeof KIND_LABEL;
};
const upcomingAlle: UpcomingItem[] = [
...(upcomingHires).map((e) => ({
id: e.id,
label: `${e.first_name} ${e.last_name}`,
employeeId: e.id,
label: fmtName(e.first_name, e.last_name),
date: e.entry_date,
kind: "hire" as const,
})),
...(upcomingExitsRes.data ?? []).map((e) => ({
...(upcomingExits).map((e) => ({
id: e.id,
label: `${e.first_name} ${e.last_name}`,
employeeId: e.id,
label: fmtName(e.first_name, e.last_name),
date: e.exit_date!,
kind: "exit" as const,
})),
...(upcomingReturnsRes.data ?? []).map((e) => ({
...(upcomingReturns).map((e) => ({
id: e.id,
label: `${e.first_name} ${e.last_name}`,
employeeId: e.id,
label: fmtName(e.first_name, e.last_name),
date: e.karenz_return_date!,
kind: "return" as const,
})),
]
.sort((a, b) => a.date.localeCompare(b.date))
.slice(0, 8);
...(upcomingNotes).map((n) => ({
id: n.id,
employeeId: n.employee_id!,
label: fmtName(n.first_name, n.last_name),
hinweis: n.note_text,
date: n.due_date!,
kind: "note" as const,
})),
].sort((a, b) => a.date.localeCompare(b.date));
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}`]));
// Die Karte bleibt eine Übersicht, keine Liste: acht Zeilen, und darunter
// steht, wie viele es insgesamt sind. Wer alle sehen will, filtert enger
// oder geht in die Mitarbeiterliste.
const upcoming = upcomingAlle.slice(0, 8);
const weitere = upcomingAlle.length - upcoming.length;
// Each tile links to the view that shows what it counts, with the filters
// pre-applied.
@@ -203,18 +189,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: hiresYtdRes.count ?? 0,
value: hiresYtd,
tone: "success",
href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${yearEnd}`,
},
{
label: "Austritte (Jahr)",
value: exitsYtdRes.count ?? 0,
value: exitsYtd,
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: openPositionsRes.count ?? 0, tone: "brand", href: "/positions" },
{ label: "Offene Positionen", value: openPositions.length, tone: "brand", href: "/positions" },
];
return (
@@ -266,37 +252,68 @@ export default async function DashboardPage() {
</Card>
<Card>
<CardTitle className="mb-1">Anstehend (60 Tage)</CardTitle>
<CardTitle className="mb-2">Anstehend ({zeitraum} Tage)</CardTitle>
{/* useSearchParams braucht eine Suspense-Grenze; ohne sie fällt beim
Bauen die ganze Seite auf Rendern zur Laufzeit zurück. */}
<Suspense fallback={<div className="mb-2 h-9" />}>
<AnstehendFilter zeitraum={zeitraum} arten={arten} />
</Suspense>
<ul className="flex flex-col divide-y divide-border-subtle">
{upcoming.map((item) => (
<li key={`${item.kind}-${item.id}`}>
<Link
href={`/employees/${item.id}`}
className="-mx-2 flex items-center justify-between gap-2 rounded px-2 py-2.5 text-sm hover:bg-surface"
>
<span className="min-w-0">
<span className="block truncate font-semibold text-ink">{item.label}</span>
<span className="text-xs text-ink-muted">{KIND_LABEL[item.kind]}</span>
</span>
<span className="shrink-0 text-xs font-semibold tabular-nums text-ink-muted">{fmtDate(item.date)}</span>
</Link>
</li>
))}
{upcoming.length === 0 && <p className="py-2 text-sm text-ink-muted">Keine anstehenden Ereignisse.</p>}
{upcoming.map((item) => {
// Überfällig gibt es nur bei Wiedervorlagen: die anderen Arten
// haben eine untere Grenze, eine offene Aufgabe nicht.
const ueberfaellig = item.date < today;
return (
<li key={`${item.kind}-${item.id}`}>
<Link
href={`/employees/${item.employeeId}`}
className="-mx-2 flex items-center justify-between gap-2 rounded px-2 py-2.5 text-sm hover:bg-surface"
>
<span className="min-w-0">
<span className="block truncate font-semibold text-ink">{item.label}</span>
<span className="block truncate text-xs text-ink-muted">
{KIND_LABEL[item.kind]}
{item.hinweis ? ` — ${item.hinweis}` : ""}
</span>
</span>
<span
className={`shrink-0 text-xs font-semibold tabular-nums ${
ueberfaellig ? "text-danger-text" : "text-ink-muted"
}`}
>
{ueberfaellig ? "überfällig " : ""}
{fmtDate(item.date)}
</span>
</Link>
</li>
);
})}
{upcoming.length === 0 && (
<p className="py-2 text-sm text-ink-muted">
{istEingeschraenkt(zeitraum, arten)
? "Zu dieser Auswahl steht nichts an."
: "Keine anstehenden Ereignisse."}
</p>
)}
</ul>
{weitere > 0 && (
<p className="mt-2 text-xs text-ink-muted">
… und {weitere} {weitere === 1 ? "weiteres Ereignis" : "weitere Ereignisse"} in diesem Zeitraum.
</p>
)}
</Card>
<Card>
<CardTitle className="mb-1">Letzte Aktivitäten</CardTitle>
<ul className="flex flex-col divide-y divide-border-subtle">
{(historyRes.data ?? []).map((h) => (
{(history).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">{employeeNameById.get(h.employee_id) ?? "Unbekannt"}</span>
<span className="text-sm font-semibold text-ink">{h.first_name && h.last_name ? fmtName(h.first_name, h.last_name) : "Unbekannt"}</span>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${actionBadgeStyle(h.event_type)}`}>
{h.event_type}
</span>
@@ -305,7 +322,7 @@ export default async function DashboardPage() {
</div>
</li>
))}
{(historyRes.data ?? []).length === 0 && <p className="py-2 text-sm text-ink-muted">Keine Aktivitäten vorhanden.</p>}
{(history).length === 0 && <p className="py-2 text-sm text-ink-muted">Keine Aktivitäten vorhanden.</p>}
</ul>
</Card>
</div>

View File

@@ -1,21 +1,51 @@
import type { UnitOption } from "@/components/positions/CreatePositionModal";
import { PositionsPageClient } from "@/components/positions/PositionsPageClient";
import { daysBetweenIso, toIsoDate } from "@/lib/format";
import { loadOpenPositions } from "@/lib/positions";
import { createClient } from "@/lib/supabase/server";
import { currentUserId } from "@/lib/auth/session";
import { kostenstellenAbfrage } from "@/lib/cost-centers";
import { withUser } from "@/lib/db";
import { jsonArrayFrom } from "@/lib/db/json";
import { daysBetweenIso, todayIso } from "@/lib/format";
import { buildOrgMaps, orgMapsAbfragen } from "@/lib/org";
import { offeneStellenAbfrage, resolveOpenPositions, type OffeneStelle } from "@/lib/positions";
export default async function PositionsPage() {
const supabase = await createClient();
const today = todayIso();
// 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"),
]);
const { openPositions, orgMaps, chiefRows, kostenstellen } = await withUser(await currentUserId(), async (tx) => {
// Alles, was ohne Vorwissen geht, in einer Rundreise (lib/db/json.ts).
const g = await tx
.selectNoFrom((eb) => [
...orgMapsAbfragen(eb),
jsonArrayFrom(offeneStellenAbfrage(eb, today)).as("open"),
jsonArrayFrom(kostenstellenAbfrage(eb, today)).as("kostenstellen"),
// 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.
jsonArrayFrom(
eb.selectFrom("om_positions").select("org_unit_id").where("is_chief", "=", true).where("valid_to", "is", null)
).as("chiefRows"),
])
.executeTakeFirstOrThrow();
const openPositionsWithDays = openPositions.map((p) => ({ ...p, daysOpen: daysBetweenIso(toIsoDate(p.created_at)) }));
const orgMaps = buildOrgMaps(g.units as never, g.locations as never);
return {
orgMaps,
chiefRows: g.chiefRows,
kostenstellen: g.kostenstellen,
openPositions: await resolveOpenPositions(tx, orgMaps, g.open as OffeneStelle[], today),
};
});
return <PositionsPageClient openPositions={openPositionsWithDays} teams={teams ?? []} />;
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(p.vacantSince) }));
return <PositionsPageClient openPositions={openPositionsWithDays} units={units} kostenstellen={kostenstellen} />;
}

View File

@@ -15,10 +15,15 @@ import {
sumValues,
totalForRows,
} from "@/lib/reports";
import { parseCriteria } from "@/lib/report-criteria";
import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data";
import { createClient } from "@/lib/supabase/server";
import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
type SearchParams = {
// Nur die Parameter, die diese Seite selbst auswertet. Die Auswahlkriterien
// stehen ebenfalls in der Adresszeile, werden aber geschlossen von
// parseCriteria gelesen — siehe lib/report-criteria.ts.
type SearchParams = Record<string, string | string[] | undefined> & {
mode?: string;
measure?: string;
group?: string;
@@ -26,7 +31,6 @@ type SearchParams = {
division?: string;
location?: string;
status?: string;
employment?: string;
asOf?: string;
eventType?: string;
from?: string;
@@ -35,7 +39,6 @@ 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
@@ -50,39 +53,50 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
const measure = parseMeasure(params.measure);
const group = parseGroupDimension(params.group);
const split = parseSplitDimension(params.split);
const criteria = parseCriteria((k) => (typeof params[k] === "string" ? (params[k] as string) : undefined));
// The report data depends on neither the org lookups nor on who is signed
// 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 [{ lookups, divisions, locations }, { data: userRes }, events, employees] = await Promise.all([
loadOrgLookups(supabase),
supabase.auth.getUser(),
mode === "events"
? loadEventHistory(supabase, {
eventType: eventType ?? undefined,
division: params.division,
location: params.location,
from,
to,
})
: Promise.resolve([]),
mode === "snapshot"
? loadSnapshotEmployees(supabase, {
division: params.division,
location: params.location,
status: params.status,
employment: params.employment,
asOf,
})
: Promise.resolve([]),
]);
const userId = await currentUserId();
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: [] };
// 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, units, locations, events, employees, savedReports } = await withUser(userId, async (tx) => {
const [{ lookups, units, locations }, events, employees, savedReports] = await Promise.all([
loadOrgLookups(tx),
mode === "events"
? loadEventHistory(tx, {
eventType: eventType ?? undefined,
division: params.division,
location: params.location,
from,
to,
})
: Promise.resolve([]),
mode === "snapshot"
? loadSnapshotEmployees(tx, {
division: params.division,
location: params.location,
status: params.status,
criteria,
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, units, locations, events, employees, savedReports };
});
if (mode === "events") {
const rows = aggregateEvents(events, eventGroup, eventSplit, lookups);
@@ -98,9 +112,9 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
rows={rows}
total={sumValues(rows)}
recordCount={events.length}
divisions={divisions}
units={units}
locations={locations}
savedReports={savedReports ?? []}
savedReports={savedReports}
/>
</Suspense>
);
@@ -120,14 +134,14 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
division: params.division ?? "",
location: params.location ?? "",
status: params.status ?? "",
employment: params.employment ?? "",
}}
criteria={criteria}
rows={rows}
total={totalForRows(rows, measure)}
recordCount={employees.length}
divisions={divisions}
units={units}
locations={locations}
savedReports={savedReports ?? []}
savedReports={savedReports}
/>
</Suspense>
);

View File

@@ -1,14 +1,20 @@
import { login, logout } from "@/actions/auth";
import { Button } from "@/components/ui/Button";
import { CONTROL_CLASS } from "@/components/ui/Field";
import { EntraSignInButton } from "@/components/auth/EntraSignInButton";
import { logout, signInWithEntra } from "@/actions/auth";
// 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.
// rufen Sie …") on the real, correctly-branded sign-in screen. Dasselbe gilt
// für die Fehlertexte, die Entra im Rückweg mitschickt.
const ERROR_MESSAGES = {
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.",
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.",
},
} as const;
type ErrorCode = keyof typeof ERROR_MESSAGES;
@@ -19,60 +25,119 @@ type LoginPageProps = {
export default async function LoginPage({ searchParams }: LoginPageProps) {
const params = await searchParams;
const code = params.error && Object.hasOwn(ERROR_MESSAGES, params.error) ? (params.error as ErrorCode) : null;
// 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 error = code ? ERROR_MESSAGES[code] : null;
return (
<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>
// 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 />
{error && (
<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-2">
<button type="submit" className="text-xs font-semibold underline hover:no-underline">
Abmelden und mit anderem Konto versuchen
</button>
</form>
)}
<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>
)}
<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>
<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>
<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>
{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>
{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
</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",
}}
/>
<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>
<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

@@ -0,0 +1,9 @@
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,5 +1,6 @@
import { NextResponse, type NextRequest } from "next/server";
import { createAdminClient } from "@/lib/supabase/admin";
import { asSystem } from "@/lib/db";
import { callFunction } from "@/lib/db/rpc";
// Applies effective-dated changes (Versetzung/Beförderung/Karenz/Reorg/Daten
// ändern with a future "Wirksam ab" date) once their date has arrived — see
@@ -13,13 +14,15 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "Nicht autorisiert." }, { status: 401 });
}
const supabase = createAdminClient();
const { data, error } = await supabase.rpc("apply_due_pending_changes");
if (error) {
console.error("apply_due_pending_changes failed:", error);
// 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);
return NextResponse.json({ error: "Interner Fehler." }, { status: 500 });
}
return NextResponse.json({ applied: data });
}

View File

@@ -1,14 +1,24 @@
import { NextResponse, type NextRequest } from "next/server";
import { statusLabel } from "@/lib/absence";
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
import { fmtName, todayIso } from "@/lib/format";
import { subtreeOf } from "@/lib/org";
import { loadPlacements, loadReportingLineMap } from "@/lib/placement";
import { LEERE_CRITERIA, parseCriteria, passtImSpeicher } from "@/lib/report-criteria";
import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "@/lib/reports";
import { loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data";
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";
import { applyCriteria, loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data";
import { requireHrUser } from "@/lib/auth/require-hr";
import { withUser } from "@/lib/db";
import type { Database, Weekday } from "@/lib/supabase/types";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
// 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;
};
// Full raw data dump — every column on `employees`, not just the fields a
// pivot report groups by. Respects the same division/location/status/
@@ -16,9 +26,8 @@ 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 supabase = await createClient();
const denied = await requireHrUser(supabase);
if (denied) return denied;
const gate = await requireHrUser();
if ("denied" in gate) return gate.denied;
const params = request.nextUrl.searchParams;
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
@@ -27,29 +36,66 @@ export async function GET(request: NextRequest) {
division: params.get("division") ?? undefined,
location: params.get("location") ?? undefined,
status: params.get("status") ?? undefined,
employment: params.get("employment") ?? undefined,
criteria: parseCriteria((k) => params.get(k)),
};
const statuses = parseStatuses(filters.status);
function employeeQuery() {
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 stichtag = asOf ?? todayIso();
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),
]);
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 (!asOf) q = q.where("status", "in", statuses);
// Dieselben Bedingungen wie im Bericht daneben — sonst stimmt die
// Zahl auf dem Bildschirm nicht mit der Zeilenzahl im Export überein.
return applyCriteria(q, filters.criteria ?? LEERE_CRITERIA);
}
const managerName = new Map(allEmployees.map((e) => [e.id, `${e.first_name} ${e.last_name}`]));
const rows = asOf ? employees.filter((e) => statuses.includes(deriveStatusAsOf(e, asOf))) : employees;
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),
]);
return {
employees,
lookups: lookupResult.lookups,
orgMaps: lookupResult.orgMaps,
allEmployees,
dependentsCounts,
placements,
lines,
};
}
);
const managerName = new Map(allEmployees.map((e) => [e.id, fmtName(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 criteria = filters.criteria ?? LEERE_CRITERIA;
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 [];
if (!passtImSpeicher({ work_days: e.work_days, dependentsCount: dependentsCounts.get(e.id) ?? 0 }, criteria)) 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 columns = employeeExportColumns(lookups, managerName, dependentsCounts, asOf);
const filename = exportFilename("mitarbeiter-export", format);
@@ -75,20 +121,22 @@ function employeeExportColumns(
{ header: "Geburtsdatum", get: (e) => e.birth_date, kind: "date" },
{ header: "SV-Nummer", get: (e) => e.sv_nummer },
{ header: "Staatsbürgerschaft", get: (e) => e.nationality },
{ header: "Aufenthaltstitel", get: (e) => e.hat_aufenthaltstitel },
{ header: "Aufenthaltstitel bis", get: (e) => e.aufenthaltstitel_bis, kind: "date" },
{ header: "Adresse", get: (e) => e.address },
{ header: "Postleitzahl", get: (e) => e.postal_code },
{ header: "Ort", get: (e) => e.city },
{ header: "Wohnsitzland", get: (e) => e.address_country },
{ header: "E-Mail", get: (e) => e.email },
{ header: "Telefon", get: (e) => e.phone },
{ 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: "Private E-Mail", get: (e) => e.email },
{ header: "Private Telefonnummer", 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: "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: "Führungskraft", get: (e) => e.is_lead },
{ header: "Org-Level", get: (e) => e.org_level },
{ header: "Planstelle", get: (e) => e.position_number },
{ header: "Leitungsplanstelle", get: (e) => e.is_chief },
{ 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
@@ -100,6 +148,14 @@ function employeeExportColumns(
{ header: "Kollektivvertrag", get: (e) => e.collective_agreement },
{ header: "Betriebsrat", get: (e) => e.is_betriebsrat },
{ header: "Dienstwagen", get: (e) => e.has_dienstwagen },
{ header: "Antriebsart", get: (e) => e.dienstwagen_art ?? "" },
{ header: "Besonderer Kündigungsschutz", get: (e) => e.has_kuendigungsschutz },
{ header: "Kündigungsschutz bis", get: (e) => e.kuendigungsschutz_bis, kind: "date" },
{ header: "Teilzeitvariante", get: (e) => e.teilzeit_art ?? "" },
{ header: "Teilzeit bis", get: (e) => e.teilzeit_bis, kind: "date" },
{ header: "Notfallkontakt", get: (e) => e.emergency_contact_name ?? "" },
{ header: "Notfallkontakt Telefon", get: (e) => e.emergency_contact_phone ?? "" },
{ header: "Notfallkontakt Verhältnis", get: (e) => e.emergency_contact_relation ?? "" },
{ header: "Laterale Führung", get: (e) => e.is_laterale_fuehrung },
{ header: "C-Level", get: (e) => e.is_c_level },
{ header: "Paygrade", get: (e) => e.paygrade },

View File

@@ -2,32 +2,34 @@ 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/supabase/auth";
import { createClient } from "@/lib/supabase/server";
import { requireHrUser } from "@/lib/auth/require-hr";
import { withUser } from "@/lib/db";
// 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 supabase = await createClient();
const denied = await requireHrUser(supabase);
if (denied) return denied;
const gate = await requireHrUser();
if ("denied" in gate) return gate.denied;
const params = request.nextUrl.searchParams;
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
const eventType = parseEventType(params.get("eventType"));
const [{ lookups }, events] = await Promise.all([
loadOrgLookups(supabase),
loadEventHistory(supabase, {
eventType: eventType ?? undefined,
division: params.get("division") ?? undefined,
location: params.get("location") ?? undefined,
from: parseEventDateParam(params.get("from")),
to: parseEventDateParam(params.get("to")),
}),
]);
const { lookups, events } = await withUser(gate.userId, async (tx) => {
const [{ lookups }, events] = await Promise.all([
loadOrgLookups(tx),
loadEventHistory(tx, {
eventType: eventType ?? undefined,
division: params.get("division") ?? undefined,
location: params.get("location") ?? undefined,
from: parseEventDateParam(params.get("from")),
to: parseEventDateParam(params.get("to")),
}),
]);
return { lookups, events };
});
const columns = eventExportColumns(lookups);
const filename = exportFilename(`ereignisse-${eventType ?? "alle"}`, format);
@@ -44,9 +46,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) => 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: "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: "Standort", get: (e) => lookups.locationName.get(e.location_id) ?? "" },
{ header: "Beschreibung", get: (e) => e.description },
];

View File

@@ -1,5 +1,6 @@
import { NextResponse, type NextRequest } from "next/server";
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
import { parseCriteria } from "@/lib/report-criteria";
import {
aggregateEvents,
aggregateReport,
@@ -24,59 +25,64 @@ import {
type ReportRow,
} from "@/lib/reports";
import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data";
import { requireHrUser } from "@/lib/supabase/auth";
import { createClient } from "@/lib/supabase/server";
import { requireHrUser } from "@/lib/auth/require-hr";
import { withUser } from "@/lib/db";
// 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 supabase = await createClient();
const denied = await requireHrUser(supabase);
if (denied) return denied;
const gate = await requireHrUser();
if ("denied" in gate) return gate.denied;
const params = request.nextUrl.searchParams;
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
const mode = parseMode(params.get("mode"));
const { lookups } = await loadOrgLookups(supabase);
// 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);
let rows: ReportRow[];
let columns: ExportColumn<ReportRow>[];
let filenameBase: string;
let rows: ReportRow[];
let columns: ExportColumn<ReportRow>[];
let filenameBase: string;
if (mode === "events") {
const group = parseEventGroupDimension(params.get("group"));
const split = parseEventSplitDimension(params.get("split"));
const eventType = parseEventType(params.get("eventType"));
const events = await loadEventHistory(tx, {
eventType: eventType ?? undefined,
division: params.get("division") ?? undefined,
location: params.get("location") ?? undefined,
from: parseEventDateParam(params.get("from")),
to: parseEventDateParam(params.get("to")),
});
rows = aggregateEvents(events, group, split, lookups);
columns = eventReportColumns(rows, group, split, sumValues(rows));
filenameBase = `ereignisse-${eventType ?? "alle"}-${group}`;
} else {
const measure = parseMeasure(params.get("measure"));
const group = parseGroupDimension(params.get("group"));
const split = parseSplitDimension(params.get("split"));
const asOf = parseIsoDateParam(params.get("asOf"));
const employees = await loadSnapshotEmployees(tx, {
division: params.get("division") ?? undefined,
location: params.get("location") ?? undefined,
status: params.get("status") ?? undefined,
criteria: parseCriteria((k) => params.get(k)),
asOf,
});
rows = aggregateReport(employees, measure, group, split, lookups, asOf);
columns = snapshotReportColumns(rows, measure, group, split, totalForRows(rows, measure));
filenameBase = `bericht-${measure}-${group}`;
}
if (mode === "events") {
const group = parseEventGroupDimension(params.get("group"));
const split = parseEventSplitDimension(params.get("split"));
const eventType = parseEventType(params.get("eventType"));
const events = await loadEventHistory(supabase, {
eventType: eventType ?? undefined,
division: params.get("division") ?? undefined,
location: params.get("location") ?? undefined,
from: parseEventDateParam(params.get("from")),
to: parseEventDateParam(params.get("to")),
});
rows = aggregateEvents(events, group, split, lookups);
columns = eventReportColumns(rows, group, split, sumValues(rows));
filenameBase = `ereignisse-${eventType ?? "alle"}-${group}`;
} else {
const measure = parseMeasure(params.get("measure"));
const group = parseGroupDimension(params.get("group"));
const split = parseSplitDimension(params.get("split"));
const asOf = parseIsoDateParam(params.get("asOf"));
const employees = await loadSnapshotEmployees(supabase, {
division: params.get("division") ?? undefined,
location: params.get("location") ?? undefined,
status: params.get("status") ?? undefined,
employment: params.get("employment") ?? undefined,
asOf,
});
rows = aggregateReport(employees, measure, group, split, lookups, asOf);
columns = snapshotReportColumns(rows, measure, group, split, totalForRows(rows, measure));
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.

135
app/api/import/route.ts Normal file
View File

@@ -0,0 +1,135 @@
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");
}
}
// 60 Sekunden, weil das die Obergrenze im kostenlosen Vercel-Tarif ist —
// ein höherer Wert lässt sich dort nicht ausrollen. Auf einem eigenen Server
// gilt die Angabe ohnehin nicht, und im Pro-Tarif liesse sie sich auf 300
// heben, falls eine Datei mit vielen tausend Zeilen ansteht.
export const maxDuration = 60;
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

@@ -0,0 +1,96 @@
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 Normal file
View File

@@ -0,0 +1,65 @@
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

@@ -0,0 +1,112 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { AenderungsTabelle } from "@/components/ui/AenderungsTabelle";
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",
});
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">
<AenderungsTabelle changes={eintrag.changes!} />
</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

@@ -0,0 +1,32 @@
"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

@@ -0,0 +1,70 @@
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { actionBadgeStyle } from "@/lib/colors";
import {
ANSTEHEND_ARTEN,
STANDARD_ZEITRAUM,
ZEITRAEUME,
type AnstehendArt,
type Zeitraum,
} from "@/lib/dashboard-filter";
// Die Auswahl wandert in die Adresse; die Seite baut sich damit neu. Das ist
// hier nötig und nicht bloss ordentlich: ein längerer Zeitraum bringt Zeilen
// ins Spiel, die vorher gar nicht geladen waren.
//
// router.replace statt push, damit der Zurück-Knopf nicht durch jede einzelne
// Filterstellung zurückläuft, und ohne Sprung nach oben — die Karte steht in
// der unteren Hälfte, und dorthin sieht gerade, wer hier klickt.
export function AnstehendFilter({ zeitraum, arten }: { zeitraum: Zeitraum; arten: AnstehendArt[] }) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
function setzen(key: string, wert: string | null) {
const params = new URLSearchParams(searchParams.toString());
if (wert) params.set(key, wert);
else params.delete(key);
const query = params.toString();
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
}
function artUmschalten(art: AnstehendArt) {
const alle = ANSTEHEND_ARTEN.map((a) => a.value);
const naechste = arten.includes(art) ? arten.filter((a) => a !== art) : [...arten, art];
// Nichts ausgewählt heisst wieder alles: eine leere Karte ist keine
// Antwort, und der Weg dorthin wäre ein Klick zu weit.
setzen("arten", naechste.length === 0 || naechste.length === alle.length ? null : naechste.join(","));
}
return (
<div className="mb-2 flex flex-wrap items-center gap-2">
<SegmentedControl<string>
value={String(zeitraum)}
onChange={(v) => setzen("tage", v === String(STANDARD_ZEITRAUM) ? null : v)}
options={ZEITRAEUME.map((t) => ({ value: String(t), label: `${t} Tage` }))}
/>
<div className="flex flex-wrap items-center gap-1.5">
{ANSTEHEND_ARTEN.map((a) => {
const aktiv = arten.includes(a.value);
return (
<button
key={a.value}
type="button"
onClick={() => artUmschalten(a.value)}
aria-pressed={aktiv}
className={`rounded-full px-2 py-0.5 text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
aktiv ? actionBadgeStyle(a.label) : "bg-surface text-ink-muted hover:text-ink"
}`}
>
{a.label}
</button>
);
})}
</div>
</div>
);
}

View File

@@ -7,6 +7,7 @@ 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";
@@ -21,42 +22,39 @@ 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 };
type OpenPosition = { id: string; position_number: string; title: string; team_id: string; is_lead: boolean };
/** Die Planstelle, die die Person heute innehat. */
type PlacementInfo = { positionNumber: string; jobTitle: string; isChief: boolean; current: boolean };
type EmployeeDetailProps = {
employee: EmployeeRow;
placement: PlacementInfo | null;
breadcrumb: string;
/** Die Kostenstelle der laufenden Planstellenbesetzung. */
kostenstelle: { code: string; name: string } | null;
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: OpenPosition[];
openPositions: OpenPositionResolved[];
};
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, manager, directReports, history, dependents, notes, divisions, departments, teams, locations } = props;
const { employee, placement, breadcrumb, kostenstelle, manager, formalManager, directReports, history, dependents, notes, locations, openPositions } = 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";
@@ -79,8 +77,17 @@ 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">{employee.job_title}</p>
<p className="text-xs text-ink-muted">{breadcrumb}</p>
<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="mt-1 text-xs text-ink-muted">
Pers.-Nr. {employee.personnel_number}
{employee.status !== "Geplant" && <> · Zugehörigkeit: {tenure(employee.entry_date, employee.exit_date)}</>}
@@ -101,14 +108,20 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
</>
)}
{canEditData && <ActionButton icon={Pencil} label="Daten ändern" onClick={() => setPanel("daten")} />}
{isActive && (
{/* Auch bei einem geplanten Eintritt, und dort heisst es anders:
wer nie angetreten ist, „tritt" nicht „aus". Die Person hat
noch keinen Tag gearbeitet, und genau dafür gibt es den Grund
„No Show" — ohne diesen Knopf bliebe sie auf Dauer als
geplanter Eintritt stehen. */}
{(isActive || employee.status === "Geplant") && (
<Button
variant="secondary"
size="sm"
onClick={() => setPanel("terminate")}
className="!border-danger-solid !text-danger-solid hover:!bg-danger-bg"
>
<XCircle className="h-4 w-4" /> Austritt
<XCircle className="h-4 w-4" />
{employee.status === "Geplant" ? "Nicht angetreten" : "Austritt"}
</Button>
)}
{employee.status === "Ausgetreten" && (
@@ -142,9 +155,16 @@ 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} directReports={directReports} breadcrumb={breadcrumb} />
<OrganisationTab
employeeId={employee.id}
manager={manager}
formalManager={formalManager}
directReports={directReports}
breadcrumb={breadcrumb}
kostenstelle={kostenstelle}
/>
)}
{tab === "Historie" && <HistorieTab history={history} />}
{tab === "Historie" && <HistorieTab history={history} employeeId={employee.id} />}
{tab === "HR-Notizen" && <NotizenTab employeeId={employee.id} notes={notes} />}
</div>
@@ -152,10 +172,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
open={panel === "transfer"}
onClose={() => setPanel(null)}
employee={employee}
divisions={divisions}
departments={departments}
teams={teams}
currentTeamId={employee.team_id}
openPositions={openPositions}
/>
<PromotePanel open={panel === "promote"} onClose={() => setPanel(null)} employee={employee} />
<KarenzPanel open={panel === "karenz"} onClose={() => setPanel(null)} employee={employee} />
@@ -167,7 +184,12 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
locationCountry={location?.country}
/>
<TerminatePanel open={panel === "terminate"} onClose={() => setPanel(null)} employee={employee} directReportCount={directReports.length} />
<RehirePanel open={panel === "rehire"} onClose={() => setPanel(null)} employee={employee} />
<RehirePanel
open={panel === "rehire"}
onClose={() => setPanel(null)}
employee={employee}
openPositions={openPositions}
/>
</div>
);
}

View File

@@ -6,7 +6,9 @@ import { FILTER_SELECT_CLASS } from "@/components/ui/Field";
import { SearchInput } from "@/components/ui/SearchInput";
type EmployeeFiltersProps = {
divisions: { id: string; name: string }[];
/** Der ganze Baum, in Tiefensuche-Reihenfolge. */
units: { id: string; name: string; unit_type: string }[];
depthOf: Map<string, number>;
locations: { id: string; name: string }[];
};
@@ -22,7 +24,7 @@ const STATUS_OPTIONS = [
{ value: "Ausgetreten", label: "Ausgetreten" },
] as const;
export function EmployeeFilters({ divisions, locations }: EmployeeFiltersProps) {
export function EmployeeFilters({ units, depthOf, locations }: EmployeeFiltersProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -55,18 +57,26 @@ export function EmployeeFilters({ divisions, locations }: EmployeeFiltersProps)
{/* 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 Bereich filtern"
aria-label="Nach Organisationseinheit filtern"
defaultValue={searchParams.get("division") ?? ""}
onChange={(e) => updateParam("division", e.target.value)}
className={FILTER_SELECT_CLASS}
>
<option value="">Alle Bereiche</option>
{divisions.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
<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>
))}
</select>
<select
aria-label="Nach Status filtern"
@@ -94,6 +104,12 @@ export function EmployeeFilters({ divisions, locations }: EmployeeFiltersProps)
</option>
))}
</select>
{/* Der Dienstwagen stand hier einmal als eigenes Auswahlfeld. Er ist
jetzt eines von rund zwanzig Kriterien unter Berichte, zusammen mit
Vertragsart, Kollektivvertrag, Eintrittszeitraum und dem Rest —
dort lässt sich die Auswahl auch exportieren, was der eigentliche
Zweck der Frage war. In dieser Leiste, die vor allem zum Suchen da
ist, wäre er ein Sonderfall unter vielen gleichrangigen. */}
</div>
);
}

View File

@@ -0,0 +1,210 @@
"use client";
import { Pencil } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { updateHistoryEntry } from "@/actions/employees";
import { Button } from "@/components/ui/Button";
import { TextField } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import { fmtDate } from "@/lib/format";
import type { AuditChange } from "@/lib/supabase/types";
// Berichtigen, nicht neu erfassen.
//
// „Daten ändern" schreibt eine *neue* Änderung — richtig, wenn sich etwas
// wirklich geändert hat. Hier geht es um den anderen Fall: der Vorgang
// stimmt, aber der erfasste Wert oder das Datum nicht. Ohne diesen Weg
// stünden in der Akte zwei Einträge für eine Änderung, von denen der erste
// nie stattgefunden hat.
//
// Bearbeitet wird nur das **Nachher**. Das Vorher steht daneben, unveränder-
// lich: es beschreibt, was vor der Änderung galt, und das lässt sich
// nachträglich nicht anders beschliessen.
export function HistorieBearbeiten({
historyId,
employeeId,
bezeichnung,
datum,
changes,
istZukunft,
heute,
nurDatum = false,
}: {
historyId: string;
employeeId: string;
bezeichnung: string;
datum: string;
changes: AuditChange[];
/** Noch nicht wirksam — dann wird der geplante Vorgang berichtigt, nicht der Stand. */
istZukunft: boolean;
/** Vom Server, nicht aus new Date(): sonst rechnet der Browser mit seiner
* eigenen Zeitzone, und in einer Renderfunktion hat die Uhr ohnehin nichts
* verloren. */
heute: string;
/**
* Der Eintritt hat keine Felder, die sich zurücknehmen liessen — nur ein
* Datum, das falsch erfasst sein kann. Daran hängt trotzdem einiges: die
* erste Planstellenbesetzung, der frühestmögliche Zeitpunkt jedes weiteren
* Ereignisses, die Zugehörigkeit. Die Datenbank prüft das und weist
* verständlich ab.
*/
nurDatum?: boolean;
}) {
const [offen, setOffen] = useState(false);
const [laeuft, setLaeuft] = useState(false);
const [neuesDatum, setNeuesDatum] = useState(datum);
const [werte, setWerte] = useState<Record<string, string>>(() =>
Object.fromEntries(changes.map((c) => [c.feld, c.nachher ?? ""]))
);
const { showToast } = useToast();
const router = useRouter();
// Morgen aus dem Serverdatum, nicht aus der Uhr des Browsers.
const morgen = new Date(Date.parse(heute + "T00:00:00Z") + 86400000).toISOString().slice(0, 10);
const etwasGeaendert =
neuesDatum !== datum || changes.some((c) => (werte[c.feld] ?? "") !== (c.nachher ?? ""));
function abbrechen() {
// Beim Schliessen zurück auf den gespeicherten Stand, damit ein zweites
// Öffnen nicht die verworfenen Eingaben zeigt.
setNeuesDatum(datum);
setWerte(Object.fromEntries(changes.map((c) => [c.feld, c.nachher ?? ""])));
setOffen(false);
}
async function speichern() {
// Ein Eintrag bleibt auf seiner Seite der Gegenwart. Eine gelaufene
// Änderung in eine geplante zu verwandeln (oder umgekehrt) hiesse,
// Stammdaten und Vorgang gegenläufig anzupassen — dafür gibt es die
// fachlichen Vorgänge. Die Datenbank weist es ohnehin ab; hier steht es
// nur früher und freundlicher.
// Beim Eintritt gilt das nicht: er darf in der Vergangenheit *und* in der
// Zukunft liegen — ein geplanter Eintritt ist ein gewöhnlicher Fall. Was
// dort zusammenpassen muss, prüft die Datenbank und sagt es verständlich.
if (!nurDatum && istZukunft && neuesDatum <= heute) {
showToast("Eine geplante Änderung lässt sich hier nicht vorziehen.", "error");
return;
}
if (!nurDatum && !istZukunft && neuesDatum > heute) {
showToast("Eine bereits wirksame Änderung lässt sich nicht in die Zukunft verschieben.", "error");
return;
}
setLaeuft(true);
const ergebnis = await updateHistoryEntry({
history_id: historyId,
employee_id: employeeId,
event_date: neuesDatum,
werte: changes.map((c) => ({ feld: c.feld, nachher: werte[c.feld]?.trim() || null })),
});
setLaeuft(false);
if (ergebnis.success) {
showToast("Eintrag berichtigt.");
setOffen(false);
router.refresh();
} else {
showToast(ergebnis.error ?? "Berichtigen fehlgeschlagen.", "error");
}
}
return (
<>
<button
type="button"
onClick={() => setOffen(true)}
aria-label={`${bezeichnung} vom ${fmtDate(datum)} bearbeiten`}
className="rounded p-1 text-ink-muted hover:bg-brand-50 hover:text-brand-700
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<Modal
open={offen}
onClose={abbrechen}
title="Eintrag berichtigen"
widthClassName="max-w-2xl"
footer={
<>
<Button variant="ghost" onClick={abbrechen}>
Abbrechen
</Button>
<Button onClick={speichern} pending={laeuft} disabled={!etwasGeaendert}>
Berichtigen
</Button>
</>
}
>
<p className="text-sm text-ink-body">
<strong className="text-ink">{bezeichnung}</strong>
{nurDatum
? " — der Eintritt selbst bleibt; berichtigt wird nur sein Datum."
: istZukunft
? " — diese Änderung ist noch nicht wirksam. Berichtigt wird, was am Stichtag passieren soll."
: " — was hier stand, war falsch erfasst. Für eine tatsächliche Änderung ist „Daten ändern“ der richtige Weg."}
</p>
<div className="mt-4 max-w-xs">
<TextField
label={nurDatum ? "Eintrittsdatum" : "Wirksam ab"}
type="date"
min={!nurDatum && istZukunft ? morgen : undefined}
max={!nurDatum && !istZukunft ? heute : undefined}
value={neuesDatum}
onChange={setNeuesDatum}
/>
</div>
{nurDatum && (
<p className="mt-4 rounded bg-surface px-3 py-2 text-xs text-ink-muted">
Daran hängt mehr als eine Zahl: die erste Planstellenbesetzung wandert mit, und kein anderes Ereignis darf
vor dem Eintritt liegen. Passt das neue Datum nicht dazu, wird die Änderung mit dem Grund abgewiesen.
</p>
)}
<div className={`mt-5 overflow-x-auto ${nurDatum ? "hidden" : ""}`}>
<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>
{changes.map((c) => (
<tr key={c.feld} className="border-b border-border-subtle align-middle last:border-0">
<td className="py-2 pr-4 font-semibold text-ink-body">{c.feld}</td>
<td className="py-2 pr-4 text-ink-muted">
{c.vorher === null || c.vorher === "" ? <span className="italic">leer</span> : c.vorher}
</td>
<td className="py-2">
<input
type="text"
aria-label={`${c.feld} — neuer Wert`}
value={werte[c.feld] ?? ""}
onChange={(e) => setWerte((v) => ({ ...v, [c.feld]: e.target.value }))}
className="w-full rounded border border-border px-2 py-1 text-sm text-ink
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mt-4 rounded bg-surface px-3 py-2 text-xs text-ink-muted">
{nurDatum
? "Stammdaten, Historie und Planstellenbesetzung werden gemeinsam nachgezogen."
: istZukunft
? "An den Stammdaten ändert sich jetzt nichts — die Änderung greift erst am Stichtag. Berichtigt wird der geplante Vorgang selbst."
: "Die Stammdaten werden nachgezogen — je Feld gilt dann der jüngste Eintrag, der es trägt. Hat eine spätere Änderung dasselbe Feld erneut gesetzt, bleibt deren Wert stehen."}{" "}
Die Berichtigung selbst steht im Protokoll.
</p>
</Modal>
</>
);
}

View File

@@ -0,0 +1,162 @@
"use client";
import { Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { deleteHistoryEntry } from "@/actions/employees";
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import { fmtDate } from "@/lib/format";
import type { Vorschau } from "@/lib/history";
// Löschen mit Ansage.
//
// Bestätigen heisst hier nicht „Wirklich?" — das beantwortet jede Person nach
// dem dritten Mal blind mit Ja. Der Dialog sagt stattdessen, **was danach
// anders ist**: welches Feld auf welchen Wert zurückgeht, und welches nicht,
// weil eine spätere Änderung es erneut angefasst hat. Wer das liest, merkt
// selbst, ob er den richtigen Eintrag erwischt hat.
export function HistorieLoeschen({
historyId,
employeeId,
bezeichnung,
datum,
vorschau,
istZukunft,
}: {
historyId: string;
employeeId: string;
bezeichnung: string;
datum: string;
vorschau: Vorschau[];
/** Noch nicht wirksam — dann wird der geplante Vorgang entschärft. */
istZukunft: boolean;
}) {
const [offen, setOffen] = useState(false);
const [laeuft, setLaeuft] = useState(false);
const { showToast } = useToast();
const router = useRouter();
const zurueck = vorschau.filter((v) => !v.bleibt);
const bleibt = vorschau.filter((v) => v.bleibt);
// Eine geplante Abwesenheit oder Rückkehr hat keine einzelnen Felder, die
// sich herausnehmen liessen — sie fällt als Ganzes. Dann ist „Löschen und
// zurücksetzen" das falsche Wort für das, was der Knopf tut.
const ganzerVorgang = istZukunft && vorschau.length === 0;
async function loeschen() {
setLaeuft(true);
const ergebnis = await deleteHistoryEntry({ history_id: historyId, employee_id: employeeId });
setLaeuft(false);
if (ergebnis.success) {
showToast("Eintrag gelöscht.");
setOffen(false);
router.refresh();
} else {
showToast(ergebnis.error ?? "Löschen fehlgeschlagen.", "error");
}
}
return (
<>
<button
type="button"
onClick={() => setOffen(true)}
aria-label={`${bezeichnung} vom ${fmtDate(datum)} löschen`}
className="rounded p-1 text-ink-muted hover:bg-danger-bg hover:text-danger-text
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<Modal
open={offen}
onClose={() => setOffen(false)}
title={ganzerVorgang ? "Geplanten Vorgang abbrechen" : "Eintrag löschen"}
footer={
<>
<Button variant="ghost" onClick={() => setOffen(false)}>
Abbrechen
</Button>
<Button onClick={loeschen} pending={laeuft} className="!bg-danger-solid text-white hover:brightness-110">
{ganzerVorgang ? "Vorgang abbrechen" : "Löschen und zurücksetzen"}
</Button>
</>
}
>
<p className="text-sm text-ink-body">
<strong className="text-ink">{bezeichnung}</strong> {istZukunft ? "zum" : "vom"} {fmtDate(datum)} wird aus der
Historie entfernt.
</p>
{ganzerVorgang && (
<div className="mt-4">
<h3 className="text-xs font-bold uppercase tracking-wide text-ink-muted">Wird nicht mehr passieren</h3>
<p className="mt-1.5 text-sm text-ink-body">
Der Vorgang entfällt ganz — die {bezeichnung} zum {fmtDate(datum)} findet nicht statt. Am Stammsatz wird
das vorgemerkte Datum mit entfernt.
</p>
<p className="mt-2 text-xs text-ink-muted">
An den heutigen Stammdaten ändert sich nichts: der Vorgang war noch nicht wirksam.
</p>
</div>
)}
{istZukunft && vorschau.length > 0 && (
<div className="mt-4">
<h3 className="text-xs font-bold uppercase tracking-wide text-ink-muted">Wird nicht mehr passieren</h3>
<ul className="mt-1.5 flex flex-col gap-1">
{vorschau.map((v) => (
<li key={v.feld} className="text-sm text-ink-body">
<span className="font-semibold text-ink">{v.feld}</span>{" "}
<span className="text-ink-muted">sollte auf</span> <span className="text-ink">{v.von || "leer"}</span>{" "}
<span className="text-ink-muted">gesetzt werden</span>
</li>
))}
</ul>
<p className="mt-2 text-xs text-ink-muted">
An den Stammdaten ändert sich nichts — die Änderung war noch nicht wirksam. Betrifft der geplante Vorgang
noch weitere Felder, läuft er mit diesen weiter.
</p>
</div>
)}
{!istZukunft && zurueck.length > 0 && (
<div className="mt-4">
<h3 className="text-xs font-bold uppercase tracking-wide text-ink-muted">Wird zurückgesetzt</h3>
<ul className="mt-1.5 flex flex-col gap-1">
{zurueck.map((v) => (
<li key={v.feld} className="text-sm text-ink-body">
<span className="font-semibold text-ink">{v.feld}</span>{" "}
<span className="text-ink-muted line-through decoration-ink-muted/40">{v.von || "leer"}</span>{" "}
<span aria-hidden="true">→</span> <span className="text-ink">{v.auf || "leer"}</span>
</li>
))}
</ul>
</div>
)}
{!istZukunft && bleibt.length > 0 && (
<div className="mt-4">
<h3 className="text-xs font-bold uppercase tracking-wide text-ink-muted">Bleibt unverändert</h3>
<ul className="mt-1.5 flex flex-col gap-1">
{bleibt.map((v) => (
<li key={v.feld} className="text-sm text-ink-muted">
<span className="font-semibold">{v.feld}</span> — eine spätere Änderung hat dieses Feld erneut
gesetzt, und die gilt weiter.
</li>
))}
</ul>
</div>
)}
<p className="mt-4 rounded bg-surface px-3 py-2 text-xs text-ink-muted">
Der Vorgang wird im Protokoll festgehalten — mit Zeitpunkt, Person und den Werten der gelöschten Zeile. Die
Zeile selbst lässt sich nicht wiederherstellen.
</p>
</Modal>
</>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { SelectField } from "@/components/ui/Field";
import type { CollectiveAgreement, Weekday, WorkerType } from "@/lib/supabase/types";
import { SelectField, TextField } from "@/components/ui/Field";
import type { CollectiveAgreement, DienstwagenArt, Weekday, WorkerType } from "@/lib/supabase/types";
const WEEKDAYS: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
@@ -11,8 +11,25 @@ export type RoleEmploymentValue = {
workDays: Weekday[];
isBetriebsrat: boolean;
hasDienstwagen: boolean;
/**
* Nur bedeutsam, solange hasDienstwagen gesetzt ist.
*
* Der Wert bleibt beim Abwählen stehen, statt zurückgesetzt zu werden —
* wer versehentlich klickt und zurückklickt, findet seine Angabe wieder.
* Beim Speichern setzen die Aufrufer ihn auf null, wie es der CHECK
* verlangt.
*/
dienstwagenArt: DienstwagenArt;
isLateraleFuehrung: boolean;
isCLevel: boolean;
/** Betriebsrat, Mutterschutz, Karenz, begünstigte Behinderung, Lehre. */
hasKuendigungsschutz: boolean;
/**
* Ende des Schutzes — freiwillig. Bei einem Betriebsratsmandat steht es
* fest, bei einer Schwangerschaft nicht; ein Pflichtfeld zwänge dort zu
* einer erfundenen Zahl. Leer heisst „bis auf Weiteres".
*/
kuendigungsschutzBis: string;
};
// Shared by the hire wizard (StepVertrag) and DatenAendernPanel — both edit
@@ -78,6 +95,21 @@ export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymen
<input type="checkbox" checked={value.hasDienstwagen} onChange={(e) => onChange({ hasDienstwagen: e.target.checked })} />
Dienstwagen
</label>
{/* Nur sichtbar, wenn es einen gibt: eine Antriebsart ohne Fahrzeug
ist keine Angabe, sondern eine Frage ohne Gegenstand — und die
Datenbank weist sie ab. */}
{value.hasDienstwagen && (
<SelectField
label="Antriebsart"
dense
value={value.dienstwagenArt}
onChange={(v) => onChange({ dienstwagenArt: v as DienstwagenArt })}
options={[
{ value: "Verbrenner", label: "Verbrenner" },
{ value: "Elektro", label: "Elektro (E-KFZ)" },
]}
/>
)}
<label className="flex items-center gap-2 text-sm text-ink-body">
<input type="checkbox" checked={value.isLateraleFuehrung} onChange={(e) => onChange({ isLateraleFuehrung: e.target.checked })} />
Laterale Führung
@@ -86,6 +118,27 @@ export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymen
<input type="checkbox" checked={value.isCLevel} onChange={(e) => onChange({ isCLevel: e.target.checked })} />
C-Level
</label>
<label className="flex items-center gap-2 text-sm text-ink-body">
<input
type="checkbox"
checked={value.hasKuendigungsschutz}
onChange={(e) => onChange({ hasKuendigungsschutz: e.target.checked })}
/>
Besonderer Kündigungsschutz
</label>
{/* Wie bei der Antriebsart: erst sichtbar, wenn es einen Gegenstand
gibt. Anders als dort aber freiwillig — leer heisst „bis auf
Weiteres", nicht „vergessen". */}
{value.hasKuendigungsschutz && (
<TextField
label="Geschützt bis"
dense
type="date"
value={value.kuendigungsschutzBis}
onChange={(v) => onChange({ kuendigungsschutzBis: v })}
hint="Optional. Leer lassen, solange das Ende nicht feststeht."
/>
)}
</div>
</div>
);

View File

@@ -12,10 +12,11 @@ import { CountryPicker } from "@/components/ui/CountryPicker";
import { Field, SelectField, TextField } from "@/components/ui/Field";
import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast";
import { UN_COUNTRIES } from "@/lib/countries";
import { brauchtAufenthaltstitel, UN_COUNTRIES } from "@/lib/countries";
import { STUNDEN_GRUENDE } from "@/lib/absence";
import { fmtFullName, todayIso } from "@/lib/format";
import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr";
import type { ContractType, Database, EmploymentType, GenderType } from "@/lib/supabase/types";
import { EMERGENCY_RELATIONS, type ContractType, type Database, type EmploymentType, type GenderType } from "@/lib/supabase/types";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
@@ -52,15 +53,28 @@ export function DatenAendernPanel({
const svNummerOk =
!svNummer.trim() || !requiresAustrianSvnr(locationCountry) || isValidSvnr(svNummer, birthDate || null);
const [nationality, setNationality] = useState(employee.nationality);
const [hatTitel, setHatTitel] = useState(employee.hat_aufenthaltstitel ?? false);
const [titelBis, setTitelBis] = useState(employee.aufenthaltstitel_bis ?? "");
// Die Staatsbürgerschaft entscheidet, ob die Frage überhaupt gestellt wird.
const titelNoetig = brauchtAufenthaltstitel(nationality);
const [address, setAddress] = useState(employee.address ?? "");
const [postalCode, setPostalCode] = useState(employee.postal_code ?? "");
const [city, setCity] = useState(employee.city ?? "");
const [addressCountry, setAddressCountry] = useState(employee.address_country ?? "Österreich");
const [email, setEmail] = useState(employee.email);
const [email, setEmail] = useState(employee.email ?? "");
const [phone, setPhone] = useState(employee.phone ?? "");
const [notfallName, setNotfallName] = useState(employee.emergency_contact_name ?? "");
const [notfallTelefon, setNotfallTelefon] = useState(employee.emergency_contact_phone ?? "");
const [notfallVerhaeltnis, setNotfallVerhaeltnis] = useState(employee.emergency_contact_relation ?? "");
const [employmentType, setEmploymentType] = useState<EmploymentType>(employee.employment_type);
const [weeklyHours, setWeeklyHours] = useState(String(employee.weekly_hours));
// Der Grund wird erst gefragt, wenn sich die Stunden tatsächlich ändern —
// sonst stünde bei jeder Adressänderung eine Frage im Weg, die niemand
// gestellt hat.
const [stundenGrund, setStundenGrund] = useState<string>(STUNDEN_GRUENDE[0]);
const stundenGeaendert = Number(weeklyHours) !== Number(employee.weekly_hours);
const [teilzeitBis, setTeilzeitBis] = useState(employee.teilzeit_bis ?? "");
const [contractType, setContractType] = useState<ContractType>(employee.contract_type);
const [contractEndDate, setContractEndDate] = useState(employee.contract_end_date ?? "");
@@ -70,6 +84,9 @@ export function DatenAendernPanel({
workDays: employee.work_days ?? ["Mo", "Di", "Mi", "Do", "Fr"],
isBetriebsrat: employee.is_betriebsrat ?? false,
hasDienstwagen: employee.has_dienstwagen ?? false,
hasKuendigungsschutz: employee.has_kuendigungsschutz ?? false,
kuendigungsschutzBis: employee.kuendigungsschutz_bis ?? "",
dienstwagenArt: employee.dienstwagen_art ?? "Verbrenner",
isLateraleFuehrung: employee.is_laterale_fuehrung ?? false,
isCLevel: employee.is_c_level ?? false,
});
@@ -99,6 +116,12 @@ export function DatenAendernPanel({
showToast("Bitte ein Wirksam-ab-Datum angeben.", "error");
return;
}
// Name und Nummer gehören zusammen — die Datenbank weist eines ohne das
// andere ab, und die Meldung dort erklärt es nicht.
if (Boolean(notfallName.trim()) !== Boolean(notfallTelefon.trim())) {
showToast("Beim Notfallkontakt braucht es Name und Telefonnummer — oder beides leer.", "error");
return;
}
setPending(true);
const result = await changeEmployeeData({
employee_id: employee.id,
@@ -112,12 +135,21 @@ export function DatenAendernPanel({
birth_date: birthDate,
sv_nummer: svNummer,
nationality,
// Wechselt die Staatsbürgerschaft in den Freizügigkeitsraum, fällt
// der Titel weg — sonst bliebe er als Rest an einer Person hängen,
// die ihn nicht mehr braucht. Die Datenbank prüft diese Kopplung
// bewusst nicht (siehe Migration), also gehört sie hierher.
hat_aufenthaltstitel: titelNoetig ? hatTitel : false,
aufenthaltstitel_bis: titelNoetig && hatTitel ? titelBis : "",
address,
postal_code: postalCode,
city,
address_country: addressCountry,
email,
phone,
emergency_contact_name: notfallName.trim(),
emergency_contact_phone: notfallTelefon.trim(),
emergency_contact_relation: notfallVerhaeltnis.trim(),
},
contract: {
employment_type: employmentType,
@@ -131,6 +163,19 @@ export function DatenAendernPanel({
work_days: role.workDays,
is_betriebsrat: role.isBetriebsrat,
has_dienstwagen: role.hasDienstwagen,
dienstwagen_art: role.hasDienstwagen ? role.dienstwagenArt : "",
has_kuendigungsschutz: role.hasKuendigungsschutz,
kuendigungsschutz_bis: role.hasKuendigungsschutz ? role.kuendigungsschutzBis : "",
// Die Teilzeitvariante wird nur mitgeschickt, wenn sich die Stunden
// tatsächlich ändern. Sonst schriebe jede Adressänderung den Wert
// erneut — und setzte ihn bei „Vertragliche Stundenänderung" sogar
// zurück, obwohl niemand die Stunden angefasst hat.
...(stundenGeaendert
? {
teilzeit_art: stundenGrund === "Vertragliche Stundenänderung" ? "" : stundenGrund,
teilzeit_bis: stundenGrund === "Vertragliche Stundenänderung" ? "" : teilzeitBis,
}
: {}),
is_laterale_fuehrung: role.isLateraleFuehrung,
is_c_level: role.isCLevel,
},
@@ -197,6 +242,34 @@ export function DatenAendernPanel({
<CountryPicker {...p} value={nationality} onChange={setNationality} countries={UN_COUNTRIES} placeholder="Staatsbürgerschaft suchen…" />
)}
</Field>
{/* Nur wo er verlangt ist. Bei Freizügigkeit — EU, EWR, Schweiz —
wäre die Frage gegenstandslos, und ein „Nein" im Formular
sähe aus wie eine Auskunft. */}
{titelNoetig && (
<>
<SelectField
label="Aufenthaltstitel"
dense
value={hatTitel ? "ja" : "nein"}
onChange={(v) => setHatTitel(v === "ja")}
options={[
{ value: "nein", label: "Nein" },
{ value: "ja", label: "Ja" },
]}
hint="Bei Staatsbürgerschaften ausserhalb von EU, EWR und Schweiz."
/>
{hatTitel && (
<TextField
label="Aufenthaltstitel gültig bis"
dense
type="date"
value={titelBis}
onChange={setTitelBis}
hint="Optional. Leer lassen, wenn unbefristet."
/>
)}
</>
)}
<TextField label="Adresse (Straße und Hausnummer)" dense value={address} onChange={setAddress} />
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] gap-3">
<TextField label="Postleitzahl" dense inputMode="numeric" value={postalCode} onChange={setPostalCode} />
@@ -208,6 +281,24 @@ export function DatenAendernPanel({
<TextField label="E-Mail" dense type="email" value={email} onChange={setEmail} />
<TextField label="Telefon" dense type="tel" value={phone} onChange={setPhone} />
</div>
{/* Eigener Block: im Ernstfall greift jemand danach, und dann darf
er nicht zwischen den Adressfeldern der Person untergehen. */}
<fieldset className="mt-4 rounded-md border border-border-subtle p-3">
<legend className="px-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Notfallkontakt</legend>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<TextField label="Name" dense value={notfallName} onChange={setNotfallName} />
<TextField label="Telefon" dense type="tel" value={notfallTelefon} onChange={setNotfallTelefon} />
<SelectField
label="Verhältnis"
dense
value={notfallVerhaeltnis}
onChange={setNotfallVerhaeltnis}
placeholder="Bitte wählen…"
options={EMERGENCY_RELATIONS.map((r) => ({ value: r, label: r }))}
/>
</div>
</fieldset>
</div>
<div>
@@ -232,6 +323,26 @@ export function DatenAendernPanel({
disabled={employmentType === "Vollzeit"}
onChange={setWeeklyHours}
/>
{stundenGeaendert && (
<SelectField
label="Grund der Stundenänderung"
dense
value={stundenGrund}
onChange={setStundenGrund}
options={STUNDEN_GRUENDE.map((g) => ({ value: g, label: g }))}
hint="Steht danach am Profil und lässt sich auswerten."
/>
)}
{stundenGeaendert && stundenGrund !== "Vertragliche Stundenänderung" && (
<TextField
label="Teilzeit bis"
dense
type="date"
value={teilzeitBis}
onChange={setTeilzeitBis}
hint="Optional. Leer lassen, solange das Ende nicht feststeht."
/>
)}
<SelectField
label="Vertragsart"
dense

View File

@@ -8,8 +8,8 @@ import { SelectField, TextField, TextareaField } from "@/components/ui/Field";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast";
import { ABSENCE_TYPES, absenceLabel } from "@/lib/absence";
import { fmtDate } from "@/lib/format";
import { ABSENCE_TYPES, absenceLabel, RUECKKEHR_GRUENDE } from "@/lib/absence";
import { fmtDate, fmtName } from "@/lib/format";
import type { Database } from "@/lib/supabase/types";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
@@ -33,7 +33,18 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
const [returnDate, setReturnDate] = useState("");
const [employmentMode, setEmploymentMode] = useState<EmploymentMode>("unverändert");
const [weeklyHours, setWeeklyHours] = useState("20");
// Warum weniger Stunden: Wiedereingliederungs- oder Elternteilzeit. Beide
// beginnen typischerweise genau dann, wenn die Abwesenheit endet — deshalb
// steht die Frage hier und nicht in einem zweiten Vorgang danach.
const [reduktionsgrund, setReduktionsgrund] = useState<string>("");
const [teilzeitBis, setTeilzeitBis] = useState("");
// Die Stunden, die vor der Abwesenheit galten — mit Komma, wie man sie
// hierzulande schreibt.
const stundenText = String(employee.weekly_hours).replace(".", ",");
// Absichtlich leer statt vorbelegt: eine Zahl, die schon dasteht, wird
// bestätigt statt erfasst. Die reduzierten Stunden stehen in einer
// Vereinbarung, und die muss jemand ablesen.
const [weeklyHours, setWeeklyHours] = useState("");
const diffDays =
isOnKarenz && employee.karenz_return_date && newReturnDate
@@ -89,9 +100,15 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
showToast("Bitte Rückkehrdatum angeben.", "error");
return;
}
if (employmentMode === "Teilzeit" && (Number(weeklyHours) <= 0 || Number(weeklyHours) >= 38.5)) {
showToast("Wochenstunden müssen zwischen 0 und 38,5 liegen.", "error");
return;
if (employmentMode === "Teilzeit") {
if (!weeklyHours.trim()) {
showToast("Bitte die reduzierten Wochenstunden erfassen.", "error");
return;
}
if (Number(weeklyHours) <= 0 || Number(weeklyHours) >= 38.5) {
showToast("Wochenstunden müssen zwischen 0 und 38,5 liegen.", "error");
return;
}
}
setPending(true);
const result = await recordKarenzReturn({
@@ -99,6 +116,8 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
return_date: returnDate,
employment_mode: employmentMode,
weekly_hours: employmentMode === "Teilzeit" ? Number(weeklyHours) : undefined,
reduction_reason: employmentMode === "Teilzeit" ? reduktionsgrund || undefined : undefined,
teilzeit_bis: employmentMode === "Teilzeit" && reduktionsgrund ? teilzeitBis || undefined : undefined,
});
setPending(false);
if (result.success) {
@@ -115,7 +134,7 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
open={open}
onClose={onClose}
title={isOnKarenz ? "Langzeitabwesenheit verwalten" : "Langzeitabwesenheit erfassen"}
subtitle={`${employee.first_name} ${employee.last_name} · ${employee.job_title}`}
subtitle={`${fmtName(employee.first_name, employee.last_name)} · ${employee.job_title}`}
footer={
<>
<Button variant="ghost" onClick={onClose}>
@@ -191,22 +210,46 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
value={employmentMode}
onChange={(v) => setEmploymentMode(v as EmploymentMode)}
options={[
{ value: "unverändert", label: "unverändert" },
{ value: "Vollzeit", label: "Vollzeit (38,5h)" },
{ value: "Teilzeit", label: "Teilzeit-Elternteilzeit" },
// Die Stunden, die zuletzt gearbeitet wurden, stehen in der
// Beschriftung. „unverändert" allein zwang dazu, in der
// Akte nachzusehen, worauf man sich da einlässt.
{ value: "unverändert", label: `Wie vor Abwesenheit (${stundenText} h)` },
{ value: "Teilzeit", label: "Reduziert" },
]}
/>
{employmentMode === "Teilzeit" && (
<TextField
label="Wochenstunden"
required
type="number"
step="0.5"
max="38"
value={weeklyHours}
onChange={setWeeklyHours}
hint="Muss unter 38,5 liegen."
/>
<>
<TextField
label="Reduzierte Wochenstunden"
required
type="number"
step="0.5"
max="38"
value={weeklyHours}
onChange={setWeeklyHours}
placeholder={`weniger als ${stundenText}`}
hint="Muss unter 38,5 liegen."
/>
<SelectField
label="Grund der Reduktion"
value={reduktionsgrund}
onChange={setReduktionsgrund}
options={[
{ value: "", label: "Ohne besonderen Grund" },
...RUECKKEHR_GRUENDE.map((g) => ({ value: g, label: g })),
]}
hint="Steht danach am Profil und lässt sich auswerten."
/>
{reduktionsgrund && (
<TextField
label="Teilzeit bis"
type="date"
value={teilzeitBis}
onChange={setTeilzeitBis}
hint="Optional. Leer lassen, solange das Ende nicht feststeht."
/>
)}
</>
)}
</>
)}

View File

@@ -8,6 +8,7 @@ import { SelectField, TextField } from "@/components/ui/Field";
import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast";
import type { Database, PaygradeType } from "@/lib/supabase/types";
import { fmtName } from "@/lib/format";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
@@ -55,7 +56,7 @@ export function PromotePanel({ open, onClose, employee }: { open: boolean; onClo
open={open}
onClose={onClose}
title="Beförderung"
subtitle={`${employee.first_name} ${employee.last_name} · ${employee.job_title}`}
subtitle={`${fmtName(employee.first_name, employee.last_name)} · ${employee.job_title}`}
footer={
<>
<Button variant="ghost" onClick={onClose}>

View File

@@ -1,30 +1,77 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useMemo, useState } from "react";
import { rehireEmployee } from "@/actions/employees";
import { Button } from "@/components/ui/Button";
import { TextField } from "@/components/ui/Field";
import { SelectField, TextField } from "@/components/ui/Field";
import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast";
import { fmtDate } from "@/lib/format";
import { fmtDate, fmtName } from "@/lib/format";
import type { OpenPositionResolved } from "@/lib/positions";
import type { Database } from "@/lib/supabase/types";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
export function RehirePanel({ open, onClose, employee }: { open: boolean; onClose: () => void; employee: EmployeeRow }) {
// Eine Wiedereinstellung braucht eine Planstelle — genau wie eine
// Neueinstellung.
//
// Die alte Stelle ist dafür kein Ersatz: sie kann inzwischen besetzt,
// ausgelaufen oder ganz entfallen sein. `rehire_employee` verlangte die
// Angabe deshalb schon immer und wies den Aufruf sonst ab; nur schickte das
// Formular sie nie mit, sodass jede Wiedereinstellung an einer Meldung
// scheiterte, die sich im Dialog gar nicht beheben liess.
export function RehirePanel({
open,
onClose,
employee,
openPositions,
}: {
open: boolean;
onClose: () => void;
employee: EmployeeRow;
openPositions: OpenPositionResolved[];
}) {
const { showToast } = useToast();
const router = useRouter();
const [rehireDate, setRehireDate] = useState("");
const [positionId, setPositionId] = 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);
// Die Datenbank weist eine Besetzung ausserhalb der Gültigkeit ab. Das hier
// nimmt die Meldung vorweg, solange sie noch etwas nützt — im Dialog, mit
// beiden Daten sichtbar.
const ausserhalb =
selected && rehireDate
? rehireDate < selected.valid_from
? `Diese Planstelle gilt erst ab ${fmtDate(selected.valid_from)}.`
: selected.valid_to && rehireDate >= selected.valid_to
? `Diese Planstelle gilt nur bis ${fmtDate(selected.valid_to)}.`
: null
: null;
async function handleSubmit() {
if (!rehireDate) {
showToast("Bitte ein Wiedereintrittsdatum angeben.", "error");
if (!rehireDate || !positionId) {
showToast("Wiedereintrittsdatum und Planstelle sind Pflicht.", "error");
return;
}
setPending(true);
const result = await rehireEmployee({ employee_id: employee.id, rehire_date: rehireDate });
const result = await rehireEmployee({
employee_id: employee.id,
rehire_date: rehireDate,
position_id: positionId,
});
setPending(false);
if (result.success) {
showToast(`${employee.first_name} ${employee.last_name} wurde wiedereingestellt.`);
@@ -40,13 +87,13 @@ export function RehirePanel({ open, onClose, employee }: { open: boolean; onClos
open={open}
onClose={onClose}
title="Wiedereinstellung"
subtitle={`${employee.first_name} ${employee.last_name}`}
subtitle={fmtName(employee.first_name, employee.last_name)}
footer={
<>
<Button variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<Button onClick={handleSubmit} pending={pending}>
<Button onClick={handleSubmit} pending={pending} disabled={Boolean(ausserhalb)}>
Wiedereinstellen
</Button>
</>
@@ -58,7 +105,42 @@ export function RehirePanel({ open, onClose, employee }: { open: boolean; onClos
<p className="mt-1 text-ink">{employee.job_title}</p>
<p className="text-xs text-ink-muted">Ausgetreten am {fmtDate(employee.exit_date)}</p>
</div>
<TextField label="Wiedereintritt am" required type="date" value={rehireDate} onChange={setRehireDate} />
{options.length === 0 ? (
<p className="rounded border border-danger-text/20 bg-danger-bg px-3 py-2 text-sm text-danger-text">
Es ist derzeit keine Planstelle frei. Ohne eine solche ist keine Wiedereinstellung möglich — zuerst eine
Planstelle anlegen oder eine bestehende freimachen.
</p>
) : (
<>
<SelectField
label="Planstelle"
required
value={positionId}
onChange={setPositionId}
placeholder="Bitte wählen…"
options={options}
/>
{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.future ? ` · gültig ab ${fmtDate(selected.valid_from)}` : ""}
{selected.valid_to ? ` · endet am ${fmtDate(selected.valid_to)}` : ""}
</div>
</div>
)}
{ausserhalb && (
<p role="alert" className="rounded border border-danger-text/20 bg-danger-bg px-3 py-2 text-sm text-danger-text">
{ausserhalb} Wiedereintritt und Gültigkeit müssen zusammenpassen.
</p>
)}
</>
)}
</div>
</SlideOver>
);

View File

@@ -8,10 +8,22 @@ import { SelectField, TextField, TextareaField } from "@/components/ui/Field";
import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast";
import type { Database } from "@/lib/supabase/types";
import { fmtDate, fmtName } from "@/lib/format";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
const EXIT_REASONS = ["Einvernehmliche Auflösung", "Kündigung AN", "Kündigung AG", "Befristungsablauf", "Pensionierung", "Entlassung"];
// „No Show" steht am Ende und getrennt: es ist kein Austritt im gewohnten
// Sinn, sondern der Fall, dass jemand nie angetreten ist.
const NO_SHOW = "No Show";
const EXIT_REASONS = [
"Einvernehmliche Auflösung",
"Kündigung AN",
"Kündigung AG",
"Befristungsablauf",
"Pensionierung",
"Entlassung",
NO_SHOW,
];
const CHECKLIST_ITEMS = ["IT-Zugänge deaktivieren", "Hardware retournieren", "ÖGK-Abmeldung", "Endabrechnung & Dienstzeugnis"];
type TerminatePanelProps = {
@@ -25,21 +37,40 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
const { showToast } = useToast();
const router = useRouter();
const [exitDate, setExitDate] = useState("");
const [reason, setReason] = useState(EXIT_REASONS[0]);
// Wer noch gar nicht angefangen hat, tritt fast nie aus einem anderen Grund
// aus. Die Vorbelegung nimmt den wahrscheinlichen Fall vorweg, ohne die
// übrigen zu verstellen.
const [reason, setReason] = useState(employee.status === "Geplant" ? NO_SHOW : EXIT_REASONS[0]);
const [note, setNote] = useState("");
const [checked, setChecked] = useState<boolean[]>(CHECKLIST_ITEMS.map(() => false));
const [pending, setPending] = useState(false);
// Bei einem Nichtantritt ist das Datum nicht frei wählbar: es ist der Tag,
// an dem die Person hätte anfangen sollen. Die Datenbank setzt es ohnehin
// so; hier steht es sichtbar, damit niemand ein Datum eintippt, das dann
// stillschweigend übergangen wird.
const istNoShow = reason === NO_SHOW;
const wirksamesDatum = istNoShow ? employee.entry_date : exitDate;
async function handleSubmit() {
if (!exitDate) {
if (!wirksamesDatum) {
showToast("Bitte ein Austrittsdatum angeben.", "error");
return;
}
setPending(true);
const result = await terminateEmployee({ employee_id: employee.id, exit_date: exitDate, exit_reason: reason, note });
const result = await terminateEmployee({
employee_id: employee.id,
exit_date: wirksamesDatum,
exit_reason: reason,
note,
});
setPending(false);
if (result.success) {
showToast(`Austritt für ${employee.first_name} ${employee.last_name} erfasst.`);
showToast(
istNoShow
? `${employee.first_name} ${employee.last_name} ist nicht angetreten.`
: `Austritt für ${employee.first_name} ${employee.last_name} erfasst.`
);
router.refresh();
onClose();
} else {
@@ -51,8 +82,8 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
<SlideOver
open={open}
onClose={onClose}
title="Austritt"
subtitle={`${employee.first_name} ${employee.last_name} · ${employee.job_title}`}
title={istNoShow ? "Nicht angetreten" : "Austritt"}
subtitle={`${fmtName(employee.first_name, employee.last_name)} · ${employee.job_title}`}
footer={
<>
<Button variant="ghost" onClick={onClose}>
@@ -65,20 +96,54 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
}
>
<div className="flex flex-col gap-4">
{/* Zuerst, und nicht zu übersehen: bei besonderem Kündigungsschutz
gelten eigene Regeln, bevor beendet werden darf. Die Anwendung
entscheidet das nicht — sie darf es aber auch nicht verschweigen,
und im Vertragsblatt nachzusehen ist genau der Schritt, den man
unter Zeitdruck auslässt. */}
{employee.has_kuendigungsschutz && (
<div className="rounded border border-danger-solid bg-danger-bg px-3 py-2 text-sm font-semibold text-danger-text">
Achtung: besonderer Kündigungsschutz
{employee.kuendigungsschutz_bis ? ` bis ${fmtDate(employee.kuendigungsschutz_bis)}` : " (Ende nicht erfasst)"}.
<span className="block font-normal">
Vor einer Beendigung ist zu prüfen, ob sie zulässig ist — je nach Grund braucht es eine Zustimmung des
Betriebsrats oder des Gerichts.
</span>
</div>
)}
{directReportCount > 0 && (
<div className="rounded bg-warning-bg px-3 py-2 text-sm text-warning-text">
{directReportCount} direkte Berichte werden automatisch der nächsthöheren Führungskraft zugeordnet.
</div>
)}
<TextField label="Austrittsdatum" required type="date" value={exitDate} onChange={setExitDate} />
<SelectField
label="Beendigungsart"
value={reason}
onChange={setReason}
options={EXIT_REASONS.map((r) => ({ value: r, label: r }))}
options={EXIT_REASONS.map((r) => ({ value: r, label: r === NO_SHOW ? "No Show (nicht angetreten)" : r }))}
/>
<TextField
label={istNoShow ? "Wirksam am (Eintrittstag)" : "Austrittsdatum"}
required
type="date"
value={wirksamesDatum}
disabled={istNoShow}
onChange={setExitDate}
hint={
istNoShow
? "Wer nie angetreten ist, scheidet am Tag seines Eintritts aus. Damit gibt es keinen Tag, an dem die Person als beschäftigt zählt."
: undefined
}
/>
<TextareaField label="Anmerkung" rows={3} value={note} onChange={setNote} />
<fieldset>
{istNoShow && (
<p className="rounded bg-surface px-3 py-2 text-sm text-ink-body">
Die Planstelle wird wieder frei und gilt als nie besetzt. In allen Auswertungen zählt die Person an keinem
Stichtag als beschäftigt.
</p>
)}
{/* Bei einem Nichtantritt wurde nichts ausgegeben, was zurückkäme. */}
<fieldset hidden={istNoShow}>
<legend className="mb-2 text-sm font-semibold text-ink">Offboarding-Checkliste</legend>
<div className="flex flex-col gap-2">
{CHECKLIST_ITEMS.map((item, i) => (

View File

@@ -7,48 +7,52 @@ 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";
import { fmtName } from "@/lib/format";
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;
divisions: Division[];
departments: Department[];
teams: Team[];
currentTeamId: string | null;
openPositions: OpenPositionResolved[];
};
export function TransferPanel({ open, onClose, employee, divisions, departments, teams, currentTeamId }: TransferPanelProps) {
// 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) {
const { showToast } = useToast();
const router = useRouter();
const [effectiveDate, setEffectiveDate] = useState("");
const [divisionId, setDivisionId] = useState(employee.division_id);
const [teamId, setTeamId] = useState(currentTeamId ?? "");
const [newTitle, setNewTitle] = useState("");
const [positionId, setPositionId] = useState("");
const [pending, setPending] = useState(false);
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]);
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);
async function handleSubmit() {
if (!effectiveDate || !teamId) {
showToast("Bitte Datum und Zielteam angeben.", "error");
if (!effectiveDate || !positionId) {
showToast("Bitte Datum und Zielplanstelle angeben.", "error");
return;
}
setPending(true);
const result = await transferEmployee({
employee_id: employee.id,
effective_date: effectiveDate,
new_team_id: teamId,
new_title: newTitle || undefined,
target_position_id: positionId,
});
setPending(false);
if (result.success) {
@@ -65,13 +69,13 @@ export function TransferPanel({ open, onClose, employee, divisions, departments,
open={open}
onClose={onClose}
title="Versetzung"
subtitle={`${employee.first_name} ${employee.last_name} · ${employee.job_title}`}
subtitle={`${fmtName(employee.first_name, employee.last_name)} · ${employee.job_title}`}
footer={
<>
<Button variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<Button onClick={handleSubmit} pending={pending}>
<Button onClick={handleSubmit} pending={pending} disabled={options.length === 0}>
Versetzen
</Button>
</>
@@ -79,31 +83,33 @@ export function TransferPanel({ open, onClose, employee, divisions, departments,
>
<div className="flex flex-col gap-4">
<TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
<SelectField
label="Neuer Bereich"
required
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."
/>
{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"
required
value={positionId}
onChange={setPositionId}
placeholder="Bitte wählen…"
options={options}
/>
{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

@@ -1,35 +1,208 @@
"use client";
import { useState } from "react";
import { HistorieBearbeiten } from "@/components/employees/HistorieBearbeiten";
import { HistorieLoeschen } from "@/components/employees/HistorieLoeschen";
import { AenderungsTabelle } from "@/components/ui/AenderungsTabelle";
import { TextField } from "@/components/ui/Field";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { actionBadgeStyle } from "@/lib/colors";
import { fmtDate, todayIso } from "@/lib/format";
import { darfBearbeitetWerden, darfKorrigiertWerden, loeschVorschau } from "@/lib/history";
import type { Database } from "@/lib/supabase/types";
type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"];
export function HistorieTab({ history }: { history: HistoryRow[] }) {
// Die Geschichte einer Person — aufklappbar bis auf die Werte, filterbar, und
// dort, wo ein Eintrag irrtümlich entstanden ist, auch zurücknehmbar.
//
// Vorher stand hier nur „Geänderte Felder: Adresse, Ort". Damit liess sich
// zwar sehen, *dass* jemand die Anschrift angefasst hat, aber nicht, was
// vorher dort stand. Die alte Adresse lag allein im Protokoll, und das ist
// eine andere Seite, nach Zeitpunkt sortiert statt nach Person — man hätte
// also erst wissen müssen, wonach man sucht.
//
// Aufgeklappt wird mit <details>, nicht mit einem Zustand im Browser: die
// Werte stehen dann schon in der Seite, sind durchsuchbar (Strg+F) und im
// Ausdruck sichtbar, und es braucht kein Skript dafür.
//
// Die Knöpfe erscheinen nur an Einträgen, wo sie etwas bewirken können. An
// allen anderen steht stattdessen der Grund — leise, aber lesbar. Ein Knopf,
// der erst nach dem Klick sagt „geht nicht", wäre eine Falle; ein fehlender
// Knopf ohne Erklärung wäre ein Rätsel.
type Sicht = "alle" | "anstehend" | "erledigt";
const SICHTEN: { value: Sicht; label: string }[] = [
{ value: "alle", label: "Alle" },
{ value: "anstehend", label: "Anstehend" },
{ value: "erledigt", label: "Gelaufen" },
];
export function HistorieTab({ history, employeeId }: { history: HistoryRow[]; employeeId: string }) {
const today = todayIso();
const [sicht, setSicht] = useState<Sicht>("alle");
const [von, setVon] = useState("");
const [bis, setBis] = useState("");
// Ereignistypen, die in dieser Akte überhaupt vorkommen — eine Auswahl aus
// elf Typen, von denen zehn nie auftauchen, wäre nur Suchaufwand.
const vorhandeneTypen = [...new Set(history.map((h) => h.event_type))];
const [typen, setTypen] = useState<Set<string>>(new Set());
const gefiltert = history.filter((h) => {
if (sicht === "anstehend" && h.event_date <= today) return false;
if (sicht === "erledigt" && h.event_date > today) return false;
if (von && h.event_date < von) return false;
if (bis && h.event_date > bis) return false;
if (typen.size > 0 && !typen.has(h.event_type)) return false;
return true;
});
const anstehend = history.filter((h) => h.event_date > today).length;
const eingeschraenkt = sicht !== "alle" || Boolean(von) || Boolean(bis) || typen.size > 0;
function typUmschalten(typ: string) {
setTypen((prev) => {
const next = new Set(prev);
if (next.has(typ)) next.delete(typ);
else next.add(typ);
return next;
});
}
function zuruecksetzen() {
setSicht("alle");
setVon("");
setBis("");
setTypen(new Set());
}
if (history.length === 0) {
return <p className="text-sm text-ink-muted">Keine Historieneinträge vorhanden.</p>;
}
return (
<ul className="flex flex-col divide-y divide-border">
{history.map((h) => {
const isFuture = h.event_date > today;
return (
<li key={h.id} className="py-3">
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${actionBadgeStyle(h.event_type)}`}>{h.event_type}</span>
<span className="text-sm text-ink-muted">{fmtDate(h.event_date)}</span>
{isFuture && (
<span className="rounded-full bg-warning-bg px-2 py-0.5 text-xs font-semibold text-warning-text">
⏱ zukünftig – wirksam ab {fmtDate(h.event_date)}
</span>
)}
</div>
<p className="mt-1 text-sm text-ink">{h.description}</p>
</li>
);
})}
</ul>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 rounded border border-border bg-surface px-3 py-2.5">
<div className="flex flex-wrap items-center gap-3">
<SegmentedControl<Sicht> value={sicht} onChange={setSicht} options={SICHTEN} />
{/* Was noch kommt, ist der häufigste Grund, hier hereinzusehen —
deshalb steht die Zahl da, auch ohne dass jemand filtert. */}
{anstehend > 0 && (
<span className="rounded-full bg-warning-bg px-2 py-0.5 text-xs font-semibold text-warning-text">
{anstehend} anstehend
</span>
)}
<div className="ml-auto flex items-end gap-2">
<TextField label="Von" dense type="date" value={von} onChange={setVon} className="w-40" />
<TextField label="Bis" dense type="date" value={bis} onChange={setBis} className="w-40" />
</div>
</div>
{vorhandeneTypen.length > 1 && (
<div className="flex flex-wrap items-center gap-1.5">
{vorhandeneTypen.map((typ) => (
<button
key={typ}
type="button"
onClick={() => typUmschalten(typ)}
aria-pressed={typen.has(typ)}
className={`rounded-full px-2 py-0.5 text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
typen.has(typ) ? actionBadgeStyle(typ) : "bg-white text-ink-muted hover:text-ink"
}`}
>
{typ}
</button>
))}
{eingeschraenkt && (
<button
type="button"
onClick={zuruecksetzen}
className="ml-auto rounded text-xs text-ink-muted hover:text-ink hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
Filter zurücksetzen
</button>
)}
</div>
)}
</div>
{gefiltert.length === 0 ? (
<p className="text-sm text-ink-muted">
Kein Eintrag passt zu dieser Auswahl. {history.length} {history.length === 1 ? "Eintrag" : "Einträge"} sind
vorhanden.
</p>
) : (
<ul className="flex flex-col divide-y divide-border">
{gefiltert.map((h) => {
const isFuture = h.event_date > today;
const changes = h.changes ?? [];
// Die ganze Historie mitgeben, nicht die gefilterte: ob eine
// Abwesenheit gelöscht werden darf, hängt an einer späteren
// Rückkehr — auch wenn die gerade ausgeblendet ist.
const loeschbar = darfKorrigiertWerden(h, today, history);
const bearbeitbar = darfBearbeitetWerden(h, today, history);
return (
<li key={h.id} className="py-3">
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${actionBadgeStyle(h.event_type)}`}>
{h.event_type}
</span>
<span className="text-sm text-ink-muted">{fmtDate(h.event_date)}</span>
{isFuture && (
<span className="rounded-full bg-warning-bg px-2 py-0.5 text-xs font-semibold text-warning-text">
⏱ zukünftig – wirksam ab {fmtDate(h.event_date)}
</span>
)}
<span className="ml-auto flex items-center gap-0.5">
{bearbeitbar.erlaubt && (
<HistorieBearbeiten
historyId={h.id}
employeeId={employeeId}
bezeichnung={h.event_type}
datum={h.event_date}
changes={changes}
istZukunft={isFuture}
heute={today}
nurDatum={h.event_type === "Eintritt"}
/>
)}
{loeschbar.erlaubt && (
<HistorieLoeschen
historyId={h.id}
employeeId={employeeId}
bezeichnung={h.event_type}
datum={h.event_date}
vorschau={loeschVorschau(h, history)}
istZukunft={isFuture}
/>
)}
</span>
</div>
<p className="mt-1 text-sm text-ink">{h.description}</p>
{changes.length > 0 && (
<details className="group mt-1.5">
<summary
className="inline-flex cursor-pointer list-none items-center gap-1 rounded text-xs font-semibold text-brand-700
hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
<span className="transition-transform group-open:rotate-90" aria-hidden="true">
›
</span>
{changes.length} {changes.length === 1 ? "Feld" : "Felder"} im Detail
</summary>
<div className="mt-2 rounded border border-border bg-surface px-3 py-2">
<AenderungsTabelle changes={changes} />
</div>
{!loeschbar.erlaubt && <p className="mt-1.5 text-xs text-ink-muted">{loeschbar.grund}</p>}
</details>
)}
</li>
);
})}
</ul>
)}
</div>
);
}

View File

@@ -2,23 +2,50 @@ import { Network } from "lucide-react";
import Link from "next/link";
import { Avatar } from "@/components/ui/Avatar";
import { LINK_BUTTON_CLASS } from "@/components/ui/Button";
import { fmtName } from "@/lib/format";
type MiniEmployee = { id: string; first_name: string; last_name: string; job_title: string; status?: string };
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;
/**
* Die Kostenstelle der Planstelle, auf der die Person heute sitzt — nicht
* ihre eigene: sie kontiert dorthin, wo ihr Sitz kontiert ist. Fehlt sie,
* hat die Person keine laufende Besetzung (geplanter Eintritt, Austritt).
*/
kostenstelle: { code: string; name: string } | null;
};
export function OrganisationTab({ employeeId, manager, directReports, breadcrumb }: OrganisationTabProps) {
export function OrganisationTab({
employeeId,
manager,
formalManager,
directReports,
breadcrumb,
kostenstelle,
}: OrganisationTabProps) {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ink-muted">Organisationseinheit</h3>
<p className="mt-1 text-sm text-ink">{breadcrumb}</p>
<h3 className="mt-4 text-xs font-semibold uppercase tracking-wide text-ink-muted">Kostenstelle</h3>
<p className="mt-1 text-sm text-ink">
{kostenstelle ? (
<>
<span className="font-semibold tabular-nums">{kostenstelle.code}</span>
<span className="text-ink-body"> · {kostenstelle.name}</span>
</>
) : (
<span className="text-ink-muted">Keine laufende Planstellenbesetzung</span>
)}
</p>
</div>
{/* ?focus= drives the same highlight/auto-expand path the org chart
search already uses, so the person is unfolded and centred on
@@ -30,13 +57,21 @@ export function OrganisationTab({ employeeId, manager, directReports, breadcrumb
</div>
<div>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Führungskraft</h3>
<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 {fmtName(formalManager.first_name, formalManager.last_name)}; während der Abwesenheit übernimmt die nächste
besetzte Ebene.
</p>
)}
{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} />
<div>
<div className="text-sm font-semibold text-ink">
{manager.first_name} {manager.last_name}
{fmtName(manager.first_name, manager.last_name)}
</div>
<div className="text-xs text-ink-muted">{manager.job_title}</div>
</div>
@@ -55,7 +90,7 @@ export function OrganisationTab({ employeeId, manager, directReports, breadcrumb
<Avatar firstName={r.first_name} lastName={r.last_name} />
<div>
<div className="text-sm font-semibold text-ink">
{r.first_name} {r.last_name}
{fmtName(r.first_name, r.last_name)}
</div>
<div className="text-xs text-ink-muted">{r.job_title}</div>
</div>

View File

@@ -1,4 +1,5 @@
import { AngehoerigeSection } from "@/components/employees/AngehoerigeSection";
import { brauchtAufenthaltstitel } from "@/lib/countries";
import { fmtAge, fmtDate } from "@/lib/format";
import type { Database } from "@/lib/supabase/types";
@@ -6,27 +7,56 @@ type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
type Location = Database["public"]["Tables"]["locations"]["Row"];
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
function formatAddress(employee: EmployeeRow): string {
const cityLine = [employee.postal_code, employee.city].filter(Boolean).join(" ");
return [employee.address, cityLine].filter(Boolean).join(", ") || "–";
}
export function StammdatenTab({ employee, location, dependents }: { employee: EmployeeRow; location?: Location; dependents: Dependent[] }) {
// Defensive against a DB that hasn't received the title_prefix/title_suffix
// migration yet — select("*") simply omits unknown columns, so these can
// be undefined rather than the empty array the column default implies.
const titles = [...(employee.title_prefix ?? []), ...(employee.title_suffix ?? [])];
const prefixe = employee.title_prefix ?? [];
const suffixe = employee.title_suffix ?? [];
// Feld für Feld dieselbe Liste wie im Abschnitt „Person" von „Daten
// ändern", in derselben Reihenfolge.
//
// Vorher fasste die Anzeige zusammen: Titel in einer Zeile, Adresse mit
// Postleitzahl und Ort verschmolzen, Vor- und Nachname gar nicht — die
// standen nur in der Kopfzeile. Wer eine Angabe prüfen wollte, musste den
// Änderungsdialog öffnen, um sie überhaupt zu sehen, und stand dann schon
// in einem Formular. Was sich ändern lässt, soll sich auch ansehen lassen.
const rows: [string, string][] = [
["Titel", titles.length > 0 ? titles.join(", ") : "–"],
["Personalnummer", String(employee.personnel_number)],
["Vorname", employee.first_name],
["Nachname", employee.last_name],
["Titel (vorangestellt)", prefixe.length > 0 ? prefixe.join(", ") : "–"],
["Titel (nachgestellt)", suffixe.length > 0 ? suffixe.join(", ") : "–"],
["Geschlecht", employee.gender === "m" ? "männlich" : "weiblich"],
["Geburtsdatum", `${fmtDate(employee.birth_date)} (${fmtAge(employee.birth_date)} Jahre)`],
["SV-Nummer", employee.sv_nummer ?? "–"],
["Staatsbürgerschaft", employee.nationality],
["E-Mail", employee.email],
["Telefon", employee.phone ?? "–"],
["Standort", location ? `${location.name} (${location.country})` : "–"],
["Adresse", formatAddress(employee)],
// Nur, wo er verlangt ist. Bei einer österreichischen Staatsbürger-
// schaft wäre die Zeile „Aufenthaltstitel: Nein" keine Auskunft,
// sondern eine Frage, die sich nicht stellt.
...(brauchtAufenthaltstitel(employee.nationality)
? ([
[
"Aufenthaltstitel",
employee.hat_aufenthaltstitel
? employee.aufenthaltstitel_bis
? `Ja, bis ${fmtDate(employee.aufenthaltstitel_bis)}`
: "Ja (unbefristet oder nicht erfasst)"
: "Nein",
],
] as [string, string][])
: []),
["Adresse", employee.address ?? "–"],
["Postleitzahl", employee.postal_code ?? "–"],
["Ort", employee.city ?? "–"],
["Land", employee.address_country ?? "–"],
["Geschlecht", employee.gender === "m" ? "männlich" : "weiblich"],
["Private E-Mail", employee.email ?? "–"],
["Private Telefonnummer", employee.phone ?? "–"],
// Der Standort ist keine Angabe zur Person, sondern die Betriebsstätte —
// er steht deshalb am Ende und nicht zwischen Adresse und Land, wo man
// ihn für den Wohnort halten könnte.
["Standort", location ? `${location.name} (${location.country})` : "–"],
];
return (
<div className="flex flex-col gap-6">
@@ -38,7 +68,46 @@ export function StammdatenTab({ employee, location, dependents }: { employee: Em
</div>
))}
</dl>
<AngehoerigeSection employeeId={employee.id} dependents={dependents} />
{/* Eigener Abschnitt statt einer Zelle im Raster, und unterhalb der
Angehörigen: beides sind Personen im Umfeld, und der Notfallkontakt
ist die Ausnahme davon — deshalb steht er zuletzt, nicht dazwischen.
Im Ernstfall greift jemand in Eile danach; dann muss die Nummer
sofort zu finden sein und wählbar. */}
<div className="border-t border-border pt-6">
<h3 className="mb-3 text-xs font-bold uppercase tracking-wide text-brand-700">Notfallkontakt</h3>
{employee.emergency_contact_name ? (
<dl className="grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-2 lg:grid-cols-3">
<div>
<dt className="text-xs font-semibold uppercase tracking-wide text-ink-muted">Name</dt>
<dd className="mt-1 text-sm text-ink">{employee.emergency_contact_name}</dd>
</div>
<div>
<dt className="text-xs font-semibold uppercase tracking-wide text-ink-muted">Telefon</dt>
<dd className="mt-1 text-sm font-semibold text-ink">
{employee.emergency_contact_phone ? (
<a
href={`tel:${employee.emergency_contact_phone.replace(/\s/g, "")}`}
className="rounded hover:text-brand-700 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
{employee.emergency_contact_phone}
</a>
) : (
"–"
)}
</dd>
</div>
<div>
<dt className="text-xs font-semibold uppercase tracking-wide text-ink-muted">Verhältnis</dt>
<dd className="mt-1 text-sm text-ink">{employee.emergency_contact_relation || "–"}</dd>
</div>
</dl>
) : (
<p className="text-sm text-ink-muted">Kein Notfallkontakt hinterlegt.</p>
)}
</div>
</div>
);
}

View File

@@ -1,3 +1,4 @@
import { dienstwagenLabel } from "@/lib/dienstwagen";
import { fmtDate } from "@/lib/format";
import type { Database } from "@/lib/supabase/types";
@@ -13,12 +14,11 @@ const PAYGRADE_LABELS: Record<string, string> = {
};
export function VertragTab({ employee }: { employee: EmployeeRow }) {
const flags = [
employee.is_betriebsrat && "Betriebsrat",
employee.has_dienstwagen && "Dienstwagen",
employee.is_laterale_fuehrung && "Laterale Führung",
employee.is_c_level && "C-Level",
].filter(Boolean);
// Früher stand hier eine einzige Zeile „Merkmale" mit allem, was zutraf,
// durch Kommas getrennt — und ein Gedankenstrich, wenn nichts zutraf. Damit
// liess sich nicht ablesen, ob jemand *keinen* Dienstwagen hat oder ob
// niemand die Frage je beantwortet hat. Jedes Merkmal steht jetzt für sich,
// mit Ja oder Nein, wie jede andere Zeile auf diesem Blatt auch.
const rows: [string, string][] = [
["Eintrittsdatum", fmtDate(employee.entry_date)],
["Vertragsart", employee.contract_type === "befristet" ? `befristet bis ${fmtDate(employee.contract_end_date)}` : "unbefristet"],
@@ -29,7 +29,32 @@ export function VertragTab({ employee }: { employee: EmployeeRow }) {
["Paygrade", PAYGRADE_LABELS[employee.paygrade] ?? employee.paygrade],
["Angestellte:r / Arbeiter:in", employee.worker_type ?? "–"],
["Arbeitstage", employee.work_days?.join(", ") || "–"],
["Merkmale", flags.length > 0 ? flags.join(", ") : "–"],
// Beim Dienstwagen steht die Antriebsart statt eines blossen „Ja" — das
// war die Frage dahinter, seit E-Fahrzeuge getrennt zu führen sind.
["Dienstwagen", employee.has_dienstwagen ? dienstwagenLabel(employee.dienstwagen_art) : "Nein"],
["Betriebsrat", employee.is_betriebsrat ? "Ja" : "Nein"],
["Laterale Führung", employee.is_laterale_fuehrung ? "Ja" : "Nein"],
["C-Level", employee.is_c_level ? "Ja" : "Nein"],
// Das Enddatum steht gleich dabei: „Ja" allein liesse offen, ob der
// Schutz noch läuft, und genau danach fragt man.
// Die Teilzeitvariante gehört neben die Stunden: sie erklärt, warum sie
// sind, wie sie sind.
[
"Teilzeitvariante",
employee.teilzeit_art
? employee.teilzeit_bis
? `${employee.teilzeit_art} bis ${fmtDate(employee.teilzeit_bis)}`
: `${employee.teilzeit_art} (Ende offen)`
: "–",
],
[
"Besonderer Kündigungsschutz",
employee.has_kuendigungsschutz
? employee.kuendigungsschutz_bis
? `bis ${fmtDate(employee.kuendigungsschutz_bis)}`
: "Ja (Ende offen)"
: "Nein",
],
];
if (employee.exit_date) rows.push(["Austrittsdatum", fmtDate(employee.exit_date)]);

View File

@@ -2,20 +2,22 @@
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { hireEmployee } from "@/actions/employees";
import { addEmployeeDependent, hireEmployee } from "@/actions/employees";
import { deleteHireDraft, saveHireDraft } from "@/actions/hireDrafts";
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import type { OpenPositionResolved } from "@/lib/positions";
import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr";
import { StepAngehoerige } from "./StepAngehoerige";
import { StepNotfallkontakt } from "./StepNotfallkontakt";
import { StepPerson } from "./StepPerson";
import { StepPosition } from "./StepPosition";
import { StepSummary } from "./StepSummary";
import { StepVertrag } from "./StepVertrag";
import { EMPTY_HIRE_DRAFT, type HireDraftData } from "./types";
const STEP_LABELS = ["Person", "Position", "Vertrag", "Zusammenfassung"];
const STEP_LABELS = ["Person", "Position", "Angehörige", "Vertrag", "Notfallkontakt", "Zusammenfassung"];
type HireWizardProps = {
open: boolean;
@@ -58,12 +60,34 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
isValidSvnr(draft.svNummer, draft.birthDate || null);
const stepValid = [
Boolean(draft.firstName && draft.lastName && draft.birthDate && draft.locationId) && svNummerOk,
// 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.
// Die private E-Mail-Adresse steht bewusst nicht mehr darunter: sie ist
// freiwillig, seit die Spalte NULL zulässt.
Boolean(draft.personnelNumber.trim() && draft.firstName && draft.lastName && draft.birthDate && draft.locationId) &&
svNummerOk,
Boolean(draft.positionId && draft.besetzung),
// Angehörige: freiwillig — aber eine begonnene Zeile muss vollständig
// sein, sonst scheitert sie erst nach dem Anlegen der Person, und die
// steht dann schon in der Datenbank.
draft.angehoerige.every(
(a) =>
a.firstName.trim() &&
a.lastName.trim() &&
a.birthDate &&
(!a.svNummer.trim() || isValidSvnr(a.svNummer, a.birthDate || null))
),
Boolean(draft.entryDate && draft.workDays.length > 0),
// Notfallkontakt: freiwillig, aber Name und Nummer nur gemeinsam — die
// Datenbank weist eines ohne das andere ab (chk_emergency_contact).
Boolean(draft.emergencyContactName.trim()) === Boolean(draft.emergencyContactPhone.trim()),
true,
][step];
/** Der letzte Schritt; von hier wird angelegt statt weitergeblättert. */
const letzterSchritt = STEP_LABELS.length - 1;
async function handleSaveDraft() {
const result = await saveHireDraft({ id: draftId, step, data: draft });
if (result.success) {
@@ -79,6 +103,7 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
if (!selectedPosition || !draft.besetzung) return;
setSubmitting(true);
const result = await hireEmployee({
personnel_number: Number(draft.personnelNumber),
first_name: draft.firstName,
last_name: draft.lastName,
title_prefix: draft.titlePrefix,
@@ -86,6 +111,7 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
gender: draft.gender,
birth_date: draft.birthDate,
sv_nummer: draft.svNummer || undefined,
email: draft.email.trim() || undefined,
phone: draft.phone || undefined,
position_id: draft.positionId,
location_id: draft.locationId,
@@ -101,17 +127,57 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
work_days: draft.workDays,
is_betriebsrat: draft.isBetriebsrat,
has_dienstwagen: draft.hasDienstwagen,
// Null, sobald kein Dienstwagen da ist — der CHECK lässt die Angabe
// sonst nicht zu.
dienstwagen_art: draft.hasDienstwagen ? draft.dienstwagenArt : null,
has_kuendigungsschutz: draft.hasKuendigungsschutz,
// Ohne Schutz kein Enddatum — der CHECK lässt es nicht anders zu.
kuendigungsschutz_bis: draft.hasKuendigungsschutz ? draft.kuendigungsschutzBis || null : null,
emergency_contact_name: draft.emergencyContactName.trim() || undefined,
emergency_contact_phone: draft.emergencyContactPhone.trim() || undefined,
emergency_contact_relation: draft.emergencyContactRelation.trim() || undefined,
is_laterale_fuehrung: draft.isLateraleFuehrung,
is_c_level: draft.isCLevel,
});
setSubmitting(false);
if (result.success) {
showToast(`${draft.firstName} ${draft.lastName} wurde eingestellt.`);
if (draftId) await deleteHireDraft(draftId);
router.refresh();
onClose();
} else {
if (!result.success || !result.employeeId) {
setSubmitting(false);
showToast(result.error ?? "Fehler beim Anlegen.", "error");
return;
}
// Angehörige erst jetzt: add_employee_dependent braucht die Kennung, und
// die entsteht mit der Einstellung.
//
// Damit hängen sie ausserhalb der Transaktion, in der die Person
// entsteht. Scheitert eine, ist die Person trotzdem angelegt — deshalb
// wird nicht stillschweigend weitergemacht, sondern genau gesagt, wer
// fehlt. Nachtragen geht in der Personalakte.
const gescheitert: string[] = [];
for (const a of draft.angehoerige) {
const r = await addEmployeeDependent({
employee_id: result.employeeId,
first_name: a.firstName.trim(),
last_name: a.lastName.trim(),
relationship: a.relationship,
birth_date: a.birthDate,
sv_nummer: a.svNummer.trim() || undefined,
effective_date: draft.entryDate,
});
if (!r.success) gescheitert.push(`${a.firstName} ${a.lastName}`.trim());
}
setSubmitting(false);
if (draftId) await deleteHireDraft(draftId);
router.refresh();
onClose();
if (gescheitert.length > 0) {
showToast(
`${draft.firstName} ${draft.lastName} wurde eingestellt, aber ${gescheitert.join(", ")} konnte nicht als Angehörige:r angelegt werden — bitte in der Personalakte nachtragen.`,
"error"
);
} else {
showToast(`${draft.firstName} ${draft.lastName} wurde eingestellt.`);
}
}
@@ -137,12 +203,12 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
Zurück
</Button>
)}
{step < 3 && (
{step < letzterSchritt && (
<Button onClick={() => setStep((s) => s + 1)} disabled={!stepValid}>
Weiter
</Button>
)}
{step === 3 && (
{step === letzterSchritt && (
<Button onClick={handleSubmit} pending={submitting}>
Anlegen
</Button>
@@ -176,8 +242,10 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
{step === 0 && <StepPerson draft={draft} update={update} locations={locations} />}
{step === 1 && <StepPosition draft={draft} update={update} openPositions={openPositions} />}
{step === 2 && <StepVertrag draft={draft} update={update} />}
{step === 3 && <StepSummary draft={draft} selectedPosition={selectedPosition} locations={locations} />}
{step === 2 && <StepAngehoerige draft={draft} update={update} />}
{step === 3 && <StepVertrag draft={draft} update={update} />}
{step === 4 && <StepNotfallkontakt draft={draft} update={update} />}
{step === 5 && <StepSummary draft={draft} selectedPosition={selectedPosition} locations={locations} />}
</Modal>
);
}

View File

@@ -0,0 +1,113 @@
import { Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { SelectField, TextField } from "@/components/ui/Field";
import { fmtDate } from "@/lib/format";
import { formatSvnr, svnrErrorMessage, validateSvnr } from "@/lib/svnr";
import type { RelationshipType } from "@/lib/supabase/types";
import type { HireDraftAngehoerige, HireDraftData } from "./types";
const VERHAELTNIS: RelationshipType[] = ["Ehepartner:in", "Lebenspartner:in", "Kind", "Sonstige"];
// Angehörige im Assistenten, obwohl es die Person noch nicht gibt.
//
// Sie werden hier gesammelt und erst nach dem Anlegen angehängt — die
// Datenbankfunktion braucht eine Kennung, und die entsteht mit der
// Einstellung. Der Preis dafür steht in HireWizard: schlägt eine der
// Ergänzungen fehl, ist die Person trotzdem angelegt, und die Meldung sagt
// das dann auch.
//
// Freiwillig: die meisten Einstellungen kommen ohne aus, und wer später
// etwas nachträgt, findet denselben Dialog in der Personalakte.
export function StepAngehoerige({
draft,
update,
}: {
draft: HireDraftData;
update: (patch: Partial<HireDraftData>) => void;
}) {
const liste = draft.angehoerige;
function setze(index: number, patch: Partial<HireDraftAngehoerige>) {
update({ angehoerige: liste.map((a, i) => (i === index ? { ...a, ...patch } : a)) });
}
function hinzufuegen() {
update({
angehoerige: [...liste, { firstName: "", lastName: draft.lastName, relationship: "Kind", birthDate: "", svNummer: "" }],
});
}
return (
<div className="flex flex-col gap-4">
<p className="max-w-prose text-sm text-ink-muted">
Angehörige sind freiwillig und lassen sich jederzeit in der Personalakte nachtragen. Der Nachname ist mit dem
der einzustellenden Person vorbelegt — überschreibbar.
</p>
{liste.length === 0 ? (
<p className="text-sm text-ink-muted">Keine Angehörigen erfasst.</p>
) : (
<div className="flex flex-col gap-3">
{liste.map((a, i) => {
// Dieselbe Prüfung wie bei der Person selbst: Prüfziffer und
// Geburtsdatum müssen zusammenpassen. Hier schon, damit der
// Fehler nicht erst nach dem Anlegen auftaucht — dann existiert
// die Person bereits und die Angehörige fehlt.
const svFehler = a.svNummer.trim() ? validateSvnr(a.svNummer, a.birthDate || null) : null;
return (
<fieldset key={i} className="rounded-md border border-border p-3">
<legend className="flex items-center gap-2 px-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">
{a.firstName || a.lastName ? `${a.firstName} ${a.lastName}`.trim() : `Angehörige:r ${i + 1}`}
{a.birthDate && <span className="font-normal normal-case">· {fmtDate(a.birthDate)}</span>}
</legend>
<div className="flex flex-col gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<TextField label="Vorname" dense required value={a.firstName} onChange={(v) => setze(i, { firstName: v })} />
<TextField label="Nachname" dense required value={a.lastName} onChange={(v) => setze(i, { lastName: v })} />
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<SelectField
label="Verhältnis"
dense
required
value={a.relationship}
onChange={(v) => setze(i, { relationship: v as RelationshipType })}
options={VERHAELTNIS.map((r) => ({ value: r, label: r }))}
/>
<TextField label="Geburtsdatum" dense required type="date" value={a.birthDate} onChange={(v) => setze(i, { birthDate: v })} />
</div>
<TextField
label="SV-Nummer"
dense
value={a.svNummer}
onChange={(v) => setze(i, { svNummer: v })}
error={svFehler ? svnrErrorMessage(svFehler) : undefined}
hint={!svFehler && a.svNummer.trim() ? formatSvnr(a.svNummer) : undefined}
/>
</div>
<div className="mt-2 flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={() => update({ angehoerige: liste.filter((_, j) => j !== i) })}
className="!px-1 text-danger-text hover:!bg-transparent hover:underline"
>
<Trash2 className="h-3.5 w-3.5" /> Entfernen
</Button>
</div>
</fieldset>
);
})}
</div>
)}
<div>
<Button variant="secondary" size="sm" onClick={hinzufuegen}>
<Plus className="h-4 w-4" /> Angehörige:n hinzufügen
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { SelectField, TextField } from "@/components/ui/Field";
import { EMERGENCY_RELATIONS } from "@/lib/supabase/types";
import type { HireDraftData } from "./types";
// Eigener Schritt, kurz vor der Zusammenfassung.
//
// Zuerst stand das zwischen den Stammdaten — dort ging es unter, obwohl es
// die einzige Angabe im ganzen Assistenten ist, die eine dritte Person
// betrifft und im Ernstfall gebraucht wird.
//
// Die Angabe bleibt freiwillig. Wer sie macht, braucht Name und Nummer
// zusammen; das prüft der Assistent, bevor er weiterlässt, und die Datenbank
// noch einmal (chk_emergency_contact).
export function StepNotfallkontakt({
draft,
update,
}: {
draft: HireDraftData;
update: (patch: Partial<HireDraftData>) => void;
}) {
const angefangen = Boolean(draft.emergencyContactName.trim() || draft.emergencyContactPhone.trim());
const unvollstaendig = angefangen && !(draft.emergencyContactName.trim() && draft.emergencyContactPhone.trim());
return (
<div className="flex flex-col gap-4">
<p className="max-w-prose text-sm text-ink-muted">
Wen sollen wir verständigen, wenn etwas passiert? Die Angabe ist freiwillig — Name und Telefonnummer gehören
aber zusammen, eines allein hilft im Ernstfall nicht.
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<TextField
label="Name"
value={draft.emergencyContactName}
onChange={(emergencyContactName) => update({ emergencyContactName })}
/>
<TextField
label="Telefon"
type="tel"
value={draft.emergencyContactPhone}
onChange={(emergencyContactPhone) => update({ emergencyContactPhone })}
/>
</div>
<SelectField
label="Verhältnis"
value={draft.emergencyContactRelation}
onChange={(emergencyContactRelation) => update({ emergencyContactRelation })}
placeholder="Bitte wählen…"
options={EMERGENCY_RELATIONS.map((r) => ({ value: r, label: r }))}
/>
{unvollstaendig && (
<p role="alert" className="rounded-md border border-danger-text/20 bg-danger-bg px-3 py-2 text-sm text-danger-text">
Name und Telefonnummer werden beide gebraucht — oder beide leer lassen.
</p>
)}
</div>
);
}

View File

@@ -12,6 +12,14 @@ type StepPersonProps = {
export function StepPerson({ draft, update, locations }: StepPersonProps) {
return (
<div className="flex flex-col gap-4">
<TextField
label="Personalnummer"
required
inputMode="numeric"
value={draft.personnelNumber}
onChange={(personnelNumber) => update({ personnelNumber: personnelNumber.replace(/\D/g, "") })}
hint="Muss mit Loga und Interflex übereinstimmen. Wird nicht automatisch vergeben."
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<TextField label="Vorname" required value={draft.firstName} onChange={(firstName) => update({ firstName })} />
<TextField label="Nachname" required value={draft.lastName} onChange={(lastName) => update({ lastName })} />
@@ -37,8 +45,11 @@ 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 (privat)" type="email" value={draft.email} onChange={(email) => update({ email })} />
<TextField label="Telefon" type="tel" value={draft.phone} onChange={(phone) => update({ phone })} />
{/* Die private Adresse, nicht die Firmenadresse — die entsteht erst
mit dem Eintritt. Freiwillig: wer keine hat oder keine angeben
will, soll nicht gezwungen sein, eine zu erfinden. */}
<TextField label="Private E-Mail" type="email" value={draft.email} onChange={(email) => update({ email })} />
<TextField label="Private Telefonnummer" type="tel" value={draft.phone} onChange={(phone) => update({ phone })} />
</div>
<SelectField
label="Standort"

View File

@@ -1,3 +1,4 @@
import { dienstwagenLabel } from "@/lib/dienstwagen";
import { fmtDate, fmtFullName } from "@/lib/format";
import type { OpenPositionResolved } from "@/lib/positions";
import type { HireDraftData } from "./types";
@@ -21,17 +22,25 @@ export function StepSummary({ draft, selectedPosition, locations }: StepSummaryP
const location = locations.find((l) => l.id === draft.locationId);
const flags = [
draft.isBetriebsrat && "Betriebsrat",
draft.hasDienstwagen && "Dienstwagen",
draft.hasDienstwagen && `Dienstwagen (${dienstwagenLabel(draft.dienstwagenArt)})`,
draft.isLateraleFuehrung && "Laterale Führung",
draft.isCLevel && "C-Level",
draft.hasKuendigungsschutz &&
`Besonderer Kündigungsschutz${draft.kuendigungsschutzBis ? ` bis ${fmtDate(draft.kuendigungsschutzBis)}` : ""}`,
].filter(Boolean);
const notfall = [draft.emergencyContactName, draft.emergencyContactRelation && `(${draft.emergencyContactRelation})`, draft.emergencyContactPhone]
.filter(Boolean)
.join(" ");
const rows: [string, string][] = [
["Personalnummer", draft.personnelNumber || "–"],
["Name", fmtFullName(draft.firstName, draft.lastName, draft.titlePrefix, draft.titleSuffix)],
["Geschlecht", draft.gender === "m" ? "männlich" : "weiblich"],
["Geburtsdatum", fmtDate(draft.birthDate)],
["SV-Nummer", draft.svNummer || "–"],
["E-Mail (privat)", draft.email || "–"],
["Telefon", draft.phone || "–"],
["Notfallkontakt", notfall || "–"],
["Standort", location ? `${location.name} (${location.country})` : "–"],
["Position", selectedPosition ? `${selectedPosition.title} (${selectedPosition.position_number})` : "–"],
["Organisationseinheit", selectedPosition?.orgLabel ?? "–"],
@@ -57,9 +66,6 @@ export function StepSummary({ draft, selectedPosition, locations }: StepSummaryP
</div>
))}
</dl>
<p className="rounded bg-info-bg px-3 py-2 text-sm text-info-text">
Personalnummer und Firmen-E-Mail-Adresse werden automatisch vergeben.
</p>
</div>
);
}

View File

@@ -1,9 +1,30 @@
import type { CollectiveAgreement, ContractType, EmploymentType, GenderType, PaygradeType, Weekday, WorkerType } from "@/lib/supabase/types";
import type { CollectiveAgreement, ContractType, DienstwagenArt, EmploymentType, GenderType, PaygradeType, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types";
// The spec's hire wizard field list (§4.4) omits Geschlecht and Standort even
// though both are NOT NULL on employees — added here (defaults keep them
// effectively "free" for the user, same treatment as the karenz-start gap).
/**
* Angehörige:r, wie sie im Assistenten gesammelt wird.
*
* Eigener Typ statt der Zeile aus der Datenbank: es gibt weder eine Kennung
* noch eine Person, an der sie hängt — beides entsteht erst mit dem Anlegen.
*/
export type HireDraftAngehoerige = {
firstName: string;
lastName: string;
relationship: RelationshipType;
birthDate: string;
svNummer: string;
};
export type HireDraftData = {
/**
* Eingabe, nicht Vergabe.
*
* Muss mit Loga und Interflex übereinstimmen — als Zeichenkette geführt,
* weil ein leeres Zahlenfeld sonst als 0 im Entwurf landet.
*/
personnelNumber: string;
firstName: string;
lastName: string;
titlePrefix: string[];
@@ -27,11 +48,21 @@ export type HireDraftData = {
workDays: Weekday[];
isBetriebsrat: boolean;
hasDienstwagen: boolean;
/** Nur ausgewertet, wenn hasDienstwagen gesetzt ist — so will es der CHECK. */
dienstwagenArt: DienstwagenArt;
emergencyContactName: string;
emergencyContactPhone: string;
emergencyContactRelation: string;
angehoerige: HireDraftAngehoerige[];
isLateraleFuehrung: boolean;
isCLevel: boolean;
hasKuendigungsschutz: boolean;
/** Freiwillig — leer heisst „bis auf Weiteres". */
kuendigungsschutzBis: string;
};
export const EMPTY_HIRE_DRAFT: HireDraftData = {
personnelNumber: "",
firstName: "",
lastName: "",
titlePrefix: [],
@@ -55,6 +86,13 @@ export const EMPTY_HIRE_DRAFT: HireDraftData = {
workDays: ["Mo", "Di", "Mi", "Do", "Fr"],
isBetriebsrat: false,
hasDienstwagen: false,
dienstwagenArt: "Verbrenner",
emergencyContactName: "",
emergencyContactPhone: "",
emergencyContactRelation: "",
angehoerige: [],
isLateraleFuehrung: false,
isCLevel: false,
hasKuendigungsschutz: false,
kuendigungsschutzBis: "",
};

View File

@@ -0,0 +1,210 @@
"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

@@ -9,6 +9,7 @@ import { SearchInput } from "@/components/ui/SearchInput";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { LazyGraphOrgChart } from "./LazyGraphOrgChart";
import type { ChartNode, OrgEmployee } from "./types";
import { fmtName } from "@/lib/format";
type ViewMode = "list" | "graph";
@@ -53,7 +54,7 @@ export function EmployeeTree({ employees, focusId = null }: { employees: OrgEmpl
// Both badges name a person by id, so the lookup is shared rather than
// rebuilt per node.
const nameById = useMemo(() => new Map(employees.map((e) => [e.id, `${e.first_name} ${e.last_name}`])), [employees]);
const nameById = useMemo(() => new Map(employees.map((e) => [e.id, fmtName(e.first_name, e.last_name)])), [employees]);
const nameOf = useCallback((id: string | null) => (id ? nameById.get(id) : undefined), [nameById]);
const matchIds = useMemo(() => {
@@ -64,8 +65,17 @@ export function EmployeeTree({ employees, focusId = null }: { employees: OrgEmpl
const q = query.trim().toLowerCase();
const matches = new Set<string>();
for (const e of employees) {
// Beide Reihenfolgen: angezeigt wird „Winkler, Hannah", im Kopf hat man
// aber je nach Anlass das eine oder das andere zuerst. Wer abtippt, was
// er sieht, soll ebenso fündig werden wie jemand, der „hannah winkler"
// eingibt.
const vorNach = `${e.first_name} ${e.last_name}`.toLowerCase();
const nachVor = fmtName(e.first_name, e.last_name).toLowerCase();
if (
`${e.first_name} ${e.last_name}`.toLowerCase().includes(q) ||
vorNach.includes(q) ||
nachVor.includes(q) ||
// Ohne den Beistrich, falls jemand ihn beim Abtippen weglässt.
nachVor.replace(",", "").includes(q) ||
e.job_title.toLowerCase().includes(q) ||
String(e.personnel_number).includes(q)
) {
@@ -118,7 +128,7 @@ export function EmployeeTree({ employees, focusId = null }: { employees: OrgEmpl
return {
id: e.id,
kind: "person",
label: `${e.first_name} ${e.last_name}`,
label: fmtName(e.first_name, e.last_name),
sublabel: e.job_title,
href: `/employees/${e.id}`,
avatar: { firstName: e.first_name, lastName: e.last_name },
@@ -159,7 +169,7 @@ export function EmployeeTree({ employees, focusId = null }: { employees: OrgEmpl
<Avatar firstName={e.first_name} lastName={e.last_name} size="sm" />
<Link href={`/employees/${e.id}`} className="min-w-0 flex-1 hover:underline">
<span className="text-sm font-semibold text-ink">
{e.first_name} {e.last_name}
{fmtName(e.first_name, e.last_name)}
</span>
<span className="ml-2 text-xs text-ink-muted">{e.job_title}</span>
</Link>

View File

@@ -1,23 +1,20 @@
"use client";
import { useState } from "react";
import { Printer } from "lucide-react";
import Link from "next/link";
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 { ReorgWorkbench } from "./ReorgWorkbench";
import type { OrgDepartment, OrgDivision, OrgEmployee, OrgTeam, ReorgScenarioSummary } from "./types";
import type { OrgEmployee, OrgUnitNode, OrgVacancy } from "./types";
type View = "ma" | "pos" | "reo";
type View = "ma" | "pos";
type OrgChartClientProps = {
employees: OrgEmployee[];
divisions: OrgDivision[];
departments: OrgDepartment[];
teams: OrgTeam[];
openPositions: OpenPositionResolved[];
reorgScenarios: ReorgScenarioSummary[];
units: OrgUnitNode[];
vacancies: OrgVacancy[];
asOf: string;
today: string;
projectedCount: number;
@@ -28,11 +25,8 @@ type OrgChartClientProps = {
export function OrgChartClient({
employees,
divisions,
departments,
teams,
openPositions,
reorgScenarios,
units,
vacancies,
asOf,
today,
projectedCount,
@@ -40,44 +34,33 @@ export function OrgChartClient({
focusId,
}: OrgChartClientProps) {
const [view, setView] = useState<View>("ma");
const isToday = asOf === today;
return (
<div className="flex flex-col gap-4">
<SegmentedControl<View>
value={view}
onChange={setView}
options={[
{ value: "ma", label: "Mitarbeiter" },
{ value: "pos", label: "Positionen" },
{ value: "reo", label: "Reorganisation" },
]}
/>
<div className="flex flex-wrap items-center justify-between gap-3">
<SegmentedControl<View>
value={view}
onChange={setView}
options={[
{ value: "ma", label: "Mitarbeiter" },
{ value: "pos", label: "Organisation" },
]}
/>
{/* Der Stichtag wandert mit: wer eine vergangene Struktur ansieht,
druckt sie auch. */}
<Link
href={asOf === today ? "/orgchart/print" : `/orgchart/print?asOf=${asOf}`}
className="inline-flex items-center gap-2 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"
>
<Printer className="h-4 w-4" />
Als PDF
</Link>
</div>
{view !== "reo" && (
<AsOfPicker asOf={asOf} today={today} projectedCount={projectedCount} historyStartsAt={historyStartsAt} />
)}
<AsOfPicker asOf={asOf} today={today} projectedCount={projectedCount} historyStartsAt={historyStartsAt} />
{view === "ma" && <EmployeeTree employees={employees} focusId={focusId} />}
{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>
))}
{view === "pos" && <PositionTree employees={employees} units={units} vacancies={vacancies} />}
</div>
);
}

View File

@@ -2,56 +2,35 @@
import { ChevronDown, ChevronRight } from "lucide-react";
import Link from "next/link";
import { useCallback, useMemo, useState, type ReactNode } from "react";
import { useCallback, useMemo, useState } from "react";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import type { OpenPositionResolved } from "@/lib/positions";
import { LazyGraphOrgChart } from "./LazyGraphOrgChart";
import type { ChartNode, OrgDepartment, OrgDivision, OrgEmployee, OrgTeam } from "./types";
import type { ChartNode, OrgEmployee, OrgUnitNode, OrgVacancy } from "./types";
import { fmtName } from "@/lib/format";
// 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.
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[];
divisions: OrgDivision[];
departments: OrgDepartment[];
teams: OrgTeam[];
openPositions: OpenPositionResolved[];
units: OrgUnitNode[];
vacancies: OrgVacancy[];
};
export function PositionTree({ employees, divisions, departments, teams, openPositions }: PositionTreeProps) {
const [expanded, setExpanded] = useState<Set<string>>(new Set(["root"]));
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}`)));
const [mode, setMode] = useState<ViewMode>("list");
// useCallback-stable: GraphOrgChart's layout memo depends on these
// references, so unstable functions would force a Dagre re-layout on
// every unrelated re-render.
// 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.
const toggle = useCallback((id: string) => {
setExpanded((prev) => {
const next = new Set(prev);
@@ -60,216 +39,178 @@ export function PositionTree({ employees, divisions, departments, teams, openPos
return next;
});
}, []);
const isExpanded = useCallback((id: string) => expanded.has(id), [expanded]);
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.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);
}
}
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]);
// 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 [];
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) ?? [];
const titleGroups: ChartNode[] = Array.from(icsByTitle.entries()).map(([title, people]) => ({
id: `${teamKey}-${title}`,
kind: "group",
label: title,
sublabel: `${people.length}x besetzt`,
children: people.map((p) => ({
id: p.id,
kind: "person",
label: `${p.first_name} ${p.last_name}`,
href: `/employees/${p.id}`,
avatar: { firstName: p.first_name, lastName: p.last_name },
children: [],
})),
}));
const vacancyNodes: ChartNode[] = openForTeam.map((p) => ({
id: `vac-${p.id}`,
kind: "vacancy",
label: `${p.position_number} · ${p.title}`,
href: "/positions",
children: [],
}));
return {
id: teamKey,
kind: "role",
label: `Teamleitung ${team.name}`,
sublabel: lead ? `besetzt: ${lead.first_name} ${lead.last_name}` : "vakant",
vacant: !lead,
children: [...titleGroups, ...vacancyNodes],
};
}
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]);
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" }]} />
<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} />
<LazyGraphOrgChart tree={tree} 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>
);
})}
{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[]>();
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);
}
}
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);
}
function build(unit: OrgUnitNode): ChartNode {
const key = `unit-${unit.id}`;
const chief = chiefOf.get(unit.id);
const subUnits = (childUnits.get(unit.id) ?? []).map(build);
// 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,
label: title,
sublabel: `${people.length}x besetzt`,
children: people
.slice()
.sort((a, b) => a.last_name.localeCompare(b.last_name, "de"))
.map((p) => ({
id: p.id,
kind: "person" as const,
label: fmtName(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}`,
href: "/positions",
vacant: true,
children: [],
}));
return {
id: key,
kind: "role",
label: `${unit.org_number} · ${unit.name}`,
sublabel: chief
? `Leitung: ${fmtName(chief.first_name, chief.last_name)}${chief.absent ? " (abwesend)" : ""}`
: "Leitung vakant",
vacant: !chief,
children: [...subUnits, ...titleGroups, ...vacancyNodes],
};
}
return (childUnits.get(null) ?? []).map(build);
}

View File

@@ -0,0 +1,609 @@
"use client";
import { ChevronRight, Printer } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/Button";
import { fmtDate } from "@/lib/format";
import type { PrintModel, PrintPerson, PrintUnit } from "@/lib/orgchart-print";
// Auswahl, dann Vorschau, dann Papier.
//
// Drei Entscheidungen prägen das hier:
//
// **Erstens wird gefragt, bevor gedruckt wird.** Ein Organigramm mit 810
// Personen auf Papier ist fast nie das, was jemand braucht — meist geht es um
// einen Bereich, oder um die oberen zwei Ebenen für eine Besprechung. Wer
// alles bekommt, druckt vierzig Blätter und wirft achtunddreissig weg.
//
// **Zweitens wird hierarchisch gezeichnet, nicht aufgelistet.** Kästen mit
// Verbindungslinien zeigen die Über- und Unterordnung; eine Spaltenliste
// zeigt sie nicht, egal wie sauber sie gesetzt ist. Die Linien entstehen aus
// Rahmen von Pseudo-Elementen — damit sind sie im PDF Vektoren und bleiben
// beim Hineinzoomen scharf.
//
// **Drittens breitet sich nur eine Ebene waagrecht aus.** Ein Baum, der auf
// jeder Stufe in die Breite geht, wächst exponentiell: sechs Bereiche mit je
// drei Abteilungen sind achtzehn Kästen nebeneinander, und quer über ein A4
// bleiben davon zwei Millimeter je Kasten — die erste Fassung lief genau
// deshalb über den Blattrand hinaus. Jetzt stehen die Bereiche in einer
// Reihe, und alles darunter hängt längs an einer Linie: die Breite ist die
// Zahl der Bereiche, unabhängig von der Tiefe. Was dann immer noch übersteht,
// wird als Ganzes so weit verkleinert, bis es aufs Blatt passt.
type Tiefe = "bereich" | "abteilung" | "team" | "personen";
const TIEFEN: { wert: Tiefe; label: string; hinweis: string }[] = [
{ wert: "bereich", label: "Bereiche", hinweis: "Gesellschaft und Bereiche — ein Blatt" },
{ wert: "abteilung", label: "bis Abteilung", hinweis: "eine Ebene tiefer, noch immer ein Blatt" },
{ wert: "team", label: "bis Team", hinweis: "je Bereich ein Blatt, ohne Namen" },
{ wert: "personen", label: "mit Personen", hinweis: "je Abteilung ein Blatt, mit allen Namen" },
];
type Format = "a4" | "a3";
// Die Satzfläche ist die Blattgrösse abzüglich der Ränder aus @page, ein
// wenig knapper gerechnet. Die Vorschau bekommt genau diese Masse fest
// zugewiesen — dadurch ist das, was am Bildschirm zu sehen ist, dasselbe, was
// aufs Papier passt, und nicht bloss ungefähr.
const FORMATE: Record<Format, { label: string; css: string; blatt: string; satzBreite: string; satzHoehe: string }> = {
a4: { label: "A4 quer", css: "A4 landscape", blatt: "297mm", satzBreite: "268mm", satzHoehe: "182mm" },
a3: { label: "A3 quer", css: "A3 landscape", blatt: "420mm", satzBreite: "390mm", satzHoehe: "269mm" },
};
/** Unter diesem Massstab wird es auf A4 mühsam — dann lieber A3. */
const LESBAR = 0.55;
type Seite = {
key: string;
titel: string;
unterzeile: string;
wurzel: PrintUnit;
/** Nur auf der Übersicht: die gewählten Bereiche statt aller Kinder. */
kinder?: PrintUnit[];
/** Wie viele Ebenen unter der Wurzel gezeichnet werden. */
ebenen: number;
mitPersonen: boolean;
};
function seitenBauen(model: PrintModel, bereiche: PrintUnit[], tiefe: Tiefe, asOf: string): Seite[] {
const stand = `Stand ${fmtDate(asOf)}`;
const seiten: Seite[] = [];
if (model.root) {
seiten.push({
key: "uebersicht",
titel: model.root.name,
unterzeile: `Organigramm · ${stand} · ${model.totals.people} Personen`,
wurzel: model.root,
kinder: bereiche,
// Bei „bis Abteilung" ist die Übersicht das ganze Ergebnis. Sonst folgen
// Detailblätter, und dann bleibt sie eine reine Bereichsübersicht.
ebenen: tiefe === "abteilung" ? 2 : 1,
mitPersonen: false,
});
}
if (tiefe === "team") {
for (const b of bereiche) {
seiten.push({
key: b.id,
titel: b.name,
unterzeile: `${b.totalPeople} Personen · ${stand}`,
wurzel: b,
ebenen: 2,
mitPersonen: false,
});
}
}
if (tiefe === "personen") {
for (const b of bereiche) {
// Wer unmittelbar im Bereich sitzt, hätte sonst kein Blatt — die
// Leitung steht auf der Übersicht, die übrigen nirgends.
if (b.members.length > 0) {
seiten.push({
key: `${b.id}-direkt`,
titel: b.name,
unterzeile: `Bereichsleitung und Stab · ${stand}`,
wurzel: b,
ebenen: 0,
mitPersonen: true,
});
}
for (const a of b.children) {
seiten.push({
key: a.id,
titel: `${b.name} · ${a.name}`,
unterzeile: `${a.totalPeople} Personen · ${stand}`,
wurzel: a,
ebenen: 1,
mitPersonen: true,
});
}
}
}
return seiten;
}
export function PrintChart({ model, asOf, today }: { model: PrintModel; asOf: string; today: string }) {
const [format, setFormat] = useState<Format>("a4");
const [tiefe, setTiefe] = useState<Tiefe>("team");
const [gewaehlt, setGewaehlt] = useState<Set<string>>(() => new Set(model.divisions.map((d) => d.id)));
const [schritt, setSchritt] = useState<"auswahl" | "vorschau">("auswahl");
const bereiche = useMemo(() => model.divisions.filter((d) => gewaehlt.has(d.id)), [model.divisions, gewaehlt]);
const seiten = useMemo(() => seitenBauen(model, bereiche, tiefe, asOf), [model, bereiche, tiefe, asOf]);
// Der kleinste Massstab aller Blätter — daran hängt der Hinweis auf A3.
//
// Die gemeldeten Werte tragen die Auswahl mit, für die sie gemessen wurden:
// ändert sie sich, sind die alten Werte nicht mehr gültig und werden
// verworfen, sobald das erste Blatt der neuen Auswahl meldet. Das erspart
// einen Effekt, der beim Wechsel aufräumt — und damit eine Renderrunde,
// in der noch der Hinweis der vorherigen Auswahl stünde.
const signatur = `${format}|${tiefe}|${[...gewaehlt].sort().join(",")}`;
const [gemessen, setGemessen] = useState<{ signatur: string; werte: Record<string, number> }>({ signatur, werte: {} });
const meldeMassstab = useCallback(
(key: string, faktor: number) => {
setGemessen((prev) => {
// Unverändert heisst unverändert: denselben Zustand zurückgeben, nicht
// ein gleich aussehendes neues Objekt. Ein neues Objekt zählt für React
// als Änderung und löst ein Rendern aus — und da das Rendern die
// Messung anstösst, die wieder hier landet, dreht sich das ohne Ende.
// Genau daran ist die Vorschau eingefroren.
if (prev.signatur === signatur && prev.werte[key] === faktor) return prev;
return prev.signatur === signatur
? { signatur, werte: { ...prev.werte, [key]: faktor } }
: { signatur, werte: { [key]: faktor } };
});
},
[signatur]
);
const engste = Math.min(1, ...Object.values(gemessen.signatur === signatur ? gemessen.werte : {}));
function umschalten(id: string) {
setGewaehlt((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
return (
<div className="flex flex-col gap-6">
<style>{`
@page { size: ${FORMATE[format].css}; margin: 12mm 14mm 14mm; }
@media print {
html, body { background: #fff; }
.nur-bildschirm { display: none !important; }
.blatt {
box-shadow: none !important; border: 0 !important; border-radius: 0 !important;
margin: 0 !important; width: auto !important; padding: 0 !important;
break-after: page;
}
.blatt:last-child { break-after: auto; }
/* Die Satzfläche behält ihre Masse — der Massstab wurde für genau
diese berechnet. Nur der Rand fällt weg, den setzt @page. */
.satz { margin: 0 !important; }
}
/* Eine Reihe: waagrechte Geschwister mit Winkeln nach oben. Das erste
und das letzte Kind lassen ihre Aussenseite weg — zusammen ergibt
das eine durchgehende Querlinie ohne ein einziges Hilfselement. */
ul.reihe {
position: relative; display: flex; justify-content: center;
list-style: none; margin: 0; padding: 18px 0 0;
}
ul.reihe::before {
content: ""; position: absolute; top: 0; left: 50%;
height: 18px; border-left: 1px solid var(--color-border);
}
ul.reihe > li { position: relative; width: var(--spalte); padding: 18px 6px 0; }
ul.reihe > li::before, ul.reihe > li::after {
content: ""; position: absolute; top: 0; width: 50%; height: 18px;
border-top: 1px solid var(--color-border);
}
ul.reihe > li::before { right: 50%; }
ul.reihe > li::after { left: 50%; border-left: 1px solid var(--color-border); }
ul.reihe > li:first-child::before { border-top: 0; }
ul.reihe > li:last-child::after { border-top: 0; }
/* Ein Stapel: längs an einer Linie, mit Winkel in jeden Kasten. Kostet
Höhe statt Breite — und Höhe ist auf einem Querformat übrig. */
ul.stapel { list-style: none; margin: 0; padding: 0; text-align: left; }
ul.stapel > li { position: relative; padding: 8px 0 0 20px; }
ul.stapel > li::before {
content: ""; position: absolute; left: 9px; top: 0; bottom: 0;
border-left: 1px solid var(--color-border);
}
ul.stapel > li:last-child::before { bottom: auto; height: 22px; }
ul.stapel > li::after {
content: ""; position: absolute; left: 9px; top: 22px; width: 11px;
border-top: 1px solid var(--color-border);
}
`}</style>
{/* ── Auswahl ─────────────────────────────────────────── */}
<section className="nur-bildschirm rounded border border-border bg-white p-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="text-sm font-bold text-ink">Was soll aufs Papier?</h2>
<p className="mt-0.5 max-w-prose text-sm text-ink-muted">
Das vollständige Organigramm sind {model.totals.people} Personen. Gedruckt wird selten alles — hier steht,
wie tief und welche Bereiche.
</p>
</div>
<p className="whitespace-nowrap text-sm text-ink-muted">
Stand {fmtDate(asOf)}
{asOf !== today && " · abweichender Stichtag"}
</p>
</div>
<div className="mt-5">
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-ink-muted">Tiefe</p>
<div className="flex flex-wrap gap-2">
{TIEFEN.map((t) => (
<button
key={t.wert}
type="button"
onClick={() => setTiefe(t.wert)}
aria-pressed={tiefe === t.wert}
className={`rounded-md border px-3 py-2 text-left focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
tiefe === t.wert ? "border-brand-500 bg-brand-50" : "border-border hover:bg-surface"
}`}
>
<span className="block text-sm font-semibold text-ink">{t.label}</span>
<span className="block text-xs text-ink-muted">{t.hinweis}</span>
</button>
))}
</div>
</div>
<div className="mt-5">
<div className="mb-2 flex items-center justify-between">
<p className="text-xs font-bold uppercase tracking-wide text-ink-muted">
Bereiche ({gewaehlt.size} von {model.divisions.length})
</p>
<div className="flex gap-3 text-xs">
<button
type="button"
onClick={() => setGewaehlt(new Set(model.divisions.map((d) => d.id)))}
className="rounded font-semibold text-brand-700 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
Alle
</button>
<button
type="button"
onClick={() => setGewaehlt(new Set())}
className="rounded font-semibold text-brand-700 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
Keine
</button>
</div>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{model.divisions.map((d) => (
<label
key={d.id}
className={`flex cursor-pointer items-start gap-2 rounded-md border px-3 py-2 ${
gewaehlt.has(d.id) ? "border-brand-500 bg-brand-50" : "border-border"
}`}
>
<input
type="checkbox"
checked={gewaehlt.has(d.id)}
onChange={() => umschalten(d.id)}
className="mt-0.5 h-4 w-4 rounded border-border text-brand-600"
/>
<span>
<span className="block text-sm font-semibold text-ink">{d.name}</span>
<span className="block text-xs text-ink-muted">
{d.totalPeople} Personen · {d.children.length} Abteilungen
</span>
</span>
</label>
))}
</div>
</div>
<div className="mt-5 flex flex-wrap items-center justify-between gap-3 border-t border-border-subtle pt-4">
<p className="text-sm text-ink-muted">
{gewaehlt.size === 0 ? (
<span className="text-danger-text">Kein Bereich gewählt — es bliebe nur die Übersicht.</span>
) : (
<>
Ergibt <strong className="text-ink">{seiten.length}</strong> {seiten.length === 1 ? "Seite" : "Seiten"}
</>
)}
</p>
<div className="flex items-center gap-3">
<Link
href="/orgchart"
className="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"
>
Zurück
</Link>
<Button onClick={() => setSchritt("vorschau")}>
Vorschau <ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</section>
{schritt === "vorschau" && (
<>
<div className="nur-bildschirm flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-ink-muted">
{seiten.length} {seiten.length === 1 ? "Seite" : "Seiten"} · so kommt es aus dem Drucker
</p>
<div className="flex items-center gap-3">
<div className="flex rounded-md border border-border p-0.5">
{(Object.keys(FORMATE) as Format[]).map((f) => (
<button
key={f}
type="button"
onClick={() => setFormat(f)}
aria-pressed={format === f}
className={`rounded px-3 py-1.5 text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
format === f ? "bg-brand-500 text-white" : "text-ink-muted hover:bg-surface"
}`}
>
{FORMATE[f].label}
</button>
))}
</div>
<Button onClick={() => window.print()}>
<Printer className="h-4 w-4" />
Als PDF speichern
</Button>
</div>
</div>
{format === "a4" && engste < LESBAR && (
<p className="nur-bildschirm rounded border border-warning-text/30 bg-warning-bg px-3 py-2 text-sm text-warning-text">
Ein Blatt musste auf {Math.round(engste * 100)} % verkleinert werden, damit es passt — auf A4 kaum noch
lesbar. A3 wählen oder weniger Bereiche.
</p>
)}
<p className="nur-bildschirm max-w-prose text-xs leading-relaxed text-ink-muted">
Im Druckdialog &bdquo;Als PDF speichern&ldquo; wählen. Hintergrundgrafiken müssen dort eingeschaltet sein,
sonst fehlen die farbigen Kopfzeilen &mdash; in Chrome unter &bdquo;Weitere Einstellungen&ldquo;.
</p>
<div className="flex flex-col items-center gap-8">
{seiten.map((seite, i) => (
<Blatt key={seite.key} format={format}>
<Kopf
titel={seite.titel}
unterzeile={seite.unterzeile}
seite={i === 0 ? "Übersicht" : `Seite ${i + 1} von ${seiten.length}`}
/>
<Skaliert kennung={`${seite.key}|${format}|${tiefe}`} onFaktor={(f) => meldeMassstab(seite.key, f)}>
<Chart seite={seite} />
</Skaliert>
</Blatt>
))}
</div>
</>
)}
</div>
);
}
function Blatt({ children, format }: { children: React.ReactNode; format: Format }) {
const { blatt, satzBreite, satzHoehe } = FORMATE[format];
return (
<div className="blatt rounded border border-border bg-white shadow-[var(--shadow-card)]" style={{ width: blatt }}>
<div className="satz flex flex-col" style={{ width: satzBreite, height: satzHoehe, margin: "12mm 14mm 14mm" }}>
{children}
</div>
</div>
);
}
function Kopf({ titel, unterzeile, seite }: { titel: string; unterzeile: string; seite: string }) {
return (
<header className="mb-5 flex shrink-0 items-end justify-between border-b-2 border-brand-500 pb-2">
<div>
<h1 className="text-lg font-extrabold tracking-tight text-ink">{titel}</h1>
<p className="mt-0.5 text-xs text-ink-muted">{unterzeile}</p>
</div>
<p className="whitespace-nowrap text-xs font-semibold text-ink-muted">{seite}</p>
</header>
);
}
/**
* Verkleinert den Inhalt so weit, dass er in die Satzfläche passt.
*
* Ein Organigramm hat keine Grösse, die man vorher kennt — sie hängt daran,
* wie viele Bereiche jemand angehakt hat und wie tief. Statt zu raten wird
* gemessen: die natürliche Grösse steht im Layout, die verfügbare auch, und
* `transform: scale` ändert nur die Darstellung, nicht das Layout. Damit ist
* die Messung stabil und läuft nicht gegen sich selbst.
*/
function Skaliert({
kennung,
onFaktor,
children,
}: {
kennung: string;
onFaktor: (faktor: number) => void;
children: React.ReactNode;
}) {
const rahmen = useRef<HTMLDivElement>(null);
const inhalt = useRef<HTMLDivElement>(null);
const [faktor, setFaktor] = useState(1);
const [versatz, setVersatz] = useState(0);
// Die Meldefunktion wird beim Aufruf frisch erzeugt und ist daher bei jedem
// Rendern eine andere. Stünde sie in den Abhängigkeiten, liefe der Effekt
// nach jedem Rendern erneut — samt Messung, die das nächste Rendern
// auslöst. Über eine Referenz bleibt der aktuelle Stand erreichbar, ohne
// dass der Effekt daran hängt.
const melden = useRef(onFaktor);
useEffect(() => {
melden.current = onFaktor;
});
useLayoutEffect(() => {
const aussen = rahmen.current;
const innen = inhalt.current;
if (!aussen || !innen) return;
function messen() {
if (!aussen || !innen) return;
const breite = aussen.clientWidth;
const hoehe = aussen.clientHeight;
const noetigeBreite = innen.offsetWidth;
const noetigeHoehe = innen.offsetHeight;
if (!breite || !hoehe || !noetigeBreite || !noetigeHoehe) return;
const f = Math.min(1, breite / noetigeBreite, hoehe / noetigeHoehe);
setFaktor(f);
// Waagrecht mittig: die Verkleinerung geht von links oben aus, sonst
// klebt ein schmales Diagramm am linken Blattrand.
setVersatz(Math.max(0, (breite - noetigeBreite * f) / 2));
melden.current(f);
}
messen();
// Schriften kommen nach dem ersten Layout — bis dahin sind alle Kästen in
// der Ersatzschrift gesetzt und damit anders breit.
void document.fonts?.ready.then(messen);
const beobachter = new ResizeObserver(messen);
beobachter.observe(aussen);
return () => beobachter.disconnect();
}, [kennung]);
return (
<div ref={rahmen} className="min-h-0 flex-1 overflow-hidden">
<div
ref={inhalt}
style={{
width: "max-content",
transform: `scale(${faktor})`,
transformOrigin: "top left",
marginLeft: versatz,
}}
>
{children}
</div>
</div>
);
}
function Chart({ seite }: { seite: Seite }) {
const kinder = seite.kinder ?? seite.wurzel.children;
const zeigeKinder = seite.ebenen > 0 && kinder.length > 0;
return (
// Feste Spaltenbreite: dadurch sitzt jeder Kasten mittig unter seiner
// Verbindungslinie, und die Gesamtbreite ist die Zahl der Spalten mal
// dieser Breite — berechenbar, statt vom längsten Namen abzuhängen.
<div className="inline-block text-center" style={{ ["--spalte" as string]: "13rem" }}>
<div className="mx-auto w-[15rem]">
<Kasten unit={seite.wurzel} mitPersonen={seite.mitPersonen} />
</div>
{zeigeKinder && (
<ul className="reihe">
{kinder.map((k) => (
<li key={k.id}>
<Kasten unit={k} mitPersonen={seite.mitPersonen} />
<Stapel units={k.children} ebenen={seite.ebenen - 1} mitPersonen={seite.mitPersonen} />
</li>
))}
</ul>
)}
</div>
);
}
function Stapel({ units, ebenen, mitPersonen }: { units: PrintUnit[]; ebenen: number; mitPersonen: boolean }) {
if (ebenen <= 0 || units.length === 0) return null;
return (
<ul className="stapel">
{units.map((u) => (
<li key={u.id}>
<Kasten unit={u} mitPersonen={mitPersonen} />
<Stapel units={u.children} ebenen={ebenen - 1} mitPersonen={mitPersonen} />
</li>
))}
</ul>
);
}
const KOPFFARBE: Record<PrintUnit["unitType"], string> = {
// Nach unten hin blasser: so bleibt die Rangfolge auch auf dem
// Schwarzweissdrucker sichtbar, auf dem die meisten Ausdrucke landen.
Gesellschaft: "bg-brand-500 text-white",
Bereich: "bg-brand-100 text-ink",
Abteilung: "bg-brand-50 text-ink",
Team: "bg-surface text-ink",
};
function Kasten({ unit, mitPersonen }: { unit: PrintUnit; mitPersonen: boolean }) {
const offen = unit.vacancies.filter((v) => !v.isChief);
// Lange Namenslisten in zwei Spalten: sonst wächst der Kasten in die Höhe,
// und die Höhe ist es, die den Massstab drückt.
const zweispaltig = mitPersonen && unit.members.length > 8;
return (
<div className="w-full overflow-hidden rounded border border-border text-left">
<div className={`px-2 py-1 ${KOPFFARBE[unit.unitType]}`}>
<p className="text-[11px] font-bold leading-tight">{unit.name}</p>
<p
className={`text-[9px] uppercase tracking-wide ${
unit.unitType === "Gesellschaft" ? "text-white/75" : "text-ink-muted"
}`}
>
{unit.unitType} · {unit.totalPeople}
</p>
</div>
<div className="px-2 py-1">
{unit.chief ? (
<PersonZeile p={unit.chief} leitung />
) : (
<p className="text-[10px] italic text-ink-muted">Leitung unbesetzt</p>
)}
{mitPersonen && unit.members.length > 0 && (
<div className={`mt-1 border-t border-border-subtle pt-1 ${zweispaltig ? "columns-2 gap-2" : ""}`}>
{unit.members.map((p) => (
<div key={p.id} className="break-inside-avoid">
<PersonZeile p={p} />
</div>
))}
</div>
)}
{mitPersonen &&
offen.map((v) => (
<p key={v.positionNumber} className="text-[10px] italic text-ink-muted">
offen · {v.jobTitle}
</p>
))}
{!mitPersonen && unit.members.length > 0 && (
<p className="text-[10px] text-ink-muted">
{unit.members.length} {unit.members.length === 1 ? "Person" : "Personen"}
</p>
)}
</div>
</div>
);
}
function PersonZeile({ p, leitung }: { p: PrintPerson; leitung?: boolean }) {
return (
<p className="text-[10px] leading-tight">
<span className={leitung ? "font-bold text-ink" : "text-ink"}>{p.name}</span>
{p.absent && <span className="text-ink-muted"> · abwesend</span>}
<br />
<span className="text-ink-muted">{p.jobTitle}</span>
</p>
);
}

View File

@@ -1,404 +0,0 @@
"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,16 +15,30 @@ export type OrgEmployee = {
/** This person is on a long-term absence as of the chart's date. */
absent: boolean;
absence_type: string | null;
team_id: string | null;
division_id: string;
is_lead: boolean;
org_level: number;
/** 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;
};
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 };
/** 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";
};
// Generic tree shape both EmployeeTree and PositionTree map their own data
// into for the graphical (React Flow + Dagre) view — see GraphOrgChart.

View File

@@ -2,51 +2,64 @@
import { useRouter } from "next/navigation";
import { useState } from "react";
import { createPosition, searchSuperiors, type SuperiorSearchResult } from "@/actions/positions";
import { createPosition } from "@/actions/positions";
import { Button } from "@/components/ui/Button";
import { Field, SelectField, TextField } from "@/components/ui/Field";
import { Lookup } from "@/components/ui/Lookup";
import { SelectField, TextField } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import { todayIso } from "@/lib/format";
import type { Database } from "@/lib/supabase/types";
type Team = Database["public"]["Tables"]["teams"]["Row"];
export type UnitOption = { id: string; name: string; unit_type: string; depth: number; hasChief: boolean };
export function CreatePositionModal({ open, onClose, teams }: { open: boolean; onClose: () => void; teams: Team[] }) {
// 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[];
}) {
const { showToast } = useToast();
const router = useRouter();
const [title, setTitle] = useState("");
const [isLead, setIsLead] = useState(false);
const [superior, setSuperior] = useState<SuperiorSearchResult | null>(null);
const [teamId, setTeamId] = useState("");
const [jobTitle, setJobTitle] = useState("");
const [orgUnitId, setOrgUnitId] = useState("");
const [isChief, setIsChief] = useState(false);
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() {
setTitle("");
setIsLead(false);
setSuperior(null);
setTeamId("");
setJobTitle("");
setOrgUnitId("");
setIsChief(false);
setValidFrom(todayIso());
}
async function handleSubmit() {
if (!title || !superior || !validFrom || (isLead && !teamId)) {
if (!jobTitle || !orgUnitId || !validFrom) {
showToast("Bitte alle Pflichtfelder ausfüllen.", "error");
return;
}
setPending(true);
const result = await createPosition({
title,
superior_employee_id: superior.id,
is_lead: isLead,
team_id: isLead ? teamId : undefined,
org_unit_id: orgUnitId,
job_title: jobTitle,
is_chief: isChief && !chiefTaken,
valid_from: validFrom,
});
setPending(false);
if (result.success) {
showToast("Position ausgeschrieben.");
showToast("Planstelle angelegt.");
router.refresh();
onClose();
reset();
@@ -59,77 +72,46 @@ export function CreatePositionModal({ open, onClose, teams }: { open: boolean; o
<Modal
open={open}
onClose={onClose}
title="Position ausschreiben"
title="Planstelle anlegen"
footer={
<>
<Button variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<Button onClick={handleSubmit} pending={pending}>
Ausschreiben
Anlegen
</Button>
</>
}
>
<div className="flex flex-col gap-4">
<TextField label="Titel" required value={title} onChange={setTitle} />
<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."
/>
<label className="flex items-center gap-2 text-sm text-ink-body">
<input
type="checkbox"
checked={isLead}
onChange={(e) => {
setIsLead(e.target.checked);
setSuperior(null);
}}
/>
Führungsposition (Teamleitung)
<input type="checkbox" checked={isChief} disabled={!unit || chiefTaken} onChange={(e) => setIsChief(e.target.checked)} />
Leitungsplanstelle für diese Einheit
</label>
{!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 }))}
/>
{chiefTaken && (
<p className="-mt-2 text-xs text-ink-muted">Für {unit?.name} besteht bereits eine Leitungsplanstelle.</p>
)}
<TextField label="Gültig ab" required type="date" value={validFrom} onChange={setValidFrom} />
</div>

View File

@@ -0,0 +1,194 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { setPositionCostCenter, 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,
kostenstellen,
heute,
onClose,
}: {
position: OpenPositionResolved;
units: UnitOption[];
kostenstellen: { id: string; code: string; name: string }[];
/** Vom Server, nicht aus new Date(): sonst rechnet der Browser mit seiner Zone. */
heute: string;
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 ?? "");
// Die Kontierung ist ein eigener Vorgang mit eigenem Stichtag, kein Feld
// unter anderen: die laufende wird beendet, die neue beginnt. Deshalb steht
// hier ein Datum daneben und kein stilles Überschreiben.
const [kostenstelleId, setKostenstelleId] = useState(
kostenstellen.find((k) => k.code === position.kostenstelle?.code)?.id ?? ""
);
const [kostenstelleAb, setKostenstelleAb] = useState(heute);
const [pending, setPending] = useState(false);
const kostenstelleGeaendert =
kostenstelleId !== "" && kostenstellen.find((k) => k.id === kostenstelleId)?.code !== position.kostenstelle?.code;
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,
});
if (result.success && kostenstelleGeaendert) {
// Nacheinander, nicht gemeinsam: die Umkontierung ist ein eigener
// Vorgang mit eigenem Stichtag und eigenem Protokolleintrag. Schlägt sie
// fehl, steht die übrige Änderung trotzdem — und die Meldung sagt, was
// offen blieb, statt beides stillschweigend zu verwerfen.
const kosten = await setPositionCostCenter({
position_id: position.id,
cost_center_id: kostenstelleId,
valid_from: kostenstelleAb,
});
if (!kosten.success) {
setPending(false);
showToast(kosten.error ?? "Die Kostenstelle konnte nicht geändert werden.", "error");
router.refresh();
return;
}
}
setPending(false);
if (result.success) {
showToast(kostenstelleGeaendert ? "Planstelle geändert und umkontiert." : "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>
<SelectField
label="Kostenstelle"
value={kostenstelleId}
onChange={setKostenstelleId}
placeholder="Keine"
options={kostenstellen.map((k) => ({ value: k.id, label: `${k.code} · ${k.name}` }))}
/>
{kostenstelleGeaendert && (
<div className="rounded border border-border bg-surface px-3 py-2">
<TextField
label="Umkontieren ab"
required
type="date"
value={kostenstelleAb}
onChange={setKostenstelleAb}
min={position.valid_from}
/>
<p className="mt-1.5 text-xs text-ink-muted">
Die bisherige Kontierung endet an diesem Tag, die neue beginnt. Was vorher gebucht wurde, bleibt, wo es
gebucht wurde.
</p>
</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

@@ -8,77 +8,137 @@ import { Button } from "@/components/ui/Button";
import { useToast } from "@/components/ui/Toast";
import { fmtDate, todayIso } from "@/lib/format";
import type { OpenPositionResolved } from "@/lib/positions";
import { CreatePositionModal } from "./CreatePositionModal";
import { CreatePositionModal, type UnitOption } from "./CreatePositionModal";
import { EditPositionModal } from "./EditPositionModal";
type OpenPositionWithDays = OpenPositionResolved & { daysOpen: number };
type PositionsPageClientProps = {
openPositions: OpenPositionWithDays[];
teams: { id: string; org_number: string; name: string; department_id: string }[];
units: UnitOption[];
kostenstellen: { id: string; code: string; name: string }[];
};
export function PositionsPageClient({ openPositions, teams }: PositionsPageClientProps) {
export function PositionsPageClient({ openPositions, units, kostenstellen }: 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);
const today = todayIso();
// 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);
async function handleDelete(id: string) {
setDeletingId(id);
const result = await deletePosition(id);
setDeletingId(null);
if (result.success) {
showToast("Position gelöscht.");
showToast("Planstelle entfernt.");
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>}
{/* Was die Stelle kostet und wen es trifft — die Frage, die bei einer
Vakanz zuerst kommt. */}
{p.kostenstelle && (
<span className="text-xs text-ink-muted">
Kostenstelle <span className="font-semibold tabular-nums text-ink-body">{p.kostenstelle.code}</span> ·{" "}
{p.kostenstelle.name}
</span>
)}
</button>
<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"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
);
}
return (
<div className="flex flex-col gap-6">
<div className="rounded border border-border bg-white p-4">
<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">Offene Positionen ({openPositions.length})</h2>
<h2 className="text-sm font-bold text-ink">Unbesetzte Planstellen ({offen.length})</h2>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
Position ausschreiben
Planstelle anlegen
</Button>
</div>
{openPositions.length === 0 ? (
<p className="text-sm text-ink-muted">Derzeit keine offenen Positionen.</p>
{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">
{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={`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>
);
})}
{offen.map((p) => (
<Karte key={p.id} p={p} />
))}
</div>
)}
</div>
</section>
<CreatePositionModal open={createOpen} onClose={() => setCreateOpen(false)} teams={teams} />
{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}
kostenstellen={kostenstellen}
heute={todayIso()}
onClose={() => setEditing(null)}
/>
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { FileSpreadsheet, FileText, Save, Trash2 } from "lucide-react";
import { ChevronDown, FileSpreadsheet, FileText, Save, Trash2 } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
@@ -10,6 +10,14 @@ import { CONTROL_CLASS, SelectField, TextField } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import { fmtDate } from "@/lib/format";
import {
anzahlKriterien,
AUSWAHL_KRITERIEN,
JANEIN_KRITERIEN,
kriterienParameter,
ZEITRAUM_KRITERIEN,
type Criteria,
} from "@/lib/report-criteria";
import {
AVERAGE_MEASURES,
EVENT_DATE_OPEN,
@@ -27,6 +35,7 @@ import {
type GroupDimension,
type Measure,
type ReportRow,
type UnitOption,
} from "@/lib/reports";
import type { HistoryEventType } from "@/lib/supabase/types";
@@ -40,7 +49,7 @@ type CommonProps = {
rows: ReportRow[];
total: number;
recordCount: number;
divisions: OrgOption[];
units: UnitOption[];
locations: OrgOption[];
savedReports: SavedReport[];
};
@@ -51,7 +60,8 @@ type SnapshotProps = CommonProps & {
group: GroupDimension;
split: GroupDimension | "";
asOf: string;
filters: { division: string; location: string; status: string; employment: string };
filters: { division: string; location: string; status: string };
criteria: Criteria;
};
type EventsProps = CommonProps & {
@@ -64,6 +74,144 @@ type EventsProps = CommonProps & {
type ReportsPageClientProps = SnapshotProps | EventsProps;
/**
* Alle übrigen Auswahlkriterien, eingeklappt.
*
* Aufgeklappt wäre die Leiste dreimal so lang wie der Bericht daneben, und
* die vier Filter, die fast immer gemeint sind, würden darin untergehen.
* Eingeklappt steht nur die Zahl der gesetzten Kriterien da — und die ist
* das, was man wissen muss: ob gerade etwas einschränkt, das man vergessen
* hat.
*/
function KriterienBlock({
criteria,
onChange,
onReset,
}: {
criteria: Criteria;
onChange: (patch: Record<string, string | undefined>) => void;
onReset: () => void;
}) {
const [offen, setOffen] = useState(false);
const anzahl = anzahlKriterien(criteria);
return (
<div className="mt-2 border-t border-border-subtle pt-2">
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => setOffen(!offen)}
aria-expanded={offen}
className="flex items-center gap-1 rounded text-xs font-semibold text-brand-700 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${offen ? "rotate-180" : ""}`} />
Weitere Kriterien
{anzahl > 0 && <span className="rounded-full bg-brand-500 px-1.5 text-[10px] text-white">{anzahl}</span>}
</button>
{anzahl > 0 && (
<button
type="button"
onClick={onReset}
className="rounded text-xs text-ink-muted hover:text-ink hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
Zurücksetzen
</button>
)}
</div>
{offen && (
<div className="mt-3 flex flex-col gap-2">
{AUSWAHL_KRITERIEN.map((k) => (
<select
key={k.key}
aria-label={`Nach ${k.label} filtern`}
value={criteria.auswahl[k.key] ?? ""}
onChange={(e) => onChange({ [k.key]: e.target.value || undefined })}
className={CONTROL_CLASS}
>
<option value="">{k.alle}</option>
{k.optionen.map((o) => (
<option key={o.wert} value={o.wert}>
{o.label}
</option>
))}
</select>
))}
<fieldset className="rounded border border-border px-3 py-2">
<legend className="mb-1.5 text-xs font-semibold text-ink-muted">Merkmale</legend>
<div className="flex flex-col gap-1.5">
{JANEIN_KRITERIEN.map((k) => (
<div key={k.key} className="flex items-center justify-between gap-2 text-sm text-ink-body">
<span>{k.label}</span>
<select
aria-label={`${k.label}: ja oder nein`}
value={criteria.jaNein[k.key] ?? ""}
onChange={(e) => onChange({ [k.key]: e.target.value || undefined })}
className="rounded border border-border px-2 py-1 text-xs focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
>
<option value="">egal</option>
<option value="ja">ja</option>
<option value="nein">nein</option>
</select>
</div>
))}
</div>
</fieldset>
{ZEITRAUM_KRITERIEN.map((k) => (
<fieldset key={k.key} className="rounded border border-border px-3 py-2">
<legend className="mb-1 text-xs font-semibold text-ink-muted">{k.label}</legend>
<div className="grid grid-cols-2 gap-2">
<TextField
label="Von"
dense
type="date"
value={criteria.zeitraum[k.key]?.von ?? ""}
onChange={(v) => onChange({ [`${k.key}From`]: v || undefined })}
/>
<TextField
label="Bis"
dense
type="date"
value={criteria.zeitraum[k.key]?.bis ?? ""}
onChange={(v) => onChange({ [`${k.key}To`]: v || undefined })}
/>
</div>
</fieldset>
))}
<fieldset className="rounded border border-border px-3 py-2">
<legend className="mb-1 text-xs font-semibold text-ink-muted">Wochenstunden</legend>
<div className="grid grid-cols-2 gap-2">
<TextField
label="Ab"
dense
type="number"
step="0.5"
value={criteria.stundenVon?.toString() ?? ""}
onChange={(v) => onChange({ hoursFrom: v || undefined })}
/>
<TextField
label="Bis"
dense
type="number"
step="0.5"
value={criteria.stundenBis?.toString() ?? ""}
onChange={(v) => onChange({ hoursTo: v || undefined })}
/>
</div>
</fieldset>
<p className="text-xs text-ink-muted">
Alle Kriterien gelten für den Bericht <em>und</em> für beide Exporte.
</p>
</div>
)}
</div>
);
}
function formatValue(measure: Measure, value: number): string {
if (measure === "headcount") return String(Math.round(value));
if (measure === "fte") return value.toFixed(1);
@@ -73,7 +221,7 @@ function formatValue(measure: Measure, value: number): string {
}
export function ReportsPageClient(props: ReportsPageClientProps) {
const { mode, rows, total, recordCount, divisions, locations, savedReports } = props;
const { mode, rows, total, recordCount, units, locations, savedReports } = props;
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -103,6 +251,12 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
updateParams({ status: next.length > 0 ? next.join(",") : undefined });
}
function resetKriterien() {
const patch: Record<string, undefined> = {};
for (const p of kriterienParameter()) patch[p] = undefined;
updateParams(patch);
}
function applyPreset(preset: { group: string; split?: string; eventType?: string; measure?: string }) {
const sp = new URLSearchParams({ mode });
if (preset.measure) sp.set("measure", preset.measure);
@@ -126,10 +280,13 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
showToast("Bitte einen Namen angeben.", "error");
return;
}
const config =
mode === "snapshot"
? { mode, measure: props.measure, group: props.group, split: props.split, asOf: props.asOf, ...props.filters }
: { mode, group: props.eventGroup, split: props.eventSplit, eventType: props.eventType, ...props.eventFilters };
// Auch hier die ganze Adresszeile: ein gespeicherter Bericht, der die
// Hälfte der Kriterien vergisst, führt beim nächsten Öffnen still zu
// anderen Zahlen.
const config: Record<string, string> = Object.fromEntries(searchParams.entries());
config.mode = mode;
config.group = mode === "snapshot" ? props.group : props.eventGroup;
if (mode === "snapshot") config.measure = props.measure;
setSavingReport(true);
const result = await saveReport({ name: newReportName.trim(), config });
setSavingReport(false);
@@ -153,41 +310,30 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
}
}
// Beide Ausgänge übernehmen die Adresszeile unverändert, statt die
// Parameter einzeln aufzuzählen. Genau diese Aufzählung war der Grund,
// warum ein neues Kriterium im Bericht wirkte und im Export fehlte — man
// musste daran denken, sie an drei Stellen zu ergänzen. Jetzt sieht der
// Export das, was der Bericht gesehen hat, weil es dieselbe Auswahl ist.
function exportHref(ziel: string, format: "csv" | "xlsx"): string {
const sp = new URLSearchParams(searchParams.toString());
sp.set("format", format);
return `${ziel}?${sp.toString()}`;
}
function reportExportHref(format: "csv" | "xlsx"): string {
const sp = new URLSearchParams();
const sp = new URLSearchParams(searchParams.toString());
sp.set("format", format);
sp.set("mode", mode);
if (mode === "snapshot") {
sp.set("measure", props.measure);
sp.set("group", props.group);
if (props.split) sp.set("split", props.split);
if (props.asOf) sp.set("asOf", props.asOf);
for (const [k, v] of Object.entries(props.filters)) if (v) sp.set(k, v);
} else {
sp.set("group", props.eventGroup);
if (props.eventSplit) sp.set("split", props.eventSplit);
if (props.eventType) sp.set("eventType", props.eventType);
for (const [k, v] of Object.entries(props.eventFilters)) if (v) sp.set(k, v);
}
// Die Vorgaben stehen nicht zwingend in der Adresszeile; die Route
// braucht sie aber, um dieselbe Auswertung zu bauen.
sp.set("group", mode === "snapshot" ? props.group : props.eventGroup);
if (mode === "snapshot") sp.set("measure", props.measure);
return `/api/export/report?${sp.toString()}`;
}
function fullExportHref(format: "csv" | "xlsx"): string {
if (mode === "snapshot") {
const sp = new URLSearchParams();
sp.set("format", format);
if (props.asOf) sp.set("asOf", props.asOf);
if (props.filters.division) sp.set("division", props.filters.division);
if (props.filters.location) sp.set("location", props.filters.location);
if (props.filters.status) sp.set("status", props.filters.status);
if (props.filters.employment) sp.set("employment", props.filters.employment);
return `/api/export/employees?${sp.toString()}`;
}
const sp = new URLSearchParams();
sp.set("format", format);
if (props.eventType) sp.set("eventType", props.eventType);
for (const [k, v] of Object.entries(props.eventFilters)) if (v) sp.set(k, v);
return `/api/export/events?${sp.toString()}`;
return exportHref(mode === "snapshot" ? "/api/export/employees" : "/api/export/events", format);
}
const isAverage = mode === "snapshot" && AVERAGE_MEASURES.includes(props.measure);
@@ -367,15 +513,19 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Filter</h3>
<div className="flex flex-col gap-2">
<select
aria-label="Nach Bereich filtern"
aria-label="Nach Organisationseinheit filtern"
value={mode === "snapshot" ? props.filters.division : props.eventFilters.division}
onChange={(e) => updateParams({ division: e.target.value })}
className={CONTROL_CLASS}
>
<option value="">Alle Bereiche</option>
{divisions.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
<option value="">Alle Organisationseinheiten</option>
{units.map((u) => (
// Einrückung mit geschützten Leerzeichen: ein <optgroup> je
// Ebene ginge nicht, weil Einheiten dort auswählbar bleiben
// müssen — eine Abteilung ist selbst ein Filterwert.
<option key={u.id} value={u.id}>
{"  ".repeat(u.depth)}
{u.name}
</option>
))}
</select>
@@ -410,19 +560,13 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
))}
</div>
</fieldset>
<select
aria-label="Nach Beschäftigungsart filtern"
value={props.filters.employment}
onChange={(e) => updateParams({ employment: e.target.value })}
className={CONTROL_CLASS}
>
<option value="">Alle Beschäftigungsarten</option>
<option value="Vollzeit">Vollzeit</option>
<option value="Teilzeit">Teilzeit</option>
</select>
</>
)}
</div>
{mode === "snapshot" && (
<KriterienBlock criteria={props.criteria} onChange={updateParams} onReset={resetKriterien} />
)}
</div>
<div className="rounded border border-border bg-white p-4">

View File

@@ -1,6 +1,6 @@
"use client";
import { BarChart3, Building2, History, LayoutGrid, Network, Users, X } from "lucide-react";
import { BarChart3, Building2, History, LayoutGrid, Network, Upload, Users, X } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect } from "react";
@@ -12,6 +12,7 @@ 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

@@ -0,0 +1,42 @@
import type { AuditChange } from "@/lib/supabase/types";
// Was sich geändert hat, feldweise — im Protokoll und in der Historie einer
// Person dieselbe Darstellung. Zwei Ansichten derselben Sache verschieden zu
// setzen, hiesse dass jemand sie zweimal lesen lernen muss.
/** 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 AenderungsTabelle({ changes }: { changes: AuditChange[] }) {
return (
<div className="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>
{changes.map((c, i) => (
<tr key={`${c.feld}-${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>
);
}

View File

@@ -25,6 +25,18 @@ function focusableWithin(container: HTMLElement): HTMLElement[] {
export function useDialogFocus(open: boolean, onClose: () => void, containerRef: RefObject<HTMLElement | null>) {
const restoreToRef = useRef<HTMLElement | null>(null);
// onClose wird von den meisten Aufrufern bei jedem Rendern neu erzeugt.
// Stünde es in den Abhängigkeiten, liefe dieser Effekt nach jedem Rendern
// erneut — und sein Aufräumen holt den Fokus dorthin zurück, wo er vor dem
// Öffnen war. In einem Dialog mit Eingabefeldern hiesse das: nach dem
// ersten Zeichen springt der Fokus auf den Knopf, der ihn geöffnet hat, und
// der Rest der Eingabe geht ins Leere. Über eine Referenz bleibt der
// aktuelle Rückruf erreichbar, ohne dass der Effekt daran hängt.
const schliessen = useRef(onClose);
useEffect(() => {
schliessen.current = onClose;
});
useEffect(() => {
if (!open) return;
const container = containerRef.current;
@@ -39,7 +51,7 @@ export function useDialogFocus(open: boolean, onClose: () => void, containerRef:
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
onClose();
schliessen.current();
return;
}
if (e.key !== "Tab" || !container) return;
@@ -74,5 +86,5 @@ export function useDialogFocus(open: boolean, onClose: () => void, containerRef:
// there instead of restarting at the top of the page.
restoreToRef.current?.focus();
};
}, [open, onClose, containerRef]);
}, [open, containerRef]);
}

45
deploy/Caddyfile Normal file
View File

@@ -0,0 +1,45 @@
# Reverse Proxy für den Betrieb im Firmennetz.
#
# Der Name ist öffentlich (hr.elycon.solutions), die Adresse dahinter privat
# (10.x). Das ist erlaubt und der übliche Weg: der DNS-Eintrag ist von aussen
# auflösbar, der Server nicht erreichbar.
#
# Warum das der Aufwand wert ist: Entra ID akzeptiert `http` nur für
# localhost. Ohne HTTPS gibt es keine Anmeldung — und ein selbst signiertes
# Zertifikat müsste auf jedem Arbeitsplatz als vertrauenswürdig hinterlegt
# werden. Mit der DNS-Challenge kommt ein regulär vertrauenswürdiges
# Zertifikat zustande, ohne dass der Server je aus dem Internet erreichbar
# sein muss.
#
# Voraussetzung: ein Caddy-Build mit dem DNS-Modul des eigenen Anbieters,
# etwa
# xcaddy build --with github.com/caddy-dns/cloudflare
# Andere Anbieter siehe https://github.com/caddy-dns
#
# Der API-Schlüssel gehört in eine Umgebungsvariable des Dienstes, nicht in
# diese Datei.
hr.elycon.solutions {
tls {
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
# Die Anwendung lauscht nur auf der Loopback-Adresse (siehe
# docker-compose.yml), erreichbar ist sie also ausschliesslich über
# diesen Proxy.
reverse_proxy 127.0.0.1:3000
# Ohne diese Weitergabe baut Auth.js seine Rückruf-Adresse aus dem
# Container-Hostnamen statt aus dem echten Namen — die Anmeldung landet
# dann auf einer Adresse, die niemand kennt. `trustHost` in
# lib/auth/config.ts wertet genau diese Header aus.
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host {host}
encode gzip zstd
log {
output file /var/log/caddy/hr.log
format json
}
}

View File

@@ -1,15 +1,17 @@
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
# Nur auf der Loopback-Adresse, nicht auf allen Schnittstellen. Erreichbar
# ist die App damit ausschliesslich über den Reverse Proxy, der TLS
# beendet — sonst stünde daneben derselbe Dienst unverschlüsselt offen,
# und ein Fehler in der Firewall genügte.
ports:
- "3000:3000"
- "127.0.0.1:3000:3000"
env_file:
- .env

148
docs/azure-migration.md Normal file
View File

@@ -0,0 +1,148 @@
# 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 | 21 |
| `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 21 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 21 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,5 +1,23 @@
# Datenmodell
> **Veraltet — siehe `docs/datenkatalog.md`.**
>
> Dieses Dokument beschreibt den Stand vor zwei Umbauten und stimmt in
> wesentlichen Teilen nicht mehr:
>
> - Die Org-Tabellen `divisions` / `departments` / `teams` und die Tabelle
> `positions` gibt es nicht mehr. An ihrer Stelle steht das SAP-OM-Modell
> (`org_units`, `jobs`, `om_positions`, `position_assignments`) mit
> zeitabhängigen Zuordnungen.
> - Supabase Auth, der anon key und die Service-Role sind weg. Angemeldet
> wird über Auth.js gegen Entra ID, die Konten stehen in `app_users`, und
> der Zugriff läuft über eine Rolle ohne `BYPASSRLS`.
> - Die Zahl der RLS-Policies ist 21, nicht 58.
>
> Der Datenkatalog wurde aus der laufenden Datenbank erzeugt und gilt. Was
> hier noch stimmt — die Grundprinzipien und der Abschnitt zu den
> effective-dated changes — steht dort ebenfalls.
Beschreibt das tatsächliche Supabase-Schema (siehe `supabase/migrations/`),
nicht ein generisches HR-Schema. Quelle der Wahrheit sind immer die
Migrationen; dieses Dokument ist eine lesbare Zusammenfassung und wird bei

421
docs/datenkatalog.md Normal file
View File

@@ -0,0 +1,421 @@
# Datenkatalog
Jede Tabelle, jede Spalte, jede Regel — ausgelesen aus der laufenden
Datenbank am **13.08.2026**.
Die Wahrheit steht in `supabase/migrations/` (48 Dateien). Dieses Dokument
ist eine lesbare Fassung davon und wurde nicht abgetippt, sondern aus dem
Systemkatalog erzeugt: Spaltentypen, Vorgabewerte, Schlüssel und
Prüfbedingungen stammen aus `information_schema` und `pg_catalog`. Wo unten
eine Regel in Worten steht, steht daneben, aus welcher `CHECK`-Bedingung sie
kommt.
> **Nicht verwechseln mit `docs/data-model.md`.** Das Dokument beschreibt den
> Stand vor der Umstellung auf das SAP-OM-Modell und auf Auth.js — es nennt
> Tabellen (`divisions`, `departments`, `teams`, `positions`), die es nicht
> mehr gibt. Bei Widerspruch gilt dieser Katalog.
| | |
|---|---|
| Tabellen | 15 |
| Spalten | 142 |
| Aufzählungstypen | 10 |
| Sichten (Views) | 0 |
| Eigene SQL-Funktionen | 38 (plus 31 aus der Erweiterung `pg_trgm`) |
| RLS-Policies | 21, auf jeder Tabelle mindestens eine |
---
## Wie das Modell gedacht ist
Drei Entscheidungen erklären fast jede Eigenheit weiter unten.
**Person und Planstelle sind zwei Dinge.** Eine Person (`employees`) hat
keine Spalte „Abteilung". Sie sitzt auf einer Planstelle (`om_positions`),
und die Planstelle hängt in einer Organisationseinheit (`org_units`). Wo
jemand arbeitet, ist damit nicht ein Feld, sondern ein Weg über zwei
Tabellen. Der Preis ist ein Join; dafür lässt sich eine Planstelle
ausschreiben, bevor jemand darauf sitzt, und eine Person wechseln, ohne dass
die Stelle verschwindet.
**Zuordnungen haben ein Von und ein Bis.** `position_assignments`,
`om_positions` und `org_units` tragen alle `valid_from` / `valid_to` als
halboffenes Intervall: `valid_from` gehört dazu, `valid_to` nicht mehr. Eine
Zuordnung, die heute endet, hat `valid_to = heute` und gilt heute schon nicht
mehr. Deshalb kann jede Auswertung einen Stichtag haben, auch einen in der
Vergangenheit, und deshalb ist eine Versetzung zum Ersten des nächsten Monats
kein Termin im Kalender, sondern eine Zeile, die erst dann greift.
**Geschichte wird geschrieben, nicht überschrieben.** `employee_history` und
`audit_log` haben keine Update- und keine Delete-Policy — RLS lässt nur
`select` und `insert` zu. Eine Korrektur ist ein neuer Eintrag, nie eine
geänderte Zeile.
Zwei Ausnahmen gibt es, und sie sind eng: `delete_history_entry` nimmt eine
irrtümlich erfasste Stammdaten- oder Vertragsänderung samt ihrer Wirkung
zurück, `update_history_entry` berichtigt Wert und Datum einer solchen. Die
Policies bleiben dabei unangetastet — beide laufen als `SECURITY DEFINER` an
ihnen vorbei und prüfen die Berechtigung selbst. Das Protokoll behält den
Vorgang, dort verschwindet nichts.
Die Namen der OM-Tabellen sind nicht zufällig gewählt: `org_units` ist der
SAP-Objekttyp O, `jobs` ist C, `om_positions` ist S, `employees` ist P, und
`position_assignments` ist die Verknüpfung A008 („Inhaber ist"). Das Flag
`is_chief` entspricht A012 („ist Leiter von"). Wer das Modell aus SAP kennt,
findet sich wieder; wer nicht, verliert nichts.
```
org_units ──parent_id──┐ (rekursiv: Gesellschaft › Bereich › Abteilung › Team)
│ │
└──────────────────┘
▲
│ org_unit_id
om_positions ──job_id──► jobs
▲
│ position_id
position_assignments ──employee_id──► employees ──location_id──► locations
▲
├── employee_history (Ereignisse)
├── employee_dependents (Angehörige)
├── employee_notes (HR-Notizen)
├── pending_org_changes (wirkt später)
└── audit_log (wer/wann/was)
app_users ──1:1──► profiles (Anmeldung ist nicht Berechtigung)
```
---
## Personen
### `employees` — 46 Spalten
Die Person selbst: Stammdaten, Vertrag, Status. **Nicht** die Organisation —
die kommt über die Planstelle.
| Spalte | Typ | Null | Vorgabe | Bedeutung |
|---|---|---|---|---|
| `id` | uuid | – | `gen_random_uuid()` | Schlüssel |
| `personnel_number` | int4 | – | – | **Eindeutig.** Wird eingegeben, nicht vergeben — sie muss mit Loga/Interflex übereinstimmen |
| `first_name`, `last_name` | text | – | – | |
| `gender` | `gender_type` | – | – | `m` / `w` |
| `birth_date` | date | – | – | |
| `sv_nummer` | text | ja | – | Österreichische SV-Nummer; gegen Prüfziffer *und* Geburtsdatum geprüft (Trigger `fn_validate_employee_svnr`) |
| `nationality` | text | – | `'Österreich'` | |
| `address`, `postal_code`, `city`, `address_country` | text | ja | – | Wohnanschrift |
| `email` | text | ja | – | **Private** E-Mail. Freiwillig, aber eindeutig, wenn angegeben |
| `phone` | text | ja | – | **Private** Telefonnummer. Freiwillig |
| `job_title` | text | – | – | Anzeigetitel. Der verbindliche Titel steht am Job der Planstelle |
| `location_id` | uuid | – | – | → `locations` |
| `employment_type` | `employment_type` | – | `Vollzeit` | |
| `weekly_hours` | numeric | – | `38.5` | An `employment_type` gekoppelt, siehe Regeln |
| `contract_type` | `contract_type` | – | `unbefristet` | |
| `contract_end_date` | date | ja | – | Pflicht bei `befristet` |
| `worker_type` | `worker_type` | – | `Angestellte:r` | |
| `collective_agreement` | `collective_agreement` | – | `Handel` | |
| `paygrade` | `paygrade_type` | – | `B` | A–F |
| `source` | `source_type` | – | `Extern` | Intern besetzt oder extern geholt |
| `work_days` | text[] | – | `{Mo,Di,Mi,Do,Fr}` | In Klickreihenfolge gespeichert, nicht sortiert |
| `status` | `employment_status` | – | `Aktiv` | Gilt für **heute**; für einen Stichtag wird er zurückgerechnet |
| `entry_date` | date | – | – | |
| `exit_date`, `exit_reason` | date/text | ja | – | |
| `karenz_start_date`, `karenz_return_date` | date | ja | – | Laufende Langzeitabwesenheit |
| `absence_type` | text | ja | – | Art der Abwesenheit; wird bei der Rückkehr geleert. 13 erlaubte Werte |
| `is_betriebsrat`, `is_laterale_fuehrung`, `is_c_level` | bool | – | `false` | |
| `has_dienstwagen` | bool | – | `false` | |
| `dienstwagen_art` | text | ja | – | `Verbrenner` / `Elektro`; nur zusammen mit `has_dienstwagen` |
| `emergency_contact_name`, `_phone`, `_relation` | text | ja | – | Daten einer dritten Person, nur für den Notfall erhoben |
| `title_prefix`, `title_suffix` | text[] | – | `{}` | Akademische Grade, gegen feste Listen geprüft |
| `avatar_color` | text | ja | – | Darstellung |
| `monthly_salary_gross` | numeric | ja | – | **Stillgelegt.** Gehalt ist ausserhalb des Funktionsumfangs; keine Funktion liest oder schreibt die Spalte mehr. Steht nur noch da, falls Altdaten drin sind |
| `created_at`, `updated_at` | timestamptz | – | `now()` | `updated_at` per Trigger |
**Regeln, die die Datenbank durchsetzt:**
- *Vollzeit heisst 38,5 Stunden; Teilzeit heisst mehr als 0 und weniger als
38,5.* Ein Vollzeitvertrag mit 30 Stunden lässt sich nicht speichern
(`chk_weekly_hours`).
- *Befristet ohne Enddatum gibt es nicht* (`chk_befristet_end`).
- *Austritt nicht vor Eintritt*, *Rückkehr nicht vor Eintritt*
(`chk_exit_after_entry`, `chk_karenz_return_after_entry`).
- *Dienstwagen und Antriebsart gehören zusammen* — beides oder keins
(`chk_dienstwagen_art`). Die Bedingung nennt den Fall ohne Wagen
ausdrücklich, weil `art in (…)` bei `null` weder wahr noch falsch ergibt
und die Regel sonst genau das durchgelassen hätte, was sie verhindern soll.
- *Notfallkontakt: Name und Telefon gemeinsam oder gar nicht*, und keiner der
beiden leer (`chk_emergency_contact`).
- *Arbeitstage nur aus Mo–So und mindestens einer* (`chk_work_days_valid`).
- *Titel nur aus den bekannten Listen* — 10 vorangestellte, 12 nachgestellte
(`chk_title_prefix_valid`, `chk_title_suffix_valid`).
- *Abwesenheitsart nur aus den 13 bekannten* (`chk_absence_type`).
### `employee_history` — die Zeitleiste
Eine Zeile je Ereignis: `employee_id`, `event_date`, `event_type`
(Aufzählung, 11 Werte), `description`. Ein Trigger verhindert Ereignisse vor
dem Eintrittsdatum (`fn_check_history_not_before_entry`). Nur einfügen und
lesen — kein Ändern, kein Löschen.
### `employee_dependents` — Angehörige
`first_name`, `last_name`, `relationship` (Ehepartner:in / Lebenspartner:in /
Kind / Sonstige), `birth_date`, optional `sv_nummer`. Ändern heisst löschen
und neu anlegen; ein In-place-Update gibt es nicht. Beim Löschen der Person
verschwinden sie mit (`on delete cascade`).
### `employee_notes` — HR-Notizen
`category` (Allgemein / Vertraulich / Personalgespräch / Wiedervorlage /
Lob / Anerkennung), `note_text`, optional `due_date` für die Wiedervorlage,
dazu `done` / `done_at` / `done_by`.
Bewusst **nicht** auf die verfassende Person eingeschränkt: jede aktive
HR-Person sieht jede offene Notiz. „Meine Notizen" ist ein gemeinsames
Postfach, kein privates.
---
## Organisation
### `org_units` — Einheiten (SAP-Objekttyp O)
`org_number` (eindeutig), `name`, `parent_id` (rekursiv), `unit_type`
(Gesellschaft / Bereich / Abteilung / Team), `valid_from` / `valid_to`.
`unit_type` ist ein Etikett für die Anzeige, keine Struktur — die Struktur
ist `parent_id`. Eine Abteilung unter einer Abteilung wäre erlaubt. Was die
Datenbank verhindert, ist nur, dass eine Einheit ihr eigenes Elternteil wird
(`chk_org_unit_not_own_parent`); tiefere Zyklen fängt sie nicht ab.
### `jobs` — Tätigkeiten (Objekttyp C)
`code` und `title`, beide eindeutig. Ein schlanker Katalog: die Planstelle
verweist darauf, statt den Titel abzuschreiben.
### `om_positions` — Planstellen (Objekttyp S)
`position_number` (eindeutig), `org_unit_id`, `job_id`, `is_chief`,
`valid_from` / `valid_to`.
Die Nummern haben die Form `6` + sieben Ziffern, weil
`next_position_number()` sie so erzeugt — erzwungen wird das Format aber
nicht: an der Spalte hängt nur Eindeutigkeit. Wer von aussen eine Nummer
einträgt, kann eine andere Form wählen, und die Zählfunktion übergeht sie
dann (sie sucht ihr Maximum nur unter `^6[0-9]{7}$`).
`is_chief` markiert die Leitungsstelle einer Einheit — daraus entsteht die
Führungslinie, nicht aus einem Feld „Vorgesetzte:r" an der Person. Eine
Planstelle lässt sich nur besetzen, solange sie gültig ist.
### `position_assignments` — Besetzungen (Verknüpfung A008)
`position_id`, `employee_id`, `valid_from` / `valid_to`. Diese Tabelle
beantwortet „wer sass wann wo" — die einzige Stelle, an der das steht.
### `locations` — Standorte
`name` (eindeutig) und `country`, beschränkt auf Österreich, Deutschland,
Tschechien und Slowenien.
---
## Ablauf und Nachweis
### `pending_org_changes` — was später wirkt
`change_type` (transfer / promotion / karenz_start / karenz_return /
contract_change / reorg / dependent_add / dependent_remove),
`effective_date`, `payload` (JSONB), `status` (pending / applied /
cancelled).
Der Weg ist `pending` → `applied`. Die Auswahl filtert immer auf `pending`,
ein zweiter Lauf wirkt also nicht doppelt. Verarbeitet wird täglich von
`apply_due_pending_changes()`.
### `audit_log` — wer, wann, was, an wem
`occurred_at`, `actor_user_id` + `actor_name`, `action`, `target_label`,
`target_employee_id`, `details`, und `changes` als JSONB in der Form
`[{feld, vorher, nachher}]` — daher die aufklappbare Detailansicht in der
Oberfläche. Bei Einträgen von vor der entsprechenden Migration ist `changes`
null.
Geschrieben wird ausschliesslich aus den SQL-Funktionen heraus, in derselben
Transaktion wie die Änderung selbst. Das ist der Punkt: ein fehlgeschlagener
Log-Eintrag lässt die ganze Änderung scheitern, statt still eine Änderung
ohne Nachweis zu hinterlassen. Einen Helfer im Anwendungscode gibt es nicht
und sollte es nicht geben — das wäre eine zweite, nicht atomare Quelle.
Dass `actor_name` als Text mitgeschrieben wird und nicht nur die Kennung: der
Nachweis soll lesbar bleiben, auch wenn das Benutzerkonto später verschwindet.
---
## Zugang
### `app_users`
Ersetzt `auth.users` aus der Supabase-Zeit. `external_id` ist die `oid` aus
Entra ID — **nicht** die E-Mail-Adresse, die kann sich ändern. Angelegt wird
die Zeile bei der ersten Anmeldung durch `app_upsert_user()`.
### `profiles`
Eine Zeile je Konto, gleicher Schlüssel wie `app_users`. `role` ist per
Prüfbedingung auf den einen Wert `'hr'` festgenagelt, `is_active` steht
anfangs auf `false`.
**Anmelden können heisst nichts.** Wer sich mit dem Firmenkonto anmeldet,
bekommt eine `app_users`-Zeile und kommt trotzdem an keine einzige
Personalzeile, solange `profiles.is_active` nicht gesetzt ist. Die Freigabe
ist ein bewusster zweiter Schritt.
### `hire_drafts`, `saved_reports`
Zwischenstand des Einstellungsassistenten (`payload` JSONB, `step`) und
gespeicherte Berichtskonfigurationen. Beide sind auf die anlegende Person
eingeschränkt.
---
## Aufzählungstypen
| Typ | Werte |
|---|---|
| `employment_status` | Aktiv, Karenz, Geplant, Ausgetreten |
| `employment_type` | Vollzeit, Teilzeit |
| `contract_type` | unbefristet, befristet |
| `worker_type` | Angestellte:r, Arbeiter:in |
| `collective_agreement` | Handel, Süßwaren |
| `paygrade_type` | A, B, C, D, E, F |
| `source_type` | Intern, Extern |
| `gender_type` | m, w |
| `org_unit_type` | Gesellschaft, Bereich, Abteilung, Team |
| `history_event_type` | Eintritt, Beförderung, Versetzung, Karenz, Vertragsänderung, Stammdatenänderung, Austritt, Wiedereintritt, Reorganisation, Gehaltsanpassung, Rückkehr |
`Karenz` heisst in der Oberfläche „Langzeitabwesenheit" — der gespeicherte
Wert wurde beim Umbenennen bewusst nicht angefasst, die Beschriftung folgt
dem neuen Begriff.
Nicht als Aufzählungstyp, sondern als Prüfbedingung auf einer Textspalte
gelöst: Abwesenheitsart, Antriebsart, Verhältnis von Angehörigen,
Notizkategorie, Änderungsart, Land. Der praktische Unterschied: eine
Prüfbedingung lässt sich in einer Migration ändern, ein Aufzählungstyp nur
erweitern.
---
## Die SQL-Schnittstelle
Änderungen laufen nicht über `insert`/`update` aus der Anwendung, sondern
über Funktionen. Jede schreibt ihren Nachweis und ihre Historie in derselben
Transaktion mit.
**Personal:** `hire_employee`, `rehire_employee`, `terminate_employee`,
`transfer_employee`, `promote_employee`, `change_employee_data`,
`start_karenz`, `adjust_karenz_return`, `record_karenz_return`
**Planstellen:** `create_position`, `update_position`, `delete_position`,
`next_position_number`
**Historie:** `delete_history_entry` nimmt eine irrtümliche Stammdaten- oder
Vertragsänderung zurück: setzt je Feld auf den Wert davor, sofern kein
späterer Eintrag dasselbe Feld angefasst hat, und entfernt die Zeile.
`update_history_entry` berichtigt stattdessen Wert und Datum — das „vorher"
bleibt unangetastet, und der heutige Stand wird je Feld aus dem jüngsten
Eintrag abgeleitet, der es trägt. Beide sind der einzige Weg an den fehlenden
`update`- und `delete`-Policies vorbei, deshalb `SECURITY DEFINER` und mit
`require_hr_admin()` davor. `app_feld_karte` liefert beiden die Zuordnung
Beschriftung → Spalte und Typ.
**Umfeld:** `add_employee_dependent`, `delete_employee_dependent`,
`add_employee_note`, `complete_employee_note`
**Auswertung:** `om_reporting_lines(p_as_of)` — löst zum Stichtag auf, wer an
wen berichtet, samt Vertretung bei Abwesenheit (`acting_manager_id` neben
`formal_manager_id`)
**Zugang und Nachweis:** `is_hr_user`, `app_current_user_id`,
`app_upsert_user`, `current_actor_name`, `app_aenderung`,
`app_aenderungsfelder`
**Automatik:** `apply_due_pending_changes` (täglich), `rls_auto_enable`
(hängt am Ereignis-Trigger `ensure_rls`: neue Tabellen bekommen sofort RLS),
die vier `fn_*`-Trigger, `is_valid_svnr`
Sechs Funktionen laufen als `SECURITY DEFINER`, also mit den Rechten ihrer
Eigentümerin statt der aufrufenden Person: `is_hr_user`,
`app_current_user_id`, `app_upsert_user`, `apply_due_pending_changes`,
`delete_history_entry` und `update_history_entry`. Die ersten drei müssen es
sein, weil sie sonst gegen dieselben Policies liefen, die sie gerade auswerten
sollen — eine Rekursion. Die vierte läuft ohne angemeldete Person, es gibt ja
nur den Zeitplan. Die letzten beiden müssen an `employee_history` schreiben,
wo es absichtlich weder eine `update`- noch eine `delete`-Policy gibt; beide
prüfen die Berechtigung deshalb selbst, in ihrer ersten Zeile.
**Der Türsteher:** `require_hr_admin()` steht am Anfang von **16**
Funktionen — jeder ändernden. Es wirft, wenn `is_hr_user()` falsch ist, und
liefert damit eine lesbare Meldung statt einer nackten RLS-Verletzung. Der
Name täuscht: ein Admin-Rollenmodell gibt es nicht, `is_hr_admin()` ruft
schlicht `is_hr_user()` auf. Die Schranke selbst bleiben die Policies.
**Übrig geblieben:** `generate_company_email` steht noch in der Datenbank,
wird aber von nichts mehr gerufen — sie stammt aus der Zeit, als eine
Firmenadresse automatisch vergeben wurde; heute ist `employees.email` die
private Adresse und freiwillig.
---
## Zugriffsschutz
Auf **jeder** der 15 Tabellen ist Row Level Security aktiv, zusammen 21
Policies. Fast alle prüfen dasselbe: `is_hr_user()` — also `profiles.role =
'hr'` **und** `is_active`. Nachgereicht wird das nicht: der Ereignis-Trigger
`ensure_rls` schaltet RLS bei jeder neu angelegten Tabelle sofort ein.
Die Prüfung hängt an einer Sitzungsvariablen (`app.user_id`), die
`withUser()` als erste Anweisung jeder Transaktion setzt — transaktionslokal,
damit sie nicht an der Verbindung kleben bleibt und die nächste Anfrage aus
dem Pool mit fremder Kennung läuft.
Wie wirksam das ist, zeigt sich beim Erzeugen dieses Katalogs: die Verbindung
lief ohne Sitzungskontext, und **jede** Tabelle lieferte null Zeilen — bei
vollständig vorhandenen Strukturdaten. Nicht „alles", nicht ein Fehler,
sondern nichts. Die Anwendung verbindet sich ausserdem als `alpenwerk_app` —
eine Rolle ohne `BYPASSRLS` und ohne Superuser-Recht; es gibt also keinen Weg
daran vorbei, auch nicht versehentlich.
Wo das Muster abweicht:
- `audit_log` und `employee_history` haben je zwei Policies — lesen und
einfügen, getrennt, und kein Ändern oder Löschen. Das ist die
Unveränderlichkeit, technisch durchgesetzt.
- `profiles` hat vier, weil dort auch die Freischaltung anderer Konten
passiert — und eine davon lässt jede Person die *eigene* Zeile lesen, auch
ohne Freischaltung. Sonst könnte niemand erfahren, warum er nicht
hineinkommt.
- `app_users` ebenso: die eigene Zeile oder HR.
- `hire_drafts` und `saved_reports` verlangen zusätzlich, dass die Zeile der
anfragenden Person gehört.
- `locations` trennt Lesen und Schreiben in zwei Policies, prüft aber beide
Male dasselbe.
---
## Was hier nicht steht
**Gehalt.** `monthly_salary_gross` ist stillgelegt und wird von keiner
Funktion mehr angefasst. Gehaltsdaten leben in Loga.
**Zeitwirtschaft.** Kommen und Gehen, Urlaubskonten, Krankenstände als
Einzelfälle — das ist Interflex. Hier steht nur die dauerhafte
Langzeitabwesenheit, weil sie die Führungslinie verschiebt.
**Bewerbungen.** Eine offene Planstelle ist hier eine Planstelle ohne
Besetzung, mehr nicht.
---
*Erzeugt aus dem Systemkatalog der laufenden Datenbank. Nach strukturellen
Änderungen gehört dieses Dokument nachgezogen — am ehrlichsten, indem es neu
aus der Datenbank erzeugt wird, statt es von Hand zu pflegen.*

137
docs/entra-sso.md Normal file
View File

@@ -0,0 +1,137 @@
# 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 21 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,6 +13,50 @@ 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

@@ -7,18 +7,24 @@ import type { EmploymentStatus } from "./supabase/types";
// a label change (see supabase/migrations/*_absence_type.sql). So the value
// is mapped to its display name here, in the one place the UI reads it from.
// Nur echte Abwesenheiten. Eine Teilzeit ist keine: wer in Bildungs-,
// Pflege-, Eltern- oder Wiedereingliederungsteilzeit ist, arbeitet — nur
// weniger. Als „Langzeitabwesenheit" geführt, verschwand die Person aus dem
// Bestand, verlor ihre Berichtslinie an eine Vertretung und zählte in
// Auswertungen nicht mehr mit, obwohl sie jede Woche im Haus war.
//
// Die vier sind deshalb hierher gewandert, wo sie hingehören: zur
// Stundenänderung (siehe STUNDEN_GRUENDE) und zur Rückkehr aus einer
// Abwesenheit (RUECKKEHR_GRUENDE).
export const ABSENCE_TYPES = [
"Wochenhilfe (Mutterschutz)",
"Elternkarenz (inkl. Väterkarenz)",
"Papamonat",
"Bildungskarenz",
"Bildungsteilzeit",
"Präsenzdienst",
"Zivildienst",
"Langer Krankenstand",
"Wiedereingliederungsteilzeit",
"Pflegekarenz",
"Pflegeteilzeit",
"Familienhospizkarenz",
"Sabbatical",
] as const;
@@ -29,6 +35,25 @@ export function isAbsenceType(value: string | null | undefined): value is Absenc
return ABSENCE_TYPES.includes(value as AbsenceType);
}
/**
* Warum jemand nach einer Abwesenheit mit weniger Stunden zurückkommt.
*
* Beides sind Ansprüche, keine freien Vereinbarungen — und beide beginnen
* typischerweise genau dann, wenn die Abwesenheit endet.
*/
export const RUECKKEHR_GRUENDE = ["Wiedereingliederungsteilzeit", "Elternteilzeit"] as const;
/**
* Warum sich die Wochenstunden ändern.
*
* Der erste Wert ist der Normalfall; die beiden anderen sind gesetzlich
* geregelte Teilzeiten, die man später wiederfinden können muss — im
* Zweifel Jahre danach, wenn jemand fragt, warum die Stunden damals sanken.
*/
export const STUNDEN_GRUENDE = ["Vertragliche Stundenänderung", "Bildungsteilzeit", "Pflegeteilzeit"] as const;
export type StundenGrund = (typeof STUNDEN_GRUENDE)[number];
/** Display names for the stored status values. */
const STATUS_LABELS: Record<EmploymentStatus, string> = {
Aktiv: "Aktiv",

139
lib/auth/config.ts Normal file
View File

@@ -0,0 +1,139 @@
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);
},
},
};
}

26
lib/auth/require-hr.ts Normal file
View File

@@ -0,0 +1,26 @@
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 };
}

32
lib/auth/session.ts Normal file
View File

@@ -0,0 +1,32 @@
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 21 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;
}

107
lib/cost-centers.ts Normal file
View File

@@ -0,0 +1,107 @@
import type { Tx } from "./db";
import { jsonArrayFrom } from "./db/json";
import { todayIso } from "./format";
import type { OrgEb } from "./org";
// Die Kostenstelle einer Planstelle — zum Stichtag, nicht „aktuell".
//
// Sie hängt an der Planstelle, nicht an der Person: der Sitz kostet Geld, auch
// wenn niemand darauf sitzt. Genau das ist die Frage bei einer Vakanz.
//
// Und sie hat einen Zeitraum, weil eine Umkontierung ein Ereignis mit Stichtag
// ist. Wer nach den Kosten des Vorjahres fragt, muss die Kostenstelle von
// damals bekommen — sonst verändert jede Umkontierung rückwirkend jede alte
// Auswertung, und das merkt niemand.
export type Kostenstelle = {
id: string;
code: string;
name: string;
};
export type KontierungsZeile = {
position_id: string;
cost_center_id: string;
code: string;
name: string;
valid_from: string;
valid_to: string | null;
};
/**
* Die Kontierungen als *Teilabfrage* — zum Einhängen in die eine Abfrage, die
* eine Seite ohnehin stellt (lib/db/json.ts).
*
* Ohne `positionIds` alle. Gefiltert wird nicht nach Stichtag: die Auswahl
* trifft `kontierungZum` im Speicher, damit dieselben Zeilen für mehrere
* Stichtage reichen und die Abfrage eine bleibt.
*/
export function kontierungenAbfrage(eb: OrgEb, positionIds?: string[]) {
const q = eb
.selectFrom("position_cost_centers as z")
.innerJoin("cost_centers as k", "k.id", "z.cost_center_id")
.select(["z.position_id", "z.cost_center_id", "k.code", "k.name", "z.valid_from", "z.valid_to"])
.orderBy("z.position_id")
.orderBy("z.valid_from");
return positionIds ? q.where("z.position_id", "in", positionIds) : q;
}
/** Alle Kostenstellen zur Auswahl — abgelaufene bleiben draussen. */
export function kostenstellenAbfrage(eb: OrgEb, asOf: string = todayIso()) {
return eb
.selectFrom("cost_centers")
.select(["id", "code", "name"])
.where((e) => e.or([e("valid_to", "is", null), e("valid_to", ">", asOf)]))
.orderBy("code");
}
/**
* Welche Kostenstelle je Planstelle am Stichtag galt.
*
* Halboffen [valid_from, valid_to): der letzte Tag gehört schon zur nächsten
* Kontierung. Dieselbe Regel wie bei den Besetzungen — eine zweite Auslegung
* desselben Zeitraummodells wäre der sichere Weg zu zwei Antworten auf
* dieselbe Frage.
*/
export function kontierungZum(rows: KontierungsZeile[], asOf: string): Map<string, Kostenstelle> {
const out = new Map<string, Kostenstelle>();
for (const r of rows) {
if (r.valid_from > asOf) continue;
if (r.valid_to !== null && r.valid_to <= asOf) continue;
out.set(r.position_id, { id: r.cost_center_id, code: r.code, name: r.name });
}
return out;
}
/**
* Wie kontierungZum, aber für eine einzelne Planstelle und einen Stichtag,
* der nicht heute sein muss.
*
* Nötig für Planstellen, die erst entstehen: ihre Kontierung beginnt mit
* ihnen. Zu heute gefragt gäbe es keine — und die Vakanzliste, die künftige
* Stellen bewusst zeigt, stünde für genau diese ohne Kostenstelle da. Die
* Frage lautet dort nicht „wer zahlt heute", sondern „wer zahlt, wenn es
* losgeht".
*/
export function kontierungAm(rows: KontierungsZeile[], positionId: string, asOf: string): Kostenstelle | null {
for (const r of rows) {
if (r.position_id !== positionId) continue;
if (r.valid_from > asOf) continue;
if (r.valid_to !== null && r.valid_to <= asOf) continue;
return { id: r.cost_center_id, code: r.code, name: r.name };
}
return null;
}
/** Der bequeme Weg für Aufrufer ohne eigene Abfrage. */
export async function loadKontierungen(
tx: Tx,
{ asOf, positionIds }: { asOf: string; positionIds?: string[] }
): Promise<Map<string, Kostenstelle>> {
if (positionIds?.length === 0) return new Map();
const { rows } = await tx
.selectNoFrom((eb) => [jsonArrayFrom(kontierungenAbfrage(eb, positionIds)).as("rows")])
.executeTakeFirstOrThrow();
return kontierungZum(rows as KontierungsZeile[], asOf);
}

View File

@@ -197,3 +197,63 @@ export const UN_COUNTRIES: string[] = [
"Zentralafrikanische Republik",
"Zypern",
].sort((a, b) => a.localeCompare(b, "de"));
// Wer keinen Aufenthaltstitel braucht.
//
// EU-27, dazu Island, Liechtenstein und Norwegen (EWR) sowie die Schweiz.
// Für diese Staatsangehörigen gilt Freizügigkeit; ein Aufenthaltstitel ist
// weder nötig noch zu erfassen. Für alle übrigen ist er es, und dann will HR
// wissen, bis wann er läuft.
//
// Die Liste steht hier und nicht in der Datenbank: eine Prüfbedingung, die
// Staatsbürgerschaft und Titel koppelt, würde bei jeder Korrektur der
// Staatsbürgerschaft zuschlagen — und Beitritte müssten als Migration
// nachgezogen werden, statt als Zeile in einer Liste.
export const FREIZUEGIGKEIT: readonly string[] = [
// EU
"Belgien",
"Bulgarien",
"Dänemark",
"Deutschland",
"Estland",
"Finnland",
"Frankreich",
"Griechenland",
"Irland",
"Italien",
"Kroatien",
"Lettland",
"Litauen",
"Luxemburg",
"Malta",
"Niederlande",
"Österreich",
"Polen",
"Portugal",
"Rumänien",
"Schweden",
"Slowakei",
"Slowenien",
"Spanien",
"Tschechien",
"Ungarn",
"Zypern",
// EWR
"Island",
"Liechtenstein",
"Norwegen",
// Bilaterale Freizügigkeit
"Schweiz",
];
/**
* Braucht jemand mit dieser Staatsbürgerschaft einen Aufenthaltstitel?
*
* Bei leerer Angabe: nein. Eine fehlende Staatsbürgerschaft ist kein Anlass,
* jemandem einen Titel abzuverlangen — sie ist ein Anlass, sie zu erfassen.
*/
export function brauchtAufenthaltstitel(nationality: string | null | undefined): boolean {
const n = nationality?.trim();
if (!n) return false;
return !FREIZUEGIGKEIT.includes(n);
}

159
lib/dashboard-data.ts Normal file
View File

@@ -0,0 +1,159 @@
import type { Tx } from "./db";
import { jsonArrayFrom, jsonObjectFrom, zeitstempel } from "./db/json";
import { besetzungenAbfrage, pickPlacements } from "./placement";
import { buildOrgMaps, orgMapsAbfragen, type OrgEb } from "./org";
import { offeneStellenAbfrage, resolveOpenPositions, type OffeneStelle } from "./positions";
import type { HistoryEventType } from "./supabase/types";
import type { AnstehendArt } from "./dashboard-filter";
// Was die Übersichtsseite liest — in zwei Rundreisen statt in dreizehn.
//
// Hier und nicht in der Seite, damit sich die Zahl der Rundreisen messen und
// das Ergebnis gegen den alten Weg halten lässt, ohne eine React-Komponente
// aufzubauen.
export type DashboardParams = {
userId: string | null;
today: string;
yearStart: string;
yearEnd: string;
bisIso: string;
arten: AnstehendArt[];
};
export async function loadDashboardData(tx: Tx, p: DashboardParams) {
const { userId, today, yearStart, yearEnd, bisIso, arten } = p;
const zeigt = (art: AnstehendArt) => arten.includes(art);
// Elf Abfragen standen hier in einem Promise.all, das keine
// Gleichzeitigkeit war: eine Transaktion hängt an einer Verbindung, und
// über eine Verbindung laufen Abfragen nacheinander. Bei rund 36 ms
// Umlaufzeit war das eine knappe Sekunde Warten für Daten, die zusammen
// keine 200 kB wiegen. Jetzt: eine Rundreise, und eine zweite für die
// offenen Stellen, deren zweite Hälfte vom Ergebnis der ersten abhängt.
// Der Weg dahin steht in lib/db/json.ts.
const countIn = (eb: OrgEb, types: readonly HistoryEventType[]) =>
eb
.selectFrom("employee_history")
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
.where("event_type", "in", [...types])
.where("event_date", ">=", yearStart)
.where("event_date", "<=", yearEnd);
const g = await tx
.selectNoFrom((eb) => [
jsonArrayFrom(
eb
.selectFrom("hire_drafts")
.select(["id", "step", "payload"])
.select((x) => zeitstempel(x.ref("updated_at")).as("updated_at"))
.where("created_by", "=", userId ?? "")
.orderBy("updated_at", "desc")
).as("drafts"),
jsonArrayFrom(
eb
.selectFrom("employees")
.select(["id", "weekly_hours", "entry_date", "exit_date", "karenz_start_date", "karenz_return_date"])
.orderBy("id")
).as("staffRows"),
// 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.
jsonObjectFrom(countIn(eb, ["Eintritt", "Wiedereintritt"])).as("hiresYtd"),
jsonObjectFrom(countIn(eb, ["Austritt"])).as("exitsYtd"),
...orgMapsAbfragen(eb),
jsonArrayFrom(offeneStellenAbfrage(eb, today)).as("open"),
jsonArrayFrom(besetzungenAbfrage(eb)).as("placementRows"),
// Abgewählte Arten werden gar nicht erst gelesen — die Karte zeigt sie
// ohnehin nicht, und eine Teilabfrage, deren Ergebnis niemand ansieht,
// ist eine Teilabfrage zu viel.
jsonArrayFrom(
eb
.selectFrom("employees")
.select(["id", "first_name", "last_name", "entry_date"])
.where("status", "=", "Geplant")
.where("entry_date", ">=", today)
.where("entry_date", "<=", bisIso)
.where((e) => e.lit(zeigt("hire")))
).as("upcomingHires"),
jsonArrayFrom(
eb
.selectFrom("employees")
.select(["id", "first_name", "last_name", "exit_date"])
.where("exit_date", "is not", null)
.where("exit_date", ">=", today)
.where("exit_date", "<=", bisIso)
.where((e) => e.lit(zeigt("exit")))
).as("upcomingExits"),
jsonArrayFrom(
eb
.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", "<=", bisIso)
.where((e) => e.lit(zeigt("return")))
).as("upcomingReturns"),
// Wiedervorlagen. Sie verhalten sich anders als der Rest dieser Karte:
// ein Eintritt am Montag ist am Dienstag vorbei, eine Wiedervorlage
// nicht. Deshalb gibt es hier **keine untere Grenze** — was fällig war
// und nicht abgehakt wurde, bleibt stehen, bis jemand „Erledigt" klickt.
// Eine Aufgabe, die von selbst aus der Liste rutscht, ist eine
// vergessene Aufgabe.
jsonArrayFrom(
eb
.selectFrom("employee_notes as n")
.innerJoin("employees as e", "e.id", "n.employee_id")
.select(["n.id", "n.employee_id", "n.due_date", "n.category", "n.note_text", "e.first_name", "e.last_name"])
.where("n.done", "=", false)
.where("n.due_date", "is not", null)
.where("n.due_date", "<=", bisIso)
.where((x) => x.lit(zeigt("note")))
.orderBy("n.due_date")
).as("upcomingNotes"),
jsonArrayFrom(
eb
.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)
).as("history"),
])
.executeTakeFirstOrThrow();
const orgMaps = buildOrgMaps(g.units as never, g.locations as never);
return {
drafts: userId ? g.drafts : [],
staffRows: g.staffRows,
hiresYtd: Number(g.hiresYtd?.anzahl ?? 0),
exitsYtd: Number(g.exitsYtd?.anzahl ?? 0),
openPositions: await resolveOpenPositions(tx, orgMaps, g.open as OffeneStelle[], today),
orgMaps,
placements: pickPlacements(g.placementRows as never, today),
upcomingHires: g.upcomingHires,
upcomingExits: g.upcomingExits,
upcomingReturns: g.upcomingReturns,
upcomingNotes: g.upcomingNotes,
history: g.history,
};
}

47
lib/dashboard-filter.ts Normal file
View File

@@ -0,0 +1,47 @@
// Was auf der Übersicht als „anstehend" gilt — Zeitraum und Art.
//
// Die Auswahl steht in der Adresse, nicht im Browser: die Übersicht wird auf
// dem Server gebaut, und ein Zeitraum von 90 statt 60 Tagen bringt Zeilen ins
// Spiel, die im Browser gar nicht liegen. Nebenbei lässt sich eine so
// eingestellte Seite verschicken und wiederfinden.
export const ZEITRAEUME = [30, 60, 90, 180] as const;
export type Zeitraum = (typeof ZEITRAEUME)[number];
export const STANDARD_ZEITRAUM: Zeitraum = 60;
export const ANSTEHEND_ARTEN = [
{ value: "hire", label: "Eintritt" },
{ value: "exit", label: "Austritt" },
{ value: "return", label: "Rückkehr" },
{ value: "note", label: "Wiedervorlage" },
] as const;
export type AnstehendArt = (typeof ANSTEHEND_ARTEN)[number]["value"];
const ALLE_ARTEN: AnstehendArt[] = ANSTEHEND_ARTEN.map((a) => a.value);
/** Alles, was nicht in der Liste steht, führt auf den Standard zurück. */
export function parseZeitraum(wert: string | undefined): Zeitraum {
const zahl = Number(wert);
return (ZEITRAEUME as readonly number[]).includes(zahl) ? (zahl as Zeitraum) : STANDARD_ZEITRAUM;
}
/**
* Keine Angabe heisst „alle" — nicht „keine". Wer die Übersicht aufruft, ohne
* etwas auszuwählen, will alles sehen; eine leere Karte wäre die falsche
* Antwort auf eine nicht gestellte Frage. Aus demselben Grund führt auch eine
* Auswahl, in der nur Unbekanntes steht, zurück auf alle.
*/
export function parseArten(wert: string | undefined): AnstehendArt[] {
const gewaehlt = (wert ?? "")
.split(",")
.map((s) => s.trim())
.filter((s): s is AnstehendArt => (ALLE_ARTEN as string[]).includes(s));
return gewaehlt.length > 0 ? gewaehlt : ALLE_ARTEN;
}
/** Steht in der Adresse etwas anderes als der Standard? */
export function istEingeschraenkt(zeitraum: Zeitraum, arten: AnstehendArt[]): boolean {
return zeitraum !== STANDARD_ZEITRAUM || arten.length !== ALLE_ARTEN.length;
}

118
lib/db/index.ts Normal file
View File

@@ -0,0 +1,118 @@
import "server-only";
import { AsyncLocalStorage } from "node:async_hooks";
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: 21 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().
// ═══ Wie viele Rundreisen eine Anfrage kostet ═══
//
// Eine Transaktion hängt an einer Verbindung, und über eine Verbindung laufen
// Abfragen nacheinander — auch die in einem Promise.all. Bei rund 36 ms
// Umlaufzeit zur Datenbank ist die Zahl der Abfragen deshalb *die* Kennzahl
// für die Ladezeit einer Seite, und zwar eine, die man nicht schätzen muss.
//
// Sie wird darum mitgezählt und im Entwicklungsbetrieb gemeldet, sobald eine
// Transaktion viele davon braucht. Ohne diese Meldung wächst so etwas
// unbemerkt: jede neue Kachel bringt ihre eigene Abfrage mit, und dass die
// Seite langsamer wird, merkt man erst, wenn es alle merken.
const zaehler = new AsyncLocalStorage<{ abfragen: number }>();
export function zaehleAbfragen(): { abfragen: number } | undefined {
return zaehler.getStore();
}
/** Ab wann eine Transaktion im Entwicklungsbetrieb gemeldet wird. */
const MELDESCHWELLE = Number(process.env.DB_QUERY_WARN ?? 6);
const db = new Kysely<Schema>({
dialect: new PostgresDialect({ pool: async () => getPool() }),
log: (event) => {
const store = zaehler.getStore();
if (store) store.abfragen++;
if (event.level === "error") console.error("Abfrage fehlgeschlagen:", event.error);
},
});
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> {
const stand = { abfragen: 0 };
const start = performance.now();
try {
return await zaehler.run(stand, () =>
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);
})
);
} finally {
// Nur im Entwicklungsbetrieb: in der Produktion gehörte das in die
// Ablaufverfolgung, nicht auf die Konsole.
if (process.env.NODE_ENV !== "production" && stand.abfragen > MELDESCHWELLE) {
console.warn(
`[db] ${stand.abfragen} Abfragen in einer Transaktion, ${Math.round(performance.now() - start)} ms — ` +
`sie laufen nacheinander über eine Verbindung. Bündeln: siehe lib/db/json.ts.`
);
}
}
}
/**
* 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 };

69
lib/db/json.ts Normal file
View File

@@ -0,0 +1,69 @@
// Kein `server-only` hier, anders als in ./index.ts und ./pool.ts: diese Datei
// baut nur Abfragen zusammen und hält weder Verbindung noch Zugangsdaten. Mit
// der Sperre wären die reinen Tests von lib/org.ts nicht mehr ladbar, obwohl
// dort nur ein Baum aus Zeilen gebaut wird.
import { sql, type Expression, type RawBuilder } from "kysely";
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/postgres";
// Mehrere unabhängige Lesevorgänge in **einer** Rundreise.
//
// ═══ Warum das nötig ist ═══
//
// Eine Transaktion hängt an genau einer Verbindung, und über eine Verbindung
// laufen Abfragen nacheinander — auch die, die in einem Promise.all stehen.
// Der Treiber stellt sie in eine Schlange. `Promise.all` sieht nach
// Gleichzeitigkeit aus und ist hier keine.
//
// Gemessen an der echten Datenbank: die Umlaufzeit beträgt rund 36 ms, zehn
// belanglose `select 1` über eine Verbindung brauchen 343 ms, über zehn
// Verbindungen 39 ms. Der Aufbau der Übersichtsseite — zehn Abfragen, die
// zusammen keine 200 kB liefern — kostete so knapp eine Sekunde, fast
// ausschliesslich Warten.
//
// Mehr Verbindungen sind trotzdem nicht die Antwort: der Sitzungskontext für
// RLS gilt je Transaktion (siehe ./index.ts), und mehrere Transaktionen je
// Anfrage vervielfachen die Verbindungen, die die Datenbank zulässt. Also
// weniger Rundreisen statt mehr Leitungen: Postgres kann jede Teilabfrage als
// JSON-Spalte in *einem* Ergebnis liefern.
//
// const { einheiten, standorte } = await tx
// .selectNoFrom((eb) => [
// jsonArrayFrom(eb.selectFrom("org_units").select([...])).as("einheiten"),
// jsonArrayFrom(eb.selectFrom("locations").selectAll()).as("standorte"),
// ])
// .executeTakeFirstOrThrow();
//
// Typisiert wie jede andere Kysely-Abfrage, mit Parametern, ohne Handarbeit
// an der Zeichenkette.
//
// ═══ Die eine Falle ═══
//
// Innerhalb von json_agg formatiert Postgres die Werte selbst, und der
// Treiber kommt nicht mehr daran (lib/db/pool.ts stellt ihn dort auf die
// Formen um, die die Typen beschreiben). Für die meisten Typen macht das
// nichts — im Gegenteil:
//
// date → "2022-03-30" wie ausserhalb
// numeric → 38.5 wie ausserhalb
// uuid → Zeichenkette wie ausserhalb
// timestamptz → "2026-08-03T12:08:06.272938+00:00"
// ← **anders**: ausserhalb "…272Z"
//
// Der Unterschied ist nicht kosmetisch. Zeitstempel werden im Projekt als
// Zeichenketten verglichen (lib/history.ts entscheidet daran, was später
// passiert ist), und "+00:00" sortiert gegen "Z" falsch herum. Deshalb geht
// **jede** timestamptz-Spalte in einer gebündelten Abfrage durch zeitstempel().
export { jsonArrayFrom, jsonObjectFrom };
/**
* Eine timestamptz-Spalte in der Form, die der Treiber ausserhalb von JSON
* liefert — ISO-8601 in UTC, auf Millisekunden gekürzt.
*
* Ohne das käme aus einer gebündelten Abfrage eine andere Zeichenkette als
* aus derselben Abfrage einzeln gestellt.
*/
export function zeitstempel(spalte: Expression<unknown> | string): RawBuilder<string> {
const ref = typeof spalte === "string" ? sql.ref(spalte) : spalte;
return sql<string>`to_char(${ref} at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`;
}

102
lib/db/pool.ts Normal file
View File

@@ -0,0 +1,102 @@
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;
}

56
lib/db/rpc.ts Normal file
View File

@@ -0,0 +1,56 @@
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." };
}
}

49
lib/db/schema.ts Normal file
View File

@@ -0,0 +1,49 @@
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 };

17
lib/dienstwagen.ts Normal file
View File

@@ -0,0 +1,17 @@
import type { DienstwagenArt } from "./supabase/types";
// Wie ein Dienstwagen benannt wird.
//
// Gespeichert steht „Elektro"; gesprochen wird von einem E-KFZ, und danach
// wird auch gefragt. Die Beschriftung stand an drei Stellen einzeln — im
// Auswahlfeld, in der Zusammenfassung des Einstellungsassistenten und auf dem
// Vertragsblatt. Drei Kopien einer Beschriftung laufen auseinander, und dann
// heisst dasselbe Auto je nach Bildschirm anders.
export function dienstwagenLabel(art: DienstwagenArt | null | undefined): string {
if (art === "Elektro") return "E-KFZ";
if (art === "Verbrenner") return "Verbrenner";
// Der CHECK erlaubt es nicht, aber gelesen wird auch, was jemand über den
// Import hineingelegt hat.
return art ?? "–";
}

137
lib/employee-detail-data.ts Normal file
View File

@@ -0,0 +1,137 @@
import type { Tx } from "./db";
import { kontierungZum, kontierungenAbfrage, type KontierungsZeile } from "./cost-centers";
import { jsonArrayFrom, zeitstempel } from "./db/json";
import { todayIso } from "./format";
import { buildOrgMaps, orgMapsAbfragen } from "./org";
import { berichtslinienAbfrage, besetzungenAbfrage, orgAsOf, pickPlacements } from "./placement";
import { offeneStellenAbfrage, resolveOpenPositions, type OffeneStelle } from "./positions";
// Was die Mitarbeiterakte liest — in vier Rundreisen statt in zwölf.
//
// Vorher: die Person, dann acht Dinge in einem Promise.all, dann die Namen der
// Beteiligten. Das Promise.all war keine Gleichzeitigkeit — eine Transaktion
// hängt an einer Verbindung (lib/db/json.ts). Bei rund 36 ms Umlaufzeit war
// diese Seite damit die teuerste der Anwendung.
//
// Die drei Tabellen mit `selectAll()` stehen hier ausgeschrieben. Das ist
// keine Umständlichkeit: innerhalb von JSON formatiert Postgres timestamptz
// anders als der Treiber sonst, und `created_at` entscheidet in lib/history.ts
// darüber, was später passiert ist. Ausgeschrieben ist sichtbar, welche Spalte
// durch zeitstempel() geht — bei selectAll() wäre es unsichtbar falsch.
export async function loadEmployeeDetail(tx: Tx, id: string, today: string = todayIso()) {
// Die Person zuerst, allein: erst aus Eintritt und Austritt ergibt sich der
// Stichtag, zu dem ihre Organisation überhaupt eine Antwort hat. Eine
// Person, die am 01.09. anfängt, hat heute keine laufende Besetzung — zu
// heute gefragt lieferte om_reporting_lines() nichts, und die Akte
// behauptete „Keine Führungskraft", obwohl das Team eine hat.
const employee = await tx.selectFrom("employees").selectAll().where("id", "=", id).executeTakeFirst();
if (!employee) return null;
const asOf = orgAsOf(employee, today);
const g = await tx
.selectNoFrom((eb) => [
// Vorgesetzte und direkte Berichte stehen nirgends als Spalte — sie
// kommen aus om_reporting_lines(). Beide schränken *in* der Funktion
// ein, es wandern also neun Zeilen über die Leitung und nicht achthundert.
jsonArrayFrom(berichtslinienAbfrage(eb, asOf, { employeeId: id })).as("ownLines"),
jsonArrayFrom(berichtslinienAbfrage(eb, asOf, { actingManagerId: id })).as("reports"),
jsonArrayFrom(
eb
.selectFrom("employee_history")
.select(["id", "employee_id", "event_date", "event_type", "description", "changes", "pending_id"])
.select((x) => zeitstempel(x.ref("created_at")).as("created_at"))
.where("employee_id", "=", id)
.orderBy("event_date", "desc")
.orderBy("created_at", "desc")
).as("history"),
jsonArrayFrom(
eb
.selectFrom("employee_dependents")
.select(["id", "employee_id", "first_name", "last_name", "relationship", "sv_nummer", "birth_date"])
.select((x) => zeitstempel(x.ref("created_at")).as("created_at"))
.where("employee_id", "=", id)
.orderBy("created_at")
).as("dependents"),
jsonArrayFrom(
eb
.selectFrom("employee_notes")
.select([
"id",
"employee_id",
"author_user_id",
"author_name",
"category",
"note_text",
"due_date",
"done",
"done_by",
])
.select((x) => [
zeitstempel(x.ref("created_at")).as("created_at"),
zeitstempel(x.ref("done_at")).as("done_at"),
])
.where("employee_id", "=", id)
.orderBy("created_at", "desc")
).as("notes"),
...orgMapsAbfragen(eb),
jsonArrayFrom(besetzungenAbfrage(eb, [id])).as("placementRows"),
// Die Kontierungen der Planstellen dieser Person. Welche gilt, hängt am
// Stichtag — und der ergibt sich erst aus Eintritt und Austritt, steht
// hier also schon fest.
jsonArrayFrom(
kontierungenAbfrage(eb).where("z.position_id", "in", (e) =>
e.selectFrom("position_assignments as pa").select("pa.position_id").where("pa.employee_id", "=", id)
)
).as("costRows"),
// Die offenen Planstellen bleiben bei heute: sie gehören zur
// Organisation, nicht zu dieser Person.
jsonArrayFrom(offeneStellenAbfrage(eb, today)).as("open"),
])
.executeTakeFirstOrThrow();
const orgMaps = buildOrgMaps(g.units as never, g.locations as never);
const line = g.ownLines[0] ?? null;
const placement = pickPlacements(g.placementRows as never, asOf).get(id) ?? null;
// 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, ...g.reports.map((r) => r.employee_id)].filter(
(x): x is string => Boolean(x)
)
)
);
const [relatedRows, openPositions] = await Promise.all([
relatedIds.length
? tx
.selectFrom("employees")
.select(["id", "first_name", "last_name", "job_title", "status"])
.where("id", "in", relatedIds)
.execute()
: Promise.resolve([]),
resolveOpenPositions(tx, orgMaps, g.open as OffeneStelle[], today),
]);
return {
employee,
line,
reports: g.reports,
history: g.history,
dependents: g.dependents,
notes: g.notes,
orgMaps,
placement,
// Die Kostenstelle des Sitzes, auf dem die Person am Stichtag sitzt.
kostenstelle: placement ? (kontierungZum(g.costRows as KontierungsZeile[], asOf).get(placement.positionId) ?? null) : null,
openPositions,
byId: new Map(relatedRows.map((e) => [e.id, e])),
};
}

35
lib/employee-search.ts Normal file
View File

@@ -0,0 +1,35 @@
// Wie aus einer Eingabe Suchmuster werden.
//
// Getrennt von der Seite, weil hier die Entscheidungen stecken, die man
// prüfen können muss: was als Wort zählt, was am Wortanfang treffen muss und
// was von LIKE als Text und nicht als Platzhalter gelesen wird.
//
// Die Bedeutung selbst — „trifft am Wortanfang" — liegt im SQL der Seite:
// verglichen wird gegen Vorname, Nachname und Position zusammengesetzt, mit
// Trennzeichen als Wortgrenze.
/** Nur Ziffern? Dann ist es eine Personalnummer und kein Name. */
export function istPersonalnummer(term: string): boolean {
return /^\d+$/.test(term.trim());
}
/**
* Je Suchwort zwei LIKE-Muster: „am Anfang" und „nach einem Leerzeichen".
* Zusammen ergeben sie „am Anfang eines Wortes".
*
* Ein Wort als Teilzeichenkette zu suchen wäre einfacher und war die erste
* Fassung — bei „Winkler H" traf das „H" dann auf Thomas, Katharina und
* CNC-Dreher:in, also auf alle. Je kürzer die Eingabe, desto unbrauchbarer
* das Ergebnis, und ein Anfangsbuchstabe ist die kürzeste sinnvolle Eingabe.
*/
export function suchMuster(term: string): string[][] {
return term
.trim()
.split(/\s+/)
.filter(Boolean)
.map((wort) => {
// Die Platzhalter von LIKE entschärfen: wer „50 %" tippt, sucht Text.
const klein = wort.toLowerCase().replace(/[\\%_]/g, (z) => `\\${z}`);
return [`${klein}%`, `% ${klein}%`];
});
}

View File

@@ -1,3 +1,5 @@
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.
@@ -19,58 +21,59 @@ 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 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;
};
type Eb = ExpressionBuilder<Schema, "employees">;
/** True once the person has started and has not left yet. */
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;
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)])]);
}
/**
* 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.
* 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.
*/
export function applyDerivedStatusFilter<Q extends Filterable>(query: Q, statuses: EmploymentStatus[], asOf: string): Q {
export function derivedStatusFilter(eb: Eb, statuses: EmploymentStatus[], asOf: string): Expression<SqlBool> | null {
const wanted = new Set(statuses);
if (wanted.size === 0) return query;
if (wanted.size === 0) return null;
// A single non-employed status is a straight date comparison.
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;
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)]);
}
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.
return employed(query, asOf);
}
// Everyone employed today, whether or not they are on leave.
if (wantsAktiv && wantsKarenz && wanted.size === 2) return employed(eb, asOf);
if (wantsKarenz && !wantsAktiv && wanted.size === 1) {
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;
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)]),
]);
}
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 employed(query, asOf).or(
`karenz_start_date.is.null,karenz_start_date.gt.${asOf},karenz_return_date.lte.${asOf}`
) as Q;
return eb.and([
employed(eb, asOf),
eb.or([
eb("karenz_start_date", "is", null),
eb("karenz_start_date", ">", asOf),
eb("karenz_return_date", "<=", asOf),
]),
]);
}
// Mixed selections spanning employed and non-employed states have no UI
// path today; filtering on a guess would be worse than not filtering.
return query;
return null;
}

View File

@@ -56,8 +56,27 @@ export function initials(firstName: string, lastName: string): string {
return `${a}${b}`;
}
// "Dr. Max Mustermann, MSc MBA" — prefix titles precede the name, suffix
// titles follow after a comma, both space-joined in the stored order.
// „Mustermann, Max" — der Nachname zuerst.
//
// So steht es in jeder Personalliste, und danach wird gesucht: wer jemanden
// sucht, hat den Nachnamen im Kopf. Nebenbei stimmt die Anzeige damit auch
// mit der Sortierung überein, die schon immer nach Nachnamen ging — vorher
// las sich eine alphabetische Liste, als wäre sie ungeordnet.
//
// Eine Stelle für alle: der Name wurde an gut zwanzig Orten einzeln
// zusammengesetzt, und eine Umstellung, die die Hälfte davon vergisst, ist
// schlimmer als gar keine.
export function fmtName(firstName: string, lastName: string): string {
const nach = lastName?.trim() ?? "";
const vor = firstName?.trim() ?? "";
if (!nach) return vor;
if (!vor) return nach;
return `${nach}, ${vor}`;
}
// "Mustermann, Dr. Max, MSc MBA" — vorangestellte Titel stehen beim
// Vornamen, nachgestellte am Ende. Der Nachname bleibt vorne, damit auch die
// Überschrift einer Personalakte so beginnt wie ihr Eintrag in der Liste.
export function fmtFullName(
firstName: string,
lastName: string,
@@ -66,7 +85,7 @@ export function fmtFullName(
): string {
const prefix = titlePrefix && titlePrefix.length > 0 ? `${titlePrefix.join(" ")} ` : "";
const suffix = titleSuffix && titleSuffix.length > 0 ? `, ${titleSuffix.join(" ")}` : "";
return `${prefix}${firstName} ${lastName}${suffix}`;
return `${fmtName(`${prefix}${firstName}`, lastName)}${suffix}`;
}
// Whole years between two ISO dates. Compares the "MM-DD" tails as strings,

136
lib/history.ts Normal file
View File

@@ -0,0 +1,136 @@
import type { AuditChange, HistoryEventType } from "./supabase/types";
// Welche Historieneinträge sich zurücknehmen lassen — und warum die übrigen
// nicht.
//
// Dieselbe Regel steht in der Datenbank (delete_history_entry). Das ist eine
// Doppelung, und zwar mit Absicht: die Datenbank ist die verbindliche Stelle,
// weil sie die einzige ist, an der niemand vorbeikommt. Hier steht sie
// nochmal, damit die Oberfläche einen Knopf nur dort zeigt, wo er auch
// funktioniert, und daneben schreiben kann, woran es sonst liegt. Ein Knopf,
// der erst nach dem Klick sagt „geht nicht", ist eine Falle.
//
// Läuft eine Seite der anderen davon, gewinnt die Datenbank: sie weist ab,
// und die Oberfläche zeigt ihre Begründung.
export type KorrekturUrteil = { erlaubt: true } | { erlaubt: false; grund: string };
type Eintrag = {
event_type: HistoryEventType;
event_date: string;
changes: AuditChange[] | null;
/** Der geplante Vorgang, falls die Änderung noch nicht wirksam ist. */
pending_id?: string | null;
/** Für die Reihenfolge bei gleichem Datum. */
created_at?: string;
};
/** Die Vorgänge, die sich zurücknehmen und berichtigen lassen. */
const KORRIGIERBAR: HistoryEventType[] = ["Stammdatenänderung", "Vertragsänderung", "Karenz", "Rückkehr"];
/**
* Abwesenheit und Rückkehr werden anders zurückgenommen als der Rest: aus
* einer geplanten Vertragsänderung nimmt man einzelne Felder heraus, eine
* geplante Abwesenheit fällt ganz. Beides steht in delete_history_entry;
* hier zählt nur, dass für diese beiden keine Feldwerte nötig sind.
*/
const OHNE_FELDWERTE: HistoryEventType[] = ["Karenz", "Rückkehr"];
export function darfKorrigiertWerden(eintrag: Eintrag, heute: string, alle: Eintrag[] = []): KorrekturUrteil {
if (eintrag.event_type === "Eintritt") {
return { erlaubt: false, grund: "Der Eintritt ist der Anfang der Zeitleiste und bleibt." };
}
if (!KORRIGIERBAR.includes(eintrag.event_type)) {
return {
erlaubt: false,
grund:
"Dieser Vorgang hat Planstellen oder den Status bewegt. Zurücknehmen lässt er sich nur über den passenden " +
"Vorgang, nicht durch Löschen der Zeile.",
};
}
// Die Reihenfolge zählt: eine Rückkehr setzt eine Abwesenheit voraus.
// Bliebe sie stehen, während die Abwesenheit verschwindet, stünde in der
// Akte eine Rückkehr aus dem Nichts — und der Status ergäbe sich aus einem
// Eintrag, dessen Ausgangslage gelöscht ist.
if (eintrag.event_type === "Karenz" && alle.some((h) => h.event_type === "Rückkehr" && spaeter(h, eintrag))) {
return {
erlaubt: false,
grund: "Zu dieser Abwesenheit gibt es eine Rückkehr. Sie muss zuerst gelöscht werden.",
};
}
// Noch nicht wirksam: das geht, aber nur mit Bezug auf den geplanten
// Vorgang. Zeilen aus der Zeit vor dieser Verknüpfung haben keinen — sie
// liessen sich nur über Person und Datum zuordnen, und das ist nicht
// eindeutig genug, um daraufhin eine geplante Änderung abzubrechen.
if (eintrag.event_date > heute && !eintrag.pending_id) {
return {
erlaubt: false,
grund:
"Zu dieser geplanten Änderung ist kein Vorgang hinterlegt — sie stammt aus der Zeit vor dieser Verknüpfung. " +
"Zurücknehmen lässt sie sich nur, indem der Vorgang selbst abgebrochen wird.",
};
}
// Eine geplante Abwesenheit hat keine Feldwerte — sie fällt als Ganzes.
if (eintrag.event_date > heute && OHNE_FELDWERTE.includes(eintrag.event_type)) {
return { erlaubt: true };
}
if (!eintrag.changes || eintrag.changes.length === 0) {
return {
erlaubt: false,
grund: "Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts, worauf zurückgesetzt werden könnte.",
};
}
return { erlaubt: true };
}
/**
* Bearbeiten ist weiter gefasst als Löschen.
*
* Der Eintritt lässt sich nicht löschen — er ist der Anfang der Zeitleiste,
* und ohne ihn hätte die Person keinen. Sein **Datum** kann aber falsch
* erfasst sein, und dann hängt daran mehr als eine Zahl: die erste
* Planstellenbesetzung, der frühestmögliche Zeitpunkt jedes weiteren
* Ereignisses, die Zugehörigkeit. Die Datenbank prüft das alles beim Ändern;
* hier geht es nur darum, den Knopf überhaupt anzubieten.
*/
export function darfBearbeitetWerden(eintrag: Eintrag, heute: string, alle: Eintrag[] = []): KorrekturUrteil {
if (eintrag.event_type === "Eintritt") return { erlaubt: true };
return darfKorrigiertWerden(eintrag, heute, alle);
}
/** Später im Sinne der Anzeige: erst das Datum, dann die Erfassungszeit. */
function spaeter(a: Eintrag, b: Eintrag): boolean {
if (a.event_date !== b.event_date) return a.event_date > b.event_date;
return (a.created_at ?? "") > (b.created_at ?? "");
}
/**
* Was das Löschen bewirken würde: je Feld entweder Zurücksetzen oder nicht,
* weil ein späterer Eintrag dasselbe Feld angefasst hat.
*
* Dient allein der Ankündigung im Bestätigungsdialog — entschieden wird es
* in der Datenbank, an denselben Daten, im selben Augenblick.
*/
export type Vorschau = { feld: string; von: string | null; auf: string | null; bleibt: boolean };
export function loeschVorschau(
eintrag: Eintrag & { id: string; created_at: string },
alle: (Eintrag & { id: string; created_at: string })[]
): Vorschau[] {
return (eintrag.changes ?? []).map((c) => {
const spaeter = alle.some(
(h) =>
h.id !== eintrag.id &&
(h.changes ?? []).some((a) => a.feld === c.feld) &&
(h.event_date > eintrag.event_date ||
(h.event_date === eintrag.event_date && h.created_at > eintrag.created_at))
);
return { feld: c.feld, von: c.nachher, auf: c.vorher, bleibt: spaeter };
});
}

336
lib/import/load.ts Normal file
View File

@@ -0,0 +1,336 @@
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])),
// Ohne Adresse gibt es nichts zu vergleichen — seit sie freiwillig ist,
// kann sie fehlen.
emails: new Set(personen.filter((e) => e.email).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),
// Ohne Dienstwagen zwingend null, mit Dienstwagen zwingend gesetzt —
// so verlangt es chk_dienstwagen_art. Fehlt die Angabe in der Datei,
// gilt Verbrenner, wie im übernommenen Bestand.
dienstwagen_art: bool(w.has_dienstwagen, false) ? (txt(w.dienstwagen_art) ?? "Verbrenner") : null,
emergency_contact_name: txt(w.emergency_contact_name),
emergency_contact_phone: txt(w.emergency_contact_phone),
emergency_contact_relation: txt(w.emergency_contact_relation),
is_laterale_fuehrung: bool(w.is_laterale_fuehrung, false),
is_c_level: bool(w.is_c_level, false),
has_kuendigungsschutz: bool(w.has_kuendigungsschutz, false),
// Ohne Schutz kein Enddatum — chk_kuendigungsschutz_bis weist es sonst
// ab, und die ganze Datei scheiterte an einer einzelnen Zeile.
kuendigungsschutz_bis: bool(w.has_kuendigungsschutz, false) ? (txt(w.kuendigungsschutz_bis) ?? null) : null,
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
),
};
// Weiterhin von Hand geschrieben, aber ohne OVERRIDING SYSTEM VALUE:
// personnel_number ist seit 20260811100000 keine Identitätsspalte mehr,
// sondern eine gewöhnliche Pflichtangabe — sie muss mit Loga und
// Interflex übereinstimmen und wird deshalb überall eingegeben, nicht
// vergeben. Für eine Identitätsspalte war das Schlüsselwort nötig; für
// eine gewöhnliche wäre es ein Fehler.
const spalten = Object.keys(werte);
const r = await sql<{ id: string; personnel_number: number }>`
insert into employees (${sql.raw(spalten.map((s) => `"${s}"`).join(", "))})
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;
// Kein Fortschreiben eines Zählers mehr: es gibt keinen. Die Nummer wird
// bei jeder Einstellung eingegeben, und die Eindeutigkeit sichert der
// Index — beim Import wie im Assistenten.
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;
}

242
lib/import/parse.ts Normal file
View File

@@ -0,0 +1,242 @@
// 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.`],
};
}

504
lib/import/schema.ts Normal file
View File

@@ -0,0 +1,504 @@
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: "Private E-Mail",
ziel: "email",
pflicht: false,
typ: { art: "text" },
hinweis: "Freiwillig. Eindeutig, wenn angegeben.",
beispiel: "s.aigner@example.at",
},
{ name: "Private Telefonnummer", 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: "Antriebsart",
ziel: "dienstwagen_art",
pflicht: false,
typ: { art: "auswahl", werte: ["Verbrenner", "Elektro"] },
hinweis: "Pflicht, wenn ein Dienstwagen eingetragen ist; sonst leer lassen.",
beispiel: "",
},
{
name: "Notfallkontakt",
ziel: "emergency_contact_name",
pflicht: false,
typ: { art: "text" },
hinweis: "Name. Nur zusammen mit der Telefonnummer.",
beispiel: "",
},
{
name: "Notfallkontakt Telefon",
ziel: "emergency_contact_phone",
pflicht: false,
typ: { art: "text" },
hinweis: "Nur zusammen mit dem Namen.",
beispiel: "",
},
{
name: "Notfallkontakt Verhältnis",
ziel: "emergency_contact_relation",
pflicht: false,
typ: { art: "text" },
hinweis: "Zum Beispiel Gattin, Bruder, Freundin.",
beispiel: "",
},
{ 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: "Besonderer Kündigungsschutz",
ziel: "has_kuendigungsschutz",
pflicht: false,
typ: { art: "janein" },
hinweis: "Leer = nein.",
beispiel: "nein",
},
{
name: "Kündigungsschutz bis",
ziel: "kuendigungsschutz_bis",
pflicht: false,
typ: { art: "datum" },
hinweis: "Nur mit gesetztem Schutz; freiwillig.",
beispiel: "",
},
{
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);
}

405
lib/import/validate.ts Normal file
View File

@@ -0,0 +1,405 @@
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, "Private E-Mail", "Kommt bereits vor.", email);
emails.add(email);
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) melde("Personen", z.zeile, "Private 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 Mo–Fr.");
}
}
// ── 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 };
}

125
lib/import/werte.ts Normal file
View File

@@ -0,0 +1,125 @@
// 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;
}

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