diff --git a/.env.example b/.env.example index cb52814..a5c1a10 100644 --- a/.env.example +++ b/.env.example @@ -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//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 ` when it # calls /api/cron/apply-pending-changes (set the same value in the Vercel diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a09cd8..1d0041e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 60d86fe..29805da 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 3406a30..bb8269e 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -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:/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://.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//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: /:` 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: /:` 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:///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. diff --git a/Dockerfile b/Dockerfile index 73ed13c..55c36a6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 32c6c8b..6ae6955 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/actions/auth.ts b/actions/auth.ts index 09f3ade..8dbda3d 100644 --- a/actions/auth.ts +++ b/actions/auth.ts @@ -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" }); } diff --git a/actions/employees.ts b/actions/employees.ts index 6bb9933..876298b 100644 --- a/actions/employees.ts +++ b/actions/employees.ts @@ -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, revalidate: string[]): Promise { - 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 { - 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) + ); + 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 { 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 { 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 { +/** + * `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 { 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 { + 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 { + 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 { 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 { - 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 ?? []; -} diff --git a/actions/hireDrafts.ts b/actions/hireDrafts.ts index 66552de..a60a9c0 100644 --- a/actions/hireDrafts.ts +++ b/actions/hireDrafts.ts @@ -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; }): Promise { - 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 { - 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." }; + } } diff --git a/actions/positions.ts b/actions/positions.ts index d06b6ff..f9da4c6 100644 --- a/actions/positions.ts +++ b/actions/positions.ts @@ -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, revalidate: string[] ): Promise { - 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 { 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 { + return callRpc("update_position", payload, POSITION_PATHS); +} + export async function deletePosition(positionId: string): Promise { 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 { - 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 { + return callRpc("set_position_cost_center", payload, POSITION_PATHS); } diff --git a/actions/reorg.ts b/actions/reorg.ts deleted file mode 100644 index cfc6b84..0000000 --- a/actions/reorg.ts +++ /dev/null @@ -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 { - 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 { - 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 }; -} diff --git a/actions/reports.ts b/actions/reports.ts index 0558ff1..b0d21e3 100644 --- a/actions/reports.ts +++ b/actions/reports.ts @@ -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 }): Promise { - 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 { - 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." }; + } } diff --git a/app/(app)/audit/page.tsx b/app/(app)/audit/page.tsx index 704f248..84f675a 100644 --- a/app/(app)/audit/page.tsx +++ b/app/(app)/audit/page.tsx @@ -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 }) { 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().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 (
-

{count ?? 0} Einträge

+

{count} Einträge

@@ -74,7 +92,7 @@ export default async function AuditPage({ searchParams }: { searchParams: Promis - {(entries ?? []).map((entry) => { + {entries.map((entry) => { return ( - + ); })} - {(entries ?? []).length === 0 && ( + {entries.length === 0 && (
@@ -98,11 +116,13 @@ export default async function AuditPage({ searchParams }: { searchParams: Promis entry.target_label )} {entry.details ?? "–"} + +
Keine Einträge gefunden. diff --git a/app/(app)/employees/[id]/page.tsx b/app/(app)/employees/[id]/page.tsx index 711f14a..b11c166 100644 --- a/app/(app)/employees/[id]/page.tsx +++ b/app/(app)/employees/[id]/page.tsx @@ -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 ( { + 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} /> ); } diff --git a/app/(app)/employees/page.tsx b/app/(app)/employees/page.tsx index 7051d7e..7b6eb46 100644 --- a/app/(app)/employees/page.tsx +++ b/app/(app)/employees/page.tsx @@ -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; @@ -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`translate(lower(first_name || ' ' || last_name), '-/:.,', ' ')` + : sql`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().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().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 (
- + -

{count ?? 0} Mitarbeiter:innen gefunden

+

+ {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 && ( + · kein Namenstreffer, gesucht nach Position + )} +

@@ -97,7 +232,9 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps {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
- {e.first_name} {e.last_name} + {fmtName(e.first_name, e.last_name)}
-
{e.job_title}
+
{placement?.jobTitle ?? e.job_title}
@@ -122,7 +259,12 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
diff --git a/app/(app)/import/page.tsx b/app/(app)/import/page.tsx new file mode 100644 index 0000000..3aa0e4d --- /dev/null +++ b/app/(app)/import/page.tsx @@ -0,0 +1,16 @@ +import { ImportWorkbench } from "@/components/import/ImportWorkbench"; + +export const metadata = { title: "Import" }; + +export default function ImportPage() { + return ( +
+

+ Ü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. +

+ +
+ ); +} diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index f189f25..3211ae0 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -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 ( - - + + {children} diff --git a/app/(app)/orgchart/page.tsx b/app/(app)/orgchart/page.tsx index cccb90a..aa2d7bb 100644 --- a/app/(app)/orgchart/page.tsx +++ b/app/(app)/orgchart/page.tsx @@ -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 ( }) { + 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 ; +} diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx index 2e323a5..e4014b5 100644 --- a/app/(app)/page.tsx +++ b/app/(app)/page.tsx @@ -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 = { 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(); 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() { - Anstehend (60 Tage) + Anstehend ({zeitraum} Tage) + {/* useSearchParams braucht eine Suspense-Grenze; ohne sie fällt beim + Bauen die ganze Seite auf Rendern zur Laufzeit zurück. */} + }> + +
    - {upcoming.map((item) => ( -
  • - - - {item.label} - {KIND_LABEL[item.kind]} - - {fmtDate(item.date)} - -
  • - ))} - {upcoming.length === 0 &&

    Keine anstehenden Ereignisse.

    } + {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 ( +
  • + + + {item.label} + + {KIND_LABEL[item.kind]} + {item.hinweis ? ` — ${item.hinweis}` : ""} + + + + {ueberfaellig ? "überfällig " : ""} + {fmtDate(item.date)} + + +
  • + ); + })} + {upcoming.length === 0 && ( +

    + {istEingeschraenkt(zeitraum, arten) + ? "Zu dieser Auswahl steht nichts an." + : "Keine anstehenden Ereignisse."} +

    + )}
+ {weitere > 0 && ( +

+ … und {weitere} {weitere === 1 ? "weiteres Ereignis" : "weitere Ereignisse"} in diesem Zeitraum. +

+ )}
Letzte Aktivitäten
    - {(historyRes.data ?? []).map((h) => ( + {(history).map((h) => (
  • {/* Dot aligned to the first line of text, not centred on the whole row, so it stays put as descriptions wrap. */}
    - {employeeNameById.get(h.employee_id) ?? "Unbekannt"} + {h.first_name && h.last_name ? fmtName(h.first_name, h.last_name) : "Unbekannt"} {h.event_type} @@ -305,7 +322,7 @@ export default async function DashboardPage() {
  • ))} - {(historyRes.data ?? []).length === 0 &&

    Keine Aktivitäten vorhanden.

    } + {(history).length === 0 &&

    Keine Aktivitäten vorhanden.

    }
diff --git a/app/(app)/positions/page.tsx b/app/(app)/positions/page.tsx index 4404a58..6505f4d 100644 --- a/app/(app)/positions/page.tsx +++ b/app/(app)/positions/page.tsx @@ -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 ; + 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 ; } diff --git a/app/(app)/reports/page.tsx b/app/(app)/reports/page.tsx index d30ef89..2543fba 100644 --- a/app/(app)/reports/page.tsx +++ b/app/(app)/reports/page.tsx @@ -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 & { 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 }) { 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} />
); @@ -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} /> ); diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 58e84b7..1571a6f 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -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 ( -
-
-

Alpenwerk HR

-

Melden Sie sich mit Ihrem Firmenkonto an.

+ // dvh statt vh: auf iOS zählt vh die Adressleiste mit, wodurch die Karte + // im ersten Moment unter dem Faltenrand sitzt. +
+ - {error && ( -
- {error} - {code === "no_hr_access" && ( -
- - - )} +
+
+
+
- )} -
-
- - -
-
- - -
- - -
+

Anmelden

+

+ Der Zugang läuft über Ihr Firmenkonto. Ein eigenes Passwort gibt es nicht. +

+ + {error && ( +
+

{error.title}

+

{error.body}

+ {code === "no_hr_access" && ( +
+ + + )} +
+ )} + +
+ + + +

+ Die Anmeldung allein erteilt keinen Zugriff. HR-Rechte vergibt die Personalabteilung — bis dahin bleiben alle + Personaldaten verschlossen. +

+
+ +
+ ); +} + +function BrandPanel() { + return ( + + ); +} + +function Wordmark({ className = "" }: { className?: string }) { + return ( +
+ {/* Vier Quadrate wie ein Organigramm-Ausschnitt: eine Wurzel, darunter + drei Einheiten. */} + + Alpenwerk HR
); } diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..11359e7 --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -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; diff --git a/app/api/cron/apply-pending-changes/route.ts b/app/api/cron/apply-pending-changes/route.ts index 2c32824..e3bc0fd 100644 --- a/app/api/cron/apply-pending-changes/route.ts +++ b/app/api/cron/apply-pending-changes/route.ts @@ -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 }); } diff --git a/app/api/export/employees/route.ts b/app/api/export/employees/route.ts index ad17ecb..4ceab50 100644 --- a/app/api/export/employees/route.ts +++ b/app/api/export/employees/route.ts @@ -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 }, diff --git a/app/api/export/events/route.ts b/app/api/export/events/route.ts index 3e24266..2459670 100644 --- a/app/api/export/events/route.ts +++ b/app/api/export/events/route.ts @@ -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[] { { 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 }, ]; diff --git a/app/api/export/report/route.ts b/app/api/export/report/route.ts index 5e306a6..8766009 100644 --- a/app/api/export/report/route.ts +++ b/app/api/export/report/route.ts @@ -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[]; + let filenameBase: string; - let rows: ReportRow[]; - let columns: ExportColumn[]; - 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 vs DOM's BlobPart/ArrayBuffer<> generic // mismatch (microsoft/TypeScript#59417) — a real Uint8Array works fine here. diff --git a/app/api/import/route.ts b/app/api/import/route.ts new file mode 100644 index 0000000..ee88a57 --- /dev/null +++ b/app/api/import/route.ts @@ -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; + 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 } + ); + } +} diff --git a/app/api/import/template/route.ts b/app/api/import/template/route.ts new file mode 100644 index 0000000..f0f1704 --- /dev/null +++ b/app/api/import/template/route.ts @@ -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") }); +} diff --git a/auth.ts b/auth.ts new file mode 100644 index 0000000..2d62ff6 --- /dev/null +++ b/auth.ts @@ -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 { + 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; + }, + }, + }; +}); diff --git a/components/audit/AuditDetail.tsx b/components/audit/AuditDetail.tsx new file mode 100644 index 0000000..9a66b54 --- /dev/null +++ b/components/audit/AuditDetail.tsx @@ -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 ( + <> + + + setOffen(false)} + title={eintrag.target_label} + subtitle={`${eintrag.action} · ${zeitFormat.format(new Date(eintrag.occurred_at))}`} + > +
+
Aktion
+
+ + {eintrag.action} + +
+
Benutzer:in
+
{eintrag.actor_name}
+
Zeitpunkt
+
{zeitFormat.format(new Date(eintrag.occurred_at))}
+ {eintrag.target_employee_id && ( + <> +
Objekt
+
+ + {eintrag.target_label} + +
+ + )} +
+ + {eintrag.details && ( +

{eintrag.details}

+ )} + +

Geänderte Felder

+ {anzahl > 0 ? ( +
+ +
+ ) : ( + // Kein Aufzählungszeichen für „nichts da“: der Grund ist wichtig, + // damit niemand einen Fehler vermutet. +

+ 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. +

+ )} +
+ + ); +} diff --git a/components/auth/EntraSignInButton.tsx b/components/auth/EntraSignInButton.tsx new file mode 100644 index 0000000..a228256 --- /dev/null +++ b/components/auth/EntraSignInButton.tsx @@ -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 ( + + ); +} diff --git a/components/dashboard/AnstehendFilter.tsx b/components/dashboard/AnstehendFilter.tsx new file mode 100644 index 0000000..091475a --- /dev/null +++ b/components/dashboard/AnstehendFilter.tsx @@ -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 ( +
+ + value={String(zeitraum)} + onChange={(v) => setzen("tage", v === String(STANDARD_ZEITRAUM) ? null : v)} + options={ZEITRAEUME.map((t) => ({ value: String(t), label: `${t} Tage` }))} + /> +
+ {ANSTEHEND_ARTEN.map((a) => { + const aktiv = arten.includes(a.value); + return ( + + ); + })} +
+
+ ); +} diff --git a/components/employees/EmployeeDetail.tsx b/components/employees/EmployeeDetail.tsx index 3101577..115df27 100644 --- a/components/employees/EmployeeDetail.tsx +++ b/components/employees/EmployeeDetail.tsx @@ -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(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) {
-

{employee.job_title}

-

{breadcrumb}

+

{placement?.jobTitle ?? employee.job_title}

+

+ {breadcrumb} + {placement && ( + <> + {" · Planstelle "} + {placement.positionNumber} + {placement.isChief && " (Leitung)"} + + )} +

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 && 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") && ( )} {employee.status === "Ausgetreten" && ( @@ -142,9 +155,16 @@ export function EmployeeDetail(props: EmployeeDetailProps) { {tab === "Stammdaten" && } {tab === "Vertrag" && } {tab === "Organisation" && ( - + )} - {tab === "Historie" && } + {tab === "Historie" && } {tab === "HR-Notizen" && }

@@ -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} /> setPanel(null)} employee={employee} /> setPanel(null)} employee={employee} /> @@ -167,7 +184,12 @@ export function EmployeeDetail(props: EmployeeDetailProps) { locationCountry={location?.country} /> setPanel(null)} employee={employee} directReportCount={directReports.length} /> - setPanel(null)} employee={employee} /> + setPanel(null)} + employee={employee} + openPositions={openPositions} + /> ); } diff --git a/components/employees/EmployeeFilters.tsx b/components/employees/EmployeeFilters.tsx index 11d22bc..72757ad 100644 --- a/components/employees/EmployeeFilters.tsx +++ b/components/employees/EmployeeFilters.tsx @@ -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; 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. */} + {/* 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. */} ); } diff --git a/components/employees/HistorieBearbeiten.tsx b/components/employees/HistorieBearbeiten.tsx new file mode 100644 index 0000000..f6a0a74 --- /dev/null +++ b/components/employees/HistorieBearbeiten.tsx @@ -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>(() => + 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 ( + <> + + + + + + + } + > +

+ {bezeichnung} + {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."} +

+ +
+ +
+ + {nurDatum && ( +

+ 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. +

+ )} + +
+
{e.personnel_number}
{division?.name ?? "–"}
-
{team?.name ?? "–"}
+ {/* 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. */} +
+ {unit && unit.id !== division?.id ? unit.name : "–"} +
{location?.name ?? "–"} {fmtDate(e.entry_date)}
+ + + + + + + + + {changes.map((c) => ( + + + + + + ))} + +
FeldVorherNachher
{c.feld} + {c.vorher === null || c.vorher === "" ? leer : c.vorher} + + 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" + /> +
+
+ +

+ {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. +

+ + + ); +} diff --git a/components/employees/HistorieLoeschen.tsx b/components/employees/HistorieLoeschen.tsx new file mode 100644 index 0000000..65d6f5b --- /dev/null +++ b/components/employees/HistorieLoeschen.tsx @@ -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 ( + <> + + + setOffen(false)} + title={ganzerVorgang ? "Geplanten Vorgang abbrechen" : "Eintrag löschen"} + footer={ + <> + + + + } + > +

+ {bezeichnung} {istZukunft ? "zum" : "vom"} {fmtDate(datum)} wird aus der + Historie entfernt. +

+ + {ganzerVorgang && ( +
+

Wird nicht mehr passieren

+

+ Der Vorgang entfällt ganz — die {bezeichnung} zum {fmtDate(datum)} findet nicht statt. Am Stammsatz wird + das vorgemerkte Datum mit entfernt. +

+

+ An den heutigen Stammdaten ändert sich nichts: der Vorgang war noch nicht wirksam. +

+
+ )} + + {istZukunft && vorschau.length > 0 && ( +
+

Wird nicht mehr passieren

+
    + {vorschau.map((v) => ( +
  • + {v.feld}{" "} + sollte auf {v.von || "leer"}{" "} + gesetzt werden +
  • + ))} +
+

+ 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. +

+
+ )} + + {!istZukunft && zurueck.length > 0 && ( +
+

Wird zurückgesetzt

+
    + {zurueck.map((v) => ( +
  • + {v.feld}{" "} + {v.von || "leer"}{" "} + {v.auf || "leer"} +
  • + ))} +
+
+ )} + + {!istZukunft && bleibt.length > 0 && ( +
+

Bleibt unverändert

+
    + {bleibt.map((v) => ( +
  • + {v.feld} — eine spätere Änderung hat dieses Feld erneut + gesetzt, und die gilt weiter. +
  • + ))} +
+
+ )} + +

+ Der Vorgang wird im Protokoll festgehalten — mit Zeitpunkt, Person und den Werten der gelöschten Zeile. Die + Zeile selbst lässt sich nicht wiederherstellen. +

+
+ + ); +} diff --git a/components/employees/RoleEmploymentFields.tsx b/components/employees/RoleEmploymentFields.tsx index 764ecdc..f81ea8b 100644 --- a/components/employees/RoleEmploymentFields.tsx +++ b/components/employees/RoleEmploymentFields.tsx @@ -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 onChange({ hasDienstwagen: e.target.checked })} /> Dienstwagen + {/* 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 && ( + onChange({ dienstwagenArt: v as DienstwagenArt })} + options={[ + { value: "Verbrenner", label: "Verbrenner" }, + { value: "Elektro", label: "Elektro (E-KFZ)" }, + ]} + /> + )} + + {/* 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 && ( + onChange({ kuendigungsschutzBis: v })} + hint="Optional. Leer lassen, solange das Ende nicht feststeht." + /> + )}
); diff --git a/components/employees/panels/DatenAendernPanel.tsx b/components/employees/panels/DatenAendernPanel.tsx index 15bf515..4086484 100644 --- a/components/employees/panels/DatenAendernPanel.tsx +++ b/components/employees/panels/DatenAendernPanel.tsx @@ -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(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(STUNDEN_GRUENDE[0]); + const stundenGeaendert = Number(weeklyHours) !== Number(employee.weekly_hours); + const [teilzeitBis, setTeilzeitBis] = useState(employee.teilzeit_bis ?? ""); const [contractType, setContractType] = useState(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({ )} + {/* 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 && ( + <> + setHatTitel(v === "ja")} + options={[ + { value: "nein", label: "Nein" }, + { value: "ja", label: "Ja" }, + ]} + hint="Bei Staatsbürgerschaften ausserhalb von EU, EWR und Schweiz." + /> + {hatTitel && ( + + )} + + )}
@@ -208,6 +281,24 @@ export function DatenAendernPanel({
+ + {/* Eigener Block: im Ernstfall greift jemand danach, und dann darf + er nicht zwischen den Adressfeldern der Person untergehen. */} +
+ Notfallkontakt +
+ + + ({ value: r, label: r }))} + /> +
+
@@ -232,6 +323,26 @@ export function DatenAendernPanel({ disabled={employmentType === "Vollzeit"} onChange={setWeeklyHours} /> + {stundenGeaendert && ( + ({ value: g, label: g }))} + hint="Steht danach am Profil und lässt sich auswerten." + /> + )} + {stundenGeaendert && stundenGrund !== "Vertragliche Stundenänderung" && ( + + )} ("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(""); + 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={ <> - @@ -58,7 +105,42 @@ export function RehirePanel({ open, onClose, employee }: { open: boolean; onClos

{employee.job_title}

Ausgetreten am {fmtDate(employee.exit_date)}

+ + + {options.length === 0 ? ( +

+ Es ist derzeit keine Planstelle frei. Ohne eine solche ist keine Wiedereinstellung möglich — zuerst eine + Planstelle anlegen oder eine bestehende freimachen. +

+ ) : ( + <> + + {selected && ( +
+
{selected.title}
+
{selected.orgLabel}
+
+ {selected.is_chief ? "Leitungsplanstelle" : "Mitarbeiterplanstelle"} + {selected.future ? ` · gültig ab ${fmtDate(selected.valid_from)}` : ""} + {selected.valid_to ? ` · endet am ${fmtDate(selected.valid_to)}` : ""} +
+
+ )} + {ausserhalb && ( +

+ {ausserhalb} Wiedereintritt und Gültigkeit müssen zusammenpassen. +

+ )} + + )} ); diff --git a/components/employees/panels/TerminatePanel.tsx b/components/employees/panels/TerminatePanel.tsx index 5946f97..fb6822c 100644 --- a/components/employees/panels/TerminatePanel.tsx +++ b/components/employees/panels/TerminatePanel.tsx @@ -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(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 - @@ -79,31 +83,33 @@ export function TransferPanel({ open, onClose, employee, divisions, departments, >
- { - setDivisionId(v); - setTeamId(""); - }} - options={divisions.map((d) => ({ value: d.id, label: d.name }))} - /> - ({ value: t.id, label: t.name }))} - /> - + {options.length === 0 ? ( +

+ Es gibt derzeit keine unbesetzte Planstelle. Eine Versetzung setzt eine freie Zielplanstelle voraus — legen Sie + zuerst unter „Positionen“ eine an. +

+ ) : ( + <> + + {selected && ( +
+
{selected.title}
+
{selected.orgLabel}
+
+ {selected.is_chief ? "Leitungsplanstelle" : "Mitarbeiterplanstelle"} + {selected.managerName ? ` · berichtet an ${selected.managerName}` : ""} +
+
+ )} + + )}
); diff --git a/components/employees/tabs/HistorieTab.tsx b/components/employees/tabs/HistorieTab.tsx index 0ec4707..e382723 100644 --- a/components/employees/tabs/HistorieTab.tsx +++ b/components/employees/tabs/HistorieTab.tsx @@ -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
, 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("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>(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

Keine Historieneinträge vorhanden.

; } return ( -
    - {history.map((h) => { - const isFuture = h.event_date > today; - return ( -
  • -
    - {h.event_type} - {fmtDate(h.event_date)} - {isFuture && ( - - ⏱ zukünftig – wirksam ab {fmtDate(h.event_date)} - - )} -
    -

    {h.description}

    -
  • - ); - })} -
+
+
+
+ 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 && ( + + {anstehend} anstehend + + )} +
+ + +
+
+ + {vorhandeneTypen.length > 1 && ( +
+ {vorhandeneTypen.map((typ) => ( + + ))} + {eingeschraenkt && ( + + )} +
+ )} +
+ + {gefiltert.length === 0 ? ( +

+ Kein Eintrag passt zu dieser Auswahl. {history.length} {history.length === 1 ? "Eintrag" : "Einträge"} sind + vorhanden. +

+ ) : ( +
    + {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 ( +
  • +
    + + {h.event_type} + + {fmtDate(h.event_date)} + {isFuture && ( + + ⏱ zukünftig – wirksam ab {fmtDate(h.event_date)} + + )} + + {bearbeitbar.erlaubt && ( + + )} + {loeschbar.erlaubt && ( + + )} + +
    +

    {h.description}

    + + {changes.length > 0 && ( +
    + + + {changes.length} {changes.length === 1 ? "Feld" : "Felder"} im Detail + +
    + +
    + {!loeschbar.erlaubt &&

    {loeschbar.grund}

    } +
    + )} +
  • + ); + })} +
+ )} +
); } diff --git a/components/employees/tabs/OrganisationTab.tsx b/components/employees/tabs/OrganisationTab.tsx index 2c2a586..adc706e 100644 --- a/components/employees/tabs/OrganisationTab.tsx +++ b/components/employees/tabs/OrganisationTab.tsx @@ -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 (

Organisationseinheit

{breadcrumb}

+

Kostenstelle

+

+ {kostenstelle ? ( + <> + {kostenstelle.code} + · {kostenstelle.name} + + ) : ( + Keine laufende Planstellenbesetzung + )} +

{/* ?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
-

Führungskraft

+

+ {formalManager ? "Führungskraft (Vertretung)" : "Führungskraft"} +

+ {formalManager && ( +

+ Zuständig ist {fmtName(formalManager.first_name, formalManager.last_name)}; während der Abwesenheit übernimmt die nächste + besetzte Ebene. +

+ )} {manager ? (
- {manager.first_name} {manager.last_name} + {fmtName(manager.first_name, manager.last_name)}
{manager.job_title}
@@ -55,7 +90,7 @@ export function OrganisationTab({ employeeId, manager, directReports, breadcrumb
- {r.first_name} {r.last_name} + {fmtName(r.first_name, r.last_name)}
{r.job_title}
diff --git a/components/employees/tabs/StammdatenTab.tsx b/components/employees/tabs/StammdatenTab.tsx index 8660ed1..a70c39c 100644 --- a/components/employees/tabs/StammdatenTab.tsx +++ b/components/employees/tabs/StammdatenTab.tsx @@ -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 (
@@ -38,7 +68,46 @@ export function StammdatenTab({ employee, location, dependents }: { employee: Em
))} + + + {/* 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. */} +
+

Notfallkontakt

+ {employee.emergency_contact_name ? ( +
+
+
Name
+
{employee.emergency_contact_name}
+
+
+
Telefon
+
+ {employee.emergency_contact_phone ? ( + + {employee.emergency_contact_phone} + + ) : ( + "–" + )} +
+
+
+
Verhältnis
+
{employee.emergency_contact_relation || "–"}
+
+
+ ) : ( +

Kein Notfallkontakt hinterlegt.

+ )} +
); } diff --git a/components/employees/tabs/VertragTab.tsx b/components/employees/tabs/VertragTab.tsx index cce7547..13cfcb5 100644 --- a/components/employees/tabs/VertragTab.tsx +++ b/components/employees/tabs/VertragTab.tsx @@ -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 = { }; 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)]); diff --git a/components/hire/HireWizard.tsx b/components/hire/HireWizard.tsx index d79aa21..299bdf8 100644 --- a/components/hire/HireWizard.tsx +++ b/components/hire/HireWizard.tsx @@ -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 )} - {step < 3 && ( + {step < letzterSchritt && ( )} - {step === 3 && ( + {step === letzterSchritt && ( @@ -176,8 +242,10 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf {step === 0 && } {step === 1 && } - {step === 2 && } - {step === 3 && } + {step === 2 && } + {step === 3 && } + {step === 4 && } + {step === 5 && } ); } diff --git a/components/hire/StepAngehoerige.tsx b/components/hire/StepAngehoerige.tsx new file mode 100644 index 0000000..0b39173 --- /dev/null +++ b/components/hire/StepAngehoerige.tsx @@ -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) => void; +}) { + const liste = draft.angehoerige; + + function setze(index: number, patch: Partial) { + 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 ( +
+

+ Angehörige sind freiwillig und lassen sich jederzeit in der Personalakte nachtragen. Der Nachname ist mit dem + der einzustellenden Person vorbelegt — überschreibbar. +

+ + {liste.length === 0 ? ( +

Keine Angehörigen erfasst.

+ ) : ( +
+ {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 ( +
+ + {a.firstName || a.lastName ? `${a.firstName} ${a.lastName}`.trim() : `Angehörige:r ${i + 1}`} + {a.birthDate && · {fmtDate(a.birthDate)}} + + +
+
+ setze(i, { firstName: v })} /> + setze(i, { lastName: v })} /> +
+
+ setze(i, { relationship: v as RelationshipType })} + options={VERHAELTNIS.map((r) => ({ value: r, label: r }))} + /> + setze(i, { birthDate: v })} /> +
+ setze(i, { svNummer: v })} + error={svFehler ? svnrErrorMessage(svFehler) : undefined} + hint={!svFehler && a.svNummer.trim() ? formatSvnr(a.svNummer) : undefined} + /> +
+ +
+ +
+
+ ); + })} +
+ )} + +
+ +
+
+ ); +} diff --git a/components/hire/StepNotfallkontakt.tsx b/components/hire/StepNotfallkontakt.tsx new file mode 100644 index 0000000..0596687 --- /dev/null +++ b/components/hire/StepNotfallkontakt.tsx @@ -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) => void; +}) { + const angefangen = Boolean(draft.emergencyContactName.trim() || draft.emergencyContactPhone.trim()); + const unvollstaendig = angefangen && !(draft.emergencyContactName.trim() && draft.emergencyContactPhone.trim()); + + return ( +
+

+ 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. +

+ +
+ update({ emergencyContactName })} + /> + update({ emergencyContactPhone })} + /> +
+ + update({ emergencyContactRelation })} + placeholder="Bitte wählen…" + options={EMERGENCY_RELATIONS.map((r) => ({ value: r, label: r }))} + /> + + {unvollstaendig && ( +

+ Name und Telefonnummer werden beide gebraucht — oder beide leer lassen. +

+ )} +
+ ); +} diff --git a/components/hire/StepPerson.tsx b/components/hire/StepPerson.tsx index 66e0f72..410f8f7 100644 --- a/components/hire/StepPerson.tsx +++ b/components/hire/StepPerson.tsx @@ -12,6 +12,14 @@ type StepPersonProps = { export function StepPerson({ draft, update, locations }: StepPersonProps) { return (
+ update({ personnelNumber: personnelNumber.replace(/\D/g, "") })} + hint="Muss mit Loga und Interflex übereinstimmen. Wird nicht automatisch vergeben." + />
update({ firstName })} /> update({ lastName })} /> @@ -37,8 +45,11 @@ export function StepPerson({ draft, update, locations }: StepPersonProps) { birthDate={draft.birthDate || null} />
- update({ email })} /> - 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. */} + update({ email })} /> + update({ phone })} />
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
))} -

- Personalnummer und Firmen-E-Mail-Adresse werden automatisch vergeben. -

); } diff --git a/components/hire/types.ts b/components/hire/types.ts index d29ce9a..52702f9 100644 --- a/components/hire/types.ts +++ b/components/hire/types.ts @@ -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: "", }; diff --git a/components/import/ImportWorkbench.tsx b/components/import/ImportWorkbench.tsx new file mode 100644 index 0000000..784e520 --- /dev/null +++ b/components/import/ImportWorkbench.tsx @@ -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; + bericht?: Record; + 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([]); + const [antwort, setAntwort] = useState(null); + const [laeuft, setLaeuft] = useState<"pruefen" | "uebernehmen" | null>(null); + const [fehlschlag, setFehlschlag] = useState(null); + const eingabe = useRef(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 ( +
+
+
+
+

Datei wählen

+

+ Eine Excel-Mappe mit den Blättern Standorte, Organisation,{" "} + Jobkatalog, Planstellen, Personen,{" "} + Historie und Angehörige — oder je Blatt eine CSV-Datei, deren Name dem + Blatt entspricht. Nicht jedes Blatt muss dabei sein. +

+
+ + Vorlage herunterladen + +
+ +
+ 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" + /> + +
+ {dateien.length > 0 && ( +

+ {dateien.length === 1 ? dateien[0].name : `${dateien.length} Dateien`} ausgewählt +

+ )} +
+ + {fehlschlag && ( +

+ {fehlschlag} +

+ )} + + {antwort?.meldung && ( +

+ {antwort.meldung} +

+ )} + + {antwort && !antwort.meldung && ( +
+

{uebernommen ? "Übernommen" : "Ergebnis der Prüfung"}

+ + + + {Object.entries(uebernommen ? antwort.bericht! : antwort.anzahl) + .filter(([, n]) => n > 0) + .map(([blatt, n]) => ( + + + + + ))} + +
{blatt}{n}
+ + {uebernommen ? ( +

+ Die Daten stehen jetzt in der Anwendung. Der Vorgang ist im Audit-Log vermerkt. +

+ ) : antwort.ok ? ( + <> +

+ Keine Beanstandung. Beim Übernehmen entsteht genau das oben Gezeigte — alles in einem Zug oder gar + nichts. +

+
+ +
+ + ) : ( +

+ {antwort.fehler.length} Beanstandung{antwort.fehler.length === 1 ? "" : "en"} — es wurde nichts + geschrieben. +

+ )} +
+ )} + + {antwort && antwort.fehler.length > 0 && ( + + )} + {antwort && antwort.hinweise.length > 0 && ( + + )} +
+ ); +} + +function BefundListe({ titel, befunde, art }: { titel: string; befunde: Befund[]; art: "fehler" | "hinweis" }) { + const sichtbar = befunde.slice(0, MAX_ANZEIGE); + return ( +
+

+ {titel} ({befunde.length}) +

+ + + + + + + + + + + + {sichtbar.map((b, i) => ( + + + + + + + + ))} + +
BlattZeileSpalteWertWas zu tun ist
{b.blatt}{b.zeile ?? "–"}{b.spalte ?? "–"} + {b.wert ?? ""} + {b.meldung}
+ {befunde.length > sichtbar.length && ( +

+ Weitere {befunde.length - sichtbar.length} nicht angezeigt. Oft hängen viele Meldungen an einer Ursache — + nach der Korrektur erneut prüfen. +

+ )} +
+ ); +} diff --git a/components/orgchart/EmployeeTree.tsx b/components/orgchart/EmployeeTree.tsx index 1ffcacb..0409f29 100644 --- a/components/orgchart/EmployeeTree.tsx +++ b/components/orgchart/EmployeeTree.tsx @@ -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(); 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 - {e.first_name} {e.last_name} + {fmtName(e.first_name, e.last_name)} {e.job_title} diff --git a/components/orgchart/OrgChartClient.tsx b/components/orgchart/OrgChartClient.tsx index 11e0dde..8d0b75e 100644 --- a/components/orgchart/OrgChartClient.tsx +++ b/components/orgchart/OrgChartClient.tsx @@ -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("ma"); - const isToday = asOf === today; return (
- - value={view} - onChange={setView} - options={[ - { value: "ma", label: "Mitarbeiter" }, - { value: "pos", label: "Positionen" }, - { value: "reo", label: "Reorganisation" }, - ]} - /> +
+ + 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. */} + + + Als PDF + +
- {view !== "reo" && ( - - )} + {view === "ma" && } - {view === "pos" && ( - - )} - {view === "reo" && - (isToday ? ( - - ) : ( - // 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. -
-

Reorganisation nur zum heutigen Stand

-

- Es ist ein abweichender Stichtag gewählt. Reorganisationen wirken immer auf die aktuelle Struktur — wechseln - Sie zurück auf „Heute“, um eine zu planen. -

-
- ))} + {view === "pos" && }
); } diff --git a/components/orgchart/PositionTree.tsx b/components/orgchart/PositionTree.tsx index f2b1893..528bf8f 100644 --- a/components/orgchart/PositionTree.tsx +++ b/components/orgchart/PositionTree.tsx @@ -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 ( -
- {expandable ? ( - - ) : ( - - )} - {children} -
- ); -} - 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>(new Set(["root"])); +export function PositionTree({ employees, units, vacancies }: PositionTreeProps) { + const [expanded, setExpanded] = useState>(() => new Set(units.filter((u) => u.parent_id === null).map((u) => `unit-${u.id}`))); const [mode, setMode] = useState("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(); - const teamLeadByTeam = new Map(); - const icsByTeamAndTitle = new Map>(); - 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(); - 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(() => { - 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(); - 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 (
- value={mode} onChange={setMode} options={[{ value: "list", label: "Liste" }, { value: "graph", label: "Grafisch" }]} /> + + value={mode} + onChange={setMode} + options={[ + { value: "list", label: "Liste" }, + { value: "graph", label: "Grafisch" }, + ]} + />
{mode === "graph" ? ( - + ) : (
- {ceo && ( - toggle("root")}> - Geschäftsführung - - besetzt: {ceo.first_name} {ceo.last_name} - - - )} - {expanded.has("root") && - divisions.map((div) => { - const head = divisionHeadByDivision.get(div.id); - const divKey = `div-${div.id}`; - return ( -
- toggle(divKey)}> - Bereichsleitung {div.name} - {head ? `besetzt: ${head.first_name} ${head.last_name}` : "vakant"} - - {expanded.has(divKey) && - departments - .filter((d) => d.division_id === div.id) - .map((dept) => { - const deptKey = `dept-${dept.id}`; - return ( -
- toggle(deptKey)}> - - {dept.org_number} · {dept.name} - - - {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(); - const openForTeam = openByTeam.get(team.id) ?? []; - return ( -
- toggle(teamKey)}> - Teamleitung {team.name} - - {lead ? `besetzt: ${lead.first_name} ${lead.last_name}` : "vakant"} - - - {expanded.has(teamKey) && ( - <> - {Array.from(icsByTitle.entries()).map(([title, people]) => { - const groupKey = `${teamKey}-${title}`; - return ( -
- 0} - expandedNow={expanded.has(groupKey)} - onToggle={() => toggle(groupKey)} - > - {title} - {people.length}x besetzt - - {expanded.has(groupKey) && - people.map((p) => ( -
- - {p.first_name} {p.last_name} - -
- ))} -
- ); - })} - {openForTeam.map((p) => ( - - + {p.position_number} · {p.title} - - ))} - - )} -
- ); - })} -
- ); - })} -
- ); - })} + {tree.map((node) => ( + + ))}
)}
); } + +function ListNode({ + node, + depth, + expanded, + onToggle, +}: { + node: ChartNode; + depth: number; + expanded: Set; + onToggle: (id: string) => void; +}) { + const expandable = node.children.length > 0; + const open = expanded.has(node.id); + + return ( +
+
+ {expandable ? ( + + ) : ( + + )} + {node.href ? ( + + {node.label} + + ) : ( + {node.label} + )} + {node.sublabel && ( + {node.sublabel} + )} +
+ {open && node.children.map((child) => )} +
+ ); +} + +/** + * 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(); + 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(); + const staffOf = new Map(); + 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(); + 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(); + 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); +} diff --git a/components/orgchart/PrintChart.tsx b/components/orgchart/PrintChart.tsx new file mode 100644 index 0000000..d1331b9 --- /dev/null +++ b/components/orgchart/PrintChart.tsx @@ -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 = { + 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("a4"); + const [tiefe, setTiefe] = useState("team"); + const [gewaehlt, setGewaehlt] = useState>(() => 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 }>({ 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 ( +
+ + + {/* ── Auswahl ─────────────────────────────────────────── */} +
+
+
+

Was soll aufs Papier?

+

+ Das vollständige Organigramm sind {model.totals.people} Personen. Gedruckt wird selten alles — hier steht, + wie tief und welche Bereiche. +

+
+

+ Stand {fmtDate(asOf)} + {asOf !== today && " · abweichender Stichtag"} +

+
+ +
+

Tiefe

+
+ {TIEFEN.map((t) => ( + + ))} +
+
+ +
+
+

+ Bereiche ({gewaehlt.size} von {model.divisions.length}) +

+
+ + +
+
+
+ {model.divisions.map((d) => ( + + ))} +
+
+ +
+

+ {gewaehlt.size === 0 ? ( + Kein Bereich gewählt — es bliebe nur die Übersicht. + ) : ( + <> + Ergibt {seiten.length} {seiten.length === 1 ? "Seite" : "Seiten"} + + )} +

+
+ + Zurück + + +
+
+
+ + {schritt === "vorschau" && ( + <> +
+

+ {seiten.length} {seiten.length === 1 ? "Seite" : "Seiten"} · so kommt es aus dem Drucker +

+
+
+ {(Object.keys(FORMATE) as Format[]).map((f) => ( + + ))} +
+ +
+
+ + {format === "a4" && engste < LESBAR && ( +

+ Ein Blatt musste auf {Math.round(engste * 100)} % verkleinert werden, damit es passt — auf A4 kaum noch + lesbar. A3 wählen oder weniger Bereiche. +

+ )} + +

+ Im Druckdialog „Als PDF speichern“ wählen. Hintergrundgrafiken müssen dort eingeschaltet sein, + sonst fehlen die farbigen Kopfzeilen — in Chrome unter „Weitere Einstellungen“. +

+ +
+ {seiten.map((seite, i) => ( + + + meldeMassstab(seite.key, f)}> + + + + ))} +
+ + )} +
+ ); +} + +function Blatt({ children, format }: { children: React.ReactNode; format: Format }) { + const { blatt, satzBreite, satzHoehe } = FORMATE[format]; + return ( +
+
+ {children} +
+
+ ); +} + +function Kopf({ titel, unterzeile, seite }: { titel: string; unterzeile: string; seite: string }) { + return ( +
+
+

{titel}

+

{unterzeile}

+
+

{seite}

+
+ ); +} + +/** + * 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(null); + const inhalt = useRef(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 ( +
+
+ {children} +
+
+ ); +} + +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. +
+
+ +
+ {zeigeKinder && ( +
    + {kinder.map((k) => ( +
  • + + +
  • + ))} +
+ )} +
+ ); +} + +function Stapel({ units, ebenen, mitPersonen }: { units: PrintUnit[]; ebenen: number; mitPersonen: boolean }) { + if (ebenen <= 0 || units.length === 0) return null; + return ( +
    + {units.map((u) => ( +
  • + + +
  • + ))} +
+ ); +} + +const KOPFFARBE: Record = { + // 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 ( +
+
+

{unit.name}

+

+ {unit.unitType} · {unit.totalPeople} +

+
+ +
+ {unit.chief ? ( + + ) : ( +

Leitung unbesetzt

+ )} + + {mitPersonen && unit.members.length > 0 && ( +
+ {unit.members.map((p) => ( +
+ +
+ ))} +
+ )} + + {mitPersonen && + offen.map((v) => ( +

+ offen · {v.jobTitle} +

+ ))} + + {!mitPersonen && unit.members.length > 0 && ( +

+ {unit.members.length} {unit.members.length === 1 ? "Person" : "Personen"} +

+ )} +
+
+ ); +} + +function PersonZeile({ p, leitung }: { p: PrintPerson; leitung?: boolean }) { + return ( +

+ {p.name} + {p.absent && · abwesend} +
+ {p.jobTitle} +

+ ); +} diff --git a/components/orgchart/ReorgWorkbench.tsx b/components/orgchart/ReorgWorkbench.tsx deleted file mode 100644 index 24c6453..0000000 --- a/components/orgchart/ReorgWorkbench.tsx +++ /dev/null @@ -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 = { - 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("emp"); - const [selectedEmployees, setSelectedEmployees] = useState([]); - const [sourceTeamId, setSourceTeamId] = useState(""); - const [sourceDeptId, setSourceDeptId] = useState(""); - const [sourceDivisionId, setSourceDivisionId] = useState(""); - const [targetDivisionId, setTargetDivisionId] = useState(""); - const [targetTeamId, setTargetTeamId] = useState(""); - const [pendingMoves, setPendingMoves] = useState([]); - const [applying, setApplying] = useState(false); - const [undoingId, setUndoingId] = useState(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(); - 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 { - 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(); - 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(); - 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 ( -
-
-

Neue Reorganisation

-
- - -
- -
- Art der Änderung -
- {(Object.keys(KIND_LABELS) as ChangeKind[]).map((kind) => ( - - ))} -
-
- -
-
- {changeType === "emp" && ( -
- - {(p) => ( - - {...p} - placeholder="Mitarbeiter:in suchen…" - onSearch={searchLocalEmployees} - onSelect={(e) => setSelectedEmployees((prev) => [...prev, e])} - renderResult={(e) => ( -
-
- {e.first_name} {e.last_name} -
-
{e.job_title}
-
- )} - /> - )} -
- {selectedEmployees.length > 0 && ( -
- {selectedEmployees.map((e) => ( - - {e.first_name} {e.last_name} - - - ))} -
- )} -
- )} - {changeType === "team" && ( - ({ value: t.id, label: t.name }))} - /> - )} - {changeType === "abt" && ( - ({ value: d.id, label: d.name }))} - /> - )} - {changeType === "dept" && ( - ({ value: d.id, label: d.name }))} - /> - )} -
- -
- { - setTargetDivisionId(v); - setTargetTeamId(""); - }} - placeholder="Ziel-Bereich wählen…" - options={divisions.map((d) => ({ value: d.id, label: d.name }))} - /> - ({ value: t.id, label: t.name }))} - /> -
-
- - -
- - {pendingMoves.length > 0 && ( -
-

Geplante Änderungen ({pendingMoves.length})

-
    - {pendingMoves.map((m) => ( -
  • -
    - {KIND_LABELS[m.kind]} - - {m.label} → {m.targetTeamLabel} - - ({m.employeeIds.length} Mitarbeiter:innen) -
    - -
  • - ))} -
- -

Auswirkung auf Headcount

- - - - - - - - - - - {Array.from(divisionDelta.entries()) - .filter(([, delta]) => delta !== 0) - .map(([divId, delta]) => { - const before = divisionBefore.get(divId) ?? 0; - return ( - - - - - - - ); - })} - -
BereichVorherNachherΔ
{divisionById.get(divId)?.name}{before}{before + delta} 0 ? "text-success-text" : "text-danger-text"}`}> - {delta > 0 ? `+${delta}` : delta} -
- -
- - -
-
- )} - - {reorgScenarios.length > 0 && ( -
-

↩ Durchgeführte Reorganisationen – rückgängig machbar

-
    - {reorgScenarios.map((s) => ( -
  • -
    - {s.name} - - wirksam ab {fmtDate(s.effective_date)} · durchgeführt am {fmtDate(s.applied_at)} - -
    - -
  • - ))} -
-
- )} -
- ); -} diff --git a/components/orgchart/types.ts b/components/orgchart/types.ts index 11de058..3cf7f9e 100644 --- a/components/orgchart/types.ts +++ b/components/orgchart/types.ts @@ -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. diff --git a/components/positions/CreatePositionModal.tsx b/components/positions/CreatePositionModal.tsx index 928c3b2..3ba72d4 100644 --- a/components/positions/CreatePositionModal.tsx +++ b/components/positions/CreatePositionModal.tsx @@ -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(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 } >
- + { + 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})`, + }))} + /> + - {!superior ? ( - - {(p) => ( - - {...p} - placeholder="Name oder Titel…" - onSearch={(q) => searchSuperiors(q, isLead)} - onSelect={setSuperior} - renderResult={(r) => ( -
-
- {r.first_name} {r.last_name} -
-
{r.job_title}
-
- )} - /> - )} -
- ) : ( -
-

- {isLead ? "Übergeordnete Bereichsleitung" : "Übergeordnete Teamleitung"} -

-
-
-
- {superior.first_name} {superior.last_name} -
-
{superior.job_title}
-
- -
-
- )} - {isLead && ( - ({ value: t.id, label: t.name }))} - /> + {chiefTaken && ( +

Für {unit?.name} besteht bereits eine Leitungsplanstelle.

)}
diff --git a/components/positions/EditPositionModal.tsx b/components/positions/EditPositionModal.tsx new file mode 100644 index 0000000..85cf2e3 --- /dev/null +++ b/components/positions/EditPositionModal.tsx @@ -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 ( + + + + + } + > +
+ + + ({ + value: u.id, + label: `${"  ".repeat(u.depth)}${u.name} (${u.unit_type})`, + }))} + /> + +
+ + +
+ + ({ value: k.id, label: `${k.code} · ${k.name}` }))} + /> + {kostenstelleGeaendert && ( +
+ +

+ Die bisherige Kontierung endet an diesem Tag, die neue beginnt. Was vorher gebucht wurde, bleibt, wo es + gebucht wurde. +

+
+ )} + + + +

+ 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. +

+
+
+ ); +} diff --git a/components/positions/PositionsPageClient.tsx b/components/positions/PositionsPageClient.tsx index 71dac4c..e8bf5a5 100644 --- a/components/positions/PositionsPageClient.tsx +++ b/components/positions/PositionsPageClient.tsx @@ -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(null); const [deletingId, setDeletingId] = useState(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 ( +
+ {/* 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. */} + + + +
+ ); + } + return (
-
+
-

Offene Positionen ({openPositions.length})

+

Unbesetzte Planstellen ({offen.length})

- {openPositions.length === 0 ? ( -

Derzeit keine offenen Positionen.

+ {offen.length === 0 ? ( +

Derzeit ist jede geltende Planstelle besetzt oder vergeben.

) : (
- {openPositions.map((p) => { - const notYetValid = p.valid_from > today; - return ( -
-
-
{p.title}
- -
-
- {p.position_number} · {p.orgLabel} -
-
seit {p.daysOpen} Tagen offen
- {notYetValid &&
Gültig ab {fmtDate(p.valid_from)}
} -
- ); - })} + {offen.map((p) => ( + + ))}
)} -
+ - setCreateOpen(false)} teams={teams} /> + {kuenftig.length > 0 && ( +
+

Künftige Planstellen ({kuenftig.length})

+

+ Beschlossen, aber noch nicht gültig. Sie zählen nicht als Vakanz und lassen sich bis zum Beginn ändern. +

+
+ {kuenftig.map((p) => ( + + ))} +
+
+ )} + + 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 && ( + setEditing(null)} + /> + )}
); } diff --git a/components/reports/ReportsPageClient.tsx b/components/reports/ReportsPageClient.tsx index 4714480..d94e4cc 100644 --- a/components/reports/ReportsPageClient.tsx +++ b/components/reports/ReportsPageClient.tsx @@ -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) => void; + onReset: () => void; +}) { + const [offen, setOffen] = useState(false); + const anzahl = anzahlKriterien(criteria); + + return ( +
+
+ + {anzahl > 0 && ( + + )} +
+ + {offen && ( +
+ {AUSWAHL_KRITERIEN.map((k) => ( + + ))} + +
+ Merkmale +
+ {JANEIN_KRITERIEN.map((k) => ( +
+ {k.label} + +
+ ))} +
+
+ + {ZEITRAUM_KRITERIEN.map((k) => ( +
+ {k.label} +
+ onChange({ [`${k.key}From`]: v || undefined })} + /> + onChange({ [`${k.key}To`]: v || undefined })} + /> +
+
+ ))} + +
+ Wochenstunden +
+ onChange({ hoursFrom: v || undefined })} + /> + onChange({ hoursTo: v || undefined })} + /> +
+
+ +

+ Alle Kriterien gelten für den Bericht und für beide Exporte. +

+
+ )} +
+ ); +} + 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 = {}; + 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 = 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) {

Filter

@@ -410,19 +560,13 @@ export function ReportsPageClient(props: ReportsPageClientProps) { ))}
- )}
+ + {mode === "snapshot" && ( + + )}
diff --git a/components/shell/Sidebar.tsx b/components/shell/Sidebar.tsx index 3c934a3..2fa0b50 100644 --- a/components/shell/Sidebar.tsx +++ b/components/shell/Sidebar.tsx @@ -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 { diff --git a/components/ui/AenderungsTabelle.tsx b/components/ui/AenderungsTabelle.tsx new file mode 100644 index 0000000..3dd2a91 --- /dev/null +++ b/components/ui/AenderungsTabelle.tsx @@ -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 leer; + } + return {text}; +} + +export function AenderungsTabelle({ changes }: { changes: AuditChange[] }) { + return ( +
+ + + + + + + + + + {changes.map((c, i) => ( + + + + + + ))} + +
FeldVorherNachher
{c.feld} + + + +
+
+ ); +} diff --git a/components/ui/useDialogFocus.ts b/components/ui/useDialogFocus.ts index d16cece..2348076 100644 --- a/components/ui/useDialogFocus.ts +++ b/components/ui/useDialogFocus.ts @@ -25,6 +25,18 @@ function focusableWithin(container: HTMLElement): HTMLElement[] { export function useDialogFocus(open: boolean, onClose: () => void, containerRef: RefObject) { const restoreToRef = useRef(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]); } diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..ca0febf --- /dev/null +++ b/deploy/Caddyfile @@ -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 + } +} diff --git a/docker-compose.yml b/docker-compose.yml index d394920..24c2775 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/azure-migration.md b/docs/azure-migration.md new file mode 100644 index 0000000..702cdca --- /dev/null +++ b/docs/azure-migration.md @@ -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? diff --git a/docs/data-model.md b/docs/data-model.md index 82e4c0d..8850934 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -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 diff --git a/docs/datenkatalog.md b/docs/datenkatalog.md new file mode 100644 index 0000000..64d4229 --- /dev/null +++ b/docs/datenkatalog.md @@ -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.* diff --git a/docs/entra-sso.md b/docs/entra-sso.md new file mode 100644 index 0000000..65412b4 --- /dev/null +++ b/docs/entra-sso.md @@ -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:///api/auth/callback/microsoft-entra-id` | +| Anwendungs-ID (Client) | `` | +| Verzeichnis-ID (Mandant) | `` | + +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//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. diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..3517ff0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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; diff --git a/lib/absence.ts b/lib/absence.ts index 2e8a6ba..d1e9628 100644 --- a/lib/absence.ts +++ b/lib/absence.ts @@ -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 = { Aktiv: "Aktiv", diff --git a/lib/auth/config.ts b/lib/auth/config.ts new file mode 100644 index 0000000..97920b6 --- /dev/null +++ b/lib/auth/config.ts @@ -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); + }, + }, + }; +} diff --git a/lib/auth/require-hr.ts b/lib/auth/require-hr.ts new file mode 100644 index 0000000..2497e11 --- /dev/null +++ b/lib/auth/require-hr.ts @@ -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 { + 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 }; +} diff --git a/lib/auth/session.ts b/lib/auth/session.ts new file mode 100644 index 0000000..46ac203 --- /dev/null +++ b/lib/auth/session.ts @@ -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 { + 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 { + const id = await currentUserId(); + if (!id) throw new Error("Nicht angemeldet."); + return id; +} diff --git a/lib/cost-centers.ts b/lib/cost-centers.ts new file mode 100644 index 0000000..96920df --- /dev/null +++ b/lib/cost-centers.ts @@ -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 { + const out = new Map(); + 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> { + 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); +} diff --git a/lib/countries.ts b/lib/countries.ts index 1bec166..f67982f 100644 --- a/lib/countries.ts +++ b/lib/countries.ts @@ -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); +} diff --git a/lib/dashboard-data.ts b/lib/dashboard-data.ts new file mode 100644 index 0000000..fef3951 --- /dev/null +++ b/lib/dashboard-data.ts @@ -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().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, + }; +} diff --git a/lib/dashboard-filter.ts b/lib/dashboard-filter.ts new file mode 100644 index 0000000..a3a8502 --- /dev/null +++ b/lib/dashboard-filter.ts @@ -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; +} diff --git a/lib/db/index.ts b/lib/db/index.ts new file mode 100644 index 0000000..0353f55 --- /dev/null +++ b/lib/db/index.ts @@ -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({ + 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; + +/** + * 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(userId: string | null, fn: (tx: Tx) => Promise): Promise { + 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(fn: (tx: Tx) => Promise): Promise { + return withUser(null, fn); +} + +/** Für Migrations- und Wartungsskripte, die ausserhalb einer Anfrage laufen. */ +export async function closeDb(): Promise { + await db.destroy(); +} + +export { sql }; diff --git a/lib/db/json.ts b/lib/db/json.ts new file mode 100644 index 0000000..81970a9 --- /dev/null +++ b/lib/db/json.ts @@ -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 | string): RawBuilder { + const ref = typeof spalte === "string" ? sql.ref(spalte) : spalte; + return sql`to_char(${ref} at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`; +} diff --git a/lib/db/pool.ts b/lib/db/pool.ts new file mode 100644 index 0000000..f5873b8 --- /dev/null +++ b/lib/db/pool.ts @@ -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; +} diff --git a/lib/db/rpc.ts b/lib/db/rpc.ts new file mode 100644 index 0000000..fade4bb --- /dev/null +++ b/lib/db/rpc.ts @@ -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): Promise { + // 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 +): Promise { + 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." }; + } +} diff --git a/lib/db/schema.ts b/lib/db/schema.ts new file mode 100644 index 0000000..51a5374 --- /dev/null +++ b/lib/db/schema.ts @@ -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 = { + [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; +}; + +export type DB = { [T in keyof Tables]: Columns }; + +/** Die Tabelle, die auth.users ablöst. Noch nicht in der Alt-Beschreibung. */ +export type AppUsersTable = { + id: ColumnType; + external_id: string; + email: string; + full_name: ColumnType; + created_at: ColumnType; + last_seen_at: ColumnType; +}; + +export type Schema = DB & { app_users: AppUsersTable }; diff --git a/lib/dienstwagen.ts b/lib/dienstwagen.ts new file mode 100644 index 0000000..cb12a2b --- /dev/null +++ b/lib/dienstwagen.ts @@ -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 ?? "–"; +} diff --git a/lib/employee-detail-data.ts b/lib/employee-detail-data.ts new file mode 100644 index 0000000..d88c9c1 --- /dev/null +++ b/lib/employee-detail-data.ts @@ -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])), + }; +} diff --git a/lib/employee-search.ts b/lib/employee-search.ts new file mode 100644 index 0000000..2d7330f --- /dev/null +++ b/lib/employee-search.ts @@ -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}%`]; + }); +} diff --git a/lib/employee-status-filter.ts b/lib/employee-status-filter.ts index cdc811b..5dd722f 100644 --- a/lib/employee-status-filter.ts +++ b/lib/employee-status-filter.ts @@ -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; /** True once the person has started and has not left yet. */ -function employed(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 { + 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(query: Q, statuses: EmploymentStatus[], asOf: string): Q { +export function derivedStatusFilter(eb: Eb, statuses: EmploymentStatus[], asOf: string): Expression | 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; } diff --git a/lib/format.ts b/lib/format.ts index a16228e..4fe62a8 100644 --- a/lib/format.ts +++ b/lib/format.ts @@ -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, diff --git a/lib/history.ts b/lib/history.ts new file mode 100644 index 0000000..44b049d --- /dev/null +++ b/lib/history.ts @@ -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 }; + }); +} diff --git a/lib/import/load.ts b/lib/import/load.ts new file mode 100644 index 0000000..784adf5 --- /dev/null +++ b/lib/import/load.ts @@ -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; + +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(tx: Tx, tabelle: string, zeilen: T[]): Promise { + 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 { + 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): { 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 { + 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; +} diff --git a/lib/import/parse.ts b/lib/import/parse.ts new file mode 100644 index 0000000..774d53e --- /dev/null +++ b/lib/import/parse.ts @@ -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; +}; + +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(); + 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 = {}; + 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 { + 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 { + 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.`], + }; +} diff --git a/lib/import/schema.ts b/lib/import/schema.ts new file mode 100644 index 0000000..7d5de00 --- /dev/null +++ b/lib/import/schema.ts @@ -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); +} diff --git a/lib/import/validate.ts b/lib/import/validate.ts new file mode 100644 index 0000000..778bad4 --- /dev/null +++ b/lib/import/validate.ts @@ -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; + orgNummern: Map; + jobCodes: Map; + /** Planstellennummer → { id, heuteBesetzt } */ + planstellen: Map; + personalnummern: Map; + emails: Set; + svNummern: Set; +}; + +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 }; +export type Datensatz = Record; + +export type Pruefergebnis = { + fehler: Befund[]; + hinweise: Befund[]; + datensatz: Datensatz; + /** Was angelegt würde, je Blatt. */ + anzahl: Record; +}; + +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(); + + for (const r of blatt.zeilen) { + const werte: Record = {}; + 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(); + 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(); + 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(); + const eintritte = new Map(); + + 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 = {}; + for (const b of BLAETTER) anzahl[b.name] = (datensatz[b.name] ?? []).length; + + return { fehler, hinweise, datensatz, anzahl }; +} diff --git a/lib/import/werte.ts b/lib/import/werte.ts new file mode 100644 index 0000000..593b533 --- /dev/null +++ b/lib/import/werte.ts @@ -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(roh: string, erlaubt: readonly T[]): T | null { + const s = roh.trim().toLowerCase(); + if (!s) return null; + return erlaubt.find((w) => w.toLowerCase() === s) ?? null; +} diff --git a/lib/notes.ts b/lib/notes.ts index 72aaebf..6f20d7b 100644 --- a/lib/notes.ts +++ b/lib/notes.ts @@ -1,26 +1,73 @@ -import type { SupabaseClient } from "@supabase/supabase-js"; -import { fetchAllRows } from "./supabase/query"; +import type { Tx } from "./db"; +import { jsonArrayFrom, zeitstempel } from "./db/json"; +import { fmtName } from "./format"; +import type { OrgEb } from "./org"; import type { Database } from "./supabase/types"; export type OpenNote = Database["public"]["Tables"]["employee_notes"]["Row"] & { employeeName: string; }; -// "Meine Notizen" (Topbar-Glocke): das geteilte, mitarbeiterübergreifende -// Postfach aller noch nicht erledigten HR-Notizen — unabhängig davon wer -// sie verfasst hat oder zu wem sie gehören (mit Nutzer abgestimmt). Zwei -// einfache Queries, in JS gemerged — gleiches Muster wie loadEventHistory -// in lib/reports-data.ts, da der handgeschriebene Database-Typ keine -// relationalen Embeddings für eine einzelne verschachtelte Query kennt. -export async function loadOpenNotes(supabase: SupabaseClient): Promise { - const [{ data: notes }, employees] = await Promise.all([ - supabase.from("employee_notes").select("*").eq("done", false).order("created_at", { ascending: false }), - fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name").order("id")), - ]); +/** Was die Teilabfrage liefert: die Notiz plus den Namen aus dem Join. */ +export type NotizZeile = Omit & { + created_at: string; + done_at: string | null; + first_name: string | null; + last_name: string | null; +}; - const employeeById = new Map(employees.map((e) => [e.id, e])); - return (notes ?? []).map((n) => { - const emp = employeeById.get(n.employee_id); - return { ...n, employeeName: emp ? `${emp.first_name} ${emp.last_name}` : "Unbekannt" }; +// „Meine Notizen" (Topbar-Glocke): das geteilte, mitarbeiterübergreifende +// Postfach aller noch nicht erledigten HR-Notizen — unabhängig davon, wer sie +// verfasst hat oder zu wem sie gehören (mit Nutzer abgestimmt). +// +// Früher zwei Abfragen, in JavaScript zusammengeführt, weil die API-Schicht +// für eine einzelne verschachtelte Abfrage keine Verknüpfung anbot. Am +// direkten Zugang ist es schlicht ein Join. + +/** + * Die offenen Notizen als *Teilabfrage* — zum Einhängen in die eine Abfrage, + * die eine Seite ohnehin stellt (lib/db/json.ts). + * + * Die beiden Zeitstempel gehen durch zeitstempel(): innerhalb von JSON + * formatiert Postgres sie anders als der Treiber es sonst täte, und der + * Unterschied fällt erst beim Vergleichen auf. + */ +export function offeneNotizenAbfrage(eb: OrgEb) { + return eb + .selectFrom("employee_notes as n") + .leftJoin("employees as e", "e.id", "n.employee_id") + .select([ + "n.id", + "n.employee_id", + "n.author_user_id", + "n.author_name", + "n.category", + "n.note_text", + "n.due_date", + "n.done", + "n.done_by", + "e.first_name", + "e.last_name", + ]) + .select((x) => [zeitstempel(x.ref("n.created_at")).as("created_at"), zeitstempel(x.ref("n.done_at")).as("done_at")]) + .where("n.done", "=", false) + .orderBy("n.created_at", "desc"); +} + +/** Der reine Teil: aus den Zeilen die Notizen mit lesbarem Namen. */ +export function baueOffeneNotizen(rows: NotizZeile[]): OpenNote[] { + return rows.map((row) => { + const { first_name, last_name, ...note } = row; + return { + ...(note as Database["public"]["Tables"]["employee_notes"]["Row"]), + employeeName: first_name && last_name ? fmtName(first_name, last_name) : "Unbekannt", + }; }); } + +export async function loadOpenNotes(tx: Tx): Promise { + const { notes } = await tx + .selectNoFrom((eb) => [jsonArrayFrom(offeneNotizenAbfrage(eb)).as("notes")]) + .executeTakeFirstOrThrow(); + return baueOffeneNotizen(notes as NotizZeile[]); +} diff --git a/lib/om-reporting.ts b/lib/om-reporting.ts new file mode 100644 index 0000000..b73c386 --- /dev/null +++ b/lib/om-reporting.ts @@ -0,0 +1,85 @@ +// Die SAP-OM-Berichtslinie, abgeleitet aus dem Organisationsbaum. +// +// Dieselbe Regel steckt als om_reporting_lines() in der Datenbank. Zwei +// Fassungen derselben Regel driften auseinander, deshalb prüft +// tests/integration/om-reporting.test.ts beide gegen denselben Bestand. +// Hier liegt sie zusätzlich, weil das Organigramm zu einem Stichtag ohnehin +// im Anwendungscode gerechnet wird und ein Datenbank-Roundtrip je +// Stichtagswechsel nichts brächte. +// +// Regel: +// Wer eine gewöhnliche Planstelle innehat, berichtet an die Leitung der +// eigenen Einheit. Wer selbst die Leitung innehat, an die Leitung der +// übergeordneten Einheit. +// +// Aufwärtsregel: Ist diese Leitung unbesetzt oder langzeitabwesend, geht es +// weiter nach oben. Eine unbesetzte Abteilungsleitung braucht damit keine +// Sonderbehandlung — sie wird übersprungen. + +export type OmUnit = { id: string; parentId: string | null }; + +export type OmHolder = { + employeeId: string; + positionId: string; + orgUnitId: string; + isChief: boolean; + /** Langzeitabwesend am Stichtag. */ + absent: boolean; +}; + +export type OmReportingLine = { + employeeId: string; + positionId: string; + orgUnitId: string; + isChief: boolean; + /** Zuständige Leitung, auch wenn abwesend. Null, wenn es keine gibt. */ + formalManagerId: string | null; + /** Nächste besetzte und anwesende Leitung ab der zuständigen Einheit aufwärts. */ + actingManagerId: string | null; +}; + +/** + * `units` und `holders` beschreiben den Stand zu genau einem Stichtag — + * gültige Einheiten und laufende Besetzungen. Die Zeitlogik bleibt bewusst + * draußen, damit diese Funktion nur eine Sache tut. + */ +export function resolveReportingLines(units: OmUnit[], holders: OmHolder[]): OmReportingLine[] { + const parentOf = new Map(units.map((u) => [u.id, u.parentId])); + const chiefOfUnit = new Map(); + for (const h of holders) { + if (h.isChief) chiefOfUnit.set(h.orgUnitId, h); + } + + return holders.map((h) => { + // Leitungen suchen ab der übergeordneten Einheit, alle anderen ab der + // eigenen — sonst berichtete eine Leitung an sich selbst. + const baseUnitId = h.isChief ? (parentOf.get(h.orgUnitId) ?? null) : h.orgUnitId; + + const formal = baseUnitId ? (chiefOfUnit.get(baseUnitId) ?? null) : null; + + // Aufwärts, bis eine besetzte und anwesende Leitung gefunden ist. Der + // Zyklusschutz ist kein Selbstzweck: parent_id ist eine gewöhnliche + // Spalte, und ein fehlerhafter Import kann einen Ring erzeugen. + let actingManagerId: string | null = null; + const seen = new Set(); + let unitId: string | null = baseUnitId; + while (unitId && !seen.has(unitId)) { + seen.add(unitId); + const chief = chiefOfUnit.get(unitId); + if (chief && !chief.absent && chief.employeeId !== h.employeeId) { + actingManagerId = chief.employeeId; + break; + } + unitId = parentOf.get(unitId) ?? null; + } + + return { + employeeId: h.employeeId, + positionId: h.positionId, + orgUnitId: h.orgUnitId, + isChief: h.isChief, + formalManagerId: formal?.employeeId ?? null, + actingManagerId, + }; + }); +} diff --git a/lib/org.ts b/lib/org.ts index 3c16e70..82813f0 100644 --- a/lib/org.ts +++ b/lib/org.ts @@ -1,48 +1,147 @@ -import type { SupabaseClient } from "@supabase/supabase-js"; +import type { ExpressionBuilder } from "kysely"; +import type { Tx } from "./db"; +import { jsonArrayFrom } from "./db/json"; +import type { Schema } from "./db/schema"; import type { Database } from "./supabase/types"; -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"]; +/** + * Der Ausdrucksbauer einer Abfrage ohne eigene Tabelle (selectNoFrom) — das + * ist der Ort, an dem Teilabfragen zusammengehängt werden. + */ +export type OrgEb = ExpressionBuilder; + +// Die Organisation ist ein Baum, keine drei Tabellen mehr. Alles, was früher +// aus divisions/departments/teams zusammengesteckt wurde, ergibt sich jetzt +// aus org_units.parent_id — und damit funktioniert es auch für eine fünfte +// Ebene, ohne dass hier etwas zu ändern wäre. + +export type OrgUnitType = "Gesellschaft" | "Bereich" | "Abteilung" | "Team"; + +export type OrgUnit = { + id: string; + org_number: string; + name: string; + parent_id: string | null; + unit_type: OrgUnitType; +}; + +export type Location = Database["public"]["Tables"]["locations"]["Row"]; export type OrgMaps = { - divisions: Map; - departments: Map; - teams: Map; + units: Map; + /** Tiefensuche ab der Wurzel: eine Einheit steht immer hinter ihrem Elternteil. */ + unitList: OrgUnit[]; + /** Abstand zur Wurzel; die Wurzel selbst hat 0. */ + depthOf: Map; + childrenOf: Map; locations: Map; - divisionList: Division[]; locationList: Location[]; }; -// Org reference data is tiny (9 divisions / 16 departments / 35 teams / 5 -// locations) — fetched whole and joined client-side rather than per-row. -export async function loadOrgMaps(supabase: SupabaseClient): Promise { - const [{ data: divisions }, { data: departments }, { data: teams }, { data: locations }] = await Promise.all([ - supabase.from("divisions").select("*").order("name"), - supabase.from("departments").select("*"), - supabase.from("teams").select("*"), - supabase.from("locations").select("*").order("name"), - ]); +// Die Referenzdaten sind winzig (60 Einheiten, 5 Standorte) — sie werden +// ganz geladen und im Speicher verknüpft, statt je Zeile nachzuschlagen. +/** + * Einheiten und Standorte als *Teilabfragen* — zum Einhängen in die eine + * Abfrage, die eine Seite ohnehin stellt. + * + * Fast jede Seite braucht den Baum. Als eigene Abfrage wäre das je Seitenauf- + * bau eine zusätzliche Rundreise für 65 Zeilen, die sich selten ändern; als + * Teilabfrage kostet sie nichts (lib/db/json.ts). + */ +export function orgMapsAbfragen(eb: OrgEb) { + return [ + jsonArrayFrom( + eb.selectFrom("org_units").select(["id", "org_number", "name", "parent_id", "unit_type"]).orderBy("org_number") + ).as("units"), + jsonArrayFrom(eb.selectFrom("locations").selectAll().orderBy("name")).as("locations"), + ] as const; +} + +export async function loadOrgMaps(tx: Tx): Promise { + // Beides in einer Rundreise. Ein Promise.all wäre hier keine + // Gleichzeitigkeit gewesen, sondern eine Schlange — der Grund steht in + // lib/db/json.ts. + const { units, locations } = await tx + .selectNoFrom((eb) => [...orgMapsAbfragen(eb)]) + .executeTakeFirstOrThrow(); + + return buildOrgMaps(units as OrgUnit[], locations as Location[]); +} + +/** Der reine Teil: aus den Zeilen den Baum bauen, ohne Datenbank. */ +export function buildOrgMaps(units: OrgUnit[], locations: Location[]): OrgMaps { + const childrenOf = new Map(); + for (const u of units) { + const list = childrenOf.get(u.parent_id) ?? []; + list.push(u); + childrenOf.set(u.parent_id, list); + } + for (const list of childrenOf.values()) list.sort((a, b) => a.name.localeCompare(b.name, "de")); + + const unitList: OrgUnit[] = []; + const depthOf = new Map(); + const walk = (parentId: string | null, depth: number) => { + for (const u of childrenOf.get(parentId) ?? []) { + unitList.push(u); + depthOf.set(u.id, depth); + walk(u.id, depth + 1); + } + }; + walk(null, 0); return { - divisions: new Map((divisions ?? []).map((d) => [d.id, d])), - departments: new Map((departments ?? []).map((d) => [d.id, d])), - teams: new Map((teams ?? []).map((t) => [t.id, t])), - locations: new Map((locations ?? []).map((l) => [l.id, l])), - divisionList: divisions ?? [], - locationList: locations ?? [], + units: new Map(units.map((u) => [u.id, u])), + unitList, + depthOf, + childrenOf, + locations: new Map(locations.map((l) => [l.id, l])), + locationList: locations, }; } -export function breadcrumbFor(orgMaps: OrgMaps, divisionId: string | null, teamId: string | null) { - const division = divisionId ? orgMaps.divisions.get(divisionId) : undefined; - const team = teamId ? orgMaps.teams.get(teamId) : undefined; - const department = team ? orgMaps.departments.get(team.department_id) : undefined; - return { division, department, team }; +/** Wurzel zuerst, die Einheit selbst zuletzt. */ +export function ancestorsOf(maps: OrgMaps, unitId: string | null | undefined): OrgUnit[] { + const chain: OrgUnit[] = []; + const seen = new Set(); + let current = unitId ? maps.units.get(unitId) : undefined; + while (current && !seen.has(current.id)) { + seen.add(current.id); + chain.unshift(current); + current = current.parent_id ? maps.units.get(current.parent_id) : undefined; + } + return chain; } -export function breadcrumbLabel(orgMaps: OrgMaps, divisionId: string | null, teamId: string | null): string { - const { division, department, team } = breadcrumbFor(orgMaps, divisionId, teamId); - return [division?.name, department?.name, team?.name].filter(Boolean).join(" › ") || "–"; +/** Die Einheit und alles darunter — die Menge, die ein Filter „Bereich X" meint. */ +export function subtreeOf(maps: OrgMaps, unitId: string): string[] { + const out: string[] = []; + const queue = [unitId]; + const seen = new Set(); + while (queue.length > 0) { + const id = queue.shift()!; + if (seen.has(id)) continue; + seen.add(id); + out.push(id); + for (const child of maps.childrenOf.get(id) ?? []) queue.push(child.id); + } + return out; +} + +/** + * „Produktion › Fertigung › Montage". Die Gesellschaft bleibt weg: sie steht + * über allem und trägt in einer Zeile nichts bei. + */ +export function breadcrumbLabel(maps: OrgMaps, unitId: string | null | undefined): string { + const chain = ancestorsOf(maps, unitId).filter((u) => u.unit_type !== "Gesellschaft"); + return chain.map((u) => u.name).join(" › ") || "–"; +} + +/** Die oberste Einheit unterhalb der Gesellschaft — das, was früher „Bereich" hiess. */ +export function divisionOf(maps: OrgMaps, unitId: string | null | undefined): OrgUnit | undefined { + return ancestorsOf(maps, unitId).find((u) => u.unit_type !== "Gesellschaft"); +} + +/** Die Einheit selbst, wenn sie nicht die Gesellschaft ist. */ +export function unitOf(maps: OrgMaps, unitId: string | null | undefined): OrgUnit | undefined { + return unitId ? maps.units.get(unitId) : undefined; } diff --git a/lib/orgchart-data.ts b/lib/orgchart-data.ts index 3436f2c..eed1adc 100644 --- a/lib/orgchart-data.ts +++ b/lib/orgchart-data.ts @@ -1,32 +1,33 @@ -import type { SupabaseClient } from "@supabase/supabase-js"; -import type { OrgEmployee } from "@/components/orgchart/types"; -import { resolveActingManagers } from "./acting-manager"; +import type { OrgEmployee, OrgVacancy } from "@/components/orgchart/types"; +import type { Tx } from "./db"; import { todayIso } from "./format"; -import { deriveStatusAsOf } from "./reports"; -import { fetchAllRows } from "./supabase/query"; -import type { Database } from "./supabase/types"; +import { resolveReportingLines, type OmHolder, type OmUnit } from "./om-reporting"; -// The Organigramm as it stood (or will stand) on a given date. Three sources -// have to be reconciled, because no single one covers the whole timeline: +// Das Organigramm, wie es an einem Stichtag stand oder stehen wird. // -// past/today employee_assignments — the interval covering `asOf` -// future pending_org_changes — effective-dated moves not yet applied -// membership entry/exit/karenz — who counted as staff on that date +// Im Altmodell mussten dafür drei Quellen versöhnt werden, weil keine den +// ganzen Zeitstrahl abdeckte: eine mitgeschriebene Zuordnungshistorie für die +// Vergangenheit, vorgemerkte Änderungen für die Zukunft und die +// Ein-/Austrittsdaten für die Frage, wer überhaupt dazuzählte. // -// See supabase/migrations/*_employee_assignment_history.sql for why the -// placement timeline is captured by a trigger rather than per-RPC. +// Im OM-Modell fällt das zusammen. position_assignments ist zeitabhängig, also +// beantwortet eine einzige Abfrage „wer besetzte am Stichtag welche +// Planstelle" — für Vergangenheit und Zukunft gleichermassen. Wer zu dem +// Zeitpunkt keine Planstelle innehatte, war nicht da; eine zweite +// Zugehörigkeitsregel braucht es nicht mehr. +// +// Übrig bleibt die Projektion vorgemerkter Versetzungen: die stehen noch nicht +// in position_assignments, weil sie erst am Stichtag geschrieben werden. -const ORG_COLUMNS = - "id, personnel_number, first_name, last_name, job_title, manager_id, team_id, division_id, is_lead, org_level, entry_date, exit_date, karenz_start_date, karenz_return_date, absence_type"; - -/** Change types that move someone in the org; the rest only affect status or contract. */ -const PLACEMENT_CHANGES = ["transfer", "reorg", "promotion"] as const; +/** Änderungsarten, die jemanden in der Organisation verschieben. */ +const PLACEMENT_CHANGES = ["transfer"] as const; export type OrgAsOfResult = { employees: OrgEmployee[]; - /** How many placements were projected from not-yet-applied changes. */ + vacancies: OrgVacancy[]; + /** Wie viele Platzierungen aus noch nicht angewandten Änderungen stammen. */ projectedCount: number; - /** Earliest date the assignment history actually covers. */ + /** Frühester Tag, den die Besetzungshistorie tatsächlich abdeckt. */ historyStartsAt: string | null; }; @@ -36,196 +37,193 @@ type EmployeeRow = { first_name: string; last_name: string; job_title: string; - manager_id: string | null; - team_id: string | null; - division_id: string; - is_lead: boolean; - org_level: number; - entry_date: string; - exit_date: string | null; karenz_start_date: string | null; karenz_return_date: string | null; absence_type: string | null; }; -type AssignmentRow = { - employee_id: string; - manager_id: string | null; - team_id: string | null; - division_id: string; - job_title: string; - is_lead: boolean; - org_level: number; - valid_from: string; +type PositionRow = { + id: string; + position_number: string; + org_unit_id: string; + is_chief: boolean; + jobs: { title: string }; }; +type AssignmentRow = { employee_id: string; position_id: string }; + type PendingRow = { employee_id: string; effective_date: string; payload: Record }; -type Placement = { team_id: string | null; division_id: string; job_title: string; is_lead: boolean; org_level: number }; - -// Mirrors resolve_manager_for() in supabase/migrations: an IC reports to -// their team's lead, a team lead to the division head, and anyone without a -// team to the CEO. Only used for employees a pending change actually moves — -// everyone else keeps the manager recorded on their assignment, so existing -// data that deviates from the rule is never silently "corrected". -function resolveManagerFor(placement: Placement, all: { id: string; placement: Placement }[]): string | null { - if (placement.team_id && !placement.is_lead) { - return all.find((e) => e.placement.team_id === placement.team_id && e.placement.is_lead)?.id ?? null; - } - if (placement.team_id && placement.is_lead) { - return ( - all.find((e) => e.placement.division_id === placement.division_id && !e.placement.team_id && e.placement.org_level === 1)?.id ?? - null - ); - } - return all.find((e) => e.placement.org_level === 0)?.id ?? null; -} - -export async function loadOrgAsOf(supabase: SupabaseClient, asOf: string): Promise { +export async function loadOrgAsOf(tx: Tx, asOf: string): Promise { const today = todayIso(); - const [allEmployees, assignments, teams, departments, pending] = await Promise.all([ - fetchAllRows(() => supabase.from("employees").select(ORG_COLUMNS).order("id")), - fetchAllRows(() => - supabase - .from("employee_assignments") - .select("employee_id, manager_id, team_id, division_id, job_title, is_lead, org_level, valid_from") - .lte("valid_from", asOf) - .or(`valid_to.is.null,valid_to.gt.${asOf}`) - .order("employee_id") - ), - fetchAllRows(() => supabase.from("teams").select("id, department_id").order("id")), - fetchAllRows(() => supabase.from("departments").select("id, division_id").order("id")), + // Ohne die 1000-Zeilen-Grenze der API-Schicht fällt das seitenweise + // Nachladen weg: sechs Abfragen, jede vollständig. + const [units, positions, assignments, employees, pending, earliest] = await Promise.all([ + tx.selectFrom("org_units").select(["id", "parent_id"]).orderBy("id").execute(), + + tx + .selectFrom("om_positions as p") + .innerJoin("jobs as j", "j.id", "p.job_id") + .select(["p.id", "p.position_number", "p.org_unit_id", "p.is_chief", "j.title"]) + .where("p.valid_from", "<=", asOf) + .where((eb) => eb.or([eb("p.valid_to", "is", null), eb("p.valid_to", ">", asOf)])) + .orderBy("p.id") + .execute(), + + tx + .selectFrom("position_assignments") + .select(["employee_id", "position_id"]) + .where("valid_from", "<=", asOf) + .where((eb) => eb.or([eb("valid_to", "is", null), eb("valid_to", ">", asOf)])) + .orderBy("employee_id") + .execute(), + + tx + .selectFrom("employees") + .select([ + "id", + "personnel_number", + "first_name", + "last_name", + "job_title", + "karenz_start_date", + "karenz_return_date", + "absence_type", + ]) + .orderBy("id") + .execute(), + asOf > today - ? fetchAllRows(() => - supabase - .from("pending_org_changes") - .select("employee_id, change_type, effective_date, payload") - .eq("status", "pending") - .lte("effective_date", asOf) - .in("change_type", [...PLACEMENT_CHANGES]) - .order("effective_date") - ) + ? tx + .selectFrom("pending_org_changes") + .select(["employee_id", "effective_date", "payload"]) + .where("status", "=", "pending") + .where("effective_date", "<=", asOf) + .where("change_type", "in", [...PLACEMENT_CHANGES]) + .orderBy("effective_date") + .execute() : Promise.resolve([]), + + tx.selectFrom("position_assignments").select("valid_from").orderBy("valid_from").limit(1).executeTakeFirst(), ]); - return resolveOrgSnapshot({ asOf, employees: allEmployees, assignments, teams, departments, pending }); + return resolveOrgSnapshot({ + asOf, + units: units.map((u) => ({ id: u.id, parentId: u.parent_id })), + // Der Join liefert den Jobtitel flach; die reine Funktion erwartet ihn + // verschachtelt, weil sie so auch aus einem Testbestand gefüttert wird. + positions: positions.map((p) => ({ + id: p.id, + position_number: p.position_number, + org_unit_id: p.org_unit_id, + is_chief: p.is_chief, + jobs: { title: p.title }, + })), + assignments: assignments as AssignmentRow[], + employees: employees as EmployeeRow[], + pending: pending as PendingRow[], + historyStartsAt: earliest?.valid_from ?? null, + }); } -// The pure half of the above: everything that turns the four row sets into a -// snapshot, with no Supabase client in sight, so the reconciliation rules can -// be tested directly. +/** + * Der reine Teil: aus den Zeilen den Stand machen, ohne Datenbank, damit die + * Regeln direkt prüfbar sind. + */ export function resolveOrgSnapshot({ asOf, - employees: allEmployees, + units, + positions, assignments, - teams, - departments, + employees: allEmployees, pending, + historyStartsAt, }: { asOf: string; - employees: EmployeeRow[]; + units: OmUnit[]; + positions: PositionRow[]; assignments: AssignmentRow[]; - teams: { id: string; department_id: string }[]; - departments: { id: string; division_id: string }[]; + employees: EmployeeRow[]; pending: PendingRow[]; + historyStartsAt: string | null; }): OrgAsOfResult { - const assignmentByEmployee = new Map(assignments.map((a) => [a.employee_id, a])); - // A projected move names only the target team; its division follows from - // the team's department, the same way the DB trigger derives it. - const departmentDivision = new Map(departments.map((d) => [d.id, d.division_id])); - const teamDivision = new Map( - teams.flatMap((t) => { - const divisionId = departmentDivision.get(t.department_id); - return divisionId ? [[t.id, divisionId] as const] : []; - }) - ); + const positionById = new Map(positions.map((p) => [p.id, p])); + const employeeById = new Map(allEmployees.map((e) => [e.id, e])); - // Employed (or on leave) on that date — the same derivation the Berichte - // page uses, so the two can never disagree on who counted when. - const staff = allEmployees.filter((e) => { - const status = deriveStatusAsOf(e, asOf); - return status === "Aktiv" || status === "Karenz"; - }); + // Dieselbe Ableitung wie in deriveStatusAsOf() und in om_reporting_lines(), + // damit die drei nie auseinanderlaufen können. + const isAbsent = (e: EmployeeRow) => + e.karenz_start_date !== null && + e.karenz_start_date <= asOf && + (e.karenz_return_date === null || asOf < e.karenz_return_date); - const resolved = staff.map((e) => { - const a = assignmentByEmployee.get(e.id); - return { - employee: e, - managerId: a ? a.manager_id : e.manager_id, - placement: { - team_id: a ? a.team_id : e.team_id, - division_id: a ? a.division_id : e.division_id, - job_title: a ? a.job_title : e.job_title, - is_lead: a ? a.is_lead : e.is_lead, - org_level: a ? a.org_level : e.org_level, - } satisfies Placement, - }; - }); + const positionOf = new Map(); + for (const a of assignments) { + if (positionById.has(a.position_id) && employeeById.has(a.employee_id)) positionOf.set(a.employee_id, a.position_id); + } - // Project the future. Ordered by effective_date, so a later move wins. - const byId = new Map(resolved.map((r) => [r.employee.id, r])); + // Die Zukunft projizieren: nach effective_date sortiert, eine spätere + // Versetzung gewinnt. const moved = new Set(); for (const change of pending) { - const target = byId.get(change.employee_id); - if (!target) continue; - const payload = change.payload as { new_team_id?: string; target_team_id?: string; new_title?: string }; - const newTeamId = payload.new_team_id ?? payload.target_team_id ?? null; - if (newTeamId) { - target.placement.team_id = newTeamId; - const divisionId = teamDivision.get(newTeamId); - if (divisionId) target.placement.division_id = divisionId; - moved.add(target.employee.id); - } - if (payload.new_title) target.placement.job_title = payload.new_title; + if (!positionOf.has(change.employee_id)) continue; + const targetId = (change.payload as { target_position_id?: string }).target_position_id; + if (!targetId || !positionById.has(targetId)) continue; + positionOf.set(change.employee_id, targetId); + moved.add(change.employee_id); } - // Second pass: a moved employee's manager follows from the *projected* - // org, not the one they left — and the lead of their new team may itself - // have moved in this same batch. - for (const r of resolved) { - if (moved.has(r.employee.id)) r.managerId = resolveManagerFor(r.placement, resolved.map((x) => ({ id: x.employee.id, placement: x.placement }))); + const holders: OmHolder[] = []; + for (const [employeeId, positionId] of positionOf) { + const position = positionById.get(positionId)!; + holders.push({ + employeeId, + positionId, + orgUnitId: position.org_unit_id, + isChief: position.is_chief, + absent: isAbsent(employeeById.get(employeeId)!), + }); } - // A manager who had not joined yet, or had already left, is not in this - // set — without re-rooting, their whole reporting line would silently - // vanish from the chart rather than showing up one level higher. - const presentIds = new Set(resolved.map((r) => r.employee.id)); - const recordedManagerOf = (r: (typeof resolved)[number]) => - r.managerId && presentIds.has(r.managerId) ? r.managerId : null; + const lines = resolveReportingLines(units, holders); - // While somebody is on a long-term absence their reports roll up to the - // next present level. Derived here rather than written to the database: - // the absent person stays formally in charge, and the stand-in is only a - // stand-in — which is why both ids travel to the UI. - const absentIds = new Set(resolved.filter((r) => deriveStatusAsOf(r.employee, asOf) === "Karenz").map((r) => r.employee.id)); - const acting = resolveActingManagers( - resolved.map((r) => ({ id: r.employee.id, managerId: recordedManagerOf(r), absent: absentIds.has(r.employee.id) })) - ); - - const employees: OrgEmployee[] = resolved.map((r) => { - const { actingManagerId, coveredForId } = acting.get(r.employee.id) ?? { actingManagerId: null, coveredForId: null }; + const employees: OrgEmployee[] = lines.map((l) => { + const e = employeeById.get(l.employeeId)!; + const position = positionById.get(l.positionId)!; return { - id: r.employee.id, - personnel_number: r.employee.personnel_number, - first_name: r.employee.first_name, - last_name: r.employee.last_name, - job_title: r.placement.job_title, - manager_id: actingManagerId, - formal_manager_id: coveredForId, - absent: absentIds.has(r.employee.id), - absence_type: r.employee.absence_type, - team_id: r.placement.team_id, - division_id: r.placement.division_id, - is_lead: r.placement.is_lead, - org_level: r.placement.org_level, + id: e.id, + personnel_number: e.personnel_number, + first_name: e.first_name, + last_name: e.last_name, + // Die Tätigkeit der Planstelle, nicht das Freitextfeld auf der Person: + // bei einer projizierten Versetzung ist nur die erste schon richtig. + job_title: position.jobs.title, + manager_id: l.actingManagerId, + // Nur setzen, wenn eine Vertretung im Spiel ist — sonst zeigt die + // Oberfläche zweimal dieselbe Person an. + formal_manager_id: l.formalManagerId === l.actingManagerId ? null : l.formalManagerId, + absent: isAbsent(e), + absence_type: e.absence_type, + org_unit_id: l.orgUnitId, + is_chief: l.isChief, + position_id: l.positionId, + position_number: position.position_number, }; }); - const historyStartsAt = assignments.reduce( - (min, a) => (min === null || a.valid_from < min ? a.valid_from : min), - null - ); + // Unbesetzte Planstellen. Im Altmodell waren offene Stellen eine eigene + // Tabelle neben der Organisation; hier sind sie schlicht das Komplement. + const besetzt = new Set(positionOf.values()); + const vacancies: OrgVacancy[] = positions + .filter((p) => !besetzt.has(p.id)) + .map((p) => ({ + position_id: p.id, + position_number: p.position_number, + job_title: p.jobs.title, + org_unit_id: p.org_unit_id, + is_chief: p.is_chief, + })); - return { employees, projectedCount: moved.size, historyStartsAt }; + return { employees, vacancies, projectedCount: moved.size, historyStartsAt }; } diff --git a/lib/orgchart-print.ts b/lib/orgchart-print.ts new file mode 100644 index 0000000..a89d601 --- /dev/null +++ b/lib/orgchart-print.ts @@ -0,0 +1,152 @@ +import type { OrgEmployee, OrgUnitNode, OrgVacancy } from "@/components/orgchart/types"; +import { fmtName } from "@/lib/format"; + +// Das Modell für den Druck. +// +// Der Bildschirm zeigt das Organigramm als Fläche, in die man hineinzoomt. +// Papier kann das nicht: es hat eine feste Grösse, und was darauf nicht +// lesbar ist, ist verloren. Ein Ausdruck der Leinwand — 850 Personen auf eine +// Seite skaliert — ergibt Kästchen von zwei Millimetern. +// +// Deshalb wird für den Druck **umgebaut statt verkleinert**: eine Übersicht +// über die obersten Ebenen, und danach ein Bereich je Seite als eigener, +// vollständiger Baum. Jede Seite ist für sich verständlich und trägt ihre +// Überschrift — auch wenn jemand nur Blatt sieben aus dem Drucker nimmt. +// +// Der Baum bleibt hier ein Baum: `children` und `totalPeople` sind das, woraus +// die Auswahl („welche Bereiche, wie tief") und die gezeichnete Hierarchie +// gleichermassen entstehen. + +export type PrintPerson = { + id: string; + /** Getrennt mitgeführt, weil danach sortiert wird — siehe nachName. */ + lastName: string; + name: string; + jobTitle: string; + personnelNumber: number; + isChief: boolean; + absent: boolean; + absenceType: string | null; +}; + +export type PrintUnit = { + id: string; + name: string; + unitType: OrgUnitNode["unit_type"]; + orgNumber: string; + /** Die Leitung dieser Einheit, falls besetzt. */ + chief: PrintPerson | null; + /** Alle übrigen Personen, die unmittelbar in dieser Einheit sitzen. */ + members: PrintPerson[]; + /** Unbesetzte Planstellen dieser Einheit. */ + vacancies: { positionNumber: string; jobTitle: string; isChief: boolean }[]; + children: PrintUnit[]; + /** Personen in dieser Einheit und allem darunter — für die Übersicht. */ + totalPeople: number; +}; + +export type PrintModel = { + root: PrintUnit | null; + /** Je Bereich eine Seite. Bei nur einer Ebene bleibt es bei der Übersicht. */ + divisions: PrintUnit[]; + totals: { people: number; vacancies: number; units: number }; +}; + +function personFrom(e: OrgEmployee): PrintPerson { + return { + id: e.id, + lastName: e.last_name, + name: fmtName(e.first_name, e.last_name), + jobTitle: e.job_title, + personnelNumber: e.personnel_number, + isChief: e.is_chief, + absent: e.absent, + absenceType: e.absence_type, + }; +} + +/** + * Nachname, dann Vorname — wie in jeder Liste, die jemand durchsucht. + * + * Der Nachname kommt aus dem eigenen Feld, nicht aus dem zusammengesetzten + * Namen. Vorher wurde das letzte Wort genommen, was bei „Hannah Winkler" + * zufällig stimmte; seit die Anzeige „Winkler, Hannah" lautet, wäre das + * letzte Wort der Vorname, und die Liste stünde nach Vornamen sortiert da. + */ +function nachName(a: PrintPerson, b: PrintPerson): number { + return a.lastName.localeCompare(b.lastName, "de") || a.name.localeCompare(b.name, "de"); +} + +export function buildPrintModel( + units: OrgUnitNode[], + employees: OrgEmployee[], + vacancies: OrgVacancy[] +): PrintModel { + const byUnit = new Map(); + for (const u of units) { + byUnit.set(u.id, { + id: u.id, + name: u.name, + unitType: u.unit_type, + orgNumber: u.org_number, + chief: null, + members: [], + vacancies: [], + children: [], + totalPeople: 0, + }); + } + + for (const e of employees) { + const unit = byUnit.get(e.org_unit_id); + if (!unit) continue; + const p = personFrom(e); + // Die Leitung steht oben und nicht in der Namensliste — sie ist die + // Antwort auf „wer führt das hier", und die soll man nicht suchen. + if (p.isChief && !unit.chief) unit.chief = p; + else unit.members.push(p); + } + + for (const v of vacancies) { + const unit = byUnit.get(v.org_unit_id); + if (!unit) continue; + unit.vacancies.push({ positionNumber: v.position_number, jobTitle: v.job_title, isChief: v.is_chief }); + } + + // Bäume verknüpfen, in der Reihenfolge der Orgnummern — dieselbe wie + // überall sonst in der Anwendung. + const sortiert = [...units].sort((a, b) => a.org_number.localeCompare(b.org_number, "de")); + let root: PrintUnit | null = null; + for (const u of sortiert) { + const node = byUnit.get(u.id)!; + if (u.parent_id && byUnit.has(u.parent_id)) byUnit.get(u.parent_id)!.children.push(node); + else if (u.unit_type === "Gesellschaft") root = node; + } + + for (const unit of byUnit.values()) { + unit.members.sort(nachName); + unit.vacancies.sort((a, b) => a.jobTitle.localeCompare(b.jobTitle, "de")); + } + + // Summen von unten nach oben. Rekursiv über höchstens vier Ebenen — die + // Tiefe ist durch das Modell begrenzt (Gesellschaft/Bereich/Abteilung/Team). + function zaehle(unit: PrintUnit): number { + const eigene = (unit.chief ? 1 : 0) + unit.members.length; + unit.totalPeople = eigene + unit.children.reduce((s, c) => s + zaehle(c), 0); + return unit.totalPeople; + } + if (root) zaehle(root); + else for (const u of byUnit.values()) if (!units.find((x) => x.id === u.id)?.parent_id) zaehle(u); + + const divisions = root ? root.children : [...byUnit.values()].filter((u) => u.unitType === "Bereich"); + + return { + root, + divisions, + totals: { + people: employees.length, + vacancies: vacancies.length, + units: units.length, + }, + }; +} diff --git a/lib/placement.ts b/lib/placement.ts new file mode 100644 index 0000000..8bd51de --- /dev/null +++ b/lib/placement.ts @@ -0,0 +1,220 @@ +import { sql, type Tx } from "./db"; +import { addDaysIso } from "./format"; +import type { OrgEb } from "./org"; + +/** + * Der Stichtag, zu dem die Organisation *dieser* Person betrachtet wird. + * + * „Heute" ist für zwei Gruppen die falsche Frage. Wer am 01.09. anfängt, hat + * heute keine laufende Besetzung — om_reporting_lines() liefert dann gar + * keine Zeile, und die Akte behauptet „Keine Führungskraft", obwohl das Team + * eine hat. Wer ausgetreten ist, ebenso. + * + * Die Stammdaten zeigten die Planstelle trotzdem an, weil pickPlacements() + * ersatzweise auf die nächstbeste Zuordnung zurückfällt. Zwei Vorstellungen + * davon, wo jemand sitzt — eine nachsichtige und eine strenge — und in der + * Akte standen sie nebeneinander. + * + * Deshalb: der Stichtag wird in das Beschäftigungsverhältnis hineingezogen. + * Für künftige Eintritte auf den ersten Tag, für Ausgetretene auf den + * letzten. Sonst bleibt es heute. + */ +export function orgAsOf( + employee: { entry_date: string; exit_date: string | null }, + today: string +): string { + if (employee.entry_date > today) return employee.entry_date; + // exit_date ist ausschliessend wie überall im Modell: der letzte Arbeitstag + // ist der Tag davor. + if (employee.exit_date && employee.exit_date <= today) return addDaysIso(employee.exit_date, -1); + return today; +} + +// Wo jemand in der Organisation steht, steht nicht mehr auf der Person. Es +// ergibt sich aus der Planstelle, die sie zum Stichtag innehat: +// +// employees ──A008──> position_assignments ──> om_positions ──> org_units +// └────────> jobs +// +// Das ist der Grund, warum es diese Datei gibt: die Verkettung braucht es an +// einem Dutzend Stellen, und sie zeitrichtig aufzulösen ist die Arbeit. + +export type Placement = { + employeeId: string; + positionId: string; + positionNumber: string; + orgUnitId: string; + isChief: boolean; + jobTitle: string; + validFrom: string; + validTo: string | null; + /** Die Besetzung läuft am Stichtag; sonst ist es die zuletzt beendete. */ + current: boolean; +}; + +type Row = { + employee_id: string; + valid_from: string; + valid_to: string | null; + position_id: string; + position_number: string; + org_unit_id: string; + is_chief: boolean; + job_title: string; +}; + +function toPlacement(row: Row, asOf: string): Placement { + return { + employeeId: row.employee_id, + positionId: row.position_id, + positionNumber: row.position_number, + orgUnitId: row.org_unit_id, + isChief: row.is_chief, + jobTitle: row.job_title, + validFrom: row.valid_from, + validTo: row.valid_to, + current: row.valid_from <= asOf && (row.valid_to === null || row.valid_to > asOf), + }; +} + +/** + * Die am Stichtag laufende Besetzung je Person — und für alle, die zu dem + * Zeitpunkt keine hatten, die zuletzt beendete. Ohne diesen Rückfall stünde + * bei jeder ausgetretenen Person „–" statt der Stelle, die sie innehatte. + */ +export function pickPlacements(rows: Row[], asOf: string): Map { + const byEmployee = new Map(); + for (const row of rows) { + const p = toPlacement(row, asOf); + const best = byEmployee.get(p.employeeId); + if (!best) { + byEmployee.set(p.employeeId, p); + continue; + } + // Laufend schlägt beendet; unter beendeten gewinnt die jüngste. + if (p.current && !best.current) byEmployee.set(p.employeeId, p); + else if (p.current === best.current && p.validFrom > best.validFrom) byEmployee.set(p.employeeId, p); + } + return byEmployee; +} + +/** + * Die Besetzungen als *Teilabfrage* — zum Einhängen in die eine Abfrage, die + * eine Seite ohnehin stellt (lib/db/json.ts). + * + * Ein Join statt einer eingebetteten Ressource. Und ohne die + * 1000-Zeilen-Grenze von PostgREST fällt das seitenweise Nachladen weg, das + * es dafür brauchte. + */ +export function besetzungenAbfrage(eb: OrgEb, employeeIds?: string[]) { + const q = eb + .selectFrom("position_assignments as pa") + .innerJoin("om_positions as p", "p.id", "pa.position_id") + .innerJoin("jobs as j", "j.id", "p.job_id") + .select([ + "pa.employee_id", + "pa.valid_from", + "pa.valid_to", + "p.id as position_id", + "p.position_number", + "p.org_unit_id", + "p.is_chief", + "j.title as job_title", + ]) + .orderBy("pa.employee_id"); + + return employeeIds ? q.where("pa.employee_id", "in", employeeIds) : q; +} + +export async function loadPlacements( + tx: Tx, + { asOf, employeeIds }: { asOf: string; employeeIds?: string[] } +): Promise> { + if (employeeIds?.length === 0) return new Map(); + + // Ein Join statt einer eingebetteten Ressource. Und ohne die + // 1000-Zeilen-Grenze von PostgREST fällt das seitenweise Nachladen weg, + // das es dafür brauchte. + let q = tx + .selectFrom("position_assignments as pa") + .innerJoin("om_positions as p", "p.id", "pa.position_id") + .innerJoin("jobs as j", "j.id", "p.job_id") + .select([ + "pa.employee_id", + "pa.valid_from", + "pa.valid_to", + "p.id as position_id", + "p.position_number", + "p.org_unit_id", + "p.is_chief", + "j.title as job_title", + ]) + .orderBy("pa.employee_id"); + + if (employeeIds) q = q.where("pa.employee_id", "in", employeeIds); + + return pickPlacements((await q.execute()) as Row[], asOf); +} + +// ── Abgeleitete Berichtslinie ────────────────────────────────────── +// Sie steht nirgends als Spalte; om_reporting_lines() rechnet sie aus dem +// Baum aus. formal_manager_id ist die zuständige Leitung, acting_manager_id +// die nächste besetzte und anwesende darüber — beides, damit sich in der +// Oberfläche zeigen lässt, dass eine Vertretung im Spiel ist, statt sie +// stillschweigend als die echte Führungskraft auszugeben. + +export type ReportingLine = { + employee_id: string; + position_id: string; + org_unit_id: string; + is_chief: boolean; + formal_manager_id: string | null; + acting_manager_id: string | null; +}; + +/** + * `filter` schränkt die Funktion selbst ein, nicht das Ergebnis im Speicher — + * bei der Detailseite wandern damit neun Zeilen über die Leitung statt + * achthundert. + */ +/** + * Die Berichtslinien als *Teilabfrage* — zum Einhängen in die eine Abfrage, + * die eine Seite ohnehin stellt (lib/db/json.ts). + * + * `filter` schränkt die Funktion selbst ein, nicht das Ergebnis im Speicher. + */ +export function berichtslinienAbfrage( + eb: OrgEb, + asOf: string, + filter?: { employeeId?: string; actingManagerId?: string } +) { + let q = eb + .selectFrom(sql`om_reporting_lines(${asOf}::date)`.as("l")) + .select(["l.employee_id", "l.position_id", "l.org_unit_id", "l.is_chief", "l.formal_manager_id", "l.acting_manager_id"]); + if (filter?.employeeId) q = q.where("l.employee_id", "=", filter.employeeId); + if (filter?.actingManagerId) q = q.where("l.acting_manager_id", "=", filter.actingManagerId); + return q; +} + +export async function loadReportingLines( + tx: Tx, + asOf: string, + filter?: { employeeId?: string; actingManagerId?: string } +): Promise { + const conditions = [sql`true`]; + if (filter?.employeeId) conditions.push(sql`employee_id = ${filter.employeeId}::uuid`); + if (filter?.actingManagerId) conditions.push(sql`acting_manager_id = ${filter.actingManagerId}::uuid`); + + const result = await sql` + select * from om_reporting_lines(${asOf}::date) + where ${sql.join(conditions, sql` and `)} + `.execute(tx); + + return result.rows; +} + +/** Wie loadReportingLines, aber als Karte über die Personen-Kennung. */ +export async function loadReportingLineMap(tx: Tx, asOf: string): Promise> { + const lines = await loadReportingLines(tx, asOf); + return new Map(lines.map((l) => [l.employee_id, l])); +} diff --git a/lib/positions.ts b/lib/positions.ts index c2ea943..75381d6 100644 --- a/lib/positions.ts +++ b/lib/positions.ts @@ -1,41 +1,207 @@ -import type { SupabaseClient } from "@supabase/supabase-js"; -import { breadcrumbLabel, loadOrgMaps } from "./org"; -import type { Database } from "./supabase/types"; +import { kontierungAm, kontierungenAbfrage, type KontierungsZeile } from "./cost-centers"; +import type { Tx } from "./db"; +import { jsonArrayFrom } from "./db/json"; +import { fmtName, todayIso } from "./format"; +import { breadcrumbLabel, buildOrgMaps, orgMapsAbfragen, type OrgEb, type OrgMaps } from "./org"; + +// Eine offene Stelle ist keine eigene Sache mehr. Sie ist eine Planstelle +// ohne laufende Besetzung — Vakanz ist eine Eigenschaft der Planstelle, kein +// zweites Objekt daneben, das mit der Organisation synchron gehalten werden +// müsste. export type OpenPositionResolved = { id: string; position_number: string; title: string; - team_id: string; - division_id: string; - is_lead: boolean; - reports_to_employee_id: string | null; + org_unit_id: string; + is_chief: boolean; valid_from: string; - created_at: string; + valid_to: string | null; + /** Die Einheit selbst — für den Änderungsdialog, der sie vorbelegt. */ + org_unit_name: string; + /** + * Ob die Planstelle heute schon gilt. + * + * Eine, die erst zum 01.10. entsteht, ist nicht „seit 2 Tagen unbesetzt" — + * sie ist geplant. Beides in einer Liste zu zeigen ist richtig, beides + * gleich zu benennen wäre falsch. + */ + future: boolean; + /** Wer die Stelle nach der Berichtslinie führen wird. */ managerName: string | null; orgLabel: string; + /** Seit wann die Stelle unbesetzt ist: Ende der letzten Besetzung, sonst ihr Beginn. */ + vacantSince: string; + /** + * Wessen Budget die Stelle belastet. + * + * Der Grund, warum die Kostenstelle an der Planstelle hängt und nicht an der + * Person: hier gibt es keine Person, und trotzdem — gerade deshalb — ist die + * Frage offen, was diese Stelle kostet und wer sie bezahlt. + */ + kostenstelle: { code: string; name: string } | null; }; -// Shared by the Hire Wizard (position lookup) and the Positions & Bereiche page. -export async function loadOpenPositions(supabase: SupabaseClient): Promise { - const orgMaps = await loadOrgMaps(supabase); - const { data: positions } = await supabase - .from("positions") - .select("id, position_number, title, team_id, division_id, is_lead, reports_to_employee_id, valid_from, created_at") - .eq("status", "open") - .order("created_at", { ascending: false }); - - const managerIds = Array.from( - new Set((positions ?? []).map((p) => p.reports_to_employee_id).filter((id): id is string => Boolean(id))) - ); - const { data: managers } = managerIds.length - ? await supabase.from("employees").select("id, first_name, last_name").in("id", managerIds) - : { data: [] as { id: string; first_name: string; last_name: string }[] }; - const managerNameById = new Map((managers ?? []).map((m) => [m.id, `${m.first_name} ${m.last_name}`])); - - return (positions ?? []).map((p) => ({ - ...p, - managerName: p.reports_to_employee_id ? (managerNameById.get(p.reports_to_employee_id) ?? null) : null, - orgLabel: breadcrumbLabel(orgMaps, p.division_id, p.team_id), - })); +/** + * Wer eine unbesetzte Planstelle führen würde: die Leitung der eigenen + * Einheit, für eine Leitungsplanstelle die der übergeordneten — dieselbe + * Regel wie in om_reporting_lines(), nur ohne Inhaber:in, für die sie gälte. + */ +function managerUnitFor(maps: OrgMaps, orgUnitId: string, isChief: boolean): string | null { + if (!isChief) return orgUnitId; + return maps.units.get(orgUnitId)?.parent_id ?? null; +} + +export type OffeneStelle = { + id: string; + position_number: string; + org_unit_id: string; + is_chief: boolean; + valid_from: string; + valid_to: string | null; + title: string; +}; + +/** + * Die unbesetzten Planstellen als *Teilabfrage*. + * + * Als eigene Funktion, damit eine Seite sie zusammen mit ihren übrigen + * Lesevorgängen in einer Rundreise holen kann statt in einer eigenen — siehe + * lib/db/json.ts. Wer nichts weiter zu holen hat, nimmt loadOpenPositions(). + * + * Unbesetzt heisst: keine Zuordnung, die noch gilt — **auch keine, die erst + * beginnt.** + * + * Der Unterschied ist kein Feinschliff. Wer unterschrieben hat und am 24.09. + * anfängt, belegt die Planstelle heute schon; sie steht nur noch nicht besetzt + * da. Die frühere Fassung fragte „sitzt heute jemand darauf?" und listete + * solche Stellen als offen — mit „seit 2 Tagen unbesetzt" daneben. Aus dieser + * Liste speist sich auch die Auswahl im Einstellungsassistenten, also lud sie + * dazu ein, dieselbe Stelle ein zweites Mal zu besetzen. Aufgefallen wäre das + * erst am Teilindex der Datenbank, nach dem Gespräch mit der zweiten Person. + * + * Eine beendete Zuordnung (valid_to in der Vergangenheit) gibt die Stelle + * dagegen wieder frei — deshalb bleibt die Bedingung auf valid_to. + * + * Künftige Planstellen bleiben drin. Sie sind der Grund, warum diese Ansicht + * existiert: eine Stelle, die zum Quartalswechsel entsteht, muss vorher + * sichtbar und planbar sein. + */ +export function offeneStellenAbfrage(eb: OrgEb, asOf: string) { + return eb + .selectFrom("om_positions as p") + .innerJoin("jobs as j", "j.id", "p.job_id") + .select(["p.id", "p.position_number", "p.org_unit_id", "p.is_chief", "p.valid_from", "p.valid_to", "j.title"]) + .where((e) => e.or([e("p.valid_to", "is", null), e("p.valid_to", ">", asOf)])) + // Als NOT EXISTS in der Datenbank statt als Filter über alle Planstellen + // im Speicher. + .where((e) => + e.not( + e.exists( + e + .selectFrom("position_assignments as a") + .select("a.id") + .whereRef("a.position_id", "=", "p.id") + .where((e2) => e2.or([e2("a.valid_to", "is", null), e2("a.valid_to", ">", asOf)])) + ) + ) + ) + .orderBy("p.position_number"); +} + +/** + * Der zweite Teil: die beiden Nachschläge, die erst gestellt werden können, + * wenn feststeht, welche Stellen offen sind — seit wann sie leer stehen, und + * wer sie führen würde. Beide in einer Rundreise. + */ +export async function resolveOpenPositions( + tx: Tx, + orgMaps: OrgMaps, + open: OffeneStelle[], + asOf: string = todayIso() +): Promise { + if (open.length === 0) return []; + + const positionIds = open.map((p) => p.id); + const chiefUnitIds = Array.from( + new Set( + open.map((p) => managerUnitFor(orgMaps, p.org_unit_id, p.is_chief)).filter((id): id is string => Boolean(id)) + ) + ); + + const { ended, chiefs, costRows } = await tx + .selectNoFrom((eb) => [ + jsonArrayFrom( + eb + .selectFrom("position_assignments") + .select(["position_id", "valid_to"]) + .where("position_id", "in", positionIds) + .where("valid_to", "is not", null) + ).as("ended"), + jsonArrayFrom(kontierungenAbfrage(eb, positionIds)).as("costRows"), + jsonArrayFrom( + eb + .selectFrom("om_positions as p") + .innerJoin("position_assignments as a", "a.position_id", "p.id") + .innerJoin("employees as e", "e.id", "a.employee_id") + .select(["p.org_unit_id", "e.first_name", "e.last_name"]) + .where("p.is_chief", "=", true) + .where("p.valid_to", "is", null) + .where("a.valid_to", "is", null) + // Ohne Einheiten darf hier keine Liste stehen: `in ()` ist ein + // Syntaxfehler, und `in ('')` bricht an der uuid-Umwandlung ab + // statt nichts zu liefern. Dann also eine Bedingung, die nie zutrifft. + .where((e) => (chiefUnitIds.length > 0 ? e("p.org_unit_id", "in", chiefUnitIds) : e.lit(false))) + ).as("chiefs"), + ]) + .executeTakeFirstOrThrow(); + + const lastEndByPosition = new Map(); + for (const e of ended) { + const prev = lastEndByPosition.get(e.position_id); + if (e.valid_to && (!prev || e.valid_to > prev)) lastEndByPosition.set(e.position_id, e.valid_to); + } + const chiefNameByUnit = new Map(chiefs.map((c) => [c.org_unit_id, fmtName(c.first_name, c.last_name)])); + + return open.map((p) => { + const managerUnit = managerUnitFor(orgMaps, p.org_unit_id, p.is_chief); + return { + id: p.id, + position_number: p.position_number, + title: p.title, + org_unit_id: p.org_unit_id, + is_chief: p.is_chief, + valid_from: p.valid_from, + valid_to: p.valid_to, + org_unit_name: orgMaps.units.get(p.org_unit_id)?.name ?? "", + future: p.valid_from > asOf, + managerName: managerUnit ? (chiefNameByUnit.get(managerUnit) ?? null) : null, + orgLabel: breadcrumbLabel(orgMaps, p.org_unit_id), + vacantSince: lastEndByPosition.get(p.id) ?? p.valid_from, + // Nicht zum heutigen Tag, sondern zu dem, an dem die Stelle besteht: + // eine künftige Planstelle wird erst mit ihrem Beginn kontiert, hätte + // heute also keine Kostenstelle — obwohl gerade sie geplant wird. + kostenstelle: kontierungAm(costRows as KontierungsZeile[], p.id, p.valid_from > asOf ? p.valid_from : asOf), + }; + }); +} + +/** + * Der bequeme Weg: Organisationsbaum und offene Stellen in einer Rundreise, + * die beiden Nachschläge in einer zweiten. + * + * Zwei sind das Minimum — was in der zweiten steht, hängt vom Ergebnis der + * ersten ab. + */ +export async function loadOpenPositions(tx: Tx): Promise { + const asOf = todayIso(); + + const { units, locations, open } = await tx + .selectNoFrom((eb) => [ + ...orgMapsAbfragen(eb), + jsonArrayFrom(offeneStellenAbfrage(eb, asOf)).as("open"), + ]) + .executeTakeFirstOrThrow(); + + return resolveOpenPositions(tx, buildOrgMaps(units as never, locations as never), open as OffeneStelle[], asOf); } diff --git a/lib/report-criteria.ts b/lib/report-criteria.ts new file mode 100644 index 0000000..7c89368 --- /dev/null +++ b/lib/report-criteria.ts @@ -0,0 +1,181 @@ +import { ABSENCE_TYPES } from "./absence"; +import { parseIsoDateParam } from "./reports"; +import type { Weekday } from "./supabase/types"; + +// Ein Verzeichnis aller Auswahlkriterien — für die Oberfläche, die Abfrage +// und den Export dasselbe. +// +// Ein Export, der weniger filtern kann als der Bericht daneben, ist der +// Grund, warum Leute Daten nach Excel kippen und dort weiterarbeiten: was +// die Anwendung nicht hergibt, wird eben von Hand nachgebaut, und ab da +// stimmt es mit nichts mehr überein. Deshalb steht hier jedes Kriterium +// einmal, mit seinen erlaubten Werten, und alle drei Seiten lesen aus +// derselben Liste: +// +// * die Oberfläche baut ihre Felder daraus, +// * `parseCriteria` prüft die Adresszeile dagegen, +// * `applyCriteria` (serverseitig) macht Bedingungen daraus. +// +// Ein neues Kriterium ist damit ein Eintrag in dieser Datei und sonst +// nichts — und es kann nicht passieren, dass es im Bericht wirkt, im Export +// aber stillschweigend ignoriert wird. +// +// Die Prüfung ist nicht bloss Ordnungsliebe: die Werte landen in +// SQL-Vergleichen und im Dateinamen des Downloads, also in einem +// Content-Disposition-Header. Was nicht in der Liste steht, kommt nicht +// durch. + +export type JaNein = "ja" | "nein"; + +/** Ein Kriterium mit fester Werteliste. */ +export type AuswahlKriterium = { + key: string; + label: string; + /** Beschriftung für „keine Einschränkung". */ + alle: string; + optionen: readonly { wert: string; label: string }[]; +}; + +function werte(...w: string[]): { wert: string; label: string }[] { + return w.map((x) => ({ wert: x, label: x })); +} + +export const AUSWAHL_KRITERIEN: readonly AuswahlKriterium[] = [ + { key: "employment", label: "Beschäftigungsart", alle: "Alle Beschäftigungsarten", optionen: werte("Vollzeit", "Teilzeit") }, + { key: "contract", label: "Vertragsart", alle: "Alle Vertragsarten", optionen: werte("unbefristet", "befristet") }, + { key: "worker", label: "Angestellte:r / Arbeiter:in", alle: "Alle", optionen: werte("Angestellte:r", "Arbeiter:in") }, + { key: "kv", label: "Kollektivvertrag", alle: "Alle Kollektivverträge", optionen: werte("Handel", "Süßwaren") }, + { key: "paygrade", label: "Paygrade", alle: "Alle Paygrades", optionen: werte("A", "B", "C", "D", "E", "F") }, + { key: "source", label: "Intern/Extern", alle: "Alle", optionen: werte("Intern", "Extern") }, + { + key: "gender", + label: "Geschlecht", + alle: "Alle", + optionen: [ + { wert: "m", label: "männlich" }, + { wert: "w", label: "weiblich" }, + ], + }, + { key: "dienstwagenArt", label: "Antriebsart Dienstwagen", alle: "Alle Antriebsarten", optionen: werte("Verbrenner", "Elektro") }, + { key: "absence", label: "Art der Langzeitabwesenheit", alle: "Alle Arten", optionen: werte(...ABSENCE_TYPES) }, + { + key: "teilzeitArt", + label: "Teilzeitvariante", + alle: "Alle Teilzeitvarianten", + optionen: werte("Bildungsteilzeit", "Elternteilzeit", "Pflegeteilzeit", "Wiedereingliederungsteilzeit"), + }, + { key: "weekday", label: "Arbeitet am", alle: "Beliebiger Wochentag", optionen: werte("Mo", "Di", "Mi", "Do", "Fr", "Sa", "So") }, +] as const; + +/** Kriterien über ein Ja/Nein-Feld. */ +export type JaNeinKriterium = { key: string; label: string }; + +export const JANEIN_KRITERIEN: readonly JaNeinKriterium[] = [ + { key: "dienstwagen", label: "Dienstwagen" }, + { key: "betriebsrat", label: "Betriebsrat" }, + { key: "lateral", label: "Laterale Führung" }, + { key: "clevel", label: "C-Level" }, + { key: "kuendigungsschutz", label: "Besonderer Kündigungsschutz" }, + { key: "teilzeit", label: "In einer Teilzeitvariante" }, + { key: "aufenthaltstitel", label: "Aufenthaltstitel" }, + { key: "dependents", label: "Angehörige erfasst" }, +] as const; + +/** Zeiträume: je ein Parameter `From` und `To`. */ +export type ZeitraumKriterium = { key: string; label: string }; + +export const ZEITRAUM_KRITERIEN: readonly ZeitraumKriterium[] = [ + { key: "entry", label: "Eintritt" }, + { key: "exit", label: "Austritt" }, + { key: "birth", label: "Geburtsdatum" }, + { key: "schutz", label: "Kündigungsschutz bis" }, + { key: "teilzeitEnde", label: "Teilzeit endet" }, + { key: "titelEnde", label: "Aufenthaltstitel läuft ab" }, +] as const; + +export type Criteria = { + /** Werteliste je Kriteriumsschlüssel aus AUSWAHL_KRITERIEN. */ + auswahl: Record; + /** Ja/Nein je Schlüssel aus JANEIN_KRITERIEN. */ + jaNein: Record; + /** ISO-Daten je Schlüssel aus ZEITRAUM_KRITERIEN, jeweils von/bis. */ + zeitraum: Record; + /** Wochenstunden von/bis. */ + stundenVon?: number; + stundenBis?: number; +}; + +export const LEERE_CRITERIA: Criteria = { auswahl: {}, jaNein: {}, zeitraum: {} }; + +function parseZahl(value: string | null | undefined): number | undefined { + if (!value) return undefined; + const n = Number(value.replace(",", ".")); + // Negative Stunden oder eine Woche mit 200 Stunden sind keine Eingabe, + // sondern ein Tippfehler oder ein Versuch. + return Number.isFinite(n) && n >= 0 && n <= 168 ? n : undefined; +} + +/** + * Liest die Kriterien aus der Adresszeile und verwirft alles, was nicht in + * den Listen oben steht. + */ +export function parseCriteria(get: (key: string) => string | null | undefined): Criteria { + const auswahl: Record = {}; + for (const k of AUSWAHL_KRITERIEN) { + const roh = get(k.key); + if (roh && k.optionen.some((o) => o.wert === roh)) auswahl[k.key] = roh; + } + + const jaNein: Record = {}; + for (const k of JANEIN_KRITERIEN) { + const roh = get(k.key); + if (roh === "ja" || roh === "nein") jaNein[k.key] = roh; + } + + const zeitraum: Record = {}; + for (const k of ZEITRAUM_KRITERIEN) { + const von = parseIsoDateParam(get(`${k.key}From`)); + const bis = parseIsoDateParam(get(`${k.key}To`)); + if (von || bis) zeitraum[k.key] = { von, bis }; + } + + return { auswahl, jaNein, zeitraum, stundenVon: parseZahl(get("hoursFrom")), stundenBis: parseZahl(get("hoursTo")) }; +} + +/** Wie viele Kriterien gesetzt sind — für die Anzeige „(3)" am Aufklapper. */ +export function anzahlKriterien(c: Criteria): number { + let n = Object.keys(c.auswahl).length + Object.keys(c.jaNein).length; + for (const z of Object.values(c.zeitraum)) n += (z.von ? 1 : 0) + (z.bis ? 1 : 0); + if (c.stundenVon !== undefined) n += 1; + if (c.stundenBis !== undefined) n += 1; + return n; +} + +/** Alle Parameternamen, die zu den Kriterien gehören — zum Zurücksetzen. */ +export function kriterienParameter(): string[] { + return [ + ...AUSWAHL_KRITERIEN.map((k) => k.key), + ...JANEIN_KRITERIEN.map((k) => k.key), + ...ZEITRAUM_KRITERIEN.flatMap((k) => [`${k.key}From`, `${k.key}To`]), + "hoursFrom", + "hoursTo", + ]; +} + +/** + * Die zwei Kriterien, die keine Spalte sind: der Wochentag steckt in einem + * Array, die Angehörigen in einer anderen Tabelle. Beide werden an den + * geladenen Zeilen geprüft, statt den Enum-Vergleich bzw. einen Join in die + * Abfrage zu zwingen. + */ +export function passtImSpeicher( + zeile: { work_days: Weekday[] | string[]; dependentsCount: number }, + c: Criteria +): boolean { + const tag = c.auswahl.weekday; + if (tag && !zeile.work_days.includes(tag as Weekday)) return false; + const angehoerige = c.jaNein.dependents; + if (angehoerige === "ja" && zeile.dependentsCount === 0) return false; + if (angehoerige === "nein" && zeile.dependentsCount > 0) return false; + return true; +} diff --git a/lib/reports-data.ts b/lib/reports-data.ts index e73ad8f..055d1cf 100644 --- a/lib/reports-data.ts +++ b/lib/reports-data.ts @@ -1,149 +1,322 @@ -import type { SupabaseClient } from "@supabase/supabase-js"; -import { deriveStatusAsOf, EVENT_DATE_OPEN, parseStatuses, todayIso, type OrgLookups, type ReportEmployee, type ReportEvent } from "./reports"; -import { fetchAllRows } from "./supabase/query"; -import type { Database, EmploymentType, HistoryEventType } from "./supabase/types"; +import type { SelectQueryBuilder } from "kysely"; +import type { Schema } from "./db/schema"; +import type { Tx } from "./db"; +import { ancestorsOf, loadOrgMaps, subtreeOf, type OrgMaps } from "./org"; +import { loadPlacements } from "./placement"; +import { LEERE_CRITERIA, passtImSpeicher, type Criteria } from "./report-criteria"; +import { + deriveStatusAsOf, + EVENT_DATE_OPEN, + parseStatuses, + todayIso, + type OrgLookups, + type ReportEmployee, + type ReportEvent, + type UnitOption, +} from "./reports"; +import type { + CollectiveAgreement, + ContractType, + DienstwagenArt, + EmploymentType, + GenderType, + HistoryEventType, + PaygradeType, + SourceType, + TeilzeitArt, + WorkerType, +} from "./supabase/types"; // Shared by the Berichte page and /api/export/* so they can never drift on // what "the current view" means — same filters, same stichtag/event-window // rules. export type ReportFilters = { + /** Id einer Organisationseinheit; wirkt auf die Einheit *und alles darunter*. */ division?: string; location?: string; status?: string; - employment?: string; + /** Alles Weitere — siehe lib/report-criteria.ts. */ + criteria?: Criteria; }; +/** + * Macht aus den Kriterien Bedingungen auf `employees`. + * + * Bewusst Zeile für Zeile statt über eine Tabelle Schlüssel→Spalte: so prüft + * der Compiler jeden Wert gegen den Spaltentyp. Die Umwandlungen sind keine + * blinden Zusicherungen — `parseCriteria` hat den Wert vorher gegen dieselbe + * Werteliste geprüft, aus der auch der Aufzählungstyp besteht. + * + * Nicht hier: Wochentag und Angehörige (siehe passtImSpeicher), Einheit + * (Teilbaum) und Status (zum Stichtag abgeleitet) — die drei kann keine + * Spaltenbedingung ausdrücken. + */ +export function applyCriteria( + q: SelectQueryBuilder, + c: Criteria +): SelectQueryBuilder { + const a = c.auswahl; + if (a.employment) q = q.where("employment_type", "=", a.employment as EmploymentType); + if (a.contract) q = q.where("contract_type", "=", a.contract as ContractType); + if (a.worker) q = q.where("worker_type", "=", a.worker as WorkerType); + if (a.kv) q = q.where("collective_agreement", "=", a.kv as CollectiveAgreement); + if (a.paygrade) q = q.where("paygrade", "=", a.paygrade as PaygradeType); + if (a.source) q = q.where("source", "=", a.source as SourceType); + if (a.gender) q = q.where("gender", "=", a.gender as GenderType); + if (a.dienstwagenArt) q = q.where("dienstwagen_art", "=", a.dienstwagenArt as DienstwagenArt); + if (a.teilzeitArt) q = q.where("teilzeit_art", "=", a.teilzeitArt as TeilzeitArt); + if (a.absence) q = q.where("absence_type", "=", a.absence); + + const j = c.jaNein; + if (j.dienstwagen) q = q.where("has_dienstwagen", "=", j.dienstwagen === "ja"); + if (j.betriebsrat) q = q.where("is_betriebsrat", "=", j.betriebsrat === "ja"); + if (j.lateral) q = q.where("is_laterale_fuehrung", "=", j.lateral === "ja"); + if (j.clevel) q = q.where("is_c_level", "=", j.clevel === "ja"); + if (j.kuendigungsschutz) q = q.where("has_kuendigungsschutz", "=", j.kuendigungsschutz === "ja"); + // „In einer Teilzeitvariante" heisst: es steht eine drin, gleich welche. + if (j.teilzeit === "ja") q = q.where("teilzeit_art", "is not", null); + if (j.teilzeit === "nein") q = q.where("teilzeit_art", "is", null); + if (j.aufenthaltstitel) q = q.where("hat_aufenthaltstitel", "=", j.aufenthaltstitel === "ja"); + + // Ein Austrittszeitraum schliesst alle ohne Austritt aus — ein Vergleich + // mit NULL ist nicht wahr, und das ist hier genau die richtige Bedeutung. + if (c.zeitraum.entry?.von) q = q.where("entry_date", ">=", c.zeitraum.entry.von); + if (c.zeitraum.entry?.bis) q = q.where("entry_date", "<=", c.zeitraum.entry.bis); + if (c.zeitraum.exit?.von) q = q.where("exit_date", ">=", c.zeitraum.exit.von); + if (c.zeitraum.exit?.bis) q = q.where("exit_date", "<=", c.zeitraum.exit.bis); + if (c.zeitraum.birth?.von) q = q.where("birth_date", ">=", c.zeitraum.birth.von); + if (c.zeitraum.birth?.bis) q = q.where("birth_date", "<=", c.zeitraum.birth.bis); + if (c.zeitraum.schutz?.von) q = q.where("kuendigungsschutz_bis", ">=", c.zeitraum.schutz.von); + if (c.zeitraum.schutz?.bis) q = q.where("kuendigungsschutz_bis", "<=", c.zeitraum.schutz.bis); + if (c.zeitraum.teilzeitEnde?.von) q = q.where("teilzeit_bis", ">=", c.zeitraum.teilzeitEnde.von); + if (c.zeitraum.teilzeitEnde?.bis) q = q.where("teilzeit_bis", "<=", c.zeitraum.teilzeitEnde.bis); + // Wer bis wann einen Titel hat — die Frage hinter „wessen Titel läuft im + // nächsten Quartal aus". + if (c.zeitraum.titelEnde?.von) q = q.where("aufenthaltstitel_bis", ">=", c.zeitraum.titelEnde.von); + if (c.zeitraum.titelEnde?.bis) q = q.where("aufenthaltstitel_bis", "<=", c.zeitraum.titelEnde.bis); + + if (c.stundenVon !== undefined) q = q.where("weekly_hours", ">=", c.stundenVon); + if (c.stundenBis !== undefined) q = q.where("weekly_hours", "<=", c.stundenBis); + + return q; +} + export type SnapshotFilters = ReportFilters & { asOf?: string }; export type EventFilters = { eventType?: HistoryEventType; division?: string; location?: string; from?: string; to?: string }; -export async function loadOrgLookups(supabase: SupabaseClient): Promise<{ +/** + * Für jede Einheit vorberechnen, welcher Bereich, welche Abteilung und + * welches Team über ihr liegen. Ein Bericht gruppiert dann über einen + * Kartenzugriff statt über einen Aufstieg im Baum je Zeile. + */ +export function lookupsFromOrgMaps(orgMaps: OrgMaps, locations: { id: string; name: string }[]): OrgLookups { + const divisionName = new Map(); + const departmentName = new Map(); + const teamName = new Map(); + + for (const unit of orgMaps.unitList) { + for (const a of ancestorsOf(orgMaps, unit.id)) { + if (a.unit_type === "Bereich") divisionName.set(unit.id, a.name); + else if (a.unit_type === "Abteilung") departmentName.set(unit.id, a.name); + else if (a.unit_type === "Team") teamName.set(unit.id, a.name); + } + } + + return { divisionName, departmentName, teamName, locationName: new Map(locations.map((l) => [l.id, l.name])) }; +} + +export async function loadOrgLookups(tx: Tx): Promise<{ lookups: OrgLookups; - divisions: { id: string; name: string }[]; + orgMaps: OrgMaps; + units: UnitOption[]; locations: { id: string; name: string }[]; }> { - const [{ data: divisions }, { data: departments }, { data: teams }, { data: locations }] = await Promise.all([ - supabase.from("divisions").select("id, name").order("name"), - supabase.from("departments").select("id, name"), - supabase.from("teams").select("id, name, department_id"), - supabase.from("locations").select("id, name").order("name"), - ]); + const orgMaps = await loadOrgMaps(tx); + const locations = orgMaps.locationList.map((l) => ({ id: l.id, name: l.name })); - const departmentNameById = new Map((departments ?? []).map((d) => [d.id, d.name])); return { - lookups: { - divisionName: new Map((divisions ?? []).map((d) => [d.id, d.name])), - departmentNameByTeam: new Map((teams ?? []).map((t) => [t.id, departmentNameById.get(t.department_id) ?? "Unbekannt"])), - teamName: new Map((teams ?? []).map((t) => [t.id, t.name])), - locationName: new Map((locations ?? []).map((l) => [l.id, l.name])), - }, - divisions: divisions ?? [], - locations: locations ?? [], + lookups: lookupsFromOrgMaps(orgMaps, locations), + orgMaps, + // Jede Ebene, nicht nur die Bereiche: wer eine einzelne Abteilung + // auswerten will, soll sie nicht über einen Umweg zusammensuchen müssen. + // Der Filter greift immer auf den ganzen Teilbaum darunter. + units: orgMaps.unitList.map((u) => ({ + id: u.id, + name: u.name, + depth: orgMaps.depthOf.get(u.id) ?? 0, + unitType: u.unit_type, + })), + locations, }; } -const SNAPSHOT_EMPLOYEE_COLUMNS = - "id, first_name, last_name, job_title, division_id, team_id, location_id, employment_type, contract_type, entry_date, exit_date, weekly_hours, source, paygrade, birth_date, gender, karenz_start_date, karenz_return_date, worker_type, collective_agreement, work_days, is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level"; +const SNAPSHOT_EMPLOYEE_COLUMNS = [ + "id", + "first_name", + "last_name", + "job_title", + "location_id", + "employment_type", + "contract_type", + "entry_date", + "exit_date", + "weekly_hours", + "source", + "paygrade", + "birth_date", + "gender", + "karenz_start_date", + "karenz_return_date", + "worker_type", + "collective_agreement", + "work_days", + "is_betriebsrat", + "has_dienstwagen", + "is_laterale_fuehrung", + "is_c_level", + "teilzeit_art", +] as const; -// employee_id -> number of employee_dependents rows. Selects only the FK -// column (no dependent PII needed) since only per-employee counts feed the -// has_dependents/avg_dependents report dimensions; counted client-side -// since the Supabase JS client has no `count(*) group by employee_id` -// shorthand. Shared by the Bestand pivot and the full employees export. -export async function loadDependentsCounts(supabase: SupabaseClient): Promise> { - const rows = await fetchAllRows(() => supabase.from("employee_dependents").select("employee_id").order("employee_id")); - const counts = new Map(); - for (const d of rows) counts.set(d.employee_id, (counts.get(d.employee_id) ?? 0) + 1); - return counts; +// Anzahl der Angehörigen je Person. Nur der Fremdschlüssel wird gelesen — +// für die Berichtsdimensionen zählt die Anzahl, nicht wer es ist. +export async function loadDependentsCounts(tx: Tx): Promise> { + // Am direkten Zugang zählt die Datenbank, statt dass die Anwendung alle + // Zeilen holt und sie selbst durchgeht. + const rows = await tx + .selectFrom("employee_dependents") + .select(({ fn }) => ["employee_id", fn.countAll().as("anzahl")]) + .groupBy("employee_id") + .execute(); + return new Map(rows.map((r) => [r.employee_id, Number(r.anzahl)])); } -// Bestand zum Stichtag: reconstructs each employee's status as of `asOf` -// (defaults to today) from entry/exit/Karenz dates — see deriveStatusAsOf. -// division/team/location still reflect the employee's *current* assignment. -export async function loadSnapshotEmployees(supabase: SupabaseClient, filters: SnapshotFilters): Promise { +// Bestand zum Stichtag: Status *und* Einordnung werden auf `asOf` aufgelöst. +export async function loadSnapshotEmployees(tx: Tx, filters: SnapshotFilters): Promise { const asOf = filters.asOf || todayIso(); function snapshotQuery() { - let query = supabase.from("employees").select(SNAPSHOT_EMPLOYEE_COLUMNS).order("id"); - if (filters.division) query = query.eq("division_id", filters.division); - if (filters.location) query = query.eq("location_id", filters.location); - if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType); - return query; + let q = tx.selectFrom("employees").select([...SNAPSHOT_EMPLOYEE_COLUMNS]).orderBy("id"); + if (filters.location) q = q.where("location_id", "=", filters.location); + return applyCriteria(q, filters.criteria ?? LEERE_CRITERIA); } - const [data, dependentsCounts] = await Promise.all([fetchAllRows(snapshotQuery), loadDependentsCounts(supabase)]); + const [data, dependentsCounts, placements, orgMaps] = await Promise.all([ + snapshotQuery().execute(), + loadDependentsCounts(tx), + loadPlacements(tx, { asOf }), + filters.division ? loadOrgMaps(tx) : Promise.resolve(null), + ]); - const withDerivedStatus: ReportEmployee[] = data.map((e) => ({ - id: e.id, - first_name: e.first_name, - last_name: e.last_name, - job_title: e.job_title, - division_id: e.division_id, - team_id: e.team_id, - location_id: e.location_id, - status: deriveStatusAsOf(e, asOf), - employment_type: e.employment_type, - contract_type: e.contract_type, - entry_date: e.entry_date, - exit_date: e.exit_date, - weekly_hours: e.weekly_hours, - source: e.source, - paygrade: e.paygrade, - birth_date: e.birth_date, - gender: e.gender, - worker_type: e.worker_type, - collective_agreement: e.collective_agreement, - work_days: e.work_days, - is_betriebsrat: e.is_betriebsrat, - has_dienstwagen: e.has_dienstwagen, - is_laterale_fuehrung: e.is_laterale_fuehrung, - is_c_level: e.is_c_level, - dependents_count: dependentsCounts.get(e.id) ?? 0, - })); + // Der Bereichsfilter meint den ganzen Teilbaum: „Produktion" schliesst + // deren Abteilungen und Teams ein, sonst käme null heraus, weil unter dem + // Bereich selbst nur die Bereichsleitung sitzt. + const allowedUnits = orgMaps && filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null; + + const criteria = filters.criteria ?? LEERE_CRITERIA; + const withDerivedStatus: ReportEmployee[] = []; + for (const e of data) { + const placement = placements.get(e.id); + // Zum Stichtag laufend? Sonst zählt die Person zwar noch im Bestand, + // sitzt aber auf keiner Planstelle mehr. + const orgUnitId = placement?.current ? placement.orgUnitId : null; + if (allowedUnits && (!orgUnitId || !allowedUnits.has(orgUnitId))) continue; + if (!passtImSpeicher({ work_days: e.work_days, dependentsCount: dependentsCounts.get(e.id) ?? 0 }, criteria)) continue; + + withDerivedStatus.push({ + id: e.id, + first_name: e.first_name, + last_name: e.last_name, + job_title: placement?.jobTitle ?? e.job_title, + org_unit_id: orgUnitId, + location_id: e.location_id, + status: deriveStatusAsOf(e, asOf), + employment_type: e.employment_type, + contract_type: e.contract_type, + entry_date: e.entry_date, + exit_date: e.exit_date, + weekly_hours: e.weekly_hours, + source: e.source, + paygrade: e.paygrade, + birth_date: e.birth_date, + gender: e.gender, + worker_type: e.worker_type, + collective_agreement: e.collective_agreement, + work_days: e.work_days, + is_betriebsrat: e.is_betriebsrat, + has_dienstwagen: e.has_dienstwagen, + is_laterale_fuehrung: e.is_laterale_fuehrung, + is_c_level: e.is_c_level, + teilzeit_art: e.teilzeit_art, + dependents_count: dependentsCounts.get(e.id) ?? 0, + }); + } const statuses = parseStatuses(filters.status); return withDerivedStatus.filter((e) => statuses.includes(e.status as (typeof statuses)[number])); } -// Ereignisse: employee_history has no division_id/team_id of its own, so -// this joins in the affected employee's *current* org placement (two plain -// queries, merged in JS — the hand-written Database type has no relational -// embedding metadata for a single nested-select query). +// Ereignisse: employee_history trägt selbst keine Organisationszuordnung, sie +// kommt über die Planstelle, die die Person *am Tag des Ereignisses* innehatte. +// Vorher war es die heutige — womit ein Austritt von vor zwei Jahren unter dem +// Team stand, in das die Person nie versetzt worden war. // // from/to: "" (unset) falls back to the current calendar year; the literal // sentinel EVENT_DATE_OPEN means that side of the interval is intentionally // unbounded (e.g. "alle Ereignisse bis heute", no start date). -export async function loadEventHistory(supabase: SupabaseClient, filters: EventFilters): Promise { +export async function loadEventHistory(tx: Tx, filters: EventFilters): Promise { const currentYear = new Date().getFullYear(); const from = filters.from === EVENT_DATE_OPEN ? undefined : filters.from || `${currentYear}-01-01`; const to = filters.to === EVENT_DATE_OPEN ? undefined : filters.to || `${currentYear}-12-31`; function historyQuery() { - let query = supabase.from("employee_history").select("employee_id, event_date, event_type, description").order("id"); - if (from) query = query.gte("event_date", from); - if (to) query = query.lte("event_date", to); - if (filters.eventType) query = query.eq("event_type", filters.eventType); - return query; + let q = tx + .selectFrom("employee_history") + .select(["employee_id", "event_date", "event_type", "description"]) + .orderBy("id"); + if (from) q = q.where("event_date", ">=", from); + if (to) q = q.where("event_date", "<=", to); + if (filters.eventType) q = q.where("event_type", "=", filters.eventType); + return q; } - const [history, employees] = await Promise.all([ - fetchAllRows(historyQuery), - fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name, job_title, division_id, team_id, location_id").order("id")), + const [history, employees, assignments, orgMaps] = await Promise.all([ + historyQuery().execute(), + tx.selectFrom("employees").select(["id", "first_name", "last_name", "job_title", "location_id"]).orderBy("id").execute(), + tx + .selectFrom("position_assignments as a") + .innerJoin("om_positions as p", "p.id", "a.position_id") + .select(["a.employee_id", "a.valid_from", "a.valid_to", "p.org_unit_id"]) + .orderBy("a.employee_id") + .execute(), + filters.division ? loadOrgMaps(tx) : Promise.resolve(null), ]); + const spans = new Map(); + for (const a of assignments) { + const list = spans.get(a.employee_id) ?? []; + list.push({ from: a.valid_from, to: a.valid_to, unitId: a.org_unit_id }); + spans.set(a.employee_id, list); + } + + const allowedUnits = orgMaps && filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null; const employeeById = new Map(employees.map((e) => [e.id, e])); + const events: ReportEvent[] = []; for (const h of history) { const emp = employeeById.get(h.employee_id); if (!emp) continue; - if (filters.division && emp.division_id !== filters.division) continue; if (filters.location && emp.location_id !== filters.location) continue; + + const unitId = + spans.get(h.employee_id)?.find((s2) => s2.from <= h.event_date && (s2.to === null || s2.to > h.event_date))?.unitId ?? null; + if (allowedUnits && (!unitId || !allowedUnits.has(unitId))) continue; + events.push({ employee_id: emp.id, first_name: emp.first_name, last_name: emp.last_name, job_title: emp.job_title, - division_id: emp.division_id, - team_id: emp.team_id, + org_unit_id: unitId, location_id: emp.location_id, event_date: h.event_date, event_type: h.event_type, diff --git a/lib/reports.ts b/lib/reports.ts index 954f25e..f00ebb7 100644 --- a/lib/reports.ts +++ b/lib/reports.ts @@ -1,4 +1,4 @@ -import { todayIso, yearsBetweenIso } from "./format"; +import { fmtName, todayIso, yearsBetweenIso } from "./format"; import type { EmploymentStatus, HistoryEventType, Weekday } from "./supabase/types"; export { todayIso }; @@ -25,6 +25,7 @@ export type GroupDimension = | "laterale_fuehrung" | "c_level" | "has_dependents" + | "teilzeit_art" | "weekday"; export const MEASURE_LABELS: Record = { @@ -55,6 +56,7 @@ export const GROUP_LABELS: Record = { laterale_fuehrung: "Laterale Führung", c_level: "C-Level", has_dependents: "Hat Angehörige", + teilzeit_art: "Teilzeitvariante", weekday: "Wochentag", }; @@ -81,8 +83,8 @@ export type ReportEmployee = { first_name: string; last_name: string; job_title: string; - division_id: string; - team_id: string | null; + /** Die Einheit der Planstelle; null, wenn zum Stichtag keine besetzt war. */ + org_unit_id: string | null; location_id: string; status: string; employment_type: string; @@ -101,23 +103,36 @@ export type ReportEmployee = { has_dienstwagen: boolean; is_laterale_fuehrung: boolean; is_c_level: boolean; + teilzeit_art: string | null; dependents_count: number; }; +// Alle drei sind über die *Einheit* der Planstelle geschlüsselt, nicht über +// drei verschiedene Fremdschlüssel: welcher Bereich, welche Abteilung und +// welches Team zu einer Einheit gehören, ergibt sich aus ihrer Vorfahrenkette +// und wird einmal vorberechnet. +/** + * Eine Organisationseinheit als Filterwert — jede Ebene, nicht nur die + * Bereiche. `depth` dient der Einrückung in der Auswahlliste. + */ +export type UnitOption = { id: string; name: string; depth: number; unitType: string }; + export type OrgLookups = { divisionName: Map; - departmentNameByTeam: Map; + departmentName: Map; teamName: Map; locationName: Map; }; // Reconstructs status as of any date from the columns that actually carry a // timeline (entry/exit/Karenz), rather than trusting `employees.status`, -// which only ever reflects *today*. Division/team/location still reflect the -// employee's *current* assignment — the schema has no history of org-unit -// changes over time, only free-text employee_history descriptions — so a -// stichtag report groups by today's org placement, not the placement as of -// that date. Documented in the UI rather than silently wrong. +// which only ever reflects *today*. +// +// Die Einordnung in die Organisation wird zum selben Stichtag aufgelöst: seit +// dem OM-Modell ist position_assignments zeitabhängig, eine Auswertung +// gruppiert also nach der Einheit von damals. Vorher gab es diese Historie +// nicht, und ein Stichtagsbericht gruppierte nach der heutigen Zuordnung — +// was in der Oberfläche vermerkt werden musste, statt still falsch zu sein. export function deriveStatusAsOf( e: { entry_date: string; exit_date: string | null; karenz_start_date: string | null; karenz_return_date: string | null }, asOf: string @@ -137,11 +152,11 @@ function tenureYearsAsOf(entryDate: string, exitDate: string | null, asOf: strin export function groupKeyFor(e: ReportEmployee, dim: GroupDimension, lookups: OrgLookups): string { switch (dim) { case "division": - return lookups.divisionName.get(e.division_id) ?? "Unbekannt"; + return e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "Unbekannt") : "–"; case "department": - return e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "Unbekannt") : "–"; + return e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "–") : "–"; case "team": - return e.team_id ? (lookups.teamName.get(e.team_id) ?? "Unbekannt") : "–"; + return e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–"; case "location": return lookups.locationName.get(e.location_id) ?? "Unbekannt"; case "status": @@ -170,6 +185,9 @@ export function groupKeyFor(e: ReportEmployee, dim: GroupDimension, lookups: Org return e.is_c_level ? "Ja" : "Nein"; case "has_dependents": return e.dependents_count > 0 ? "Ja" : "Nein"; + case "teilzeit_art": + // „Keine" statt „–": die Antwort ist hier eine Aussage, kein Fehlen. + return e.teilzeit_art ?? "Keine"; case "weekday": // Not a strict partition — see groupKeysFor, which aggregateReport // actually uses. This single-key fallback only covers a direct @@ -254,9 +272,9 @@ export function aggregateReport( const value = measureValue(rowsForGroup, measure, asOf); const people: ReportPerson[] = rowsForGroup.map((e) => ({ id: e.id, - name: `${e.first_name} ${e.last_name}`, + name: fmtName(e.first_name, e.last_name), title: e.job_title, - team: e.team_id ? (lookups.teamName.get(e.team_id) ?? "–") : "–", + team: e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–", entry_date: e.entry_date, })); const row: ReportRow = { key, value, count: rowsForGroup.length, people }; @@ -345,8 +363,8 @@ export type ReportEvent = { first_name: string; last_name: string; job_title: string; - division_id: string; - team_id: string | null; + /** Die Einheit der Planstelle; null, wenn zum Stichtag keine besetzt war. */ + org_unit_id: string | null; location_id: string; event_date: string; event_type: HistoryEventType; @@ -358,11 +376,11 @@ function eventGroupKeyFor(e: ReportEvent, dim: EventGroupDimension, lookups: Org case "event_type": return EVENT_TYPE_LABELS[e.event_type] ?? e.event_type; case "division": - return lookups.divisionName.get(e.division_id) ?? "Unbekannt"; + return e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "Unbekannt") : "–"; case "department": - return e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "Unbekannt") : "–"; + return e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "–") : "–"; case "team": - return e.team_id ? (lookups.teamName.get(e.team_id) ?? "Unbekannt") : "–"; + return e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–"; case "location": return lookups.locationName.get(e.location_id) ?? "Unbekannt"; case "event_year": @@ -392,9 +410,9 @@ export function aggregateEvents( // code working unchanged for both report modes. const people: ReportPerson[] = rowsForGroup.map((e) => ({ id: e.employee_id, - name: `${e.first_name} ${e.last_name}`, + name: fmtName(e.first_name, e.last_name), title: e.description, - team: e.team_id ? (lookups.teamName.get(e.team_id) ?? "–") : "–", + team: e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–", entry_date: e.event_date, })); const row: ReportRow = { key, value: rowsForGroup.length, count: rowsForGroup.length, people }; diff --git a/lib/shell-data.ts b/lib/shell-data.ts new file mode 100644 index 0000000..f387db5 --- /dev/null +++ b/lib/shell-data.ts @@ -0,0 +1,76 @@ +import type { Tx } from "./db"; +import { jsonArrayFrom, jsonObjectFrom, zeitstempel } from "./db/json"; +import { todayIso } from "./format"; +import { baueOffeneNotizen, offeneNotizenAbfrage, type NotizZeile, type OpenNote } from "./notes"; +import { buildOrgMaps, orgMapsAbfragen, type Location } from "./org"; +import { + offeneStellenAbfrage, + resolveOpenPositions, + type OffeneStelle, + type OpenPositionResolved, +} from "./positions"; + +// Was die Hülle jeder Seite braucht — in zwei Rundreisen statt in sechs. +// +// Vorher stand das im Layout selbst, als Promise.all, das wie Gleichzeitigkeit +// aussah und keine war: eine Transaktion hängt an einer Verbindung, und über +// eine Verbindung laufen Abfragen nacheinander. Bei rund 36 ms Umlaufzeit +// kostete diese Hülle — die **jede** Seite mitlädt — eine halbe Sekunde +// Warten für ein paar Kilobyte. Der Weg dahin steht in lib/db/json.ts. +// +// Hier und nicht im Layout, damit sich die Zahl der Rundreisen messen lässt, +// ohne eine React-Komponente aufzubauen. + +export type ShellData = { + profile: { full_name: string | null; email: string | null; role: string | null; is_active: boolean | null }; + openPositions: OpenPositionResolved[]; + locations: Location[]; + drafts: { id: string; step: number; payload: Record; updated_at: string }[]; + openNotes: OpenNote[]; +}; + +/** + * `null` heisst: angemeldet, aber nicht als HR freigeschaltet. + * + * Die Zugangsprüfung fragt gleichzeitig mit dem Rest statt davor. Das liest + * ein paar Zeilen mehr, als eine gesperrte Person sehen dürfte, wirft sie aber + * weg, ohne sie je auszuliefern — und die eigentliche Grenze ist ohnehin RLS, + * nicht die Reihenfolge hier. + */ +export async function loadShellData(tx: Tx, userId: string): Promise { + const asOf = todayIso(); + + const gelesen = await tx + .selectNoFrom((eb) => [ + jsonObjectFrom( + eb.selectFrom("profiles").select(["full_name", "email", "role", "is_active"]).where("id", "=", userId) + ).as("profile"), + ...orgMapsAbfragen(eb), + jsonArrayFrom(offeneStellenAbfrage(eb, asOf)).as("open"), + jsonArrayFrom(offeneNotizenAbfrage(eb)).as("notes"), + 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"), + ]) + .executeTakeFirstOrThrow(); + + const profile = gelesen.profile; + if (profile?.role !== "hr" || profile?.is_active !== true) return null; + + const orgMaps = buildOrgMaps(gelesen.units as never, gelesen.locations as never); + + return { + profile, + // Die zweite Rundreise: was sie fragt, hängt davon ab, welche Stellen + // offen sind — das lässt sich nicht in die erste ziehen. + openPositions: await resolveOpenPositions(tx, orgMaps, gelesen.open as OffeneStelle[], asOf), + locations: gelesen.locations as Location[], + drafts: gelesen.drafts as ShellData["drafts"], + openNotes: baueOffeneNotizen(gelesen.notes as NotizZeile[]), + }; +} diff --git a/lib/supabase/admin.ts b/lib/supabase/admin.ts deleted file mode 100644 index 575fdd1..0000000 --- a/lib/supabase/admin.ts +++ /dev/null @@ -1,23 +0,0 @@ -import "server-only"; -import { createClient as createSupabaseClient } from "@supabase/supabase-js"; -import type { Database } from "./types"; - -// Service-role client: bypasses RLS entirely. Server-only — never import this -// from a Client Component or anything bundled for the browser. The -// "server-only" import makes an accidental client-side import a build error -// instead of a runtime one. -export function createAdminClient() { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; - - if (!supabaseUrl) { - throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL"); - } - if (!serviceRoleKey) { - throw new Error("Missing SUPABASE_SERVICE_ROLE_KEY"); - } - - return createSupabaseClient(supabaseUrl, serviceRoleKey, { - auth: { autoRefreshToken: false, persistSession: false }, - }); -} diff --git a/lib/supabase/auth.ts b/lib/supabase/auth.ts deleted file mode 100644 index 5e142e6..0000000 --- a/lib/supabase/auth.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { SupabaseClient } from "@supabase/supabase-js"; -import { NextResponse } from "next/server"; -import type { Database } from "./types"; - -// Route Handlers under /api/export/* are outside the App Router layout tree, -// so app/(app)/layout.tsx's HR gate never runs for them — each one has to -// re-establish that the caller is an active HR user itself. RLS is still the -// real boundary (an unauthorized session simply reads nothing); this exists -// so those routes answer 401/403 instead of handing back an empty workbook. -export async function requireHrUser(supabase: SupabaseClient): Promise { - const { - data: { user }, - } = await supabase.auth.getUser(); - if (!user) return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }); - - const { data: profile } = await supabase.from("profiles").select("role, is_active").eq("id", user.id).maybeSingle(); - if (profile?.role !== "hr" || profile.is_active !== true) { - return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }); - } - return null; -} diff --git a/lib/supabase/client.ts b/lib/supabase/client.ts deleted file mode 100644 index e546941..0000000 --- a/lib/supabase/client.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { createBrowserClient } from "@supabase/ssr"; -import type { Database } from "./types"; - -// For use in Client Components that need interactivity (filters, live -// hints, etc). Server Components/Actions should use lib/supabase/server.ts. -// Only ever reads NEXT_PUBLIC_* vars — this file is bundled for the browser. -export function createClient() { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; - - if (!supabaseUrl) { - throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL"); - } - if (!supabaseAnonKey) { - throw new Error("Missing NEXT_PUBLIC_SUPABASE_ANON_KEY"); - } - - return createBrowserClient(supabaseUrl, supabaseAnonKey); -} diff --git a/lib/supabase/query.ts b/lib/supabase/query.ts deleted file mode 100644 index 3d6d2a8..0000000 --- a/lib/supabase/query.ts +++ /dev/null @@ -1,33 +0,0 @@ -// PostgREST's .or() filter syntax treats "," "(" and ")" as structural -// delimiters between conditions. A raw user-supplied search term containing -// them (e.g. from a search box or ?q= param) can break out of the intended -// column conditions and append arbitrary extra filters to the query. Strip -// them before interpolating — harmless for real name/title searches, which -// never legitimately contain them. -export function sanitizeIlikeTerm(term: string): string { - return term.replace(/[,()]/g, ""); -} - -// PostgREST caps every response at db.max_rows (1000, see -// supabase/config.toml) and does so *silently* — a query over ~800 employees -// or the employee_history log just stops returning rows, and a report or -// export built from it is quietly wrong rather than failing. Anything that -// aggregates a whole table has to page explicitly; anything that renders a -// bounded list (an employee page, the audit log) uses .range() directly and -// does not need this. -const PAGE_SIZE = 1000; - -type PagedQuery = { - range: (from: number, to: number) => PromiseLike<{ data: Row[] | null; error: unknown }>; -}; - -export async function fetchAllRows(buildQuery: () => PagedQuery): Promise { - const rows: Row[] = []; - for (let page = 0; ; page++) { - const { data, error } = await buildQuery().range(page * PAGE_SIZE, (page + 1) * PAGE_SIZE - 1); - if (error || !data) break; - rows.push(...data); - if (data.length < PAGE_SIZE) break; - } - return rows; -} diff --git a/lib/supabase/server.ts b/lib/supabase/server.ts deleted file mode 100644 index 60494a0..0000000 --- a/lib/supabase/server.ts +++ /dev/null @@ -1,40 +0,0 @@ -import "server-only"; -import { createServerClient } from "@supabase/ssr"; -import { cookies } from "next/headers"; -import type { Database } from "./types"; - -// For use in Server Components and Server Actions. Respects the signed-in -// user's session, so all reads/writes go through RLS as that user. Uses only -// the anon key (never the service role key) — the user's own session cookie -// is what determines access, via RLS. -export async function createClient() { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; - - if (!supabaseUrl) { - throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL"); - } - if (!supabaseAnonKey) { - throw new Error("Missing NEXT_PUBLIC_SUPABASE_ANON_KEY"); - } - - const cookieStore = await cookies(); - - return createServerClient(supabaseUrl, supabaseAnonKey, { - cookies: { - getAll() { - return cookieStore.getAll(); - }, - setAll(cookiesToSet) { - try { - cookiesToSet.forEach(({ name, value, options }) => - cookieStore.set(name, value, options) - ); - } catch { - // Called from a Server Component during render — safe to ignore - // because proxy.ts refreshes the session cookie on every request. - } - }, - }, - }); -} diff --git a/lib/supabase/types.ts b/lib/supabase/types.ts index 672f613..68da0a2 100644 --- a/lib/supabase/types.ts +++ b/lib/supabase/types.ts @@ -12,12 +12,67 @@ export type GenderType = "m" | "w"; export type WorkerType = "Angestellte:r" | "Arbeiter:in"; export type CollectiveAgreement = "Handel" | "Süßwaren"; export type Weekday = "Mo" | "Di" | "Mi" | "Do" | "Fr" | "Sa" | "So"; + +/** + * Antriebsart des Dienstwagens. + * + * Bewusst keine dritte Möglichkeit „unbekannt": die Angabe hängt per CHECK + * an has_dienstwagen, und wer einen Dienstwagen hat, weiss auch, ob er lädt + * oder tankt. + */ +export type DienstwagenArt = "Verbrenner" | "Elektro"; + +/** + * Gesetzlich geregelte Teilzeiten. + * + * Keine Abwesenheiten: die Person arbeitet, nur kürzer. Als Zustand geführt, + * damit sich auswerten lässt, wer gerade in einer ist — mit einem optionalen + * Enddatum, weil nicht jede ein bekanntes Ende hat. + */ +export type TeilzeitArt = "Bildungsteilzeit" | "Elternteilzeit" | "Pflegeteilzeit" | "Wiedereingliederungsteilzeit"; + +/** + * Verhältnis zum Notfallkontakt. + * + * Auswahlliste statt Freitext, damit sich danach auswerten lässt und nicht + * „Gattin", „Ehefrau" und „Frau" nebeneinander stehen. „Sonstige" ist der + * Ausweg für alles, was hier fehlt — ohne ihn wäre die Liste eine Anmassung. + * + * In der Datenbank bleibt die Spalte `text`: eine Aufzählung dort würde jede + * Ergänzung zu einer Migration machen, und diese Liste wird sich ändern. + */ +export const EMERGENCY_RELATIONS = [ + "Gattin/Gatte", + "Lebensgefährtin/Lebensgefährte", + "Mutter", + "Vater", + "Tochter", + "Sohn", + "Schwester", + "Bruder", + "Freundin/Freund", + "Sonstige", +] as const; + +export type EmergencyRelation = (typeof EMERGENCY_RELATIONS)[number]; + +/** + * Eine einzelne Feldänderung im Protokoll. + * + * `vorher` und `nachher` sind bewusst Text: die Datenbank stellt jeden Typ + * über ::text dar, damit ein Datum, eine Zahl und eine Liste von Arbeitstagen + * in derselben Spalte nebeneinander stehen können. Für die Anzeige reicht + * das; gerechnet wird damit nicht. + */ +export type AuditChange = { feld: string; vorher: string | null; nachher: string | null }; export type RelationshipType = "Ehepartner:in" | "Lebenspartner:in" | "Kind" | "Sonstige"; export type NoteCategory = "Allgemein" | "Vertraulich" | "Personalgespräch" | "Wiedervorlage" | "Lob / Anerkennung"; // Single HR-only role (see docs/decisions/0001-hr-only-access.md). Kept as a // union (not a string literal) so a future hr_admin/hr_user split, if ever // technically required, is a type-level addition, not a rewrite. export type ProfileRole = "hr"; +/** Etikett einer Organisationseinheit; die Struktur steckt in parent_id. */ +export type OrgUnitType = "Gesellschaft" | "Bereich" | "Abteilung" | "Team"; export type HistoryEventType = | "Eintritt" | "Beförderung" @@ -30,8 +85,6 @@ export type HistoryEventType = | "Reorganisation" | "Gehaltsanpassung" | "Rückkehr"; -export type PositionStatus = "open" | "filled"; -export type ReorgMoveKind = "emp" | "team" | "abt" | "dept"; export type PendingChangeType = | "transfer" | "promotion" @@ -49,21 +102,6 @@ type NoRelationships = { Relationships: [] }; export type Database = { public: { Tables: { - divisions: NoRelationships & { - Row: { id: string; org_number: string; name: string }; - Insert: { id?: string; org_number: string; name: string }; - Update: Partial<{ id: string; org_number: string; name: string }>; - }; - departments: NoRelationships & { - Row: { id: string; org_number: string; name: string; division_id: string }; - Insert: { id?: string; org_number: string; name: string; division_id: string }; - Update: Partial<{ id: string; org_number: string; name: string; division_id: string }>; - }; - teams: NoRelationships & { - Row: { id: string; org_number: string; name: string; department_id: string }; - Insert: { id?: string; org_number: string; name: string; department_id: string }; - Update: Partial<{ id: string; org_number: string; name: string; department_id: string }>; - }; locations: NoRelationships & { Row: { id: string; name: string; country: string }; Insert: { id?: string; name: string; country: string }; @@ -111,19 +149,19 @@ export type Database = { birth_date: string; sv_nummer: string | null; nationality: string; + /** Aufenthaltstitel — nur ausserhalb von EU, EWR und Schweiz erhoben. */ + hat_aufenthaltstitel: boolean; + /** Gültig bis, falls befristet. Nur mit dem Kennzeichen zusammen erlaubt. */ + aufenthaltstitel_bis: string | null; address: string | null; postal_code: string | null; city: string | null; address_country: string | null; - email: string; + /** Private Adresse, freiwillig — eindeutig, wenn angegeben. */ + email: string | null; phone: string | null; - team_id: string | null; - division_id: string; job_title: string; location_id: string; - manager_id: string | null; - org_level: number; - is_lead: boolean; employment_type: EmploymentType; weekly_hours: number; /** @deprecated Salary is out of MVP scope; column kept only for pre-existing data. */ @@ -145,6 +183,20 @@ export type Database = { work_days: Weekday[]; is_betriebsrat: boolean; has_dienstwagen: boolean; + /** Null genau dann, wenn kein Dienstwagen vorhanden ist — chk_dienstwagen_art. */ + dienstwagen_art: DienstwagenArt | null; + /** Besonderer Kündigungsschutz — löst beim Austritt eine Warnung aus. */ + has_kuendigungsschutz: boolean; + /** Ende des Schutzes, falls bekannt. Nur mit dem Kennzeichen zusammen erlaubt. */ + kuendigungsschutz_bis: string | null; + /** Bildungs-, Eltern-, Pflege- oder Wiedereingliederungsteilzeit; null bei einer gewöhnlichen Regelung. */ + teilzeit_art: TeilzeitArt | null; + /** Ende der Teilzeit, falls bekannt. Nur mit einer Variante zusammen erlaubt. */ + teilzeit_bis: string | null; + /** Nur gemeinsam gesetzt oder gemeinsam leer — chk_emergency_contact. */ + emergency_contact_name: string | null; + emergency_contact_phone: string | null; + emergency_contact_relation: string | null; is_laterale_fuehrung: boolean; is_c_level: boolean; title_prefix: string[]; @@ -160,19 +212,16 @@ export type Database = { birth_date: string; sv_nummer?: string | null; nationality?: string; + hat_aufenthaltstitel?: boolean; + aufenthaltstitel_bis?: string | null; address?: string | null; postal_code?: string | null; city?: string | null; address_country?: string | null; - email: string; + email?: string | null; phone?: string | null; - team_id?: string | null; - division_id?: string; job_title: string; location_id: string; - manager_id?: string | null; - org_level?: number; - is_lead?: boolean; employment_type?: EmploymentType; weekly_hours?: number; contract_type?: ContractType; @@ -192,6 +241,14 @@ export type Database = { work_days?: Weekday[]; is_betriebsrat?: boolean; has_dienstwagen?: boolean; + dienstwagen_art?: DienstwagenArt | null; + has_kuendigungsschutz?: boolean; + kuendigungsschutz_bis?: string | null; + teilzeit_art?: TeilzeitArt | null; + teilzeit_bis?: string | null; + emergency_contact_name?: string | null; + emergency_contact_phone?: string | null; + emergency_contact_relation?: string | null; is_laterale_fuehrung?: boolean; is_c_level?: boolean; title_prefix?: string[]; @@ -208,7 +265,10 @@ export type Database = { event_date: string; event_type: HistoryEventType; description: string; - reorg_scenario_id: string | null; + /** Feldweise Änderungen — dieselbe Form wie audit_log.changes. */ + changes: AuditChange[] | null; + /** Der geplante Vorgang, solange die Änderung noch nicht wirksam ist. */ + pending_id: string | null; created_at: string; }; Insert: { @@ -217,7 +277,8 @@ export type Database = { event_date: string; event_type: HistoryEventType; description: string; - reorg_scenario_id?: string | null; + changes?: AuditChange[] | null; + pending_id?: string | null; created_at?: string; }; Update: Partial; @@ -274,37 +335,6 @@ export type Database = { }; Update: Partial; }; - positions: NoRelationships & { - Row: { - id: string; - position_number: string; - title: string; - team_id: string; - division_id: string; - is_lead: boolean; - reports_to_employee_id: string | null; - status: PositionStatus; - valid_from: string; - created_at: string; - filled_at: string | null; - filled_by_employee_id: string | null; - }; - Insert: { - id?: string; - position_number?: string; - title: string; - team_id: string; - division_id?: string; - is_lead?: boolean; - reports_to_employee_id?: string | null; - status?: PositionStatus; - valid_from?: string; - created_at?: string; - filled_at?: string | null; - filled_by_employee_id?: string | null; - }; - Update: Partial; - }; hire_drafts: NoRelationships & { Row: { id: string; created_by: string | null; step: number; payload: Record; updated_at: string }; Insert: { id?: string; created_by?: string | null; step?: number; payload: Record; updated_at?: string }; @@ -325,6 +355,13 @@ export type Database = { target_label: string; target_employee_id: string | null; details: string | null; + /** + * Feldweise Änderungen. Null bei Einträgen aus der Zeit vor + * 20260803140000_audit_changes_detail.sql — dort wurden nur die + * Feldnamen behalten, nicht die Werte, und das lässt sich nicht + * nachliefern. + */ + changes: AuditChange[] | null; }; Insert: { id?: string; @@ -335,37 +372,10 @@ export type Database = { target_label: string; target_employee_id?: string | null; details?: string | null; + changes?: AuditChange[] | null; }; Update: Partial; }; - reorg_scenarios: NoRelationships & { - Row: { - id: string; - name: string; - effective_date: string; - created_by: string | null; - applied: boolean; - applied_at: string | null; - undo_snapshot: Record | null; - created_at: string; - }; - Insert: { - id?: string; - name: string; - effective_date: string; - created_by?: string | null; - applied?: boolean; - applied_at?: string | null; - undo_snapshot?: Record | null; - created_at?: string; - }; - Update: Partial; - }; - reorg_moves: NoRelationships & { - Row: { id: string; scenario_id: string; kind: ReorgMoveKind; payload: Record }; - Insert: { id?: string; scenario_id: string; kind: ReorgMoveKind; payload: Record }; - Update: Partial; - }; pending_org_changes: NoRelationships & { Row: { id: string; @@ -373,7 +383,6 @@ export type Database = { change_type: PendingChangeType; effective_date: string; payload: Record; - reorg_scenario_id: string | null; status: PendingChangeStatus; created_by: string | null; created_at: string; @@ -385,7 +394,6 @@ export type Database = { change_type: PendingChangeType; effective_date: string; payload: Record; - reorg_scenario_id?: string | null; status?: PendingChangeStatus; created_by?: string | null; created_at?: string; @@ -393,24 +401,163 @@ export type Database = { }; Update: Partial; }; - // Written exclusively by trg_track_employee_assignment; RLS grants HR - // read access only, hence no Insert/Update shapes worth modelling. - employee_assignments: NoRelationships & { + // ── SAP-OM-Modell ────────────────────────────────────────── + // O: rekursiv über parent_id, unit_type ist nur ein Etikett. + org_units: { + Relationships: [ + { + foreignKeyName: "org_units_parent_id_fkey"; + columns: ["parent_id"]; + referencedRelation: "org_units"; + referencedColumns: ["id"]; + }, + ]; Row: { id: string; - employee_id: string; - manager_id: string | null; - team_id: string | null; - division_id: string; - job_title: string; - is_lead: boolean; - org_level: number; + org_number: string; + name: string; + parent_id: string | null; + unit_type: OrgUnitType; valid_from: string; valid_to: string | null; created_at: string; }; - Insert: never; - Update: never; + Insert: { + id?: string; + org_number: string; + name: string; + parent_id?: string | null; + unit_type: OrgUnitType; + valid_from?: string; + valid_to?: string | null; + created_at?: string; + }; + Update: Partial; + }; + // C: Katalog der Tätigkeiten. + jobs: NoRelationships & { + Row: { id: string; code: string; title: string; created_at: string }; + Insert: { id?: string; code: string; title: string; created_at?: string }; + Update: Partial; + }; + // S: Planstelle. Der Name om_positions stammt aus der Zeit, in der die + // alte positions-Tabelle noch danebenstand; sie ist inzwischen weg. + om_positions: { + Relationships: [ + { + foreignKeyName: "om_positions_org_unit_id_fkey"; + columns: ["org_unit_id"]; + referencedRelation: "org_units"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "om_positions_job_id_fkey"; + columns: ["job_id"]; + referencedRelation: "jobs"; + referencedColumns: ["id"]; + }, + ]; + Row: { + id: string; + position_number: string; + org_unit_id: string; + job_id: string; + is_chief: boolean; + valid_from: string; + valid_to: string | null; + created_at: string; + }; + Insert: { + id?: string; + position_number: string; + org_unit_id: string; + job_id: string; + is_chief?: boolean; + valid_from?: string; + valid_to?: string | null; + created_at?: string; + }; + Update: Partial; + }; + // Kostenstellen. Die Zuordnung hängt an der Planstelle, nicht an der + // Person: der Sitz kostet Geld, auch wenn niemand darauf sitzt. + cost_centers: NoRelationships & { + Row: { + id: string; + code: string; + name: string; + org_unit_id: string | null; + valid_from: string; + valid_to: string | null; + created_at: string; + }; + Insert: { + id?: string; + code: string; + name: string; + org_unit_id?: string | null; + valid_from?: string; + valid_to?: string | null; + created_at?: string; + }; + Update: Partial; + }; + // A011: Planstelle kontiert auf Kostenstelle, zeitabhängig. + position_cost_centers: NoRelationships & { + Row: { + id: string; + position_id: string; + cost_center_id: string; + valid_from: string; + valid_to: string | null; + created_at: string; + }; + Insert: { + id?: string; + position_id: string; + cost_center_id: string; + valid_from: string; + valid_to?: string | null; + created_at?: string; + }; + Update: Partial; + }; + // A008: Person besetzt Planstelle, zeitabhängig. + position_assignments: { + Row: { + id: string; + position_id: string; + employee_id: string; + valid_from: string; + valid_to: string | null; + created_at: string; + }; + Insert: { + id?: string; + position_id: string; + employee_id: string; + valid_from: string; + valid_to?: string | null; + created_at?: string; + }; + Update: Partial; + // Beide Richtungen: über die Planstelle hängt die Verortung in der + // Organisation, über die Person die Verortung in der Akte. Die + // Einbettung erspart an einem Dutzend Stellen eine zweite Abfrage. + Relationships: [ + { + foreignKeyName: "position_assignments_position_id_fkey"; + columns: ["position_id"]; + referencedRelation: "om_positions"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "position_assignments_employee_id_fkey"; + columns: ["employee_id"]; + referencedRelation: "employees"; + referencedColumns: ["id"]; + }, + ]; }; }; Views: Record; @@ -428,13 +575,30 @@ export type Database = { delete_employee_dependent: { Args: { payload: Record }; Returns: void }; add_employee_note: { Args: { payload: Record }; Returns: string }; complete_employee_note: { Args: { payload: Record }; Returns: void }; + // Nimmt eine irrtümliche Stammdaten-/Vertragsänderung zurück. Der einzige + // Weg an der fehlenden delete-Policy auf employee_history vorbei. + delete_history_entry: { Args: { payload: Record }; Returns: void }; + // Berichtigt Wert und/oder Datum eines Historieneintrags. + update_history_entry: { Args: { payload: Record }; Returns: void }; + // Planstelle anlegen bzw. schliessen — im OM-Modell Operationen auf + // om_positions, nicht mehr auf einer eigenen Ausschreibungstabelle. create_position: { Args: { payload: Record }; Returns: string }; + update_position: { Args: { payload: Record }; Returns: void }; + set_position_cost_center: { Args: { payload: Record }; Returns: void }; delete_position: { Args: { payload: Record }; Returns: void }; - staff_position_internally: { Args: { payload: Record }; Returns: void }; is_valid_svnr: { Args: { p_svnr: string; p_birth_date?: string | null }; Returns: boolean }; - apply_reorg: { Args: { payload: Record }; Returns: string }; - undo_reorg: { Args: { payload: Record }; Returns: void }; apply_due_pending_changes: { Args: Record; Returns: number }; + om_reporting_lines: { + Args: { p_as_of?: string }; + Returns: { + employee_id: string; + position_id: string; + org_unit_id: string; + is_chief: boolean; + formal_manager_id: string | null; + acting_manager_id: string | null; + }[]; + }; }; }; }; diff --git a/next.config.ts b/next.config.ts index 17ee181..26231ff 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,25 +2,28 @@ import type { NextConfig } from "next"; // Report-only rather than enforcing, deliberately: the policy is derived from // what this app is known to load — its own bundle, the self-hosted Nunito -// files from next/font, and the Supabase project from -// NEXT_PUBLIC_SUPABASE_URL — but an unenforced policy that logs violations is -// worth more than a guessed one that blanks the app for every HR user. -// Promote it to `Content-Security-Policy` once the reports come back clean. +// files from next/font, and nothing else — but an unenforced policy that logs +// violations is worth more than a guessed one that blanks the app for every +// HR user. Promote it to `Content-Security-Policy` once the reports come back +// clean. // // 'unsafe-inline' on script-src is not laziness: Next.js inlines its // bootstrap and hydration payload as inline