Merge branch 'feat/sap-om-org-model'
This commit is contained in:
46
.env.example
46
.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/<verzeichnis-id>/v2.0
|
||||
|
||||
# Nur nötig, wenn die Anwendung hinter einem Reverse Proxy unter einer
|
||||
# anderen Adresse erreichbar ist, als sie selbst sieht. Ohne diesen Wert baut
|
||||
# Auth.js die Rückruf-Adresse aus den Request-Headern.
|
||||
AUTH_URL=
|
||||
|
||||
# Shared secret Vercel Cron sends as `Authorization: Bearer <value>` when it
|
||||
# calls /api/cron/apply-pending-changes (set the same value in the Vercel
|
||||
|
||||
13
.github/workflows/ci.yml
vendored
13
.github/workflows/ci.yml
vendored
@@ -68,16 +68,23 @@ jobs:
|
||||
- name: Supabase starten
|
||||
run: supabase start
|
||||
|
||||
# `-o env` emits API_URL / ANON_KEY / SERVICE_ROLE_KEY; the app expects
|
||||
# them under its own names.
|
||||
# `-o env` emits API_URL / ANON_KEY / SERVICE_ROLE_KEY / DB_URL; the app
|
||||
# expects them under its own names. DATABASE_URL ist der direkte
|
||||
# Postgres-Zugang — den braucht die neue Zugriffsschicht (lib/db) und
|
||||
# vor allem der Nachweis zum Sitzungskontext.
|
||||
- name: Testumgebung schreiben
|
||||
run: |
|
||||
supabase status -o env \
|
||||
--override-name api.url=NEXT_PUBLIC_SUPABASE_URL \
|
||||
--override-name auth.anon_key=NEXT_PUBLIC_SUPABASE_ANON_KEY \
|
||||
--override-name auth.service_role_key=SUPABASE_SERVICE_ROLE_KEY \
|
||||
| grep -E '^(NEXT_PUBLIC_SUPABASE_URL|NEXT_PUBLIC_SUPABASE_ANON_KEY|SUPABASE_SERVICE_ROLE_KEY)=' \
|
||||
--override-name db.url=DATABASE_URL \
|
||||
| grep -E '^(NEXT_PUBLIC_SUPABASE_URL|NEXT_PUBLIC_SUPABASE_ANON_KEY|SUPABASE_SERVICE_ROLE_KEY|DATABASE_URL)=' \
|
||||
| tr -d '"' > .env.test.local
|
||||
# Lokal läuft Postgres ohne TLS; ohne das versucht `pg` es trotzdem.
|
||||
echo "DATABASE_SSL=false" >> .env.test.local
|
||||
grep -q '^DATABASE_URL=' .env.test.local \
|
||||
|| { echo "DATABASE_URL wurde nicht geschrieben — der Sitzungskontext-Nachweis liefe ins Leere."; exit 1; }
|
||||
|
||||
- name: Seed
|
||||
run: node --env-file=.env.test.local supabase/seed.ts
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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/
|
||||
|
||||
204
DEPLOYMENT.md
204
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:<konto>/alpenwerk-hr.git
|
||||
git push github feat/sap-om-org-model
|
||||
```
|
||||
|
||||
Danach das GitHub-Repository in Vercel verbinden. Ab dann rollt jeder Push
|
||||
aus. Zwei Fernziele bedeuten aber auch: beide müssen gepflegt werden.
|
||||
|
||||
### Danach
|
||||
|
||||
1. **Umgebungsvariablen** im Vercel-Projekt setzen (Settings → Environment
|
||||
Variables), dieselben wie in [Abschnitt 1](#1-env-anlegen). `AUTH_URL` ist
|
||||
nicht nötig, Vercel setzt den Host selbst.
|
||||
2. **Umleitungs-URI** in der Entra-Registrierung ergänzen:
|
||||
`https://<projekt>.vercel.app/api/auth/callback/microsoft-entra-id`
|
||||
3. Der nächtliche Lauf ist über `vercel.json` bereits eingerichtet.
|
||||
|
||||
Zwei Eigenheiten der Plattform, die im Code berücksichtigt sind:
|
||||
`output: "standalone"` entfällt dort automatisch (Vercel baut selbst), und
|
||||
`/api/import` ist auf 60 Sekunden begrenzt — die Obergrenze des kostenlosen
|
||||
Tarifs. Im Pro-Tarif liessen sich 300 setzen, falls eine Importdatei mit
|
||||
vielen tausend Zeilen ansteht.
|
||||
|
||||
Der Verbindungspool passt zu serverlosen Aufrufen, **weil** `DATABASE_URL`
|
||||
auf den Transaktions-Modus zeigt (Port 6543). Mit dem Sitzungs-Modus wären
|
||||
die 15 Verbindungen des Tarifs nach wenigen gleichzeitigen Aufrufen
|
||||
verbraucht.
|
||||
|
||||
## Deployment mit Docker
|
||||
|
||||
Dieser Guide beschreibt, wie die App stattdessen als Docker-Container auf
|
||||
einem eigenen Linux-Server läuft.
|
||||
|
||||
## Was wird containerisiert – und was nicht
|
||||
|
||||
- **Containerisiert:** nur die Next.js-App selbst (`Dockerfile`).
|
||||
- **Nicht containerisiert:** Supabase (Datenbank + Auth). Die App verbindet
|
||||
sich per URL/Key zu einem bestehenden Supabase-Projekt (Cloud oder
|
||||
selbst gehostet) – das bleibt unverändert. `supabase/` in diesem Repo ist
|
||||
nur die lokale Dev-/Migrations-Umgebung (`supabase start`), kein Teil des
|
||||
Deployments.
|
||||
- **Nicht containerisiert:** die Datenbank. Die App verbindet sich über
|
||||
`DATABASE_URL` zu einem beliebigen PostgreSQL ab 15 — heute ein
|
||||
Supabase-Projekt, genauso möglich sind Azure Flexible Server, RDS,
|
||||
Cloud SQL oder eigenes Blech. `supabase/` in diesem Repo ist die
|
||||
Migrations- und Entwicklungsumgebung, kein Teil des Deployments.
|
||||
- **Ebenfalls nicht containerisiert:** die Anmeldung. Sie läuft über
|
||||
Microsoft Entra ID; die App hält nur das Sitzungscookie (Auth.js). Es gibt
|
||||
keinen Anmeldedienst, der mit ausgerollt werden müsste.
|
||||
- **Ersetzt:** der Vercel-Cron-Job aus `vercel.json` (täglich 03:00 Uhr,
|
||||
ruft `/api/cron/apply-pending-changes` auf, um fällige Versetzungen/
|
||||
Beförderungen/Karenz/Reorg-Änderungen zu übernehmen). Da es außerhalb von
|
||||
@@ -19,6 +85,39 @@ läuft.
|
||||
enthaltene `cron`-Sidecar-Container diese Aufgabe mit demselben Schema
|
||||
und demselben Bearer-Secret, das die Route bereits erwartet.
|
||||
|
||||
## Betrieb im Firmennetz — zwei Dinge vorab
|
||||
|
||||
Die App läuft intern, aber sie ist **nicht** von der Aussenwelt unabhängig.
|
||||
Beides vor der Installation klären, sonst scheitert es am Ende an der
|
||||
Firewall:
|
||||
|
||||
**1. Der Server braucht ausgehenden Zugang.** Eingehend aus dem Internet
|
||||
nichts, ausgehend zwingend:
|
||||
|
||||
| Ziel | Wofür | Ohne das |
|
||||
|---|---|---|
|
||||
| `login.microsoftonline.com` (443) | Auth.js tauscht den Anmeldecode **serverseitig** gegen ein Token und lädt die Konfiguration des Ausstellers | keine Anmeldung möglich |
|
||||
| Die Datenbank (Supabase: `*.pooler.supabase.com`, 6543) | jede Abfrage | die App startet, zeigt aber nichts |
|
||||
|
||||
Dass die Anmeldung im Browser der Person stattfindet, genügt **nicht** — der
|
||||
Tausch von Code gegen Token läuft vom Server aus. Liegt die VM in einem
|
||||
abgeschotteten Netz, ist entweder ein Proxy nötig oder eine PostgreSQL-
|
||||
Instanz im selben Netz statt Supabase.
|
||||
|
||||
**2. HTTPS ist Pflicht, auch intern.** Entra ID akzeptiert `http` nur für
|
||||
`localhost`. Der praktikable Weg ohne öffentliche Erreichbarkeit: ein
|
||||
**öffentlicher DNS-Name, der auf die private Adresse zeigt** (z. B.
|
||||
`hr.elycon.solutions` → `10.x.x.x`) und ein Zertifikat über die
|
||||
DNS-Challenge. Das ist zulässig, verbreitet, und liefert ein regulär
|
||||
vertrauenswürdiges Zertifikat, ohne dass der Server je aus dem Internet
|
||||
erreichbar ist.
|
||||
|
||||
Beispielkonfiguration: [`deploy/Caddyfile`](deploy/Caddyfile).
|
||||
|
||||
Die Alternative — selbst signiertes Zertifikat — bedeutet, es auf jedem
|
||||
Arbeitsplatz als vertrauenswürdig zu hinterlegen. Bei zwei Personen machbar,
|
||||
bei zwanzig nicht.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- Docker + Docker Compose (v2, das im Docker Desktop/Docker Engine
|
||||
@@ -37,21 +136,22 @@ Werte eintragen:
|
||||
|
||||
| Variable | Woher |
|
||||
|---|---|
|
||||
| `NEXT_PUBLIC_SUPABASE_URL` | Supabase-Projekt → Settings → API |
|
||||
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase-Projekt → Settings → API |
|
||||
| `SUPABASE_SERVICE_ROLE_KEY` | Supabase-Projekt → Settings → API (geheim!) |
|
||||
| `DATABASE_URL` | Verbindungsstring der PostgreSQL-Instanz. Die Rolle darf **kein** `BYPASSRLS` haben; hinter einem Pooler den **Transaktions-Modus** (bei Supabase Port 6543) |
|
||||
| `DATABASE_SSL` | nur setzen (`false`), wenn die Datenbank ohne TLS läuft |
|
||||
| `AUTH_SECRET` | selbst generieren: `openssl rand -base64 32` |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_ID` | Entra-Portal → App-Registrierung → Übersicht |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | Entra-Portal → Zertifikate & Geheimnisse (nur einmal sichtbar!) |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_ISSUER` | `https://login.microsoftonline.com/<verzeichnis-id>/v2.0` |
|
||||
| `CRON_SECRET` | selbst generieren: `openssl rand -hex 32` |
|
||||
|
||||
Details zur Entra-Registrierung: [`docs/entra-sso.md`](docs/entra-sso.md).
|
||||
|
||||
Wichtig zum Verständnis:
|
||||
|
||||
- `NEXT_PUBLIC_*`-Variablen werden **beim Build** in das Browser-Bundle
|
||||
eingebacken (Next.js-Verhalten, nicht Docker-spezifisch). Ändern sich
|
||||
diese Werte, muss das Image **neu gebaut** werden – ein reiner Container-
|
||||
Neustart reicht nicht.
|
||||
- `SUPABASE_SERVICE_ROLE_KEY` und `CRON_SECRET` sind Server-only-Secrets.
|
||||
Sie werden bewusst **nicht** als Build-Arg übergeben (das würde sie im
|
||||
Image-Layer-History sichtbar machen), sondern erst zur Laufzeit über
|
||||
`env_file` injiziert.
|
||||
- **Nichts davon wird in das Image eingebacken.** Es gibt keine
|
||||
`NEXT_PUBLIC_*`-Variablen mehr; alle Werte liest die Anwendung zur Laufzeit
|
||||
über `env_file`. Eine Änderung braucht deshalb nur einen Neustart, keinen
|
||||
neuen Build — und dasselbe Image läuft in Test und Produktion.
|
||||
- `.env` steht schon in `.gitignore` – nicht committen.
|
||||
|
||||
## 2. Bauen und lokal testen
|
||||
@@ -82,30 +182,42 @@ docker compose up -d --build
|
||||
```
|
||||
|
||||
Alternative für CI/CD (Image einmal bauen, überall pullen): Image in einer
|
||||
Registry (GHCR, Docker Hub, …) bauen und pushen, auf dem Server nur
|
||||
Registry bauen und pushen, auf dem Server nur
|
||||
`docker compose pull && docker compose up -d` ausführen. Dafür in
|
||||
`docker-compose.yml` zusätzlich `image: <registry>/<name>:<tag>` setzen und
|
||||
den Build in der CI-Pipeline mit den `--build-arg`-Werten für
|
||||
`NEXT_PUBLIC_*` laufen lassen.
|
||||
`docker-compose.yml` zusätzlich `image: <registry>/<name>:<tag>` setzen.
|
||||
|
||||
Build-Argumente braucht es dabei **keine**: das Abbild enthält keine
|
||||
umgebungsabhängigen Werte mehr, alles kommt zur Laufzeit aus `.env`.
|
||||
Dasselbe Abbild läuft damit in Test und Produktion.
|
||||
|
||||
## 4. Reverse Proxy + HTTPS
|
||||
|
||||
Next.js selbst sollte laut den offiziellen Docs **nicht** direkt exponiert
|
||||
werden – ein Reverse Proxy übernimmt TLS, Rate-Limiting und Request-
|
||||
Validierung. Beispiel mit [Caddy](https://caddyserver.com/) (automatisches
|
||||
HTTPS via Let's Encrypt):
|
||||
Validierung.
|
||||
|
||||
```caddyfile
|
||||
# /etc/caddy/Caddyfile
|
||||
hr.example.com {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
Fertige Konfiguration: [`deploy/Caddyfile`](deploy/Caddyfile) — mit
|
||||
DNS-Challenge, weil der Server aus dem Internet nicht erreichbar ist (siehe
|
||||
[oben](#betrieb-im-firmennetz--zwei-dinge-vorab)).
|
||||
|
||||
```bash
|
||||
sudo cp deploy/Caddyfile /etc/caddy/Caddyfile
|
||||
sudo systemctl reload caddy
|
||||
```
|
||||
|
||||
`docker-compose.yml` published Port 3000 aktuell auf den Host – bei
|
||||
Verwendung eines Reverse Proxys auf demselben Host kann das Publishing auf
|
||||
`127.0.0.1:3000:3000` eingeschränkt werden, damit der Container-Port nicht
|
||||
direkt von außen erreichbar ist.
|
||||
`docker-compose.yml` veröffentlicht Port 3000 bewusst nur auf
|
||||
`127.0.0.1` — die App ist also ausschliesslich über den Proxy erreichbar,
|
||||
nicht daneben unverschlüsselt.
|
||||
|
||||
Zusätzlich in die `.env`:
|
||||
|
||||
```
|
||||
AUTH_URL=https://hr.elycon.solutions
|
||||
```
|
||||
|
||||
Ohne diesen Wert baut Auth.js seine Rückruf-Adresse aus dem, was der
|
||||
Container sieht — und das ist hinter dem Proxy nicht der Name, den der
|
||||
Browser benutzt hat.
|
||||
|
||||
## 5. Updates ausrollen
|
||||
|
||||
@@ -145,12 +257,22 @@ Single-Instance-Compose-Konfiguration ist das nicht nötig.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Login-Redirect-Loop / `proxy.ts` verhält sich falsch:** meist falsche
|
||||
`NEXT_PUBLIC_SUPABASE_URL`/`ANON_KEY` – Image neu bauen (siehe oben, diese
|
||||
Werte sind eingebacken).
|
||||
- **Anmeldung endet auf `/login?error=…`:** die Umleitungs-URI in der
|
||||
Entra-Registrierung muss exakt
|
||||
`https://<host>/api/auth/callback/microsoft-entra-id` lauten. Steht die
|
||||
Anwendung hinter einem Reverse Proxy unter einer anderen Adresse, als sie
|
||||
selbst sieht, zusätzlich `AUTH_URL` setzen.
|
||||
- **Angemeldet, aber sofort zurück auf `/login?error=no_hr_access`:** die
|
||||
Anmeldung hat funktioniert, es fehlt die Freischaltung. Es braucht eine
|
||||
`profiles`-Zeile mit `role = 'hr'` und `is_active = true` auf derselben
|
||||
Kennung, die in `app_users` steht.
|
||||
- **Cron läuft nicht:** `docker compose logs cron` – prüft, ob
|
||||
`/etc/crontabs/root` korrekt geschrieben wurde und ob `CRON_SECRET` in
|
||||
`.env` gesetzt ist (leer/fehlend führt serverseitig zu `401`).
|
||||
- **Healthcheck rot:** `docker compose logs app` – meist fehlende/falsche
|
||||
Supabase-Env-Variablen zur Laufzeit (`SUPABASE_SERVICE_ROLE_KEY`,
|
||||
Server-Komponenten).
|
||||
- **`max clients reached in session mode`:** der Verbindungsstring zeigt auf
|
||||
den Sitzungs-Modus des Poolers. Auf den Transaktions-Modus wechseln (bei
|
||||
Supabase Port 6543).
|
||||
- **Healthcheck rot:** `docker compose logs app` – meist `DATABASE_URL`
|
||||
fehlend oder nicht erreichbar. Der Pool baut die Verbindung erst beim
|
||||
ersten Zugriff auf, der Fehler steht deshalb im Log der Anfrage, nicht im
|
||||
Start-Log.
|
||||
|
||||
14
Dockerfile
14
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
|
||||
|
||||
34
README.md
34
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
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import type { CollectiveAgreement, Database, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types";
|
||||
|
||||
type ActionResult = { success: boolean; error?: string };
|
||||
type MutationFn = keyof Database["public"]["Functions"];
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
import { callFunction, runMutation, type ActionResult, type MutationFn } from "@/lib/db/rpc";
|
||||
import type { CollectiveAgreement, DienstwagenArt, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types";
|
||||
|
||||
async function callRpc(fn: MutationFn, payload: Record<string, unknown>, revalidate: string[]): Promise<ActionResult> {
|
||||
const supabase = await createClient();
|
||||
const { error } = await supabase.rpc(fn, { payload });
|
||||
if (error) return { success: false, error: error.message };
|
||||
const result = await runMutation(await currentUserId(), fn, payload);
|
||||
if (!result.success) return result;
|
||||
for (const path of revalidate) revalidatePath(path);
|
||||
return { success: true };
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function hireEmployee(payload: {
|
||||
/**
|
||||
* Wird eingegeben, nicht vergeben.
|
||||
*
|
||||
* Sie muss mit Loga und Interflex übereinstimmen; eine hier selbst gezogene
|
||||
* Nummer wäre dort unbekannt und die Person hätte in drei Systemen zwei
|
||||
* Nummern. Die Datenbank weist eine bereits vergebene Nummer ab.
|
||||
*/
|
||||
personnel_number: number;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
title_prefix?: string[];
|
||||
@@ -24,6 +29,14 @@ export async function hireEmployee(payload: {
|
||||
gender: "m" | "w";
|
||||
birth_date: string;
|
||||
sv_nummer?: string;
|
||||
/**
|
||||
* Die **private** Adresse, freiwillig.
|
||||
*
|
||||
* Sie war einmal Pflicht, weil die Spalte NOT NULL war — für eine private
|
||||
* Angabe die falsche Vorgabe: wer keine hat, musste eine erfinden. Bleibt
|
||||
* eindeutig, wenn angegeben.
|
||||
*/
|
||||
email?: string;
|
||||
phone?: string;
|
||||
position_id?: string;
|
||||
team_id?: string;
|
||||
@@ -41,16 +54,29 @@ export async function hireEmployee(payload: {
|
||||
work_days?: Weekday[];
|
||||
is_betriebsrat?: boolean;
|
||||
has_dienstwagen?: boolean;
|
||||
dienstwagen_art?: DienstwagenArt | null;
|
||||
emergency_contact_name?: string;
|
||||
emergency_contact_phone?: string;
|
||||
emergency_contact_relation?: string;
|
||||
is_laterale_fuehrung?: boolean;
|
||||
is_c_level?: boolean;
|
||||
has_kuendigungsschutz?: boolean;
|
||||
/** Nur mit dem Kennzeichen zusammen — so verlangt es chk_kuendigungsschutz_bis. */
|
||||
kuendigungsschutz_bis?: string | null;
|
||||
}): Promise<ActionResult & { employeeId?: string }> {
|
||||
const supabase = await createClient();
|
||||
const { data, error } = await supabase.rpc("hire_employee", { payload });
|
||||
if (error) return { success: false, error: error.message };
|
||||
revalidatePath("/employees");
|
||||
revalidatePath("/");
|
||||
revalidatePath("/positions");
|
||||
return { success: true, employeeId: data as string };
|
||||
// Einzige Mutation, deren Rückgabewert gebraucht wird: die neue
|
||||
// Personen-Kennung, damit die Oberfläche direkt auf die Akte springen kann.
|
||||
try {
|
||||
const employeeId = await withUser(await currentUserId(), (tx) =>
|
||||
callFunction(tx, "hire_employee", payload as Record<string, unknown>)
|
||||
);
|
||||
revalidatePath("/employees");
|
||||
revalidatePath("/");
|
||||
revalidatePath("/positions");
|
||||
return { success: true, employeeId: employeeId as string };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function terminateEmployee(payload: {
|
||||
@@ -65,8 +91,8 @@ export async function terminateEmployee(payload: {
|
||||
export async function transferEmployee(payload: {
|
||||
employee_id: string;
|
||||
effective_date: string;
|
||||
new_team_id: string;
|
||||
new_title?: string;
|
||||
/** Die Zielplanstelle; Bereich, Abteilung und Team ergeben sich aus ihrer Einheit. */
|
||||
target_position_id: string;
|
||||
}): Promise<ActionResult> {
|
||||
return callRpc("transfer_employee", payload, [`/employees/${payload.employee_id}`, "/employees"]);
|
||||
}
|
||||
@@ -103,6 +129,10 @@ export async function recordKarenzReturn(payload: {
|
||||
return_date: string;
|
||||
employment_mode: "unverändert" | "Vollzeit" | "Teilzeit";
|
||||
weekly_hours?: number;
|
||||
/** Wiedereingliederungs- oder Elternteilzeit, wenn reduziert zurückgekehrt wird. */
|
||||
reduction_reason?: string;
|
||||
/** Ende der Teilzeit, falls bekannt — nur mit einem Grund zusammen. */
|
||||
teilzeit_bis?: string;
|
||||
}): Promise<ActionResult> {
|
||||
return callRpc("record_karenz_return", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]);
|
||||
}
|
||||
@@ -117,7 +147,19 @@ export async function changeEmployeeData(payload: {
|
||||
return callRpc("change_employee_data", payload, [`/employees/${payload.employee_id}`, "/employees"]);
|
||||
}
|
||||
|
||||
export async function rehireEmployee(payload: { employee_id: string; rehire_date: string }): Promise<ActionResult> {
|
||||
/**
|
||||
* `position_id` ist Pflicht — die Datenbankfunktion verlangt sie seit jeher.
|
||||
*
|
||||
* Die alte Stelle taugt nicht als stille Vorgabe: sie kann inzwischen besetzt
|
||||
* oder ausgelaufen sein. Sie fehlte hier nur in der Signatur, weshalb jede
|
||||
* Wiedereinstellung an einer Meldung scheiterte, die im Dialog nicht zu
|
||||
* beheben war.
|
||||
*/
|
||||
export async function rehireEmployee(payload: {
|
||||
employee_id: string;
|
||||
rehire_date: string;
|
||||
position_id: string;
|
||||
}): Promise<ActionResult> {
|
||||
return callRpc("rehire_employee", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]);
|
||||
}
|
||||
|
||||
@@ -141,6 +183,41 @@ export async function deleteEmployeeDependent(payload: {
|
||||
return callRpc("delete_employee_dependent", payload, [`/employees/${payload.employee_id}`]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt eine irrtümlich erfasste Stammdaten- oder Vertragsänderung zurück.
|
||||
*
|
||||
* Was zurückgesetzt wird und was stehen bleibt, entscheidet die Datenbank —
|
||||
* sie prüft dabei erneut, ob der Eintrag überhaupt gelöscht werden darf. Die
|
||||
* Oberfläche zeigt den Knopf nur dort, wo es geht (siehe lib/history.ts);
|
||||
* kommt trotzdem eine Ablehnung zurück, wird deren Begründung angezeigt.
|
||||
*/
|
||||
export async function deleteHistoryEntry(payload: {
|
||||
history_id: string;
|
||||
employee_id: string;
|
||||
}): Promise<ActionResult> {
|
||||
return callRpc("delete_history_entry", { history_id: payload.history_id }, [`/employees/${payload.employee_id}`, "/audit"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Berichtigt Wert und/oder Datum eines Historieneintrags.
|
||||
*
|
||||
* Geändert wird nur das „Nachher" — was vor der Änderung galt, ist nicht
|
||||
* nachträglich beschliessbar. Welcher Wert danach in den Stammdaten steht,
|
||||
* leitet die Datenbank je Feld aus dem jüngsten Eintrag ab, der es trägt.
|
||||
*/
|
||||
export async function updateHistoryEntry(payload: {
|
||||
history_id: string;
|
||||
employee_id: string;
|
||||
event_date?: string;
|
||||
werte: { feld: string; nachher: string | null }[];
|
||||
}): Promise<ActionResult> {
|
||||
return callRpc(
|
||||
"update_history_entry",
|
||||
{ history_id: payload.history_id, event_date: payload.event_date, werte: payload.werte },
|
||||
[`/employees/${payload.employee_id}`, "/audit"]
|
||||
);
|
||||
}
|
||||
|
||||
export async function addEmployeeNote(payload: {
|
||||
employee_id: string;
|
||||
category: NoteCategory;
|
||||
@@ -153,23 +230,3 @@ export async function addEmployeeNote(payload: {
|
||||
export async function completeEmployeeNote(payload: { note_id: string; employee_id: string }): Promise<ActionResult> {
|
||||
return callRpc("complete_employee_note", payload, [`/employees/${payload.employee_id}`, "/"]);
|
||||
}
|
||||
|
||||
export type EmployeeSearchResult = { id: string; first_name: string; last_name: string; job_title: string; team_id: string | null };
|
||||
|
||||
// Shared by "Position besetzen" (staff an open position) and the reorg
|
||||
// workbench's "Mitarbeiter:in(nen)" multi-select — both search active/
|
||||
// on-leave employees by name or title.
|
||||
export async function searchActiveEmployees(query: string): Promise<EmployeeSearchResult[]> {
|
||||
const supabase = await createClient();
|
||||
let q = supabase
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, job_title, team_id")
|
||||
.in("status", ["Aktiv", "Karenz"])
|
||||
.limit(20);
|
||||
if (query.trim()) {
|
||||
const term = sanitizeIlikeTerm(query.trim());
|
||||
q = q.or(`first_name.ilike.%${term}%,last_name.ilike.%${term}%,job_title.ilike.%${term}%`);
|
||||
}
|
||||
const { data } = await q;
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
@@ -1,45 +1,52 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
type ActionResult = { success: boolean; error?: string };
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
import type { ActionResult } from "@/lib/db/rpc";
|
||||
|
||||
export async function saveHireDraft(payload: {
|
||||
id?: string;
|
||||
step: number;
|
||||
data: Record<string, unknown>;
|
||||
}): Promise<ActionResult & { id?: string }> {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) return { success: false, error: "Nicht angemeldet." };
|
||||
const userId = await currentUserId();
|
||||
if (!userId) return { success: false, error: "Nicht angemeldet." };
|
||||
|
||||
try {
|
||||
const id = await withUser(userId, async (tx) => {
|
||||
if (payload.id) {
|
||||
// Ob die Zeile der aufrufenden Person gehört, entscheidet die
|
||||
// Policy hire_drafts_owner — nicht eine Prüfung hier.
|
||||
await tx
|
||||
.updateTable("hire_drafts")
|
||||
.set({ step: payload.step, payload: payload.data, updated_at: new Date().toISOString() })
|
||||
.where("id", "=", payload.id)
|
||||
.execute();
|
||||
return payload.id;
|
||||
}
|
||||
|
||||
const row = await tx
|
||||
.insertInto("hire_drafts")
|
||||
.values({ created_by: userId, step: payload.step, payload: payload.data })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
return row.id;
|
||||
});
|
||||
|
||||
if (payload.id) {
|
||||
const { error } = await supabase
|
||||
.from("hire_drafts")
|
||||
.update({ step: payload.step, payload: payload.data, updated_at: new Date().toISOString() })
|
||||
.eq("id", payload.id);
|
||||
if (error) return { success: false, error: error.message };
|
||||
revalidatePath("/");
|
||||
return { success: true, id: payload.id };
|
||||
return { success: true, id };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("hire_drafts")
|
||||
.insert({ created_by: user.id, step: payload.step, payload: payload.data })
|
||||
.select("id")
|
||||
.single();
|
||||
if (error) return { success: false, error: error.message };
|
||||
revalidatePath("/");
|
||||
return { success: true, id: data.id };
|
||||
}
|
||||
|
||||
export async function deleteHireDraft(id: string): Promise<ActionResult> {
|
||||
const supabase = await createClient();
|
||||
const { error } = await supabase.from("hire_drafts").delete().eq("id", id);
|
||||
if (error) return { success: false, error: error.message };
|
||||
revalidatePath("/");
|
||||
return { success: true };
|
||||
try {
|
||||
await withUser(await currentUserId(), (tx) => tx.deleteFrom("hire_drafts").where("id", "=", id).execute());
|
||||
revalidatePath("/");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +1,67 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
type ActionResult = { success: boolean; error?: string };
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { runMutation, type ActionResult } from "@/lib/db/rpc";
|
||||
|
||||
const POSITION_PATHS = ["/positions", "/orgchart", "/"];
|
||||
|
||||
async function callRpc(
|
||||
fn: "create_position" | "delete_position",
|
||||
fn: "create_position" | "update_position" | "delete_position" | "set_position_cost_center",
|
||||
payload: Record<string, unknown>,
|
||||
revalidate: string[]
|
||||
): Promise<ActionResult> {
|
||||
const supabase = await createClient();
|
||||
const { error } = await supabase.rpc(fn, { payload });
|
||||
if (error) return { success: false, error: error.message };
|
||||
const result = await runMutation(await currentUserId(), fn, payload);
|
||||
if (!result.success) return result;
|
||||
for (const path of revalidate) revalidatePath(path);
|
||||
return { success: true };
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function createPosition(payload: {
|
||||
title: string;
|
||||
superior_employee_id: string;
|
||||
is_lead: boolean;
|
||||
team_id?: string;
|
||||
org_unit_id: string;
|
||||
job_title: string;
|
||||
is_chief: boolean;
|
||||
valid_from: string;
|
||||
}): Promise<ActionResult> {
|
||||
return callRpc("create_position", payload, POSITION_PATHS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ändert eine Planstelle.
|
||||
*
|
||||
* Weggelassene Felder bleiben, wie sie sind. `valid_to: null` beendet die
|
||||
* Befristung ausdrücklich — deshalb ist es hier `string | null` und nicht
|
||||
* optional: „nicht mitgeschickt" und „auf leer setzen" müssen unterscheidbar
|
||||
* bleiben.
|
||||
*/
|
||||
export async function updatePosition(payload: {
|
||||
position_id: string;
|
||||
org_unit_id?: string;
|
||||
job_title?: string;
|
||||
is_chief?: boolean;
|
||||
valid_from?: string;
|
||||
valid_to?: string | null;
|
||||
}): Promise<ActionResult> {
|
||||
return callRpc("update_position", payload, POSITION_PATHS);
|
||||
}
|
||||
|
||||
export async function deletePosition(positionId: string): Promise<ActionResult> {
|
||||
return callRpc("delete_position", { position_id: positionId }, POSITION_PATHS);
|
||||
}
|
||||
|
||||
export type SuperiorSearchResult = { id: string; first_name: string; last_name: string; job_title: string; division_id: string };
|
||||
|
||||
// For "Position ausschreiben": superior lookup, filtered to team-leads when
|
||||
// the new position is an IC role, or to division-heads/CEO when the new
|
||||
// position is itself a team lead (§2).
|
||||
export async function searchSuperiors(query: string, forLeadPosition: boolean): Promise<SuperiorSearchResult[]> {
|
||||
const supabase = await createClient();
|
||||
let q = supabase
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, job_title, division_id")
|
||||
.eq("status", "Aktiv")
|
||||
.limit(20);
|
||||
q = forLeadPosition ? q.lte("org_level", 1) : q.eq("is_lead", true).eq("org_level", 2);
|
||||
if (query.trim()) {
|
||||
const term = sanitizeIlikeTerm(query.trim());
|
||||
q = q.or(`first_name.ilike.%${term}%,last_name.ilike.%${term}%,job_title.ilike.%${term}%`);
|
||||
}
|
||||
const { data } = await q;
|
||||
return data ?? [];
|
||||
/**
|
||||
* Kontiert eine Planstelle auf eine andere Kostenstelle — zum Stichtag.
|
||||
*
|
||||
* Kein Feld im Änderungsdialog, sondern ein eigener Vorgang, weil es einer
|
||||
* ist: die laufende Kontierung wird beendet, die neue beginnt. Die alte
|
||||
* einfach zu überschreiben hiesse, die Kosten der Vergangenheit nachträglich
|
||||
* anderswohin zu buchen.
|
||||
*/
|
||||
export async function setPositionCostCenter(payload: {
|
||||
position_id: string;
|
||||
cost_center_id: string;
|
||||
valid_from: string;
|
||||
}): Promise<ActionResult> {
|
||||
return callRpc("set_position_cost_center", payload, POSITION_PATHS);
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
type ActionResult = { success: boolean; error?: string };
|
||||
|
||||
export type ReorgMovePayload = {
|
||||
kind: "emp" | "team" | "abt" | "dept";
|
||||
label: string;
|
||||
employee_ids: string[];
|
||||
target_team_id: string;
|
||||
};
|
||||
|
||||
export async function applyReorg(payload: {
|
||||
name: string;
|
||||
effective_date: string;
|
||||
moves: ReorgMovePayload[];
|
||||
}): Promise<ActionResult & { scenarioId?: string }> {
|
||||
const supabase = await createClient();
|
||||
const { data, error } = await supabase.rpc("apply_reorg", { payload });
|
||||
if (error) return { success: false, error: error.message };
|
||||
revalidatePath("/orgchart");
|
||||
revalidatePath("/employees");
|
||||
revalidatePath("/");
|
||||
return { success: true, scenarioId: data as string };
|
||||
}
|
||||
|
||||
export async function undoReorg(payload: { scenario_id: string }): Promise<ActionResult> {
|
||||
const supabase = await createClient();
|
||||
const { error } = await supabase.rpc("undo_reorg", { payload });
|
||||
if (error) return { success: false, error: error.message };
|
||||
revalidatePath("/orgchart");
|
||||
revalidatePath("/employees");
|
||||
revalidatePath("/");
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,27 +1,31 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
type ActionResult = { success: boolean; error?: string };
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
import type { ActionResult } from "@/lib/db/rpc";
|
||||
|
||||
export async function saveReport(payload: { name: string; config: Record<string, unknown> }): Promise<ActionResult> {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) return { success: false, error: "Nicht angemeldet." };
|
||||
const userId = await currentUserId();
|
||||
if (!userId) return { success: false, error: "Nicht angemeldet." };
|
||||
|
||||
const { error } = await supabase.from("saved_reports").insert({ created_by: user.id, name: payload.name, config: payload.config });
|
||||
if (error) return { success: false, error: error.message };
|
||||
revalidatePath("/reports");
|
||||
return { success: true };
|
||||
try {
|
||||
await withUser(userId, (tx) =>
|
||||
tx.insertInto("saved_reports").values({ created_by: userId, name: payload.name, config: payload.config }).execute()
|
||||
);
|
||||
revalidatePath("/reports");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteReport(id: string): Promise<ActionResult> {
|
||||
const supabase = await createClient();
|
||||
const { error } = await supabase.from("saved_reports").delete().eq("id", id);
|
||||
if (error) return { success: false, error: error.message };
|
||||
revalidatePath("/reports");
|
||||
return { success: true };
|
||||
try {
|
||||
await withUser(await currentUserId(), (tx) => tx.deleteFrom("saved_reports").where("id", "=", id).execute());
|
||||
revalidatePath("/reports");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { AuditDetail } from "@/components/audit/AuditDetail";
|
||||
import { AuditFilters } from "@/components/audit/AuditFilters";
|
||||
import { CARD_CLASS } from "@/components/ui/Card";
|
||||
import { Pagination } from "@/components/ui/Pagination";
|
||||
import { actionBadgeStyle } from "@/lib/colors";
|
||||
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
@@ -34,33 +35,50 @@ const dateTimeFormatter = new Intl.DateTimeFormat("de-AT", {
|
||||
|
||||
export default async function AuditPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||
const params = await searchParams;
|
||||
const supabase = await createClient();
|
||||
|
||||
const page = Math.max(1, Number(params.page ?? "1") || 1);
|
||||
const from = (page - 1) * PAGE_SIZE;
|
||||
const to = from + PAGE_SIZE - 1;
|
||||
|
||||
let query = supabase
|
||||
.from("audit_log")
|
||||
.select("id, occurred_at, actor_name, action, target_label, target_employee_id, details", { count: "exact" })
|
||||
.order("occurred_at", { ascending: false })
|
||||
.range(from, to);
|
||||
const { entries, count } = await withUser(await currentUserId(), async (tx) => {
|
||||
const base = () => {
|
||||
let q = tx.selectFrom("audit_log");
|
||||
if (params.action) q = q.where("action", "=", params.action);
|
||||
if (params.q) {
|
||||
// Als Parameter gebunden statt in die Abfrage geschrieben: die
|
||||
// Zeichen, die in der alten Filtersyntax ausbrechen konnten, haben
|
||||
// hier keine Bedeutung mehr.
|
||||
const like = `%${params.q.trim()}%`;
|
||||
q = q.where((eb) =>
|
||||
eb.or([eb("target_label", "ilike", like), eb("details", "ilike", like), eb("actor_name", "ilike", like)])
|
||||
);
|
||||
}
|
||||
return q;
|
||||
};
|
||||
|
||||
if (params.action) query = query.eq("action", params.action);
|
||||
if (params.q) {
|
||||
const q = sanitizeIlikeTerm(params.q.trim());
|
||||
query = query.or(`target_label.ilike.%${q}%,details.ilike.%${q}%,actor_name.ilike.%${q}%`);
|
||||
}
|
||||
const [entries, total] = await Promise.all([
|
||||
base()
|
||||
.select(["id", "occurred_at", "actor_name", "action", "target_label", "target_employee_id", "details", "changes"])
|
||||
// Nach id als zweitem Kriterium: bei gleichem Zeitstempel wäre die
|
||||
// Reihenfolge sonst unbestimmt und ein Eintrag könnte auf zwei Seiten
|
||||
// erscheinen oder auf keiner.
|
||||
.orderBy("occurred_at", "desc")
|
||||
.orderBy("id", "desc")
|
||||
.limit(PAGE_SIZE)
|
||||
.offset((page - 1) * PAGE_SIZE)
|
||||
.execute(),
|
||||
base()
|
||||
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
|
||||
.executeTakeFirst(),
|
||||
]);
|
||||
return { entries, count: Number(total?.anzahl ?? 0) };
|
||||
});
|
||||
|
||||
const { data: entries, count } = await query;
|
||||
const totalPages = Math.max(1, Math.ceil((count ?? 0) / PAGE_SIZE));
|
||||
const totalPages = Math.max(1, Math.ceil(count / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Suspense>
|
||||
<AuditFilters />
|
||||
</Suspense>
|
||||
<p className="text-sm text-ink-muted">{count ?? 0} Einträge</p>
|
||||
<p className="text-sm text-ink-muted">{count} Einträge</p>
|
||||
|
||||
<div className={`overflow-x-auto ${CARD_CLASS}`}>
|
||||
<table className="w-full min-w-[800px] text-sm">
|
||||
@@ -74,7 +92,7 @@ export default async function AuditPage({ searchParams }: { searchParams: Promis
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(entries ?? []).map((entry) => {
|
||||
{entries.map((entry) => {
|
||||
return (
|
||||
<tr key={entry.id} className="border-b border-border-subtle transition-colors last:border-0 hover:bg-brand-50">
|
||||
<td className="whitespace-nowrap px-4 py-2.5 tabular-nums text-ink-body">
|
||||
@@ -98,11 +116,13 @@ export default async function AuditPage({ searchParams }: { searchParams: Promis
|
||||
entry.target_label
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-ink-muted">{entry.details ?? "–"}</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<AuditDetail eintrag={entry} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{(entries ?? []).length === 0 && (
|
||||
{entries.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-sm text-ink-muted">
|
||||
Keine Einträge gefunden.
|
||||
|
||||
@@ -1,74 +1,54 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { EmployeeDetail } from "@/components/employees/EmployeeDetail";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
import { todayIso } from "@/lib/format";
|
||||
import { breadcrumbLabel } from "@/lib/org";
|
||||
import { loadEmployeeDetail } from "@/lib/employee-detail-data";
|
||||
|
||||
type PageProps = { params: Promise<{ id: string }> };
|
||||
|
||||
type EmployeeWithManager = Database["public"]["Tables"]["employees"]["Row"] & {
|
||||
manager: { id: string; first_name: string; last_name: string; job_title: string } | null;
|
||||
};
|
||||
|
||||
export default async function EmployeeDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const supabase = await createClient();
|
||||
const today = todayIso();
|
||||
|
||||
// Everything here keys off the id already in the URL, and the manager
|
||||
// comes back as an embedded resource on the employee row rather than as a
|
||||
// follow-up query — so the page is one round trip instead of two. Measured
|
||||
// against the hosted database that halved the data time (120ms -> 62ms,
|
||||
// median of five), because a round trip costs more than these queries do.
|
||||
//
|
||||
// The hand-written Database type carries no relationship metadata
|
||||
// (NoRelationships), so the embed is typed at the destructure below.
|
||||
const [
|
||||
{ data: employeeRow },
|
||||
{ data: directReports },
|
||||
{ data: history },
|
||||
{ data: dependents },
|
||||
{ data: notes },
|
||||
{ data: divisions },
|
||||
{ data: departments },
|
||||
{ data: teams },
|
||||
{ data: locations },
|
||||
{ data: openPositions },
|
||||
] = await Promise.all([
|
||||
supabase.from("employees").select("*, manager:manager_id(id, first_name, last_name, job_title)").eq("id", id).single(),
|
||||
supabase.from("employees").select("id, first_name, last_name, job_title, status").eq("manager_id", id).order("last_name"),
|
||||
supabase
|
||||
.from("employee_history")
|
||||
.select("*")
|
||||
.eq("employee_id", id)
|
||||
.order("event_date", { ascending: false })
|
||||
.order("created_at", { ascending: false }),
|
||||
supabase.from("employee_dependents").select("*").eq("employee_id", id).order("created_at"),
|
||||
supabase.from("employee_notes").select("*").eq("employee_id", id).order("created_at", { ascending: false }),
|
||||
supabase.from("divisions").select("*").order("name"),
|
||||
supabase.from("departments").select("*"),
|
||||
supabase.from("teams").select("*"),
|
||||
supabase.from("locations").select("*").order("name"),
|
||||
supabase.from("positions").select("id, position_number, title, team_id, is_lead").eq("status", "open"),
|
||||
]);
|
||||
// Was die Akte liest und in wie vielen Rundreisen, steht in
|
||||
// lib/employee-detail-data.ts.
|
||||
const data = await withUser(await currentUserId(), (tx) => loadEmployeeDetail(tx, id, today));
|
||||
|
||||
if (!employeeRow) notFound();
|
||||
|
||||
// Split the embedded manager back off so EmployeeDetail keeps receiving a
|
||||
// plain employees row plus a separate manager, unchanged.
|
||||
const { manager, ...employee } = employeeRow as EmployeeWithManager;
|
||||
if (!data) notFound();
|
||||
const { employee, line, reports, history, dependents, notes, orgMaps, placement, kostenstelle, openPositions, byId } = data;
|
||||
|
||||
return (
|
||||
<EmployeeDetail
|
||||
employee={employee}
|
||||
manager={manager ?? null}
|
||||
directReports={directReports ?? []}
|
||||
placement={
|
||||
placement && {
|
||||
positionNumber: placement.positionNumber,
|
||||
jobTitle: placement.jobTitle,
|
||||
isChief: placement.isChief,
|
||||
current: placement.current,
|
||||
}
|
||||
}
|
||||
breadcrumb={breadcrumbLabel(orgMaps, placement?.orgUnitId)}
|
||||
kostenstelle={kostenstelle}
|
||||
manager={(line?.acting_manager_id ? byId.get(line.acting_manager_id) : null) ?? null}
|
||||
// Nur wenn eine Vertretung im Spiel ist — sonst stünde dieselbe Person
|
||||
// zweimal da.
|
||||
formalManager={
|
||||
line?.formal_manager_id && line.formal_manager_id !== line.acting_manager_id
|
||||
? (byId.get(line.formal_manager_id) ?? null)
|
||||
: null
|
||||
}
|
||||
directReports={reports.flatMap((r) => {
|
||||
const e = byId.get(r.employee_id);
|
||||
return e ? [e] : [];
|
||||
})}
|
||||
history={history ?? []}
|
||||
dependents={dependents ?? []}
|
||||
notes={notes ?? []}
|
||||
divisions={divisions ?? []}
|
||||
departments={departments ?? []}
|
||||
teams={teams ?? []}
|
||||
locations={locations ?? []}
|
||||
openPositions={openPositions ?? []}
|
||||
locations={orgMaps.locationList}
|
||||
openPositions={openPositions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,16 +5,25 @@ import { Avatar } from "@/components/ui/Avatar";
|
||||
import { CARD_CLASS } from "@/components/ui/Card";
|
||||
import { Pagination } from "@/components/ui/Pagination";
|
||||
import { StatusChip } from "@/components/ui/StatusChip";
|
||||
import { applyDerivedStatusFilter } from "@/lib/employee-status-filter";
|
||||
import { fmtDate, todayIso } from "@/lib/format";
|
||||
import { breadcrumbFor, loadOrgMaps } from "@/lib/org";
|
||||
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { sql, withUser } from "@/lib/db";
|
||||
import { jsonArrayFrom, jsonObjectFrom } from "@/lib/db/json";
|
||||
import { istPersonalnummer, suchMuster } from "@/lib/employee-search";
|
||||
import { derivedStatusFilter } from "@/lib/employee-status-filter";
|
||||
import { fmtDate, fmtName, todayIso } from "@/lib/format";
|
||||
import { breadcrumbLabel, divisionOf, loadOrgMaps, subtreeOf, unitOf, type OrgEb } from "@/lib/org";
|
||||
import { loadPlacements } from "@/lib/placement";
|
||||
import type { EmploymentStatus } from "@/lib/supabase/types";
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
type SearchParams = { q?: string; division?: string; status?: string; location?: string; page?: string };
|
||||
type SearchParams = {
|
||||
q?: string;
|
||||
division?: string;
|
||||
status?: string;
|
||||
location?: string;
|
||||
page?: string;
|
||||
};
|
||||
|
||||
type EmployeesPageProps = {
|
||||
searchParams: Promise<SearchParams>;
|
||||
@@ -32,55 +41,181 @@ function pageHref(params: SearchParams, page: number): string {
|
||||
|
||||
export default async function EmployeesPage({ searchParams }: EmployeesPageProps) {
|
||||
const params = await searchParams;
|
||||
const supabase = await createClient();
|
||||
|
||||
const page = Math.max(1, Number(params.page ?? "1") || 1);
|
||||
const from = (page - 1) * PAGE_SIZE;
|
||||
const to = from + PAGE_SIZE - 1;
|
||||
const today = todayIso();
|
||||
|
||||
let query = supabase
|
||||
.from("employees")
|
||||
.select(
|
||||
"id, first_name, last_name, personnel_number, job_title, team_id, division_id, location_id, entry_date, employment_type, weekly_hours, status, absence_type",
|
||||
{ count: "exact" }
|
||||
)
|
||||
.order("last_name", { ascending: true })
|
||||
.range(from, to);
|
||||
|
||||
if (params.q) {
|
||||
const q = params.q.trim();
|
||||
if (/^\d+$/.test(q)) {
|
||||
query = query.eq("personnel_number", Number(q));
|
||||
} else {
|
||||
const term = sanitizeIlikeTerm(q);
|
||||
query = query.or(`first_name.ilike.%${term}%,last_name.ilike.%${term}%,job_title.ilike.%${term}%`);
|
||||
}
|
||||
}
|
||||
if (params.division) query = query.eq("division_id", params.division);
|
||||
// Comma-separated, so a dashboard tile can link here with the same
|
||||
// status set it counted rather than a narrower one.
|
||||
const statuses = (params.status ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s): s is EmploymentStatus => (["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const).includes(s as EmploymentStatus));
|
||||
// Derived from the dates, not read off employees.status — see
|
||||
// lib/employee-status-filter.ts for why the two can disagree.
|
||||
query = applyDerivedStatusFilter(query, statuses, todayIso());
|
||||
if (params.location) query = query.eq("location_id", params.location);
|
||||
|
||||
// The org lookup tables are needed only to label the rows, so they load
|
||||
// alongside the page of employees instead of before it — one round trip
|
||||
// saved on a page that is otherwise two fast queries.
|
||||
const [orgMaps, { data: employeesData, count }] = await Promise.all([loadOrgMaps(supabase), query]);
|
||||
const employees = employeesData ?? [];
|
||||
const totalPages = Math.max(1, Math.ceil((count ?? 0) / PAGE_SIZE));
|
||||
const { orgMaps, employees, count, placements, ueberPosition } = await withUser(await currentUserId(), async (tx) => {
|
||||
// Die Referenzdaten zuerst: der Bereichsfilter braucht den Teilbaum.
|
||||
// „Produktion" meint die Abteilungen und Teams darunter — in der Einheit
|
||||
// selbst sitzt nur die Bereichsleitung.
|
||||
const orgMaps = await loadOrgMaps(tx);
|
||||
const unitFilter = params.division && orgMaps.units.has(params.division) ? params.division : null;
|
||||
|
||||
// Eine Filterkette, zwei Abfragen: eine für die Seite, eine für die
|
||||
// Gesamtzahl. Am direkten Zugang teilen sie sich denselben Aufbau —
|
||||
// vorher brauchte es zwei getrennte Select-Formen, weil der Typparser der
|
||||
// API-Schicht einen bedingt zusammengesetzten Select-String nicht
|
||||
// auflösen konnte.
|
||||
// Der Ausdrucksbauer wird durchgereicht, damit dieselbe Filterkette
|
||||
// einmal für die Seite und einmal für die Zählung in *einer* Abfrage
|
||||
// stehen kann.
|
||||
const base = (nurNamen: boolean, eb: OrgEb = tx as never) => {
|
||||
let q = eb.selectFrom("employees");
|
||||
|
||||
if (unitFilter) {
|
||||
// Nach Organisationseinheit gefiltert wird über die *laufende*
|
||||
// Besetzung. Als EXISTS, damit eine Person nicht mehrfach erscheint,
|
||||
// wenn sie über die Zeit mehrere Zuordnungen hatte.
|
||||
const units = subtreeOf(orgMaps, unitFilter);
|
||||
q = q.where((eb) =>
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom("position_assignments as a")
|
||||
.innerJoin("om_positions as p", "p.id", "a.position_id")
|
||||
.select("a.id")
|
||||
.whereRef("a.employee_id", "=", "employees.id")
|
||||
.where("a.valid_to", "is", null)
|
||||
.where("p.org_unit_id", "in", units)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (params.q) {
|
||||
const term = params.q.trim();
|
||||
// `\d`, nicht `d`: der fehlende Backslash liess die Ziffernerkennung
|
||||
// nie greifen — „1590" wurde als Name gesucht und fand nichts,
|
||||
// während das Muster auf „ddd" ansprang.
|
||||
if (istPersonalnummer(term)) {
|
||||
q = q.where("personnel_number", "=", Number(term));
|
||||
} else {
|
||||
// ── Wie hier gesucht wird ──────────────────────────────────
|
||||
//
|
||||
// **Wortweise, Reihenfolge egal.** Jedes Wort muss treffen, aber
|
||||
// nicht in einer bestimmten Ordnung: „Winkler micha" und „micha
|
||||
// Winkler" führen beide zu Michaela Winkler. Am Stück gesucht stand
|
||||
// „Michael Winkler" in keinem einzelnen Feld und ergab null Treffer,
|
||||
// obwohl es vierzehn Winkler gibt.
|
||||
//
|
||||
// **Am Wortanfang, nicht mittendrin.** Als Teilzeichenkette traf ein
|
||||
// „H" auf T-h-omas und Kat-h-arina — bei „Winkler H" kamen alle
|
||||
// sieben Winkler zurück. Trennzeichen zählen als Wortgrenze, damit
|
||||
// „dreher" auch „CNC-Dreher:in" findet.
|
||||
//
|
||||
// **Namen vor Positionen.** Die Position mitzudurchsuchen ist
|
||||
// nützlich („dreher"), darf aber eine Namenssuche nicht verwässern:
|
||||
// bei „Winkler M" tauchten sonst Karin Winkler (Montagemitarbeiterin)
|
||||
// und Katharina Winkler (Maschinenbedienerin) auf, weil ihre
|
||||
// Position mit M beginnt. Deshalb wird zuerst nur über die Namen
|
||||
// gesucht; nur wenn das *nichts* findet, kommt die Position dazu.
|
||||
// Eine feste Mindestlänge fürs Wort wäre die einfachere Regel, aber
|
||||
// jede Grenze wäre geraten — diese hier ergibt sich aus den Daten.
|
||||
//
|
||||
// Der Trigramm-Index auf dem zusammengesetzten Namen greift bei
|
||||
// diesem Ausdruck nicht mehr. Bei knapp neunhundert Zeilen liest
|
||||
// Postgres die Tabelle in wenigen Millisekunden; ein Index auf
|
||||
// demselben Ausdruck holt das zurück, sobald das nicht mehr stimmt.
|
||||
const heuhaufen = nurNamen
|
||||
? sql<string>`translate(lower(first_name || ' ' || last_name), '-/:.,', ' ')`
|
||||
: sql<string>`translate(lower(first_name || ' ' || last_name || ' ' || job_title), '-/:.,', ' ')`;
|
||||
q = q.where((eb) =>
|
||||
eb.and(
|
||||
// Als Parameter gebunden, nicht in die Abfrage geschrieben.
|
||||
suchMuster(term).map(([amAnfang, nachLeerzeichen]) =>
|
||||
eb.or([eb(heuhaufen, "like", amAnfang), eb(heuhaufen, "like", nachLeerzeichen)])
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Derived from the dates, not read off employees.status — see
|
||||
// lib/employee-status-filter.ts for why the two can disagree.
|
||||
if (statuses.length > 0) {
|
||||
q = q.where((eb) => derivedStatusFilter(eb, statuses, today) ?? eb.val(true));
|
||||
}
|
||||
|
||||
if (params.location) q = q.where("location_id", "=", params.location);
|
||||
return q;
|
||||
};
|
||||
|
||||
// Erst nachsehen, ob die Namen allein etwas hergeben. Nur wenn nicht,
|
||||
// wird die Position mitgesucht — eine zusätzliche, sehr kleine Abfrage,
|
||||
// und nur bei einer Textsuche.
|
||||
const sucheNachNamen = Boolean(params.q) && !istPersonalnummer(params.q!.trim());
|
||||
const namensTreffer = sucheNachNamen
|
||||
? Number((await base(true).select(({ fn }) => fn.countAll<string>().as("anzahl")).executeTakeFirst())?.anzahl ?? 0)
|
||||
: 0;
|
||||
const nurNamen = sucheNachNamen && namensTreffer > 0;
|
||||
|
||||
// Seite und Gesamtzahl in *einer* Rundreise. Als Promise.all sah das nach
|
||||
// Gleichzeitigkeit aus und war keine: eine Transaktion hängt an einer
|
||||
// Verbindung, und darüber laufen Abfragen nacheinander (lib/db/json.ts).
|
||||
const { rows, total } = await tx
|
||||
.selectNoFrom((eb) => [
|
||||
jsonArrayFrom(
|
||||
base(nurNamen, eb)
|
||||
.select([
|
||||
"id",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"personnel_number",
|
||||
"job_title",
|
||||
"location_id",
|
||||
"entry_date",
|
||||
"employment_type",
|
||||
"weekly_hours",
|
||||
"status",
|
||||
"absence_type",
|
||||
])
|
||||
// Nach id als zweitem Kriterium: bei gleichem Nachnamen wäre die
|
||||
// Reihenfolge sonst unbestimmt, und dieselbe Person könnte auf zwei
|
||||
// Seiten erscheinen oder auf keiner.
|
||||
.orderBy("last_name")
|
||||
.orderBy("id")
|
||||
.limit(PAGE_SIZE)
|
||||
.offset((page - 1) * PAGE_SIZE)
|
||||
).as("rows"),
|
||||
jsonObjectFrom(base(nurNamen, eb).select(({ fn }) => fn.countAll<string>().as("anzahl"))).as("total"),
|
||||
])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
// Die Einordnung kommt über die Planstelle — nur für die 15 Zeilen dieser
|
||||
// Seite, nicht für den ganzen Bestand.
|
||||
const placements = await loadPlacements(tx, { asOf: today, employeeIds: rows.map((e) => e.id) });
|
||||
|
||||
return {
|
||||
orgMaps,
|
||||
employees: rows,
|
||||
count: Number(total?.anzahl ?? 0),
|
||||
placements,
|
||||
// Für den Hinweis über der Liste: wurde nach Namen gesucht, und hat es
|
||||
// gereicht?
|
||||
ueberPosition: sucheNachNamen && !nurNamen,
|
||||
};
|
||||
});
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(count / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Suspense>
|
||||
<EmployeeFilters divisions={orgMaps.divisionList} locations={orgMaps.locationList} />
|
||||
<EmployeeFilters units={orgMaps.unitList} depthOf={orgMaps.depthOf} locations={orgMaps.locationList} />
|
||||
</Suspense>
|
||||
<p className="text-sm text-ink-muted">{count ?? 0} Mitarbeiter:innen gefunden</p>
|
||||
<p className="text-sm text-ink-muted">
|
||||
{count ?? 0} Mitarbeiter:innen gefunden
|
||||
{/* Wenn kein Name passte, wurde nach der Position gesucht. Ohne diesen
|
||||
Hinweis wirkt das Ergebnis, als hätte die Suche etwas erfunden. */}
|
||||
{ueberPosition && (count ?? 0) > 0 && (
|
||||
<span className="text-ink-muted"> · kein Namenstreffer, gesucht nach Position</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className={`overflow-x-auto ${CARD_CLASS}`}>
|
||||
<table className="w-full min-w-[800px] text-sm">
|
||||
@@ -97,7 +232,9 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
||||
</thead>
|
||||
<tbody>
|
||||
{employees.map((e) => {
|
||||
const { division, team } = breadcrumbFor(orgMaps, e.division_id, e.team_id);
|
||||
const placement = placements.get(e.id);
|
||||
const division = divisionOf(orgMaps, placement?.orgUnitId);
|
||||
const unit = unitOf(orgMaps, placement?.orgUnitId);
|
||||
const location = e.location_id ? orgMaps.locations.get(e.location_id) : undefined;
|
||||
return (
|
||||
// border-subtle between rows: the full-strength border made
|
||||
@@ -111,9 +248,9 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
||||
<Avatar firstName={e.first_name} lastName={e.last_name} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-semibold text-ink">
|
||||
{e.first_name} {e.last_name}
|
||||
{fmtName(e.first_name, e.last_name)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-ink-muted">{e.job_title}</div>
|
||||
<div className="truncate text-xs text-ink-muted">{placement?.jobTitle ?? e.job_title}</div>
|
||||
</div>
|
||||
</Link>
|
||||
</td>
|
||||
@@ -122,7 +259,12 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
||||
<td className="px-4 py-2.5 tabular-nums text-ink-body">{e.personnel_number}</td>
|
||||
<td className="px-4 py-2.5 text-ink-body">
|
||||
<div>{division?.name ?? "–"}</div>
|
||||
<div className="text-xs text-ink-muted">{team?.name ?? "–"}</div>
|
||||
{/* Die eigene Einheit, egal auf welcher Ebene sie hängt —
|
||||
eine Bereichsleitung sitzt am Bereich, nicht an einem
|
||||
Team, und stand vorher deshalb ohne Zuordnung da. */}
|
||||
<div className="text-xs text-ink-muted" title={breadcrumbLabel(orgMaps, placement?.orgUnitId)}>
|
||||
{unit && unit.id !== division?.id ? unit.name : "–"}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-ink-body">{location?.name ?? "–"}</td>
|
||||
<td className="px-4 py-2.5 tabular-nums text-ink-body">{fmtDate(e.entry_date)}</td>
|
||||
|
||||
16
app/(app)/import/page.tsx
Normal file
16
app/(app)/import/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ImportWorkbench } from "@/components/import/ImportWorkbench";
|
||||
|
||||
export const metadata = { title: "Import" };
|
||||
|
||||
export default function ImportPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="max-w-prose text-sm text-ink-muted">
|
||||
Übernahme aus einer Datei — Organisation, Planstellen, Personen, Historie und Angehörige. Angelegt wird nur;
|
||||
bestehende Datensätze werden nie überschrieben. Geprüft wird vor dem Schreiben, und geschrieben wird alles
|
||||
zusammen oder gar nichts.
|
||||
</p>
|
||||
<ImportWorkbench />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,36 +2,32 @@ import { redirect } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { HireWizardProvider } from "@/components/hire/HireWizardContext";
|
||||
import { AppShell } from "@/components/shell/AppShell";
|
||||
import { loadOpenNotes } from "@/lib/notes";
|
||||
import { loadOpenPositions } from "@/lib/positions";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
import { loadShellData } from "@/lib/shell-data";
|
||||
|
||||
export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) redirect("/login");
|
||||
const userId = await currentUserId();
|
||||
if (!userId) redirect("/login");
|
||||
|
||||
// Defense in depth: proxy.ts already redirects any non-active-HR session
|
||||
// away before this layout ever renders. Re-checking here means a gap in
|
||||
// the proxy matcher (or a future route added outside it) still fails
|
||||
// closed instead of silently granting access — see docs/security.md.
|
||||
const { data: profile } = await supabase.from("profiles").select("full_name, email, role, is_active").eq("id", user.id).maybeSingle();
|
||||
if (profile?.role !== "hr" || profile?.is_active !== true) redirect("/login");
|
||||
// Alles in *einer* Transaktion, weil nur dort der Sitzungskontext gilt —
|
||||
// und damit nebenbei auf einem einheitlichen Lesestand. Was dabei in wie
|
||||
// vielen Rundreisen gelesen wird, steht in lib/shell-data.ts.
|
||||
const data = await withUser(userId, (tx) => loadShellData(tx, userId));
|
||||
|
||||
const userLabel = profile.full_name || profile.email || user.email || "";
|
||||
// `data` ist null, wenn die Person angemeldet, aber nicht freigeschaltet
|
||||
// ist. Hier — und nicht im Proxy — fällt diese Entscheidung: der Proxy hat
|
||||
// keine Datenbankverbindung. Sie wird bei jedem Aufbau frisch gestellt, eine
|
||||
// entzogene Freischaltung wirkt also sofort statt erst mit dem nächsten
|
||||
// Sitzungstoken. Ohne den Grund in der Adresse stünde die Person vor einer
|
||||
// wortlosen Anmeldeseite und versuchte es endlos erneut.
|
||||
if (!data) redirect("/login?error=no_hr_access");
|
||||
|
||||
const [openPositions, locationsRes, draftsRes, openNotes] = await Promise.all([
|
||||
loadOpenPositions(supabase),
|
||||
supabase.from("locations").select("id, name, country").order("name"),
|
||||
supabase.from("hire_drafts").select("id, step, payload, updated_at").eq("created_by", user.id).order("updated_at", { ascending: false }),
|
||||
loadOpenNotes(supabase),
|
||||
]);
|
||||
const userLabel = data.profile.full_name || data.profile.email || "";
|
||||
|
||||
return (
|
||||
<HireWizardProvider openPositions={openPositions} locations={locationsRes.data ?? []} drafts={draftsRes.data ?? []}>
|
||||
<AppShell userLabel={userLabel} openNotes={openNotes}>
|
||||
<HireWizardProvider openPositions={data.openPositions} locations={data.locations} drafts={data.drafts}>
|
||||
<AppShell userLabel={userLabel} openNotes={data.openNotes}>
|
||||
{children}
|
||||
</AppShell>
|
||||
</HireWizardProvider>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Suspense } from "react";
|
||||
import { OrgChartClient } from "@/components/orgchart/OrgChartClient";
|
||||
import type { OrgUnitNode } from "@/components/orgchart/types";
|
||||
import { todayIso } from "@/lib/format";
|
||||
import { loadOrgAsOf } from "@/lib/orgchart-data";
|
||||
import { loadOpenPositions } from "@/lib/positions";
|
||||
import { parseIsoDateParam } from "@/lib/reports";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
|
||||
type SearchParams = { asOf?: string; focus?: string };
|
||||
|
||||
@@ -18,32 +19,20 @@ export default async function OrgChartPage({ searchParams }: { searchParams: Pro
|
||||
// so a junk value can't reach the client as an arbitrary string.
|
||||
const focusId = params.focus && UUID.test(params.focus) ? params.focus : null;
|
||||
|
||||
const supabase = await createClient();
|
||||
|
||||
const [org, { data: divisions }, { data: departments }, { data: teams }, openPositions, { data: reorgScenarios }] =
|
||||
await Promise.all([
|
||||
loadOrgAsOf(supabase, asOf),
|
||||
supabase.from("divisions").select("*").order("name"),
|
||||
supabase.from("departments").select("*"),
|
||||
supabase.from("teams").select("*"),
|
||||
loadOpenPositions(supabase),
|
||||
supabase
|
||||
.from("reorg_scenarios")
|
||||
.select("id, name, effective_date, applied, applied_at")
|
||||
.eq("applied", true)
|
||||
.order("applied_at", { ascending: false })
|
||||
.limit(5),
|
||||
const { org, units } = await withUser(await currentUserId(), async (tx) => {
|
||||
const [org, units] = await Promise.all([
|
||||
loadOrgAsOf(tx, asOf),
|
||||
tx.selectFrom("org_units").select(["id", "org_number", "name", "parent_id", "unit_type"]).orderBy("org_number").execute(),
|
||||
]);
|
||||
return { org, units };
|
||||
});
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
<OrgChartClient
|
||||
employees={org.employees}
|
||||
divisions={divisions ?? []}
|
||||
departments={departments ?? []}
|
||||
teams={teams ?? []}
|
||||
openPositions={openPositions}
|
||||
reorgScenarios={reorgScenarios ?? []}
|
||||
units={units as OrgUnitNode[]}
|
||||
vacancies={org.vacancies}
|
||||
asOf={asOf}
|
||||
today={today}
|
||||
projectedCount={org.projectedCount}
|
||||
|
||||
43
app/(app)/orgchart/print/page.tsx
Normal file
43
app/(app)/orgchart/print/page.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { PrintChart } from "@/components/orgchart/PrintChart";
|
||||
import type { OrgUnitNode } from "@/components/orgchart/types";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
import { todayIso } from "@/lib/format";
|
||||
import { loadOrgAsOf } from "@/lib/orgchart-data";
|
||||
import { buildPrintModel } from "@/lib/orgchart-print";
|
||||
import { parseIsoDateParam } from "@/lib/reports";
|
||||
|
||||
export const metadata = { title: "Organigramm drucken" };
|
||||
|
||||
type SearchParams = { asOf?: string };
|
||||
|
||||
// Eigene Route statt eines Druckstils auf der Organigramm-Seite.
|
||||
//
|
||||
// Die interaktive Ansicht ist eine Leinwand mit Zoom und Verschiebung; was
|
||||
// davon auf Papier landet, hängt vom Zufallsstand der Ansicht ab. Hier wird
|
||||
// stattdessen aus denselben Daten eine Seitenfolge gebaut, und der Stichtag
|
||||
// wird aus der Adresse übernommen — wer im Organigramm einen Stichtag
|
||||
// eingestellt hat, druckt genau den.
|
||||
export default async function OrgChartPrintPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||
const params = await searchParams;
|
||||
const today = todayIso();
|
||||
const asOf = parseIsoDateParam(params.asOf) ?? today;
|
||||
|
||||
const { org, units } = await withUser(await currentUserId(), async (tx) => {
|
||||
const [org, units] = await Promise.all([
|
||||
loadOrgAsOf(tx, asOf),
|
||||
tx
|
||||
.selectFrom("org_units")
|
||||
.select(["id", "org_number", "name", "parent_id", "unit_type"])
|
||||
.where((eb) => eb.or([eb("valid_to", "is", null), eb("valid_to", ">", asOf)]))
|
||||
.where("valid_from", "<=", asOf)
|
||||
.orderBy("org_number")
|
||||
.execute(),
|
||||
]);
|
||||
return { org, units };
|
||||
});
|
||||
|
||||
const model = buildPrintModel(units as OrgUnitNode[], org.employees, org.vacancies);
|
||||
|
||||
return <PrintChart model={model} asOf={asOf} today={today} />;
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { AnstehendFilter } from "@/components/dashboard/AnstehendFilter";
|
||||
import { DraftsCard } from "@/components/dashboard/DraftsCard";
|
||||
import { Card, CARD_CLASS, CardTitle } from "@/components/ui/Card";
|
||||
import { actionBadgeStyle } from "@/lib/colors";
|
||||
import { addDaysIso, fmtDate, todayIso } from "@/lib/format";
|
||||
import { istEingeschraenkt, parseArten, parseZeitraum } from "@/lib/dashboard-filter";
|
||||
import { loadDashboardData } from "@/lib/dashboard-data";
|
||||
import { addDaysIso, fmtDate, fmtName, todayIso } from "@/lib/format";
|
||||
import { divisionOf } from "@/lib/org";
|
||||
import { deriveStatusAsOf } from "@/lib/reports";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { fetchAllRows } from "@/lib/supabase/query";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
|
||||
// Each KPI carries a colour already; the accent bar repeats it in a second
|
||||
// channel so the tiles are scannable as a row rather than six identical
|
||||
@@ -33,21 +38,18 @@ const DOT_STYLES: Record<string, string> = {
|
||||
Gehaltsanpassung: "bg-warning-text",
|
||||
};
|
||||
|
||||
const KIND_LABEL = { hire: "Eintritt", exit: "Austritt", return: "Rückkehr aus Abwesenheit" } as const;
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
const { data: drafts } = user
|
||||
? await supabase
|
||||
.from("hire_drafts")
|
||||
.select("id, step, payload, updated_at")
|
||||
.eq("created_by", user.id)
|
||||
.order("updated_at", { ascending: false })
|
||||
: { data: [] };
|
||||
const KIND_LABEL = {
|
||||
hire: "Eintritt",
|
||||
exit: "Austritt",
|
||||
return: "Rückkehr aus Abwesenheit",
|
||||
note: "Wiedervorlage",
|
||||
} as const;
|
||||
|
||||
export default async function DashboardPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ tage?: string; arten?: string }>;
|
||||
}) {
|
||||
// Built as strings, not by round-tripping a local Date through
|
||||
// toISOString(): in any positive-offset zone new Date(year, 0, 1) is still
|
||||
// the previous year in UTC, which shifted the whole YTD window a day early
|
||||
@@ -56,7 +58,16 @@ export default async function DashboardPage() {
|
||||
const year = today.slice(0, 4);
|
||||
const yearStart = `${year}-01-01`;
|
||||
const yearEnd = `${year}-12-31`;
|
||||
const in60Iso = addDaysIso(today, 60);
|
||||
|
||||
// Der Vorschauzeitraum ist einstellbar, und mit ihm, was überhaupt geladen
|
||||
// wird. Deshalb steht die Auswahl in der Adresse und nicht im Browser: 90
|
||||
// statt 60 Tage bringt Zeilen ins Spiel, die sonst nirgends lägen.
|
||||
const params = await searchParams;
|
||||
const zeitraum = parseZeitraum(params.tage);
|
||||
const arten = parseArten(params.arten);
|
||||
const bisIso = addDaysIso(today, zeitraum);
|
||||
|
||||
const userId = await currentUserId();
|
||||
|
||||
// Headcount, FTE, Karenz and the division bars all come from one full read
|
||||
// and the *derived* status, not from the `employees.status` column.
|
||||
@@ -66,69 +77,20 @@ export default async function DashboardPage() {
|
||||
// planned hire whose start date has passed, or a Karenz that ended without
|
||||
// anyone recording the return, made the dashboard and the Berichte page
|
||||
// disagree about the same headcount. Same derivation, same numbers.
|
||||
// It also replaces four separate count queries with one.
|
||||
const [
|
||||
const {
|
||||
drafts,
|
||||
staffRows,
|
||||
hiresYtdRes,
|
||||
exitsYtdRes,
|
||||
openPositionsRes,
|
||||
divisionsRes,
|
||||
upcomingHiresRes,
|
||||
upcomingExitsRes,
|
||||
upcomingReturnsRes,
|
||||
historyRes,
|
||||
] = await Promise.all([
|
||||
fetchAllRows(() =>
|
||||
supabase
|
||||
.from("employees")
|
||||
.select("weekly_hours, division_id, entry_date, exit_date, karenz_start_date, karenz_return_date")
|
||||
.order("id")
|
||||
),
|
||||
// Entries/exits count history events, which is what the linked report
|
||||
// counts too. `entry_date` would also sweep up rehires, whose event is
|
||||
// logged as 'Wiedereintritt' — the tile and its destination then showed
|
||||
// different numbers for the same year.
|
||||
supabase
|
||||
.from("employee_history")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.in("event_type", ["Eintritt", "Wiedereintritt"])
|
||||
.gte("event_date", yearStart)
|
||||
.lte("event_date", yearEnd),
|
||||
supabase
|
||||
.from("employee_history")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.eq("event_type", "Austritt")
|
||||
.gte("event_date", yearStart)
|
||||
.lte("event_date", yearEnd),
|
||||
supabase.from("positions").select("id", { count: "exact", head: true }).eq("status", "open"),
|
||||
supabase.from("divisions").select("id, name"),
|
||||
supabase
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, entry_date")
|
||||
.eq("status", "Geplant")
|
||||
.gte("entry_date", today)
|
||||
.lte("entry_date", in60Iso),
|
||||
supabase
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, exit_date")
|
||||
.not("exit_date", "is", null)
|
||||
.gte("exit_date", today)
|
||||
.lte("exit_date", in60Iso),
|
||||
supabase
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, karenz_return_date")
|
||||
.eq("status", "Karenz")
|
||||
.not("karenz_return_date", "is", null)
|
||||
.gte("karenz_return_date", today)
|
||||
.lte("karenz_return_date", in60Iso),
|
||||
supabase
|
||||
.from("employee_history")
|
||||
.select("id, employee_id, event_date, event_type, description")
|
||||
.order("event_date", { ascending: false })
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(10),
|
||||
]);
|
||||
|
||||
hiresYtd,
|
||||
exitsYtd,
|
||||
openPositions,
|
||||
orgMaps,
|
||||
placements,
|
||||
upcomingHires,
|
||||
upcomingExits,
|
||||
upcomingReturns,
|
||||
upcomingNotes,
|
||||
history,
|
||||
} = await withUser(userId, (tx) => loadDashboardData(tx, { userId, today, yearStart, yearEnd, bisIso, arten }));
|
||||
// "Aktiv" means status Aktiv — somebody on Karenz is employed but not
|
||||
// active, and is counted by its own tile instead. FTE follows the same
|
||||
// set: Karenz contributes no capacity, so including it would overstate
|
||||
@@ -143,45 +105,69 @@ export default async function DashboardPage() {
|
||||
const karenzCount = staffRows.filter((row) => statusOf(row) === "Karenz").length;
|
||||
const fte = activeStaff.reduce((sum, row) => sum + Number(row.weekly_hours), 0) / 38.5;
|
||||
|
||||
// Der Bereich einer Person steht nicht mehr auf ihr; er ergibt sich aus der
|
||||
// Einheit ihrer Planstelle und deren Vorfahren. Die Bereichsleitung selbst
|
||||
// sitzt *am* Bereich, ihre Leute darunter — beide landen über die
|
||||
// Vorfahrenkette im selben Balken.
|
||||
const headcountByDivision = new Map<string, number>();
|
||||
for (const row of activeStaff) {
|
||||
if (!row.division_id) continue;
|
||||
headcountByDivision.set(row.division_id, (headcountByDivision.get(row.division_id) ?? 0) + 1);
|
||||
const division = divisionOf(orgMaps, placements.get(row.id)?.orgUnitId);
|
||||
if (!division) continue;
|
||||
headcountByDivision.set(division.id, (headcountByDivision.get(division.id) ?? 0) + 1);
|
||||
}
|
||||
const divisionBars = (divisionsRes.data ?? [])
|
||||
const divisionBars = orgMaps.unitList
|
||||
.filter((u) => u.unit_type === "Bereich")
|
||||
.map((d) => ({ name: d.name, count: headcountByDivision.get(d.id) ?? 0 }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
const maxDivisionCount = Math.max(1, ...divisionBars.map((d) => d.count));
|
||||
|
||||
type UpcomingItem = { id: string; label: string; date: string; kind: keyof typeof KIND_LABEL };
|
||||
const upcoming: UpcomingItem[] = [
|
||||
...(upcomingHiresRes.data ?? []).map((e) => ({
|
||||
type UpcomingItem = {
|
||||
id: string;
|
||||
/** Ziel des Klicks — bei einer Wiedervorlage die Akte, nicht die Notiz. */
|
||||
employeeId: string;
|
||||
label: string;
|
||||
/** Zweite Zeile: bei einer Wiedervorlage der Notiztext statt der Art. */
|
||||
hinweis?: string;
|
||||
date: string;
|
||||
kind: keyof typeof KIND_LABEL;
|
||||
};
|
||||
const upcomingAlle: UpcomingItem[] = [
|
||||
...(upcomingHires).map((e) => ({
|
||||
id: e.id,
|
||||
label: `${e.first_name} ${e.last_name}`,
|
||||
employeeId: e.id,
|
||||
label: fmtName(e.first_name, e.last_name),
|
||||
date: e.entry_date,
|
||||
kind: "hire" as const,
|
||||
})),
|
||||
...(upcomingExitsRes.data ?? []).map((e) => ({
|
||||
...(upcomingExits).map((e) => ({
|
||||
id: e.id,
|
||||
label: `${e.first_name} ${e.last_name}`,
|
||||
employeeId: e.id,
|
||||
label: fmtName(e.first_name, e.last_name),
|
||||
date: e.exit_date!,
|
||||
kind: "exit" as const,
|
||||
})),
|
||||
...(upcomingReturnsRes.data ?? []).map((e) => ({
|
||||
...(upcomingReturns).map((e) => ({
|
||||
id: e.id,
|
||||
label: `${e.first_name} ${e.last_name}`,
|
||||
employeeId: e.id,
|
||||
label: fmtName(e.first_name, e.last_name),
|
||||
date: e.karenz_return_date!,
|
||||
kind: "return" as const,
|
||||
})),
|
||||
]
|
||||
.sort((a, b) => a.date.localeCompare(b.date))
|
||||
.slice(0, 8);
|
||||
...(upcomingNotes).map((n) => ({
|
||||
id: n.id,
|
||||
employeeId: n.employee_id!,
|
||||
label: fmtName(n.first_name, n.last_name),
|
||||
hinweis: n.note_text,
|
||||
date: n.due_date!,
|
||||
kind: "note" as const,
|
||||
})),
|
||||
].sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
const historyEmployeeIds = Array.from(new Set((historyRes.data ?? []).map((h) => h.employee_id)));
|
||||
const historyEmployeesRes = historyEmployeeIds.length
|
||||
? await supabase.from("employees").select("id, first_name, last_name").in("id", historyEmployeeIds)
|
||||
: { data: [] as { id: string; first_name: string; last_name: string }[] };
|
||||
const employeeNameById = new Map((historyEmployeesRes.data ?? []).map((e) => [e.id, `${e.first_name} ${e.last_name}`]));
|
||||
// Die Karte bleibt eine Übersicht, keine Liste: acht Zeilen, und darunter
|
||||
// steht, wie viele es insgesamt sind. Wer alle sehen will, filtert enger
|
||||
// oder geht in die Mitarbeiterliste.
|
||||
const upcoming = upcomingAlle.slice(0, 8);
|
||||
const weitere = upcomingAlle.length - upcoming.length;
|
||||
|
||||
// Each tile links to the view that shows what it counts, with the filters
|
||||
// pre-applied.
|
||||
@@ -203,18 +189,18 @@ export default async function DashboardPage() {
|
||||
{ label: "FTE", value: fte.toFixed(1), tone: "default", href: "/reports?mode=snapshot&measure=fte&status=Aktiv" },
|
||||
{
|
||||
label: "Eintritte (Jahr)",
|
||||
value: hiresYtdRes.count ?? 0,
|
||||
value: hiresYtd,
|
||||
tone: "success",
|
||||
href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${yearEnd}`,
|
||||
},
|
||||
{
|
||||
label: "Austritte (Jahr)",
|
||||
value: exitsYtdRes.count ?? 0,
|
||||
value: exitsYtd,
|
||||
tone: "danger",
|
||||
href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`,
|
||||
},
|
||||
{ label: "Langzeitabwesend", value: karenzCount, tone: "warning", href: "/employees?status=Karenz" },
|
||||
{ label: "Offene Positionen", value: openPositionsRes.count ?? 0, tone: "brand", href: "/positions" },
|
||||
{ label: "Offene Positionen", value: openPositions.length, tone: "brand", href: "/positions" },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -266,37 +252,68 @@ export default async function DashboardPage() {
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle className="mb-1">Anstehend (60 Tage)</CardTitle>
|
||||
<CardTitle className="mb-2">Anstehend ({zeitraum} Tage)</CardTitle>
|
||||
{/* useSearchParams braucht eine Suspense-Grenze; ohne sie fällt beim
|
||||
Bauen die ganze Seite auf Rendern zur Laufzeit zurück. */}
|
||||
<Suspense fallback={<div className="mb-2 h-9" />}>
|
||||
<AnstehendFilter zeitraum={zeitraum} arten={arten} />
|
||||
</Suspense>
|
||||
<ul className="flex flex-col divide-y divide-border-subtle">
|
||||
{upcoming.map((item) => (
|
||||
<li key={`${item.kind}-${item.id}`}>
|
||||
<Link
|
||||
href={`/employees/${item.id}`}
|
||||
className="-mx-2 flex items-center justify-between gap-2 rounded px-2 py-2.5 text-sm hover:bg-surface"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-semibold text-ink">{item.label}</span>
|
||||
<span className="text-xs text-ink-muted">{KIND_LABEL[item.kind]}</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-xs font-semibold tabular-nums text-ink-muted">{fmtDate(item.date)}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
{upcoming.length === 0 && <p className="py-2 text-sm text-ink-muted">Keine anstehenden Ereignisse.</p>}
|
||||
{upcoming.map((item) => {
|
||||
// Überfällig gibt es nur bei Wiedervorlagen: die anderen Arten
|
||||
// haben eine untere Grenze, eine offene Aufgabe nicht.
|
||||
const ueberfaellig = item.date < today;
|
||||
return (
|
||||
<li key={`${item.kind}-${item.id}`}>
|
||||
<Link
|
||||
href={`/employees/${item.employeeId}`}
|
||||
className="-mx-2 flex items-center justify-between gap-2 rounded px-2 py-2.5 text-sm hover:bg-surface"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-semibold text-ink">{item.label}</span>
|
||||
<span className="block truncate text-xs text-ink-muted">
|
||||
{KIND_LABEL[item.kind]}
|
||||
{item.hinweis ? ` — ${item.hinweis}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={`shrink-0 text-xs font-semibold tabular-nums ${
|
||||
ueberfaellig ? "text-danger-text" : "text-ink-muted"
|
||||
}`}
|
||||
>
|
||||
{ueberfaellig ? "überfällig " : ""}
|
||||
{fmtDate(item.date)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{upcoming.length === 0 && (
|
||||
<p className="py-2 text-sm text-ink-muted">
|
||||
{istEingeschraenkt(zeitraum, arten)
|
||||
? "Zu dieser Auswahl steht nichts an."
|
||||
: "Keine anstehenden Ereignisse."}
|
||||
</p>
|
||||
)}
|
||||
</ul>
|
||||
{weitere > 0 && (
|
||||
<p className="mt-2 text-xs text-ink-muted">
|
||||
… und {weitere} {weitere === 1 ? "weiteres Ereignis" : "weitere Ereignisse"} in diesem Zeitraum.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle className="mb-1">Letzte Aktivitäten</CardTitle>
|
||||
<ul className="flex flex-col divide-y divide-border-subtle">
|
||||
{(historyRes.data ?? []).map((h) => (
|
||||
{(history).map((h) => (
|
||||
<li key={h.id} className="flex gap-2.5 py-2.5">
|
||||
{/* Dot aligned to the first line of text, not centred on the
|
||||
whole row, so it stays put as descriptions wrap. */}
|
||||
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${DOT_STYLES[h.event_type] ?? "bg-ink-muted"}`} aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="text-sm font-semibold text-ink">{employeeNameById.get(h.employee_id) ?? "Unbekannt"}</span>
|
||||
<span className="text-sm font-semibold text-ink">{h.first_name && h.last_name ? fmtName(h.first_name, h.last_name) : "Unbekannt"}</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${actionBadgeStyle(h.event_type)}`}>
|
||||
{h.event_type}
|
||||
</span>
|
||||
@@ -305,7 +322,7 @@ export default async function DashboardPage() {
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{(historyRes.data ?? []).length === 0 && <p className="py-2 text-sm text-ink-muted">Keine Aktivitäten vorhanden.</p>}
|
||||
{(history).length === 0 && <p className="py-2 text-sm text-ink-muted">Keine Aktivitäten vorhanden.</p>}
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,51 @@
|
||||
import type { UnitOption } from "@/components/positions/CreatePositionModal";
|
||||
import { PositionsPageClient } from "@/components/positions/PositionsPageClient";
|
||||
import { daysBetweenIso, toIsoDate } from "@/lib/format";
|
||||
import { loadOpenPositions } from "@/lib/positions";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { kostenstellenAbfrage } from "@/lib/cost-centers";
|
||||
import { withUser } from "@/lib/db";
|
||||
import { jsonArrayFrom } from "@/lib/db/json";
|
||||
import { daysBetweenIso, todayIso } from "@/lib/format";
|
||||
import { buildOrgMaps, orgMapsAbfragen } from "@/lib/org";
|
||||
import { offeneStellenAbfrage, resolveOpenPositions, type OffeneStelle } from "@/lib/positions";
|
||||
|
||||
export default async function PositionsPage() {
|
||||
const supabase = await createClient();
|
||||
const today = todayIso();
|
||||
|
||||
// Teams are only needed for the "Position ausschreiben" dialog's team
|
||||
// select. The division/department/team headcount overview this page used
|
||||
// to render was dropped, and with it the two employee-wide aggregation
|
||||
// queries that fed it.
|
||||
const [openPositions, { data: teams }] = await Promise.all([
|
||||
loadOpenPositions(supabase),
|
||||
supabase.from("teams").select("*").order("name"),
|
||||
]);
|
||||
const { openPositions, orgMaps, chiefRows, kostenstellen } = await withUser(await currentUserId(), async (tx) => {
|
||||
// Alles, was ohne Vorwissen geht, in einer Rundreise (lib/db/json.ts).
|
||||
const g = await tx
|
||||
.selectNoFrom((eb) => [
|
||||
...orgMapsAbfragen(eb),
|
||||
jsonArrayFrom(offeneStellenAbfrage(eb, today)).as("open"),
|
||||
jsonArrayFrom(kostenstellenAbfrage(eb, today)).as("kostenstellen"),
|
||||
// Wo es schon eine gültige Leitungsplanstelle gibt, lässt der
|
||||
// Unique-Index keine zweite zu — das gehört in den Dialog, nicht in eine
|
||||
// Fehlermeldung nach dem Absenden.
|
||||
jsonArrayFrom(
|
||||
eb.selectFrom("om_positions").select("org_unit_id").where("is_chief", "=", true).where("valid_to", "is", null)
|
||||
).as("chiefRows"),
|
||||
])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const openPositionsWithDays = openPositions.map((p) => ({ ...p, daysOpen: daysBetweenIso(toIsoDate(p.created_at)) }));
|
||||
const orgMaps = buildOrgMaps(g.units as never, g.locations as never);
|
||||
return {
|
||||
orgMaps,
|
||||
chiefRows: g.chiefRows,
|
||||
kostenstellen: g.kostenstellen,
|
||||
openPositions: await resolveOpenPositions(tx, orgMaps, g.open as OffeneStelle[], today),
|
||||
};
|
||||
});
|
||||
|
||||
return <PositionsPageClient openPositions={openPositionsWithDays} teams={teams ?? []} />;
|
||||
const withChief = new Set(chiefRows.map((r) => r.org_unit_id));
|
||||
const units: UnitOption[] = orgMaps.unitList.map((u) => ({
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
unit_type: u.unit_type,
|
||||
depth: orgMaps.depthOf.get(u.id) ?? 0,
|
||||
hasChief: withChief.has(u.id),
|
||||
}));
|
||||
|
||||
const openPositionsWithDays = openPositions.map((p) => ({ ...p, daysOpen: daysBetweenIso(p.vacantSince) }));
|
||||
|
||||
return <PositionsPageClient openPositions={openPositionsWithDays} units={units} kostenstellen={kostenstellen} />;
|
||||
}
|
||||
|
||||
@@ -15,10 +15,15 @@ import {
|
||||
sumValues,
|
||||
totalForRows,
|
||||
} from "@/lib/reports";
|
||||
import { parseCriteria } from "@/lib/report-criteria";
|
||||
import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
|
||||
type SearchParams = {
|
||||
// Nur die Parameter, die diese Seite selbst auswertet. Die Auswahlkriterien
|
||||
// stehen ebenfalls in der Adresszeile, werden aber geschlossen von
|
||||
// parseCriteria gelesen — siehe lib/report-criteria.ts.
|
||||
type SearchParams = Record<string, string | string[] | undefined> & {
|
||||
mode?: string;
|
||||
measure?: string;
|
||||
group?: string;
|
||||
@@ -26,7 +31,6 @@ type SearchParams = {
|
||||
division?: string;
|
||||
location?: string;
|
||||
status?: string;
|
||||
employment?: string;
|
||||
asOf?: string;
|
||||
eventType?: string;
|
||||
from?: string;
|
||||
@@ -35,7 +39,6 @@ type SearchParams = {
|
||||
|
||||
export default async function ReportsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||
const params = await searchParams;
|
||||
const supabase = await createClient();
|
||||
const mode = parseMode(params.mode);
|
||||
|
||||
// Both modes are parsed up front so the data load can start before
|
||||
@@ -50,39 +53,50 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
||||
const measure = parseMeasure(params.measure);
|
||||
const group = parseGroupDimension(params.group);
|
||||
const split = parseSplitDimension(params.split);
|
||||
const criteria = parseCriteria((k) => (typeof params[k] === "string" ? (params[k] as string) : undefined));
|
||||
|
||||
// The report data depends on neither the org lookups nor on who is signed
|
||||
// in, so all three go out together. Against a hosted database a round trip
|
||||
// costs about as much as the query itself, which made this page's three
|
||||
// sequential waves its dominant cost.
|
||||
const [{ lookups, divisions, locations }, { data: userRes }, events, employees] = await Promise.all([
|
||||
loadOrgLookups(supabase),
|
||||
supabase.auth.getUser(),
|
||||
mode === "events"
|
||||
? loadEventHistory(supabase, {
|
||||
eventType: eventType ?? undefined,
|
||||
division: params.division,
|
||||
location: params.location,
|
||||
from,
|
||||
to,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
mode === "snapshot"
|
||||
? loadSnapshotEmployees(supabase, {
|
||||
division: params.division,
|
||||
location: params.location,
|
||||
status: params.status,
|
||||
employment: params.employment,
|
||||
asOf,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const userId = await currentUserId();
|
||||
|
||||
const user = userRes.user;
|
||||
// Still a wave of its own: it needs the user id the call above resolves.
|
||||
const { data: savedReports } = user
|
||||
? await supabase.from("saved_reports").select("id, name, config").eq("created_by", user.id).order("created_at", { ascending: false })
|
||||
: { data: [] };
|
||||
// Alles in einer Transaktion — dort gilt der Sitzungskontext, und der
|
||||
// Lesestand ist über alle Abfragen hinweg derselbe. Vorher waren es drei
|
||||
// Wellen nacheinander, was gegen eine entfernte Datenbank der teuerste
|
||||
// Teil dieser Seite war.
|
||||
const { lookups, units, locations, events, employees, savedReports } = await withUser(userId, async (tx) => {
|
||||
const [{ lookups, units, locations }, events, employees, savedReports] = await Promise.all([
|
||||
loadOrgLookups(tx),
|
||||
mode === "events"
|
||||
? loadEventHistory(tx, {
|
||||
eventType: eventType ?? undefined,
|
||||
division: params.division,
|
||||
location: params.location,
|
||||
from,
|
||||
to,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
mode === "snapshot"
|
||||
? loadSnapshotEmployees(tx, {
|
||||
division: params.division,
|
||||
location: params.location,
|
||||
status: params.status,
|
||||
criteria,
|
||||
asOf,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
userId
|
||||
? tx
|
||||
.selectFrom("saved_reports")
|
||||
.select(["id", "name", "config"])
|
||||
.where("created_by", "=", userId)
|
||||
.orderBy("created_at", "desc")
|
||||
.execute()
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
return { lookups, units, locations, events, employees, savedReports };
|
||||
});
|
||||
|
||||
if (mode === "events") {
|
||||
const rows = aggregateEvents(events, eventGroup, eventSplit, lookups);
|
||||
@@ -98,9 +112,9 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
||||
rows={rows}
|
||||
total={sumValues(rows)}
|
||||
recordCount={events.length}
|
||||
divisions={divisions}
|
||||
units={units}
|
||||
locations={locations}
|
||||
savedReports={savedReports ?? []}
|
||||
savedReports={savedReports}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
@@ -120,14 +134,14 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
||||
division: params.division ?? "",
|
||||
location: params.location ?? "",
|
||||
status: params.status ?? "",
|
||||
employment: params.employment ?? "",
|
||||
}}
|
||||
criteria={criteria}
|
||||
rows={rows}
|
||||
total={totalForRows(rows, measure)}
|
||||
recordCount={employees.length}
|
||||
divisions={divisions}
|
||||
units={units}
|
||||
locations={locations}
|
||||
savedReports={savedReports ?? []}
|
||||
savedReports={savedReports}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { login, logout } from "@/actions/auth";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { CONTROL_CLASS } from "@/components/ui/Field";
|
||||
import { EntraSignInButton } from "@/components/auth/EntraSignInButton";
|
||||
import { logout, signInWithEntra } from "@/actions/auth";
|
||||
|
||||
// The query string is attacker-controlled, so the login page renders a message
|
||||
// looked up by code rather than whatever text ?error= carries. Reflecting the
|
||||
// raw parameter let anyone put arbitrary wording ("Ihr Konto wurde gesperrt,
|
||||
// rufen Sie …") on the real, correctly-branded sign-in screen.
|
||||
// rufen Sie …") on the real, correctly-branded sign-in screen. Dasselbe gilt
|
||||
// für die Fehlertexte, die Entra im Rückweg mitschickt.
|
||||
const ERROR_MESSAGES = {
|
||||
no_hr_access: "Kein HR-Zugriff. Bitte wenden Sie sich an eine:n bestehende:n HR-Benutzer:in.",
|
||||
invalid_credentials: "E-Mail oder Passwort ist falsch.",
|
||||
no_hr_access: {
|
||||
title: "Kein HR-Zugriff",
|
||||
body: "Ihr Firmenkonto ist bekannt, aber nicht für die Personalverwaltung freigeschaltet. Bitte wenden Sie sich an eine:n bestehende:n HR-Benutzer:in.",
|
||||
},
|
||||
sso_failed: {
|
||||
title: "Anmeldung fehlgeschlagen",
|
||||
body: "Die Anmeldung über das Firmenkonto konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut.",
|
||||
},
|
||||
} as const;
|
||||
|
||||
type ErrorCode = keyof typeof ERROR_MESSAGES;
|
||||
@@ -19,60 +25,119 @@ type LoginPageProps = {
|
||||
|
||||
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
const params = await searchParams;
|
||||
const code = params.error && Object.hasOwn(ERROR_MESSAGES, params.error) ? (params.error as ErrorCode) : null;
|
||||
// Ein unbekannter Code wird nicht verschluckt, sondern auf die allgemeine
|
||||
// Meldung abgebildet: Auth.js schickt bei einem Fehlschlag seine eigenen
|
||||
// Codes („Configuration", „AccessDenied", „OAuthCallbackError" …), und ohne
|
||||
// diese Abbildung stünde man vor einer Anmeldeseite, die so tut, als wäre
|
||||
// nichts gewesen. Angezeigt wird trotzdem nur eigener Text — der Parameter
|
||||
// selbst kommt nie auf die Seite.
|
||||
const code = params.error
|
||||
? Object.hasOwn(ERROR_MESSAGES, params.error)
|
||||
? (params.error as ErrorCode)
|
||||
: "sso_failed"
|
||||
: null;
|
||||
const error = code ? ERROR_MESSAGES[code] : null;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-surface px-4">
|
||||
<div className="w-full max-w-sm rounded border border-border bg-white p-8 shadow-sm">
|
||||
<h1 className="text-xl font-extrabold text-ink">Alpenwerk HR</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">Melden Sie sich mit Ihrem Firmenkonto an.</p>
|
||||
// dvh statt vh: auf iOS zählt vh die Adressleiste mit, wodurch die Karte
|
||||
// im ersten Moment unter dem Faltenrand sitzt.
|
||||
<div className="min-h-dvh lg:grid lg:grid-cols-[1.05fr_1fr]">
|
||||
<BrandPanel />
|
||||
|
||||
{error && (
|
||||
<div role="alert" className="mt-4 rounded bg-danger-bg px-3 py-2 text-sm text-danger-text">
|
||||
{error}
|
||||
{code === "no_hr_access" && (
|
||||
<form action={logout} className="mt-2">
|
||||
<button type="submit" className="text-xs font-semibold underline hover:no-underline">
|
||||
Abmelden und mit anderem Konto versuchen
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<main className="flex items-center justify-center px-6 py-12 lg:py-6">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="lg:hidden">
|
||||
<Wordmark className="text-ink" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form action={login} className="mt-6 flex flex-col gap-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="mb-1 block text-sm font-semibold text-ink">
|
||||
E-Mail
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="username"
|
||||
className={CONTROL_CLASS}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="mb-1 block text-sm font-semibold text-ink">
|
||||
Passwort
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className={CONTROL_CLASS}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" fullWidth className="mt-2">
|
||||
Anmelden
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
<h2 className="mt-8 text-2xl font-extrabold tracking-tight text-ink lg:mt-0">Anmelden</h2>
|
||||
<p className="mt-1.5 text-sm text-ink-muted">
|
||||
Der Zugang läuft über Ihr Firmenkonto. Ein eigenes Passwort gibt es nicht.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div role="alert" className="mt-6 rounded-md border border-danger-text/20 bg-danger-bg px-4 py-3">
|
||||
<p className="text-sm font-bold text-danger-text">{error.title}</p>
|
||||
<p className="mt-1 text-sm text-danger-text/90">{error.body}</p>
|
||||
{code === "no_hr_access" && (
|
||||
<form action={logout} className="mt-3">
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded text-xs font-semibold text-danger-text underline underline-offset-2 hover:no-underline
|
||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-danger-text"
|
||||
>
|
||||
Abmelden und mit einem anderen Konto versuchen
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form action={signInWithEntra} className="mt-7">
|
||||
<EntraSignInButton />
|
||||
</form>
|
||||
|
||||
<p className="mt-6 border-t border-border-subtle pt-5 text-xs leading-relaxed text-ink-muted">
|
||||
Die Anmeldung allein erteilt keinen Zugriff. HR-Rechte vergibt die Personalabteilung — bis dahin bleiben alle
|
||||
Personaldaten verschlossen.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BrandPanel() {
|
||||
return (
|
||||
<aside className="relative hidden overflow-hidden bg-brand-700 lg:flex lg:flex-col lg:justify-between lg:p-12">
|
||||
{/* Zwei weiche Lichtpunkte und ein feines Raster — genug Struktur, damit
|
||||
die Fläche nicht wie ein Farbfehler wirkt, aber ohne Bilddatei und
|
||||
ohne von der einen Schaltfläche gegenüber abzulenken. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(60rem 40rem at 15% 0%, rgb(255 255 255 / 0.16), transparent 60%)," +
|
||||
"radial-gradient(40rem 40rem at 100% 100%, rgb(255 255 255 / 0.10), transparent 55%)," +
|
||||
"linear-gradient(rgb(255 255 255 / 0.05) 1px, transparent 1px)," +
|
||||
"linear-gradient(90deg, rgb(255 255 255 / 0.05) 1px, transparent 1px)",
|
||||
backgroundSize: "auto, auto, 3rem 3rem, 3rem 3rem",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<Wordmark className="text-white" />
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-md">
|
||||
<p className="text-3xl font-extrabold leading-tight tracking-tight text-white">
|
||||
Die Organisation, so wie sie heute wirklich aussieht.
|
||||
</p>
|
||||
<p className="mt-4 text-sm leading-relaxed text-white/70">
|
||||
Stammdaten, Planstellen und Berichtslinien der Alpenwerk Industrie GmbH — jederzeit auch zu einem beliebigen
|
||||
Stichtag.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="relative text-xs text-white/50">Interne Anwendung · Zugriff nur für die Personalabteilung</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function Wordmark({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2.5 ${className}`}>
|
||||
{/* Vier Quadrate wie ein Organigramm-Ausschnitt: eine Wurzel, darunter
|
||||
drei Einheiten. */}
|
||||
<svg viewBox="0 0 24 24" className="h-7 w-7" aria-hidden="true">
|
||||
<rect x="9" y="2" width="6" height="6" rx="1.5" fill="currentColor" />
|
||||
<rect x="1" y="16" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.55" />
|
||||
<rect x="9" y="16" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.75" />
|
||||
<rect x="17" y="16" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.55" />
|
||||
<path d="M12 8v4M4 16v-4h16v4" stroke="currentColor" strokeWidth="1.5" fill="none" opacity="0.5" />
|
||||
</svg>
|
||||
<span className="text-lg font-extrabold tracking-tight">Alpenwerk HR</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
9
app/api/auth/[...nextauth]/route.ts
Normal file
9
app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { handlers } from "@/auth";
|
||||
|
||||
// Der Rückweg aus Entra ID und die Endpunkte für An- und Abmeldung.
|
||||
//
|
||||
// Tritt an die Stelle von app/auth/callback/route.ts: den Tausch des
|
||||
// Einmal-Codes gegen eine Sitzung, die Prüfung von `state` und `nonce` und
|
||||
// das Setzen des Cookies macht jetzt Auth.js. Die Rückruf-Adresse in der
|
||||
// Entra-Anwendungsregistrierung ändert sich dadurch — siehe docs/entra-sso.md.
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -2,32 +2,34 @@ import { NextResponse, type NextRequest } from "next/server";
|
||||
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
|
||||
import { EVENT_TYPE_LABELS, parseEventDateParam, parseEventType, type OrgLookups, type ReportEvent } from "@/lib/reports";
|
||||
import { loadEventHistory, loadOrgLookups } from "@/lib/reports-data";
|
||||
import { requireHrUser } from "@/lib/supabase/auth";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { requireHrUser } from "@/lib/auth/require-hr";
|
||||
import { withUser } from "@/lib/db";
|
||||
|
||||
// Full raw event-log dump — one row per employee_history entry in the
|
||||
// selected period (default: current year), every event type unless one is
|
||||
// picked, org columns resolved from each affected employee's current
|
||||
// placement (see loadEventHistory).
|
||||
export async function GET(request: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const denied = await requireHrUser(supabase);
|
||||
if (denied) return denied;
|
||||
const gate = await requireHrUser();
|
||||
if ("denied" in gate) return gate.denied;
|
||||
|
||||
const params = request.nextUrl.searchParams;
|
||||
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
|
||||
const eventType = parseEventType(params.get("eventType"));
|
||||
|
||||
const [{ lookups }, events] = await Promise.all([
|
||||
loadOrgLookups(supabase),
|
||||
loadEventHistory(supabase, {
|
||||
eventType: eventType ?? undefined,
|
||||
division: params.get("division") ?? undefined,
|
||||
location: params.get("location") ?? undefined,
|
||||
from: parseEventDateParam(params.get("from")),
|
||||
to: parseEventDateParam(params.get("to")),
|
||||
}),
|
||||
]);
|
||||
const { lookups, events } = await withUser(gate.userId, async (tx) => {
|
||||
const [{ lookups }, events] = await Promise.all([
|
||||
loadOrgLookups(tx),
|
||||
loadEventHistory(tx, {
|
||||
eventType: eventType ?? undefined,
|
||||
division: params.get("division") ?? undefined,
|
||||
location: params.get("location") ?? undefined,
|
||||
from: parseEventDateParam(params.get("from")),
|
||||
to: parseEventDateParam(params.get("to")),
|
||||
}),
|
||||
]);
|
||||
return { lookups, events };
|
||||
});
|
||||
|
||||
const columns = eventExportColumns(lookups);
|
||||
const filename = exportFilename(`ereignisse-${eventType ?? "alle"}`, format);
|
||||
@@ -44,9 +46,9 @@ function eventExportColumns(lookups: OrgLookups): ExportColumn<ReportEvent>[] {
|
||||
{ header: "Vorname", get: (e) => e.first_name },
|
||||
{ header: "Nachname", get: (e) => e.last_name },
|
||||
{ header: "Position", get: (e) => e.job_title },
|
||||
{ header: "Bereich", get: (e) => lookups.divisionName.get(e.division_id) ?? "" },
|
||||
{ header: "Abteilung", get: (e) => (e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "") : "") },
|
||||
{ header: "Team", get: (e) => (e.team_id ? (lookups.teamName.get(e.team_id) ?? "") : "") },
|
||||
{ header: "Bereich", get: (e) => (e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "") : "") },
|
||||
{ header: "Abteilung", get: (e) => (e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "") : "") },
|
||||
{ header: "Team", get: (e) => (e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "") : "") },
|
||||
{ header: "Standort", get: (e) => lookups.locationName.get(e.location_id) ?? "" },
|
||||
{ header: "Beschreibung", get: (e) => e.description },
|
||||
];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
|
||||
import { parseCriteria } from "@/lib/report-criteria";
|
||||
import {
|
||||
aggregateEvents,
|
||||
aggregateReport,
|
||||
@@ -24,59 +25,64 @@ import {
|
||||
type ReportRow,
|
||||
} from "@/lib/reports";
|
||||
import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data";
|
||||
import { requireHrUser } from "@/lib/supabase/auth";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { requireHrUser } from "@/lib/auth/require-hr";
|
||||
import { withUser } from "@/lib/db";
|
||||
|
||||
// Exports exactly the pivot table currently on screen (same mode/measure or
|
||||
// event-type/group/split/filters, read from the query string the client
|
||||
// already keeps in the URL) as a flat table — one row per group, one column
|
||||
// per split value if a split is active.
|
||||
export async function GET(request: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const denied = await requireHrUser(supabase);
|
||||
if (denied) return denied;
|
||||
const gate = await requireHrUser();
|
||||
if ("denied" in gate) return gate.denied;
|
||||
|
||||
const params = request.nextUrl.searchParams;
|
||||
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
|
||||
const mode = parseMode(params.get("mode"));
|
||||
const { lookups } = await loadOrgLookups(supabase);
|
||||
// Eine Transaktion für Nachschlagewerte und Daten: dort gilt der
|
||||
// Sitzungskontext, und beide sehen denselben Lesestand.
|
||||
const { rows, columns, filenameBase } = await withUser(gate.userId, async (tx) => {
|
||||
const { lookups } = await loadOrgLookups(tx);
|
||||
let rows: ReportRow[];
|
||||
let columns: ExportColumn<ReportRow>[];
|
||||
let filenameBase: string;
|
||||
|
||||
let rows: ReportRow[];
|
||||
let columns: ExportColumn<ReportRow>[];
|
||||
let filenameBase: string;
|
||||
if (mode === "events") {
|
||||
const group = parseEventGroupDimension(params.get("group"));
|
||||
const split = parseEventSplitDimension(params.get("split"));
|
||||
const eventType = parseEventType(params.get("eventType"));
|
||||
const events = await loadEventHistory(tx, {
|
||||
eventType: eventType ?? undefined,
|
||||
division: params.get("division") ?? undefined,
|
||||
location: params.get("location") ?? undefined,
|
||||
from: parseEventDateParam(params.get("from")),
|
||||
to: parseEventDateParam(params.get("to")),
|
||||
});
|
||||
rows = aggregateEvents(events, group, split, lookups);
|
||||
columns = eventReportColumns(rows, group, split, sumValues(rows));
|
||||
filenameBase = `ereignisse-${eventType ?? "alle"}-${group}`;
|
||||
} else {
|
||||
const measure = parseMeasure(params.get("measure"));
|
||||
const group = parseGroupDimension(params.get("group"));
|
||||
const split = parseSplitDimension(params.get("split"));
|
||||
const asOf = parseIsoDateParam(params.get("asOf"));
|
||||
const employees = await loadSnapshotEmployees(tx, {
|
||||
division: params.get("division") ?? undefined,
|
||||
location: params.get("location") ?? undefined,
|
||||
status: params.get("status") ?? undefined,
|
||||
criteria: parseCriteria((k) => params.get(k)),
|
||||
asOf,
|
||||
});
|
||||
rows = aggregateReport(employees, measure, group, split, lookups, asOf);
|
||||
columns = snapshotReportColumns(rows, measure, group, split, totalForRows(rows, measure));
|
||||
filenameBase = `bericht-${measure}-${group}`;
|
||||
}
|
||||
|
||||
if (mode === "events") {
|
||||
const group = parseEventGroupDimension(params.get("group"));
|
||||
const split = parseEventSplitDimension(params.get("split"));
|
||||
const eventType = parseEventType(params.get("eventType"));
|
||||
const events = await loadEventHistory(supabase, {
|
||||
eventType: eventType ?? undefined,
|
||||
division: params.get("division") ?? undefined,
|
||||
location: params.get("location") ?? undefined,
|
||||
from: parseEventDateParam(params.get("from")),
|
||||
to: parseEventDateParam(params.get("to")),
|
||||
});
|
||||
rows = aggregateEvents(events, group, split, lookups);
|
||||
columns = eventReportColumns(rows, group, split, sumValues(rows));
|
||||
filenameBase = `ereignisse-${eventType ?? "alle"}-${group}`;
|
||||
} else {
|
||||
const measure = parseMeasure(params.get("measure"));
|
||||
const group = parseGroupDimension(params.get("group"));
|
||||
const split = parseSplitDimension(params.get("split"));
|
||||
const asOf = parseIsoDateParam(params.get("asOf"));
|
||||
const employees = await loadSnapshotEmployees(supabase, {
|
||||
division: params.get("division") ?? undefined,
|
||||
location: params.get("location") ?? undefined,
|
||||
status: params.get("status") ?? undefined,
|
||||
employment: params.get("employment") ?? undefined,
|
||||
asOf,
|
||||
});
|
||||
rows = aggregateReport(employees, measure, group, split, lookups, asOf);
|
||||
columns = snapshotReportColumns(rows, measure, group, split, totalForRows(rows, measure));
|
||||
filenameBase = `bericht-${measure}-${group}`;
|
||||
}
|
||||
return { rows, columns, filenameBase };
|
||||
});
|
||||
|
||||
const filename = exportFilename(filenameBase, format);
|
||||
|
||||
const body = format === "xlsx" ? await toXlsx(rows, columns, "Bericht") : toCsv(rows, columns);
|
||||
// TS 5.9's Uint8Array<ArrayBufferLike> vs DOM's BlobPart/ArrayBuffer<> generic
|
||||
// mismatch (microsoft/TypeScript#59417) — a real Uint8Array works fine here.
|
||||
|
||||
135
app/api/import/route.ts
Normal file
135
app/api/import/route.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { requireHrUser } from "@/lib/auth/require-hr";
|
||||
import { withUser } from "@/lib/db";
|
||||
import { bestandLaden, laden, type Ladebericht } from "@/lib/import/load";
|
||||
import { dateiLesen, type ImportSheet } from "@/lib/import/parse";
|
||||
import { pruefe, type Befund } from "@/lib/import/validate";
|
||||
|
||||
// Massenimport — Prüflauf und Übernahme über denselben Weg.
|
||||
//
|
||||
// Es gibt bewusst **keinen** Zwischenspeicher zwischen beiden Schritten. Die
|
||||
// Oberfläche schickt die Datei zweimal: einmal mit `pruefen=1`, um den
|
||||
// Bericht zu zeigen, und nach der Bestätigung noch einmal zum Übernehmen.
|
||||
// Das kostet eine Übertragung und erspart serverseitigen Zustand, der
|
||||
// ablaufen, vollaufen oder zwischen zwei Personen verwechselt werden kann.
|
||||
//
|
||||
// Beide Läufe sehen denselben Bestand, weil Prüfung und Schreiben in
|
||||
// derselben Transaktion stattfinden. Zwischen „geprüft" und „geschrieben"
|
||||
// passt sonst eine fremde Änderung — etwa jemand, der dieselbe Planstelle
|
||||
// besetzt.
|
||||
|
||||
/** Bricht die Transaktion ab, ohne einen Fehler zu sein. */
|
||||
class Rueckabwicklung extends Error {
|
||||
constructor(readonly nutzlast: unknown) {
|
||||
super("Prüflauf");
|
||||
}
|
||||
}
|
||||
|
||||
// 60 Sekunden, weil das die Obergrenze im kostenlosen Vercel-Tarif ist —
|
||||
// ein höherer Wert lässt sich dort nicht ausrollen. Auf einem eigenen Server
|
||||
// gilt die Angabe ohnehin nicht, und im Pro-Tarif liesse sie sich auf 300
|
||||
// heben, falls eine Datei mit vielen tausend Zeilen ansteht.
|
||||
export const maxDuration = 60;
|
||||
|
||||
type Antwort = {
|
||||
ok: boolean;
|
||||
geprueft: boolean;
|
||||
blaetter: string[];
|
||||
fehler: Befund[];
|
||||
hinweise: Befund[];
|
||||
anzahl: Record<string, number>;
|
||||
bericht?: Ladebericht;
|
||||
meldung?: string;
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const gate = await requireHrUser();
|
||||
if ("denied" in gate) return gate.denied;
|
||||
|
||||
const form = await request.formData();
|
||||
const nurPruefen = form.get("pruefen") === "1";
|
||||
const dateien = form.getAll("datei").filter((f): f is File => f instanceof File);
|
||||
|
||||
if (dateien.length === 0) {
|
||||
return NextResponse.json({ ok: false, meldung: "Keine Datei erhalten." }, { status: 400 });
|
||||
}
|
||||
|
||||
// Mehrere Dateien werden zusammengesetzt: eine Mappe mit allen Blättern
|
||||
// oder eine CSV je Blatt sind derselbe Vorgang.
|
||||
const blaetter: ImportSheet[] = [];
|
||||
const lesefehler: string[] = [];
|
||||
for (const datei of dateien) {
|
||||
const ergebnis = await dateiLesen(datei.name, await datei.arrayBuffer());
|
||||
blaetter.push(...ergebnis.blaetter);
|
||||
lesefehler.push(...ergebnis.fehler);
|
||||
}
|
||||
|
||||
if (lesefehler.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
geprueft: true,
|
||||
blaetter: blaetter.map((b) => b.name),
|
||||
fehler: lesefehler.map((m) => ({ blatt: "Datei", zeile: null, spalte: null, meldung: m })),
|
||||
hinweise: [],
|
||||
anzahl: {},
|
||||
} satisfies Antwort,
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
|
||||
const profil = await withUser(gate.userId, (tx) =>
|
||||
tx.selectFrom("profiles").select(["full_name", "email"]).where("id", "=", gate.userId).executeTakeFirst()
|
||||
);
|
||||
|
||||
try {
|
||||
const antwort = await withUser(gate.userId, async (tx) => {
|
||||
const bestand = await bestandLaden(tx);
|
||||
const geprueft = pruefe(blaetter, bestand);
|
||||
|
||||
const basis: Antwort = {
|
||||
ok: geprueft.fehler.length === 0,
|
||||
geprueft: true,
|
||||
blaetter: blaetter.map((b) => b.name),
|
||||
fehler: geprueft.fehler,
|
||||
hinweise: geprueft.hinweise,
|
||||
anzahl: geprueft.anzahl,
|
||||
};
|
||||
|
||||
// Fehler oder Prüflauf: die Transaktion wird zurückgerollt. Beim
|
||||
// Prüflauf hat sie trotzdem echte Abfragen gemacht — der Bericht
|
||||
// beruht also auf dem tatsächlichen Bestand, nicht auf einer Kopie.
|
||||
if (!basis.ok || nurPruefen) throw new Rueckabwicklung({ ...basis, geprueft: nurPruefen || !basis.ok });
|
||||
|
||||
const bericht = await laden(tx, geprueft.datensatz, bestand, {
|
||||
userId: gate.userId,
|
||||
name: profil?.full_name || profil?.email || "Unbekannt",
|
||||
});
|
||||
return { ...basis, geprueft: false, bericht };
|
||||
});
|
||||
|
||||
return NextResponse.json(antwort);
|
||||
} catch (err) {
|
||||
if (err instanceof Rueckabwicklung) {
|
||||
const nutzlast = err.nutzlast as Antwort;
|
||||
return NextResponse.json(nutzlast, { status: nutzlast.ok ? 200 : 422 });
|
||||
}
|
||||
// Ein echter Fehler beim Schreiben. Die Transaktion ist zurückgerollt,
|
||||
// es steht also nichts Halbes in der Datenbank.
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
geprueft: false,
|
||||
blaetter: blaetter.map((b) => b.name),
|
||||
fehler: [],
|
||||
hinweise: [],
|
||||
anzahl: {},
|
||||
meldung:
|
||||
err instanceof Error
|
||||
? `Der Import wurde vollständig zurückgenommen. Grund: ${err.message}`
|
||||
: "Der Import wurde vollständig zurückgenommen.",
|
||||
} satisfies Antwort,
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
96
app/api/import/template/route.ts
Normal file
96
app/api/import/template/route.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import ExcelJS from "exceljs";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireHrUser } from "@/lib/auth/require-hr";
|
||||
import { exportFilename, exportResponseHeaders } from "@/lib/export";
|
||||
import { BLAETTER } from "@/lib/import/schema";
|
||||
|
||||
// Die Vorlage entsteht aus demselben Schema wie die Prüfung.
|
||||
//
|
||||
// Das ist der Punkt: eine von Hand gepflegte Beispieldatei läuft dem Code
|
||||
// hinterher, und dann verlangt die Vorlage eine Spalte, die es nicht mehr
|
||||
// gibt — oder umgekehrt. Hier kann das nicht passieren; kommt in schema.ts
|
||||
// eine Spalte dazu, steht sie beim nächsten Herunterladen drin.
|
||||
|
||||
export async function GET() {
|
||||
const gate = await requireHrUser();
|
||||
if ("denied" in gate) return gate.denied;
|
||||
|
||||
const mappe = new ExcelJS.Workbook();
|
||||
mappe.creator = "Alpenwerk HR";
|
||||
mappe.created = new Date();
|
||||
|
||||
const hinweise = mappe.addWorksheet("Hinweise");
|
||||
hinweise.columns = [
|
||||
{ header: "Blatt", width: 16 },
|
||||
{ header: "Spalte", width: 26 },
|
||||
{ header: "Pflicht", width: 9 },
|
||||
{ header: "Format", width: 30 },
|
||||
{ header: "Hinweis", width: 70 },
|
||||
];
|
||||
hinweise.getRow(1).font = { bold: true };
|
||||
|
||||
const formatText = (typ: (typeof BLAETTER)[number]["spalten"][number]["typ"]): string => {
|
||||
switch (typ.art) {
|
||||
case "datum":
|
||||
return "Datum (31.12.2026)";
|
||||
case "zahl":
|
||||
return "Zahl (38,5)";
|
||||
case "ganzzahl":
|
||||
return "Ganze Zahl";
|
||||
case "janein":
|
||||
return "ja / nein";
|
||||
case "liste":
|
||||
return typ.werte ? `Mehrere mit Semikolon aus: ${typ.werte.join(", ")}` : "Mehrere mit Semikolon";
|
||||
case "auswahl":
|
||||
return typ.werte.join(" | ");
|
||||
default:
|
||||
return "Text";
|
||||
}
|
||||
};
|
||||
|
||||
for (const schema of BLAETTER) {
|
||||
hinweise.addRow([schema.name, "", "", "", schema.zweck]).font = { bold: true };
|
||||
for (const s of schema.spalten) {
|
||||
hinweise.addRow([schema.name, s.name, s.pflicht ? "ja" : "", formatText(s.typ), s.hinweis]);
|
||||
}
|
||||
hinweise.addRow([]);
|
||||
}
|
||||
|
||||
for (const schema of BLAETTER) {
|
||||
const blatt = mappe.addWorksheet(schema.name);
|
||||
blatt.columns = schema.spalten.map((s) => ({
|
||||
header: s.name,
|
||||
width: Math.max(12, Math.min(28, s.name.length + 4)),
|
||||
}));
|
||||
|
||||
const kopf = blatt.getRow(1);
|
||||
kopf.font = { bold: true };
|
||||
kopf.eachCell((zelle, i) => {
|
||||
const spalte = schema.spalten[i - 1];
|
||||
if (!spalte) return;
|
||||
// Pflichtspalten sichtbar markieren — sonst ist die erste Rückmeldung
|
||||
// eine Fehlerliste statt eines Hinweises beim Ausfüllen.
|
||||
if (spalte.pflicht) {
|
||||
zelle.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFFDE7EF" } };
|
||||
}
|
||||
const teile = [spalte.pflicht ? "Pflichtfeld." : "Optional.", formatText(spalte.typ), spalte.hinweis].filter(Boolean);
|
||||
zelle.note = teile.join("\n");
|
||||
});
|
||||
|
||||
// Eine Beispielzeile. Alles als Text, damit Excel nicht selbst
|
||||
// interpretiert — der Leser deutet die Werte ohnehin.
|
||||
const beispiel = schema.spalten.map((s) => s.beispiel);
|
||||
if (beispiel.some(Boolean)) {
|
||||
const zeile = blatt.addRow(beispiel);
|
||||
zeile.font = { italic: true, color: { argb: "FF8A8A8A" } };
|
||||
zeile.eachCell((z) => {
|
||||
z.numFmt = "@";
|
||||
});
|
||||
}
|
||||
blatt.views = [{ state: "frozen", ySplit: 1 }];
|
||||
}
|
||||
|
||||
const puffer = await mappe.xlsx.writeBuffer();
|
||||
const dateiname = exportFilename("import-vorlage", "xlsx");
|
||||
return new NextResponse(new Blob([puffer as BlobPart]), { headers: exportResponseHeaders(dateiname, "xlsx") });
|
||||
}
|
||||
65
auth.ts
Normal file
65
auth.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import "server-only";
|
||||
import NextAuth from "next-auth";
|
||||
import { authConfig } from "@/lib/auth/config";
|
||||
import { asSystem, sql } from "@/lib/db";
|
||||
|
||||
// Die vollständige Anmeldung — die Fassung, die die Datenbank kennt.
|
||||
//
|
||||
// Aufgeteilt ist sie, weil proxy.ts nur den Teil aus lib/auth/config.ts lädt.
|
||||
// Hier kommt das dazu, was einmal pro Anmeldung passieren muss: aus der
|
||||
// Kennung, die Entra ausstellt, eine Kennung machen, die diese Anwendung
|
||||
// versteht.
|
||||
|
||||
/**
|
||||
* Legt die app_users-Zeile an oder frischt sie auf und liefert die Kennung,
|
||||
* die überall sonst als `userId` durchgereicht wird.
|
||||
*
|
||||
* Die Datenbankfunktion ist SECURITY DEFINER und darf genau dieses eine:
|
||||
* app_users schreiben. Für den einen Schreibvorgang, für den es noch keinen
|
||||
* Sitzungskontext geben kann, ist das der kleinstmögliche Hebel — früher lag
|
||||
* hier ein Dienstschlüssel, der jede Zeile jeder Tabelle lesen konnte.
|
||||
*/
|
||||
async function upsertAppUser(externalId: string, email: string, fullName: string | null): Promise<string> {
|
||||
const row = await asSystem(async (tx) => {
|
||||
const result = await sql<{ id: string }>`
|
||||
select app_upsert_user(${externalId}, ${email}, ${fullName}) as id
|
||||
`.execute(tx);
|
||||
return result.rows[0];
|
||||
});
|
||||
|
||||
if (!row?.id) throw new Error("app_upsert_user() lieferte keine Kennung.");
|
||||
return row.id;
|
||||
}
|
||||
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth(() => {
|
||||
const base = authConfig();
|
||||
return {
|
||||
...base,
|
||||
callbacks: {
|
||||
// Die Rückrufe aus der Basis **behalten**, nicht ersetzen: dort liegt
|
||||
// session(), das die Kennung aus dem Token auf die Sitzung legt. Ein
|
||||
// schlichtes `callbacks: { jwt }` hätte es stillschweigend entfernt.
|
||||
...base.callbacks,
|
||||
async jwt({ token, profile }) {
|
||||
// `profile` liegt nur beim ersten Durchlauf nach der Rückkehr von Entra
|
||||
// vor. Danach wird das Token nur noch weitergereicht — die Datenbank
|
||||
// wird also einmal pro Anmeldung befragt, nicht einmal pro Aufruf.
|
||||
if (!profile) return token;
|
||||
|
||||
const externalId = typeof profile.oid === "string" ? profile.oid : null;
|
||||
const email = [profile.email, profile.preferred_username, profile.upn].find(
|
||||
(v): v is string => typeof v === "string" && v.length > 0
|
||||
);
|
||||
|
||||
// Lieber abbrechen als eine Sitzung ohne Kennung ausstellen: die käme
|
||||
// als `null` bei withUser() an, und die Policies gäben dann konsequent
|
||||
// nichts zurück — was sich als „die Anwendung ist leer" zeigt statt als
|
||||
// Anmeldefehler.
|
||||
if (!externalId || !email) throw new Error("Entra lieferte weder oid noch E-Mail-Adresse.");
|
||||
|
||||
token.uid = await upsertAppUser(externalId, email, typeof profile.name === "string" ? profile.name : null);
|
||||
return token;
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
112
components/audit/AuditDetail.tsx
Normal file
112
components/audit/AuditDetail.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { AenderungsTabelle } from "@/components/ui/AenderungsTabelle";
|
||||
import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { actionBadgeStyle } from "@/lib/colors";
|
||||
import type { AuditChange } from "@/lib/supabase/types";
|
||||
|
||||
// Eine Protokollzeile zum Aufklappen.
|
||||
//
|
||||
// Die Liste zeigt, *dass* etwas geändert wurde; hier steht, *was*. Beides in
|
||||
// der Tabelle unterzubringen ginge nicht — bei sieben geänderten Feldern
|
||||
// wäre die Zeile höher als der Bildschirm.
|
||||
|
||||
export type AuditEintrag = {
|
||||
id: string;
|
||||
occurred_at: string;
|
||||
actor_name: string;
|
||||
action: string;
|
||||
target_label: string;
|
||||
target_employee_id: string | null;
|
||||
details: string | null;
|
||||
changes: AuditChange[] | null;
|
||||
};
|
||||
|
||||
const zeitFormat = new Intl.DateTimeFormat("de-AT", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
timeZone: "Europe/Vienna",
|
||||
});
|
||||
|
||||
export function AuditDetail({ eintrag }: { eintrag: AuditEintrag }) {
|
||||
const [offen, setOffen] = useState(false);
|
||||
const anzahl = eintrag.changes?.length ?? 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOffen(true)}
|
||||
aria-haspopup="dialog"
|
||||
className="w-full rounded px-2 py-1 text-left text-ink-muted hover:bg-brand-50 hover:text-ink
|
||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
<span>{eintrag.details ?? "–"}</span>
|
||||
{anzahl > 0 && (
|
||||
<span className="ml-2 whitespace-nowrap rounded-full bg-brand-50 px-2 py-0.5 text-[11px] font-semibold text-brand-700">
|
||||
{anzahl} {anzahl === 1 ? "Feld" : "Felder"}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<SlideOver
|
||||
open={offen}
|
||||
onClose={() => setOffen(false)}
|
||||
title={eintrag.target_label}
|
||||
subtitle={`${eintrag.action} · ${zeitFormat.format(new Date(eintrag.occurred_at))}`}
|
||||
>
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-2 text-sm">
|
||||
<dt className="font-semibold text-ink-muted">Aktion</dt>
|
||||
<dd>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${actionBadgeStyle(eintrag.action)}`}>
|
||||
{eintrag.action}
|
||||
</span>
|
||||
</dd>
|
||||
<dt className="font-semibold text-ink-muted">Benutzer:in</dt>
|
||||
<dd className="text-ink">{eintrag.actor_name}</dd>
|
||||
<dt className="font-semibold text-ink-muted">Zeitpunkt</dt>
|
||||
<dd className="tabular-nums text-ink">{zeitFormat.format(new Date(eintrag.occurred_at))}</dd>
|
||||
{eintrag.target_employee_id && (
|
||||
<>
|
||||
<dt className="font-semibold text-ink-muted">Objekt</dt>
|
||||
<dd>
|
||||
<Link
|
||||
href={`/employees/${eintrag.target_employee_id}`}
|
||||
className="rounded font-semibold text-brand-700 hover:underline
|
||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
{eintrag.target_label}
|
||||
</Link>
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{eintrag.details && (
|
||||
<p className="mt-5 rounded-md bg-surface px-3 py-2 text-sm text-ink-body">{eintrag.details}</p>
|
||||
)}
|
||||
|
||||
<h3 className="mt-6 text-sm font-bold text-ink">Geänderte Felder</h3>
|
||||
{anzahl > 0 ? (
|
||||
<div className="mt-2">
|
||||
<AenderungsTabelle changes={eintrag.changes!} />
|
||||
</div>
|
||||
) : (
|
||||
// Kein Aufzählungszeichen für „nichts da“: der Grund ist wichtig,
|
||||
// damit niemand einen Fehler vermutet.
|
||||
<p className="mt-2 max-w-prose text-sm text-ink-muted">
|
||||
Für diesen Eintrag liegen keine Feldwerte vor. Vorgänge wie Eintritt, Austritt oder Import erfassen keine
|
||||
Einzelfelder — und Einträge von vor der Erweiterung des Protokolls haben nur die Feldnamen behalten, nicht
|
||||
die Werte. Nachliefern lässt sich das nicht.
|
||||
</p>
|
||||
)}
|
||||
</SlideOver>
|
||||
</>
|
||||
);
|
||||
}
|
||||
32
components/auth/EntraSignInButton.tsx
Normal file
32
components/auth/EntraSignInButton.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useFormStatus } from "react-dom";
|
||||
|
||||
// Eigene Schaltfläche statt der Button-Komponente: Microsoft gibt für „Sign in
|
||||
// with Microsoft" Fläche, Schrift und Logo vor, und eine magentafarbene
|
||||
// Variante wäre nicht nur regelwidrig, sondern auch irreführend — sie sähe aus
|
||||
// wie eine Aktion *in* dieser Anwendung, während sie in Wirklichkeit auf eine
|
||||
// fremde Anmeldeseite springt.
|
||||
export function EntraSignInButton() {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
aria-busy={pending || undefined}
|
||||
className="inline-flex w-full items-center justify-center gap-3 rounded-md border border-[#8c8c8c] bg-white px-4 py-3
|
||||
text-sm font-semibold text-[#5e5e5e] transition-colors hover:bg-[#f3f3f3]
|
||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500
|
||||
disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<svg viewBox="0 0 21 21" className="h-[18px] w-[18px] shrink-0" aria-hidden="true">
|
||||
<rect x="1" y="1" width="9" height="9" fill="#f25022" />
|
||||
<rect x="11" y="1" width="9" height="9" fill="#7fba00" />
|
||||
<rect x="1" y="11" width="9" height="9" fill="#00a4ef" />
|
||||
<rect x="11" y="11" width="9" height="9" fill="#ffb900" />
|
||||
</svg>
|
||||
{pending ? "Weiterleitung zu Microsoft…" : "Mit Firmenkonto anmelden"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
70
components/dashboard/AnstehendFilter.tsx
Normal file
70
components/dashboard/AnstehendFilter.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
import { actionBadgeStyle } from "@/lib/colors";
|
||||
import {
|
||||
ANSTEHEND_ARTEN,
|
||||
STANDARD_ZEITRAUM,
|
||||
ZEITRAEUME,
|
||||
type AnstehendArt,
|
||||
type Zeitraum,
|
||||
} from "@/lib/dashboard-filter";
|
||||
|
||||
// Die Auswahl wandert in die Adresse; die Seite baut sich damit neu. Das ist
|
||||
// hier nötig und nicht bloss ordentlich: ein längerer Zeitraum bringt Zeilen
|
||||
// ins Spiel, die vorher gar nicht geladen waren.
|
||||
//
|
||||
// router.replace statt push, damit der Zurück-Knopf nicht durch jede einzelne
|
||||
// Filterstellung zurückläuft, und ohne Sprung nach oben — die Karte steht in
|
||||
// der unteren Hälfte, und dorthin sieht gerade, wer hier klickt.
|
||||
|
||||
export function AnstehendFilter({ zeitraum, arten }: { zeitraum: Zeitraum; arten: AnstehendArt[] }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
function setzen(key: string, wert: string | null) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (wert) params.set(key, wert);
|
||||
else params.delete(key);
|
||||
const query = params.toString();
|
||||
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
|
||||
}
|
||||
|
||||
function artUmschalten(art: AnstehendArt) {
|
||||
const alle = ANSTEHEND_ARTEN.map((a) => a.value);
|
||||
const naechste = arten.includes(art) ? arten.filter((a) => a !== art) : [...arten, art];
|
||||
// Nichts ausgewählt heisst wieder alles: eine leere Karte ist keine
|
||||
// Antwort, und der Weg dorthin wäre ein Klick zu weit.
|
||||
setzen("arten", naechste.length === 0 || naechste.length === alle.length ? null : naechste.join(","));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<SegmentedControl<string>
|
||||
value={String(zeitraum)}
|
||||
onChange={(v) => setzen("tage", v === String(STANDARD_ZEITRAUM) ? null : v)}
|
||||
options={ZEITRAEUME.map((t) => ({ value: String(t), label: `${t} Tage` }))}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{ANSTEHEND_ARTEN.map((a) => {
|
||||
const aktiv = arten.includes(a.value);
|
||||
return (
|
||||
<button
|
||||
key={a.value}
|
||||
type="button"
|
||||
onClick={() => artUmschalten(a.value)}
|
||||
aria-pressed={aktiv}
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
|
||||
aktiv ? actionBadgeStyle(a.label) : "bg-surface text-ink-muted hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{a.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { Avatar } from "@/components/ui/Avatar";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { StatusChip } from "@/components/ui/StatusChip";
|
||||
import { fmtFullName, tenure } from "@/lib/format";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
import { DatenAendernPanel } from "./panels/DatenAendernPanel";
|
||||
import { KarenzPanel } from "./panels/KarenzPanel";
|
||||
@@ -21,42 +22,39 @@ import { StammdatenTab } from "./tabs/StammdatenTab";
|
||||
import { VertragTab } from "./tabs/VertragTab";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
type Division = Database["public"]["Tables"]["divisions"]["Row"];
|
||||
type Department = Database["public"]["Tables"]["departments"]["Row"];
|
||||
type Team = Database["public"]["Tables"]["teams"]["Row"];
|
||||
type Location = Database["public"]["Tables"]["locations"]["Row"];
|
||||
type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"];
|
||||
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
|
||||
type NoteRow = Database["public"]["Tables"]["employee_notes"]["Row"];
|
||||
type MiniEmployee = { id: string; first_name: string; last_name: string; job_title: string; status?: string };
|
||||
type OpenPosition = { id: string; position_number: string; title: string; team_id: string; is_lead: boolean };
|
||||
/** Die Planstelle, die die Person heute innehat. */
|
||||
type PlacementInfo = { positionNumber: string; jobTitle: string; isChief: boolean; current: boolean };
|
||||
|
||||
type EmployeeDetailProps = {
|
||||
employee: EmployeeRow;
|
||||
placement: PlacementInfo | null;
|
||||
breadcrumb: string;
|
||||
/** Die Kostenstelle der laufenden Planstellenbesetzung. */
|
||||
kostenstelle: { code: string; name: string } | null;
|
||||
manager: MiniEmployee | null;
|
||||
/** Nur gesetzt, wenn die zuständige Leitung abwesend ist und vertreten wird. */
|
||||
formalManager: MiniEmployee | null;
|
||||
directReports: MiniEmployee[];
|
||||
history: HistoryRow[];
|
||||
dependents: Dependent[];
|
||||
notes: NoteRow[];
|
||||
divisions: Division[];
|
||||
departments: Department[];
|
||||
teams: Team[];
|
||||
locations: Location[];
|
||||
openPositions: OpenPosition[];
|
||||
openPositions: OpenPositionResolved[];
|
||||
};
|
||||
|
||||
type PanelType = "transfer" | "promote" | "karenz" | "daten" | "terminate" | "rehire" | null;
|
||||
const TABS = ["Stammdaten", "Vertrag", "Organisation", "Historie", "HR-Notizen"] as const;
|
||||
|
||||
export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
const { employee, manager, directReports, history, dependents, notes, divisions, departments, teams, locations } = props;
|
||||
const { employee, placement, breadcrumb, kostenstelle, manager, formalManager, directReports, history, dependents, notes, locations, openPositions } = props;
|
||||
const [tab, setTab] = useState<(typeof TABS)[number]>("Stammdaten");
|
||||
const [panel, setPanel] = useState<PanelType>(null);
|
||||
|
||||
const division = divisions.find((d) => d.id === employee.division_id);
|
||||
const team = employee.team_id ? teams.find((t) => t.id === employee.team_id) : undefined;
|
||||
const department = team ? departments.find((d) => d.id === team.department_id) : undefined;
|
||||
const breadcrumb = [division?.name, department?.name, team?.name].filter(Boolean).join(" › ") || "–";
|
||||
const location = locations.find((l) => l.id === employee.location_id);
|
||||
|
||||
const isActive = employee.status === "Aktiv" || employee.status === "Karenz";
|
||||
@@ -79,8 +77,17 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
</h2>
|
||||
<StatusChip status={employee.status} entryDate={employee.entry_date} absenceType={employee.absence_type} />
|
||||
</div>
|
||||
<p className="text-sm text-ink-body">{employee.job_title}</p>
|
||||
<p className="text-xs text-ink-muted">{breadcrumb}</p>
|
||||
<p className="text-sm text-ink-body">{placement?.jobTitle ?? employee.job_title}</p>
|
||||
<p className="text-xs text-ink-muted">
|
||||
{breadcrumb}
|
||||
{placement && (
|
||||
<>
|
||||
{" · Planstelle "}
|
||||
{placement.positionNumber}
|
||||
{placement.isChief && " (Leitung)"}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-ink-muted">
|
||||
Pers.-Nr. {employee.personnel_number}
|
||||
{employee.status !== "Geplant" && <> · Zugehörigkeit: {tenure(employee.entry_date, employee.exit_date)}</>}
|
||||
@@ -101,14 +108,20 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
</>
|
||||
)}
|
||||
{canEditData && <ActionButton icon={Pencil} label="Daten ändern" onClick={() => setPanel("daten")} />}
|
||||
{isActive && (
|
||||
{/* Auch bei einem geplanten Eintritt, und dort heisst es anders:
|
||||
wer nie angetreten ist, „tritt" nicht „aus". Die Person hat
|
||||
noch keinen Tag gearbeitet, und genau dafür gibt es den Grund
|
||||
„No Show" — ohne diesen Knopf bliebe sie auf Dauer als
|
||||
geplanter Eintritt stehen. */}
|
||||
{(isActive || employee.status === "Geplant") && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setPanel("terminate")}
|
||||
className="!border-danger-solid !text-danger-solid hover:!bg-danger-bg"
|
||||
>
|
||||
<XCircle className="h-4 w-4" /> Austritt
|
||||
<XCircle className="h-4 w-4" />
|
||||
{employee.status === "Geplant" ? "Nicht angetreten" : "Austritt"}
|
||||
</Button>
|
||||
)}
|
||||
{employee.status === "Ausgetreten" && (
|
||||
@@ -142,9 +155,16 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
{tab === "Stammdaten" && <StammdatenTab employee={employee} location={location} dependents={dependents} />}
|
||||
{tab === "Vertrag" && <VertragTab employee={employee} />}
|
||||
{tab === "Organisation" && (
|
||||
<OrganisationTab employeeId={employee.id} manager={manager} directReports={directReports} breadcrumb={breadcrumb} />
|
||||
<OrganisationTab
|
||||
employeeId={employee.id}
|
||||
manager={manager}
|
||||
formalManager={formalManager}
|
||||
directReports={directReports}
|
||||
breadcrumb={breadcrumb}
|
||||
kostenstelle={kostenstelle}
|
||||
/>
|
||||
)}
|
||||
{tab === "Historie" && <HistorieTab history={history} />}
|
||||
{tab === "Historie" && <HistorieTab history={history} employeeId={employee.id} />}
|
||||
{tab === "HR-Notizen" && <NotizenTab employeeId={employee.id} notes={notes} />}
|
||||
</div>
|
||||
|
||||
@@ -152,10 +172,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
open={panel === "transfer"}
|
||||
onClose={() => setPanel(null)}
|
||||
employee={employee}
|
||||
divisions={divisions}
|
||||
departments={departments}
|
||||
teams={teams}
|
||||
currentTeamId={employee.team_id}
|
||||
openPositions={openPositions}
|
||||
/>
|
||||
<PromotePanel open={panel === "promote"} onClose={() => setPanel(null)} employee={employee} />
|
||||
<KarenzPanel open={panel === "karenz"} onClose={() => setPanel(null)} employee={employee} />
|
||||
@@ -167,7 +184,12 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
locationCountry={location?.country}
|
||||
/>
|
||||
<TerminatePanel open={panel === "terminate"} onClose={() => setPanel(null)} employee={employee} directReportCount={directReports.length} />
|
||||
<RehirePanel open={panel === "rehire"} onClose={() => setPanel(null)} employee={employee} />
|
||||
<RehirePanel
|
||||
open={panel === "rehire"}
|
||||
onClose={() => setPanel(null)}
|
||||
employee={employee}
|
||||
openPositions={openPositions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import { FILTER_SELECT_CLASS } from "@/components/ui/Field";
|
||||
import { SearchInput } from "@/components/ui/SearchInput";
|
||||
|
||||
type EmployeeFiltersProps = {
|
||||
divisions: { id: string; name: string }[];
|
||||
/** Der ganze Baum, in Tiefensuche-Reihenfolge. */
|
||||
units: { id: string; name: string; unit_type: string }[];
|
||||
depthOf: Map<string, number>;
|
||||
locations: { id: string; name: string }[];
|
||||
};
|
||||
|
||||
@@ -22,7 +24,7 @@ const STATUS_OPTIONS = [
|
||||
{ value: "Ausgetreten", label: "Ausgetreten" },
|
||||
] as const;
|
||||
|
||||
export function EmployeeFilters({ divisions, locations }: EmployeeFiltersProps) {
|
||||
export function EmployeeFilters({ units, depthOf, locations }: EmployeeFiltersProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -55,18 +57,26 @@ export function EmployeeFilters({ divisions, locations }: EmployeeFiltersProps)
|
||||
{/* aria-label rather than a visible label: the filter bar is a single
|
||||
horizontal row, and each select's first option already names it on
|
||||
screen. */}
|
||||
{/* Der ganze Baum, nicht nur die oberste Ebene: die Auswahl greift
|
||||
jeweils auf die Einheit *und alles darunter*, weshalb sich damit
|
||||
auch nach einer einzelnen Abteilung oder einem Team filtern lässt.
|
||||
Eingerückt statt gruppiert, weil optgroup keine Verschachtelung
|
||||
kennt und die Tiefe hier beliebig ist. */}
|
||||
<select
|
||||
aria-label="Nach Bereich filtern"
|
||||
aria-label="Nach Organisationseinheit filtern"
|
||||
defaultValue={searchParams.get("division") ?? ""}
|
||||
onChange={(e) => updateParam("division", e.target.value)}
|
||||
className={FILTER_SELECT_CLASS}
|
||||
>
|
||||
<option value="">Alle Bereiche</option>
|
||||
{divisions.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="">Alle Einheiten</option>
|
||||
{units
|
||||
.filter((u) => u.unit_type !== "Gesellschaft")
|
||||
.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{" ".repeat(Math.max(0, (depthOf.get(u.id) ?? 1) - 1) * 3)}
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label="Nach Status filtern"
|
||||
@@ -94,6 +104,12 @@ export function EmployeeFilters({ divisions, locations }: EmployeeFiltersProps)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* Der Dienstwagen stand hier einmal als eigenes Auswahlfeld. Er ist
|
||||
jetzt eines von rund zwanzig Kriterien unter Berichte, zusammen mit
|
||||
Vertragsart, Kollektivvertrag, Eintrittszeitraum und dem Rest —
|
||||
dort lässt sich die Auswahl auch exportieren, was der eigentliche
|
||||
Zweck der Frage war. In dieser Leiste, die vor allem zum Suchen da
|
||||
ist, wäre er ein Sonderfall unter vielen gleichrangigen. */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
210
components/employees/HistorieBearbeiten.tsx
Normal file
210
components/employees/HistorieBearbeiten.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { Pencil } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { updateHistoryEntry } from "@/actions/employees";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { TextField } from "@/components/ui/Field";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import type { AuditChange } from "@/lib/supabase/types";
|
||||
|
||||
// Berichtigen, nicht neu erfassen.
|
||||
//
|
||||
// „Daten ändern" schreibt eine *neue* Änderung — richtig, wenn sich etwas
|
||||
// wirklich geändert hat. Hier geht es um den anderen Fall: der Vorgang
|
||||
// stimmt, aber der erfasste Wert oder das Datum nicht. Ohne diesen Weg
|
||||
// stünden in der Akte zwei Einträge für eine Änderung, von denen der erste
|
||||
// nie stattgefunden hat.
|
||||
//
|
||||
// Bearbeitet wird nur das **Nachher**. Das Vorher steht daneben, unveränder-
|
||||
// lich: es beschreibt, was vor der Änderung galt, und das lässt sich
|
||||
// nachträglich nicht anders beschliessen.
|
||||
|
||||
export function HistorieBearbeiten({
|
||||
historyId,
|
||||
employeeId,
|
||||
bezeichnung,
|
||||
datum,
|
||||
changes,
|
||||
istZukunft,
|
||||
heute,
|
||||
nurDatum = false,
|
||||
}: {
|
||||
historyId: string;
|
||||
employeeId: string;
|
||||
bezeichnung: string;
|
||||
datum: string;
|
||||
changes: AuditChange[];
|
||||
/** Noch nicht wirksam — dann wird der geplante Vorgang berichtigt, nicht der Stand. */
|
||||
istZukunft: boolean;
|
||||
/** Vom Server, nicht aus new Date(): sonst rechnet der Browser mit seiner
|
||||
* eigenen Zeitzone, und in einer Renderfunktion hat die Uhr ohnehin nichts
|
||||
* verloren. */
|
||||
heute: string;
|
||||
/**
|
||||
* Der Eintritt hat keine Felder, die sich zurücknehmen liessen — nur ein
|
||||
* Datum, das falsch erfasst sein kann. Daran hängt trotzdem einiges: die
|
||||
* erste Planstellenbesetzung, der frühestmögliche Zeitpunkt jedes weiteren
|
||||
* Ereignisses, die Zugehörigkeit. Die Datenbank prüft das und weist
|
||||
* verständlich ab.
|
||||
*/
|
||||
nurDatum?: boolean;
|
||||
}) {
|
||||
const [offen, setOffen] = useState(false);
|
||||
const [laeuft, setLaeuft] = useState(false);
|
||||
const [neuesDatum, setNeuesDatum] = useState(datum);
|
||||
const [werte, setWerte] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(changes.map((c) => [c.feld, c.nachher ?? ""]))
|
||||
);
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
// Morgen aus dem Serverdatum, nicht aus der Uhr des Browsers.
|
||||
const morgen = new Date(Date.parse(heute + "T00:00:00Z") + 86400000).toISOString().slice(0, 10);
|
||||
const etwasGeaendert =
|
||||
neuesDatum !== datum || changes.some((c) => (werte[c.feld] ?? "") !== (c.nachher ?? ""));
|
||||
|
||||
function abbrechen() {
|
||||
// Beim Schliessen zurück auf den gespeicherten Stand, damit ein zweites
|
||||
// Öffnen nicht die verworfenen Eingaben zeigt.
|
||||
setNeuesDatum(datum);
|
||||
setWerte(Object.fromEntries(changes.map((c) => [c.feld, c.nachher ?? ""])));
|
||||
setOffen(false);
|
||||
}
|
||||
|
||||
async function speichern() {
|
||||
// Ein Eintrag bleibt auf seiner Seite der Gegenwart. Eine gelaufene
|
||||
// Änderung in eine geplante zu verwandeln (oder umgekehrt) hiesse,
|
||||
// Stammdaten und Vorgang gegenläufig anzupassen — dafür gibt es die
|
||||
// fachlichen Vorgänge. Die Datenbank weist es ohnehin ab; hier steht es
|
||||
// nur früher und freundlicher.
|
||||
// Beim Eintritt gilt das nicht: er darf in der Vergangenheit *und* in der
|
||||
// Zukunft liegen — ein geplanter Eintritt ist ein gewöhnlicher Fall. Was
|
||||
// dort zusammenpassen muss, prüft die Datenbank und sagt es verständlich.
|
||||
if (!nurDatum && istZukunft && neuesDatum <= heute) {
|
||||
showToast("Eine geplante Änderung lässt sich hier nicht vorziehen.", "error");
|
||||
return;
|
||||
}
|
||||
if (!nurDatum && !istZukunft && neuesDatum > heute) {
|
||||
showToast("Eine bereits wirksame Änderung lässt sich nicht in die Zukunft verschieben.", "error");
|
||||
return;
|
||||
}
|
||||
setLaeuft(true);
|
||||
const ergebnis = await updateHistoryEntry({
|
||||
history_id: historyId,
|
||||
employee_id: employeeId,
|
||||
event_date: neuesDatum,
|
||||
werte: changes.map((c) => ({ feld: c.feld, nachher: werte[c.feld]?.trim() || null })),
|
||||
});
|
||||
setLaeuft(false);
|
||||
if (ergebnis.success) {
|
||||
showToast("Eintrag berichtigt.");
|
||||
setOffen(false);
|
||||
router.refresh();
|
||||
} else {
|
||||
showToast(ergebnis.error ?? "Berichtigen fehlgeschlagen.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOffen(true)}
|
||||
aria-label={`${bezeichnung} vom ${fmtDate(datum)} bearbeiten`}
|
||||
className="rounded p-1 text-ink-muted hover:bg-brand-50 hover:text-brand-700
|
||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
<Modal
|
||||
open={offen}
|
||||
onClose={abbrechen}
|
||||
title="Eintrag berichtigen"
|
||||
widthClassName="max-w-2xl"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={abbrechen}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={speichern} pending={laeuft} disabled={!etwasGeaendert}>
|
||||
Berichtigen
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-ink-body">
|
||||
<strong className="text-ink">{bezeichnung}</strong>
|
||||
{nurDatum
|
||||
? " — der Eintritt selbst bleibt; berichtigt wird nur sein Datum."
|
||||
: istZukunft
|
||||
? " — diese Änderung ist noch nicht wirksam. Berichtigt wird, was am Stichtag passieren soll."
|
||||
: " — was hier stand, war falsch erfasst. Für eine tatsächliche Änderung ist „Daten ändern“ der richtige Weg."}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 max-w-xs">
|
||||
<TextField
|
||||
label={nurDatum ? "Eintrittsdatum" : "Wirksam ab"}
|
||||
type="date"
|
||||
min={!nurDatum && istZukunft ? morgen : undefined}
|
||||
max={!nurDatum && !istZukunft ? heute : undefined}
|
||||
value={neuesDatum}
|
||||
onChange={setNeuesDatum}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{nurDatum && (
|
||||
<p className="mt-4 rounded bg-surface px-3 py-2 text-xs text-ink-muted">
|
||||
Daran hängt mehr als eine Zahl: die erste Planstellenbesetzung wandert mit, und kein anderes Ereignis darf
|
||||
vor dem Eintritt liegen. Passt das neue Datum nicht dazu, wird die Änderung mit dem Grund abgewiesen.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={`mt-5 overflow-x-auto ${nurDatum ? "hidden" : ""}`}>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-[11px] font-bold uppercase tracking-wider text-ink-muted">
|
||||
<th className="py-2 pr-4">Feld</th>
|
||||
<th className="py-2 pr-4">Vorher</th>
|
||||
<th className="py-2">Nachher</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{changes.map((c) => (
|
||||
<tr key={c.feld} className="border-b border-border-subtle align-middle last:border-0">
|
||||
<td className="py-2 pr-4 font-semibold text-ink-body">{c.feld}</td>
|
||||
<td className="py-2 pr-4 text-ink-muted">
|
||||
{c.vorher === null || c.vorher === "" ? <span className="italic">leer</span> : c.vorher}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<input
|
||||
type="text"
|
||||
aria-label={`${c.feld} — neuer Wert`}
|
||||
value={werte[c.feld] ?? ""}
|
||||
onChange={(e) => setWerte((v) => ({ ...v, [c.feld]: e.target.value }))}
|
||||
className="w-full rounded border border-border px-2 py-1 text-sm text-ink
|
||||
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 rounded bg-surface px-3 py-2 text-xs text-ink-muted">
|
||||
{nurDatum
|
||||
? "Stammdaten, Historie und Planstellenbesetzung werden gemeinsam nachgezogen."
|
||||
: istZukunft
|
||||
? "An den Stammdaten ändert sich jetzt nichts — die Änderung greift erst am Stichtag. Berichtigt wird der geplante Vorgang selbst."
|
||||
: "Die Stammdaten werden nachgezogen — je Feld gilt dann der jüngste Eintrag, der es trägt. Hat eine spätere Änderung dasselbe Feld erneut gesetzt, bleibt deren Wert stehen."}{" "}
|
||||
Die Berichtigung selbst steht im Protokoll.
|
||||
</p>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
162
components/employees/HistorieLoeschen.tsx
Normal file
162
components/employees/HistorieLoeschen.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { deleteHistoryEntry } from "@/actions/employees";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import type { Vorschau } from "@/lib/history";
|
||||
|
||||
// Löschen mit Ansage.
|
||||
//
|
||||
// Bestätigen heisst hier nicht „Wirklich?" — das beantwortet jede Person nach
|
||||
// dem dritten Mal blind mit Ja. Der Dialog sagt stattdessen, **was danach
|
||||
// anders ist**: welches Feld auf welchen Wert zurückgeht, und welches nicht,
|
||||
// weil eine spätere Änderung es erneut angefasst hat. Wer das liest, merkt
|
||||
// selbst, ob er den richtigen Eintrag erwischt hat.
|
||||
|
||||
export function HistorieLoeschen({
|
||||
historyId,
|
||||
employeeId,
|
||||
bezeichnung,
|
||||
datum,
|
||||
vorschau,
|
||||
istZukunft,
|
||||
}: {
|
||||
historyId: string;
|
||||
employeeId: string;
|
||||
bezeichnung: string;
|
||||
datum: string;
|
||||
vorschau: Vorschau[];
|
||||
/** Noch nicht wirksam — dann wird der geplante Vorgang entschärft. */
|
||||
istZukunft: boolean;
|
||||
}) {
|
||||
const [offen, setOffen] = useState(false);
|
||||
const [laeuft, setLaeuft] = useState(false);
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const zurueck = vorschau.filter((v) => !v.bleibt);
|
||||
const bleibt = vorschau.filter((v) => v.bleibt);
|
||||
// Eine geplante Abwesenheit oder Rückkehr hat keine einzelnen Felder, die
|
||||
// sich herausnehmen liessen — sie fällt als Ganzes. Dann ist „Löschen und
|
||||
// zurücksetzen" das falsche Wort für das, was der Knopf tut.
|
||||
const ganzerVorgang = istZukunft && vorschau.length === 0;
|
||||
|
||||
async function loeschen() {
|
||||
setLaeuft(true);
|
||||
const ergebnis = await deleteHistoryEntry({ history_id: historyId, employee_id: employeeId });
|
||||
setLaeuft(false);
|
||||
if (ergebnis.success) {
|
||||
showToast("Eintrag gelöscht.");
|
||||
setOffen(false);
|
||||
router.refresh();
|
||||
} else {
|
||||
showToast(ergebnis.error ?? "Löschen fehlgeschlagen.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOffen(true)}
|
||||
aria-label={`${bezeichnung} vom ${fmtDate(datum)} löschen`}
|
||||
className="rounded p-1 text-ink-muted hover:bg-danger-bg hover:text-danger-text
|
||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
<Modal
|
||||
open={offen}
|
||||
onClose={() => setOffen(false)}
|
||||
title={ganzerVorgang ? "Geplanten Vorgang abbrechen" : "Eintrag löschen"}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setOffen(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={loeschen} pending={laeuft} className="!bg-danger-solid text-white hover:brightness-110">
|
||||
{ganzerVorgang ? "Vorgang abbrechen" : "Löschen und zurücksetzen"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-ink-body">
|
||||
<strong className="text-ink">{bezeichnung}</strong> {istZukunft ? "zum" : "vom"} {fmtDate(datum)} wird aus der
|
||||
Historie entfernt.
|
||||
</p>
|
||||
|
||||
{ganzerVorgang && (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wide text-ink-muted">Wird nicht mehr passieren</h3>
|
||||
<p className="mt-1.5 text-sm text-ink-body">
|
||||
Der Vorgang entfällt ganz — die {bezeichnung} zum {fmtDate(datum)} findet nicht statt. Am Stammsatz wird
|
||||
das vorgemerkte Datum mit entfernt.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-ink-muted">
|
||||
An den heutigen Stammdaten ändert sich nichts: der Vorgang war noch nicht wirksam.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{istZukunft && vorschau.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wide text-ink-muted">Wird nicht mehr passieren</h3>
|
||||
<ul className="mt-1.5 flex flex-col gap-1">
|
||||
{vorschau.map((v) => (
|
||||
<li key={v.feld} className="text-sm text-ink-body">
|
||||
<span className="font-semibold text-ink">{v.feld}</span>{" "}
|
||||
<span className="text-ink-muted">sollte auf</span> <span className="text-ink">{v.von || "leer"}</span>{" "}
|
||||
<span className="text-ink-muted">gesetzt werden</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-2 text-xs text-ink-muted">
|
||||
An den Stammdaten ändert sich nichts — die Änderung war noch nicht wirksam. Betrifft der geplante Vorgang
|
||||
noch weitere Felder, läuft er mit diesen weiter.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!istZukunft && zurueck.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wide text-ink-muted">Wird zurückgesetzt</h3>
|
||||
<ul className="mt-1.5 flex flex-col gap-1">
|
||||
{zurueck.map((v) => (
|
||||
<li key={v.feld} className="text-sm text-ink-body">
|
||||
<span className="font-semibold text-ink">{v.feld}</span>{" "}
|
||||
<span className="text-ink-muted line-through decoration-ink-muted/40">{v.von || "leer"}</span>{" "}
|
||||
<span aria-hidden="true">→</span> <span className="text-ink">{v.auf || "leer"}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!istZukunft && bleibt.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wide text-ink-muted">Bleibt unverändert</h3>
|
||||
<ul className="mt-1.5 flex flex-col gap-1">
|
||||
{bleibt.map((v) => (
|
||||
<li key={v.feld} className="text-sm text-ink-muted">
|
||||
<span className="font-semibold">{v.feld}</span> — eine spätere Änderung hat dieses Feld erneut
|
||||
gesetzt, und die gilt weiter.
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-4 rounded bg-surface px-3 py-2 text-xs text-ink-muted">
|
||||
Der Vorgang wird im Protokoll festgehalten — mit Zeitpunkt, Person und den Werten der gelöschten Zeile. Die
|
||||
Zeile selbst lässt sich nicht wiederherstellen.
|
||||
</p>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { SelectField } from "@/components/ui/Field";
|
||||
import type { CollectiveAgreement, Weekday, WorkerType } from "@/lib/supabase/types";
|
||||
import { SelectField, TextField } from "@/components/ui/Field";
|
||||
import type { CollectiveAgreement, DienstwagenArt, Weekday, WorkerType } from "@/lib/supabase/types";
|
||||
|
||||
const WEEKDAYS: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||
|
||||
@@ -11,8 +11,25 @@ export type RoleEmploymentValue = {
|
||||
workDays: Weekday[];
|
||||
isBetriebsrat: boolean;
|
||||
hasDienstwagen: boolean;
|
||||
/**
|
||||
* Nur bedeutsam, solange hasDienstwagen gesetzt ist.
|
||||
*
|
||||
* Der Wert bleibt beim Abwählen stehen, statt zurückgesetzt zu werden —
|
||||
* wer versehentlich klickt und zurückklickt, findet seine Angabe wieder.
|
||||
* Beim Speichern setzen die Aufrufer ihn auf null, wie es der CHECK
|
||||
* verlangt.
|
||||
*/
|
||||
dienstwagenArt: DienstwagenArt;
|
||||
isLateraleFuehrung: boolean;
|
||||
isCLevel: boolean;
|
||||
/** Betriebsrat, Mutterschutz, Karenz, begünstigte Behinderung, Lehre. */
|
||||
hasKuendigungsschutz: boolean;
|
||||
/**
|
||||
* Ende des Schutzes — freiwillig. Bei einem Betriebsratsmandat steht es
|
||||
* fest, bei einer Schwangerschaft nicht; ein Pflichtfeld zwänge dort zu
|
||||
* einer erfundenen Zahl. Leer heisst „bis auf Weiteres".
|
||||
*/
|
||||
kuendigungsschutzBis: string;
|
||||
};
|
||||
|
||||
// Shared by the hire wizard (StepVertrag) and DatenAendernPanel — both edit
|
||||
@@ -78,6 +95,21 @@ export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymen
|
||||
<input type="checkbox" checked={value.hasDienstwagen} onChange={(e) => onChange({ hasDienstwagen: e.target.checked })} />
|
||||
Dienstwagen
|
||||
</label>
|
||||
{/* Nur sichtbar, wenn es einen gibt: eine Antriebsart ohne Fahrzeug
|
||||
ist keine Angabe, sondern eine Frage ohne Gegenstand — und die
|
||||
Datenbank weist sie ab. */}
|
||||
{value.hasDienstwagen && (
|
||||
<SelectField
|
||||
label="Antriebsart"
|
||||
dense
|
||||
value={value.dienstwagenArt}
|
||||
onChange={(v) => onChange({ dienstwagenArt: v as DienstwagenArt })}
|
||||
options={[
|
||||
{ value: "Verbrenner", label: "Verbrenner" },
|
||||
{ value: "Elektro", label: "Elektro (E-KFZ)" },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-sm text-ink-body">
|
||||
<input type="checkbox" checked={value.isLateraleFuehrung} onChange={(e) => onChange({ isLateraleFuehrung: e.target.checked })} />
|
||||
Laterale Führung
|
||||
@@ -86,6 +118,27 @@ export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymen
|
||||
<input type="checkbox" checked={value.isCLevel} onChange={(e) => onChange({ isCLevel: e.target.checked })} />
|
||||
C-Level
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-ink-body">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value.hasKuendigungsschutz}
|
||||
onChange={(e) => onChange({ hasKuendigungsschutz: e.target.checked })}
|
||||
/>
|
||||
Besonderer Kündigungsschutz
|
||||
</label>
|
||||
{/* Wie bei der Antriebsart: erst sichtbar, wenn es einen Gegenstand
|
||||
gibt. Anders als dort aber freiwillig — leer heisst „bis auf
|
||||
Weiteres", nicht „vergessen". */}
|
||||
{value.hasKuendigungsschutz && (
|
||||
<TextField
|
||||
label="Geschützt bis"
|
||||
dense
|
||||
type="date"
|
||||
value={value.kuendigungsschutzBis}
|
||||
onChange={(v) => onChange({ kuendigungsschutzBis: v })}
|
||||
hint="Optional. Leer lassen, solange das Ende nicht feststeht."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,10 +12,11 @@ import { CountryPicker } from "@/components/ui/CountryPicker";
|
||||
import { Field, SelectField, TextField } from "@/components/ui/Field";
|
||||
import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { UN_COUNTRIES } from "@/lib/countries";
|
||||
import { brauchtAufenthaltstitel, UN_COUNTRIES } from "@/lib/countries";
|
||||
import { STUNDEN_GRUENDE } from "@/lib/absence";
|
||||
import { fmtFullName, todayIso } from "@/lib/format";
|
||||
import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr";
|
||||
import type { ContractType, Database, EmploymentType, GenderType } from "@/lib/supabase/types";
|
||||
import { EMERGENCY_RELATIONS, type ContractType, type Database, type EmploymentType, type GenderType } from "@/lib/supabase/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
|
||||
@@ -52,15 +53,28 @@ export function DatenAendernPanel({
|
||||
const svNummerOk =
|
||||
!svNummer.trim() || !requiresAustrianSvnr(locationCountry) || isValidSvnr(svNummer, birthDate || null);
|
||||
const [nationality, setNationality] = useState(employee.nationality);
|
||||
const [hatTitel, setHatTitel] = useState(employee.hat_aufenthaltstitel ?? false);
|
||||
const [titelBis, setTitelBis] = useState(employee.aufenthaltstitel_bis ?? "");
|
||||
// Die Staatsbürgerschaft entscheidet, ob die Frage überhaupt gestellt wird.
|
||||
const titelNoetig = brauchtAufenthaltstitel(nationality);
|
||||
const [address, setAddress] = useState(employee.address ?? "");
|
||||
const [postalCode, setPostalCode] = useState(employee.postal_code ?? "");
|
||||
const [city, setCity] = useState(employee.city ?? "");
|
||||
const [addressCountry, setAddressCountry] = useState(employee.address_country ?? "Österreich");
|
||||
const [email, setEmail] = useState(employee.email);
|
||||
const [email, setEmail] = useState(employee.email ?? "");
|
||||
const [phone, setPhone] = useState(employee.phone ?? "");
|
||||
const [notfallName, setNotfallName] = useState(employee.emergency_contact_name ?? "");
|
||||
const [notfallTelefon, setNotfallTelefon] = useState(employee.emergency_contact_phone ?? "");
|
||||
const [notfallVerhaeltnis, setNotfallVerhaeltnis] = useState(employee.emergency_contact_relation ?? "");
|
||||
|
||||
const [employmentType, setEmploymentType] = useState<EmploymentType>(employee.employment_type);
|
||||
const [weeklyHours, setWeeklyHours] = useState(String(employee.weekly_hours));
|
||||
// Der Grund wird erst gefragt, wenn sich die Stunden tatsächlich ändern —
|
||||
// sonst stünde bei jeder Adressänderung eine Frage im Weg, die niemand
|
||||
// gestellt hat.
|
||||
const [stundenGrund, setStundenGrund] = useState<string>(STUNDEN_GRUENDE[0]);
|
||||
const stundenGeaendert = Number(weeklyHours) !== Number(employee.weekly_hours);
|
||||
const [teilzeitBis, setTeilzeitBis] = useState(employee.teilzeit_bis ?? "");
|
||||
const [contractType, setContractType] = useState<ContractType>(employee.contract_type);
|
||||
const [contractEndDate, setContractEndDate] = useState(employee.contract_end_date ?? "");
|
||||
|
||||
@@ -70,6 +84,9 @@ export function DatenAendernPanel({
|
||||
workDays: employee.work_days ?? ["Mo", "Di", "Mi", "Do", "Fr"],
|
||||
isBetriebsrat: employee.is_betriebsrat ?? false,
|
||||
hasDienstwagen: employee.has_dienstwagen ?? false,
|
||||
hasKuendigungsschutz: employee.has_kuendigungsschutz ?? false,
|
||||
kuendigungsschutzBis: employee.kuendigungsschutz_bis ?? "",
|
||||
dienstwagenArt: employee.dienstwagen_art ?? "Verbrenner",
|
||||
isLateraleFuehrung: employee.is_laterale_fuehrung ?? false,
|
||||
isCLevel: employee.is_c_level ?? false,
|
||||
});
|
||||
@@ -99,6 +116,12 @@ export function DatenAendernPanel({
|
||||
showToast("Bitte ein Wirksam-ab-Datum angeben.", "error");
|
||||
return;
|
||||
}
|
||||
// Name und Nummer gehören zusammen — die Datenbank weist eines ohne das
|
||||
// andere ab, und die Meldung dort erklärt es nicht.
|
||||
if (Boolean(notfallName.trim()) !== Boolean(notfallTelefon.trim())) {
|
||||
showToast("Beim Notfallkontakt braucht es Name und Telefonnummer — oder beides leer.", "error");
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
const result = await changeEmployeeData({
|
||||
employee_id: employee.id,
|
||||
@@ -112,12 +135,21 @@ export function DatenAendernPanel({
|
||||
birth_date: birthDate,
|
||||
sv_nummer: svNummer,
|
||||
nationality,
|
||||
// Wechselt die Staatsbürgerschaft in den Freizügigkeitsraum, fällt
|
||||
// der Titel weg — sonst bliebe er als Rest an einer Person hängen,
|
||||
// die ihn nicht mehr braucht. Die Datenbank prüft diese Kopplung
|
||||
// bewusst nicht (siehe Migration), also gehört sie hierher.
|
||||
hat_aufenthaltstitel: titelNoetig ? hatTitel : false,
|
||||
aufenthaltstitel_bis: titelNoetig && hatTitel ? titelBis : "",
|
||||
address,
|
||||
postal_code: postalCode,
|
||||
city,
|
||||
address_country: addressCountry,
|
||||
email,
|
||||
phone,
|
||||
emergency_contact_name: notfallName.trim(),
|
||||
emergency_contact_phone: notfallTelefon.trim(),
|
||||
emergency_contact_relation: notfallVerhaeltnis.trim(),
|
||||
},
|
||||
contract: {
|
||||
employment_type: employmentType,
|
||||
@@ -131,6 +163,19 @@ export function DatenAendernPanel({
|
||||
work_days: role.workDays,
|
||||
is_betriebsrat: role.isBetriebsrat,
|
||||
has_dienstwagen: role.hasDienstwagen,
|
||||
dienstwagen_art: role.hasDienstwagen ? role.dienstwagenArt : "",
|
||||
has_kuendigungsschutz: role.hasKuendigungsschutz,
|
||||
kuendigungsschutz_bis: role.hasKuendigungsschutz ? role.kuendigungsschutzBis : "",
|
||||
// Die Teilzeitvariante wird nur mitgeschickt, wenn sich die Stunden
|
||||
// tatsächlich ändern. Sonst schriebe jede Adressänderung den Wert
|
||||
// erneut — und setzte ihn bei „Vertragliche Stundenänderung" sogar
|
||||
// zurück, obwohl niemand die Stunden angefasst hat.
|
||||
...(stundenGeaendert
|
||||
? {
|
||||
teilzeit_art: stundenGrund === "Vertragliche Stundenänderung" ? "" : stundenGrund,
|
||||
teilzeit_bis: stundenGrund === "Vertragliche Stundenänderung" ? "" : teilzeitBis,
|
||||
}
|
||||
: {}),
|
||||
is_laterale_fuehrung: role.isLateraleFuehrung,
|
||||
is_c_level: role.isCLevel,
|
||||
},
|
||||
@@ -197,6 +242,34 @@ export function DatenAendernPanel({
|
||||
<CountryPicker {...p} value={nationality} onChange={setNationality} countries={UN_COUNTRIES} placeholder="Staatsbürgerschaft suchen…" />
|
||||
)}
|
||||
</Field>
|
||||
{/* Nur wo er verlangt ist. Bei Freizügigkeit — EU, EWR, Schweiz —
|
||||
wäre die Frage gegenstandslos, und ein „Nein" im Formular
|
||||
sähe aus wie eine Auskunft. */}
|
||||
{titelNoetig && (
|
||||
<>
|
||||
<SelectField
|
||||
label="Aufenthaltstitel"
|
||||
dense
|
||||
value={hatTitel ? "ja" : "nein"}
|
||||
onChange={(v) => setHatTitel(v === "ja")}
|
||||
options={[
|
||||
{ value: "nein", label: "Nein" },
|
||||
{ value: "ja", label: "Ja" },
|
||||
]}
|
||||
hint="Bei Staatsbürgerschaften ausserhalb von EU, EWR und Schweiz."
|
||||
/>
|
||||
{hatTitel && (
|
||||
<TextField
|
||||
label="Aufenthaltstitel gültig bis"
|
||||
dense
|
||||
type="date"
|
||||
value={titelBis}
|
||||
onChange={setTitelBis}
|
||||
hint="Optional. Leer lassen, wenn unbefristet."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<TextField label="Adresse (Straße und Hausnummer)" dense value={address} onChange={setAddress} />
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] gap-3">
|
||||
<TextField label="Postleitzahl" dense inputMode="numeric" value={postalCode} onChange={setPostalCode} />
|
||||
@@ -208,6 +281,24 @@ export function DatenAendernPanel({
|
||||
<TextField label="E-Mail" dense type="email" value={email} onChange={setEmail} />
|
||||
<TextField label="Telefon" dense type="tel" value={phone} onChange={setPhone} />
|
||||
</div>
|
||||
|
||||
{/* Eigener Block: im Ernstfall greift jemand danach, und dann darf
|
||||
er nicht zwischen den Adressfeldern der Person untergehen. */}
|
||||
<fieldset className="mt-4 rounded-md border border-border-subtle p-3">
|
||||
<legend className="px-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Notfallkontakt</legend>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<TextField label="Name" dense value={notfallName} onChange={setNotfallName} />
|
||||
<TextField label="Telefon" dense type="tel" value={notfallTelefon} onChange={setNotfallTelefon} />
|
||||
<SelectField
|
||||
label="Verhältnis"
|
||||
dense
|
||||
value={notfallVerhaeltnis}
|
||||
onChange={setNotfallVerhaeltnis}
|
||||
placeholder="Bitte wählen…"
|
||||
options={EMERGENCY_RELATIONS.map((r) => ({ value: r, label: r }))}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -232,6 +323,26 @@ export function DatenAendernPanel({
|
||||
disabled={employmentType === "Vollzeit"}
|
||||
onChange={setWeeklyHours}
|
||||
/>
|
||||
{stundenGeaendert && (
|
||||
<SelectField
|
||||
label="Grund der Stundenänderung"
|
||||
dense
|
||||
value={stundenGrund}
|
||||
onChange={setStundenGrund}
|
||||
options={STUNDEN_GRUENDE.map((g) => ({ value: g, label: g }))}
|
||||
hint="Steht danach am Profil und lässt sich auswerten."
|
||||
/>
|
||||
)}
|
||||
{stundenGeaendert && stundenGrund !== "Vertragliche Stundenänderung" && (
|
||||
<TextField
|
||||
label="Teilzeit bis"
|
||||
dense
|
||||
type="date"
|
||||
value={teilzeitBis}
|
||||
onChange={setTeilzeitBis}
|
||||
hint="Optional. Leer lassen, solange das Ende nicht feststeht."
|
||||
/>
|
||||
)}
|
||||
<SelectField
|
||||
label="Vertragsart"
|
||||
dense
|
||||
|
||||
@@ -8,8 +8,8 @@ import { SelectField, TextField, TextareaField } from "@/components/ui/Field";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { ABSENCE_TYPES, absenceLabel } from "@/lib/absence";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import { ABSENCE_TYPES, absenceLabel, RUECKKEHR_GRUENDE } from "@/lib/absence";
|
||||
import { fmtDate, fmtName } from "@/lib/format";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
@@ -33,7 +33,18 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
|
||||
|
||||
const [returnDate, setReturnDate] = useState("");
|
||||
const [employmentMode, setEmploymentMode] = useState<EmploymentMode>("unverändert");
|
||||
const [weeklyHours, setWeeklyHours] = useState("20");
|
||||
// Warum weniger Stunden: Wiedereingliederungs- oder Elternteilzeit. Beide
|
||||
// beginnen typischerweise genau dann, wenn die Abwesenheit endet — deshalb
|
||||
// steht die Frage hier und nicht in einem zweiten Vorgang danach.
|
||||
const [reduktionsgrund, setReduktionsgrund] = useState<string>("");
|
||||
const [teilzeitBis, setTeilzeitBis] = useState("");
|
||||
// Die Stunden, die vor der Abwesenheit galten — mit Komma, wie man sie
|
||||
// hierzulande schreibt.
|
||||
const stundenText = String(employee.weekly_hours).replace(".", ",");
|
||||
// Absichtlich leer statt vorbelegt: eine Zahl, die schon dasteht, wird
|
||||
// bestätigt statt erfasst. Die reduzierten Stunden stehen in einer
|
||||
// Vereinbarung, und die muss jemand ablesen.
|
||||
const [weeklyHours, setWeeklyHours] = useState("");
|
||||
|
||||
const diffDays =
|
||||
isOnKarenz && employee.karenz_return_date && newReturnDate
|
||||
@@ -89,9 +100,15 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
|
||||
showToast("Bitte Rückkehrdatum angeben.", "error");
|
||||
return;
|
||||
}
|
||||
if (employmentMode === "Teilzeit" && (Number(weeklyHours) <= 0 || Number(weeklyHours) >= 38.5)) {
|
||||
showToast("Wochenstunden müssen zwischen 0 und 38,5 liegen.", "error");
|
||||
return;
|
||||
if (employmentMode === "Teilzeit") {
|
||||
if (!weeklyHours.trim()) {
|
||||
showToast("Bitte die reduzierten Wochenstunden erfassen.", "error");
|
||||
return;
|
||||
}
|
||||
if (Number(weeklyHours) <= 0 || Number(weeklyHours) >= 38.5) {
|
||||
showToast("Wochenstunden müssen zwischen 0 und 38,5 liegen.", "error");
|
||||
return;
|
||||
}
|
||||
}
|
||||
setPending(true);
|
||||
const result = await recordKarenzReturn({
|
||||
@@ -99,6 +116,8 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
|
||||
return_date: returnDate,
|
||||
employment_mode: employmentMode,
|
||||
weekly_hours: employmentMode === "Teilzeit" ? Number(weeklyHours) : undefined,
|
||||
reduction_reason: employmentMode === "Teilzeit" ? reduktionsgrund || undefined : undefined,
|
||||
teilzeit_bis: employmentMode === "Teilzeit" && reduktionsgrund ? teilzeitBis || undefined : undefined,
|
||||
});
|
||||
setPending(false);
|
||||
if (result.success) {
|
||||
@@ -115,7 +134,7 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={isOnKarenz ? "Langzeitabwesenheit verwalten" : "Langzeitabwesenheit erfassen"}
|
||||
subtitle={`${employee.first_name} ${employee.last_name} · ${employee.job_title}`}
|
||||
subtitle={`${fmtName(employee.first_name, employee.last_name)} · ${employee.job_title}`}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
@@ -191,22 +210,46 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
|
||||
value={employmentMode}
|
||||
onChange={(v) => setEmploymentMode(v as EmploymentMode)}
|
||||
options={[
|
||||
{ value: "unverändert", label: "unverändert" },
|
||||
{ value: "Vollzeit", label: "Vollzeit (38,5h)" },
|
||||
{ value: "Teilzeit", label: "Teilzeit-Elternteilzeit" },
|
||||
// Die Stunden, die zuletzt gearbeitet wurden, stehen in der
|
||||
// Beschriftung. „unverändert" allein zwang dazu, in der
|
||||
// Akte nachzusehen, worauf man sich da einlässt.
|
||||
{ value: "unverändert", label: `Wie vor Abwesenheit (${stundenText} h)` },
|
||||
{ value: "Teilzeit", label: "Reduziert" },
|
||||
]}
|
||||
/>
|
||||
{employmentMode === "Teilzeit" && (
|
||||
<TextField
|
||||
label="Wochenstunden"
|
||||
required
|
||||
type="number"
|
||||
step="0.5"
|
||||
max="38"
|
||||
value={weeklyHours}
|
||||
onChange={setWeeklyHours}
|
||||
hint="Muss unter 38,5 liegen."
|
||||
/>
|
||||
<>
|
||||
<TextField
|
||||
label="Reduzierte Wochenstunden"
|
||||
required
|
||||
type="number"
|
||||
step="0.5"
|
||||
max="38"
|
||||
value={weeklyHours}
|
||||
onChange={setWeeklyHours}
|
||||
placeholder={`weniger als ${stundenText}`}
|
||||
hint="Muss unter 38,5 liegen."
|
||||
/>
|
||||
<SelectField
|
||||
label="Grund der Reduktion"
|
||||
value={reduktionsgrund}
|
||||
onChange={setReduktionsgrund}
|
||||
options={[
|
||||
{ value: "", label: "Ohne besonderen Grund" },
|
||||
...RUECKKEHR_GRUENDE.map((g) => ({ value: g, label: g })),
|
||||
]}
|
||||
hint="Steht danach am Profil und lässt sich auswerten."
|
||||
/>
|
||||
{reduktionsgrund && (
|
||||
<TextField
|
||||
label="Teilzeit bis"
|
||||
type="date"
|
||||
value={teilzeitBis}
|
||||
onChange={setTeilzeitBis}
|
||||
hint="Optional. Leer lassen, solange das Ende nicht feststeht."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { SelectField, TextField } from "@/components/ui/Field";
|
||||
import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import type { Database, PaygradeType } from "@/lib/supabase/types";
|
||||
import { fmtName } from "@/lib/format";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
|
||||
@@ -55,7 +56,7 @@ export function PromotePanel({ open, onClose, employee }: { open: boolean; onClo
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Beförderung"
|
||||
subtitle={`${employee.first_name} ${employee.last_name} · ${employee.job_title}`}
|
||||
subtitle={`${fmtName(employee.first_name, employee.last_name)} · ${employee.job_title}`}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
|
||||
@@ -1,30 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { rehireEmployee } from "@/actions/employees";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { TextField } from "@/components/ui/Field";
|
||||
import { SelectField, TextField } from "@/components/ui/Field";
|
||||
import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import { fmtDate, fmtName } from "@/lib/format";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
|
||||
export function RehirePanel({ open, onClose, employee }: { open: boolean; onClose: () => void; employee: EmployeeRow }) {
|
||||
// Eine Wiedereinstellung braucht eine Planstelle — genau wie eine
|
||||
// Neueinstellung.
|
||||
//
|
||||
// Die alte Stelle ist dafür kein Ersatz: sie kann inzwischen besetzt,
|
||||
// ausgelaufen oder ganz entfallen sein. `rehire_employee` verlangte die
|
||||
// Angabe deshalb schon immer und wies den Aufruf sonst ab; nur schickte das
|
||||
// Formular sie nie mit, sodass jede Wiedereinstellung an einer Meldung
|
||||
// scheiterte, die sich im Dialog gar nicht beheben liess.
|
||||
export function RehirePanel({
|
||||
open,
|
||||
onClose,
|
||||
employee,
|
||||
openPositions,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
employee: EmployeeRow;
|
||||
openPositions: OpenPositionResolved[];
|
||||
}) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [rehireDate, setRehireDate] = useState("");
|
||||
const [positionId, setPositionId] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
openPositions
|
||||
.slice()
|
||||
.sort((a, b) => a.orgLabel.localeCompare(b.orgLabel, "de") || a.title.localeCompare(b.title, "de"))
|
||||
.map((p) => ({ value: p.id, label: `${p.orgLabel} · ${p.title} (${p.position_number})` })),
|
||||
[openPositions]
|
||||
);
|
||||
|
||||
const selected = openPositions.find((p) => p.id === positionId);
|
||||
|
||||
// Die Datenbank weist eine Besetzung ausserhalb der Gültigkeit ab. Das hier
|
||||
// nimmt die Meldung vorweg, solange sie noch etwas nützt — im Dialog, mit
|
||||
// beiden Daten sichtbar.
|
||||
const ausserhalb =
|
||||
selected && rehireDate
|
||||
? rehireDate < selected.valid_from
|
||||
? `Diese Planstelle gilt erst ab ${fmtDate(selected.valid_from)}.`
|
||||
: selected.valid_to && rehireDate >= selected.valid_to
|
||||
? `Diese Planstelle gilt nur bis ${fmtDate(selected.valid_to)}.`
|
||||
: null
|
||||
: null;
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!rehireDate) {
|
||||
showToast("Bitte ein Wiedereintrittsdatum angeben.", "error");
|
||||
if (!rehireDate || !positionId) {
|
||||
showToast("Wiedereintrittsdatum und Planstelle sind Pflicht.", "error");
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
const result = await rehireEmployee({ employee_id: employee.id, rehire_date: rehireDate });
|
||||
const result = await rehireEmployee({
|
||||
employee_id: employee.id,
|
||||
rehire_date: rehireDate,
|
||||
position_id: positionId,
|
||||
});
|
||||
setPending(false);
|
||||
if (result.success) {
|
||||
showToast(`${employee.first_name} ${employee.last_name} wurde wiedereingestellt.`);
|
||||
@@ -40,13 +87,13 @@ export function RehirePanel({ open, onClose, employee }: { open: boolean; onClos
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Wiedereinstellung"
|
||||
subtitle={`${employee.first_name} ${employee.last_name}`}
|
||||
subtitle={fmtName(employee.first_name, employee.last_name)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} pending={pending}>
|
||||
<Button onClick={handleSubmit} pending={pending} disabled={Boolean(ausserhalb)}>
|
||||
Wiedereinstellen
|
||||
</Button>
|
||||
</>
|
||||
@@ -58,7 +105,42 @@ export function RehirePanel({ open, onClose, employee }: { open: boolean; onClos
|
||||
<p className="mt-1 text-ink">{employee.job_title}</p>
|
||||
<p className="text-xs text-ink-muted">Ausgetreten am {fmtDate(employee.exit_date)}</p>
|
||||
</div>
|
||||
|
||||
<TextField label="Wiedereintritt am" required type="date" value={rehireDate} onChange={setRehireDate} />
|
||||
|
||||
{options.length === 0 ? (
|
||||
<p className="rounded border border-danger-text/20 bg-danger-bg px-3 py-2 text-sm text-danger-text">
|
||||
Es ist derzeit keine Planstelle frei. Ohne eine solche ist keine Wiedereinstellung möglich — zuerst eine
|
||||
Planstelle anlegen oder eine bestehende freimachen.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<SelectField
|
||||
label="Planstelle"
|
||||
required
|
||||
value={positionId}
|
||||
onChange={setPositionId}
|
||||
placeholder="Bitte wählen…"
|
||||
options={options}
|
||||
/>
|
||||
{selected && (
|
||||
<div className="rounded border border-border bg-surface p-3 text-sm text-ink-body">
|
||||
<div className="font-semibold text-ink">{selected.title}</div>
|
||||
<div className="text-xs text-ink-muted">{selected.orgLabel}</div>
|
||||
<div className="mt-1 text-xs text-ink-muted">
|
||||
{selected.is_chief ? "Leitungsplanstelle" : "Mitarbeiterplanstelle"}
|
||||
{selected.future ? ` · gültig ab ${fmtDate(selected.valid_from)}` : ""}
|
||||
{selected.valid_to ? ` · endet am ${fmtDate(selected.valid_to)}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ausserhalb && (
|
||||
<p role="alert" className="rounded border border-danger-text/20 bg-danger-bg px-3 py-2 text-sm text-danger-text">
|
||||
{ausserhalb} Wiedereintritt und Gültigkeit müssen zusammenpassen.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SlideOver>
|
||||
);
|
||||
|
||||
@@ -8,10 +8,22 @@ import { SelectField, TextField, TextareaField } from "@/components/ui/Field";
|
||||
import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
import { fmtDate, fmtName } from "@/lib/format";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
|
||||
const EXIT_REASONS = ["Einvernehmliche Auflösung", "Kündigung AN", "Kündigung AG", "Befristungsablauf", "Pensionierung", "Entlassung"];
|
||||
// „No Show" steht am Ende und getrennt: es ist kein Austritt im gewohnten
|
||||
// Sinn, sondern der Fall, dass jemand nie angetreten ist.
|
||||
const NO_SHOW = "No Show";
|
||||
const EXIT_REASONS = [
|
||||
"Einvernehmliche Auflösung",
|
||||
"Kündigung AN",
|
||||
"Kündigung AG",
|
||||
"Befristungsablauf",
|
||||
"Pensionierung",
|
||||
"Entlassung",
|
||||
NO_SHOW,
|
||||
];
|
||||
const CHECKLIST_ITEMS = ["IT-Zugänge deaktivieren", "Hardware retournieren", "ÖGK-Abmeldung", "Endabrechnung & Dienstzeugnis"];
|
||||
|
||||
type TerminatePanelProps = {
|
||||
@@ -25,21 +37,40 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [exitDate, setExitDate] = useState("");
|
||||
const [reason, setReason] = useState(EXIT_REASONS[0]);
|
||||
// Wer noch gar nicht angefangen hat, tritt fast nie aus einem anderen Grund
|
||||
// aus. Die Vorbelegung nimmt den wahrscheinlichen Fall vorweg, ohne die
|
||||
// übrigen zu verstellen.
|
||||
const [reason, setReason] = useState(employee.status === "Geplant" ? NO_SHOW : EXIT_REASONS[0]);
|
||||
const [note, setNote] = useState("");
|
||||
const [checked, setChecked] = useState<boolean[]>(CHECKLIST_ITEMS.map(() => false));
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
// Bei einem Nichtantritt ist das Datum nicht frei wählbar: es ist der Tag,
|
||||
// an dem die Person hätte anfangen sollen. Die Datenbank setzt es ohnehin
|
||||
// so; hier steht es sichtbar, damit niemand ein Datum eintippt, das dann
|
||||
// stillschweigend übergangen wird.
|
||||
const istNoShow = reason === NO_SHOW;
|
||||
const wirksamesDatum = istNoShow ? employee.entry_date : exitDate;
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!exitDate) {
|
||||
if (!wirksamesDatum) {
|
||||
showToast("Bitte ein Austrittsdatum angeben.", "error");
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
const result = await terminateEmployee({ employee_id: employee.id, exit_date: exitDate, exit_reason: reason, note });
|
||||
const result = await terminateEmployee({
|
||||
employee_id: employee.id,
|
||||
exit_date: wirksamesDatum,
|
||||
exit_reason: reason,
|
||||
note,
|
||||
});
|
||||
setPending(false);
|
||||
if (result.success) {
|
||||
showToast(`Austritt für ${employee.first_name} ${employee.last_name} erfasst.`);
|
||||
showToast(
|
||||
istNoShow
|
||||
? `${employee.first_name} ${employee.last_name} ist nicht angetreten.`
|
||||
: `Austritt für ${employee.first_name} ${employee.last_name} erfasst.`
|
||||
);
|
||||
router.refresh();
|
||||
onClose();
|
||||
} else {
|
||||
@@ -51,8 +82,8 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
|
||||
<SlideOver
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Austritt"
|
||||
subtitle={`${employee.first_name} ${employee.last_name} · ${employee.job_title}`}
|
||||
title={istNoShow ? "Nicht angetreten" : "Austritt"}
|
||||
subtitle={`${fmtName(employee.first_name, employee.last_name)} · ${employee.job_title}`}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
@@ -65,20 +96,54 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Zuerst, und nicht zu übersehen: bei besonderem Kündigungsschutz
|
||||
gelten eigene Regeln, bevor beendet werden darf. Die Anwendung
|
||||
entscheidet das nicht — sie darf es aber auch nicht verschweigen,
|
||||
und im Vertragsblatt nachzusehen ist genau der Schritt, den man
|
||||
unter Zeitdruck auslässt. */}
|
||||
{employee.has_kuendigungsschutz && (
|
||||
<div className="rounded border border-danger-solid bg-danger-bg px-3 py-2 text-sm font-semibold text-danger-text">
|
||||
Achtung: besonderer Kündigungsschutz
|
||||
{employee.kuendigungsschutz_bis ? ` bis ${fmtDate(employee.kuendigungsschutz_bis)}` : " (Ende nicht erfasst)"}.
|
||||
<span className="block font-normal">
|
||||
Vor einer Beendigung ist zu prüfen, ob sie zulässig ist — je nach Grund braucht es eine Zustimmung des
|
||||
Betriebsrats oder des Gerichts.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{directReportCount > 0 && (
|
||||
<div className="rounded bg-warning-bg px-3 py-2 text-sm text-warning-text">
|
||||
{directReportCount} direkte Berichte werden automatisch der nächsthöheren Führungskraft zugeordnet.
|
||||
</div>
|
||||
)}
|
||||
<TextField label="Austrittsdatum" required type="date" value={exitDate} onChange={setExitDate} />
|
||||
<SelectField
|
||||
label="Beendigungsart"
|
||||
value={reason}
|
||||
onChange={setReason}
|
||||
options={EXIT_REASONS.map((r) => ({ value: r, label: r }))}
|
||||
options={EXIT_REASONS.map((r) => ({ value: r, label: r === NO_SHOW ? "No Show (nicht angetreten)" : r }))}
|
||||
/>
|
||||
<TextField
|
||||
label={istNoShow ? "Wirksam am (Eintrittstag)" : "Austrittsdatum"}
|
||||
required
|
||||
type="date"
|
||||
value={wirksamesDatum}
|
||||
disabled={istNoShow}
|
||||
onChange={setExitDate}
|
||||
hint={
|
||||
istNoShow
|
||||
? "Wer nie angetreten ist, scheidet am Tag seines Eintritts aus. Damit gibt es keinen Tag, an dem die Person als beschäftigt zählt."
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<TextareaField label="Anmerkung" rows={3} value={note} onChange={setNote} />
|
||||
<fieldset>
|
||||
{istNoShow && (
|
||||
<p className="rounded bg-surface px-3 py-2 text-sm text-ink-body">
|
||||
Die Planstelle wird wieder frei und gilt als nie besetzt. In allen Auswertungen zählt die Person an keinem
|
||||
Stichtag als beschäftigt.
|
||||
</p>
|
||||
)}
|
||||
{/* Bei einem Nichtantritt wurde nichts ausgegeben, was zurückkäme. */}
|
||||
<fieldset hidden={istNoShow}>
|
||||
<legend className="mb-2 text-sm font-semibold text-ink">Offboarding-Checkliste</legend>
|
||||
<div className="flex flex-col gap-2">
|
||||
{CHECKLIST_ITEMS.map((item, i) => (
|
||||
|
||||
@@ -7,48 +7,52 @@ import { Button } from "@/components/ui/Button";
|
||||
import { SelectField, TextField } from "@/components/ui/Field";
|
||||
import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
import { fmtName } from "@/lib/format";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
type Division = Database["public"]["Tables"]["divisions"]["Row"];
|
||||
type Department = Database["public"]["Tables"]["departments"]["Row"];
|
||||
type Team = Database["public"]["Tables"]["teams"]["Row"];
|
||||
|
||||
type TransferPanelProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
employee: EmployeeRow;
|
||||
divisions: Division[];
|
||||
departments: Department[];
|
||||
teams: Team[];
|
||||
currentTeamId: string | null;
|
||||
openPositions: OpenPositionResolved[];
|
||||
};
|
||||
|
||||
export function TransferPanel({ open, onClose, employee, divisions, departments, teams, currentTeamId }: TransferPanelProps) {
|
||||
// Eine Versetzung ist der Wechsel auf eine andere Planstelle — nicht mehr die
|
||||
// Angabe eines Zielteams samt frei getipptem Titel. Bereich, Abteilung und
|
||||
// Team ergeben sich aus der Einheit der Zielplanstelle, die neue Tätigkeit aus
|
||||
// ihrem Job. Damit kann eine Versetzung gar nicht erst irgendwo landen, wo es
|
||||
// keine Stelle gibt.
|
||||
export function TransferPanel({ open, onClose, employee, openPositions }: TransferPanelProps) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [effectiveDate, setEffectiveDate] = useState("");
|
||||
const [divisionId, setDivisionId] = useState(employee.division_id);
|
||||
const [teamId, setTeamId] = useState(currentTeamId ?? "");
|
||||
const [newTitle, setNewTitle] = useState("");
|
||||
const [positionId, setPositionId] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const teamsInDivision = useMemo(() => {
|
||||
const deptIds = new Set(departments.filter((d) => d.division_id === divisionId).map((d) => d.id));
|
||||
return teams.filter((t) => deptIds.has(t.department_id));
|
||||
}, [departments, teams, divisionId]);
|
||||
const options = useMemo(
|
||||
() =>
|
||||
openPositions
|
||||
.slice()
|
||||
.sort((a, b) => a.orgLabel.localeCompare(b.orgLabel, "de") || a.title.localeCompare(b.title, "de"))
|
||||
.map((p) => ({ value: p.id, label: `${p.orgLabel} · ${p.title} (${p.position_number})` })),
|
||||
[openPositions]
|
||||
);
|
||||
|
||||
const selected = openPositions.find((p) => p.id === positionId);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!effectiveDate || !teamId) {
|
||||
showToast("Bitte Datum und Zielteam angeben.", "error");
|
||||
if (!effectiveDate || !positionId) {
|
||||
showToast("Bitte Datum und Zielplanstelle angeben.", "error");
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
const result = await transferEmployee({
|
||||
employee_id: employee.id,
|
||||
effective_date: effectiveDate,
|
||||
new_team_id: teamId,
|
||||
new_title: newTitle || undefined,
|
||||
target_position_id: positionId,
|
||||
});
|
||||
setPending(false);
|
||||
if (result.success) {
|
||||
@@ -65,13 +69,13 @@ export function TransferPanel({ open, onClose, employee, divisions, departments,
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Versetzung"
|
||||
subtitle={`${employee.first_name} ${employee.last_name} · ${employee.job_title}`}
|
||||
subtitle={`${fmtName(employee.first_name, employee.last_name)} · ${employee.job_title}`}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} pending={pending}>
|
||||
<Button onClick={handleSubmit} pending={pending} disabled={options.length === 0}>
|
||||
Versetzen
|
||||
</Button>
|
||||
</>
|
||||
@@ -79,31 +83,33 @@ export function TransferPanel({ open, onClose, employee, divisions, departments,
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
|
||||
<SelectField
|
||||
label="Neuer Bereich"
|
||||
required
|
||||
value={divisionId}
|
||||
onChange={(v) => {
|
||||
setDivisionId(v);
|
||||
setTeamId("");
|
||||
}}
|
||||
options={divisions.map((d) => ({ value: d.id, label: d.name }))}
|
||||
/>
|
||||
<SelectField
|
||||
label="Neues Team"
|
||||
required
|
||||
value={teamId}
|
||||
onChange={setTeamId}
|
||||
placeholder="Bitte wählen…"
|
||||
options={teamsInDivision.map((t) => ({ value: t.id, label: t.name }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Neuer Titel (optional)"
|
||||
value={newTitle}
|
||||
onChange={setNewTitle}
|
||||
placeholder={employee.job_title}
|
||||
hint="Die neue Führungskraft wird automatisch anhand des Zielteams bestimmt."
|
||||
/>
|
||||
{options.length === 0 ? (
|
||||
<p className="rounded border border-border bg-surface p-3 text-sm text-ink-body">
|
||||
Es gibt derzeit keine unbesetzte Planstelle. Eine Versetzung setzt eine freie Zielplanstelle voraus — legen Sie
|
||||
zuerst unter „Positionen“ eine an.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<SelectField
|
||||
label="Zielplanstelle"
|
||||
required
|
||||
value={positionId}
|
||||
onChange={setPositionId}
|
||||
placeholder="Bitte wählen…"
|
||||
options={options}
|
||||
/>
|
||||
{selected && (
|
||||
<div className="rounded border border-border bg-surface p-3 text-sm text-ink-body">
|
||||
<div className="font-semibold text-ink">{selected.title}</div>
|
||||
<div className="text-xs text-ink-muted">{selected.orgLabel}</div>
|
||||
<div className="mt-1 text-xs text-ink-muted">
|
||||
{selected.is_chief ? "Leitungsplanstelle" : "Mitarbeiterplanstelle"}
|
||||
{selected.managerName ? ` · berichtet an ${selected.managerName}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SlideOver>
|
||||
);
|
||||
|
||||
@@ -1,35 +1,208 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { HistorieBearbeiten } from "@/components/employees/HistorieBearbeiten";
|
||||
import { HistorieLoeschen } from "@/components/employees/HistorieLoeschen";
|
||||
import { AenderungsTabelle } from "@/components/ui/AenderungsTabelle";
|
||||
import { TextField } from "@/components/ui/Field";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
import { actionBadgeStyle } from "@/lib/colors";
|
||||
import { fmtDate, todayIso } from "@/lib/format";
|
||||
import { darfBearbeitetWerden, darfKorrigiertWerden, loeschVorschau } from "@/lib/history";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"];
|
||||
|
||||
export function HistorieTab({ history }: { history: HistoryRow[] }) {
|
||||
// Die Geschichte einer Person — aufklappbar bis auf die Werte, filterbar, und
|
||||
// dort, wo ein Eintrag irrtümlich entstanden ist, auch zurücknehmbar.
|
||||
//
|
||||
// Vorher stand hier nur „Geänderte Felder: Adresse, Ort". Damit liess sich
|
||||
// zwar sehen, *dass* jemand die Anschrift angefasst hat, aber nicht, was
|
||||
// vorher dort stand. Die alte Adresse lag allein im Protokoll, und das ist
|
||||
// eine andere Seite, nach Zeitpunkt sortiert statt nach Person — man hätte
|
||||
// also erst wissen müssen, wonach man sucht.
|
||||
//
|
||||
// Aufgeklappt wird mit <details>, nicht mit einem Zustand im Browser: die
|
||||
// Werte stehen dann schon in der Seite, sind durchsuchbar (Strg+F) und im
|
||||
// Ausdruck sichtbar, und es braucht kein Skript dafür.
|
||||
//
|
||||
// Die Knöpfe erscheinen nur an Einträgen, wo sie etwas bewirken können. An
|
||||
// allen anderen steht stattdessen der Grund — leise, aber lesbar. Ein Knopf,
|
||||
// der erst nach dem Klick sagt „geht nicht", wäre eine Falle; ein fehlender
|
||||
// Knopf ohne Erklärung wäre ein Rätsel.
|
||||
|
||||
type Sicht = "alle" | "anstehend" | "erledigt";
|
||||
|
||||
const SICHTEN: { value: Sicht; label: string }[] = [
|
||||
{ value: "alle", label: "Alle" },
|
||||
{ value: "anstehend", label: "Anstehend" },
|
||||
{ value: "erledigt", label: "Gelaufen" },
|
||||
];
|
||||
|
||||
export function HistorieTab({ history, employeeId }: { history: HistoryRow[]; employeeId: string }) {
|
||||
const today = todayIso();
|
||||
const [sicht, setSicht] = useState<Sicht>("alle");
|
||||
const [von, setVon] = useState("");
|
||||
const [bis, setBis] = useState("");
|
||||
// Ereignistypen, die in dieser Akte überhaupt vorkommen — eine Auswahl aus
|
||||
// elf Typen, von denen zehn nie auftauchen, wäre nur Suchaufwand.
|
||||
const vorhandeneTypen = [...new Set(history.map((h) => h.event_type))];
|
||||
const [typen, setTypen] = useState<Set<string>>(new Set());
|
||||
|
||||
const gefiltert = history.filter((h) => {
|
||||
if (sicht === "anstehend" && h.event_date <= today) return false;
|
||||
if (sicht === "erledigt" && h.event_date > today) return false;
|
||||
if (von && h.event_date < von) return false;
|
||||
if (bis && h.event_date > bis) return false;
|
||||
if (typen.size > 0 && !typen.has(h.event_type)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const anstehend = history.filter((h) => h.event_date > today).length;
|
||||
const eingeschraenkt = sicht !== "alle" || Boolean(von) || Boolean(bis) || typen.size > 0;
|
||||
|
||||
function typUmschalten(typ: string) {
|
||||
setTypen((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(typ)) next.delete(typ);
|
||||
else next.add(typ);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function zuruecksetzen() {
|
||||
setSicht("alle");
|
||||
setVon("");
|
||||
setBis("");
|
||||
setTypen(new Set());
|
||||
}
|
||||
|
||||
if (history.length === 0) {
|
||||
return <p className="text-sm text-ink-muted">Keine Historieneinträge vorhanden.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{history.map((h) => {
|
||||
const isFuture = h.event_date > today;
|
||||
return (
|
||||
<li key={h.id} className="py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${actionBadgeStyle(h.event_type)}`}>{h.event_type}</span>
|
||||
<span className="text-sm text-ink-muted">{fmtDate(h.event_date)}</span>
|
||||
{isFuture && (
|
||||
<span className="rounded-full bg-warning-bg px-2 py-0.5 text-xs font-semibold text-warning-text">
|
||||
⏱ zukünftig – wirksam ab {fmtDate(h.event_date)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-ink">{h.description}</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 rounded border border-border bg-surface px-3 py-2.5">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<SegmentedControl<Sicht> value={sicht} onChange={setSicht} options={SICHTEN} />
|
||||
{/* Was noch kommt, ist der häufigste Grund, hier hereinzusehen —
|
||||
deshalb steht die Zahl da, auch ohne dass jemand filtert. */}
|
||||
{anstehend > 0 && (
|
||||
<span className="rounded-full bg-warning-bg px-2 py-0.5 text-xs font-semibold text-warning-text">
|
||||
{anstehend} anstehend
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-end gap-2">
|
||||
<TextField label="Von" dense type="date" value={von} onChange={setVon} className="w-40" />
|
||||
<TextField label="Bis" dense type="date" value={bis} onChange={setBis} className="w-40" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{vorhandeneTypen.length > 1 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{vorhandeneTypen.map((typ) => (
|
||||
<button
|
||||
key={typ}
|
||||
type="button"
|
||||
onClick={() => typUmschalten(typ)}
|
||||
aria-pressed={typen.has(typ)}
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
|
||||
typen.has(typ) ? actionBadgeStyle(typ) : "bg-white text-ink-muted hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{typ}
|
||||
</button>
|
||||
))}
|
||||
{eingeschraenkt && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={zuruecksetzen}
|
||||
className="ml-auto rounded text-xs text-ink-muted hover:text-ink hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
Filter zurücksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{gefiltert.length === 0 ? (
|
||||
<p className="text-sm text-ink-muted">
|
||||
Kein Eintrag passt zu dieser Auswahl. {history.length} {history.length === 1 ? "Eintrag" : "Einträge"} sind
|
||||
vorhanden.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{gefiltert.map((h) => {
|
||||
const isFuture = h.event_date > today;
|
||||
const changes = h.changes ?? [];
|
||||
// Die ganze Historie mitgeben, nicht die gefilterte: ob eine
|
||||
// Abwesenheit gelöscht werden darf, hängt an einer späteren
|
||||
// Rückkehr — auch wenn die gerade ausgeblendet ist.
|
||||
const loeschbar = darfKorrigiertWerden(h, today, history);
|
||||
const bearbeitbar = darfBearbeitetWerden(h, today, history);
|
||||
return (
|
||||
<li key={h.id} className="py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${actionBadgeStyle(h.event_type)}`}>
|
||||
{h.event_type}
|
||||
</span>
|
||||
<span className="text-sm text-ink-muted">{fmtDate(h.event_date)}</span>
|
||||
{isFuture && (
|
||||
<span className="rounded-full bg-warning-bg px-2 py-0.5 text-xs font-semibold text-warning-text">
|
||||
⏱ zukünftig – wirksam ab {fmtDate(h.event_date)}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto flex items-center gap-0.5">
|
||||
{bearbeitbar.erlaubt && (
|
||||
<HistorieBearbeiten
|
||||
historyId={h.id}
|
||||
employeeId={employeeId}
|
||||
bezeichnung={h.event_type}
|
||||
datum={h.event_date}
|
||||
changes={changes}
|
||||
istZukunft={isFuture}
|
||||
heute={today}
|
||||
nurDatum={h.event_type === "Eintritt"}
|
||||
/>
|
||||
)}
|
||||
{loeschbar.erlaubt && (
|
||||
<HistorieLoeschen
|
||||
historyId={h.id}
|
||||
employeeId={employeeId}
|
||||
bezeichnung={h.event_type}
|
||||
datum={h.event_date}
|
||||
vorschau={loeschVorschau(h, history)}
|
||||
istZukunft={isFuture}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-ink">{h.description}</p>
|
||||
|
||||
{changes.length > 0 && (
|
||||
<details className="group mt-1.5">
|
||||
<summary
|
||||
className="inline-flex cursor-pointer list-none items-center gap-1 rounded text-xs font-semibold text-brand-700
|
||||
hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
<span className="transition-transform group-open:rotate-90" aria-hidden="true">
|
||||
›
|
||||
</span>
|
||||
{changes.length} {changes.length === 1 ? "Feld" : "Felder"} im Detail
|
||||
</summary>
|
||||
<div className="mt-2 rounded border border-border bg-surface px-3 py-2">
|
||||
<AenderungsTabelle changes={changes} />
|
||||
</div>
|
||||
{!loeschbar.erlaubt && <p className="mt-1.5 text-xs text-ink-muted">{loeschbar.grund}</p>}
|
||||
</details>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,23 +2,50 @@ import { Network } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Avatar } from "@/components/ui/Avatar";
|
||||
import { LINK_BUTTON_CLASS } from "@/components/ui/Button";
|
||||
import { fmtName } from "@/lib/format";
|
||||
|
||||
type MiniEmployee = { id: string; first_name: string; last_name: string; job_title: string; status?: string };
|
||||
|
||||
type OrganisationTabProps = {
|
||||
employeeId: string;
|
||||
manager: MiniEmployee | null;
|
||||
/** Nur gesetzt, wenn die zuständige Leitung abwesend ist und vertreten wird. */
|
||||
formalManager: MiniEmployee | null;
|
||||
directReports: MiniEmployee[];
|
||||
breadcrumb: string;
|
||||
/**
|
||||
* Die Kostenstelle der Planstelle, auf der die Person heute sitzt — nicht
|
||||
* ihre eigene: sie kontiert dorthin, wo ihr Sitz kontiert ist. Fehlt sie,
|
||||
* hat die Person keine laufende Besetzung (geplanter Eintritt, Austritt).
|
||||
*/
|
||||
kostenstelle: { code: string; name: string } | null;
|
||||
};
|
||||
|
||||
export function OrganisationTab({ employeeId, manager, directReports, breadcrumb }: OrganisationTabProps) {
|
||||
export function OrganisationTab({
|
||||
employeeId,
|
||||
manager,
|
||||
formalManager,
|
||||
directReports,
|
||||
breadcrumb,
|
||||
kostenstelle,
|
||||
}: OrganisationTabProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-ink-muted">Organisationseinheit</h3>
|
||||
<p className="mt-1 text-sm text-ink">{breadcrumb}</p>
|
||||
<h3 className="mt-4 text-xs font-semibold uppercase tracking-wide text-ink-muted">Kostenstelle</h3>
|
||||
<p className="mt-1 text-sm text-ink">
|
||||
{kostenstelle ? (
|
||||
<>
|
||||
<span className="font-semibold tabular-nums">{kostenstelle.code}</span>
|
||||
<span className="text-ink-body"> · {kostenstelle.name}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-ink-muted">Keine laufende Planstellenbesetzung</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{/* ?focus= drives the same highlight/auto-expand path the org chart
|
||||
search already uses, so the person is unfolded and centred on
|
||||
@@ -30,13 +57,21 @@ export function OrganisationTab({ employeeId, manager, directReports, breadcrumb
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Führungskraft</h3>
|
||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">
|
||||
{formalManager ? "Führungskraft (Vertretung)" : "Führungskraft"}
|
||||
</h3>
|
||||
{formalManager && (
|
||||
<p className="mb-2 text-xs text-ink-muted">
|
||||
Zuständig ist {fmtName(formalManager.first_name, formalManager.last_name)}; während der Abwesenheit übernimmt die nächste
|
||||
besetzte Ebene.
|
||||
</p>
|
||||
)}
|
||||
{manager ? (
|
||||
<Link href={`/employees/${manager.id}`} className="flex w-fit items-center gap-3 rounded border border-border p-3 hover:bg-surface">
|
||||
<Avatar firstName={manager.first_name} lastName={manager.last_name} />
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-ink">
|
||||
{manager.first_name} {manager.last_name}
|
||||
{fmtName(manager.first_name, manager.last_name)}
|
||||
</div>
|
||||
<div className="text-xs text-ink-muted">{manager.job_title}</div>
|
||||
</div>
|
||||
@@ -55,7 +90,7 @@ export function OrganisationTab({ employeeId, manager, directReports, breadcrumb
|
||||
<Avatar firstName={r.first_name} lastName={r.last_name} />
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-ink">
|
||||
{r.first_name} {r.last_name}
|
||||
{fmtName(r.first_name, r.last_name)}
|
||||
</div>
|
||||
<div className="text-xs text-ink-muted">{r.job_title}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AngehoerigeSection } from "@/components/employees/AngehoerigeSection";
|
||||
import { brauchtAufenthaltstitel } from "@/lib/countries";
|
||||
import { fmtAge, fmtDate } from "@/lib/format";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
@@ -6,27 +7,56 @@ type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
type Location = Database["public"]["Tables"]["locations"]["Row"];
|
||||
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
|
||||
|
||||
function formatAddress(employee: EmployeeRow): string {
|
||||
const cityLine = [employee.postal_code, employee.city].filter(Boolean).join(" ");
|
||||
return [employee.address, cityLine].filter(Boolean).join(", ") || "–";
|
||||
}
|
||||
|
||||
export function StammdatenTab({ employee, location, dependents }: { employee: EmployeeRow; location?: Location; dependents: Dependent[] }) {
|
||||
// Defensive against a DB that hasn't received the title_prefix/title_suffix
|
||||
// migration yet — select("*") simply omits unknown columns, so these can
|
||||
// be undefined rather than the empty array the column default implies.
|
||||
const titles = [...(employee.title_prefix ?? []), ...(employee.title_suffix ?? [])];
|
||||
const prefixe = employee.title_prefix ?? [];
|
||||
const suffixe = employee.title_suffix ?? [];
|
||||
|
||||
// Feld für Feld dieselbe Liste wie im Abschnitt „Person" von „Daten
|
||||
// ändern", in derselben Reihenfolge.
|
||||
//
|
||||
// Vorher fasste die Anzeige zusammen: Titel in einer Zeile, Adresse mit
|
||||
// Postleitzahl und Ort verschmolzen, Vor- und Nachname gar nicht — die
|
||||
// standen nur in der Kopfzeile. Wer eine Angabe prüfen wollte, musste den
|
||||
// Änderungsdialog öffnen, um sie überhaupt zu sehen, und stand dann schon
|
||||
// in einem Formular. Was sich ändern lässt, soll sich auch ansehen lassen.
|
||||
const rows: [string, string][] = [
|
||||
["Titel", titles.length > 0 ? titles.join(", ") : "–"],
|
||||
["Personalnummer", String(employee.personnel_number)],
|
||||
["Vorname", employee.first_name],
|
||||
["Nachname", employee.last_name],
|
||||
["Titel (vorangestellt)", prefixe.length > 0 ? prefixe.join(", ") : "–"],
|
||||
["Titel (nachgestellt)", suffixe.length > 0 ? suffixe.join(", ") : "–"],
|
||||
["Geschlecht", employee.gender === "m" ? "männlich" : "weiblich"],
|
||||
["Geburtsdatum", `${fmtDate(employee.birth_date)} (${fmtAge(employee.birth_date)} Jahre)`],
|
||||
["SV-Nummer", employee.sv_nummer ?? "–"],
|
||||
["Staatsbürgerschaft", employee.nationality],
|
||||
["E-Mail", employee.email],
|
||||
["Telefon", employee.phone ?? "–"],
|
||||
["Standort", location ? `${location.name} (${location.country})` : "–"],
|
||||
["Adresse", formatAddress(employee)],
|
||||
// Nur, wo er verlangt ist. Bei einer österreichischen Staatsbürger-
|
||||
// schaft wäre die Zeile „Aufenthaltstitel: Nein" keine Auskunft,
|
||||
// sondern eine Frage, die sich nicht stellt.
|
||||
...(brauchtAufenthaltstitel(employee.nationality)
|
||||
? ([
|
||||
[
|
||||
"Aufenthaltstitel",
|
||||
employee.hat_aufenthaltstitel
|
||||
? employee.aufenthaltstitel_bis
|
||||
? `Ja, bis ${fmtDate(employee.aufenthaltstitel_bis)}`
|
||||
: "Ja (unbefristet oder nicht erfasst)"
|
||||
: "Nein",
|
||||
],
|
||||
] as [string, string][])
|
||||
: []),
|
||||
["Adresse", employee.address ?? "–"],
|
||||
["Postleitzahl", employee.postal_code ?? "–"],
|
||||
["Ort", employee.city ?? "–"],
|
||||
["Land", employee.address_country ?? "–"],
|
||||
["Geschlecht", employee.gender === "m" ? "männlich" : "weiblich"],
|
||||
["Private E-Mail", employee.email ?? "–"],
|
||||
["Private Telefonnummer", employee.phone ?? "–"],
|
||||
// Der Standort ist keine Angabe zur Person, sondern die Betriebsstätte —
|
||||
// er steht deshalb am Ende und nicht zwischen Adresse und Land, wo man
|
||||
// ihn für den Wohnort halten könnte.
|
||||
["Standort", location ? `${location.name} (${location.country})` : "–"],
|
||||
];
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -38,7 +68,46 @@ export function StammdatenTab({ employee, location, dependents }: { employee: Em
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<AngehoerigeSection employeeId={employee.id} dependents={dependents} />
|
||||
|
||||
{/* Eigener Abschnitt statt einer Zelle im Raster, und unterhalb der
|
||||
Angehörigen: beides sind Personen im Umfeld, und der Notfallkontakt
|
||||
ist die Ausnahme davon — deshalb steht er zuletzt, nicht dazwischen.
|
||||
Im Ernstfall greift jemand in Eile danach; dann muss die Nummer
|
||||
sofort zu finden sein und wählbar. */}
|
||||
<div className="border-t border-border pt-6">
|
||||
<h3 className="mb-3 text-xs font-bold uppercase tracking-wide text-brand-700">Notfallkontakt</h3>
|
||||
{employee.emergency_contact_name ? (
|
||||
<dl className="grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<dt className="text-xs font-semibold uppercase tracking-wide text-ink-muted">Name</dt>
|
||||
<dd className="mt-1 text-sm text-ink">{employee.emergency_contact_name}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs font-semibold uppercase tracking-wide text-ink-muted">Telefon</dt>
|
||||
<dd className="mt-1 text-sm font-semibold text-ink">
|
||||
{employee.emergency_contact_phone ? (
|
||||
<a
|
||||
href={`tel:${employee.emergency_contact_phone.replace(/\s/g, "")}`}
|
||||
className="rounded hover:text-brand-700 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
{employee.emergency_contact_phone}
|
||||
</a>
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs font-semibold uppercase tracking-wide text-ink-muted">Verhältnis</dt>
|
||||
<dd className="mt-1 text-sm text-ink">{employee.emergency_contact_relation || "–"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : (
|
||||
<p className="text-sm text-ink-muted">Kein Notfallkontakt hinterlegt.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { dienstwagenLabel } from "@/lib/dienstwagen";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
@@ -13,12 +14,11 @@ const PAYGRADE_LABELS: Record<string, string> = {
|
||||
};
|
||||
|
||||
export function VertragTab({ employee }: { employee: EmployeeRow }) {
|
||||
const flags = [
|
||||
employee.is_betriebsrat && "Betriebsrat",
|
||||
employee.has_dienstwagen && "Dienstwagen",
|
||||
employee.is_laterale_fuehrung && "Laterale Führung",
|
||||
employee.is_c_level && "C-Level",
|
||||
].filter(Boolean);
|
||||
// Früher stand hier eine einzige Zeile „Merkmale" mit allem, was zutraf,
|
||||
// durch Kommas getrennt — und ein Gedankenstrich, wenn nichts zutraf. Damit
|
||||
// liess sich nicht ablesen, ob jemand *keinen* Dienstwagen hat oder ob
|
||||
// niemand die Frage je beantwortet hat. Jedes Merkmal steht jetzt für sich,
|
||||
// mit Ja oder Nein, wie jede andere Zeile auf diesem Blatt auch.
|
||||
const rows: [string, string][] = [
|
||||
["Eintrittsdatum", fmtDate(employee.entry_date)],
|
||||
["Vertragsart", employee.contract_type === "befristet" ? `befristet bis ${fmtDate(employee.contract_end_date)}` : "unbefristet"],
|
||||
@@ -29,7 +29,32 @@ export function VertragTab({ employee }: { employee: EmployeeRow }) {
|
||||
["Paygrade", PAYGRADE_LABELS[employee.paygrade] ?? employee.paygrade],
|
||||
["Angestellte:r / Arbeiter:in", employee.worker_type ?? "–"],
|
||||
["Arbeitstage", employee.work_days?.join(", ") || "–"],
|
||||
["Merkmale", flags.length > 0 ? flags.join(", ") : "–"],
|
||||
// Beim Dienstwagen steht die Antriebsart statt eines blossen „Ja" — das
|
||||
// war die Frage dahinter, seit E-Fahrzeuge getrennt zu führen sind.
|
||||
["Dienstwagen", employee.has_dienstwagen ? dienstwagenLabel(employee.dienstwagen_art) : "Nein"],
|
||||
["Betriebsrat", employee.is_betriebsrat ? "Ja" : "Nein"],
|
||||
["Laterale Führung", employee.is_laterale_fuehrung ? "Ja" : "Nein"],
|
||||
["C-Level", employee.is_c_level ? "Ja" : "Nein"],
|
||||
// Das Enddatum steht gleich dabei: „Ja" allein liesse offen, ob der
|
||||
// Schutz noch läuft, und genau danach fragt man.
|
||||
// Die Teilzeitvariante gehört neben die Stunden: sie erklärt, warum sie
|
||||
// sind, wie sie sind.
|
||||
[
|
||||
"Teilzeitvariante",
|
||||
employee.teilzeit_art
|
||||
? employee.teilzeit_bis
|
||||
? `${employee.teilzeit_art} bis ${fmtDate(employee.teilzeit_bis)}`
|
||||
: `${employee.teilzeit_art} (Ende offen)`
|
||||
: "–",
|
||||
],
|
||||
[
|
||||
"Besonderer Kündigungsschutz",
|
||||
employee.has_kuendigungsschutz
|
||||
? employee.kuendigungsschutz_bis
|
||||
? `bis ${fmtDate(employee.kuendigungsschutz_bis)}`
|
||||
: "Ja (Ende offen)"
|
||||
: "Nein",
|
||||
],
|
||||
];
|
||||
if (employee.exit_date) rows.push(["Austrittsdatum", fmtDate(employee.exit_date)]);
|
||||
|
||||
|
||||
@@ -2,20 +2,22 @@
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { hireEmployee } from "@/actions/employees";
|
||||
import { addEmployeeDependent, hireEmployee } from "@/actions/employees";
|
||||
import { deleteHireDraft, saveHireDraft } from "@/actions/hireDrafts";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr";
|
||||
import { StepAngehoerige } from "./StepAngehoerige";
|
||||
import { StepNotfallkontakt } from "./StepNotfallkontakt";
|
||||
import { StepPerson } from "./StepPerson";
|
||||
import { StepPosition } from "./StepPosition";
|
||||
import { StepSummary } from "./StepSummary";
|
||||
import { StepVertrag } from "./StepVertrag";
|
||||
import { EMPTY_HIRE_DRAFT, type HireDraftData } from "./types";
|
||||
|
||||
const STEP_LABELS = ["Person", "Position", "Vertrag", "Zusammenfassung"];
|
||||
const STEP_LABELS = ["Person", "Position", "Angehörige", "Vertrag", "Notfallkontakt", "Zusammenfassung"];
|
||||
|
||||
type HireWizardProps = {
|
||||
open: boolean;
|
||||
@@ -58,12 +60,34 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
||||
isValidSvnr(draft.svNummer, draft.birthDate || null);
|
||||
|
||||
const stepValid = [
|
||||
Boolean(draft.firstName && draft.lastName && draft.birthDate && draft.locationId) && svNummerOk,
|
||||
// E-Mail gehört zu den Pflichtfeldern, weil die Spalte NOT NULL ist. Ohne
|
||||
// die Prüfung hier bricht erst die Datenbank ab — am Ende des vierten
|
||||
// Schritts, nach allen Eingaben.
|
||||
// Die private E-Mail-Adresse steht bewusst nicht mehr darunter: sie ist
|
||||
// freiwillig, seit die Spalte NULL zulässt.
|
||||
Boolean(draft.personnelNumber.trim() && draft.firstName && draft.lastName && draft.birthDate && draft.locationId) &&
|
||||
svNummerOk,
|
||||
Boolean(draft.positionId && draft.besetzung),
|
||||
// Angehörige: freiwillig — aber eine begonnene Zeile muss vollständig
|
||||
// sein, sonst scheitert sie erst nach dem Anlegen der Person, und die
|
||||
// steht dann schon in der Datenbank.
|
||||
draft.angehoerige.every(
|
||||
(a) =>
|
||||
a.firstName.trim() &&
|
||||
a.lastName.trim() &&
|
||||
a.birthDate &&
|
||||
(!a.svNummer.trim() || isValidSvnr(a.svNummer, a.birthDate || null))
|
||||
),
|
||||
Boolean(draft.entryDate && draft.workDays.length > 0),
|
||||
// Notfallkontakt: freiwillig, aber Name und Nummer nur gemeinsam — die
|
||||
// Datenbank weist eines ohne das andere ab (chk_emergency_contact).
|
||||
Boolean(draft.emergencyContactName.trim()) === Boolean(draft.emergencyContactPhone.trim()),
|
||||
true,
|
||||
][step];
|
||||
|
||||
/** Der letzte Schritt; von hier wird angelegt statt weitergeblättert. */
|
||||
const letzterSchritt = STEP_LABELS.length - 1;
|
||||
|
||||
async function handleSaveDraft() {
|
||||
const result = await saveHireDraft({ id: draftId, step, data: draft });
|
||||
if (result.success) {
|
||||
@@ -79,6 +103,7 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
||||
if (!selectedPosition || !draft.besetzung) return;
|
||||
setSubmitting(true);
|
||||
const result = await hireEmployee({
|
||||
personnel_number: Number(draft.personnelNumber),
|
||||
first_name: draft.firstName,
|
||||
last_name: draft.lastName,
|
||||
title_prefix: draft.titlePrefix,
|
||||
@@ -86,6 +111,7 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
||||
gender: draft.gender,
|
||||
birth_date: draft.birthDate,
|
||||
sv_nummer: draft.svNummer || undefined,
|
||||
email: draft.email.trim() || undefined,
|
||||
phone: draft.phone || undefined,
|
||||
position_id: draft.positionId,
|
||||
location_id: draft.locationId,
|
||||
@@ -101,17 +127,57 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
||||
work_days: draft.workDays,
|
||||
is_betriebsrat: draft.isBetriebsrat,
|
||||
has_dienstwagen: draft.hasDienstwagen,
|
||||
// Null, sobald kein Dienstwagen da ist — der CHECK lässt die Angabe
|
||||
// sonst nicht zu.
|
||||
dienstwagen_art: draft.hasDienstwagen ? draft.dienstwagenArt : null,
|
||||
has_kuendigungsschutz: draft.hasKuendigungsschutz,
|
||||
// Ohne Schutz kein Enddatum — der CHECK lässt es nicht anders zu.
|
||||
kuendigungsschutz_bis: draft.hasKuendigungsschutz ? draft.kuendigungsschutzBis || null : null,
|
||||
emergency_contact_name: draft.emergencyContactName.trim() || undefined,
|
||||
emergency_contact_phone: draft.emergencyContactPhone.trim() || undefined,
|
||||
emergency_contact_relation: draft.emergencyContactRelation.trim() || undefined,
|
||||
is_laterale_fuehrung: draft.isLateraleFuehrung,
|
||||
is_c_level: draft.isCLevel,
|
||||
});
|
||||
setSubmitting(false);
|
||||
if (result.success) {
|
||||
showToast(`${draft.firstName} ${draft.lastName} wurde eingestellt.`);
|
||||
if (draftId) await deleteHireDraft(draftId);
|
||||
router.refresh();
|
||||
onClose();
|
||||
} else {
|
||||
if (!result.success || !result.employeeId) {
|
||||
setSubmitting(false);
|
||||
showToast(result.error ?? "Fehler beim Anlegen.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Angehörige erst jetzt: add_employee_dependent braucht die Kennung, und
|
||||
// die entsteht mit der Einstellung.
|
||||
//
|
||||
// Damit hängen sie ausserhalb der Transaktion, in der die Person
|
||||
// entsteht. Scheitert eine, ist die Person trotzdem angelegt — deshalb
|
||||
// wird nicht stillschweigend weitergemacht, sondern genau gesagt, wer
|
||||
// fehlt. Nachtragen geht in der Personalakte.
|
||||
const gescheitert: string[] = [];
|
||||
for (const a of draft.angehoerige) {
|
||||
const r = await addEmployeeDependent({
|
||||
employee_id: result.employeeId,
|
||||
first_name: a.firstName.trim(),
|
||||
last_name: a.lastName.trim(),
|
||||
relationship: a.relationship,
|
||||
birth_date: a.birthDate,
|
||||
sv_nummer: a.svNummer.trim() || undefined,
|
||||
effective_date: draft.entryDate,
|
||||
});
|
||||
if (!r.success) gescheitert.push(`${a.firstName} ${a.lastName}`.trim());
|
||||
}
|
||||
|
||||
setSubmitting(false);
|
||||
if (draftId) await deleteHireDraft(draftId);
|
||||
router.refresh();
|
||||
onClose();
|
||||
|
||||
if (gescheitert.length > 0) {
|
||||
showToast(
|
||||
`${draft.firstName} ${draft.lastName} wurde eingestellt, aber ${gescheitert.join(", ")} konnte nicht als Angehörige:r angelegt werden — bitte in der Personalakte nachtragen.`,
|
||||
"error"
|
||||
);
|
||||
} else {
|
||||
showToast(`${draft.firstName} ${draft.lastName} wurde eingestellt.`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,12 +203,12 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
||||
Zurück
|
||||
</Button>
|
||||
)}
|
||||
{step < 3 && (
|
||||
{step < letzterSchritt && (
|
||||
<Button onClick={() => setStep((s) => s + 1)} disabled={!stepValid}>
|
||||
Weiter
|
||||
</Button>
|
||||
)}
|
||||
{step === 3 && (
|
||||
{step === letzterSchritt && (
|
||||
<Button onClick={handleSubmit} pending={submitting}>
|
||||
Anlegen
|
||||
</Button>
|
||||
@@ -176,8 +242,10 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
||||
|
||||
{step === 0 && <StepPerson draft={draft} update={update} locations={locations} />}
|
||||
{step === 1 && <StepPosition draft={draft} update={update} openPositions={openPositions} />}
|
||||
{step === 2 && <StepVertrag draft={draft} update={update} />}
|
||||
{step === 3 && <StepSummary draft={draft} selectedPosition={selectedPosition} locations={locations} />}
|
||||
{step === 2 && <StepAngehoerige draft={draft} update={update} />}
|
||||
{step === 3 && <StepVertrag draft={draft} update={update} />}
|
||||
{step === 4 && <StepNotfallkontakt draft={draft} update={update} />}
|
||||
{step === 5 && <StepSummary draft={draft} selectedPosition={selectedPosition} locations={locations} />}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
113
components/hire/StepAngehoerige.tsx
Normal file
113
components/hire/StepAngehoerige.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { SelectField, TextField } from "@/components/ui/Field";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import { formatSvnr, svnrErrorMessage, validateSvnr } from "@/lib/svnr";
|
||||
import type { RelationshipType } from "@/lib/supabase/types";
|
||||
import type { HireDraftAngehoerige, HireDraftData } from "./types";
|
||||
|
||||
const VERHAELTNIS: RelationshipType[] = ["Ehepartner:in", "Lebenspartner:in", "Kind", "Sonstige"];
|
||||
|
||||
// Angehörige im Assistenten, obwohl es die Person noch nicht gibt.
|
||||
//
|
||||
// Sie werden hier gesammelt und erst nach dem Anlegen angehängt — die
|
||||
// Datenbankfunktion braucht eine Kennung, und die entsteht mit der
|
||||
// Einstellung. Der Preis dafür steht in HireWizard: schlägt eine der
|
||||
// Ergänzungen fehl, ist die Person trotzdem angelegt, und die Meldung sagt
|
||||
// das dann auch.
|
||||
//
|
||||
// Freiwillig: die meisten Einstellungen kommen ohne aus, und wer später
|
||||
// etwas nachträgt, findet denselben Dialog in der Personalakte.
|
||||
export function StepAngehoerige({
|
||||
draft,
|
||||
update,
|
||||
}: {
|
||||
draft: HireDraftData;
|
||||
update: (patch: Partial<HireDraftData>) => void;
|
||||
}) {
|
||||
const liste = draft.angehoerige;
|
||||
|
||||
function setze(index: number, patch: Partial<HireDraftAngehoerige>) {
|
||||
update({ angehoerige: liste.map((a, i) => (i === index ? { ...a, ...patch } : a)) });
|
||||
}
|
||||
|
||||
function hinzufuegen() {
|
||||
update({
|
||||
angehoerige: [...liste, { firstName: "", lastName: draft.lastName, relationship: "Kind", birthDate: "", svNummer: "" }],
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="max-w-prose text-sm text-ink-muted">
|
||||
Angehörige sind freiwillig und lassen sich jederzeit in der Personalakte nachtragen. Der Nachname ist mit dem
|
||||
der einzustellenden Person vorbelegt — überschreibbar.
|
||||
</p>
|
||||
|
||||
{liste.length === 0 ? (
|
||||
<p className="text-sm text-ink-muted">Keine Angehörigen erfasst.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{liste.map((a, i) => {
|
||||
// Dieselbe Prüfung wie bei der Person selbst: Prüfziffer und
|
||||
// Geburtsdatum müssen zusammenpassen. Hier schon, damit der
|
||||
// Fehler nicht erst nach dem Anlegen auftaucht — dann existiert
|
||||
// die Person bereits und die Angehörige fehlt.
|
||||
const svFehler = a.svNummer.trim() ? validateSvnr(a.svNummer, a.birthDate || null) : null;
|
||||
return (
|
||||
<fieldset key={i} className="rounded-md border border-border p-3">
|
||||
<legend className="flex items-center gap-2 px-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">
|
||||
{a.firstName || a.lastName ? `${a.firstName} ${a.lastName}`.trim() : `Angehörige:r ${i + 1}`}
|
||||
{a.birthDate && <span className="font-normal normal-case">· {fmtDate(a.birthDate)}</span>}
|
||||
</legend>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<TextField label="Vorname" dense required value={a.firstName} onChange={(v) => setze(i, { firstName: v })} />
|
||||
<TextField label="Nachname" dense required value={a.lastName} onChange={(v) => setze(i, { lastName: v })} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<SelectField
|
||||
label="Verhältnis"
|
||||
dense
|
||||
required
|
||||
value={a.relationship}
|
||||
onChange={(v) => setze(i, { relationship: v as RelationshipType })}
|
||||
options={VERHAELTNIS.map((r) => ({ value: r, label: r }))}
|
||||
/>
|
||||
<TextField label="Geburtsdatum" dense required type="date" value={a.birthDate} onChange={(v) => setze(i, { birthDate: v })} />
|
||||
</div>
|
||||
<TextField
|
||||
label="SV-Nummer"
|
||||
dense
|
||||
value={a.svNummer}
|
||||
onChange={(v) => setze(i, { svNummer: v })}
|
||||
error={svFehler ? svnrErrorMessage(svFehler) : undefined}
|
||||
hint={!svFehler && a.svNummer.trim() ? formatSvnr(a.svNummer) : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => update({ angehoerige: liste.filter((_, j) => j !== i) })}
|
||||
className="!px-1 text-danger-text hover:!bg-transparent hover:underline"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" /> Entfernen
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Button variant="secondary" size="sm" onClick={hinzufuegen}>
|
||||
<Plus className="h-4 w-4" /> Angehörige:n hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
components/hire/StepNotfallkontakt.tsx
Normal file
60
components/hire/StepNotfallkontakt.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { SelectField, TextField } from "@/components/ui/Field";
|
||||
import { EMERGENCY_RELATIONS } from "@/lib/supabase/types";
|
||||
import type { HireDraftData } from "./types";
|
||||
|
||||
// Eigener Schritt, kurz vor der Zusammenfassung.
|
||||
//
|
||||
// Zuerst stand das zwischen den Stammdaten — dort ging es unter, obwohl es
|
||||
// die einzige Angabe im ganzen Assistenten ist, die eine dritte Person
|
||||
// betrifft und im Ernstfall gebraucht wird.
|
||||
//
|
||||
// Die Angabe bleibt freiwillig. Wer sie macht, braucht Name und Nummer
|
||||
// zusammen; das prüft der Assistent, bevor er weiterlässt, und die Datenbank
|
||||
// noch einmal (chk_emergency_contact).
|
||||
export function StepNotfallkontakt({
|
||||
draft,
|
||||
update,
|
||||
}: {
|
||||
draft: HireDraftData;
|
||||
update: (patch: Partial<HireDraftData>) => void;
|
||||
}) {
|
||||
const angefangen = Boolean(draft.emergencyContactName.trim() || draft.emergencyContactPhone.trim());
|
||||
const unvollstaendig = angefangen && !(draft.emergencyContactName.trim() && draft.emergencyContactPhone.trim());
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="max-w-prose text-sm text-ink-muted">
|
||||
Wen sollen wir verständigen, wenn etwas passiert? Die Angabe ist freiwillig — Name und Telefonnummer gehören
|
||||
aber zusammen, eines allein hilft im Ernstfall nicht.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<TextField
|
||||
label="Name"
|
||||
value={draft.emergencyContactName}
|
||||
onChange={(emergencyContactName) => update({ emergencyContactName })}
|
||||
/>
|
||||
<TextField
|
||||
label="Telefon"
|
||||
type="tel"
|
||||
value={draft.emergencyContactPhone}
|
||||
onChange={(emergencyContactPhone) => update({ emergencyContactPhone })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SelectField
|
||||
label="Verhältnis"
|
||||
value={draft.emergencyContactRelation}
|
||||
onChange={(emergencyContactRelation) => update({ emergencyContactRelation })}
|
||||
placeholder="Bitte wählen…"
|
||||
options={EMERGENCY_RELATIONS.map((r) => ({ value: r, label: r }))}
|
||||
/>
|
||||
|
||||
{unvollstaendig && (
|
||||
<p role="alert" className="rounded-md border border-danger-text/20 bg-danger-bg px-3 py-2 text-sm text-danger-text">
|
||||
Name und Telefonnummer werden beide gebraucht — oder beide leer lassen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,14 @@ type StepPersonProps = {
|
||||
export function StepPerson({ draft, update, locations }: StepPersonProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<TextField
|
||||
label="Personalnummer"
|
||||
required
|
||||
inputMode="numeric"
|
||||
value={draft.personnelNumber}
|
||||
onChange={(personnelNumber) => update({ personnelNumber: personnelNumber.replace(/\D/g, "") })}
|
||||
hint="Muss mit Loga und Interflex übereinstimmen. Wird nicht automatisch vergeben."
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<TextField label="Vorname" required value={draft.firstName} onChange={(firstName) => update({ firstName })} />
|
||||
<TextField label="Nachname" required value={draft.lastName} onChange={(lastName) => update({ lastName })} />
|
||||
@@ -37,8 +45,11 @@ export function StepPerson({ draft, update, locations }: StepPersonProps) {
|
||||
birthDate={draft.birthDate || null}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<TextField label="E-Mail (privat)" type="email" value={draft.email} onChange={(email) => update({ email })} />
|
||||
<TextField label="Telefon" type="tel" value={draft.phone} onChange={(phone) => update({ phone })} />
|
||||
{/* Die private Adresse, nicht die Firmenadresse — die entsteht erst
|
||||
mit dem Eintritt. Freiwillig: wer keine hat oder keine angeben
|
||||
will, soll nicht gezwungen sein, eine zu erfinden. */}
|
||||
<TextField label="Private E-Mail" type="email" value={draft.email} onChange={(email) => update({ email })} />
|
||||
<TextField label="Private Telefonnummer" type="tel" value={draft.phone} onChange={(phone) => update({ phone })} />
|
||||
</div>
|
||||
<SelectField
|
||||
label="Standort"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { dienstwagenLabel } from "@/lib/dienstwagen";
|
||||
import { fmtDate, fmtFullName } from "@/lib/format";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import type { HireDraftData } from "./types";
|
||||
@@ -21,17 +22,25 @@ export function StepSummary({ draft, selectedPosition, locations }: StepSummaryP
|
||||
const location = locations.find((l) => l.id === draft.locationId);
|
||||
const flags = [
|
||||
draft.isBetriebsrat && "Betriebsrat",
|
||||
draft.hasDienstwagen && "Dienstwagen",
|
||||
draft.hasDienstwagen && `Dienstwagen (${dienstwagenLabel(draft.dienstwagenArt)})`,
|
||||
draft.isLateraleFuehrung && "Laterale Führung",
|
||||
draft.isCLevel && "C-Level",
|
||||
draft.hasKuendigungsschutz &&
|
||||
`Besonderer Kündigungsschutz${draft.kuendigungsschutzBis ? ` bis ${fmtDate(draft.kuendigungsschutzBis)}` : ""}`,
|
||||
].filter(Boolean);
|
||||
const notfall = [draft.emergencyContactName, draft.emergencyContactRelation && `(${draft.emergencyContactRelation})`, draft.emergencyContactPhone]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const rows: [string, string][] = [
|
||||
["Personalnummer", draft.personnelNumber || "–"],
|
||||
["Name", fmtFullName(draft.firstName, draft.lastName, draft.titlePrefix, draft.titleSuffix)],
|
||||
["Geschlecht", draft.gender === "m" ? "männlich" : "weiblich"],
|
||||
["Geburtsdatum", fmtDate(draft.birthDate)],
|
||||
["SV-Nummer", draft.svNummer || "–"],
|
||||
["E-Mail (privat)", draft.email || "–"],
|
||||
["Telefon", draft.phone || "–"],
|
||||
["Notfallkontakt", notfall || "–"],
|
||||
["Standort", location ? `${location.name} (${location.country})` : "–"],
|
||||
["Position", selectedPosition ? `${selectedPosition.title} (${selectedPosition.position_number})` : "–"],
|
||||
["Organisationseinheit", selectedPosition?.orgLabel ?? "–"],
|
||||
@@ -57,9 +66,6 @@ export function StepSummary({ draft, selectedPosition, locations }: StepSummaryP
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<p className="rounded bg-info-bg px-3 py-2 text-sm text-info-text">
|
||||
Personalnummer und Firmen-E-Mail-Adresse werden automatisch vergeben.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: "",
|
||||
};
|
||||
|
||||
210
components/import/ImportWorkbench.tsx
Normal file
210
components/import/ImportWorkbench.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { CARD_CLASS } from "@/components/ui/Card";
|
||||
|
||||
// Der Ablauf hat bewusst zwei Schritte: prüfen, dann übernehmen.
|
||||
//
|
||||
// Ein Import ist nicht rückgängig zu machen. Wer 800 Zeilen schickt, soll
|
||||
// vorher sehen, was entstehen würde — und bei einem Fehler die Zeilennummer
|
||||
// lesen, nicht „Import fehlgeschlagen".
|
||||
|
||||
type Befund = { blatt: string; zeile: number | null; spalte: string | null; wert?: string; meldung: string };
|
||||
|
||||
type Antwort = {
|
||||
ok: boolean;
|
||||
geprueft: boolean;
|
||||
blaetter: string[];
|
||||
fehler: Befund[];
|
||||
hinweise: Befund[];
|
||||
anzahl: Record<string, number>;
|
||||
bericht?: Record<string, number>;
|
||||
meldung?: string;
|
||||
};
|
||||
|
||||
/** Mehr als das zeigt niemand durch; der Rest steht in der Anzahl. */
|
||||
const MAX_ANZEIGE = 200;
|
||||
|
||||
export function ImportWorkbench() {
|
||||
const [dateien, setDateien] = useState<File[]>([]);
|
||||
const [antwort, setAntwort] = useState<Antwort | null>(null);
|
||||
const [laeuft, setLaeuft] = useState<"pruefen" | "uebernehmen" | null>(null);
|
||||
const [fehlschlag, setFehlschlag] = useState<string | null>(null);
|
||||
const eingabe = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function senden(nurPruefen: boolean) {
|
||||
if (dateien.length === 0) return;
|
||||
setLaeuft(nurPruefen ? "pruefen" : "uebernehmen");
|
||||
setFehlschlag(null);
|
||||
try {
|
||||
const daten = new FormData();
|
||||
for (const d of dateien) daten.append("datei", d);
|
||||
if (nurPruefen) daten.append("pruefen", "1");
|
||||
const antwort = await fetch("/api/import", { method: "POST", body: daten });
|
||||
const inhalt = (await antwort.json()) as Antwort;
|
||||
setAntwort(inhalt);
|
||||
} catch {
|
||||
setFehlschlag("Die Datei konnte nicht übertragen werden. Bitte erneut versuchen.");
|
||||
} finally {
|
||||
setLaeuft(null);
|
||||
}
|
||||
}
|
||||
|
||||
function neueAuswahl(liste: FileList | null) {
|
||||
setDateien(liste ? Array.from(liste) : []);
|
||||
// Ein alter Bericht zu einer neuen Datei ist schlimmer als keiner.
|
||||
setAntwort(null);
|
||||
setFehlschlag(null);
|
||||
}
|
||||
|
||||
const uebernommen = antwort?.bericht !== undefined;
|
||||
const bereit = antwort?.ok === true && antwort.geprueft && !uebernommen;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<section className={`${CARD_CLASS} p-5`}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-ink">Datei wählen</h2>
|
||||
<p className="mt-1 max-w-prose text-sm text-ink-muted">
|
||||
Eine Excel-Mappe mit den Blättern <strong>Standorte</strong>, <strong>Organisation</strong>,{" "}
|
||||
<strong>Jobkatalog</strong>, <strong>Planstellen</strong>, <strong>Personen</strong>,{" "}
|
||||
<strong>Historie</strong> und <strong>Angehörige</strong> — oder je Blatt eine CSV-Datei, deren Name dem
|
||||
Blatt entspricht. Nicht jedes Blatt muss dabei sein.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="/api/import/template"
|
||||
className="shrink-0 rounded-md border border-border px-3 py-2 text-sm font-semibold text-ink hover:bg-brand-50
|
||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
Vorlage herunterladen
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
ref={eingabe}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".xlsx,.xlsm,.csv"
|
||||
onChange={(e) => neueAuswahl(e.target.files)}
|
||||
className="block w-full max-w-md text-sm text-ink-muted file:mr-3 file:rounded-md file:border file:border-border
|
||||
file:bg-surface file:px-3 file:py-2 file:text-sm file:font-semibold file:text-ink hover:file:bg-brand-50"
|
||||
/>
|
||||
<Button onClick={() => senden(true)} disabled={dateien.length === 0 || laeuft !== null}>
|
||||
{laeuft === "pruefen" ? "Wird geprüft…" : "Prüfen"}
|
||||
</Button>
|
||||
</div>
|
||||
{dateien.length > 0 && (
|
||||
<p className="mt-2 text-xs text-ink-muted">
|
||||
{dateien.length === 1 ? dateien[0].name : `${dateien.length} Dateien`} ausgewählt
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{fehlschlag && (
|
||||
<p role="alert" className="rounded-md border border-danger-text/20 bg-danger-bg px-4 py-3 text-sm text-danger-text">
|
||||
{fehlschlag}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{antwort?.meldung && (
|
||||
<p role="alert" className="rounded-md border border-danger-text/20 bg-danger-bg px-4 py-3 text-sm text-danger-text">
|
||||
{antwort.meldung}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{antwort && !antwort.meldung && (
|
||||
<section className={`${CARD_CLASS} p-5`}>
|
||||
<h2 className="text-sm font-bold text-ink">{uebernommen ? "Übernommen" : "Ergebnis der Prüfung"}</h2>
|
||||
|
||||
<table className="mt-3 w-full max-w-md text-sm">
|
||||
<tbody>
|
||||
{Object.entries(uebernommen ? antwort.bericht! : antwort.anzahl)
|
||||
.filter(([, n]) => n > 0)
|
||||
.map(([blatt, n]) => (
|
||||
<tr key={blatt} className="border-b border-border-subtle last:border-0">
|
||||
<td className="py-1.5 text-ink-body">{blatt}</td>
|
||||
<td className="py-1.5 text-right font-semibold tabular-nums text-ink">{n}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{uebernommen ? (
|
||||
<p className="mt-4 text-sm text-ink-muted">
|
||||
Die Daten stehen jetzt in der Anwendung. Der Vorgang ist im Audit-Log vermerkt.
|
||||
</p>
|
||||
) : antwort.ok ? (
|
||||
<>
|
||||
<p className="mt-4 text-sm text-ink-body">
|
||||
Keine Beanstandung. Beim Übernehmen entsteht genau das oben Gezeigte — alles in einem Zug oder gar
|
||||
nichts.
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Button onClick={() => senden(false)} disabled={laeuft !== null || !bereit}>
|
||||
{laeuft === "uebernehmen" ? "Wird übernommen…" : "Übernehmen"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-4 text-sm font-semibold text-danger-text">
|
||||
{antwort.fehler.length} Beanstandung{antwort.fehler.length === 1 ? "" : "en"} — es wurde nichts
|
||||
geschrieben.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{antwort && antwort.fehler.length > 0 && (
|
||||
<BefundListe titel="Zu beheben" befunde={antwort.fehler} art="fehler" />
|
||||
)}
|
||||
{antwort && antwort.hinweise.length > 0 && (
|
||||
<BefundListe titel="Hinweise" befunde={antwort.hinweise} art="hinweis" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BefundListe({ titel, befunde, art }: { titel: string; befunde: Befund[]; art: "fehler" | "hinweis" }) {
|
||||
const sichtbar = befunde.slice(0, MAX_ANZEIGE);
|
||||
return (
|
||||
<section className={`overflow-x-auto ${CARD_CLASS}`}>
|
||||
<h2 className="px-5 pt-5 text-sm font-bold text-ink">
|
||||
{titel} <span className="font-normal text-ink-muted">({befunde.length})</span>
|
||||
</h2>
|
||||
<table className="mt-3 w-full min-w-[640px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-y border-border bg-surface text-left text-[11px] font-bold uppercase tracking-wider text-ink-muted">
|
||||
<th className="px-5 py-2">Blatt</th>
|
||||
<th className="px-3 py-2">Zeile</th>
|
||||
<th className="px-3 py-2">Spalte</th>
|
||||
<th className="px-3 py-2">Wert</th>
|
||||
<th className="px-5 py-2">Was zu tun ist</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sichtbar.map((b, i) => (
|
||||
<tr key={i} className="border-b border-border-subtle last:border-0">
|
||||
<td className="whitespace-nowrap px-5 py-2 text-ink-body">{b.blatt}</td>
|
||||
<td className="px-3 py-2 tabular-nums text-ink-muted">{b.zeile ?? "–"}</td>
|
||||
<td className="px-3 py-2 text-ink-body">{b.spalte ?? "–"}</td>
|
||||
<td className="max-w-[16rem] truncate px-3 py-2 text-ink-muted" title={b.wert}>
|
||||
{b.wert ?? ""}
|
||||
</td>
|
||||
<td className={`px-5 py-2 ${art === "fehler" ? "text-danger-text" : "text-ink-muted"}`}>{b.meldung}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{befunde.length > sichtbar.length && (
|
||||
<p className="px-5 py-3 text-xs text-ink-muted">
|
||||
Weitere {befunde.length - sichtbar.length} nicht angezeigt. Oft hängen viele Meldungen an einer Ursache —
|
||||
nach der Korrektur erneut prüfen.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { SearchInput } from "@/components/ui/SearchInput";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
import { LazyGraphOrgChart } from "./LazyGraphOrgChart";
|
||||
import type { ChartNode, OrgEmployee } from "./types";
|
||||
import { fmtName } from "@/lib/format";
|
||||
|
||||
type ViewMode = "list" | "graph";
|
||||
|
||||
@@ -53,7 +54,7 @@ export function EmployeeTree({ employees, focusId = null }: { employees: OrgEmpl
|
||||
|
||||
// Both badges name a person by id, so the lookup is shared rather than
|
||||
// rebuilt per node.
|
||||
const nameById = useMemo(() => new Map(employees.map((e) => [e.id, `${e.first_name} ${e.last_name}`])), [employees]);
|
||||
const nameById = useMemo(() => new Map(employees.map((e) => [e.id, fmtName(e.first_name, e.last_name)])), [employees]);
|
||||
const nameOf = useCallback((id: string | null) => (id ? nameById.get(id) : undefined), [nameById]);
|
||||
|
||||
const matchIds = useMemo(() => {
|
||||
@@ -64,8 +65,17 @@ export function EmployeeTree({ employees, focusId = null }: { employees: OrgEmpl
|
||||
const q = query.trim().toLowerCase();
|
||||
const matches = new Set<string>();
|
||||
for (const e of employees) {
|
||||
// Beide Reihenfolgen: angezeigt wird „Winkler, Hannah", im Kopf hat man
|
||||
// aber je nach Anlass das eine oder das andere zuerst. Wer abtippt, was
|
||||
// er sieht, soll ebenso fündig werden wie jemand, der „hannah winkler"
|
||||
// eingibt.
|
||||
const vorNach = `${e.first_name} ${e.last_name}`.toLowerCase();
|
||||
const nachVor = fmtName(e.first_name, e.last_name).toLowerCase();
|
||||
if (
|
||||
`${e.first_name} ${e.last_name}`.toLowerCase().includes(q) ||
|
||||
vorNach.includes(q) ||
|
||||
nachVor.includes(q) ||
|
||||
// Ohne den Beistrich, falls jemand ihn beim Abtippen weglässt.
|
||||
nachVor.replace(",", "").includes(q) ||
|
||||
e.job_title.toLowerCase().includes(q) ||
|
||||
String(e.personnel_number).includes(q)
|
||||
) {
|
||||
@@ -118,7 +128,7 @@ export function EmployeeTree({ employees, focusId = null }: { employees: OrgEmpl
|
||||
return {
|
||||
id: e.id,
|
||||
kind: "person",
|
||||
label: `${e.first_name} ${e.last_name}`,
|
||||
label: fmtName(e.first_name, e.last_name),
|
||||
sublabel: e.job_title,
|
||||
href: `/employees/${e.id}`,
|
||||
avatar: { firstName: e.first_name, lastName: e.last_name },
|
||||
@@ -159,7 +169,7 @@ export function EmployeeTree({ employees, focusId = null }: { employees: OrgEmpl
|
||||
<Avatar firstName={e.first_name} lastName={e.last_name} size="sm" />
|
||||
<Link href={`/employees/${e.id}`} className="min-w-0 flex-1 hover:underline">
|
||||
<span className="text-sm font-semibold text-ink">
|
||||
{e.first_name} {e.last_name}
|
||||
{fmtName(e.first_name, e.last_name)}
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-ink-muted">{e.job_title}</span>
|
||||
</Link>
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Printer } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import { AsOfPicker } from "./AsOfPicker";
|
||||
import { EmployeeTree } from "./EmployeeTree";
|
||||
import { PositionTree } from "./PositionTree";
|
||||
import { ReorgWorkbench } from "./ReorgWorkbench";
|
||||
import type { OrgDepartment, OrgDivision, OrgEmployee, OrgTeam, ReorgScenarioSummary } from "./types";
|
||||
import type { OrgEmployee, OrgUnitNode, OrgVacancy } from "./types";
|
||||
|
||||
type View = "ma" | "pos" | "reo";
|
||||
type View = "ma" | "pos";
|
||||
|
||||
type OrgChartClientProps = {
|
||||
employees: OrgEmployee[];
|
||||
divisions: OrgDivision[];
|
||||
departments: OrgDepartment[];
|
||||
teams: OrgTeam[];
|
||||
openPositions: OpenPositionResolved[];
|
||||
reorgScenarios: ReorgScenarioSummary[];
|
||||
units: OrgUnitNode[];
|
||||
vacancies: OrgVacancy[];
|
||||
asOf: string;
|
||||
today: string;
|
||||
projectedCount: number;
|
||||
@@ -28,11 +25,8 @@ type OrgChartClientProps = {
|
||||
|
||||
export function OrgChartClient({
|
||||
employees,
|
||||
divisions,
|
||||
departments,
|
||||
teams,
|
||||
openPositions,
|
||||
reorgScenarios,
|
||||
units,
|
||||
vacancies,
|
||||
asOf,
|
||||
today,
|
||||
projectedCount,
|
||||
@@ -40,44 +34,33 @@ export function OrgChartClient({
|
||||
focusId,
|
||||
}: OrgChartClientProps) {
|
||||
const [view, setView] = useState<View>("ma");
|
||||
const isToday = asOf === today;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<SegmentedControl<View>
|
||||
value={view}
|
||||
onChange={setView}
|
||||
options={[
|
||||
{ value: "ma", label: "Mitarbeiter" },
|
||||
{ value: "pos", label: "Positionen" },
|
||||
{ value: "reo", label: "Reorganisation" },
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<SegmentedControl<View>
|
||||
value={view}
|
||||
onChange={setView}
|
||||
options={[
|
||||
{ value: "ma", label: "Mitarbeiter" },
|
||||
{ value: "pos", label: "Organisation" },
|
||||
]}
|
||||
/>
|
||||
{/* Der Stichtag wandert mit: wer eine vergangene Struktur ansieht,
|
||||
druckt sie auch. */}
|
||||
<Link
|
||||
href={asOf === today ? "/orgchart/print" : `/orgchart/print?asOf=${asOf}`}
|
||||
className="inline-flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-semibold text-ink hover:bg-brand-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
<Printer className="h-4 w-4" />
|
||||
Als PDF
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{view !== "reo" && (
|
||||
<AsOfPicker asOf={asOf} today={today} projectedCount={projectedCount} historyStartsAt={historyStartsAt} />
|
||||
)}
|
||||
<AsOfPicker asOf={asOf} today={today} projectedCount={projectedCount} historyStartsAt={historyStartsAt} />
|
||||
|
||||
{view === "ma" && <EmployeeTree employees={employees} focusId={focusId} />}
|
||||
{view === "pos" && (
|
||||
<PositionTree employees={employees} divisions={divisions} departments={departments} teams={teams} openPositions={openPositions} />
|
||||
)}
|
||||
{view === "reo" &&
|
||||
(isToday ? (
|
||||
<ReorgWorkbench employees={employees} divisions={divisions} departments={departments} teams={teams} reorgScenarios={reorgScenarios} />
|
||||
) : (
|
||||
// A reorg planned against a past or projected roster would be
|
||||
// applied to the *live* org anyway — better to send the user back
|
||||
// to today than to let them assemble moves from a roster that is
|
||||
// not the one the change would hit.
|
||||
<div className="rounded border border-border bg-white p-6 text-sm text-ink-body">
|
||||
<p className="font-semibold text-ink">Reorganisation nur zum heutigen Stand</p>
|
||||
<p className="mt-1 text-ink-muted">
|
||||
Es ist ein abweichender Stichtag gewählt. Reorganisationen wirken immer auf die aktuelle Struktur — wechseln
|
||||
Sie zurück auf „Heute“, um eine zu planen.
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{view === "pos" && <PositionTree employees={employees} units={units} vacancies={vacancies} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,56 +2,35 @@
|
||||
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import { LazyGraphOrgChart } from "./LazyGraphOrgChart";
|
||||
import type { ChartNode, OrgDepartment, OrgDivision, OrgEmployee, OrgTeam } from "./types";
|
||||
import type { ChartNode, OrgEmployee, OrgUnitNode, OrgVacancy } from "./types";
|
||||
import { fmtName } from "@/lib/format";
|
||||
|
||||
// Die Struktursicht. Sie folgt jetzt org_units.parent_id statt einer fest
|
||||
// verdrahteten Abfolge Bereich → Abteilung → Team: eine fünfte Ebene ist
|
||||
// damit eine Datenfrage und keine Änderung an dieser Datei.
|
||||
//
|
||||
// Liste und Grafik werden aus *einem* Baum gerendert. Vorher gab es dieselbe
|
||||
// Hierarchie zweimal — einmal als JSX-Schachtelung, einmal als ChartNode —
|
||||
// und die beiden konnten auseinanderlaufen, ohne dass es auffiel.
|
||||
|
||||
type ViewMode = "list" | "graph";
|
||||
|
||||
function TreeRow({
|
||||
depth,
|
||||
expandable,
|
||||
expandedNow,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
depth: number;
|
||||
expandable: boolean;
|
||||
expandedNow: boolean;
|
||||
onToggle: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface" style={{ paddingLeft: depth * 24 + 8 }}>
|
||||
{expandable ? (
|
||||
<button type="button" onClick={onToggle} className="shrink-0 text-ink-muted">
|
||||
{expandedNow ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-4 shrink-0" />
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type PositionTreeProps = {
|
||||
employees: OrgEmployee[];
|
||||
divisions: OrgDivision[];
|
||||
departments: OrgDepartment[];
|
||||
teams: OrgTeam[];
|
||||
openPositions: OpenPositionResolved[];
|
||||
units: OrgUnitNode[];
|
||||
vacancies: OrgVacancy[];
|
||||
};
|
||||
|
||||
export function PositionTree({ employees, divisions, departments, teams, openPositions }: PositionTreeProps) {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set(["root"]));
|
||||
export function PositionTree({ employees, units, vacancies }: PositionTreeProps) {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(units.filter((u) => u.parent_id === null).map((u) => `unit-${u.id}`)));
|
||||
const [mode, setMode] = useState<ViewMode>("list");
|
||||
|
||||
// useCallback-stable: GraphOrgChart's layout memo depends on these
|
||||
// references, so unstable functions would force a Dagre re-layout on
|
||||
// every unrelated re-render.
|
||||
// useCallback-stabil: das Layout-Memo von GraphOrgChart hängt an diesen
|
||||
// Referenzen, instabile Funktionen erzwängen sonst bei jedem Re-Render ein
|
||||
// neues Dagre-Layout.
|
||||
const toggle = useCallback((id: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -60,216 +39,178 @@ export function PositionTree({ employees, divisions, departments, teams, openPos
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isExpanded = useCallback((id: string) => expanded.has(id), [expanded]);
|
||||
|
||||
const ceo = employees.find((e) => e.org_level === 0) ?? null;
|
||||
const { divisionHeadByDivision, teamLeadByTeam, icsByTeamAndTitle } = useMemo(() => {
|
||||
const divisionHeadByDivision = new Map<string, OrgEmployee>();
|
||||
const teamLeadByTeam = new Map<string, OrgEmployee>();
|
||||
const icsByTeamAndTitle = new Map<string, Map<string, OrgEmployee[]>>();
|
||||
for (const e of employees) {
|
||||
if (e.org_level === 1 && e.division_id) divisionHeadByDivision.set(e.division_id, e);
|
||||
if (e.is_lead && e.team_id) teamLeadByTeam.set(e.team_id, e);
|
||||
if (!e.is_lead && e.org_level === 3 && e.team_id) {
|
||||
if (!icsByTeamAndTitle.has(e.team_id)) icsByTeamAndTitle.set(e.team_id, new Map());
|
||||
const byTitle = icsByTeamAndTitle.get(e.team_id)!;
|
||||
if (!byTitle.has(e.job_title)) byTitle.set(e.job_title, []);
|
||||
byTitle.get(e.job_title)!.push(e);
|
||||
}
|
||||
}
|
||||
return { divisionHeadByDivision, teamLeadByTeam, icsByTeamAndTitle };
|
||||
}, [employees]);
|
||||
const openByTeam = useMemo(() => {
|
||||
const map = new Map<string, OpenPositionResolved[]>();
|
||||
for (const p of openPositions) {
|
||||
if (!map.has(p.team_id)) map.set(p.team_id, []);
|
||||
map.get(p.team_id)!.push(p);
|
||||
}
|
||||
return map;
|
||||
}, [openPositions]);
|
||||
|
||||
// Mirrors the JSX walk below into the generic ChartNode shape for graph
|
||||
// mode — same synthetic ids ("root", div-*, dept-*, team-*, teamKey-title)
|
||||
// the list already uses as `expanded` keys, so one Set drives both.
|
||||
const chartTree = useMemo<ChartNode[]>(() => {
|
||||
if (!ceo) return [];
|
||||
|
||||
function buildTeam(team: OrgTeam): ChartNode {
|
||||
const teamKey = `team-${team.id}`;
|
||||
const lead = teamLeadByTeam.get(team.id);
|
||||
const icsByTitle = icsByTeamAndTitle.get(team.id) ?? new Map<string, OrgEmployee[]>();
|
||||
const openForTeam = openByTeam.get(team.id) ?? [];
|
||||
|
||||
const titleGroups: ChartNode[] = Array.from(icsByTitle.entries()).map(([title, people]) => ({
|
||||
id: `${teamKey}-${title}`,
|
||||
kind: "group",
|
||||
label: title,
|
||||
sublabel: `${people.length}x besetzt`,
|
||||
children: people.map((p) => ({
|
||||
id: p.id,
|
||||
kind: "person",
|
||||
label: `${p.first_name} ${p.last_name}`,
|
||||
href: `/employees/${p.id}`,
|
||||
avatar: { firstName: p.first_name, lastName: p.last_name },
|
||||
children: [],
|
||||
})),
|
||||
}));
|
||||
|
||||
const vacancyNodes: ChartNode[] = openForTeam.map((p) => ({
|
||||
id: `vac-${p.id}`,
|
||||
kind: "vacancy",
|
||||
label: `${p.position_number} · ${p.title}`,
|
||||
href: "/positions",
|
||||
children: [],
|
||||
}));
|
||||
|
||||
return {
|
||||
id: teamKey,
|
||||
kind: "role",
|
||||
label: `Teamleitung ${team.name}`,
|
||||
sublabel: lead ? `besetzt: ${lead.first_name} ${lead.last_name}` : "vakant",
|
||||
vacant: !lead,
|
||||
children: [...titleGroups, ...vacancyNodes],
|
||||
};
|
||||
}
|
||||
|
||||
function buildDept(dept: OrgDepartment): ChartNode {
|
||||
return {
|
||||
id: `dept-${dept.id}`,
|
||||
kind: "group",
|
||||
label: `${dept.org_number} · ${dept.name}`,
|
||||
children: teams.filter((t) => t.department_id === dept.id).map(buildTeam),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDivision(div: OrgDivision): ChartNode {
|
||||
const head = divisionHeadByDivision.get(div.id);
|
||||
return {
|
||||
id: `div-${div.id}`,
|
||||
kind: "role",
|
||||
label: `Bereichsleitung ${div.name}`,
|
||||
sublabel: head ? `besetzt: ${head.first_name} ${head.last_name}` : "vakant",
|
||||
vacant: !head,
|
||||
children: departments.filter((d) => d.division_id === div.id).map(buildDept),
|
||||
};
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: "root",
|
||||
kind: "role",
|
||||
label: "Geschäftsführung",
|
||||
sublabel: `besetzt: ${ceo.first_name} ${ceo.last_name}`,
|
||||
children: divisions.map(buildDivision),
|
||||
},
|
||||
];
|
||||
}, [ceo, divisionHeadByDivision, teamLeadByTeam, icsByTeamAndTitle, openByTeam, divisions, departments, teams]);
|
||||
const tree = useMemo(() => buildUnitTree(units, employees, vacancies), [units, employees, vacancies]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-end">
|
||||
<SegmentedControl<ViewMode> value={mode} onChange={setMode} options={[{ value: "list", label: "Liste" }, { value: "graph", label: "Grafisch" }]} />
|
||||
<SegmentedControl<ViewMode>
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={[
|
||||
{ value: "list", label: "Liste" },
|
||||
{ value: "graph", label: "Grafisch" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{mode === "graph" ? (
|
||||
<LazyGraphOrgChart tree={chartTree} isExpanded={isExpanded} onToggle={toggle} />
|
||||
<LazyGraphOrgChart tree={tree} isExpanded={isExpanded} onToggle={toggle} />
|
||||
) : (
|
||||
<div className="rounded border border-border bg-white p-4">
|
||||
{ceo && (
|
||||
<TreeRow depth={0} expandable expandedNow={expanded.has("root")} onToggle={() => toggle("root")}>
|
||||
<span className="text-sm font-semibold text-ink">Geschäftsführung</span>
|
||||
<span className="text-xs text-ink-muted">
|
||||
besetzt: {ceo.first_name} {ceo.last_name}
|
||||
</span>
|
||||
</TreeRow>
|
||||
)}
|
||||
{expanded.has("root") &&
|
||||
divisions.map((div) => {
|
||||
const head = divisionHeadByDivision.get(div.id);
|
||||
const divKey = `div-${div.id}`;
|
||||
return (
|
||||
<div key={div.id}>
|
||||
<TreeRow depth={1} expandable expandedNow={expanded.has(divKey)} onToggle={() => toggle(divKey)}>
|
||||
<span className="text-sm font-semibold text-ink">Bereichsleitung {div.name}</span>
|
||||
<span className="text-xs text-ink-muted">{head ? `besetzt: ${head.first_name} ${head.last_name}` : "vakant"}</span>
|
||||
</TreeRow>
|
||||
{expanded.has(divKey) &&
|
||||
departments
|
||||
.filter((d) => d.division_id === div.id)
|
||||
.map((dept) => {
|
||||
const deptKey = `dept-${dept.id}`;
|
||||
return (
|
||||
<div key={dept.id}>
|
||||
<TreeRow depth={2} expandable expandedNow={expanded.has(deptKey)} onToggle={() => toggle(deptKey)}>
|
||||
<span className="text-sm text-ink">
|
||||
{dept.org_number} · {dept.name}
|
||||
</span>
|
||||
</TreeRow>
|
||||
{expanded.has(deptKey) &&
|
||||
teams
|
||||
.filter((t) => t.department_id === dept.id)
|
||||
.map((team) => {
|
||||
const teamKey = `team-${team.id}`;
|
||||
const lead = teamLeadByTeam.get(team.id);
|
||||
const icsByTitle = icsByTeamAndTitle.get(team.id) ?? new Map<string, OrgEmployee[]>();
|
||||
const openForTeam = openByTeam.get(team.id) ?? [];
|
||||
return (
|
||||
<div key={team.id}>
|
||||
<TreeRow depth={3} expandable expandedNow={expanded.has(teamKey)} onToggle={() => toggle(teamKey)}>
|
||||
<span className="text-sm text-ink">Teamleitung {team.name}</span>
|
||||
<span className="text-xs text-ink-muted">
|
||||
{lead ? `besetzt: ${lead.first_name} ${lead.last_name}` : "vakant"}
|
||||
</span>
|
||||
</TreeRow>
|
||||
{expanded.has(teamKey) && (
|
||||
<>
|
||||
{Array.from(icsByTitle.entries()).map(([title, people]) => {
|
||||
const groupKey = `${teamKey}-${title}`;
|
||||
return (
|
||||
<div key={title}>
|
||||
<TreeRow
|
||||
depth={4}
|
||||
expandable={people.length > 0}
|
||||
expandedNow={expanded.has(groupKey)}
|
||||
onToggle={() => toggle(groupKey)}
|
||||
>
|
||||
<span className="text-sm text-ink">{title}</span>
|
||||
<span className="text-xs text-ink-muted">{people.length}x besetzt</span>
|
||||
</TreeRow>
|
||||
{expanded.has(groupKey) &&
|
||||
people.map((p) => (
|
||||
<div key={p.id} className="py-1" style={{ paddingLeft: 5 * 24 + 8 }}>
|
||||
<Link href={`/employees/${p.id}`} className="text-sm text-ink-body hover:text-brand-700 hover:underline">
|
||||
{p.first_name} {p.last_name}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{openForTeam.map((p) => (
|
||||
<Link
|
||||
key={p.id}
|
||||
href="/positions"
|
||||
className="block border-l-2 border-dashed border-brand-200 py-1 text-sm text-brand-700 hover:underline"
|
||||
style={{ paddingLeft: 4 * 24 + 8 }}
|
||||
>
|
||||
+ {p.position_number} · {p.title}
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{tree.map((node) => (
|
||||
<ListNode key={node.id} node={node} depth={0} expanded={expanded} onToggle={toggle} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListNode({
|
||||
node,
|
||||
depth,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
node: ChartNode;
|
||||
depth: number;
|
||||
expanded: Set<string>;
|
||||
onToggle: (id: string) => void;
|
||||
}) {
|
||||
const expandable = node.children.length > 0;
|
||||
const open = expanded.has(node.id);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface"
|
||||
style={{ paddingLeft: depth * 24 + 8 }}
|
||||
>
|
||||
{expandable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(node.id)}
|
||||
className="shrink-0 text-ink-muted"
|
||||
aria-expanded={open}
|
||||
aria-label={open ? `${node.label} zuklappen` : `${node.label} aufklappen`}
|
||||
>
|
||||
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-4 shrink-0" />
|
||||
)}
|
||||
{node.href ? (
|
||||
<Link
|
||||
href={node.href}
|
||||
className={node.kind === "vacancy" ? "text-sm text-brand-700 hover:underline" : "text-sm text-ink-body hover:text-brand-700 hover:underline"}
|
||||
>
|
||||
{node.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className={node.kind === "group" ? "text-sm text-ink" : "text-sm font-semibold text-ink"}>{node.label}</span>
|
||||
)}
|
||||
{node.sublabel && (
|
||||
<span className={node.vacant ? "text-xs font-semibold text-warning-text" : "text-xs text-ink-muted"}>{node.sublabel}</span>
|
||||
)}
|
||||
</div>
|
||||
{open && node.children.map((child) => <ListNode key={child.id} node={child} depth={depth + 1} expanded={expanded} onToggle={onToggle} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Je Einheit: die Leitung als Kopfzeile, darunter die untergeordneten
|
||||
* Einheiten, dann die eigenen Mitarbeitenden nach Tätigkeit gruppiert und
|
||||
* zuletzt die unbesetzten Planstellen.
|
||||
*/
|
||||
export function buildUnitTree(units: OrgUnitNode[], employees: OrgEmployee[], vacancies: OrgVacancy[]): ChartNode[] {
|
||||
const childUnits = new Map<string | null, OrgUnitNode[]>();
|
||||
for (const u of units) {
|
||||
const list = childUnits.get(u.parent_id) ?? [];
|
||||
list.push(u);
|
||||
childUnits.set(u.parent_id, list);
|
||||
}
|
||||
for (const list of childUnits.values()) list.sort((a, b) => a.org_number.localeCompare(b.org_number));
|
||||
|
||||
const chiefOf = new Map<string, OrgEmployee>();
|
||||
const staffOf = new Map<string, OrgEmployee[]>();
|
||||
for (const e of employees) {
|
||||
if (e.is_chief) chiefOf.set(e.org_unit_id, e);
|
||||
else {
|
||||
const list = staffOf.get(e.org_unit_id) ?? [];
|
||||
list.push(e);
|
||||
staffOf.set(e.org_unit_id, list);
|
||||
}
|
||||
}
|
||||
|
||||
const vacantOf = new Map<string, OrgVacancy[]>();
|
||||
for (const v of vacancies) {
|
||||
const list = vacantOf.get(v.org_unit_id) ?? [];
|
||||
list.push(v);
|
||||
vacantOf.set(v.org_unit_id, list);
|
||||
}
|
||||
|
||||
function build(unit: OrgUnitNode): ChartNode {
|
||||
const key = `unit-${unit.id}`;
|
||||
const chief = chiefOf.get(unit.id);
|
||||
|
||||
const subUnits = (childUnits.get(unit.id) ?? []).map(build);
|
||||
|
||||
// Nach Tätigkeit gruppiert: dreissig Zeilen „Maschinenbediener:in" sagen
|
||||
// weniger als eine Zeile „Maschinenbediener:in — 30x besetzt".
|
||||
const byTitle = new Map<string, OrgEmployee[]>();
|
||||
for (const e of staffOf.get(unit.id) ?? []) {
|
||||
const list = byTitle.get(e.job_title) ?? [];
|
||||
list.push(e);
|
||||
byTitle.set(e.job_title, list);
|
||||
}
|
||||
const titleGroups: ChartNode[] = Array.from(byTitle.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b, "de"))
|
||||
.map(([title, people]) => ({
|
||||
id: `${key}-job-${title}`,
|
||||
kind: "group" as const,
|
||||
label: title,
|
||||
sublabel: `${people.length}x besetzt`,
|
||||
children: people
|
||||
.slice()
|
||||
.sort((a, b) => a.last_name.localeCompare(b.last_name, "de"))
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
kind: "person" as const,
|
||||
label: fmtName(p.first_name, p.last_name),
|
||||
href: `/employees/${p.id}`,
|
||||
avatar: { firstName: p.first_name, lastName: p.last_name },
|
||||
absent: p.absent,
|
||||
badge: p.absent ? (p.absence_type ?? "Abwesend") : undefined,
|
||||
children: [],
|
||||
})),
|
||||
}));
|
||||
|
||||
const vacancyNodes: ChartNode[] = (vacantOf.get(unit.id) ?? [])
|
||||
.filter((v) => !v.is_chief) // die Leitungsvakanz steht schon in der Kopfzeile
|
||||
.sort((a, b) => a.position_number.localeCompare(b.position_number))
|
||||
.map((v) => ({
|
||||
id: `vac-${v.position_id}`,
|
||||
kind: "vacancy" as const,
|
||||
label: `+ ${v.position_number} · ${v.job_title}`,
|
||||
href: "/positions",
|
||||
vacant: true,
|
||||
children: [],
|
||||
}));
|
||||
|
||||
return {
|
||||
id: key,
|
||||
kind: "role",
|
||||
label: `${unit.org_number} · ${unit.name}`,
|
||||
sublabel: chief
|
||||
? `Leitung: ${fmtName(chief.first_name, chief.last_name)}${chief.absent ? " (abwesend)" : ""}`
|
||||
: "Leitung vakant",
|
||||
vacant: !chief,
|
||||
children: [...subUnits, ...titleGroups, ...vacancyNodes],
|
||||
};
|
||||
}
|
||||
|
||||
return (childUnits.get(null) ?? []).map(build);
|
||||
}
|
||||
|
||||
609
components/orgchart/PrintChart.tsx
Normal file
609
components/orgchart/PrintChart.tsx
Normal file
@@ -0,0 +1,609 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronRight, Printer } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import type { PrintModel, PrintPerson, PrintUnit } from "@/lib/orgchart-print";
|
||||
|
||||
// Auswahl, dann Vorschau, dann Papier.
|
||||
//
|
||||
// Drei Entscheidungen prägen das hier:
|
||||
//
|
||||
// **Erstens wird gefragt, bevor gedruckt wird.** Ein Organigramm mit 810
|
||||
// Personen auf Papier ist fast nie das, was jemand braucht — meist geht es um
|
||||
// einen Bereich, oder um die oberen zwei Ebenen für eine Besprechung. Wer
|
||||
// alles bekommt, druckt vierzig Blätter und wirft achtunddreissig weg.
|
||||
//
|
||||
// **Zweitens wird hierarchisch gezeichnet, nicht aufgelistet.** Kästen mit
|
||||
// Verbindungslinien zeigen die Über- und Unterordnung; eine Spaltenliste
|
||||
// zeigt sie nicht, egal wie sauber sie gesetzt ist. Die Linien entstehen aus
|
||||
// Rahmen von Pseudo-Elementen — damit sind sie im PDF Vektoren und bleiben
|
||||
// beim Hineinzoomen scharf.
|
||||
//
|
||||
// **Drittens breitet sich nur eine Ebene waagrecht aus.** Ein Baum, der auf
|
||||
// jeder Stufe in die Breite geht, wächst exponentiell: sechs Bereiche mit je
|
||||
// drei Abteilungen sind achtzehn Kästen nebeneinander, und quer über ein A4
|
||||
// bleiben davon zwei Millimeter je Kasten — die erste Fassung lief genau
|
||||
// deshalb über den Blattrand hinaus. Jetzt stehen die Bereiche in einer
|
||||
// Reihe, und alles darunter hängt längs an einer Linie: die Breite ist die
|
||||
// Zahl der Bereiche, unabhängig von der Tiefe. Was dann immer noch übersteht,
|
||||
// wird als Ganzes so weit verkleinert, bis es aufs Blatt passt.
|
||||
|
||||
type Tiefe = "bereich" | "abteilung" | "team" | "personen";
|
||||
|
||||
const TIEFEN: { wert: Tiefe; label: string; hinweis: string }[] = [
|
||||
{ wert: "bereich", label: "Bereiche", hinweis: "Gesellschaft und Bereiche — ein Blatt" },
|
||||
{ wert: "abteilung", label: "bis Abteilung", hinweis: "eine Ebene tiefer, noch immer ein Blatt" },
|
||||
{ wert: "team", label: "bis Team", hinweis: "je Bereich ein Blatt, ohne Namen" },
|
||||
{ wert: "personen", label: "mit Personen", hinweis: "je Abteilung ein Blatt, mit allen Namen" },
|
||||
];
|
||||
|
||||
type Format = "a4" | "a3";
|
||||
// Die Satzfläche ist die Blattgrösse abzüglich der Ränder aus @page, ein
|
||||
// wenig knapper gerechnet. Die Vorschau bekommt genau diese Masse fest
|
||||
// zugewiesen — dadurch ist das, was am Bildschirm zu sehen ist, dasselbe, was
|
||||
// aufs Papier passt, und nicht bloss ungefähr.
|
||||
const FORMATE: Record<Format, { label: string; css: string; blatt: string; satzBreite: string; satzHoehe: string }> = {
|
||||
a4: { label: "A4 quer", css: "A4 landscape", blatt: "297mm", satzBreite: "268mm", satzHoehe: "182mm" },
|
||||
a3: { label: "A3 quer", css: "A3 landscape", blatt: "420mm", satzBreite: "390mm", satzHoehe: "269mm" },
|
||||
};
|
||||
|
||||
/** Unter diesem Massstab wird es auf A4 mühsam — dann lieber A3. */
|
||||
const LESBAR = 0.55;
|
||||
|
||||
type Seite = {
|
||||
key: string;
|
||||
titel: string;
|
||||
unterzeile: string;
|
||||
wurzel: PrintUnit;
|
||||
/** Nur auf der Übersicht: die gewählten Bereiche statt aller Kinder. */
|
||||
kinder?: PrintUnit[];
|
||||
/** Wie viele Ebenen unter der Wurzel gezeichnet werden. */
|
||||
ebenen: number;
|
||||
mitPersonen: boolean;
|
||||
};
|
||||
|
||||
function seitenBauen(model: PrintModel, bereiche: PrintUnit[], tiefe: Tiefe, asOf: string): Seite[] {
|
||||
const stand = `Stand ${fmtDate(asOf)}`;
|
||||
const seiten: Seite[] = [];
|
||||
|
||||
if (model.root) {
|
||||
seiten.push({
|
||||
key: "uebersicht",
|
||||
titel: model.root.name,
|
||||
unterzeile: `Organigramm · ${stand} · ${model.totals.people} Personen`,
|
||||
wurzel: model.root,
|
||||
kinder: bereiche,
|
||||
// Bei „bis Abteilung" ist die Übersicht das ganze Ergebnis. Sonst folgen
|
||||
// Detailblätter, und dann bleibt sie eine reine Bereichsübersicht.
|
||||
ebenen: tiefe === "abteilung" ? 2 : 1,
|
||||
mitPersonen: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (tiefe === "team") {
|
||||
for (const b of bereiche) {
|
||||
seiten.push({
|
||||
key: b.id,
|
||||
titel: b.name,
|
||||
unterzeile: `${b.totalPeople} Personen · ${stand}`,
|
||||
wurzel: b,
|
||||
ebenen: 2,
|
||||
mitPersonen: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (tiefe === "personen") {
|
||||
for (const b of bereiche) {
|
||||
// Wer unmittelbar im Bereich sitzt, hätte sonst kein Blatt — die
|
||||
// Leitung steht auf der Übersicht, die übrigen nirgends.
|
||||
if (b.members.length > 0) {
|
||||
seiten.push({
|
||||
key: `${b.id}-direkt`,
|
||||
titel: b.name,
|
||||
unterzeile: `Bereichsleitung und Stab · ${stand}`,
|
||||
wurzel: b,
|
||||
ebenen: 0,
|
||||
mitPersonen: true,
|
||||
});
|
||||
}
|
||||
for (const a of b.children) {
|
||||
seiten.push({
|
||||
key: a.id,
|
||||
titel: `${b.name} · ${a.name}`,
|
||||
unterzeile: `${a.totalPeople} Personen · ${stand}`,
|
||||
wurzel: a,
|
||||
ebenen: 1,
|
||||
mitPersonen: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return seiten;
|
||||
}
|
||||
|
||||
export function PrintChart({ model, asOf, today }: { model: PrintModel; asOf: string; today: string }) {
|
||||
const [format, setFormat] = useState<Format>("a4");
|
||||
const [tiefe, setTiefe] = useState<Tiefe>("team");
|
||||
const [gewaehlt, setGewaehlt] = useState<Set<string>>(() => new Set(model.divisions.map((d) => d.id)));
|
||||
const [schritt, setSchritt] = useState<"auswahl" | "vorschau">("auswahl");
|
||||
|
||||
const bereiche = useMemo(() => model.divisions.filter((d) => gewaehlt.has(d.id)), [model.divisions, gewaehlt]);
|
||||
const seiten = useMemo(() => seitenBauen(model, bereiche, tiefe, asOf), [model, bereiche, tiefe, asOf]);
|
||||
|
||||
// Der kleinste Massstab aller Blätter — daran hängt der Hinweis auf A3.
|
||||
//
|
||||
// Die gemeldeten Werte tragen die Auswahl mit, für die sie gemessen wurden:
|
||||
// ändert sie sich, sind die alten Werte nicht mehr gültig und werden
|
||||
// verworfen, sobald das erste Blatt der neuen Auswahl meldet. Das erspart
|
||||
// einen Effekt, der beim Wechsel aufräumt — und damit eine Renderrunde,
|
||||
// in der noch der Hinweis der vorherigen Auswahl stünde.
|
||||
const signatur = `${format}|${tiefe}|${[...gewaehlt].sort().join(",")}`;
|
||||
const [gemessen, setGemessen] = useState<{ signatur: string; werte: Record<string, number> }>({ signatur, werte: {} });
|
||||
const meldeMassstab = useCallback(
|
||||
(key: string, faktor: number) => {
|
||||
setGemessen((prev) => {
|
||||
// Unverändert heisst unverändert: denselben Zustand zurückgeben, nicht
|
||||
// ein gleich aussehendes neues Objekt. Ein neues Objekt zählt für React
|
||||
// als Änderung und löst ein Rendern aus — und da das Rendern die
|
||||
// Messung anstösst, die wieder hier landet, dreht sich das ohne Ende.
|
||||
// Genau daran ist die Vorschau eingefroren.
|
||||
if (prev.signatur === signatur && prev.werte[key] === faktor) return prev;
|
||||
return prev.signatur === signatur
|
||||
? { signatur, werte: { ...prev.werte, [key]: faktor } }
|
||||
: { signatur, werte: { [key]: faktor } };
|
||||
});
|
||||
},
|
||||
[signatur]
|
||||
);
|
||||
const engste = Math.min(1, ...Object.values(gemessen.signatur === signatur ? gemessen.werte : {}));
|
||||
|
||||
function umschalten(id: string) {
|
||||
setGewaehlt((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<style>{`
|
||||
@page { size: ${FORMATE[format].css}; margin: 12mm 14mm 14mm; }
|
||||
@media print {
|
||||
html, body { background: #fff; }
|
||||
.nur-bildschirm { display: none !important; }
|
||||
.blatt {
|
||||
box-shadow: none !important; border: 0 !important; border-radius: 0 !important;
|
||||
margin: 0 !important; width: auto !important; padding: 0 !important;
|
||||
break-after: page;
|
||||
}
|
||||
.blatt:last-child { break-after: auto; }
|
||||
/* Die Satzfläche behält ihre Masse — der Massstab wurde für genau
|
||||
diese berechnet. Nur der Rand fällt weg, den setzt @page. */
|
||||
.satz { margin: 0 !important; }
|
||||
}
|
||||
|
||||
/* Eine Reihe: waagrechte Geschwister mit Winkeln nach oben. Das erste
|
||||
und das letzte Kind lassen ihre Aussenseite weg — zusammen ergibt
|
||||
das eine durchgehende Querlinie ohne ein einziges Hilfselement. */
|
||||
ul.reihe {
|
||||
position: relative; display: flex; justify-content: center;
|
||||
list-style: none; margin: 0; padding: 18px 0 0;
|
||||
}
|
||||
ul.reihe::before {
|
||||
content: ""; position: absolute; top: 0; left: 50%;
|
||||
height: 18px; border-left: 1px solid var(--color-border);
|
||||
}
|
||||
ul.reihe > li { position: relative; width: var(--spalte); padding: 18px 6px 0; }
|
||||
ul.reihe > li::before, ul.reihe > li::after {
|
||||
content: ""; position: absolute; top: 0; width: 50%; height: 18px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
ul.reihe > li::before { right: 50%; }
|
||||
ul.reihe > li::after { left: 50%; border-left: 1px solid var(--color-border); }
|
||||
ul.reihe > li:first-child::before { border-top: 0; }
|
||||
ul.reihe > li:last-child::after { border-top: 0; }
|
||||
|
||||
/* Ein Stapel: längs an einer Linie, mit Winkel in jeden Kasten. Kostet
|
||||
Höhe statt Breite — und Höhe ist auf einem Querformat übrig. */
|
||||
ul.stapel { list-style: none; margin: 0; padding: 0; text-align: left; }
|
||||
ul.stapel > li { position: relative; padding: 8px 0 0 20px; }
|
||||
ul.stapel > li::before {
|
||||
content: ""; position: absolute; left: 9px; top: 0; bottom: 0;
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
ul.stapel > li:last-child::before { bottom: auto; height: 22px; }
|
||||
ul.stapel > li::after {
|
||||
content: ""; position: absolute; left: 9px; top: 22px; width: 11px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* ── Auswahl ─────────────────────────────────────────── */}
|
||||
<section className="nur-bildschirm rounded border border-border bg-white p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-ink">Was soll aufs Papier?</h2>
|
||||
<p className="mt-0.5 max-w-prose text-sm text-ink-muted">
|
||||
Das vollständige Organigramm sind {model.totals.people} Personen. Gedruckt wird selten alles — hier steht,
|
||||
wie tief und welche Bereiche.
|
||||
</p>
|
||||
</div>
|
||||
<p className="whitespace-nowrap text-sm text-ink-muted">
|
||||
Stand {fmtDate(asOf)}
|
||||
{asOf !== today && " · abweichender Stichtag"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-ink-muted">Tiefe</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TIEFEN.map((t) => (
|
||||
<button
|
||||
key={t.wert}
|
||||
type="button"
|
||||
onClick={() => setTiefe(t.wert)}
|
||||
aria-pressed={tiefe === t.wert}
|
||||
className={`rounded-md border px-3 py-2 text-left focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
|
||||
tiefe === t.wert ? "border-brand-500 bg-brand-50" : "border-border hover:bg-surface"
|
||||
}`}
|
||||
>
|
||||
<span className="block text-sm font-semibold text-ink">{t.label}</span>
|
||||
<span className="block text-xs text-ink-muted">{t.hinweis}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-ink-muted">
|
||||
Bereiche ({gewaehlt.size} von {model.divisions.length})
|
||||
</p>
|
||||
<div className="flex gap-3 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGewaehlt(new Set(model.divisions.map((d) => d.id)))}
|
||||
className="rounded font-semibold text-brand-700 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
Alle
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGewaehlt(new Set())}
|
||||
className="rounded font-semibold text-brand-700 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
Keine
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{model.divisions.map((d) => (
|
||||
<label
|
||||
key={d.id}
|
||||
className={`flex cursor-pointer items-start gap-2 rounded-md border px-3 py-2 ${
|
||||
gewaehlt.has(d.id) ? "border-brand-500 bg-brand-50" : "border-border"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={gewaehlt.has(d.id)}
|
||||
onChange={() => umschalten(d.id)}
|
||||
className="mt-0.5 h-4 w-4 rounded border-border text-brand-600"
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-sm font-semibold text-ink">{d.name}</span>
|
||||
<span className="block text-xs text-ink-muted">
|
||||
{d.totalPeople} Personen · {d.children.length} Abteilungen
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-center justify-between gap-3 border-t border-border-subtle pt-4">
|
||||
<p className="text-sm text-ink-muted">
|
||||
{gewaehlt.size === 0 ? (
|
||||
<span className="text-danger-text">Kein Bereich gewählt — es bliebe nur die Übersicht.</span>
|
||||
) : (
|
||||
<>
|
||||
Ergibt <strong className="text-ink">{seiten.length}</strong> {seiten.length === 1 ? "Seite" : "Seiten"}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/orgchart"
|
||||
className="rounded-md border border-border px-3 py-2 text-sm font-semibold text-ink hover:bg-brand-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
Zurück
|
||||
</Link>
|
||||
<Button onClick={() => setSchritt("vorschau")}>
|
||||
Vorschau <ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{schritt === "vorschau" && (
|
||||
<>
|
||||
<div className="nur-bildschirm flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm text-ink-muted">
|
||||
{seiten.length} {seiten.length === 1 ? "Seite" : "Seiten"} · so kommt es aus dem Drucker
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex rounded-md border border-border p-0.5">
|
||||
{(Object.keys(FORMATE) as Format[]).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
onClick={() => setFormat(f)}
|
||||
aria-pressed={format === f}
|
||||
className={`rounded px-3 py-1.5 text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
|
||||
format === f ? "bg-brand-500 text-white" : "text-ink-muted hover:bg-surface"
|
||||
}`}
|
||||
>
|
||||
{FORMATE[f].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button onClick={() => window.print()}>
|
||||
<Printer className="h-4 w-4" />
|
||||
Als PDF speichern
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{format === "a4" && engste < LESBAR && (
|
||||
<p className="nur-bildschirm rounded border border-warning-text/30 bg-warning-bg px-3 py-2 text-sm text-warning-text">
|
||||
Ein Blatt musste auf {Math.round(engste * 100)} % verkleinert werden, damit es passt — auf A4 kaum noch
|
||||
lesbar. A3 wählen oder weniger Bereiche.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="nur-bildschirm max-w-prose text-xs leading-relaxed text-ink-muted">
|
||||
Im Druckdialog „Als PDF speichern“ wählen. Hintergrundgrafiken müssen dort eingeschaltet sein,
|
||||
sonst fehlen die farbigen Kopfzeilen — in Chrome unter „Weitere Einstellungen“.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col items-center gap-8">
|
||||
{seiten.map((seite, i) => (
|
||||
<Blatt key={seite.key} format={format}>
|
||||
<Kopf
|
||||
titel={seite.titel}
|
||||
unterzeile={seite.unterzeile}
|
||||
seite={i === 0 ? "Übersicht" : `Seite ${i + 1} von ${seiten.length}`}
|
||||
/>
|
||||
<Skaliert kennung={`${seite.key}|${format}|${tiefe}`} onFaktor={(f) => meldeMassstab(seite.key, f)}>
|
||||
<Chart seite={seite} />
|
||||
</Skaliert>
|
||||
</Blatt>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Blatt({ children, format }: { children: React.ReactNode; format: Format }) {
|
||||
const { blatt, satzBreite, satzHoehe } = FORMATE[format];
|
||||
return (
|
||||
<div className="blatt rounded border border-border bg-white shadow-[var(--shadow-card)]" style={{ width: blatt }}>
|
||||
<div className="satz flex flex-col" style={{ width: satzBreite, height: satzHoehe, margin: "12mm 14mm 14mm" }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Kopf({ titel, unterzeile, seite }: { titel: string; unterzeile: string; seite: string }) {
|
||||
return (
|
||||
<header className="mb-5 flex shrink-0 items-end justify-between border-b-2 border-brand-500 pb-2">
|
||||
<div>
|
||||
<h1 className="text-lg font-extrabold tracking-tight text-ink">{titel}</h1>
|
||||
<p className="mt-0.5 text-xs text-ink-muted">{unterzeile}</p>
|
||||
</div>
|
||||
<p className="whitespace-nowrap text-xs font-semibold text-ink-muted">{seite}</p>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verkleinert den Inhalt so weit, dass er in die Satzfläche passt.
|
||||
*
|
||||
* Ein Organigramm hat keine Grösse, die man vorher kennt — sie hängt daran,
|
||||
* wie viele Bereiche jemand angehakt hat und wie tief. Statt zu raten wird
|
||||
* gemessen: die natürliche Grösse steht im Layout, die verfügbare auch, und
|
||||
* `transform: scale` ändert nur die Darstellung, nicht das Layout. Damit ist
|
||||
* die Messung stabil und läuft nicht gegen sich selbst.
|
||||
*/
|
||||
function Skaliert({
|
||||
kennung,
|
||||
onFaktor,
|
||||
children,
|
||||
}: {
|
||||
kennung: string;
|
||||
onFaktor: (faktor: number) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const rahmen = useRef<HTMLDivElement>(null);
|
||||
const inhalt = useRef<HTMLDivElement>(null);
|
||||
const [faktor, setFaktor] = useState(1);
|
||||
const [versatz, setVersatz] = useState(0);
|
||||
|
||||
// Die Meldefunktion wird beim Aufruf frisch erzeugt und ist daher bei jedem
|
||||
// Rendern eine andere. Stünde sie in den Abhängigkeiten, liefe der Effekt
|
||||
// nach jedem Rendern erneut — samt Messung, die das nächste Rendern
|
||||
// auslöst. Über eine Referenz bleibt der aktuelle Stand erreichbar, ohne
|
||||
// dass der Effekt daran hängt.
|
||||
const melden = useRef(onFaktor);
|
||||
useEffect(() => {
|
||||
melden.current = onFaktor;
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const aussen = rahmen.current;
|
||||
const innen = inhalt.current;
|
||||
if (!aussen || !innen) return;
|
||||
|
||||
function messen() {
|
||||
if (!aussen || !innen) return;
|
||||
const breite = aussen.clientWidth;
|
||||
const hoehe = aussen.clientHeight;
|
||||
const noetigeBreite = innen.offsetWidth;
|
||||
const noetigeHoehe = innen.offsetHeight;
|
||||
if (!breite || !hoehe || !noetigeBreite || !noetigeHoehe) return;
|
||||
const f = Math.min(1, breite / noetigeBreite, hoehe / noetigeHoehe);
|
||||
setFaktor(f);
|
||||
// Waagrecht mittig: die Verkleinerung geht von links oben aus, sonst
|
||||
// klebt ein schmales Diagramm am linken Blattrand.
|
||||
setVersatz(Math.max(0, (breite - noetigeBreite * f) / 2));
|
||||
melden.current(f);
|
||||
}
|
||||
|
||||
messen();
|
||||
// Schriften kommen nach dem ersten Layout — bis dahin sind alle Kästen in
|
||||
// der Ersatzschrift gesetzt und damit anders breit.
|
||||
void document.fonts?.ready.then(messen);
|
||||
const beobachter = new ResizeObserver(messen);
|
||||
beobachter.observe(aussen);
|
||||
return () => beobachter.disconnect();
|
||||
}, [kennung]);
|
||||
|
||||
return (
|
||||
<div ref={rahmen} className="min-h-0 flex-1 overflow-hidden">
|
||||
<div
|
||||
ref={inhalt}
|
||||
style={{
|
||||
width: "max-content",
|
||||
transform: `scale(${faktor})`,
|
||||
transformOrigin: "top left",
|
||||
marginLeft: versatz,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Chart({ seite }: { seite: Seite }) {
|
||||
const kinder = seite.kinder ?? seite.wurzel.children;
|
||||
const zeigeKinder = seite.ebenen > 0 && kinder.length > 0;
|
||||
|
||||
return (
|
||||
// Feste Spaltenbreite: dadurch sitzt jeder Kasten mittig unter seiner
|
||||
// Verbindungslinie, und die Gesamtbreite ist die Zahl der Spalten mal
|
||||
// dieser Breite — berechenbar, statt vom längsten Namen abzuhängen.
|
||||
<div className="inline-block text-center" style={{ ["--spalte" as string]: "13rem" }}>
|
||||
<div className="mx-auto w-[15rem]">
|
||||
<Kasten unit={seite.wurzel} mitPersonen={seite.mitPersonen} />
|
||||
</div>
|
||||
{zeigeKinder && (
|
||||
<ul className="reihe">
|
||||
{kinder.map((k) => (
|
||||
<li key={k.id}>
|
||||
<Kasten unit={k} mitPersonen={seite.mitPersonen} />
|
||||
<Stapel units={k.children} ebenen={seite.ebenen - 1} mitPersonen={seite.mitPersonen} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stapel({ units, ebenen, mitPersonen }: { units: PrintUnit[]; ebenen: number; mitPersonen: boolean }) {
|
||||
if (ebenen <= 0 || units.length === 0) return null;
|
||||
return (
|
||||
<ul className="stapel">
|
||||
{units.map((u) => (
|
||||
<li key={u.id}>
|
||||
<Kasten unit={u} mitPersonen={mitPersonen} />
|
||||
<Stapel units={u.children} ebenen={ebenen - 1} mitPersonen={mitPersonen} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
const KOPFFARBE: Record<PrintUnit["unitType"], string> = {
|
||||
// Nach unten hin blasser: so bleibt die Rangfolge auch auf dem
|
||||
// Schwarzweissdrucker sichtbar, auf dem die meisten Ausdrucke landen.
|
||||
Gesellschaft: "bg-brand-500 text-white",
|
||||
Bereich: "bg-brand-100 text-ink",
|
||||
Abteilung: "bg-brand-50 text-ink",
|
||||
Team: "bg-surface text-ink",
|
||||
};
|
||||
|
||||
function Kasten({ unit, mitPersonen }: { unit: PrintUnit; mitPersonen: boolean }) {
|
||||
const offen = unit.vacancies.filter((v) => !v.isChief);
|
||||
// Lange Namenslisten in zwei Spalten: sonst wächst der Kasten in die Höhe,
|
||||
// und die Höhe ist es, die den Massstab drückt.
|
||||
const zweispaltig = mitPersonen && unit.members.length > 8;
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-hidden rounded border border-border text-left">
|
||||
<div className={`px-2 py-1 ${KOPFFARBE[unit.unitType]}`}>
|
||||
<p className="text-[11px] font-bold leading-tight">{unit.name}</p>
|
||||
<p
|
||||
className={`text-[9px] uppercase tracking-wide ${
|
||||
unit.unitType === "Gesellschaft" ? "text-white/75" : "text-ink-muted"
|
||||
}`}
|
||||
>
|
||||
{unit.unitType} · {unit.totalPeople}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="px-2 py-1">
|
||||
{unit.chief ? (
|
||||
<PersonZeile p={unit.chief} leitung />
|
||||
) : (
|
||||
<p className="text-[10px] italic text-ink-muted">Leitung unbesetzt</p>
|
||||
)}
|
||||
|
||||
{mitPersonen && unit.members.length > 0 && (
|
||||
<div className={`mt-1 border-t border-border-subtle pt-1 ${zweispaltig ? "columns-2 gap-2" : ""}`}>
|
||||
{unit.members.map((p) => (
|
||||
<div key={p.id} className="break-inside-avoid">
|
||||
<PersonZeile p={p} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mitPersonen &&
|
||||
offen.map((v) => (
|
||||
<p key={v.positionNumber} className="text-[10px] italic text-ink-muted">
|
||||
offen · {v.jobTitle}
|
||||
</p>
|
||||
))}
|
||||
|
||||
{!mitPersonen && unit.members.length > 0 && (
|
||||
<p className="text-[10px] text-ink-muted">
|
||||
{unit.members.length} {unit.members.length === 1 ? "Person" : "Personen"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonZeile({ p, leitung }: { p: PrintPerson; leitung?: boolean }) {
|
||||
return (
|
||||
<p className="text-[10px] leading-tight">
|
||||
<span className={leitung ? "font-bold text-ink" : "text-ink"}>{p.name}</span>
|
||||
{p.absent && <span className="text-ink-muted"> · abwesend</span>}
|
||||
<br />
|
||||
<span className="text-ink-muted">{p.jobTitle}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -1,404 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { RotateCcw, X } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { applyReorg, undoReorg, type ReorgMovePayload } from "@/actions/reorg";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Field, SelectField, TextField } from "@/components/ui/Field";
|
||||
import { Lookup } from "@/components/ui/Lookup";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import type { OrgDepartment, OrgDivision, OrgEmployee, OrgTeam, ReorgScenarioSummary } from "./types";
|
||||
|
||||
type ChangeKind = "emp" | "team" | "abt" | "dept";
|
||||
|
||||
type PendingMove = {
|
||||
id: string;
|
||||
kind: ChangeKind;
|
||||
label: string;
|
||||
employeeIds: string[];
|
||||
targetTeamId: string;
|
||||
targetTeamLabel: string;
|
||||
};
|
||||
|
||||
const KIND_LABELS: Record<ChangeKind, string> = {
|
||||
emp: "Mitarbeiter:in(nen)",
|
||||
team: "Ganzes Team",
|
||||
abt: "Ganze Abteilung",
|
||||
dept: "Ganzer Bereich",
|
||||
};
|
||||
|
||||
type ReorgWorkbenchProps = {
|
||||
employees: OrgEmployee[];
|
||||
divisions: OrgDivision[];
|
||||
departments: OrgDepartment[];
|
||||
teams: OrgTeam[];
|
||||
reorgScenarios: ReorgScenarioSummary[];
|
||||
};
|
||||
|
||||
export function ReorgWorkbench({ employees, divisions, departments, teams, reorgScenarios }: ReorgWorkbenchProps) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [effectiveDate, setEffectiveDate] = useState("");
|
||||
const [changeType, setChangeType] = useState<ChangeKind>("emp");
|
||||
const [selectedEmployees, setSelectedEmployees] = useState<OrgEmployee[]>([]);
|
||||
const [sourceTeamId, setSourceTeamId] = useState("");
|
||||
const [sourceDeptId, setSourceDeptId] = useState("");
|
||||
const [sourceDivisionId, setSourceDivisionId] = useState("");
|
||||
const [targetDivisionId, setTargetDivisionId] = useState("");
|
||||
const [targetTeamId, setTargetTeamId] = useState("");
|
||||
const [pendingMoves, setPendingMoves] = useState<PendingMove[]>([]);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [undoingId, setUndoingId] = useState<string | null>(null);
|
||||
|
||||
const divisionById = useMemo(() => new Map(divisions.map((d) => [d.id, d])), [divisions]);
|
||||
const departmentById = useMemo(() => new Map(departments.map((d) => [d.id, d])), [departments]);
|
||||
const teamById = useMemo(() => new Map(teams.map((t) => [t.id, t])), [teams]);
|
||||
const teamDivisionId = useMemo(() => {
|
||||
const deptDivision = new Map(departments.map((d) => [d.id, d.division_id]));
|
||||
const m = new Map<string, string>();
|
||||
for (const t of teams) m.set(t.id, deptDivision.get(t.department_id) ?? "");
|
||||
return m;
|
||||
}, [teams, departments]);
|
||||
const teamsInTargetDivision = useMemo(
|
||||
() => teams.filter((t) => teamDivisionId.get(t.id) === targetDivisionId),
|
||||
[teams, teamDivisionId, targetDivisionId]
|
||||
);
|
||||
|
||||
async function searchLocalEmployees(query: string): Promise<OrgEmployee[]> {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (q.length < 2) return [];
|
||||
return employees
|
||||
.filter(
|
||||
(e) =>
|
||||
!selectedEmployees.some((s) => s.id === e.id) &&
|
||||
(`${e.first_name} ${e.last_name}`.toLowerCase().includes(q) || e.job_title.toLowerCase().includes(q))
|
||||
)
|
||||
.slice(0, 20);
|
||||
}
|
||||
|
||||
function resolveEmployeeIds(): string[] {
|
||||
if (changeType === "emp") return selectedEmployees.map((e) => e.id);
|
||||
if (changeType === "team") return employees.filter((e) => e.team_id === sourceTeamId).map((e) => e.id);
|
||||
if (changeType === "abt") {
|
||||
const teamIds = new Set(teams.filter((t) => t.department_id === sourceDeptId).map((t) => t.id));
|
||||
return employees.filter((e) => e.team_id && teamIds.has(e.team_id)).map((e) => e.id);
|
||||
}
|
||||
return employees.filter((e) => e.division_id === sourceDivisionId).map((e) => e.id);
|
||||
}
|
||||
|
||||
function sourceLabel(): string {
|
||||
if (changeType === "emp") return `${selectedEmployees.length} Mitarbeiter:in(nen)`;
|
||||
if (changeType === "team") return `Team ${teamById.get(sourceTeamId)?.name ?? ""}`;
|
||||
if (changeType === "abt") return `Abteilung ${departmentById.get(sourceDeptId)?.name ?? ""}`;
|
||||
return `Bereich ${divisionById.get(sourceDivisionId)?.name ?? ""}`;
|
||||
}
|
||||
|
||||
function handleAddMove() {
|
||||
const employeeIds = resolveEmployeeIds();
|
||||
if (employeeIds.length === 0) {
|
||||
showToast("Keine Mitarbeiter:innen in der Auswahl gefunden.", "error");
|
||||
return;
|
||||
}
|
||||
if (!targetTeamId) {
|
||||
showToast("Bitte ein Ziel-Team wählen.", "error");
|
||||
return;
|
||||
}
|
||||
const targetTeam = teamById.get(targetTeamId);
|
||||
setPendingMoves((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
kind: changeType,
|
||||
label: sourceLabel(),
|
||||
employeeIds,
|
||||
targetTeamId,
|
||||
targetTeamLabel: targetTeam?.name ?? "",
|
||||
},
|
||||
]);
|
||||
setSelectedEmployees([]);
|
||||
setSourceTeamId("");
|
||||
setSourceDeptId("");
|
||||
setSourceDivisionId("");
|
||||
}
|
||||
|
||||
function removeMove(id: string) {
|
||||
setPendingMoves((prev) => prev.filter((m) => m.id !== id));
|
||||
}
|
||||
|
||||
const divisionBefore = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
for (const e of employees) m.set(e.division_id, (m.get(e.division_id) ?? 0) + 1);
|
||||
return m;
|
||||
}, [employees]);
|
||||
|
||||
const divisionDelta = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
const employeeById = new Map(employees.map((e) => [e.id, e]));
|
||||
for (const move of pendingMoves) {
|
||||
const targetDivId = teamDivisionId.get(move.targetTeamId);
|
||||
for (const empId of move.employeeIds) {
|
||||
const emp = employeeById.get(empId);
|
||||
if (!emp || !targetDivId || emp.division_id === targetDivId) continue;
|
||||
m.set(emp.division_id, (m.get(emp.division_id) ?? 0) - 1);
|
||||
m.set(targetDivId, (m.get(targetDivId) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}, [pendingMoves, employees, teamDivisionId]);
|
||||
|
||||
async function handleApply() {
|
||||
if (!name || !effectiveDate || pendingMoves.length === 0) {
|
||||
showToast("Bitte Name, Datum und mindestens eine Änderung angeben.", "error");
|
||||
return;
|
||||
}
|
||||
setApplying(true);
|
||||
const moves: ReorgMovePayload[] = pendingMoves.map((m) => ({
|
||||
kind: m.kind,
|
||||
label: m.label,
|
||||
employee_ids: m.employeeIds,
|
||||
target_team_id: m.targetTeamId,
|
||||
}));
|
||||
const result = await applyReorg({ name, effective_date: effectiveDate, moves });
|
||||
setApplying(false);
|
||||
if (result.success) {
|
||||
showToast("Reorganisation durchgeführt.");
|
||||
setPendingMoves([]);
|
||||
setName("");
|
||||
setEffectiveDate("");
|
||||
router.refresh();
|
||||
} else {
|
||||
showToast(result.error ?? "Fehler bei der Reorganisation.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUndo(scenarioId: string) {
|
||||
setUndoingId(scenarioId);
|
||||
const result = await undoReorg({ scenario_id: scenarioId });
|
||||
setUndoingId(null);
|
||||
if (result.success) {
|
||||
showToast("Reorganisation rückgängig gemacht.");
|
||||
router.refresh();
|
||||
} else {
|
||||
showToast(result.error ?? "Fehler beim Rückgängigmachen.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded border border-border bg-white p-4">
|
||||
<h2 className="mb-3 text-sm font-bold text-ink">Neue Reorganisation</h2>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<TextField label="Name der Reorganisation" required value={name} onChange={setName} />
|
||||
<TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
|
||||
</div>
|
||||
|
||||
<fieldset className="mt-4">
|
||||
<legend className="sr-only">Art der Änderung</legend>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(Object.keys(KIND_LABELS) as ChangeKind[]).map((kind) => (
|
||||
<button
|
||||
key={kind}
|
||||
type="button"
|
||||
aria-pressed={changeType === kind}
|
||||
onClick={() => setChangeType(kind)}
|
||||
className={`rounded px-3 py-1.5 text-sm font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
|
||||
changeType === kind ? "bg-brand-500 text-white" : "border border-border text-ink-body hover:bg-surface"
|
||||
}`}
|
||||
>
|
||||
{KIND_LABELS[kind]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
{changeType === "emp" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Field label="Quelle: Mitarbeiter:innen">
|
||||
{(p) => (
|
||||
<Lookup<OrgEmployee>
|
||||
{...p}
|
||||
placeholder="Mitarbeiter:in suchen…"
|
||||
onSearch={searchLocalEmployees}
|
||||
onSelect={(e) => setSelectedEmployees((prev) => [...prev, e])}
|
||||
renderResult={(e) => (
|
||||
<div>
|
||||
<div className="font-semibold text-ink">
|
||||
{e.first_name} {e.last_name}
|
||||
</div>
|
||||
<div className="text-xs text-ink-muted">{e.job_title}</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
{selectedEmployees.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selectedEmployees.map((e) => (
|
||||
<span key={e.id} className="flex items-center gap-1 rounded-full bg-brand-100 px-2.5 py-1 text-xs font-semibold text-brand-700">
|
||||
{e.first_name} {e.last_name}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${e.first_name} ${e.last_name} aus der Auswahl entfernen`}
|
||||
onClick={() => setSelectedEmployees((prev) => prev.filter((s) => s.id !== e.id))}
|
||||
className="rounded focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{changeType === "team" && (
|
||||
<SelectField
|
||||
label="Quelle: Team"
|
||||
value={sourceTeamId}
|
||||
onChange={setSourceTeamId}
|
||||
placeholder="Team wählen…"
|
||||
options={teams.map((t) => ({ value: t.id, label: t.name }))}
|
||||
/>
|
||||
)}
|
||||
{changeType === "abt" && (
|
||||
<SelectField
|
||||
label="Quelle: Abteilung"
|
||||
value={sourceDeptId}
|
||||
onChange={setSourceDeptId}
|
||||
placeholder="Abteilung wählen…"
|
||||
options={departments.map((d) => ({ value: d.id, label: d.name }))}
|
||||
/>
|
||||
)}
|
||||
{changeType === "dept" && (
|
||||
<SelectField
|
||||
label="Quelle: Bereich"
|
||||
value={sourceDivisionId}
|
||||
onChange={setSourceDivisionId}
|
||||
placeholder="Bereich wählen…"
|
||||
options={divisions.map((d) => ({ value: d.id, label: d.name }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<SelectField
|
||||
label="Ziel-Bereich"
|
||||
required
|
||||
value={targetDivisionId}
|
||||
onChange={(v) => {
|
||||
setTargetDivisionId(v);
|
||||
setTargetTeamId("");
|
||||
}}
|
||||
placeholder="Ziel-Bereich wählen…"
|
||||
options={divisions.map((d) => ({ value: d.id, label: d.name }))}
|
||||
/>
|
||||
<SelectField
|
||||
label="Ziel-Team"
|
||||
required
|
||||
value={targetTeamId}
|
||||
onChange={setTargetTeamId}
|
||||
placeholder="Ziel-Team wählen…"
|
||||
options={teamsInTargetDivision.map((t) => ({ value: t.id, label: t.name }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant="secondary" onClick={handleAddMove} className="mt-4">
|
||||
+ Zur Reorganisation hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{pendingMoves.length > 0 && (
|
||||
<div className="rounded border border-border bg-white p-4">
|
||||
<h2 className="mb-3 text-sm font-bold text-ink">Geplante Änderungen ({pendingMoves.length})</h2>
|
||||
<ul className="mb-4 flex flex-col divide-y divide-border">
|
||||
{pendingMoves.map((m) => (
|
||||
<li key={m.id} className="flex items-center justify-between py-2 text-sm">
|
||||
<div>
|
||||
<span className="mr-2 rounded-full bg-purple-bg px-2 py-0.5 text-xs font-semibold text-purple-text">{KIND_LABELS[m.kind]}</span>
|
||||
<span className="text-ink">
|
||||
{m.label} → {m.targetTeamLabel}
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-ink-muted">({m.employeeIds.length} Mitarbeiter:innen)</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="icon"
|
||||
onClick={() => removeMove(m.id)}
|
||||
aria-label={`${m.label} aus der Reorganisation entfernen`}
|
||||
className="hover:!text-danger-solid"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Auswirkung auf Headcount</h3>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-ink-muted">
|
||||
<th className="py-1 pr-3">Bereich</th>
|
||||
<th className="py-1 pr-3">Vorher</th>
|
||||
<th className="py-1 pr-3">Nachher</th>
|
||||
<th className="py-1 pr-3">Δ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from(divisionDelta.entries())
|
||||
.filter(([, delta]) => delta !== 0)
|
||||
.map(([divId, delta]) => {
|
||||
const before = divisionBefore.get(divId) ?? 0;
|
||||
return (
|
||||
<tr key={divId} className="border-t border-border">
|
||||
<td className="py-1.5 pr-3 text-ink">{divisionById.get(divId)?.name}</td>
|
||||
<td className="py-1.5 pr-3 text-ink-body">{before}</td>
|
||||
<td className="py-1.5 pr-3 text-ink-body">{before + delta}</td>
|
||||
<td className={`py-1.5 pr-3 font-semibold ${delta > 0 ? "text-success-text" : "text-danger-text"}`}>
|
||||
{delta > 0 ? `+${delta}` : delta}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button variant="secondary" onClick={() => setPendingMoves([])}>
|
||||
Verwerfen
|
||||
</Button>
|
||||
<Button onClick={handleApply} pending={applying}>
|
||||
Reorganisation durchführen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reorgScenarios.length > 0 && (
|
||||
<div className="rounded border border-border bg-white p-4">
|
||||
<h2 className="mb-3 text-sm font-bold text-ink">↩ Durchgeführte Reorganisationen – rückgängig machbar</h2>
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{reorgScenarios.map((s) => (
|
||||
<li key={s.id} className="flex items-center justify-between py-2 text-sm">
|
||||
<div>
|
||||
<span className="font-semibold text-ink">{s.name}</span>
|
||||
<span className="ml-2 text-xs text-ink-muted">
|
||||
wirksam ab {fmtDate(s.effective_date)} · durchgeführt am {fmtDate(s.applied_at)}
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={() => handleUndo(s.id)} pending={undoingId === s.id}>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
Rückgängig machen
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -2,51 +2,64 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { createPosition, searchSuperiors, type SuperiorSearchResult } from "@/actions/positions";
|
||||
import { createPosition } from "@/actions/positions";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Field, SelectField, TextField } from "@/components/ui/Field";
|
||||
import { Lookup } from "@/components/ui/Lookup";
|
||||
import { SelectField, TextField } from "@/components/ui/Field";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { todayIso } from "@/lib/format";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
type Team = Database["public"]["Tables"]["teams"]["Row"];
|
||||
export type UnitOption = { id: string; name: string; unit_type: string; depth: number; hasChief: boolean };
|
||||
|
||||
export function CreatePositionModal({ open, onClose, teams }: { open: boolean; onClose: () => void; teams: Team[] }) {
|
||||
// Eine Planstelle gehört zu genau einer Organisationseinheit — mehr braucht
|
||||
// es nicht. Vorher musste hier eine vorgesetzte Person gesucht werden; die
|
||||
// ergibt sich jetzt aus der Einheit, und die Frage kann gar nicht mehr falsch
|
||||
// beantwortet werden.
|
||||
export function CreatePositionModal({
|
||||
open,
|
||||
onClose,
|
||||
units,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
units: UnitOption[];
|
||||
}) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [title, setTitle] = useState("");
|
||||
const [isLead, setIsLead] = useState(false);
|
||||
const [superior, setSuperior] = useState<SuperiorSearchResult | null>(null);
|
||||
const [teamId, setTeamId] = useState("");
|
||||
const [jobTitle, setJobTitle] = useState("");
|
||||
const [orgUnitId, setOrgUnitId] = useState("");
|
||||
const [isChief, setIsChief] = useState(false);
|
||||
const [validFrom, setValidFrom] = useState(todayIso);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const unit = units.find((u) => u.id === orgUnitId);
|
||||
// Der Unique-Index lässt nur eine gültige Leitung je Einheit zu. Das hier
|
||||
// vorwegzunehmen erspart eine Fehlermeldung, die in der Oberfläche nichts
|
||||
// erklärt.
|
||||
const chiefTaken = Boolean(unit?.hasChief);
|
||||
|
||||
function reset() {
|
||||
setTitle("");
|
||||
setIsLead(false);
|
||||
setSuperior(null);
|
||||
setTeamId("");
|
||||
setJobTitle("");
|
||||
setOrgUnitId("");
|
||||
setIsChief(false);
|
||||
setValidFrom(todayIso());
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!title || !superior || !validFrom || (isLead && !teamId)) {
|
||||
if (!jobTitle || !orgUnitId || !validFrom) {
|
||||
showToast("Bitte alle Pflichtfelder ausfüllen.", "error");
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
const result = await createPosition({
|
||||
title,
|
||||
superior_employee_id: superior.id,
|
||||
is_lead: isLead,
|
||||
team_id: isLead ? teamId : undefined,
|
||||
org_unit_id: orgUnitId,
|
||||
job_title: jobTitle,
|
||||
is_chief: isChief && !chiefTaken,
|
||||
valid_from: validFrom,
|
||||
});
|
||||
setPending(false);
|
||||
if (result.success) {
|
||||
showToast("Position ausgeschrieben.");
|
||||
showToast("Planstelle angelegt.");
|
||||
router.refresh();
|
||||
onClose();
|
||||
reset();
|
||||
@@ -59,77 +72,46 @@ export function CreatePositionModal({ open, onClose, teams }: { open: boolean; o
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Position ausschreiben"
|
||||
title="Planstelle anlegen"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} pending={pending}>
|
||||
Ausschreiben
|
||||
Anlegen
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<TextField label="Titel" required value={title} onChange={setTitle} />
|
||||
<SelectField
|
||||
label="Organisationseinheit"
|
||||
required
|
||||
value={orgUnitId}
|
||||
onChange={(v) => {
|
||||
setOrgUnitId(v);
|
||||
setIsChief(false);
|
||||
}}
|
||||
placeholder="Bitte wählen…"
|
||||
options={units.map((u) => ({
|
||||
value: u.id,
|
||||
label: `${" ".repeat(u.depth * 3)}${u.name} (${u.unit_type})`,
|
||||
}))}
|
||||
/>
|
||||
<TextField
|
||||
label="Tätigkeit"
|
||||
required
|
||||
value={jobTitle}
|
||||
onChange={setJobTitle}
|
||||
hint="Bestehende Tätigkeiten werden wiederverwendet; ein neuer Name legt einen Katalogeintrag an."
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-sm text-ink-body">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isLead}
|
||||
onChange={(e) => {
|
||||
setIsLead(e.target.checked);
|
||||
setSuperior(null);
|
||||
}}
|
||||
/>
|
||||
Führungsposition (Teamleitung)
|
||||
<input type="checkbox" checked={isChief} disabled={!unit || chiefTaken} onChange={(e) => setIsChief(e.target.checked)} />
|
||||
Leitungsplanstelle für diese Einheit
|
||||
</label>
|
||||
{!superior ? (
|
||||
<Field label={isLead ? "Übergeordnete Bereichsleitung" : "Übergeordnete Teamleitung"} required>
|
||||
{(p) => (
|
||||
<Lookup<SuperiorSearchResult>
|
||||
{...p}
|
||||
placeholder="Name oder Titel…"
|
||||
onSearch={(q) => searchSuperiors(q, isLead)}
|
||||
onSelect={setSuperior}
|
||||
renderResult={(r) => (
|
||||
<div>
|
||||
<div className="font-semibold text-ink">
|
||||
{r.first_name} {r.last_name}
|
||||
</div>
|
||||
<div className="text-xs text-ink-muted">{r.job_title}</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
) : (
|
||||
<div>
|
||||
<p className="mb-1 block text-sm font-semibold text-ink">
|
||||
{isLead ? "Übergeordnete Bereichsleitung" : "Übergeordnete Teamleitung"}
|
||||
</p>
|
||||
<div className="flex items-center justify-between rounded border border-border bg-surface p-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-ink">
|
||||
{superior.first_name} {superior.last_name}
|
||||
</div>
|
||||
<div className="text-xs text-ink-muted">{superior.job_title}</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setSuperior(null)}>
|
||||
Ändern
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isLead && (
|
||||
<SelectField
|
||||
label="Zu leitendes Team"
|
||||
required
|
||||
value={teamId}
|
||||
onChange={setTeamId}
|
||||
placeholder="Bitte wählen…"
|
||||
options={teams.map((t) => ({ value: t.id, label: t.name }))}
|
||||
/>
|
||||
{chiefTaken && (
|
||||
<p className="-mt-2 text-xs text-ink-muted">Für {unit?.name} besteht bereits eine Leitungsplanstelle.</p>
|
||||
)}
|
||||
<TextField label="Gültig ab" required type="date" value={validFrom} onChange={setValidFrom} />
|
||||
</div>
|
||||
|
||||
194
components/positions/EditPositionModal.tsx
Normal file
194
components/positions/EditPositionModal.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { setPositionCostCenter, updatePosition } from "@/actions/positions";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { SelectField, TextField } from "@/components/ui/Field";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import type { UnitOption } from "./CreatePositionModal";
|
||||
|
||||
// Ändern statt löschen und neu anlegen.
|
||||
//
|
||||
// Die Planstellennummer steht in Ausschreibungen, Budgets und
|
||||
// Protokolleinträgen. Wer wegen eines Tippfehlers in der Tätigkeit eine neue
|
||||
// Nummer vergibt, macht die alten Bezüge wertlos — deshalb gibt es diesen
|
||||
// Dialog.
|
||||
|
||||
/**
|
||||
* Erwartet eine Planstelle, keine „vielleicht keine".
|
||||
*
|
||||
* Die aufrufende Seite hängt einen `key` mit der Kennung daran und rendert
|
||||
* ihn nur, solange etwas bearbeitet wird. Dadurch baut React den Dialog je
|
||||
* Planstelle neu auf, und die Felder lassen sich direkt aus den Eigenschaften
|
||||
* vorbelegen — statt sie in einem Effekt nachzuziehen, der beim zweiten
|
||||
* Öffnen kurz die Werte der vorigen Stelle zeigt.
|
||||
*/
|
||||
export function EditPositionModal({
|
||||
position,
|
||||
units,
|
||||
kostenstellen,
|
||||
heute,
|
||||
onClose,
|
||||
}: {
|
||||
position: OpenPositionResolved;
|
||||
units: UnitOption[];
|
||||
kostenstellen: { id: string; code: string; name: string }[];
|
||||
/** Vom Server, nicht aus new Date(): sonst rechnet der Browser mit seiner Zone. */
|
||||
heute: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [jobTitle, setJobTitle] = useState(position.title);
|
||||
const [orgUnitId, setOrgUnitId] = useState(position.org_unit_id);
|
||||
const [isChief, setIsChief] = useState(position.is_chief);
|
||||
const [validFrom, setValidFrom] = useState(position.valid_from);
|
||||
const [validTo, setValidTo] = useState(position.valid_to ?? "");
|
||||
// Die Kontierung ist ein eigener Vorgang mit eigenem Stichtag, kein Feld
|
||||
// unter anderen: die laufende wird beendet, die neue beginnt. Deshalb steht
|
||||
// hier ein Datum daneben und kein stilles Überschreiben.
|
||||
const [kostenstelleId, setKostenstelleId] = useState(
|
||||
kostenstellen.find((k) => k.code === position.kostenstelle?.code)?.id ?? ""
|
||||
);
|
||||
const [kostenstelleAb, setKostenstelleAb] = useState(heute);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const kostenstelleGeaendert =
|
||||
kostenstelleId !== "" && kostenstellen.find((k) => k.id === kostenstelleId)?.code !== position.kostenstelle?.code;
|
||||
|
||||
const unit = units.find((u) => u.id === orgUnitId);
|
||||
// Die eigene Leitung zählt nicht als Hindernis für sich selbst.
|
||||
const chiefTaken = Boolean(unit?.hasChief) && !(position.is_chief && orgUnitId === position.org_unit_id);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!jobTitle.trim() || !orgUnitId || !validFrom) {
|
||||
showToast("Tätigkeit, Einheit und Gültigkeitsbeginn sind Pflicht.", "error");
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
const result = await updatePosition({
|
||||
position_id: position.id,
|
||||
org_unit_id: orgUnitId,
|
||||
job_title: jobTitle.trim(),
|
||||
is_chief: isChief && !chiefTaken,
|
||||
valid_from: validFrom,
|
||||
valid_to: validTo || null,
|
||||
});
|
||||
if (result.success && kostenstelleGeaendert) {
|
||||
// Nacheinander, nicht gemeinsam: die Umkontierung ist ein eigener
|
||||
// Vorgang mit eigenem Stichtag und eigenem Protokolleintrag. Schlägt sie
|
||||
// fehl, steht die übrige Änderung trotzdem — und die Meldung sagt, was
|
||||
// offen blieb, statt beides stillschweigend zu verwerfen.
|
||||
const kosten = await setPositionCostCenter({
|
||||
position_id: position.id,
|
||||
cost_center_id: kostenstelleId,
|
||||
valid_from: kostenstelleAb,
|
||||
});
|
||||
if (!kosten.success) {
|
||||
setPending(false);
|
||||
showToast(kosten.error ?? "Die Kostenstelle konnte nicht geändert werden.", "error");
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
}
|
||||
setPending(false);
|
||||
if (result.success) {
|
||||
showToast(kostenstelleGeaendert ? "Planstelle geändert und umkontiert." : "Planstelle geändert.");
|
||||
router.refresh();
|
||||
onClose();
|
||||
} else {
|
||||
// Die Meldungen der Datenbankfunktion sind für die Oberfläche
|
||||
// geschrieben („Diese Planstelle ist vergeben …“) und werden gezeigt.
|
||||
showToast(result.error ?? "Die Änderung war nicht möglich.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
title={`Planstelle ${position.position_number}`}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} pending={pending}>
|
||||
Speichern
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<TextField label="Tätigkeit" required value={jobTitle} onChange={setJobTitle} />
|
||||
|
||||
<SelectField
|
||||
label="Organisationseinheit"
|
||||
required
|
||||
value={orgUnitId}
|
||||
onChange={setOrgUnitId}
|
||||
placeholder="Bitte wählen…"
|
||||
options={units.map((u) => ({
|
||||
value: u.id,
|
||||
label: `${" ".repeat(u.depth)}${u.name} (${u.unit_type})`,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<TextField label="Gültig ab" required type="date" value={validFrom} onChange={setValidFrom} />
|
||||
<TextField label="Gültig bis" type="date" value={validTo} onChange={setValidTo} />
|
||||
</div>
|
||||
|
||||
<SelectField
|
||||
label="Kostenstelle"
|
||||
value={kostenstelleId}
|
||||
onChange={setKostenstelleId}
|
||||
placeholder="Keine"
|
||||
options={kostenstellen.map((k) => ({ value: k.id, label: `${k.code} · ${k.name}` }))}
|
||||
/>
|
||||
{kostenstelleGeaendert && (
|
||||
<div className="rounded border border-border bg-surface px-3 py-2">
|
||||
<TextField
|
||||
label="Umkontieren ab"
|
||||
required
|
||||
type="date"
|
||||
value={kostenstelleAb}
|
||||
onChange={setKostenstelleAb}
|
||||
min={position.valid_from}
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-ink-muted">
|
||||
Die bisherige Kontierung endet an diesem Tag, die neue beginnt. Was vorher gebucht wurde, bleibt, wo es
|
||||
gebucht wurde.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="flex items-start gap-2 text-sm text-ink-body">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 h-4 w-4 rounded border-border text-brand-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
checked={isChief && !chiefTaken}
|
||||
disabled={chiefTaken}
|
||||
onChange={(e) => setIsChief(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
Leitungsplanstelle
|
||||
{chiefTaken && (
|
||||
<span className="block text-xs text-ink-muted">
|
||||
Für {unit?.name} besteht bereits eine Leitungsplanstelle.
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<p className="rounded-md bg-surface px-3 py-2 text-xs leading-relaxed text-ink-muted">
|
||||
Die Planstellennummer bleibt. Ist die Stelle vergeben, lassen sich Einheit und Gültigkeitsende nicht ändern —
|
||||
ein Wechsel der Einheit ist eine Versetzung und gehört zur Person, nicht zur Stelle.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -8,77 +8,137 @@ import { Button } from "@/components/ui/Button";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { fmtDate, todayIso } from "@/lib/format";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import { CreatePositionModal } from "./CreatePositionModal";
|
||||
import { CreatePositionModal, type UnitOption } from "./CreatePositionModal";
|
||||
import { EditPositionModal } from "./EditPositionModal";
|
||||
|
||||
type OpenPositionWithDays = OpenPositionResolved & { daysOpen: number };
|
||||
|
||||
type PositionsPageClientProps = {
|
||||
openPositions: OpenPositionWithDays[];
|
||||
teams: { id: string; org_number: string; name: string; department_id: string }[];
|
||||
units: UnitOption[];
|
||||
kostenstellen: { id: string; code: string; name: string }[];
|
||||
};
|
||||
|
||||
export function PositionsPageClient({ openPositions, teams }: PositionsPageClientProps) {
|
||||
export function PositionsPageClient({ openPositions, units, kostenstellen }: PositionsPageClientProps) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<OpenPositionWithDays | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const today = todayIso();
|
||||
|
||||
// Getrennt, weil es zwei verschiedene Aussagen sind: „hier fehlt jemand"
|
||||
// und „das entsteht erst". In einer Liste vermischt liest sich eine
|
||||
// Planstelle zum 01.10. wie eine Vakanz, um die sich niemand kümmert.
|
||||
const offen = openPositions.filter((p) => !p.future);
|
||||
const kuenftig = openPositions.filter((p) => p.future);
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
setDeletingId(id);
|
||||
const result = await deletePosition(id);
|
||||
setDeletingId(null);
|
||||
if (result.success) {
|
||||
showToast("Position gelöscht.");
|
||||
showToast("Planstelle entfernt.");
|
||||
router.refresh();
|
||||
} else {
|
||||
showToast(result.error ?? "Fehler beim Löschen.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function Karte({ p }: { p: OpenPositionWithDays }) {
|
||||
return (
|
||||
<div className="relative flex flex-col rounded border border-border transition-colors focus-within:border-brand-500 hover:border-brand-500">
|
||||
{/* Die ganze Karte öffnet den Änderungsdialog; der Papierkorb liegt
|
||||
absolut darüber. Ein Knopf im Knopf wäre ungültiges HTML und mit
|
||||
der Tastatur nicht erreichbar. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(p)}
|
||||
aria-label={`Planstelle ${p.position_number} (${p.title}) ändern`}
|
||||
className="flex flex-col items-start rounded p-3 pr-10 text-left
|
||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
<span className="text-sm font-semibold text-ink">{p.title}</span>
|
||||
<span className="text-xs text-ink-muted">
|
||||
{p.position_number} · {p.orgLabel}
|
||||
</span>
|
||||
<span className="mt-1 text-xs text-ink-muted">
|
||||
{p.is_chief ? "Leitungsplanstelle · " : ""}
|
||||
{p.future ? `gültig ab ${fmtDate(p.valid_from)}` : `seit ${p.daysOpen} Tagen unbesetzt`}
|
||||
</span>
|
||||
{p.valid_to && <span className="text-xs font-semibold text-warning-text">endet am {fmtDate(p.valid_to)}</span>}
|
||||
{p.managerName && <span className="text-xs text-ink-muted">berichtet an {p.managerName}</span>}
|
||||
{/* Was die Stelle kostet und wen es trifft — die Frage, die bei einer
|
||||
Vakanz zuerst kommt. */}
|
||||
{p.kostenstelle && (
|
||||
<span className="text-xs text-ink-muted">
|
||||
Kostenstelle <span className="font-semibold tabular-nums text-ink-body">{p.kostenstelle.code}</span> ·{" "}
|
||||
{p.kostenstelle.name}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<Button
|
||||
variant="icon"
|
||||
onClick={() => handleDelete(p.id)}
|
||||
pending={deletingId === p.id}
|
||||
aria-label={`Planstelle ${p.position_number} (${p.title}) entfernen`}
|
||||
className="absolute right-2 top-2 hover:!text-danger-solid"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="rounded border border-border bg-white p-4">
|
||||
<section className="rounded border border-border bg-white p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-sm font-bold text-ink">Offene Positionen ({openPositions.length})</h2>
|
||||
<h2 className="text-sm font-bold text-ink">Unbesetzte Planstellen ({offen.length})</h2>
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Position ausschreiben
|
||||
Planstelle anlegen
|
||||
</Button>
|
||||
</div>
|
||||
{openPositions.length === 0 ? (
|
||||
<p className="text-sm text-ink-muted">Derzeit keine offenen Positionen.</p>
|
||||
{offen.length === 0 ? (
|
||||
<p className="text-sm text-ink-muted">Derzeit ist jede geltende Planstelle besetzt oder vergeben.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{openPositions.map((p) => {
|
||||
const notYetValid = p.valid_from > today;
|
||||
return (
|
||||
<div key={p.id} className="flex flex-col rounded border border-border p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="text-sm font-semibold text-ink">{p.title}</div>
|
||||
<Button
|
||||
variant="icon"
|
||||
onClick={() => handleDelete(p.id)}
|
||||
pending={deletingId === p.id}
|
||||
aria-label={`Position ${p.title} löschen`}
|
||||
className="-mr-1 -mt-1 hover:!text-danger-solid"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-ink-muted">
|
||||
{p.position_number} · {p.orgLabel}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-ink-muted">seit {p.daysOpen} Tagen offen</div>
|
||||
{notYetValid && <div className="mt-1 text-xs font-semibold text-warning-text">Gültig ab {fmtDate(p.valid_from)}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{offen.map((p) => (
|
||||
<Karte key={p.id} p={p} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<CreatePositionModal open={createOpen} onClose={() => setCreateOpen(false)} teams={teams} />
|
||||
{kuenftig.length > 0 && (
|
||||
<section className="rounded border border-border bg-white p-4">
|
||||
<h2 className="text-sm font-bold text-ink">Künftige Planstellen ({kuenftig.length})</h2>
|
||||
<p className="mb-3 mt-1 text-xs text-ink-muted">
|
||||
Beschlossen, aber noch nicht gültig. Sie zählen nicht als Vakanz und lassen sich bis zum Beginn ändern.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{kuenftig.map((p) => (
|
||||
<Karte key={p.id} p={p} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<CreatePositionModal open={createOpen} onClose={() => setCreateOpen(false)} units={units} />
|
||||
{/* `key` sorgt dafür, dass React je Planstelle einen frischen Dialog
|
||||
baut — sonst blieben beim zweiten Öffnen die Werte des ersten
|
||||
stehen. */}
|
||||
{editing && (
|
||||
<EditPositionModal
|
||||
key={editing.id}
|
||||
position={editing}
|
||||
units={units}
|
||||
kostenstellen={kostenstellen}
|
||||
heute={todayIso()}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { FileSpreadsheet, FileText, Save, Trash2 } from "lucide-react";
|
||||
import { ChevronDown, FileSpreadsheet, FileText, Save, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
@@ -10,6 +10,14 @@ import { CONTROL_CLASS, SelectField, TextField } from "@/components/ui/Field";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import {
|
||||
anzahlKriterien,
|
||||
AUSWAHL_KRITERIEN,
|
||||
JANEIN_KRITERIEN,
|
||||
kriterienParameter,
|
||||
ZEITRAUM_KRITERIEN,
|
||||
type Criteria,
|
||||
} from "@/lib/report-criteria";
|
||||
import {
|
||||
AVERAGE_MEASURES,
|
||||
EVENT_DATE_OPEN,
|
||||
@@ -27,6 +35,7 @@ import {
|
||||
type GroupDimension,
|
||||
type Measure,
|
||||
type ReportRow,
|
||||
type UnitOption,
|
||||
} from "@/lib/reports";
|
||||
import type { HistoryEventType } from "@/lib/supabase/types";
|
||||
|
||||
@@ -40,7 +49,7 @@ type CommonProps = {
|
||||
rows: ReportRow[];
|
||||
total: number;
|
||||
recordCount: number;
|
||||
divisions: OrgOption[];
|
||||
units: UnitOption[];
|
||||
locations: OrgOption[];
|
||||
savedReports: SavedReport[];
|
||||
};
|
||||
@@ -51,7 +60,8 @@ type SnapshotProps = CommonProps & {
|
||||
group: GroupDimension;
|
||||
split: GroupDimension | "";
|
||||
asOf: string;
|
||||
filters: { division: string; location: string; status: string; employment: string };
|
||||
filters: { division: string; location: string; status: string };
|
||||
criteria: Criteria;
|
||||
};
|
||||
|
||||
type EventsProps = CommonProps & {
|
||||
@@ -64,6 +74,144 @@ type EventsProps = CommonProps & {
|
||||
|
||||
type ReportsPageClientProps = SnapshotProps | EventsProps;
|
||||
|
||||
/**
|
||||
* Alle übrigen Auswahlkriterien, eingeklappt.
|
||||
*
|
||||
* Aufgeklappt wäre die Leiste dreimal so lang wie der Bericht daneben, und
|
||||
* die vier Filter, die fast immer gemeint sind, würden darin untergehen.
|
||||
* Eingeklappt steht nur die Zahl der gesetzten Kriterien da — und die ist
|
||||
* das, was man wissen muss: ob gerade etwas einschränkt, das man vergessen
|
||||
* hat.
|
||||
*/
|
||||
function KriterienBlock({
|
||||
criteria,
|
||||
onChange,
|
||||
onReset,
|
||||
}: {
|
||||
criteria: Criteria;
|
||||
onChange: (patch: Record<string, string | undefined>) => void;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
const [offen, setOffen] = useState(false);
|
||||
const anzahl = anzahlKriterien(criteria);
|
||||
|
||||
return (
|
||||
<div className="mt-2 border-t border-border-subtle pt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOffen(!offen)}
|
||||
aria-expanded={offen}
|
||||
className="flex items-center gap-1 rounded text-xs font-semibold text-brand-700 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${offen ? "rotate-180" : ""}`} />
|
||||
Weitere Kriterien
|
||||
{anzahl > 0 && <span className="rounded-full bg-brand-500 px-1.5 text-[10px] text-white">{anzahl}</span>}
|
||||
</button>
|
||||
{anzahl > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="rounded text-xs text-ink-muted hover:text-ink hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{offen && (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{AUSWAHL_KRITERIEN.map((k) => (
|
||||
<select
|
||||
key={k.key}
|
||||
aria-label={`Nach ${k.label} filtern`}
|
||||
value={criteria.auswahl[k.key] ?? ""}
|
||||
onChange={(e) => onChange({ [k.key]: e.target.value || undefined })}
|
||||
className={CONTROL_CLASS}
|
||||
>
|
||||
<option value="">{k.alle}</option>
|
||||
{k.optionen.map((o) => (
|
||||
<option key={o.wert} value={o.wert}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
))}
|
||||
|
||||
<fieldset className="rounded border border-border px-3 py-2">
|
||||
<legend className="mb-1.5 text-xs font-semibold text-ink-muted">Merkmale</legend>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{JANEIN_KRITERIEN.map((k) => (
|
||||
<div key={k.key} className="flex items-center justify-between gap-2 text-sm text-ink-body">
|
||||
<span>{k.label}</span>
|
||||
<select
|
||||
aria-label={`${k.label}: ja oder nein`}
|
||||
value={criteria.jaNein[k.key] ?? ""}
|
||||
onChange={(e) => onChange({ [k.key]: e.target.value || undefined })}
|
||||
className="rounded border border-border px-2 py-1 text-xs focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
|
||||
>
|
||||
<option value="">egal</option>
|
||||
<option value="ja">ja</option>
|
||||
<option value="nein">nein</option>
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{ZEITRAUM_KRITERIEN.map((k) => (
|
||||
<fieldset key={k.key} className="rounded border border-border px-3 py-2">
|
||||
<legend className="mb-1 text-xs font-semibold text-ink-muted">{k.label}</legend>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<TextField
|
||||
label="Von"
|
||||
dense
|
||||
type="date"
|
||||
value={criteria.zeitraum[k.key]?.von ?? ""}
|
||||
onChange={(v) => onChange({ [`${k.key}From`]: v || undefined })}
|
||||
/>
|
||||
<TextField
|
||||
label="Bis"
|
||||
dense
|
||||
type="date"
|
||||
value={criteria.zeitraum[k.key]?.bis ?? ""}
|
||||
onChange={(v) => onChange({ [`${k.key}To`]: v || undefined })}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
))}
|
||||
|
||||
<fieldset className="rounded border border-border px-3 py-2">
|
||||
<legend className="mb-1 text-xs font-semibold text-ink-muted">Wochenstunden</legend>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<TextField
|
||||
label="Ab"
|
||||
dense
|
||||
type="number"
|
||||
step="0.5"
|
||||
value={criteria.stundenVon?.toString() ?? ""}
|
||||
onChange={(v) => onChange({ hoursFrom: v || undefined })}
|
||||
/>
|
||||
<TextField
|
||||
label="Bis"
|
||||
dense
|
||||
type="number"
|
||||
step="0.5"
|
||||
value={criteria.stundenBis?.toString() ?? ""}
|
||||
onChange={(v) => onChange({ hoursTo: v || undefined })}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<p className="text-xs text-ink-muted">
|
||||
Alle Kriterien gelten für den Bericht <em>und</em> für beide Exporte.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatValue(measure: Measure, value: number): string {
|
||||
if (measure === "headcount") return String(Math.round(value));
|
||||
if (measure === "fte") return value.toFixed(1);
|
||||
@@ -73,7 +221,7 @@ function formatValue(measure: Measure, value: number): string {
|
||||
}
|
||||
|
||||
export function ReportsPageClient(props: ReportsPageClientProps) {
|
||||
const { mode, rows, total, recordCount, divisions, locations, savedReports } = props;
|
||||
const { mode, rows, total, recordCount, units, locations, savedReports } = props;
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -103,6 +251,12 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
|
||||
updateParams({ status: next.length > 0 ? next.join(",") : undefined });
|
||||
}
|
||||
|
||||
function resetKriterien() {
|
||||
const patch: Record<string, undefined> = {};
|
||||
for (const p of kriterienParameter()) patch[p] = undefined;
|
||||
updateParams(patch);
|
||||
}
|
||||
|
||||
function applyPreset(preset: { group: string; split?: string; eventType?: string; measure?: string }) {
|
||||
const sp = new URLSearchParams({ mode });
|
||||
if (preset.measure) sp.set("measure", preset.measure);
|
||||
@@ -126,10 +280,13 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
|
||||
showToast("Bitte einen Namen angeben.", "error");
|
||||
return;
|
||||
}
|
||||
const config =
|
||||
mode === "snapshot"
|
||||
? { mode, measure: props.measure, group: props.group, split: props.split, asOf: props.asOf, ...props.filters }
|
||||
: { mode, group: props.eventGroup, split: props.eventSplit, eventType: props.eventType, ...props.eventFilters };
|
||||
// Auch hier die ganze Adresszeile: ein gespeicherter Bericht, der die
|
||||
// Hälfte der Kriterien vergisst, führt beim nächsten Öffnen still zu
|
||||
// anderen Zahlen.
|
||||
const config: Record<string, string> = Object.fromEntries(searchParams.entries());
|
||||
config.mode = mode;
|
||||
config.group = mode === "snapshot" ? props.group : props.eventGroup;
|
||||
if (mode === "snapshot") config.measure = props.measure;
|
||||
setSavingReport(true);
|
||||
const result = await saveReport({ name: newReportName.trim(), config });
|
||||
setSavingReport(false);
|
||||
@@ -153,41 +310,30 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Beide Ausgänge übernehmen die Adresszeile unverändert, statt die
|
||||
// Parameter einzeln aufzuzählen. Genau diese Aufzählung war der Grund,
|
||||
// warum ein neues Kriterium im Bericht wirkte und im Export fehlte — man
|
||||
// musste daran denken, sie an drei Stellen zu ergänzen. Jetzt sieht der
|
||||
// Export das, was der Bericht gesehen hat, weil es dieselbe Auswahl ist.
|
||||
function exportHref(ziel: string, format: "csv" | "xlsx"): string {
|
||||
const sp = new URLSearchParams(searchParams.toString());
|
||||
sp.set("format", format);
|
||||
return `${ziel}?${sp.toString()}`;
|
||||
}
|
||||
|
||||
function reportExportHref(format: "csv" | "xlsx"): string {
|
||||
const sp = new URLSearchParams();
|
||||
const sp = new URLSearchParams(searchParams.toString());
|
||||
sp.set("format", format);
|
||||
sp.set("mode", mode);
|
||||
if (mode === "snapshot") {
|
||||
sp.set("measure", props.measure);
|
||||
sp.set("group", props.group);
|
||||
if (props.split) sp.set("split", props.split);
|
||||
if (props.asOf) sp.set("asOf", props.asOf);
|
||||
for (const [k, v] of Object.entries(props.filters)) if (v) sp.set(k, v);
|
||||
} else {
|
||||
sp.set("group", props.eventGroup);
|
||||
if (props.eventSplit) sp.set("split", props.eventSplit);
|
||||
if (props.eventType) sp.set("eventType", props.eventType);
|
||||
for (const [k, v] of Object.entries(props.eventFilters)) if (v) sp.set(k, v);
|
||||
}
|
||||
// Die Vorgaben stehen nicht zwingend in der Adresszeile; die Route
|
||||
// braucht sie aber, um dieselbe Auswertung zu bauen.
|
||||
sp.set("group", mode === "snapshot" ? props.group : props.eventGroup);
|
||||
if (mode === "snapshot") sp.set("measure", props.measure);
|
||||
return `/api/export/report?${sp.toString()}`;
|
||||
}
|
||||
|
||||
function fullExportHref(format: "csv" | "xlsx"): string {
|
||||
if (mode === "snapshot") {
|
||||
const sp = new URLSearchParams();
|
||||
sp.set("format", format);
|
||||
if (props.asOf) sp.set("asOf", props.asOf);
|
||||
if (props.filters.division) sp.set("division", props.filters.division);
|
||||
if (props.filters.location) sp.set("location", props.filters.location);
|
||||
if (props.filters.status) sp.set("status", props.filters.status);
|
||||
if (props.filters.employment) sp.set("employment", props.filters.employment);
|
||||
return `/api/export/employees?${sp.toString()}`;
|
||||
}
|
||||
const sp = new URLSearchParams();
|
||||
sp.set("format", format);
|
||||
if (props.eventType) sp.set("eventType", props.eventType);
|
||||
for (const [k, v] of Object.entries(props.eventFilters)) if (v) sp.set(k, v);
|
||||
return `/api/export/events?${sp.toString()}`;
|
||||
return exportHref(mode === "snapshot" ? "/api/export/employees" : "/api/export/events", format);
|
||||
}
|
||||
|
||||
const isAverage = mode === "snapshot" && AVERAGE_MEASURES.includes(props.measure);
|
||||
@@ -367,15 +513,19 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
|
||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Filter</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<select
|
||||
aria-label="Nach Bereich filtern"
|
||||
aria-label="Nach Organisationseinheit filtern"
|
||||
value={mode === "snapshot" ? props.filters.division : props.eventFilters.division}
|
||||
onChange={(e) => updateParams({ division: e.target.value })}
|
||||
className={CONTROL_CLASS}
|
||||
>
|
||||
<option value="">Alle Bereiche</option>
|
||||
{divisions.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
<option value="">Alle Organisationseinheiten</option>
|
||||
{units.map((u) => (
|
||||
// Einrückung mit geschützten Leerzeichen: ein <optgroup> je
|
||||
// Ebene ginge nicht, weil Einheiten dort auswählbar bleiben
|
||||
// müssen — eine Abteilung ist selbst ein Filterwert.
|
||||
<option key={u.id} value={u.id}>
|
||||
{" ".repeat(u.depth)}
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -410,19 +560,13 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<select
|
||||
aria-label="Nach Beschäftigungsart filtern"
|
||||
value={props.filters.employment}
|
||||
onChange={(e) => updateParams({ employment: e.target.value })}
|
||||
className={CONTROL_CLASS}
|
||||
>
|
||||
<option value="">Alle Beschäftigungsarten</option>
|
||||
<option value="Vollzeit">Vollzeit</option>
|
||||
<option value="Teilzeit">Teilzeit</option>
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mode === "snapshot" && (
|
||||
<KriterienBlock criteria={props.criteria} onChange={updateParams} onReset={resetKriterien} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-border bg-white p-4">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
42
components/ui/AenderungsTabelle.tsx
Normal file
42
components/ui/AenderungsTabelle.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { AuditChange } from "@/lib/supabase/types";
|
||||
|
||||
// Was sich geändert hat, feldweise — im Protokoll und in der Historie einer
|
||||
// Person dieselbe Darstellung. Zwei Ansichten derselben Sache verschieden zu
|
||||
// setzen, hiesse dass jemand sie zweimal lesen lernen muss.
|
||||
|
||||
/** Leerer Wert heisst „war nicht gesetzt“ — und das ist eine Aussage. */
|
||||
function Wert({ text, art }: { text: string | null; art: "vorher" | "nachher" }) {
|
||||
if (text === null || text === "") {
|
||||
return <span className="text-ink-muted italic">leer</span>;
|
||||
}
|
||||
return <span className={art === "vorher" ? "text-ink-muted line-through decoration-ink-muted/40" : "text-ink"}>{text}</span>;
|
||||
}
|
||||
|
||||
export function AenderungsTabelle({ changes }: { changes: AuditChange[] }) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-[11px] font-bold uppercase tracking-wider text-ink-muted">
|
||||
<th className="py-2 pr-4">Feld</th>
|
||||
<th className="py-2 pr-4">Vorher</th>
|
||||
<th className="py-2">Nachher</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{changes.map((c, i) => (
|
||||
<tr key={`${c.feld}-${i}`} className="border-b border-border-subtle align-top last:border-0">
|
||||
<td className="py-2 pr-4 font-semibold text-ink-body">{c.feld}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<Wert text={c.vorher} art="vorher" />
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<Wert text={c.nachher} art="nachher" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,18 @@ function focusableWithin(container: HTMLElement): HTMLElement[] {
|
||||
export function useDialogFocus(open: boolean, onClose: () => void, containerRef: RefObject<HTMLElement | null>) {
|
||||
const restoreToRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// onClose wird von den meisten Aufrufern bei jedem Rendern neu erzeugt.
|
||||
// Stünde es in den Abhängigkeiten, liefe dieser Effekt nach jedem Rendern
|
||||
// erneut — und sein Aufräumen holt den Fokus dorthin zurück, wo er vor dem
|
||||
// Öffnen war. In einem Dialog mit Eingabefeldern hiesse das: nach dem
|
||||
// ersten Zeichen springt der Fokus auf den Knopf, der ihn geöffnet hat, und
|
||||
// der Rest der Eingabe geht ins Leere. Über eine Referenz bleibt der
|
||||
// aktuelle Rückruf erreichbar, ohne dass der Effekt daran hängt.
|
||||
const schliessen = useRef(onClose);
|
||||
useEffect(() => {
|
||||
schliessen.current = onClose;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const container = containerRef.current;
|
||||
@@ -39,7 +51,7 @@ export function useDialogFocus(open: boolean, onClose: () => void, containerRef:
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
schliessen.current();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab" || !container) return;
|
||||
@@ -74,5 +86,5 @@ export function useDialogFocus(open: boolean, onClose: () => void, containerRef:
|
||||
// there instead of restarting at the top of the page.
|
||||
restoreToRef.current?.focus();
|
||||
};
|
||||
}, [open, onClose, containerRef]);
|
||||
}, [open, containerRef]);
|
||||
}
|
||||
|
||||
45
deploy/Caddyfile
Normal file
45
deploy/Caddyfile
Normal file
@@ -0,0 +1,45 @@
|
||||
# Reverse Proxy für den Betrieb im Firmennetz.
|
||||
#
|
||||
# Der Name ist öffentlich (hr.elycon.solutions), die Adresse dahinter privat
|
||||
# (10.x). Das ist erlaubt und der übliche Weg: der DNS-Eintrag ist von aussen
|
||||
# auflösbar, der Server nicht erreichbar.
|
||||
#
|
||||
# Warum das der Aufwand wert ist: Entra ID akzeptiert `http` nur für
|
||||
# localhost. Ohne HTTPS gibt es keine Anmeldung — und ein selbst signiertes
|
||||
# Zertifikat müsste auf jedem Arbeitsplatz als vertrauenswürdig hinterlegt
|
||||
# werden. Mit der DNS-Challenge kommt ein regulär vertrauenswürdiges
|
||||
# Zertifikat zustande, ohne dass der Server je aus dem Internet erreichbar
|
||||
# sein muss.
|
||||
#
|
||||
# Voraussetzung: ein Caddy-Build mit dem DNS-Modul des eigenen Anbieters,
|
||||
# etwa
|
||||
# xcaddy build --with github.com/caddy-dns/cloudflare
|
||||
# Andere Anbieter siehe https://github.com/caddy-dns
|
||||
#
|
||||
# Der API-Schlüssel gehört in eine Umgebungsvariable des Dienstes, nicht in
|
||||
# diese Datei.
|
||||
|
||||
hr.elycon.solutions {
|
||||
tls {
|
||||
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
||||
}
|
||||
|
||||
# Die Anwendung lauscht nur auf der Loopback-Adresse (siehe
|
||||
# docker-compose.yml), erreichbar ist sie also ausschliesslich über
|
||||
# diesen Proxy.
|
||||
reverse_proxy 127.0.0.1:3000
|
||||
|
||||
# Ohne diese Weitergabe baut Auth.js seine Rückruf-Adresse aus dem
|
||||
# Container-Hostnamen statt aus dem echten Namen — die Anmeldung landet
|
||||
# dann auf einer Adresse, die niemand kennt. `trustHost` in
|
||||
# lib/auth/config.ts wertet genau diese Header aus.
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
header_up X-Forwarded-Host {host}
|
||||
|
||||
encode gzip zstd
|
||||
|
||||
log {
|
||||
output file /var/log/caddy/hr.log
|
||||
format json
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
services:
|
||||
app:
|
||||
# Ohne Build-Argumente: alles, was die Anwendung braucht — DATABASE_URL,
|
||||
# AUTH_* — liest sie zur Laufzeit aus .env. Das Abbild ist damit für jede
|
||||
# Umgebung dasselbe.
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
# NEXT_PUBLIC_* vars are inlined into the browser bundle at build
|
||||
# time, so they have to be passed here, not just in env_file below.
|
||||
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||
restart: unless-stopped
|
||||
# Nur auf der Loopback-Adresse, nicht auf allen Schnittstellen. Erreichbar
|
||||
# ist die App damit ausschliesslich über den Reverse Proxy, der TLS
|
||||
# beendet — sonst stünde daneben derselbe Dienst unverschlüsselt offen,
|
||||
# und ein Fehler in der Firewall genügte.
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "127.0.0.1:3000:3000"
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
|
||||
148
docs/azure-migration.md
Normal file
148
docs/azure-migration.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# Umstieg auf Azure — Sicherheitsarchitektur
|
||||
|
||||
Entwurf zur Abnahme. **Noch kein Code umgestellt.**
|
||||
|
||||
Ziel: Azure Database for PostgreSQL (Flexible Server), Anmeldung über Entra ID,
|
||||
Supabase als Abhängigkeit entfernt.
|
||||
|
||||
## Ausgangslage, gemessen
|
||||
|
||||
| | Anzahl |
|
||||
|---|---|
|
||||
| RLS-Policies | 21 |
|
||||
| `auth.uid()` / `auth.users` in Migrationen | 79 |
|
||||
| Fremdschlüssel auf `auth.users` | 9 |
|
||||
| Datenzugriffe in der App (`.from()`, `.rpc()`) | 50 |
|
||||
| Dateien mit Supabase-Import | 10 |
|
||||
|
||||
Die Anwendung läuft mit dem **anon-Key** (`lib/supabase/server.ts`,
|
||||
`client.ts`); der Service-Role-Key kommt nur in `lib/supabase/admin.ts` vor.
|
||||
Die RLS-Policies sind damit die tatsächliche Sicherheitsgrenze — nicht der
|
||||
Proxy und nicht der Anwendungscode.
|
||||
|
||||
## Der entscheidende Befund
|
||||
|
||||
`auth.uid()` erscheint 70-mal, aber für die Absicherung zählt genau **eine**
|
||||
Stelle:
|
||||
|
||||
```sql
|
||||
create or replace function is_hr_user() returns boolean
|
||||
language sql security definer stable as $$
|
||||
select exists (
|
||||
select 1 from profiles p
|
||||
where p.id = auth.uid() and p.role = 'hr' and p.is_active = true
|
||||
);
|
||||
$$;
|
||||
```
|
||||
|
||||
Alle 21 Policies rufen `is_hr_user()` auf. Wird hier die Herkunft der
|
||||
Benutzerkennung ausgetauscht, **bleiben alle Policies unverändert gültig**.
|
||||
Die Sicherheitsarchitektur wandert also *nicht* in den Anwendungscode — das
|
||||
war meine Sorge bei Variante B, und sie ist ausgeräumt.
|
||||
|
||||
Die übrigen ~55 Vorkommen stehen in Mutations-RPCs (`insert into audit_log
|
||||
values (auth.uid(), …)`) und sind eine mechanische Ersetzung.
|
||||
|
||||
## Zielarchitektur
|
||||
|
||||
### 1. Benutzertabelle statt `auth.users`
|
||||
|
||||
```sql
|
||||
create table app_users (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
entra_object_id uuid not null unique, -- oid aus dem Entra-Token
|
||||
email text not null,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
```
|
||||
|
||||
Die neun Fremdschlüssel zeigen künftig hierauf. `profiles.id` bleibt der
|
||||
Schlüssel, an dem `role` und `is_active` hängen — die HR-Freischaltung
|
||||
funktioniert unverändert.
|
||||
|
||||
### 2. Sitzungskontext statt `auth.uid()`
|
||||
|
||||
```sql
|
||||
create or replace function current_app_user() returns uuid
|
||||
language sql stable as $$
|
||||
select nullif(current_setting('app.user_id', true), '')::uuid;
|
||||
$$;
|
||||
```
|
||||
|
||||
`auth.uid()` → `current_app_user()`, überall. `is_hr_user()` bleibt sonst
|
||||
Wort für Wort gleich.
|
||||
|
||||
### 3. Der kritische Punkt: wie der Kontext gesetzt wird
|
||||
|
||||
**Hier entscheidet sich, ob die Migration sicher ist.**
|
||||
|
||||
Jeder Datenbankzugriff muss in einer Transaktion laufen, die zuerst
|
||||
`set local app.user_id` ausführt:
|
||||
|
||||
```ts
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.execute(sql`select set_config('app.user_id', ${userId}, true)`);
|
||||
return tx.select()…;
|
||||
});
|
||||
```
|
||||
|
||||
Das dritte Argument `true` bedeutet *transaktionslokal*. Ohne Transaktion
|
||||
bliebe die Einstellung an der Verbindung hängen — und die nächste Anfrage,
|
||||
die dieselbe Verbindung aus dem Pool zieht, liefe **mit der Kennung des
|
||||
vorherigen Benutzers**. Das ist genau die Art Fehler, die in einem Test nie
|
||||
auffällt und im Betrieb Personaldaten quer über Benutzer hinweg preisgibt.
|
||||
|
||||
Deshalb: **kein direkter Zugriff auf den Pool.** Es gibt eine einzige
|
||||
Zugriffsfunktion, die die Transaktion und `set_config` erzwingt, und eine
|
||||
Lint-Regel, die den Import des Pools außerhalb dieser Datei verbietet.
|
||||
Das muss strukturell unmöglich sein, nicht per Konvention.
|
||||
|
||||
Zusätzlich verbindet sich die Anwendung mit einer Datenbankrolle **ohne**
|
||||
`BYPASSRLS`. Selbst wenn der Kontext fehlt, liefern die Policies dann nichts
|
||||
zurück — statt alles.
|
||||
|
||||
### 4. Anmeldung
|
||||
|
||||
Entra ID über NextAuth (Azure-AD-Provider) oder MSAL. Nach der Validierung
|
||||
des Tokens wird die `oid` auf `app_users.entra_object_id` abgebildet; existiert
|
||||
kein Eintrag, wird einer angelegt — **ohne** `profiles`-Zeile, also ohne
|
||||
Zugriff. Die Freischaltung bleibt ein bewusster Schritt, wie heute
|
||||
(`is_active` ist per Vorgabe `false`).
|
||||
|
||||
Damit erledigt sich die SSO-Frage aus der IT-Liste mit.
|
||||
|
||||
### 5. Datenzugriff
|
||||
|
||||
PostgREST entfällt; die 50 Aufrufe werden auf Drizzle umgestellt. Die sechs
|
||||
`.rpc()`-Aufrufe sind trivial (direkter Funktionsaufruf), die 44
|
||||
`.from()`-Aufrufe sind Query-Builder-Umschreibungen.
|
||||
|
||||
Das handgeschriebene `lib/supabase/types.ts` entfällt: Drizzle erzeugt die
|
||||
Typen aus dem Schema, womit auch der Schema-Drift-Prüfer überflüssig wird.
|
||||
|
||||
## Was bewusst gleich bleibt
|
||||
|
||||
- **Alle 21 RLS-Policies**, unverändert
|
||||
- Das gesamte Schema samt Enums, Arrays, `jsonb`, PL/pgSQL, partiellen Indizes
|
||||
- `pgcrypto` und `pg_trgm` (beide auf Azure freigegeben)
|
||||
- Die Geschäftslogik in den RPCs
|
||||
|
||||
## Reihenfolge
|
||||
|
||||
1. `app_users`, `current_app_user()`, Fremdschlüssel umhängen — additiv, gegen die bestehende Datenbank testbar
|
||||
2. Zugriffsschicht mit erzwungener Transaktion + `set_config`, plus Test, der den Kontextverlust nachweist
|
||||
3. Entra-ID-Anmeldung
|
||||
4. Die 50 Datenzugriffe umstellen
|
||||
5. Supabase-Pakete entfernen
|
||||
6. Umzug der Datenbank per `pg_dump`/`pg_restore`
|
||||
|
||||
Schritt 2 ist der einzige, bei dem ein Fehler still bleibt. Dafür braucht es
|
||||
einen Test, der zwei Anfragen über dieselbe gepoolte Verbindung schickt und
|
||||
prüft, dass die zweite die erste nicht sieht.
|
||||
|
||||
## Offene Fragen an die Kunden-IT
|
||||
|
||||
- Welcher Entra-Mandant, und wer legt die App-Registrierung an?
|
||||
- Gruppenbasierte Freischaltung (Entra-Gruppe „HR") oder weiter manuell über `profiles.is_active`?
|
||||
- Flexible Server: Version, Region, Netzwerkzugang (Private Endpoint oder Firewall-Regeln)?
|
||||
- Wer betreibt und patcht?
|
||||
@@ -1,5 +1,23 @@
|
||||
# Datenmodell
|
||||
|
||||
> **Veraltet — siehe `docs/datenkatalog.md`.**
|
||||
>
|
||||
> Dieses Dokument beschreibt den Stand vor zwei Umbauten und stimmt in
|
||||
> wesentlichen Teilen nicht mehr:
|
||||
>
|
||||
> - Die Org-Tabellen `divisions` / `departments` / `teams` und die Tabelle
|
||||
> `positions` gibt es nicht mehr. An ihrer Stelle steht das SAP-OM-Modell
|
||||
> (`org_units`, `jobs`, `om_positions`, `position_assignments`) mit
|
||||
> zeitabhängigen Zuordnungen.
|
||||
> - Supabase Auth, der anon key und die Service-Role sind weg. Angemeldet
|
||||
> wird über Auth.js gegen Entra ID, die Konten stehen in `app_users`, und
|
||||
> der Zugriff läuft über eine Rolle ohne `BYPASSRLS`.
|
||||
> - Die Zahl der RLS-Policies ist 21, nicht 58.
|
||||
>
|
||||
> Der Datenkatalog wurde aus der laufenden Datenbank erzeugt und gilt. Was
|
||||
> hier noch stimmt — die Grundprinzipien und der Abschnitt zu den
|
||||
> effective-dated changes — steht dort ebenfalls.
|
||||
|
||||
Beschreibt das tatsächliche Supabase-Schema (siehe `supabase/migrations/`),
|
||||
nicht ein generisches HR-Schema. Quelle der Wahrheit sind immer die
|
||||
Migrationen; dieses Dokument ist eine lesbare Zusammenfassung und wird bei
|
||||
|
||||
421
docs/datenkatalog.md
Normal file
421
docs/datenkatalog.md
Normal file
@@ -0,0 +1,421 @@
|
||||
# Datenkatalog
|
||||
|
||||
Jede Tabelle, jede Spalte, jede Regel — ausgelesen aus der laufenden
|
||||
Datenbank am **13.08.2026**.
|
||||
|
||||
Die Wahrheit steht in `supabase/migrations/` (48 Dateien). Dieses Dokument
|
||||
ist eine lesbare Fassung davon und wurde nicht abgetippt, sondern aus dem
|
||||
Systemkatalog erzeugt: Spaltentypen, Vorgabewerte, Schlüssel und
|
||||
Prüfbedingungen stammen aus `information_schema` und `pg_catalog`. Wo unten
|
||||
eine Regel in Worten steht, steht daneben, aus welcher `CHECK`-Bedingung sie
|
||||
kommt.
|
||||
|
||||
> **Nicht verwechseln mit `docs/data-model.md`.** Das Dokument beschreibt den
|
||||
> Stand vor der Umstellung auf das SAP-OM-Modell und auf Auth.js — es nennt
|
||||
> Tabellen (`divisions`, `departments`, `teams`, `positions`), die es nicht
|
||||
> mehr gibt. Bei Widerspruch gilt dieser Katalog.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Tabellen | 15 |
|
||||
| Spalten | 142 |
|
||||
| Aufzählungstypen | 10 |
|
||||
| Sichten (Views) | 0 |
|
||||
| Eigene SQL-Funktionen | 38 (plus 31 aus der Erweiterung `pg_trgm`) |
|
||||
| RLS-Policies | 21, auf jeder Tabelle mindestens eine |
|
||||
|
||||
---
|
||||
|
||||
## Wie das Modell gedacht ist
|
||||
|
||||
Drei Entscheidungen erklären fast jede Eigenheit weiter unten.
|
||||
|
||||
**Person und Planstelle sind zwei Dinge.** Eine Person (`employees`) hat
|
||||
keine Spalte „Abteilung". Sie sitzt auf einer Planstelle (`om_positions`),
|
||||
und die Planstelle hängt in einer Organisationseinheit (`org_units`). Wo
|
||||
jemand arbeitet, ist damit nicht ein Feld, sondern ein Weg über zwei
|
||||
Tabellen. Der Preis ist ein Join; dafür lässt sich eine Planstelle
|
||||
ausschreiben, bevor jemand darauf sitzt, und eine Person wechseln, ohne dass
|
||||
die Stelle verschwindet.
|
||||
|
||||
**Zuordnungen haben ein Von und ein Bis.** `position_assignments`,
|
||||
`om_positions` und `org_units` tragen alle `valid_from` / `valid_to` als
|
||||
halboffenes Intervall: `valid_from` gehört dazu, `valid_to` nicht mehr. Eine
|
||||
Zuordnung, die heute endet, hat `valid_to = heute` und gilt heute schon nicht
|
||||
mehr. Deshalb kann jede Auswertung einen Stichtag haben, auch einen in der
|
||||
Vergangenheit, und deshalb ist eine Versetzung zum Ersten des nächsten Monats
|
||||
kein Termin im Kalender, sondern eine Zeile, die erst dann greift.
|
||||
|
||||
**Geschichte wird geschrieben, nicht überschrieben.** `employee_history` und
|
||||
`audit_log` haben keine Update- und keine Delete-Policy — RLS lässt nur
|
||||
`select` und `insert` zu. Eine Korrektur ist ein neuer Eintrag, nie eine
|
||||
geänderte Zeile.
|
||||
|
||||
Zwei Ausnahmen gibt es, und sie sind eng: `delete_history_entry` nimmt eine
|
||||
irrtümlich erfasste Stammdaten- oder Vertragsänderung samt ihrer Wirkung
|
||||
zurück, `update_history_entry` berichtigt Wert und Datum einer solchen. Die
|
||||
Policies bleiben dabei unangetastet — beide laufen als `SECURITY DEFINER` an
|
||||
ihnen vorbei und prüfen die Berechtigung selbst. Das Protokoll behält den
|
||||
Vorgang, dort verschwindet nichts.
|
||||
|
||||
Die Namen der OM-Tabellen sind nicht zufällig gewählt: `org_units` ist der
|
||||
SAP-Objekttyp O, `jobs` ist C, `om_positions` ist S, `employees` ist P, und
|
||||
`position_assignments` ist die Verknüpfung A008 („Inhaber ist"). Das Flag
|
||||
`is_chief` entspricht A012 („ist Leiter von"). Wer das Modell aus SAP kennt,
|
||||
findet sich wieder; wer nicht, verliert nichts.
|
||||
|
||||
```
|
||||
org_units ──parent_id──┐ (rekursiv: Gesellschaft › Bereich › Abteilung › Team)
|
||||
│ │
|
||||
└──────────────────┘
|
||||
▲
|
||||
│ org_unit_id
|
||||
om_positions ──job_id──► jobs
|
||||
▲
|
||||
│ position_id
|
||||
position_assignments ──employee_id──► employees ──location_id──► locations
|
||||
▲
|
||||
├── employee_history (Ereignisse)
|
||||
├── employee_dependents (Angehörige)
|
||||
├── employee_notes (HR-Notizen)
|
||||
├── pending_org_changes (wirkt später)
|
||||
└── audit_log (wer/wann/was)
|
||||
|
||||
app_users ──1:1──► profiles (Anmeldung ist nicht Berechtigung)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Personen
|
||||
|
||||
### `employees` — 46 Spalten
|
||||
|
||||
Die Person selbst: Stammdaten, Vertrag, Status. **Nicht** die Organisation —
|
||||
die kommt über die Planstelle.
|
||||
|
||||
| Spalte | Typ | Null | Vorgabe | Bedeutung |
|
||||
|---|---|---|---|---|
|
||||
| `id` | uuid | – | `gen_random_uuid()` | Schlüssel |
|
||||
| `personnel_number` | int4 | – | – | **Eindeutig.** Wird eingegeben, nicht vergeben — sie muss mit Loga/Interflex übereinstimmen |
|
||||
| `first_name`, `last_name` | text | – | – | |
|
||||
| `gender` | `gender_type` | – | – | `m` / `w` |
|
||||
| `birth_date` | date | – | – | |
|
||||
| `sv_nummer` | text | ja | – | Österreichische SV-Nummer; gegen Prüfziffer *und* Geburtsdatum geprüft (Trigger `fn_validate_employee_svnr`) |
|
||||
| `nationality` | text | – | `'Österreich'` | |
|
||||
| `address`, `postal_code`, `city`, `address_country` | text | ja | – | Wohnanschrift |
|
||||
| `email` | text | ja | – | **Private** E-Mail. Freiwillig, aber eindeutig, wenn angegeben |
|
||||
| `phone` | text | ja | – | **Private** Telefonnummer. Freiwillig |
|
||||
| `job_title` | text | – | – | Anzeigetitel. Der verbindliche Titel steht am Job der Planstelle |
|
||||
| `location_id` | uuid | – | – | → `locations` |
|
||||
| `employment_type` | `employment_type` | – | `Vollzeit` | |
|
||||
| `weekly_hours` | numeric | – | `38.5` | An `employment_type` gekoppelt, siehe Regeln |
|
||||
| `contract_type` | `contract_type` | – | `unbefristet` | |
|
||||
| `contract_end_date` | date | ja | – | Pflicht bei `befristet` |
|
||||
| `worker_type` | `worker_type` | – | `Angestellte:r` | |
|
||||
| `collective_agreement` | `collective_agreement` | – | `Handel` | |
|
||||
| `paygrade` | `paygrade_type` | – | `B` | A–F |
|
||||
| `source` | `source_type` | – | `Extern` | Intern besetzt oder extern geholt |
|
||||
| `work_days` | text[] | – | `{Mo,Di,Mi,Do,Fr}` | In Klickreihenfolge gespeichert, nicht sortiert |
|
||||
| `status` | `employment_status` | – | `Aktiv` | Gilt für **heute**; für einen Stichtag wird er zurückgerechnet |
|
||||
| `entry_date` | date | – | – | |
|
||||
| `exit_date`, `exit_reason` | date/text | ja | – | |
|
||||
| `karenz_start_date`, `karenz_return_date` | date | ja | – | Laufende Langzeitabwesenheit |
|
||||
| `absence_type` | text | ja | – | Art der Abwesenheit; wird bei der Rückkehr geleert. 13 erlaubte Werte |
|
||||
| `is_betriebsrat`, `is_laterale_fuehrung`, `is_c_level` | bool | – | `false` | |
|
||||
| `has_dienstwagen` | bool | – | `false` | |
|
||||
| `dienstwagen_art` | text | ja | – | `Verbrenner` / `Elektro`; nur zusammen mit `has_dienstwagen` |
|
||||
| `emergency_contact_name`, `_phone`, `_relation` | text | ja | – | Daten einer dritten Person, nur für den Notfall erhoben |
|
||||
| `title_prefix`, `title_suffix` | text[] | – | `{}` | Akademische Grade, gegen feste Listen geprüft |
|
||||
| `avatar_color` | text | ja | – | Darstellung |
|
||||
| `monthly_salary_gross` | numeric | ja | – | **Stillgelegt.** Gehalt ist ausserhalb des Funktionsumfangs; keine Funktion liest oder schreibt die Spalte mehr. Steht nur noch da, falls Altdaten drin sind |
|
||||
| `created_at`, `updated_at` | timestamptz | – | `now()` | `updated_at` per Trigger |
|
||||
|
||||
**Regeln, die die Datenbank durchsetzt:**
|
||||
|
||||
- *Vollzeit heisst 38,5 Stunden; Teilzeit heisst mehr als 0 und weniger als
|
||||
38,5.* Ein Vollzeitvertrag mit 30 Stunden lässt sich nicht speichern
|
||||
(`chk_weekly_hours`).
|
||||
- *Befristet ohne Enddatum gibt es nicht* (`chk_befristet_end`).
|
||||
- *Austritt nicht vor Eintritt*, *Rückkehr nicht vor Eintritt*
|
||||
(`chk_exit_after_entry`, `chk_karenz_return_after_entry`).
|
||||
- *Dienstwagen und Antriebsart gehören zusammen* — beides oder keins
|
||||
(`chk_dienstwagen_art`). Die Bedingung nennt den Fall ohne Wagen
|
||||
ausdrücklich, weil `art in (…)` bei `null` weder wahr noch falsch ergibt
|
||||
und die Regel sonst genau das durchgelassen hätte, was sie verhindern soll.
|
||||
- *Notfallkontakt: Name und Telefon gemeinsam oder gar nicht*, und keiner der
|
||||
beiden leer (`chk_emergency_contact`).
|
||||
- *Arbeitstage nur aus Mo–So und mindestens einer* (`chk_work_days_valid`).
|
||||
- *Titel nur aus den bekannten Listen* — 10 vorangestellte, 12 nachgestellte
|
||||
(`chk_title_prefix_valid`, `chk_title_suffix_valid`).
|
||||
- *Abwesenheitsart nur aus den 13 bekannten* (`chk_absence_type`).
|
||||
|
||||
### `employee_history` — die Zeitleiste
|
||||
|
||||
Eine Zeile je Ereignis: `employee_id`, `event_date`, `event_type`
|
||||
(Aufzählung, 11 Werte), `description`. Ein Trigger verhindert Ereignisse vor
|
||||
dem Eintrittsdatum (`fn_check_history_not_before_entry`). Nur einfügen und
|
||||
lesen — kein Ändern, kein Löschen.
|
||||
|
||||
### `employee_dependents` — Angehörige
|
||||
|
||||
`first_name`, `last_name`, `relationship` (Ehepartner:in / Lebenspartner:in /
|
||||
Kind / Sonstige), `birth_date`, optional `sv_nummer`. Ändern heisst löschen
|
||||
und neu anlegen; ein In-place-Update gibt es nicht. Beim Löschen der Person
|
||||
verschwinden sie mit (`on delete cascade`).
|
||||
|
||||
### `employee_notes` — HR-Notizen
|
||||
|
||||
`category` (Allgemein / Vertraulich / Personalgespräch / Wiedervorlage /
|
||||
Lob / Anerkennung), `note_text`, optional `due_date` für die Wiedervorlage,
|
||||
dazu `done` / `done_at` / `done_by`.
|
||||
|
||||
Bewusst **nicht** auf die verfassende Person eingeschränkt: jede aktive
|
||||
HR-Person sieht jede offene Notiz. „Meine Notizen" ist ein gemeinsames
|
||||
Postfach, kein privates.
|
||||
|
||||
---
|
||||
|
||||
## Organisation
|
||||
|
||||
### `org_units` — Einheiten (SAP-Objekttyp O)
|
||||
|
||||
`org_number` (eindeutig), `name`, `parent_id` (rekursiv), `unit_type`
|
||||
(Gesellschaft / Bereich / Abteilung / Team), `valid_from` / `valid_to`.
|
||||
|
||||
`unit_type` ist ein Etikett für die Anzeige, keine Struktur — die Struktur
|
||||
ist `parent_id`. Eine Abteilung unter einer Abteilung wäre erlaubt. Was die
|
||||
Datenbank verhindert, ist nur, dass eine Einheit ihr eigenes Elternteil wird
|
||||
(`chk_org_unit_not_own_parent`); tiefere Zyklen fängt sie nicht ab.
|
||||
|
||||
### `jobs` — Tätigkeiten (Objekttyp C)
|
||||
|
||||
`code` und `title`, beide eindeutig. Ein schlanker Katalog: die Planstelle
|
||||
verweist darauf, statt den Titel abzuschreiben.
|
||||
|
||||
### `om_positions` — Planstellen (Objekttyp S)
|
||||
|
||||
`position_number` (eindeutig), `org_unit_id`, `job_id`, `is_chief`,
|
||||
`valid_from` / `valid_to`.
|
||||
|
||||
Die Nummern haben die Form `6` + sieben Ziffern, weil
|
||||
`next_position_number()` sie so erzeugt — erzwungen wird das Format aber
|
||||
nicht: an der Spalte hängt nur Eindeutigkeit. Wer von aussen eine Nummer
|
||||
einträgt, kann eine andere Form wählen, und die Zählfunktion übergeht sie
|
||||
dann (sie sucht ihr Maximum nur unter `^6[0-9]{7}$`).
|
||||
|
||||
`is_chief` markiert die Leitungsstelle einer Einheit — daraus entsteht die
|
||||
Führungslinie, nicht aus einem Feld „Vorgesetzte:r" an der Person. Eine
|
||||
Planstelle lässt sich nur besetzen, solange sie gültig ist.
|
||||
|
||||
### `position_assignments` — Besetzungen (Verknüpfung A008)
|
||||
|
||||
`position_id`, `employee_id`, `valid_from` / `valid_to`. Diese Tabelle
|
||||
beantwortet „wer sass wann wo" — die einzige Stelle, an der das steht.
|
||||
|
||||
### `locations` — Standorte
|
||||
|
||||
`name` (eindeutig) und `country`, beschränkt auf Österreich, Deutschland,
|
||||
Tschechien und Slowenien.
|
||||
|
||||
---
|
||||
|
||||
## Ablauf und Nachweis
|
||||
|
||||
### `pending_org_changes` — was später wirkt
|
||||
|
||||
`change_type` (transfer / promotion / karenz_start / karenz_return /
|
||||
contract_change / reorg / dependent_add / dependent_remove),
|
||||
`effective_date`, `payload` (JSONB), `status` (pending / applied /
|
||||
cancelled).
|
||||
|
||||
Der Weg ist `pending` → `applied`. Die Auswahl filtert immer auf `pending`,
|
||||
ein zweiter Lauf wirkt also nicht doppelt. Verarbeitet wird täglich von
|
||||
`apply_due_pending_changes()`.
|
||||
|
||||
### `audit_log` — wer, wann, was, an wem
|
||||
|
||||
`occurred_at`, `actor_user_id` + `actor_name`, `action`, `target_label`,
|
||||
`target_employee_id`, `details`, und `changes` als JSONB in der Form
|
||||
`[{feld, vorher, nachher}]` — daher die aufklappbare Detailansicht in der
|
||||
Oberfläche. Bei Einträgen von vor der entsprechenden Migration ist `changes`
|
||||
null.
|
||||
|
||||
Geschrieben wird ausschliesslich aus den SQL-Funktionen heraus, in derselben
|
||||
Transaktion wie die Änderung selbst. Das ist der Punkt: ein fehlgeschlagener
|
||||
Log-Eintrag lässt die ganze Änderung scheitern, statt still eine Änderung
|
||||
ohne Nachweis zu hinterlassen. Einen Helfer im Anwendungscode gibt es nicht
|
||||
und sollte es nicht geben — das wäre eine zweite, nicht atomare Quelle.
|
||||
|
||||
Dass `actor_name` als Text mitgeschrieben wird und nicht nur die Kennung: der
|
||||
Nachweis soll lesbar bleiben, auch wenn das Benutzerkonto später verschwindet.
|
||||
|
||||
---
|
||||
|
||||
## Zugang
|
||||
|
||||
### `app_users`
|
||||
|
||||
Ersetzt `auth.users` aus der Supabase-Zeit. `external_id` ist die `oid` aus
|
||||
Entra ID — **nicht** die E-Mail-Adresse, die kann sich ändern. Angelegt wird
|
||||
die Zeile bei der ersten Anmeldung durch `app_upsert_user()`.
|
||||
|
||||
### `profiles`
|
||||
|
||||
Eine Zeile je Konto, gleicher Schlüssel wie `app_users`. `role` ist per
|
||||
Prüfbedingung auf den einen Wert `'hr'` festgenagelt, `is_active` steht
|
||||
anfangs auf `false`.
|
||||
|
||||
**Anmelden können heisst nichts.** Wer sich mit dem Firmenkonto anmeldet,
|
||||
bekommt eine `app_users`-Zeile und kommt trotzdem an keine einzige
|
||||
Personalzeile, solange `profiles.is_active` nicht gesetzt ist. Die Freigabe
|
||||
ist ein bewusster zweiter Schritt.
|
||||
|
||||
### `hire_drafts`, `saved_reports`
|
||||
|
||||
Zwischenstand des Einstellungsassistenten (`payload` JSONB, `step`) und
|
||||
gespeicherte Berichtskonfigurationen. Beide sind auf die anlegende Person
|
||||
eingeschränkt.
|
||||
|
||||
---
|
||||
|
||||
## Aufzählungstypen
|
||||
|
||||
| Typ | Werte |
|
||||
|---|---|
|
||||
| `employment_status` | Aktiv, Karenz, Geplant, Ausgetreten |
|
||||
| `employment_type` | Vollzeit, Teilzeit |
|
||||
| `contract_type` | unbefristet, befristet |
|
||||
| `worker_type` | Angestellte:r, Arbeiter:in |
|
||||
| `collective_agreement` | Handel, Süßwaren |
|
||||
| `paygrade_type` | A, B, C, D, E, F |
|
||||
| `source_type` | Intern, Extern |
|
||||
| `gender_type` | m, w |
|
||||
| `org_unit_type` | Gesellschaft, Bereich, Abteilung, Team |
|
||||
| `history_event_type` | Eintritt, Beförderung, Versetzung, Karenz, Vertragsänderung, Stammdatenänderung, Austritt, Wiedereintritt, Reorganisation, Gehaltsanpassung, Rückkehr |
|
||||
|
||||
`Karenz` heisst in der Oberfläche „Langzeitabwesenheit" — der gespeicherte
|
||||
Wert wurde beim Umbenennen bewusst nicht angefasst, die Beschriftung folgt
|
||||
dem neuen Begriff.
|
||||
|
||||
Nicht als Aufzählungstyp, sondern als Prüfbedingung auf einer Textspalte
|
||||
gelöst: Abwesenheitsart, Antriebsart, Verhältnis von Angehörigen,
|
||||
Notizkategorie, Änderungsart, Land. Der praktische Unterschied: eine
|
||||
Prüfbedingung lässt sich in einer Migration ändern, ein Aufzählungstyp nur
|
||||
erweitern.
|
||||
|
||||
---
|
||||
|
||||
## Die SQL-Schnittstelle
|
||||
|
||||
Änderungen laufen nicht über `insert`/`update` aus der Anwendung, sondern
|
||||
über Funktionen. Jede schreibt ihren Nachweis und ihre Historie in derselben
|
||||
Transaktion mit.
|
||||
|
||||
**Personal:** `hire_employee`, `rehire_employee`, `terminate_employee`,
|
||||
`transfer_employee`, `promote_employee`, `change_employee_data`,
|
||||
`start_karenz`, `adjust_karenz_return`, `record_karenz_return`
|
||||
|
||||
**Planstellen:** `create_position`, `update_position`, `delete_position`,
|
||||
`next_position_number`
|
||||
|
||||
**Historie:** `delete_history_entry` nimmt eine irrtümliche Stammdaten- oder
|
||||
Vertragsänderung zurück: setzt je Feld auf den Wert davor, sofern kein
|
||||
späterer Eintrag dasselbe Feld angefasst hat, und entfernt die Zeile.
|
||||
`update_history_entry` berichtigt stattdessen Wert und Datum — das „vorher"
|
||||
bleibt unangetastet, und der heutige Stand wird je Feld aus dem jüngsten
|
||||
Eintrag abgeleitet, der es trägt. Beide sind der einzige Weg an den fehlenden
|
||||
`update`- und `delete`-Policies vorbei, deshalb `SECURITY DEFINER` und mit
|
||||
`require_hr_admin()` davor. `app_feld_karte` liefert beiden die Zuordnung
|
||||
Beschriftung → Spalte und Typ.
|
||||
|
||||
**Umfeld:** `add_employee_dependent`, `delete_employee_dependent`,
|
||||
`add_employee_note`, `complete_employee_note`
|
||||
|
||||
**Auswertung:** `om_reporting_lines(p_as_of)` — löst zum Stichtag auf, wer an
|
||||
wen berichtet, samt Vertretung bei Abwesenheit (`acting_manager_id` neben
|
||||
`formal_manager_id`)
|
||||
|
||||
**Zugang und Nachweis:** `is_hr_user`, `app_current_user_id`,
|
||||
`app_upsert_user`, `current_actor_name`, `app_aenderung`,
|
||||
`app_aenderungsfelder`
|
||||
|
||||
**Automatik:** `apply_due_pending_changes` (täglich), `rls_auto_enable`
|
||||
(hängt am Ereignis-Trigger `ensure_rls`: neue Tabellen bekommen sofort RLS),
|
||||
die vier `fn_*`-Trigger, `is_valid_svnr`
|
||||
|
||||
Sechs Funktionen laufen als `SECURITY DEFINER`, also mit den Rechten ihrer
|
||||
Eigentümerin statt der aufrufenden Person: `is_hr_user`,
|
||||
`app_current_user_id`, `app_upsert_user`, `apply_due_pending_changes`,
|
||||
`delete_history_entry` und `update_history_entry`. Die ersten drei müssen es
|
||||
sein, weil sie sonst gegen dieselben Policies liefen, die sie gerade auswerten
|
||||
sollen — eine Rekursion. Die vierte läuft ohne angemeldete Person, es gibt ja
|
||||
nur den Zeitplan. Die letzten beiden müssen an `employee_history` schreiben,
|
||||
wo es absichtlich weder eine `update`- noch eine `delete`-Policy gibt; beide
|
||||
prüfen die Berechtigung deshalb selbst, in ihrer ersten Zeile.
|
||||
|
||||
**Der Türsteher:** `require_hr_admin()` steht am Anfang von **16**
|
||||
Funktionen — jeder ändernden. Es wirft, wenn `is_hr_user()` falsch ist, und
|
||||
liefert damit eine lesbare Meldung statt einer nackten RLS-Verletzung. Der
|
||||
Name täuscht: ein Admin-Rollenmodell gibt es nicht, `is_hr_admin()` ruft
|
||||
schlicht `is_hr_user()` auf. Die Schranke selbst bleiben die Policies.
|
||||
|
||||
**Übrig geblieben:** `generate_company_email` steht noch in der Datenbank,
|
||||
wird aber von nichts mehr gerufen — sie stammt aus der Zeit, als eine
|
||||
Firmenadresse automatisch vergeben wurde; heute ist `employees.email` die
|
||||
private Adresse und freiwillig.
|
||||
|
||||
---
|
||||
|
||||
## Zugriffsschutz
|
||||
|
||||
Auf **jeder** der 15 Tabellen ist Row Level Security aktiv, zusammen 21
|
||||
Policies. Fast alle prüfen dasselbe: `is_hr_user()` — also `profiles.role =
|
||||
'hr'` **und** `is_active`. Nachgereicht wird das nicht: der Ereignis-Trigger
|
||||
`ensure_rls` schaltet RLS bei jeder neu angelegten Tabelle sofort ein.
|
||||
|
||||
Die Prüfung hängt an einer Sitzungsvariablen (`app.user_id`), die
|
||||
`withUser()` als erste Anweisung jeder Transaktion setzt — transaktionslokal,
|
||||
damit sie nicht an der Verbindung kleben bleibt und die nächste Anfrage aus
|
||||
dem Pool mit fremder Kennung läuft.
|
||||
|
||||
Wie wirksam das ist, zeigt sich beim Erzeugen dieses Katalogs: die Verbindung
|
||||
lief ohne Sitzungskontext, und **jede** Tabelle lieferte null Zeilen — bei
|
||||
vollständig vorhandenen Strukturdaten. Nicht „alles", nicht ein Fehler,
|
||||
sondern nichts. Die Anwendung verbindet sich ausserdem als `alpenwerk_app` —
|
||||
eine Rolle ohne `BYPASSRLS` und ohne Superuser-Recht; es gibt also keinen Weg
|
||||
daran vorbei, auch nicht versehentlich.
|
||||
|
||||
Wo das Muster abweicht:
|
||||
|
||||
- `audit_log` und `employee_history` haben je zwei Policies — lesen und
|
||||
einfügen, getrennt, und kein Ändern oder Löschen. Das ist die
|
||||
Unveränderlichkeit, technisch durchgesetzt.
|
||||
- `profiles` hat vier, weil dort auch die Freischaltung anderer Konten
|
||||
passiert — und eine davon lässt jede Person die *eigene* Zeile lesen, auch
|
||||
ohne Freischaltung. Sonst könnte niemand erfahren, warum er nicht
|
||||
hineinkommt.
|
||||
- `app_users` ebenso: die eigene Zeile oder HR.
|
||||
- `hire_drafts` und `saved_reports` verlangen zusätzlich, dass die Zeile der
|
||||
anfragenden Person gehört.
|
||||
- `locations` trennt Lesen und Schreiben in zwei Policies, prüft aber beide
|
||||
Male dasselbe.
|
||||
|
||||
---
|
||||
|
||||
## Was hier nicht steht
|
||||
|
||||
**Gehalt.** `monthly_salary_gross` ist stillgelegt und wird von keiner
|
||||
Funktion mehr angefasst. Gehaltsdaten leben in Loga.
|
||||
|
||||
**Zeitwirtschaft.** Kommen und Gehen, Urlaubskonten, Krankenstände als
|
||||
Einzelfälle — das ist Interflex. Hier steht nur die dauerhafte
|
||||
Langzeitabwesenheit, weil sie die Führungslinie verschiebt.
|
||||
|
||||
**Bewerbungen.** Eine offene Planstelle ist hier eine Planstelle ohne
|
||||
Besetzung, mehr nicht.
|
||||
|
||||
---
|
||||
|
||||
*Erzeugt aus dem Systemkatalog der laufenden Datenbank. Nach strukturellen
|
||||
Änderungen gehört dieses Dokument nachgezogen — am ehrlichsten, indem es neu
|
||||
aus der Datenbank erzeugt wird, statt es von Hand zu pflegen.*
|
||||
137
docs/entra-sso.md
Normal file
137
docs/entra-sso.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# Anmeldung über Entra ID
|
||||
|
||||
Die Anwendung meldet ausschliesslich über Microsoft Entra ID an. Die
|
||||
Sitzungsverwaltung macht **Auth.js** (`auth.ts`, `lib/auth/config.ts`) — es gibt
|
||||
keinen Anmeldedienst eines Anbieters mehr dazwischen.
|
||||
|
||||
**Warum das trotzdem eine kleine Änderung ist:** die Anmeldung liefert nach wie
|
||||
vor nur eine UUID. `profiles.id` trägt weiterhin `role` und `is_active`, und
|
||||
damit bleiben `is_hr_user()` und alle 21 RLS-Policies unverändert gültig. Die
|
||||
Sicherheitsgrenze wandert nicht in den Anwendungscode.
|
||||
|
||||
## Einrichtung im Entra-Mandanten
|
||||
|
||||
App-Registrierung, einmalig — angelegt im Mandanten *loudspring management GmbH*:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Name | Alpenwerk HR |
|
||||
| Kontotypen | Nur ein Mandant |
|
||||
| Umleitungs-URI (Web) | `https://<produktion>/api/auth/callback/microsoft-entra-id` |
|
||||
| Anwendungs-ID (Client) | `<client-id>` |
|
||||
| Verzeichnis-ID (Mandant) | `<tenant-id>` |
|
||||
|
||||
Die konkreten Werte stehen bewusst nicht hier, sondern in der
|
||||
Übergabedokumentation. Sie sind zwar keine Geheimnisse — ohne Client-Geheimnis
|
||||
gibt eine ID nichts her, und RLS greift ohnehin —, aber sie zeigen auf die echte
|
||||
Umgebung, und dieses Repository wandert weiter als sie.
|
||||
|
||||
Die Umleitungs-URI zeigt jetzt auf die **Anwendung selbst**. Für die lokale
|
||||
Entwicklung kommt `http://localhost:3000/api/auth/callback/microsoft-entra-id`
|
||||
als zweite URI dazu; Entra erlaubt `http` nur für `localhost`.
|
||||
|
||||
Danach:
|
||||
|
||||
1. **Zertifikate & Geheimnisse** → neues Client-Geheimnis. Der *Wert* wird
|
||||
gebraucht, nicht die Geheimnis-ID, und er ist nur einmal sichtbar.
|
||||
2. **API-Berechtigungen** → `openid`, `profile`, `email` (Microsoft Graph,
|
||||
delegiert), Administratorzustimmung erteilen. `User.Read` wird **nicht**
|
||||
gebraucht: der eingebaute Anbieter von Auth.js fordert es an, um das
|
||||
Profilbild aus dem Graph zu holen — `lib/auth/config.ts` schaltet beides ab.
|
||||
3. **Tokenkonfiguration** → Gruppenanspruch, siehe unten.
|
||||
|
||||
## Konfiguration der Anwendung
|
||||
|
||||
Vier Werte, alle server-seitig — nichts davon landet im Browser-Bundle:
|
||||
|
||||
| Variable | Wert |
|
||||
|---|---|
|
||||
| `AUTH_SECRET` | `npx auth secret` oder `openssl rand -base64 32` |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_ID` | Anwendungs-ID (Client) |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | der Wert aus „Zertifikate & Geheimnisse" |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_ISSUER` | `https://login.microsoftonline.com/<tenant-id>/v2.0` |
|
||||
|
||||
Der Aussteller ist bei „Nur ein Mandant" **nicht optional**. Bleibt er leer,
|
||||
benutzt Auth.js `common` — dann dürfte sich jedes Microsoft-Konto anmelden, auch
|
||||
ein privates Outlook-Konto. Die Freischaltung über `profiles` fängt das zwar ab,
|
||||
aber die Eingangstür soll erst gar nicht so weit offenstehen.
|
||||
|
||||
`AUTH_SECRET` verschlüsselt das Sitzungscookie. Ein Wechsel meldet alle ab — im
|
||||
Ernstfall genau das gewünschte Mittel.
|
||||
|
||||
## Was bei der ersten Anmeldung passiert
|
||||
|
||||
1. Auth.js prüft das Token von Entra (`state`, `nonce`, Signatur, Aussteller).
|
||||
2. Der Rückruf in `auth.ts` nimmt daraus die **`oid`** — nicht `sub`, nicht die
|
||||
E-Mail. Die `oid` identifiziert dieselbe Person über Anwendungen hinweg und
|
||||
überlebt Namens- und Adressänderungen.
|
||||
3. `app_upsert_user(oid, email, name)` legt die `app_users`-Zeile an und liefert
|
||||
die Kennung, die von da an in jeder Transaktion als `app.user_id` steht.
|
||||
4. **Gibt es zu der Adresse bereits ein `profiles`-Eintrag, übernimmt die
|
||||
Funktion dessen Kennung** statt eine neue zu vergeben. Das ist der Grund,
|
||||
warum bestehende Zugänge nach der Umstellung weiterlaufen: Notizen,
|
||||
Entwürfe und Protokolleinträge hängen an dieser ID.
|
||||
|
||||
Der Abgleich über die Adresse ist genau hier vertretbar und sonst nirgends: sie
|
||||
kommt aus einem von Entra ausgestellten Token, nicht aus einem Formular. Wer sie
|
||||
behauptet, hat sie bereits bewiesen.
|
||||
|
||||
## Freischaltung über die Entra-Gruppe
|
||||
|
||||
Wer sich anmeldet, hat damit **noch keinen Zugriff**. Zugriff hat, wer eine
|
||||
`profiles`-Zeile mit `role = 'hr'` und `is_active = true` besitzt.
|
||||
|
||||
### Woher der Gruppen-Anspruch kommt
|
||||
|
||||
Entra schickt Gruppen nur mit, wenn es in der Tokenkonfiguration eingestellt
|
||||
ist. Zwei Varianten:
|
||||
|
||||
| Variante | Lizenz | Haken |
|
||||
|---|---|---|
|
||||
| Sicherheitsgruppen | frei | Schickt *alle* Sicherheitsgruppen mit. Ab etwa 200 Gruppen liefert Entra statt der Liste einen Verweis, und die Auswertung greift ins Leere. |
|
||||
| Der Anwendung zugewiesene Gruppen | Entra ID P1 | Nur die zugewiesene Gruppe steht im Token. |
|
||||
|
||||
### Wo die Auswertung hingehört
|
||||
|
||||
In den `jwt`-Rückruf in `auth.ts`, neben `app_upsert_user()` — dort liegt
|
||||
`profile.groups` aus dem ID-Token vor.
|
||||
|
||||
Das ist belastbar, und der Grund ist wichtig: das ID-Token ist von Entra
|
||||
signiert und wurde von Auth.js gegen den Aussteller geprüft. Die angemeldete
|
||||
Person kann seinen Inhalt nicht beeinflussen. (Unter GoTrue war dieselbe Stelle
|
||||
eine Falle: `auth.users.raw_user_meta_data` war von der Person selbst
|
||||
beschreibbar, und eine Freischaltung, die von dort gelesen hätte, wäre
|
||||
selbstbedienbar gewesen.)
|
||||
|
||||
### Reihenfolge
|
||||
|
||||
Gebaut wird das erst, wenn feststeht, wie der Anspruch tatsächlich ankommt — das
|
||||
hängt an der gewählten Variante und an der Konfiguration des Mandanten. Ablauf:
|
||||
|
||||
1. SSO in Betrieb nehmen, einmal anmelden.
|
||||
2. Im `jwt`-Rückruf einmalig `console.log(profile)` — das zeigt die Ansprüche so,
|
||||
wie der Mandant sie tatsächlich schickt.
|
||||
3. Erst dann die Auswertung mit der konkreten Gruppen-ID schreiben.
|
||||
|
||||
Ohne Schritt 2 wäre sie geraten. Bis dahin wird `profiles` von Hand gepflegt.
|
||||
|
||||
### Was die Gruppe nicht kann
|
||||
|
||||
Die Mitgliedschaft steht im Token. Wer aus der Gruppe entfernt wird, verliert den
|
||||
Zugriff deshalb **bei der nächsten Anmeldung**, nicht sofort. Für den sofortigen
|
||||
Entzug bleibt `profiles.is_active = false` das Mittel — das wirkt beim nächsten
|
||||
Datenbankzugriff, weil `is_hr_user()` die Spalte je Abfrage liest.
|
||||
|
||||
## Wer prüft was
|
||||
|
||||
| Stelle | Prüft | Wann |
|
||||
|---|---|---|
|
||||
| `proxy.ts` | Gibt es überhaupt eine Sitzung? | jede Anfrage |
|
||||
| `app/(app)/layout.tsx` | `profiles.role` / `is_active` | jeder Seitenaufbau |
|
||||
| `lib/auth/require-hr.ts` | dasselbe, für `/api/export/*` | jeder Aufruf |
|
||||
| RLS-Policies | `is_hr_user()` | jede einzelne Abfrage |
|
||||
|
||||
Der Proxy prüft die HR-Rechte **nicht** — er hat keine Datenbankverbindung. Sie
|
||||
in das Sitzungstoken zu schreiben wäre schneller gewesen und hätte eine
|
||||
Behauptung eingefroren: eine entzogene Freischaltung wirkte dann erst mit dem
|
||||
nächsten Token. Bei einer Personalanwendung ist das die falsche Richtung.
|
||||
@@ -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;
|
||||
|
||||
@@ -7,18 +7,24 @@ import type { EmploymentStatus } from "./supabase/types";
|
||||
// a label change (see supabase/migrations/*_absence_type.sql). So the value
|
||||
// is mapped to its display name here, in the one place the UI reads it from.
|
||||
|
||||
// Nur echte Abwesenheiten. Eine Teilzeit ist keine: wer in Bildungs-,
|
||||
// Pflege-, Eltern- oder Wiedereingliederungsteilzeit ist, arbeitet — nur
|
||||
// weniger. Als „Langzeitabwesenheit" geführt, verschwand die Person aus dem
|
||||
// Bestand, verlor ihre Berichtslinie an eine Vertretung und zählte in
|
||||
// Auswertungen nicht mehr mit, obwohl sie jede Woche im Haus war.
|
||||
//
|
||||
// Die vier sind deshalb hierher gewandert, wo sie hingehören: zur
|
||||
// Stundenänderung (siehe STUNDEN_GRUENDE) und zur Rückkehr aus einer
|
||||
// Abwesenheit (RUECKKEHR_GRUENDE).
|
||||
export const ABSENCE_TYPES = [
|
||||
"Wochenhilfe (Mutterschutz)",
|
||||
"Elternkarenz (inkl. Väterkarenz)",
|
||||
"Papamonat",
|
||||
"Bildungskarenz",
|
||||
"Bildungsteilzeit",
|
||||
"Präsenzdienst",
|
||||
"Zivildienst",
|
||||
"Langer Krankenstand",
|
||||
"Wiedereingliederungsteilzeit",
|
||||
"Pflegekarenz",
|
||||
"Pflegeteilzeit",
|
||||
"Familienhospizkarenz",
|
||||
"Sabbatical",
|
||||
] as const;
|
||||
@@ -29,6 +35,25 @@ export function isAbsenceType(value: string | null | undefined): value is Absenc
|
||||
return ABSENCE_TYPES.includes(value as AbsenceType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Warum jemand nach einer Abwesenheit mit weniger Stunden zurückkommt.
|
||||
*
|
||||
* Beides sind Ansprüche, keine freien Vereinbarungen — und beide beginnen
|
||||
* typischerweise genau dann, wenn die Abwesenheit endet.
|
||||
*/
|
||||
export const RUECKKEHR_GRUENDE = ["Wiedereingliederungsteilzeit", "Elternteilzeit"] as const;
|
||||
|
||||
/**
|
||||
* Warum sich die Wochenstunden ändern.
|
||||
*
|
||||
* Der erste Wert ist der Normalfall; die beiden anderen sind gesetzlich
|
||||
* geregelte Teilzeiten, die man später wiederfinden können muss — im
|
||||
* Zweifel Jahre danach, wenn jemand fragt, warum die Stunden damals sanken.
|
||||
*/
|
||||
export const STUNDEN_GRUENDE = ["Vertragliche Stundenänderung", "Bildungsteilzeit", "Pflegeteilzeit"] as const;
|
||||
|
||||
export type StundenGrund = (typeof STUNDEN_GRUENDE)[number];
|
||||
|
||||
/** Display names for the stored status values. */
|
||||
const STATUS_LABELS: Record<EmploymentStatus, string> = {
|
||||
Aktiv: "Aktiv",
|
||||
|
||||
139
lib/auth/config.ts
Normal file
139
lib/auth/config.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id";
|
||||
import type { NextAuthConfig } from "next-auth";
|
||||
|
||||
// Der Teil der Anmeldung, der **ohne Datenbank** auskommt.
|
||||
//
|
||||
// Das ist keine Stilfrage: proxy.ts läuft je nach Betriebsart in einer
|
||||
// Umgebung ohne Node-Module — dort gibt es kein `pg` und keine Verbindung.
|
||||
// Würde der Proxy die vollständige Konfiguration laden, zöge er die
|
||||
// Zugriffsschicht mit hinein und liesse sich nicht mehr ausliefern. Deshalb
|
||||
// hier nur Anbieter und Sitzungsregeln; alles, was die Datenbank berührt,
|
||||
// steht in auth.ts.
|
||||
|
||||
/** Wie lange eine Anmeldung ohne erneuten Besuch bei Entra gilt. */
|
||||
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 9; // ein Arbeitstag
|
||||
|
||||
/**
|
||||
* Der Aussteller — mit Abbruch statt Rückfall.
|
||||
*
|
||||
* Ohne diese Prüfung setzt Auth.js bei fehlender Variablen stillschweigend
|
||||
* `https://login.microsoftonline.com/common/v2.0` ein. Das ist beim ersten
|
||||
* Ausprobieren aufgefallen: die Weiterleitung ging tatsächlich nach
|
||||
* `/common/oauth2/v2.0/authorize`, und damit hätte sich **jedes**
|
||||
* Microsoft-Konto anmelden dürfen, auch ein privates.
|
||||
*
|
||||
* Aufgefallen wäre das im Betrieb sonst nicht — die Anmeldung funktioniert
|
||||
* ja, nur eben für zu viele. Ein vergessener Wert in der Deployment-Umgebung
|
||||
* muss deshalb den Start verhindern, nicht die Tür aufmachen.
|
||||
*
|
||||
* In der Entwicklung bleibt es bei einer Warnung: dort ist nichts
|
||||
* konfiguriert, und ein Abbruch beim Laden des Moduls nähme auch die
|
||||
* Anmeldeseite mit.
|
||||
*/
|
||||
function tenantIssuer(): string | undefined {
|
||||
const issuer = process.env.AUTH_MICROSOFT_ENTRA_ID_ISSUER;
|
||||
if (issuer) return issuer;
|
||||
|
||||
const hinweis =
|
||||
"AUTH_MICROSOFT_ENTRA_ID_ISSUER fehlt. Ohne Mandanten-Aussteller fiele die " +
|
||||
"Anmeldung auf /common/ zurück und stünde jedem Microsoft-Konto offen.";
|
||||
if (process.env.NODE_ENV === "production") throw new Error(hinweis);
|
||||
console.warn(`[auth] ${hinweis}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Konfiguration als **Funktion**, nicht als Objekt.
|
||||
*
|
||||
* Auth.js wertet die Funktionsform pro Anfrage aus. Das ist hier nötig, weil
|
||||
* tenantIssuer() in der Produktion abbricht: als Objekt gebaut liefe die
|
||||
* Prüfung schon beim Import — und `next build` importiert jedes Route-Modul,
|
||||
* um die Seitendaten einzusammeln. Der Bau bräuchte dann die
|
||||
* Anmeldekonfiguration der Zielumgebung, und ein Abbild liesse sich nicht
|
||||
* mehr einmal bauen und überall ausliefern.
|
||||
*/
|
||||
export function authConfig(): NextAuthConfig {
|
||||
return {
|
||||
providers: [
|
||||
MicrosoftEntraID({
|
||||
clientId: process.env.AUTH_MICROSOFT_ENTRA_ID_ID,
|
||||
clientSecret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET,
|
||||
issuer: tenantIssuer(),
|
||||
|
||||
// Der eingebaute Anbieter fordert zusätzlich `User.Read` an und holt
|
||||
// damit das Profilbild aus dem Graph. Beides ist hier unerwünscht: eine
|
||||
// Berechtigung, die niemand braucht, muss die Mandantenverwaltung
|
||||
// trotzdem genehmigen — und das Bild landete base64-kodiert im
|
||||
// Sitzungscookie, das dann in Teile zerfällt.
|
||||
authorization: { params: { scope: "openid profile email" } },
|
||||
profile(profile) {
|
||||
return {
|
||||
// Die `oid`, nicht `sub`: `sub` ist pro Anwendung verschieden, die
|
||||
// `oid` identifiziert dieselbe Person über Anwendungen hinweg und
|
||||
// überlebt Namens- und Adressänderungen.
|
||||
id: profile.oid,
|
||||
name: profile.name ?? null,
|
||||
// `email` ist im Token ein optionaler Anspruch — je nach Mandant
|
||||
// fehlt er. `preferred_username` bzw. `upn` tragen dann dieselbe
|
||||
// Adresse. Ohne diesen Rückfall scheitert die Anmeldung in genau
|
||||
// den Mandanten, die den Anspruch nicht ausdrücklich konfiguriert
|
||||
// haben.
|
||||
email: profile.email ?? profile.preferred_username ?? profile.upn ?? null,
|
||||
image: null,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
// Eigene Seite statt der von Auth.js mitgelieferten: die Anmeldung ist die
|
||||
// erste Seite, die jemand sieht, und soll aussehen wie die Anwendung.
|
||||
pages: { signIn: "/login", error: "/login" },
|
||||
|
||||
session: { strategy: "jwt", maxAge: SESSION_MAX_AGE_SECONDS },
|
||||
|
||||
callbacks: {
|
||||
// Muss **hier** stehen und nicht in auth.ts, obwohl es nur eine
|
||||
// Zuweisung ist.
|
||||
//
|
||||
// proxy.ts baut eine eigene Auth.js-Instanz aus genau dieser Datei. Lag
|
||||
// die Zuordnung in auth.ts, bekäme der Proxy die Standard-Sitzung ohne
|
||||
// `id`, hielte jede angemeldete Person für nicht angemeldet und
|
||||
// schickte sie zurück auf /login — obwohl das Cookie längst gesetzt
|
||||
// ist. Genau so ist die erste echte Anmeldung in einer Schleife
|
||||
// gelandet: Konto angelegt, Sitzung gültig, und trotzdem kam man nicht
|
||||
// hinein.
|
||||
//
|
||||
// Datenbank braucht das nicht, es liest nur aus dem entschlüsselten
|
||||
// Token — deshalb darf es am Rand laufen.
|
||||
session({ session, token }) {
|
||||
if (token.uid) session.user.id = token.uid;
|
||||
return session;
|
||||
},
|
||||
},
|
||||
|
||||
// Hinter Reverse Proxy und Container-Netzwerk kommt der Host aus dem
|
||||
// Header. Ohne das verweigert Auth.js in der Produktion den Dienst, weil
|
||||
// es die Herkunft nicht bestätigen kann.
|
||||
trustHost: true,
|
||||
|
||||
// Auf der Anmeldeseite steht bewusst nur eine allgemeine Meldung — der
|
||||
// Grund ist fremdbestimmt und gehört nicht auf eine Seite, die echt
|
||||
// aussieht. Im Serverprotokoll gehört er dagegen hin, und zwar
|
||||
// vollständig: ohne das scheitert die Anmeldung lautlos, der Browser
|
||||
// springt auf /login zurück, und es gibt nichts zu lesen ausser einer
|
||||
// 302. Genau so ist die erste Anmeldung hier fehlgeschlagen.
|
||||
logger: {
|
||||
error(err) {
|
||||
console.error("[auth] Fehler:", err);
|
||||
const cause = (err as { cause?: unknown }).cause;
|
||||
// Der eigentliche Grund steckt oft eine Ebene tiefer — bei einem
|
||||
// Fehler in einem Rückruf ist die äussere Meldung nur „Read more at
|
||||
// …/errors#callback-route-error".
|
||||
if (cause) console.error("[auth] Ursache:", cause);
|
||||
},
|
||||
warn(code) {
|
||||
console.warn("[auth] Warnung:", code);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
26
lib/auth/require-hr.ts
Normal file
26
lib/auth/require-hr.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import "server-only";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withUser } from "@/lib/db";
|
||||
import { currentUserId } from "./session";
|
||||
|
||||
// Route Handlers under /api/export/* are outside the App Router layout tree,
|
||||
// so app/(app)/layout.tsx's HR gate never runs for them — each one has to
|
||||
// re-establish that the caller is an active HR user itself. RLS is still the
|
||||
// real boundary (an unauthorized session simply reads nothing); this exists
|
||||
// so those routes answer 401/403 instead of handing back an empty workbook.
|
||||
|
||||
export type HrGate = { denied: NextResponse } | { userId: string };
|
||||
|
||||
export async function requireHrUser(): Promise<HrGate> {
|
||||
const userId = await currentUserId();
|
||||
if (!userId) return { denied: NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }) };
|
||||
|
||||
const profile = await withUser(userId, (tx) =>
|
||||
tx.selectFrom("profiles").select(["role", "is_active"]).where("id", "=", userId).executeTakeFirst()
|
||||
);
|
||||
|
||||
if (profile?.role !== "hr" || profile.is_active !== true) {
|
||||
return { denied: NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }) };
|
||||
}
|
||||
return { userId };
|
||||
}
|
||||
32
lib/auth/session.ts
Normal file
32
lib/auth/session.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import "server-only";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
// Der einzige Ort, an dem die Kennung der angemeldeten Person herkommt.
|
||||
//
|
||||
// Dass der Wechsel von GoTrue auf Auth.js eine Änderung an dieser Datei war
|
||||
// und nicht an fünfzig Aufrufstellen, lag genau an dieser Bündelung: alles
|
||||
// andere ruft `currentUserId()` auf und reicht den Wert an withUser() weiter.
|
||||
//
|
||||
// Der Wert ist app_users.id — nicht die `oid` von Entra. Die Zuordnung
|
||||
// zwischen beiden macht app_upsert_user() bei der Anmeldung, und sie
|
||||
// übernimmt für eine bereits bekannte Adresse die vorhandene profiles.id.
|
||||
// Deshalb passt die Kennung weiterhin auf das, was app_current_user_id() in
|
||||
// der Datenbank erwartet, und die 21 RLS-Policies merken vom Wechsel nichts.
|
||||
|
||||
export async function currentUserId(): Promise<string | null> {
|
||||
const session = await auth();
|
||||
return session?.user?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie currentUserId(), bricht aber ab, statt null zu liefern.
|
||||
*
|
||||
* Für Stellen, die ohne angemeldete Person keinen Sinn ergeben. Die
|
||||
* Absicherung hängt trotzdem nicht daran: ohne Kontext geben die
|
||||
* RLS-Policies nichts zurück, unabhängig davon, was der Anwendungscode tut.
|
||||
*/
|
||||
export async function requireUserId(): Promise<string> {
|
||||
const id = await currentUserId();
|
||||
if (!id) throw new Error("Nicht angemeldet.");
|
||||
return id;
|
||||
}
|
||||
107
lib/cost-centers.ts
Normal file
107
lib/cost-centers.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { Tx } from "./db";
|
||||
import { jsonArrayFrom } from "./db/json";
|
||||
import { todayIso } from "./format";
|
||||
import type { OrgEb } from "./org";
|
||||
|
||||
// Die Kostenstelle einer Planstelle — zum Stichtag, nicht „aktuell".
|
||||
//
|
||||
// Sie hängt an der Planstelle, nicht an der Person: der Sitz kostet Geld, auch
|
||||
// wenn niemand darauf sitzt. Genau das ist die Frage bei einer Vakanz.
|
||||
//
|
||||
// Und sie hat einen Zeitraum, weil eine Umkontierung ein Ereignis mit Stichtag
|
||||
// ist. Wer nach den Kosten des Vorjahres fragt, muss die Kostenstelle von
|
||||
// damals bekommen — sonst verändert jede Umkontierung rückwirkend jede alte
|
||||
// Auswertung, und das merkt niemand.
|
||||
|
||||
export type Kostenstelle = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type KontierungsZeile = {
|
||||
position_id: string;
|
||||
cost_center_id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
valid_from: string;
|
||||
valid_to: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Die Kontierungen als *Teilabfrage* — zum Einhängen in die eine Abfrage, die
|
||||
* eine Seite ohnehin stellt (lib/db/json.ts).
|
||||
*
|
||||
* Ohne `positionIds` alle. Gefiltert wird nicht nach Stichtag: die Auswahl
|
||||
* trifft `kontierungZum` im Speicher, damit dieselben Zeilen für mehrere
|
||||
* Stichtage reichen und die Abfrage eine bleibt.
|
||||
*/
|
||||
export function kontierungenAbfrage(eb: OrgEb, positionIds?: string[]) {
|
||||
const q = eb
|
||||
.selectFrom("position_cost_centers as z")
|
||||
.innerJoin("cost_centers as k", "k.id", "z.cost_center_id")
|
||||
.select(["z.position_id", "z.cost_center_id", "k.code", "k.name", "z.valid_from", "z.valid_to"])
|
||||
.orderBy("z.position_id")
|
||||
.orderBy("z.valid_from");
|
||||
|
||||
return positionIds ? q.where("z.position_id", "in", positionIds) : q;
|
||||
}
|
||||
|
||||
/** Alle Kostenstellen zur Auswahl — abgelaufene bleiben draussen. */
|
||||
export function kostenstellenAbfrage(eb: OrgEb, asOf: string = todayIso()) {
|
||||
return eb
|
||||
.selectFrom("cost_centers")
|
||||
.select(["id", "code", "name"])
|
||||
.where((e) => e.or([e("valid_to", "is", null), e("valid_to", ">", asOf)]))
|
||||
.orderBy("code");
|
||||
}
|
||||
|
||||
/**
|
||||
* Welche Kostenstelle je Planstelle am Stichtag galt.
|
||||
*
|
||||
* Halboffen [valid_from, valid_to): der letzte Tag gehört schon zur nächsten
|
||||
* Kontierung. Dieselbe Regel wie bei den Besetzungen — eine zweite Auslegung
|
||||
* desselben Zeitraummodells wäre der sichere Weg zu zwei Antworten auf
|
||||
* dieselbe Frage.
|
||||
*/
|
||||
export function kontierungZum(rows: KontierungsZeile[], asOf: string): Map<string, Kostenstelle> {
|
||||
const out = new Map<string, Kostenstelle>();
|
||||
for (const r of rows) {
|
||||
if (r.valid_from > asOf) continue;
|
||||
if (r.valid_to !== null && r.valid_to <= asOf) continue;
|
||||
out.set(r.position_id, { id: r.cost_center_id, code: r.code, name: r.name });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie kontierungZum, aber für eine einzelne Planstelle und einen Stichtag,
|
||||
* der nicht heute sein muss.
|
||||
*
|
||||
* Nötig für Planstellen, die erst entstehen: ihre Kontierung beginnt mit
|
||||
* ihnen. Zu heute gefragt gäbe es keine — und die Vakanzliste, die künftige
|
||||
* Stellen bewusst zeigt, stünde für genau diese ohne Kostenstelle da. Die
|
||||
* Frage lautet dort nicht „wer zahlt heute", sondern „wer zahlt, wenn es
|
||||
* losgeht".
|
||||
*/
|
||||
export function kontierungAm(rows: KontierungsZeile[], positionId: string, asOf: string): Kostenstelle | null {
|
||||
for (const r of rows) {
|
||||
if (r.position_id !== positionId) continue;
|
||||
if (r.valid_from > asOf) continue;
|
||||
if (r.valid_to !== null && r.valid_to <= asOf) continue;
|
||||
return { id: r.cost_center_id, code: r.code, name: r.name };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Der bequeme Weg für Aufrufer ohne eigene Abfrage. */
|
||||
export async function loadKontierungen(
|
||||
tx: Tx,
|
||||
{ asOf, positionIds }: { asOf: string; positionIds?: string[] }
|
||||
): Promise<Map<string, Kostenstelle>> {
|
||||
if (positionIds?.length === 0) return new Map();
|
||||
const { rows } = await tx
|
||||
.selectNoFrom((eb) => [jsonArrayFrom(kontierungenAbfrage(eb, positionIds)).as("rows")])
|
||||
.executeTakeFirstOrThrow();
|
||||
return kontierungZum(rows as KontierungsZeile[], asOf);
|
||||
}
|
||||
@@ -197,3 +197,63 @@ export const UN_COUNTRIES: string[] = [
|
||||
"Zentralafrikanische Republik",
|
||||
"Zypern",
|
||||
].sort((a, b) => a.localeCompare(b, "de"));
|
||||
|
||||
// Wer keinen Aufenthaltstitel braucht.
|
||||
//
|
||||
// EU-27, dazu Island, Liechtenstein und Norwegen (EWR) sowie die Schweiz.
|
||||
// Für diese Staatsangehörigen gilt Freizügigkeit; ein Aufenthaltstitel ist
|
||||
// weder nötig noch zu erfassen. Für alle übrigen ist er es, und dann will HR
|
||||
// wissen, bis wann er läuft.
|
||||
//
|
||||
// Die Liste steht hier und nicht in der Datenbank: eine Prüfbedingung, die
|
||||
// Staatsbürgerschaft und Titel koppelt, würde bei jeder Korrektur der
|
||||
// Staatsbürgerschaft zuschlagen — und Beitritte müssten als Migration
|
||||
// nachgezogen werden, statt als Zeile in einer Liste.
|
||||
export const FREIZUEGIGKEIT: readonly string[] = [
|
||||
// EU
|
||||
"Belgien",
|
||||
"Bulgarien",
|
||||
"Dänemark",
|
||||
"Deutschland",
|
||||
"Estland",
|
||||
"Finnland",
|
||||
"Frankreich",
|
||||
"Griechenland",
|
||||
"Irland",
|
||||
"Italien",
|
||||
"Kroatien",
|
||||
"Lettland",
|
||||
"Litauen",
|
||||
"Luxemburg",
|
||||
"Malta",
|
||||
"Niederlande",
|
||||
"Österreich",
|
||||
"Polen",
|
||||
"Portugal",
|
||||
"Rumänien",
|
||||
"Schweden",
|
||||
"Slowakei",
|
||||
"Slowenien",
|
||||
"Spanien",
|
||||
"Tschechien",
|
||||
"Ungarn",
|
||||
"Zypern",
|
||||
// EWR
|
||||
"Island",
|
||||
"Liechtenstein",
|
||||
"Norwegen",
|
||||
// Bilaterale Freizügigkeit
|
||||
"Schweiz",
|
||||
];
|
||||
|
||||
/**
|
||||
* Braucht jemand mit dieser Staatsbürgerschaft einen Aufenthaltstitel?
|
||||
*
|
||||
* Bei leerer Angabe: nein. Eine fehlende Staatsbürgerschaft ist kein Anlass,
|
||||
* jemandem einen Titel abzuverlangen — sie ist ein Anlass, sie zu erfassen.
|
||||
*/
|
||||
export function brauchtAufenthaltstitel(nationality: string | null | undefined): boolean {
|
||||
const n = nationality?.trim();
|
||||
if (!n) return false;
|
||||
return !FREIZUEGIGKEIT.includes(n);
|
||||
}
|
||||
|
||||
159
lib/dashboard-data.ts
Normal file
159
lib/dashboard-data.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import type { Tx } from "./db";
|
||||
import { jsonArrayFrom, jsonObjectFrom, zeitstempel } from "./db/json";
|
||||
import { besetzungenAbfrage, pickPlacements } from "./placement";
|
||||
import { buildOrgMaps, orgMapsAbfragen, type OrgEb } from "./org";
|
||||
import { offeneStellenAbfrage, resolveOpenPositions, type OffeneStelle } from "./positions";
|
||||
import type { HistoryEventType } from "./supabase/types";
|
||||
import type { AnstehendArt } from "./dashboard-filter";
|
||||
|
||||
// Was die Übersichtsseite liest — in zwei Rundreisen statt in dreizehn.
|
||||
//
|
||||
// Hier und nicht in der Seite, damit sich die Zahl der Rundreisen messen und
|
||||
// das Ergebnis gegen den alten Weg halten lässt, ohne eine React-Komponente
|
||||
// aufzubauen.
|
||||
|
||||
export type DashboardParams = {
|
||||
userId: string | null;
|
||||
today: string;
|
||||
yearStart: string;
|
||||
yearEnd: string;
|
||||
bisIso: string;
|
||||
arten: AnstehendArt[];
|
||||
};
|
||||
|
||||
export async function loadDashboardData(tx: Tx, p: DashboardParams) {
|
||||
const { userId, today, yearStart, yearEnd, bisIso, arten } = p;
|
||||
const zeigt = (art: AnstehendArt) => arten.includes(art);
|
||||
// Elf Abfragen standen hier in einem Promise.all, das keine
|
||||
// Gleichzeitigkeit war: eine Transaktion hängt an einer Verbindung, und
|
||||
// über eine Verbindung laufen Abfragen nacheinander. Bei rund 36 ms
|
||||
// Umlaufzeit war das eine knappe Sekunde Warten für Daten, die zusammen
|
||||
// keine 200 kB wiegen. Jetzt: eine Rundreise, und eine zweite für die
|
||||
// offenen Stellen, deren zweite Hälfte vom Ergebnis der ersten abhängt.
|
||||
// Der Weg dahin steht in lib/db/json.ts.
|
||||
const countIn = (eb: OrgEb, types: readonly HistoryEventType[]) =>
|
||||
eb
|
||||
.selectFrom("employee_history")
|
||||
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
|
||||
.where("event_type", "in", [...types])
|
||||
.where("event_date", ">=", yearStart)
|
||||
.where("event_date", "<=", yearEnd);
|
||||
|
||||
const g = await tx
|
||||
.selectNoFrom((eb) => [
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("hire_drafts")
|
||||
.select(["id", "step", "payload"])
|
||||
.select((x) => zeitstempel(x.ref("updated_at")).as("updated_at"))
|
||||
.where("created_by", "=", userId ?? "")
|
||||
.orderBy("updated_at", "desc")
|
||||
).as("drafts"),
|
||||
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("employees")
|
||||
.select(["id", "weekly_hours", "entry_date", "exit_date", "karenz_start_date", "karenz_return_date"])
|
||||
.orderBy("id")
|
||||
).as("staffRows"),
|
||||
|
||||
// Entries/exits count history events, which is what the linked report
|
||||
// counts too. `entry_date` would also sweep up rehires, whose event is
|
||||
// logged as 'Wiedereintritt' — the tile and its destination then showed
|
||||
// different numbers for the same year.
|
||||
jsonObjectFrom(countIn(eb, ["Eintritt", "Wiedereintritt"])).as("hiresYtd"),
|
||||
jsonObjectFrom(countIn(eb, ["Austritt"])).as("exitsYtd"),
|
||||
|
||||
...orgMapsAbfragen(eb),
|
||||
jsonArrayFrom(offeneStellenAbfrage(eb, today)).as("open"),
|
||||
jsonArrayFrom(besetzungenAbfrage(eb)).as("placementRows"),
|
||||
|
||||
// Abgewählte Arten werden gar nicht erst gelesen — die Karte zeigt sie
|
||||
// ohnehin nicht, und eine Teilabfrage, deren Ergebnis niemand ansieht,
|
||||
// ist eine Teilabfrage zu viel.
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("employees")
|
||||
.select(["id", "first_name", "last_name", "entry_date"])
|
||||
.where("status", "=", "Geplant")
|
||||
.where("entry_date", ">=", today)
|
||||
.where("entry_date", "<=", bisIso)
|
||||
.where((e) => e.lit(zeigt("hire")))
|
||||
).as("upcomingHires"),
|
||||
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("employees")
|
||||
.select(["id", "first_name", "last_name", "exit_date"])
|
||||
.where("exit_date", "is not", null)
|
||||
.where("exit_date", ">=", today)
|
||||
.where("exit_date", "<=", bisIso)
|
||||
.where((e) => e.lit(zeigt("exit")))
|
||||
).as("upcomingExits"),
|
||||
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("employees")
|
||||
.select(["id", "first_name", "last_name", "karenz_return_date"])
|
||||
.where("status", "=", "Karenz")
|
||||
.where("karenz_return_date", "is not", null)
|
||||
.where("karenz_return_date", ">=", today)
|
||||
.where("karenz_return_date", "<=", bisIso)
|
||||
.where((e) => e.lit(zeigt("return")))
|
||||
).as("upcomingReturns"),
|
||||
|
||||
// Wiedervorlagen. Sie verhalten sich anders als der Rest dieser Karte:
|
||||
// ein Eintritt am Montag ist am Dienstag vorbei, eine Wiedervorlage
|
||||
// nicht. Deshalb gibt es hier **keine untere Grenze** — was fällig war
|
||||
// und nicht abgehakt wurde, bleibt stehen, bis jemand „Erledigt" klickt.
|
||||
// Eine Aufgabe, die von selbst aus der Liste rutscht, ist eine
|
||||
// vergessene Aufgabe.
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("employee_notes as n")
|
||||
.innerJoin("employees as e", "e.id", "n.employee_id")
|
||||
.select(["n.id", "n.employee_id", "n.due_date", "n.category", "n.note_text", "e.first_name", "e.last_name"])
|
||||
.where("n.done", "=", false)
|
||||
.where("n.due_date", "is not", null)
|
||||
.where("n.due_date", "<=", bisIso)
|
||||
.where((x) => x.lit(zeigt("note")))
|
||||
.orderBy("n.due_date")
|
||||
).as("upcomingNotes"),
|
||||
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("employee_history as h")
|
||||
.leftJoin("employees as e", "e.id", "h.employee_id")
|
||||
.select([
|
||||
"h.id",
|
||||
"h.employee_id",
|
||||
"h.event_date",
|
||||
"h.event_type",
|
||||
"h.description",
|
||||
"e.first_name",
|
||||
"e.last_name",
|
||||
])
|
||||
.orderBy("h.event_date", "desc")
|
||||
.orderBy("h.created_at", "desc")
|
||||
.limit(10)
|
||||
).as("history"),
|
||||
])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const orgMaps = buildOrgMaps(g.units as never, g.locations as never);
|
||||
|
||||
return {
|
||||
drafts: userId ? g.drafts : [],
|
||||
staffRows: g.staffRows,
|
||||
hiresYtd: Number(g.hiresYtd?.anzahl ?? 0),
|
||||
exitsYtd: Number(g.exitsYtd?.anzahl ?? 0),
|
||||
openPositions: await resolveOpenPositions(tx, orgMaps, g.open as OffeneStelle[], today),
|
||||
orgMaps,
|
||||
placements: pickPlacements(g.placementRows as never, today),
|
||||
upcomingHires: g.upcomingHires,
|
||||
upcomingExits: g.upcomingExits,
|
||||
upcomingReturns: g.upcomingReturns,
|
||||
upcomingNotes: g.upcomingNotes,
|
||||
history: g.history,
|
||||
};
|
||||
}
|
||||
47
lib/dashboard-filter.ts
Normal file
47
lib/dashboard-filter.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// Was auf der Übersicht als „anstehend" gilt — Zeitraum und Art.
|
||||
//
|
||||
// Die Auswahl steht in der Adresse, nicht im Browser: die Übersicht wird auf
|
||||
// dem Server gebaut, und ein Zeitraum von 90 statt 60 Tagen bringt Zeilen ins
|
||||
// Spiel, die im Browser gar nicht liegen. Nebenbei lässt sich eine so
|
||||
// eingestellte Seite verschicken und wiederfinden.
|
||||
|
||||
export const ZEITRAEUME = [30, 60, 90, 180] as const;
|
||||
export type Zeitraum = (typeof ZEITRAEUME)[number];
|
||||
|
||||
export const STANDARD_ZEITRAUM: Zeitraum = 60;
|
||||
|
||||
export const ANSTEHEND_ARTEN = [
|
||||
{ value: "hire", label: "Eintritt" },
|
||||
{ value: "exit", label: "Austritt" },
|
||||
{ value: "return", label: "Rückkehr" },
|
||||
{ value: "note", label: "Wiedervorlage" },
|
||||
] as const;
|
||||
|
||||
export type AnstehendArt = (typeof ANSTEHEND_ARTEN)[number]["value"];
|
||||
|
||||
const ALLE_ARTEN: AnstehendArt[] = ANSTEHEND_ARTEN.map((a) => a.value);
|
||||
|
||||
/** Alles, was nicht in der Liste steht, führt auf den Standard zurück. */
|
||||
export function parseZeitraum(wert: string | undefined): Zeitraum {
|
||||
const zahl = Number(wert);
|
||||
return (ZEITRAEUME as readonly number[]).includes(zahl) ? (zahl as Zeitraum) : STANDARD_ZEITRAUM;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keine Angabe heisst „alle" — nicht „keine". Wer die Übersicht aufruft, ohne
|
||||
* etwas auszuwählen, will alles sehen; eine leere Karte wäre die falsche
|
||||
* Antwort auf eine nicht gestellte Frage. Aus demselben Grund führt auch eine
|
||||
* Auswahl, in der nur Unbekanntes steht, zurück auf alle.
|
||||
*/
|
||||
export function parseArten(wert: string | undefined): AnstehendArt[] {
|
||||
const gewaehlt = (wert ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s): s is AnstehendArt => (ALLE_ARTEN as string[]).includes(s));
|
||||
return gewaehlt.length > 0 ? gewaehlt : ALLE_ARTEN;
|
||||
}
|
||||
|
||||
/** Steht in der Adresse etwas anderes als der Standard? */
|
||||
export function istEingeschraenkt(zeitraum: Zeitraum, arten: AnstehendArt[]): boolean {
|
||||
return zeitraum !== STANDARD_ZEITRAUM || arten.length !== ALLE_ARTEN.length;
|
||||
}
|
||||
118
lib/db/index.ts
Normal file
118
lib/db/index.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import "server-only";
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { Kysely, PostgresDialect, sql, type Transaction } from "kysely";
|
||||
import { getPool } from "./pool";
|
||||
import type { Schema } from "./schema";
|
||||
|
||||
// Der einzige Weg an die Datenbank.
|
||||
//
|
||||
// ═══ Warum das keine gewöhnliche Datenbankschicht ist ═══
|
||||
//
|
||||
// Die Zugriffsrechte liegen in der Datenbank: 21 RLS-Policies rufen
|
||||
// is_hr_user() auf, und das fragt seit der Umstellung nicht mehr Supabase,
|
||||
// sondern `current_setting('app.user_id')` — eine Sitzungsvariable.
|
||||
//
|
||||
// Sitzungsvariablen hängen an der *Verbindung*, nicht an der Anfrage. Und
|
||||
// Verbindungen kommen aus einem Pool. Wird die Variable ohne Transaktion
|
||||
// gesetzt, bleibt sie an der Verbindung kleben, und die nächste Anfrage, die
|
||||
// dieselbe Verbindung zieht, läuft mit der Kennung der vorherigen Person —
|
||||
// quer über Benutzer hinweg, in einer Personaldatenbank.
|
||||
//
|
||||
// Das ist die Art Fehler, die in keinem Test auffällt, den man nicht
|
||||
// absichtlich dafür schreibt (tests/integration/session-context.test.ts tut
|
||||
// genau das). Deshalb:
|
||||
//
|
||||
// 1. Die Kysely-Instanz wird **nicht exportiert**. Wer abfragen will, muss
|
||||
// durch withUser() — und das öffnet immer eine Transaktion.
|
||||
// 2. `set_config(..., true)` — das dritte Argument bedeutet
|
||||
// transaktionslokal. Mit `false` wäre die ganze Vorsichtsmassnahme
|
||||
// wirkungslos.
|
||||
// 3. Eine ESLint-Regel verbietet den Import von `pg` und `./pool`
|
||||
// ausserhalb dieses Verzeichnisses.
|
||||
//
|
||||
// Zusätzlich verbindet sich die Anwendung mit einer Datenbankrolle **ohne**
|
||||
// BYPASSRLS. Fehlt der Kontext trotz allem, liefern die Policies nichts
|
||||
// zurück — nicht alles.
|
||||
|
||||
// Der Pool wird als Funktion übergeben, nicht als fertige Instanz: Kysely
|
||||
// ruft sie erst bei der ersten Abfrage auf. So verlangt der Import dieses
|
||||
// Moduls noch keine Zugangsdaten — siehe getPool().
|
||||
// ═══ Wie viele Rundreisen eine Anfrage kostet ═══
|
||||
//
|
||||
// Eine Transaktion hängt an einer Verbindung, und über eine Verbindung laufen
|
||||
// Abfragen nacheinander — auch die in einem Promise.all. Bei rund 36 ms
|
||||
// Umlaufzeit zur Datenbank ist die Zahl der Abfragen deshalb *die* Kennzahl
|
||||
// für die Ladezeit einer Seite, und zwar eine, die man nicht schätzen muss.
|
||||
//
|
||||
// Sie wird darum mitgezählt und im Entwicklungsbetrieb gemeldet, sobald eine
|
||||
// Transaktion viele davon braucht. Ohne diese Meldung wächst so etwas
|
||||
// unbemerkt: jede neue Kachel bringt ihre eigene Abfrage mit, und dass die
|
||||
// Seite langsamer wird, merkt man erst, wenn es alle merken.
|
||||
const zaehler = new AsyncLocalStorage<{ abfragen: number }>();
|
||||
|
||||
export function zaehleAbfragen(): { abfragen: number } | undefined {
|
||||
return zaehler.getStore();
|
||||
}
|
||||
|
||||
/** Ab wann eine Transaktion im Entwicklungsbetrieb gemeldet wird. */
|
||||
const MELDESCHWELLE = Number(process.env.DB_QUERY_WARN ?? 6);
|
||||
|
||||
const db = new Kysely<Schema>({
|
||||
dialect: new PostgresDialect({ pool: async () => getPool() }),
|
||||
log: (event) => {
|
||||
const store = zaehler.getStore();
|
||||
if (store) store.abfragen++;
|
||||
if (event.level === "error") console.error("Abfrage fehlgeschlagen:", event.error);
|
||||
},
|
||||
});
|
||||
|
||||
export type Tx = Transaction<Schema>;
|
||||
|
||||
/**
|
||||
* Führt `fn` im Namen der angegebenen Person aus.
|
||||
*
|
||||
* `userId` ist die app_users.id. Für nicht angemeldete Zugriffe null — dann
|
||||
* greift keine Policy und es kommt nichts zurück, was auch richtig ist.
|
||||
*/
|
||||
export async function withUser<T>(userId: string | null, fn: (tx: Tx) => Promise<T>): Promise<T> {
|
||||
const stand = { abfragen: 0 };
|
||||
const start = performance.now();
|
||||
try {
|
||||
return await zaehler.run(stand, () =>
|
||||
db.transaction().execute(async (tx) => {
|
||||
// Erste Anweisung der Transaktion, vor allem anderen.
|
||||
await sql`select set_config('app.user_id', ${userId ?? ""}, true)`.execute(tx);
|
||||
return fn(tx);
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
// Nur im Entwicklungsbetrieb: in der Produktion gehörte das in die
|
||||
// Ablaufverfolgung, nicht auf die Konsole.
|
||||
if (process.env.NODE_ENV !== "production" && stand.abfragen > MELDESCHWELLE) {
|
||||
console.warn(
|
||||
`[db] ${stand.abfragen} Abfragen in einer Transaktion, ${Math.round(performance.now() - start)} ms — ` +
|
||||
`sie laufen nacheinander über eine Verbindung. Bündeln: siehe lib/db/json.ts.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Für Abläufe ohne angemeldete Person — heute nur der nächtliche Lauf für
|
||||
* fällige Änderungen.
|
||||
*
|
||||
* Bewusst kein privilegierter Zugang: die Verbindung benutzt dieselbe Rolle
|
||||
* ohne BYPASSRLS. Was hier laufen darf, muss als SECURITY-DEFINER-Funktion
|
||||
* in der Datenbank stehen und dort selbst prüfen, was es tut. Ein
|
||||
* Dienstschlüssel, der RLS aushebelt, existiert nicht mehr.
|
||||
*/
|
||||
export async function asSystem<T>(fn: (tx: Tx) => Promise<T>): Promise<T> {
|
||||
return withUser(null, fn);
|
||||
}
|
||||
|
||||
/** Für Migrations- und Wartungsskripte, die ausserhalb einer Anfrage laufen. */
|
||||
export async function closeDb(): Promise<void> {
|
||||
await db.destroy();
|
||||
}
|
||||
|
||||
export { sql };
|
||||
69
lib/db/json.ts
Normal file
69
lib/db/json.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
// Kein `server-only` hier, anders als in ./index.ts und ./pool.ts: diese Datei
|
||||
// baut nur Abfragen zusammen und hält weder Verbindung noch Zugangsdaten. Mit
|
||||
// der Sperre wären die reinen Tests von lib/org.ts nicht mehr ladbar, obwohl
|
||||
// dort nur ein Baum aus Zeilen gebaut wird.
|
||||
import { sql, type Expression, type RawBuilder } from "kysely";
|
||||
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/postgres";
|
||||
|
||||
// Mehrere unabhängige Lesevorgänge in **einer** Rundreise.
|
||||
//
|
||||
// ═══ Warum das nötig ist ═══
|
||||
//
|
||||
// Eine Transaktion hängt an genau einer Verbindung, und über eine Verbindung
|
||||
// laufen Abfragen nacheinander — auch die, die in einem Promise.all stehen.
|
||||
// Der Treiber stellt sie in eine Schlange. `Promise.all` sieht nach
|
||||
// Gleichzeitigkeit aus und ist hier keine.
|
||||
//
|
||||
// Gemessen an der echten Datenbank: die Umlaufzeit beträgt rund 36 ms, zehn
|
||||
// belanglose `select 1` über eine Verbindung brauchen 343 ms, über zehn
|
||||
// Verbindungen 39 ms. Der Aufbau der Übersichtsseite — zehn Abfragen, die
|
||||
// zusammen keine 200 kB liefern — kostete so knapp eine Sekunde, fast
|
||||
// ausschliesslich Warten.
|
||||
//
|
||||
// Mehr Verbindungen sind trotzdem nicht die Antwort: der Sitzungskontext für
|
||||
// RLS gilt je Transaktion (siehe ./index.ts), und mehrere Transaktionen je
|
||||
// Anfrage vervielfachen die Verbindungen, die die Datenbank zulässt. Also
|
||||
// weniger Rundreisen statt mehr Leitungen: Postgres kann jede Teilabfrage als
|
||||
// JSON-Spalte in *einem* Ergebnis liefern.
|
||||
//
|
||||
// const { einheiten, standorte } = await tx
|
||||
// .selectNoFrom((eb) => [
|
||||
// jsonArrayFrom(eb.selectFrom("org_units").select([...])).as("einheiten"),
|
||||
// jsonArrayFrom(eb.selectFrom("locations").selectAll()).as("standorte"),
|
||||
// ])
|
||||
// .executeTakeFirstOrThrow();
|
||||
//
|
||||
// Typisiert wie jede andere Kysely-Abfrage, mit Parametern, ohne Handarbeit
|
||||
// an der Zeichenkette.
|
||||
//
|
||||
// ═══ Die eine Falle ═══
|
||||
//
|
||||
// Innerhalb von json_agg formatiert Postgres die Werte selbst, und der
|
||||
// Treiber kommt nicht mehr daran (lib/db/pool.ts stellt ihn dort auf die
|
||||
// Formen um, die die Typen beschreiben). Für die meisten Typen macht das
|
||||
// nichts — im Gegenteil:
|
||||
//
|
||||
// date → "2022-03-30" wie ausserhalb
|
||||
// numeric → 38.5 wie ausserhalb
|
||||
// uuid → Zeichenkette wie ausserhalb
|
||||
// timestamptz → "2026-08-03T12:08:06.272938+00:00"
|
||||
// ← **anders**: ausserhalb "…272Z"
|
||||
//
|
||||
// Der Unterschied ist nicht kosmetisch. Zeitstempel werden im Projekt als
|
||||
// Zeichenketten verglichen (lib/history.ts entscheidet daran, was später
|
||||
// passiert ist), und "+00:00" sortiert gegen "Z" falsch herum. Deshalb geht
|
||||
// **jede** timestamptz-Spalte in einer gebündelten Abfrage durch zeitstempel().
|
||||
|
||||
export { jsonArrayFrom, jsonObjectFrom };
|
||||
|
||||
/**
|
||||
* Eine timestamptz-Spalte in der Form, die der Treiber ausserhalb von JSON
|
||||
* liefert — ISO-8601 in UTC, auf Millisekunden gekürzt.
|
||||
*
|
||||
* Ohne das käme aus einer gebündelten Abfrage eine andere Zeichenkette als
|
||||
* aus derselben Abfrage einzeln gestellt.
|
||||
*/
|
||||
export function zeitstempel(spalte: Expression<unknown> | string): RawBuilder<string> {
|
||||
const ref = typeof spalte === "string" ? sql.ref(spalte) : spalte;
|
||||
return sql<string>`to_char(${ref} at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`;
|
||||
}
|
||||
102
lib/db/pool.ts
Normal file
102
lib/db/pool.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import "server-only";
|
||||
import { Pool, types } from "pg";
|
||||
|
||||
// ═══ Wie Werte aus der Datenbank ankommen ════════════════════════
|
||||
//
|
||||
// Der Wechsel des Zugriffswegs hat hier eine Falle hinterlassen, die kein
|
||||
// Typprüfer und keiner der Tests fangen konnte.
|
||||
//
|
||||
// Die alte API-Schicht lieferte JSON: ein `date` kam als "2026-08-03" an,
|
||||
// ein `numeric` als Zahl. Genau so steht es in lib/supabase/types.ts, und
|
||||
// darauf baut die gesamte Anwendung — Sortierungen mit localeCompare,
|
||||
// Vergleiche wie `entry_date <= stichtag`, das Ableiten des Status.
|
||||
//
|
||||
// Der `pg`-Treiber macht es anders herum: aus `date` wird ein Date-Objekt,
|
||||
// aus `numeric` eine Zeichenkette. Die Deklarationen blieben dabei
|
||||
// unverändert gültig — sie beschreiben ja nur, was der Code *glaubt*. Der
|
||||
// Fehler zeigt sich erst zur Laufzeit, und im günstigen Fall als Absturz
|
||||
// („a.date.localeCompare is not a function"). Im ungünstigen Fall gar
|
||||
// nicht: ein Datumsvergleich zwischen Date und Zeichenkette wirft nicht, er
|
||||
// liefert bloss das falsche Ergebnis.
|
||||
//
|
||||
// Deshalb wird der Treiber hier auf die Form zurückgestellt, die die Typen
|
||||
// beschreiben. Das ist die kleinere und ehrlichere Änderung, als 49
|
||||
// Abfragestellen umzuschreiben.
|
||||
//
|
||||
// Nebenbei löst es ein zweites Problem: `date` ist ein Kalendertag ohne
|
||||
// Zeitzone. Als Date-Objekt bekäme er eine — Mitternacht in der Zone des
|
||||
// Servers —, und ein Geburtsdatum verschöbe sich beim Formatieren um einen
|
||||
// Tag. Dieselbe Klasse von Fehler wie im Seed.
|
||||
types.setTypeParser(1082, (v) => v); // date → "YYYY-MM-DD", unverändert
|
||||
types.setTypeParser(1184, (v) => new Date(v).toISOString()); // timestamptz → ISO-8601 mit Z
|
||||
types.setTypeParser(1114, (v) => new Date(v + "Z").toISOString()); // timestamp ohne Zone
|
||||
types.setTypeParser(1700, (v) => Number(v)); // numeric → Zahl
|
||||
|
||||
// Bewusst *nicht* umgestellt: int8 (bigint). Es kommt nur aus count() und
|
||||
// wird überall mit Number() gelesen; als Zahl geparst verlöre es jenseits
|
||||
// von 2^53 stillschweigend an Genauigkeit.
|
||||
|
||||
// Die einzige Stelle im Projekt, die `pg` importieren darf.
|
||||
//
|
||||
// Der Grund steht in lib/db/index.ts: eine Abfrage ausserhalb von withUser()
|
||||
// läuft ohne Sitzungskontext und damit — je nachdem, was die vorherige
|
||||
// Anfrage auf derselben gepoolten Verbindung hinterlassen hat — im Namen
|
||||
// einer fremden Person. Deshalb wird der Pool nicht exportiert, sondern nur
|
||||
// die Kysely-Instanz, die ihn benutzt, und eine ESLint-Regel verbietet den
|
||||
// Import von `pg` und von dieser Datei überall sonst.
|
||||
|
||||
// Der Pool hängt am globalen Objekt, nicht nur am Modul.
|
||||
//
|
||||
// Im Entwicklungsbetrieb lädt Next.js geänderte Module neu. Ein modul-lokales
|
||||
// `let` wäre danach leer, der alte Pool aber weiterhin am Leben — mit seinen
|
||||
// Verbindungen. Nach ein paar Bearbeitungen ist das Kontingent des Anbieters
|
||||
// aufgebraucht, und die Anwendung antwortet nur noch mit „max clients
|
||||
// reached". Genau so ist sie hier stehengeblieben.
|
||||
//
|
||||
// In der Produktion gibt es kein Neuladen; dort ist die Zeile wirkungslos.
|
||||
const globalForPool = globalThis as typeof globalThis & { __alpenwerkPool?: Pool };
|
||||
|
||||
let instance: Pool | undefined = globalForPool.__alpenwerkPool;
|
||||
|
||||
/**
|
||||
* Der Verbindungspool — erst beim ersten Zugriff angelegt, nicht beim Import.
|
||||
*
|
||||
* Der Unterschied ist nicht kosmetisch: `next build` importiert jedes Route-
|
||||
* Modul, um die Seitendaten einzusammeln. Entstünde der Pool dabei, bräuchte
|
||||
* schon der Bau Zugangsdaten zur Datenbank — ein Container-Abbild liesse sich
|
||||
* in einer Baustrecke ohne Produktionsgeheimnisse nicht mehr erzeugen.
|
||||
*/
|
||||
export function getPool(): Pool {
|
||||
if (instance) return instance;
|
||||
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
throw new Error(
|
||||
"DATABASE_URL fehlt. Erwartet wird ein PostgreSQL-Verbindungsstring — " +
|
||||
"die Anwendung spricht direkt mit der Datenbank, nicht über eine API-Schicht."
|
||||
);
|
||||
}
|
||||
|
||||
instance = new Pool({
|
||||
connectionString,
|
||||
// Der Standard sind 10; bei serverseitigem Rendering hängt an jeder
|
||||
// Anfrage genau eine Transaktion, und mehr Verbindungen als die Datenbank
|
||||
// zulässt bringen nur Wartezeit an einer anderen Stelle.
|
||||
max: Number(process.env.DATABASE_POOL_MAX ?? 10),
|
||||
// Eine Anfrage, die länger braucht, ist kaputt und soll das melden statt
|
||||
// eine Verbindung zu belegen.
|
||||
statement_timeout: 20_000,
|
||||
idle_in_transaction_session_timeout: 20_000,
|
||||
connectionTimeoutMillis: 10_000,
|
||||
// Verwaltete Anbieter (Azure, RDS, Supabase) verlangen TLS; lokal nicht.
|
||||
ssl: process.env.DATABASE_SSL === "false" ? undefined : { rejectUnauthorized: false },
|
||||
});
|
||||
|
||||
// Ein Fehler auf einer Leerlaufverbindung beendet sonst den Prozess.
|
||||
instance.on("error", (err) => {
|
||||
console.error("Unerwarteter Fehler auf einer Leerlaufverbindung:", err);
|
||||
});
|
||||
|
||||
globalForPool.__alpenwerkPool = instance;
|
||||
return instance;
|
||||
}
|
||||
56
lib/db/rpc.ts
Normal file
56
lib/db/rpc.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import "server-only";
|
||||
import { sql, withUser, type Tx } from "./index";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
// Aufruf einer Datenbankfunktion.
|
||||
//
|
||||
// Die Geschäftslogik liegt in PL/pgSQL — Eintritt, Versetzung, Austritt und
|
||||
// die übrigen zehn Mutationen. Daran ändert der Wechsel des Zugriffswegs
|
||||
// nichts: es fällt nur die API-Schicht dazwischen weg. Aufgerufen wird die
|
||||
// Funktion jetzt unmittelbar, innerhalb der Transaktion, in der auch der
|
||||
// Sitzungskontext gilt — ohne den würde require_hr_admin() darin abweisen.
|
||||
|
||||
export type MutationFn = keyof Database["public"]["Functions"];
|
||||
|
||||
/**
|
||||
* Ruft `fn(payload)` innerhalb der laufenden Transaktion auf.
|
||||
*
|
||||
* `payload` weglassen für die Funktionen ohne Argument —
|
||||
* apply_due_pending_changes() ist die einzige. Mit einem jsonb-Argument
|
||||
* aufgerufen fände Postgres keine passende Signatur.
|
||||
*/
|
||||
export async function callFunction(tx: Tx, fn: MutationFn, payload?: Record<string, unknown>): Promise<unknown> {
|
||||
// Der Funktionsname stammt aus einer geschlossenen Aufzählung, nie aus
|
||||
// einer Eingabe — sonst wäre die Verkettung hier eine Einladung.
|
||||
const name = sql.raw(`"${fn}"`);
|
||||
const query =
|
||||
payload === undefined
|
||||
? sql<{ result: unknown }>`select ${name}() as result`
|
||||
: sql<{ result: unknown }>`select ${name}(${sql.val(JSON.stringify(payload))}::jsonb) as result`;
|
||||
const result = await query.execute(tx);
|
||||
return result.rows[0]?.result;
|
||||
}
|
||||
|
||||
export type ActionResult = { success: boolean; error?: string };
|
||||
|
||||
/**
|
||||
* Eine Mutation im Namen der angemeldeten Person, mit der üblichen
|
||||
* Fehlerbehandlung für Server Actions.
|
||||
*
|
||||
* Die Prüfung der Berechtigung passiert in der Funktion selbst
|
||||
* (require_hr_admin) und unabhängig davon in den RLS-Policies — nicht hier.
|
||||
*/
|
||||
export async function runMutation(
|
||||
userId: string | null,
|
||||
fn: MutationFn,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
await withUser(userId, (tx) => callFunction(tx, fn, payload));
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
// Die Meldungen der Funktionen sind für die Oberfläche geschrieben
|
||||
// („Diese Planstelle ist bereits besetzt.") und werden durchgereicht.
|
||||
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
||||
}
|
||||
}
|
||||
49
lib/db/schema.ts
Normal file
49
lib/db/schema.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { ColumnType } from "kysely";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
// Die Tabellenform für Kysely, abgeleitet aus der bestehenden
|
||||
// Schemabeschreibung — nicht daneben gestellt.
|
||||
//
|
||||
// Zwei Beschreibungen desselben Schemas driften auseinander, und die eine
|
||||
// hier ist bereits gegen die Migrationen abgesichert: `npm run types:check`
|
||||
// vergleicht lib/supabase/types.ts Spalte für Spalte mit
|
||||
// supabase/migrations/*.sql und schlägt in der CI fehl, wenn etwas fehlt.
|
||||
// Diese Ableitung erbt diese Absicherung.
|
||||
//
|
||||
// Die Datei heisst noch lib/supabase/types.ts, weil sie aus der Zeit stammt,
|
||||
// als PostgREST der Zugriffsweg war. Sie beschreibt reines PostgreSQL und
|
||||
// wird beim Entfernen der Supabase-Pakete lediglich umbenannt.
|
||||
|
||||
type Tables = Database["public"]["Tables"];
|
||||
|
||||
/**
|
||||
* Row/Insert/Update einer Tabelle in Kyselys ColumnType-Form.
|
||||
*
|
||||
* Kysely braucht die drei Richtungen getrennt: was beim Lesen herauskommt,
|
||||
* was beim Einfügen erlaubt ist (Spalten mit Vorgabewert dürfen fehlen) und
|
||||
* was beim Aktualisieren erlaubt ist.
|
||||
*/
|
||||
type Columns<T extends keyof Tables> = {
|
||||
[K in keyof Tables[T]["Row"]]: K extends keyof Tables[T]["Insert"]
|
||||
? ColumnType<
|
||||
Tables[T]["Row"][K],
|
||||
Tables[T]["Insert"][K],
|
||||
K extends keyof Tables[T]["Update"] ? Tables[T]["Update"][K] : never
|
||||
>
|
||||
: // Spalten, die es nur beim Lesen gibt (von Triggern gesetzt).
|
||||
ColumnType<Tables[T]["Row"][K], never, never>;
|
||||
};
|
||||
|
||||
export type DB = { [T in keyof Tables]: Columns<T> };
|
||||
|
||||
/** Die Tabelle, die auth.users ablöst. Noch nicht in der Alt-Beschreibung. */
|
||||
export type AppUsersTable = {
|
||||
id: ColumnType<string, string | undefined, never>;
|
||||
external_id: string;
|
||||
email: string;
|
||||
full_name: ColumnType<string | null, string | null | undefined, string | null>;
|
||||
created_at: ColumnType<string, string | undefined, never>;
|
||||
last_seen_at: ColumnType<string | null, string | null | undefined, string | null>;
|
||||
};
|
||||
|
||||
export type Schema = DB & { app_users: AppUsersTable };
|
||||
17
lib/dienstwagen.ts
Normal file
17
lib/dienstwagen.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { DienstwagenArt } from "./supabase/types";
|
||||
|
||||
// Wie ein Dienstwagen benannt wird.
|
||||
//
|
||||
// Gespeichert steht „Elektro"; gesprochen wird von einem E-KFZ, und danach
|
||||
// wird auch gefragt. Die Beschriftung stand an drei Stellen einzeln — im
|
||||
// Auswahlfeld, in der Zusammenfassung des Einstellungsassistenten und auf dem
|
||||
// Vertragsblatt. Drei Kopien einer Beschriftung laufen auseinander, und dann
|
||||
// heisst dasselbe Auto je nach Bildschirm anders.
|
||||
|
||||
export function dienstwagenLabel(art: DienstwagenArt | null | undefined): string {
|
||||
if (art === "Elektro") return "E-KFZ";
|
||||
if (art === "Verbrenner") return "Verbrenner";
|
||||
// Der CHECK erlaubt es nicht, aber gelesen wird auch, was jemand über den
|
||||
// Import hineingelegt hat.
|
||||
return art ?? "–";
|
||||
}
|
||||
137
lib/employee-detail-data.ts
Normal file
137
lib/employee-detail-data.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { Tx } from "./db";
|
||||
import { kontierungZum, kontierungenAbfrage, type KontierungsZeile } from "./cost-centers";
|
||||
import { jsonArrayFrom, zeitstempel } from "./db/json";
|
||||
import { todayIso } from "./format";
|
||||
import { buildOrgMaps, orgMapsAbfragen } from "./org";
|
||||
import { berichtslinienAbfrage, besetzungenAbfrage, orgAsOf, pickPlacements } from "./placement";
|
||||
import { offeneStellenAbfrage, resolveOpenPositions, type OffeneStelle } from "./positions";
|
||||
|
||||
// Was die Mitarbeiterakte liest — in vier Rundreisen statt in zwölf.
|
||||
//
|
||||
// Vorher: die Person, dann acht Dinge in einem Promise.all, dann die Namen der
|
||||
// Beteiligten. Das Promise.all war keine Gleichzeitigkeit — eine Transaktion
|
||||
// hängt an einer Verbindung (lib/db/json.ts). Bei rund 36 ms Umlaufzeit war
|
||||
// diese Seite damit die teuerste der Anwendung.
|
||||
//
|
||||
// Die drei Tabellen mit `selectAll()` stehen hier ausgeschrieben. Das ist
|
||||
// keine Umständlichkeit: innerhalb von JSON formatiert Postgres timestamptz
|
||||
// anders als der Treiber sonst, und `created_at` entscheidet in lib/history.ts
|
||||
// darüber, was später passiert ist. Ausgeschrieben ist sichtbar, welche Spalte
|
||||
// durch zeitstempel() geht — bei selectAll() wäre es unsichtbar falsch.
|
||||
|
||||
export async function loadEmployeeDetail(tx: Tx, id: string, today: string = todayIso()) {
|
||||
// Die Person zuerst, allein: erst aus Eintritt und Austritt ergibt sich der
|
||||
// Stichtag, zu dem ihre Organisation überhaupt eine Antwort hat. Eine
|
||||
// Person, die am 01.09. anfängt, hat heute keine laufende Besetzung — zu
|
||||
// heute gefragt lieferte om_reporting_lines() nichts, und die Akte
|
||||
// behauptete „Keine Führungskraft", obwohl das Team eine hat.
|
||||
const employee = await tx.selectFrom("employees").selectAll().where("id", "=", id).executeTakeFirst();
|
||||
if (!employee) return null;
|
||||
|
||||
const asOf = orgAsOf(employee, today);
|
||||
|
||||
const g = await tx
|
||||
.selectNoFrom((eb) => [
|
||||
// Vorgesetzte und direkte Berichte stehen nirgends als Spalte — sie
|
||||
// kommen aus om_reporting_lines(). Beide schränken *in* der Funktion
|
||||
// ein, es wandern also neun Zeilen über die Leitung und nicht achthundert.
|
||||
jsonArrayFrom(berichtslinienAbfrage(eb, asOf, { employeeId: id })).as("ownLines"),
|
||||
jsonArrayFrom(berichtslinienAbfrage(eb, asOf, { actingManagerId: id })).as("reports"),
|
||||
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("employee_history")
|
||||
.select(["id", "employee_id", "event_date", "event_type", "description", "changes", "pending_id"])
|
||||
.select((x) => zeitstempel(x.ref("created_at")).as("created_at"))
|
||||
.where("employee_id", "=", id)
|
||||
.orderBy("event_date", "desc")
|
||||
.orderBy("created_at", "desc")
|
||||
).as("history"),
|
||||
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("employee_dependents")
|
||||
.select(["id", "employee_id", "first_name", "last_name", "relationship", "sv_nummer", "birth_date"])
|
||||
.select((x) => zeitstempel(x.ref("created_at")).as("created_at"))
|
||||
.where("employee_id", "=", id)
|
||||
.orderBy("created_at")
|
||||
).as("dependents"),
|
||||
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("employee_notes")
|
||||
.select([
|
||||
"id",
|
||||
"employee_id",
|
||||
"author_user_id",
|
||||
"author_name",
|
||||
"category",
|
||||
"note_text",
|
||||
"due_date",
|
||||
"done",
|
||||
"done_by",
|
||||
])
|
||||
.select((x) => [
|
||||
zeitstempel(x.ref("created_at")).as("created_at"),
|
||||
zeitstempel(x.ref("done_at")).as("done_at"),
|
||||
])
|
||||
.where("employee_id", "=", id)
|
||||
.orderBy("created_at", "desc")
|
||||
).as("notes"),
|
||||
|
||||
...orgMapsAbfragen(eb),
|
||||
jsonArrayFrom(besetzungenAbfrage(eb, [id])).as("placementRows"),
|
||||
// Die Kontierungen der Planstellen dieser Person. Welche gilt, hängt am
|
||||
// Stichtag — und der ergibt sich erst aus Eintritt und Austritt, steht
|
||||
// hier also schon fest.
|
||||
jsonArrayFrom(
|
||||
kontierungenAbfrage(eb).where("z.position_id", "in", (e) =>
|
||||
e.selectFrom("position_assignments as pa").select("pa.position_id").where("pa.employee_id", "=", id)
|
||||
)
|
||||
).as("costRows"),
|
||||
// Die offenen Planstellen bleiben bei heute: sie gehören zur
|
||||
// Organisation, nicht zu dieser Person.
|
||||
jsonArrayFrom(offeneStellenAbfrage(eb, today)).as("open"),
|
||||
])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const orgMaps = buildOrgMaps(g.units as never, g.locations as never);
|
||||
const line = g.ownLines[0] ?? null;
|
||||
const placement = pickPlacements(g.placementRows as never, asOf).get(id) ?? null;
|
||||
|
||||
// Namen für die beteiligten Personen in einem Zug: die Vertretung, die
|
||||
// formal zuständige Leitung und die direkten Berichte.
|
||||
const relatedIds = Array.from(
|
||||
new Set(
|
||||
[line?.acting_manager_id, line?.formal_manager_id, ...g.reports.map((r) => r.employee_id)].filter(
|
||||
(x): x is string => Boolean(x)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const [relatedRows, openPositions] = await Promise.all([
|
||||
relatedIds.length
|
||||
? tx
|
||||
.selectFrom("employees")
|
||||
.select(["id", "first_name", "last_name", "job_title", "status"])
|
||||
.where("id", "in", relatedIds)
|
||||
.execute()
|
||||
: Promise.resolve([]),
|
||||
resolveOpenPositions(tx, orgMaps, g.open as OffeneStelle[], today),
|
||||
]);
|
||||
|
||||
return {
|
||||
employee,
|
||||
line,
|
||||
reports: g.reports,
|
||||
history: g.history,
|
||||
dependents: g.dependents,
|
||||
notes: g.notes,
|
||||
orgMaps,
|
||||
placement,
|
||||
// Die Kostenstelle des Sitzes, auf dem die Person am Stichtag sitzt.
|
||||
kostenstelle: placement ? (kontierungZum(g.costRows as KontierungsZeile[], asOf).get(placement.positionId) ?? null) : null,
|
||||
openPositions,
|
||||
byId: new Map(relatedRows.map((e) => [e.id, e])),
|
||||
};
|
||||
}
|
||||
35
lib/employee-search.ts
Normal file
35
lib/employee-search.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// Wie aus einer Eingabe Suchmuster werden.
|
||||
//
|
||||
// Getrennt von der Seite, weil hier die Entscheidungen stecken, die man
|
||||
// prüfen können muss: was als Wort zählt, was am Wortanfang treffen muss und
|
||||
// was von LIKE als Text und nicht als Platzhalter gelesen wird.
|
||||
//
|
||||
// Die Bedeutung selbst — „trifft am Wortanfang" — liegt im SQL der Seite:
|
||||
// verglichen wird gegen Vorname, Nachname und Position zusammengesetzt, mit
|
||||
// Trennzeichen als Wortgrenze.
|
||||
|
||||
/** Nur Ziffern? Dann ist es eine Personalnummer und kein Name. */
|
||||
export function istPersonalnummer(term: string): boolean {
|
||||
return /^\d+$/.test(term.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Je Suchwort zwei LIKE-Muster: „am Anfang" und „nach einem Leerzeichen".
|
||||
* Zusammen ergeben sie „am Anfang eines Wortes".
|
||||
*
|
||||
* Ein Wort als Teilzeichenkette zu suchen wäre einfacher und war die erste
|
||||
* Fassung — bei „Winkler H" traf das „H" dann auf Thomas, Katharina und
|
||||
* CNC-Dreher:in, also auf alle. Je kürzer die Eingabe, desto unbrauchbarer
|
||||
* das Ergebnis, und ein Anfangsbuchstabe ist die kürzeste sinnvolle Eingabe.
|
||||
*/
|
||||
export function suchMuster(term: string): string[][] {
|
||||
return term
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((wort) => {
|
||||
// Die Platzhalter von LIKE entschärfen: wer „50 %" tippt, sucht Text.
|
||||
const klein = wort.toLowerCase().replace(/[\\%_]/g, (z) => `\\${z}`);
|
||||
return [`${klein}%`, `% ${klein}%`];
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Expression, ExpressionBuilder, SqlBool } from "kysely";
|
||||
import type { Schema } from "./db/schema";
|
||||
import type { EmploymentStatus } from "./supabase/types";
|
||||
|
||||
// The SQL counterpart of deriveStatusAsOf() in lib/reports.ts.
|
||||
@@ -19,58 +21,59 @@ import type { EmploymentStatus } from "./supabase/types";
|
||||
// tests/integration/employee-status-filter.test.ts asserts the two agree
|
||||
// against a real database, which is the only place that can prove it.
|
||||
|
||||
type Filterable = {
|
||||
gt: (column: string, value: string) => Filterable;
|
||||
lte: (column: string, value: string) => Filterable;
|
||||
gte: (column: string, value: string) => Filterable;
|
||||
or: (filters: string) => Filterable;
|
||||
is: (column: string, value: null) => Filterable;
|
||||
not: (column: string, operator: string, value: null) => Filterable;
|
||||
};
|
||||
type Eb = ExpressionBuilder<Schema, "employees">;
|
||||
|
||||
/** True once the person has started and has not left yet. */
|
||||
function employed<Q extends Filterable>(query: Q, asOf: string): Q {
|
||||
return query.lte("entry_date", asOf).or(`exit_date.is.null,exit_date.gt.${asOf}`) as Q;
|
||||
function employed(eb: Eb, asOf: string): Expression<SqlBool> {
|
||||
return eb.and([eb("entry_date", "<=", asOf), eb.or([eb("exit_date", "is", null), eb("exit_date", ">", asOf)])]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrows a PostgREST query to the employees whose *derived* status on
|
||||
* `asOf` is one of `statuses`. Only the combinations the UI offers are
|
||||
* supported; anything else is left unfiltered rather than silently applying
|
||||
* a wrong one.
|
||||
* Die Bedingung für die Menge, deren *abgeleiteter* Status am Stichtag einer
|
||||
* der genannten ist — oder null, wenn nicht eingeschränkt werden soll.
|
||||
*
|
||||
* Nur die Kombinationen, die die Oberfläche anbietet, sind abgedeckt. Für
|
||||
* alles andere kommt null zurück: lieber nicht filtern als falsch filtern.
|
||||
*/
|
||||
export function applyDerivedStatusFilter<Q extends Filterable>(query: Q, statuses: EmploymentStatus[], asOf: string): Q {
|
||||
export function derivedStatusFilter(eb: Eb, statuses: EmploymentStatus[], asOf: string): Expression<SqlBool> | null {
|
||||
const wanted = new Set(statuses);
|
||||
if (wanted.size === 0) return query;
|
||||
if (wanted.size === 0) return null;
|
||||
|
||||
// A single non-employed status is a straight date comparison.
|
||||
if (wanted.size === 1 && wanted.has("Geplant")) return query.gt("entry_date", asOf) as Q;
|
||||
if (wanted.size === 1 && wanted.has("Ausgetreten")) return query.not("exit_date", "is", null).lte("exit_date", asOf) as Q;
|
||||
if (wanted.size === 1 && wanted.has("Geplant")) return eb("entry_date", ">", asOf);
|
||||
if (wanted.size === 1 && wanted.has("Ausgetreten")) {
|
||||
return eb.and([eb("exit_date", "is not", null), eb("exit_date", "<=", asOf)]);
|
||||
}
|
||||
|
||||
const wantsAktiv = wanted.has("Aktiv");
|
||||
const wantsKarenz = wanted.has("Karenz");
|
||||
|
||||
if (wantsAktiv && wantsKarenz && wanted.size === 2) {
|
||||
// Everyone employed today, whether or not they are on leave.
|
||||
return employed(query, asOf);
|
||||
}
|
||||
// Everyone employed today, whether or not they are on leave.
|
||||
if (wantsAktiv && wantsKarenz && wanted.size === 2) return employed(eb, asOf);
|
||||
|
||||
if (wantsKarenz && !wantsAktiv && wanted.size === 1) {
|
||||
return employed(query, asOf)
|
||||
.not("karenz_start_date", "is", null)
|
||||
.lte("karenz_start_date", asOf)
|
||||
.or(`karenz_return_date.is.null,karenz_return_date.gt.${asOf}`) as Q;
|
||||
return eb.and([
|
||||
employed(eb, asOf),
|
||||
eb("karenz_start_date", "is not", null),
|
||||
eb("karenz_start_date", "<=", asOf),
|
||||
eb.or([eb("karenz_return_date", "is", null), eb("karenz_return_date", ">", asOf)]),
|
||||
]);
|
||||
}
|
||||
|
||||
if (wantsAktiv && !wantsKarenz && wanted.size === 1) {
|
||||
// Employed but *not* inside a karenz window: either no start date, a
|
||||
// start still ahead, or a return that has already happened.
|
||||
return employed(query, asOf).or(
|
||||
`karenz_start_date.is.null,karenz_start_date.gt.${asOf},karenz_return_date.lte.${asOf}`
|
||||
) as Q;
|
||||
return eb.and([
|
||||
employed(eb, asOf),
|
||||
eb.or([
|
||||
eb("karenz_start_date", "is", null),
|
||||
eb("karenz_start_date", ">", asOf),
|
||||
eb("karenz_return_date", "<=", asOf),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
// Mixed selections spanning employed and non-employed states have no UI
|
||||
// path today; filtering on a guess would be worse than not filtering.
|
||||
return query;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -56,8 +56,27 @@ export function initials(firstName: string, lastName: string): string {
|
||||
return `${a}${b}`;
|
||||
}
|
||||
|
||||
// "Dr. Max Mustermann, MSc MBA" — prefix titles precede the name, suffix
|
||||
// titles follow after a comma, both space-joined in the stored order.
|
||||
// „Mustermann, Max" — der Nachname zuerst.
|
||||
//
|
||||
// So steht es in jeder Personalliste, und danach wird gesucht: wer jemanden
|
||||
// sucht, hat den Nachnamen im Kopf. Nebenbei stimmt die Anzeige damit auch
|
||||
// mit der Sortierung überein, die schon immer nach Nachnamen ging — vorher
|
||||
// las sich eine alphabetische Liste, als wäre sie ungeordnet.
|
||||
//
|
||||
// Eine Stelle für alle: der Name wurde an gut zwanzig Orten einzeln
|
||||
// zusammengesetzt, und eine Umstellung, die die Hälfte davon vergisst, ist
|
||||
// schlimmer als gar keine.
|
||||
export function fmtName(firstName: string, lastName: string): string {
|
||||
const nach = lastName?.trim() ?? "";
|
||||
const vor = firstName?.trim() ?? "";
|
||||
if (!nach) return vor;
|
||||
if (!vor) return nach;
|
||||
return `${nach}, ${vor}`;
|
||||
}
|
||||
|
||||
// "Mustermann, Dr. Max, MSc MBA" — vorangestellte Titel stehen beim
|
||||
// Vornamen, nachgestellte am Ende. Der Nachname bleibt vorne, damit auch die
|
||||
// Überschrift einer Personalakte so beginnt wie ihr Eintrag in der Liste.
|
||||
export function fmtFullName(
|
||||
firstName: string,
|
||||
lastName: string,
|
||||
@@ -66,7 +85,7 @@ export function fmtFullName(
|
||||
): string {
|
||||
const prefix = titlePrefix && titlePrefix.length > 0 ? `${titlePrefix.join(" ")} ` : "";
|
||||
const suffix = titleSuffix && titleSuffix.length > 0 ? `, ${titleSuffix.join(" ")}` : "";
|
||||
return `${prefix}${firstName} ${lastName}${suffix}`;
|
||||
return `${fmtName(`${prefix}${firstName}`, lastName)}${suffix}`;
|
||||
}
|
||||
|
||||
// Whole years between two ISO dates. Compares the "MM-DD" tails as strings,
|
||||
|
||||
136
lib/history.ts
Normal file
136
lib/history.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { AuditChange, HistoryEventType } from "./supabase/types";
|
||||
|
||||
// Welche Historieneinträge sich zurücknehmen lassen — und warum die übrigen
|
||||
// nicht.
|
||||
//
|
||||
// Dieselbe Regel steht in der Datenbank (delete_history_entry). Das ist eine
|
||||
// Doppelung, und zwar mit Absicht: die Datenbank ist die verbindliche Stelle,
|
||||
// weil sie die einzige ist, an der niemand vorbeikommt. Hier steht sie
|
||||
// nochmal, damit die Oberfläche einen Knopf nur dort zeigt, wo er auch
|
||||
// funktioniert, und daneben schreiben kann, woran es sonst liegt. Ein Knopf,
|
||||
// der erst nach dem Klick sagt „geht nicht", ist eine Falle.
|
||||
//
|
||||
// Läuft eine Seite der anderen davon, gewinnt die Datenbank: sie weist ab,
|
||||
// und die Oberfläche zeigt ihre Begründung.
|
||||
|
||||
export type KorrekturUrteil = { erlaubt: true } | { erlaubt: false; grund: string };
|
||||
|
||||
type Eintrag = {
|
||||
event_type: HistoryEventType;
|
||||
event_date: string;
|
||||
changes: AuditChange[] | null;
|
||||
/** Der geplante Vorgang, falls die Änderung noch nicht wirksam ist. */
|
||||
pending_id?: string | null;
|
||||
/** Für die Reihenfolge bei gleichem Datum. */
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
/** Die Vorgänge, die sich zurücknehmen und berichtigen lassen. */
|
||||
const KORRIGIERBAR: HistoryEventType[] = ["Stammdatenänderung", "Vertragsänderung", "Karenz", "Rückkehr"];
|
||||
|
||||
/**
|
||||
* Abwesenheit und Rückkehr werden anders zurückgenommen als der Rest: aus
|
||||
* einer geplanten Vertragsänderung nimmt man einzelne Felder heraus, eine
|
||||
* geplante Abwesenheit fällt ganz. Beides steht in delete_history_entry;
|
||||
* hier zählt nur, dass für diese beiden keine Feldwerte nötig sind.
|
||||
*/
|
||||
const OHNE_FELDWERTE: HistoryEventType[] = ["Karenz", "Rückkehr"];
|
||||
|
||||
export function darfKorrigiertWerden(eintrag: Eintrag, heute: string, alle: Eintrag[] = []): KorrekturUrteil {
|
||||
if (eintrag.event_type === "Eintritt") {
|
||||
return { erlaubt: false, grund: "Der Eintritt ist der Anfang der Zeitleiste und bleibt." };
|
||||
}
|
||||
|
||||
if (!KORRIGIERBAR.includes(eintrag.event_type)) {
|
||||
return {
|
||||
erlaubt: false,
|
||||
grund:
|
||||
"Dieser Vorgang hat Planstellen oder den Status bewegt. Zurücknehmen lässt er sich nur über den passenden " +
|
||||
"Vorgang, nicht durch Löschen der Zeile.",
|
||||
};
|
||||
}
|
||||
|
||||
// Die Reihenfolge zählt: eine Rückkehr setzt eine Abwesenheit voraus.
|
||||
// Bliebe sie stehen, während die Abwesenheit verschwindet, stünde in der
|
||||
// Akte eine Rückkehr aus dem Nichts — und der Status ergäbe sich aus einem
|
||||
// Eintrag, dessen Ausgangslage gelöscht ist.
|
||||
if (eintrag.event_type === "Karenz" && alle.some((h) => h.event_type === "Rückkehr" && spaeter(h, eintrag))) {
|
||||
return {
|
||||
erlaubt: false,
|
||||
grund: "Zu dieser Abwesenheit gibt es eine Rückkehr. Sie muss zuerst gelöscht werden.",
|
||||
};
|
||||
}
|
||||
|
||||
// Noch nicht wirksam: das geht, aber nur mit Bezug auf den geplanten
|
||||
// Vorgang. Zeilen aus der Zeit vor dieser Verknüpfung haben keinen — sie
|
||||
// liessen sich nur über Person und Datum zuordnen, und das ist nicht
|
||||
// eindeutig genug, um daraufhin eine geplante Änderung abzubrechen.
|
||||
if (eintrag.event_date > heute && !eintrag.pending_id) {
|
||||
return {
|
||||
erlaubt: false,
|
||||
grund:
|
||||
"Zu dieser geplanten Änderung ist kein Vorgang hinterlegt — sie stammt aus der Zeit vor dieser Verknüpfung. " +
|
||||
"Zurücknehmen lässt sie sich nur, indem der Vorgang selbst abgebrochen wird.",
|
||||
};
|
||||
}
|
||||
|
||||
// Eine geplante Abwesenheit hat keine Feldwerte — sie fällt als Ganzes.
|
||||
if (eintrag.event_date > heute && OHNE_FELDWERTE.includes(eintrag.event_type)) {
|
||||
return { erlaubt: true };
|
||||
}
|
||||
|
||||
if (!eintrag.changes || eintrag.changes.length === 0) {
|
||||
return {
|
||||
erlaubt: false,
|
||||
grund: "Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts, worauf zurückgesetzt werden könnte.",
|
||||
};
|
||||
}
|
||||
|
||||
return { erlaubt: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearbeiten ist weiter gefasst als Löschen.
|
||||
*
|
||||
* Der Eintritt lässt sich nicht löschen — er ist der Anfang der Zeitleiste,
|
||||
* und ohne ihn hätte die Person keinen. Sein **Datum** kann aber falsch
|
||||
* erfasst sein, und dann hängt daran mehr als eine Zahl: die erste
|
||||
* Planstellenbesetzung, der frühestmögliche Zeitpunkt jedes weiteren
|
||||
* Ereignisses, die Zugehörigkeit. Die Datenbank prüft das alles beim Ändern;
|
||||
* hier geht es nur darum, den Knopf überhaupt anzubieten.
|
||||
*/
|
||||
export function darfBearbeitetWerden(eintrag: Eintrag, heute: string, alle: Eintrag[] = []): KorrekturUrteil {
|
||||
if (eintrag.event_type === "Eintritt") return { erlaubt: true };
|
||||
return darfKorrigiertWerden(eintrag, heute, alle);
|
||||
}
|
||||
|
||||
/** Später im Sinne der Anzeige: erst das Datum, dann die Erfassungszeit. */
|
||||
function spaeter(a: Eintrag, b: Eintrag): boolean {
|
||||
if (a.event_date !== b.event_date) return a.event_date > b.event_date;
|
||||
return (a.created_at ?? "") > (b.created_at ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Was das Löschen bewirken würde: je Feld entweder Zurücksetzen oder nicht,
|
||||
* weil ein späterer Eintrag dasselbe Feld angefasst hat.
|
||||
*
|
||||
* Dient allein der Ankündigung im Bestätigungsdialog — entschieden wird es
|
||||
* in der Datenbank, an denselben Daten, im selben Augenblick.
|
||||
*/
|
||||
export type Vorschau = { feld: string; von: string | null; auf: string | null; bleibt: boolean };
|
||||
|
||||
export function loeschVorschau(
|
||||
eintrag: Eintrag & { id: string; created_at: string },
|
||||
alle: (Eintrag & { id: string; created_at: string })[]
|
||||
): Vorschau[] {
|
||||
return (eintrag.changes ?? []).map((c) => {
|
||||
const spaeter = alle.some(
|
||||
(h) =>
|
||||
h.id !== eintrag.id &&
|
||||
(h.changes ?? []).some((a) => a.feld === c.feld) &&
|
||||
(h.event_date > eintrag.event_date ||
|
||||
(h.event_date === eintrag.event_date && h.created_at > eintrag.created_at))
|
||||
);
|
||||
return { feld: c.feld, von: c.nachher, auf: c.vorher, bleibt: spaeter };
|
||||
});
|
||||
}
|
||||
336
lib/import/load.ts
Normal file
336
lib/import/load.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import "server-only";
|
||||
import { sql, type Tx } from "@/lib/db";
|
||||
import { todayIso } from "@/lib/format";
|
||||
import { deriveStatusAsOf } from "@/lib/reports";
|
||||
import { normalizeSvnr } from "@/lib/svnr";
|
||||
import type { Bestand, Datensatz, Zeile } from "./validate";
|
||||
|
||||
// Schreiben einer geprüften Datei.
|
||||
//
|
||||
// Der Aufruf steckt in **einer** Transaktion (siehe die Route). Das ist keine
|
||||
// Vorsicht, sondern die Bedingung: eine halb geladene Organisation — Bereiche
|
||||
// ohne Abteilungen, Planstellen ohne Personen — ist schlimmer als gar keine,
|
||||
// weil sie nach Daten aussieht. Bricht irgendetwas ab, war nichts.
|
||||
//
|
||||
// Angelegt wird nur; aktualisiert wird nie. Was es schon gibt, hat die
|
||||
// Prüfung vorher abgewiesen. Damit kann ein Tippfehler in einer Datei keine
|
||||
// bestehenden Personaldaten überschreiben.
|
||||
|
||||
/** Wie viele Zeilen je INSERT. Postgres verträgt 65535 Parameter je Anweisung. */
|
||||
const PORTION = 200;
|
||||
|
||||
export type Ladebericht = Record<string, number>;
|
||||
|
||||
const txt = (v: unknown): string | null => (typeof v === "string" && v !== "" ? v : null);
|
||||
const zahl = (v: unknown): number | null => (typeof v === "number" ? v : null);
|
||||
const bool = (v: unknown, ersatz: boolean): boolean => (typeof v === "boolean" ? v : ersatz);
|
||||
const liste = (v: unknown): string[] | null => (Array.isArray(v) ? (v as string[]) : null);
|
||||
|
||||
async function einfuegen<T>(tx: Tx, tabelle: string, zeilen: T[]): Promise<void> {
|
||||
for (let i = 0; i < zeilen.length; i += PORTION) {
|
||||
await tx
|
||||
// Die Tabellennamen stammen aus einer geschlossenen Aufzählung in
|
||||
// diesem Modul, nie aus der Datei.
|
||||
.insertInto(tabelle as never)
|
||||
.values(zeilen.slice(i, i + PORTION) as never)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Was bereits im System steht.
|
||||
*
|
||||
* Läuft in derselben Transaktion wie das Schreiben. Zwischen Prüfung und
|
||||
* Schreiben könnte sonst jemand dieselbe Planstelle besetzen, und der Import
|
||||
* liefe in den Teilindex statt in eine verständliche Meldung.
|
||||
*/
|
||||
export async function bestandLaden(tx: Tx): Promise<Bestand> {
|
||||
const heute = todayIso();
|
||||
|
||||
const [standorte, einheiten, jobs, stellen, besetzungen, personen] = await Promise.all([
|
||||
tx.selectFrom("locations").select(["id", "name"]).execute(),
|
||||
tx.selectFrom("org_units").select(["id", "org_number"]).execute(),
|
||||
tx.selectFrom("jobs").select(["id", "code"]).execute(),
|
||||
tx.selectFrom("om_positions").select(["id", "position_number"]).execute(),
|
||||
tx
|
||||
.selectFrom("position_assignments")
|
||||
.select(["position_id"])
|
||||
.where((eb) => eb.or([eb("valid_to", "is", null), eb("valid_to", ">=", heute)]))
|
||||
.execute(),
|
||||
tx.selectFrom("employees").select(["id", "personnel_number", "email", "sv_nummer"]).execute(),
|
||||
]);
|
||||
|
||||
const besetzt = new Set(besetzungen.map((b) => b.position_id));
|
||||
|
||||
return {
|
||||
standorte: new Map(standorte.map((l) => [l.name, l.id])),
|
||||
orgNummern: new Map(einheiten.map((o) => [o.org_number, o.id])),
|
||||
jobCodes: new Map(jobs.map((j) => [j.code, j.id])),
|
||||
planstellen: new Map(stellen.map((p) => [p.position_number, { id: p.id, besetzt: besetzt.has(p.id) }])),
|
||||
personalnummern: new Map(personen.map((e) => [e.personnel_number, e.id])),
|
||||
// Ohne Adresse gibt es nichts zu vergleichen — seit sie freiwillig ist,
|
||||
// kann sie fehlen.
|
||||
emails: new Set(personen.filter((e) => e.email).map((e) => e.email!.toLowerCase())),
|
||||
svNummern: new Set(personen.filter((e) => e.sv_nummer).map((e) => normalizeSvnr(e.sv_nummer!))),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordnet Einheiten so, dass jede nach ihrer übergeordneten kommt.
|
||||
*
|
||||
* Der Fremdschlüssel auf parent_id wird je Zeile geprüft, und eine Datei
|
||||
* darf ihre Zeilen in beliebiger Reihenfolge führen — eine Mappe, die nach
|
||||
* Bezeichnung sortiert ist, hätte sonst Pech.
|
||||
*/
|
||||
function elternZuerst(zeilen: Zeile[], bekannt: Set<string>): { sortiert: Zeile[]; ungeloest: Zeile[] } {
|
||||
const offen = [...zeilen];
|
||||
const sortiert: Zeile[] = [];
|
||||
const erledigt = new Set(bekannt);
|
||||
|
||||
let fortschritt = true;
|
||||
while (offen.length > 0 && fortschritt) {
|
||||
fortschritt = false;
|
||||
for (let i = offen.length - 1; i >= 0; i--) {
|
||||
const eltern = txt(offen[i].werte.parent_org_number);
|
||||
if (!eltern || erledigt.has(eltern)) {
|
||||
const nummer = txt(offen[i].werte.org_number);
|
||||
if (nummer) erledigt.add(nummer);
|
||||
sortiert.push(offen[i]);
|
||||
offen.splice(i, 1);
|
||||
fortschritt = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Was übrig bleibt, hängt in einem Kreis — die Prüfung fängt den einfachen
|
||||
// Fall (sich selbst übergeordnet), längere Ketten fallen hier auf.
|
||||
return { sortiert, ungeloest: offen };
|
||||
}
|
||||
|
||||
export async function laden(
|
||||
tx: Tx,
|
||||
datensatz: Datensatz,
|
||||
bestand: Bestand,
|
||||
akteur: { userId: string; name: string }
|
||||
): Promise<Ladebericht> {
|
||||
const hole = (name: string) => datensatz[name] ?? [];
|
||||
const bericht: Ladebericht = {};
|
||||
const heute = todayIso();
|
||||
|
||||
// ── Standorte ─────────────────────────────────────────────────
|
||||
const standortId = new Map(bestand.standorte);
|
||||
const neueStandorte = hole("Standorte").map((z) => ({
|
||||
name: txt(z.werte.name)!,
|
||||
country: txt(z.werte.country)!,
|
||||
}));
|
||||
if (neueStandorte.length) {
|
||||
const zurueck = await tx.insertInto("locations").values(neueStandorte).returning(["id", "name"]).execute();
|
||||
for (const r of zurueck) standortId.set(r.name, r.id);
|
||||
}
|
||||
bericht.Standorte = neueStandorte.length;
|
||||
|
||||
// ── Organisation ──────────────────────────────────────────────
|
||||
const orgId = new Map(bestand.orgNummern);
|
||||
const { sortiert, ungeloest } = elternZuerst(hole("Organisation"), new Set(orgId.keys()));
|
||||
if (ungeloest.length) {
|
||||
throw new Error(
|
||||
`Die übergeordneten Einheiten von ${ungeloest.length} Zeile(n) lassen sich nicht auflösen — vermutlich ein Kreis in der Spalte „Übergeordnet“.`
|
||||
);
|
||||
}
|
||||
for (const z of sortiert) {
|
||||
const eltern = txt(z.werte.parent_org_number);
|
||||
const r = await tx
|
||||
.insertInto("org_units")
|
||||
.values({
|
||||
org_number: txt(z.werte.org_number)!,
|
||||
name: txt(z.werte.name)!,
|
||||
unit_type: txt(z.werte.unit_type) as never,
|
||||
parent_id: eltern ? orgId.get(eltern)! : null,
|
||||
valid_from: txt(z.werte.valid_from) ?? heute,
|
||||
valid_to: txt(z.werte.valid_to),
|
||||
})
|
||||
.returning(["id", "org_number"])
|
||||
.execute();
|
||||
orgId.set(r[0].org_number, r[0].id);
|
||||
}
|
||||
bericht.Organisation = sortiert.length;
|
||||
|
||||
// ── Jobkatalog ────────────────────────────────────────────────
|
||||
const jobId = new Map(bestand.jobCodes);
|
||||
const neueJobs = hole("Jobkatalog").map((z) => ({ code: txt(z.werte.code)!, title: txt(z.werte.title)! }));
|
||||
if (neueJobs.length) {
|
||||
const zurueck = await tx.insertInto("jobs").values(neueJobs).returning(["id", "code"]).execute();
|
||||
for (const r of zurueck) jobId.set(r.code, r.id);
|
||||
}
|
||||
bericht.Jobkatalog = neueJobs.length;
|
||||
|
||||
// ── Planstellen ───────────────────────────────────────────────
|
||||
const stellenId = new Map([...bestand.planstellen].map(([nr, p]) => [nr, p.id]));
|
||||
const neueStellen = hole("Planstellen").map((z) => ({
|
||||
position_number: txt(z.werte.position_number)!,
|
||||
org_unit_id: orgId.get(txt(z.werte.org_number)!)!,
|
||||
job_id: jobId.get(txt(z.werte.job_code)!)!,
|
||||
is_chief: bool(z.werte.is_chief, false),
|
||||
valid_from: txt(z.werte.valid_from) ?? heute,
|
||||
valid_to: txt(z.werte.valid_to),
|
||||
}));
|
||||
if (neueStellen.length) {
|
||||
const zurueck = await tx
|
||||
.insertInto("om_positions")
|
||||
.values(neueStellen)
|
||||
.returning(["id", "position_number"])
|
||||
.execute();
|
||||
for (const r of zurueck) stellenId.set(r.position_number, r.id);
|
||||
}
|
||||
bericht.Planstellen = neueStellen.length;
|
||||
|
||||
// ── Personen ──────────────────────────────────────────────────
|
||||
const personId = new Map(bestand.personalnummern);
|
||||
const personenZeilen = hole("Personen");
|
||||
const besetzungen: { position_id: string; employee_id: string; valid_from: string; valid_to: string | null }[] = [];
|
||||
|
||||
for (const z of personenZeilen) {
|
||||
const w = z.werte;
|
||||
const eintritt = txt(w.entry_date)!;
|
||||
const austritt = txt(w.exit_date);
|
||||
const karenzVon = txt(w.karenz_start_date);
|
||||
const karenzBis = txt(w.karenz_return_date);
|
||||
|
||||
const werte = {
|
||||
personnel_number: zahl(w.personnel_number)!,
|
||||
first_name: txt(w.first_name)!,
|
||||
last_name: txt(w.last_name)!,
|
||||
gender: txt(w.gender) as never,
|
||||
birth_date: txt(w.birth_date)!,
|
||||
sv_nummer: txt(w.sv_nummer) ? normalizeSvnr(txt(w.sv_nummer)!) : null,
|
||||
nationality: txt(w.nationality) ?? "Österreich",
|
||||
address: txt(w.address),
|
||||
postal_code: txt(w.postal_code),
|
||||
city: txt(w.city),
|
||||
address_country: txt(w.address_country),
|
||||
email: txt(w.email)!,
|
||||
phone: txt(w.phone),
|
||||
title_prefix: liste(w.title_prefix) ?? [],
|
||||
title_suffix: liste(w.title_suffix) ?? [],
|
||||
job_title: txt(w.job_title)!,
|
||||
location_id: standortId.get(txt(w.location)!)!,
|
||||
employment_type: (txt(w.employment_type) ?? "Vollzeit") as never,
|
||||
weekly_hours: zahl(w.weekly_hours) ?? 38.5,
|
||||
// Die Prüfung hat jeden Eintrag gegen die Wochentage abgeglichen und
|
||||
// auf die Schreibweise der Datenbank gebracht; hier steht deshalb
|
||||
// sicher nur Mo…So.
|
||||
work_days: (liste(w.work_days) ?? ["Mo", "Di", "Mi", "Do", "Fr"]) as never,
|
||||
contract_type: (txt(w.contract_type) ?? "unbefristet") as never,
|
||||
contract_end_date: txt(w.contract_end_date),
|
||||
paygrade: (txt(w.paygrade) ?? "B") as never,
|
||||
collective_agreement: (txt(w.collective_agreement) ?? "Handel") as never,
|
||||
worker_type: (txt(w.worker_type) ?? "Angestellte:r") as never,
|
||||
monthly_salary_gross: zahl(w.monthly_salary_gross),
|
||||
source: (txt(w.source) ?? "Extern") as never,
|
||||
is_betriebsrat: bool(w.is_betriebsrat, false),
|
||||
has_dienstwagen: bool(w.has_dienstwagen, false),
|
||||
// Ohne Dienstwagen zwingend null, mit Dienstwagen zwingend gesetzt —
|
||||
// so verlangt es chk_dienstwagen_art. Fehlt die Angabe in der Datei,
|
||||
// gilt Verbrenner, wie im übernommenen Bestand.
|
||||
dienstwagen_art: bool(w.has_dienstwagen, false) ? (txt(w.dienstwagen_art) ?? "Verbrenner") : null,
|
||||
emergency_contact_name: txt(w.emergency_contact_name),
|
||||
emergency_contact_phone: txt(w.emergency_contact_phone),
|
||||
emergency_contact_relation: txt(w.emergency_contact_relation),
|
||||
is_laterale_fuehrung: bool(w.is_laterale_fuehrung, false),
|
||||
is_c_level: bool(w.is_c_level, false),
|
||||
has_kuendigungsschutz: bool(w.has_kuendigungsschutz, false),
|
||||
// Ohne Schutz kein Enddatum — chk_kuendigungsschutz_bis weist es sonst
|
||||
// ab, und die ganze Datei scheiterte an einer einzelnen Zeile.
|
||||
kuendigungsschutz_bis: bool(w.has_kuendigungsschutz, false) ? (txt(w.kuendigungsschutz_bis) ?? null) : null,
|
||||
entry_date: eintritt,
|
||||
exit_date: austritt,
|
||||
exit_reason: txt(w.exit_reason),
|
||||
karenz_start_date: karenzVon,
|
||||
karenz_return_date: karenzBis,
|
||||
absence_type: txt(w.absence_type),
|
||||
// Der Status wird **abgeleitet**, nicht importiert. Stünde er in der
|
||||
// Datei, könnte er den Daten widersprechen — jemand mit Austritt und
|
||||
// Status „Aktiv“ —, und die Anwendung leitet ihn ohnehin überall aus
|
||||
// denselben Datumsangaben ab.
|
||||
status: deriveStatusAsOf(
|
||||
{ entry_date: eintritt, exit_date: austritt, karenz_start_date: karenzVon, karenz_return_date: karenzBis },
|
||||
heute
|
||||
),
|
||||
};
|
||||
|
||||
// Weiterhin von Hand geschrieben, aber ohne OVERRIDING SYSTEM VALUE:
|
||||
// personnel_number ist seit 20260811100000 keine Identitätsspalte mehr,
|
||||
// sondern eine gewöhnliche Pflichtangabe — sie muss mit Loga und
|
||||
// Interflex übereinstimmen und wird deshalb überall eingegeben, nicht
|
||||
// vergeben. Für eine Identitätsspalte war das Schlüsselwort nötig; für
|
||||
// eine gewöhnliche wäre es ein Fehler.
|
||||
const spalten = Object.keys(werte);
|
||||
const r = await sql<{ id: string; personnel_number: number }>`
|
||||
insert into employees (${sql.raw(spalten.map((s) => `"${s}"`).join(", "))})
|
||||
values (${sql.join(Object.values(werte).map((v) => sql.val(v)))})
|
||||
returning id, personnel_number
|
||||
`.execute(tx);
|
||||
|
||||
personId.set(r.rows[0].personnel_number, r.rows[0].id);
|
||||
|
||||
const stelle = txt(w.position_number);
|
||||
if (stelle) {
|
||||
besetzungen.push({
|
||||
position_id: stellenId.get(stelle)!,
|
||||
employee_id: r.rows[0].id,
|
||||
valid_from: eintritt,
|
||||
// Beim Austritt endet die Besetzung — sonst gälte die Planstelle als
|
||||
// belegt und liesse sich nicht nachbesetzen.
|
||||
valid_to: austritt,
|
||||
});
|
||||
}
|
||||
}
|
||||
bericht.Personen = personenZeilen.length;
|
||||
|
||||
// Kein Fortschreiben eines Zählers mehr: es gibt keinen. Die Nummer wird
|
||||
// bei jeder Einstellung eingegeben, und die Eindeutigkeit sichert der
|
||||
// Index — beim Import wie im Assistenten.
|
||||
|
||||
await einfuegen(tx, "position_assignments", besetzungen);
|
||||
|
||||
// ── Historie und Angehörige ───────────────────────────────────
|
||||
const historie = hole("Historie").map((z) => ({
|
||||
employee_id: personId.get(zahl(z.werte.personnel_number)!)!,
|
||||
event_date: txt(z.werte.event_date)!,
|
||||
event_type: txt(z.werte.event_type)! as never,
|
||||
description: txt(z.werte.description)!,
|
||||
}));
|
||||
await einfuegen(tx, "employee_history", historie);
|
||||
bericht.Historie = historie.length;
|
||||
|
||||
const angehoerige = hole("Angehörige").map((z) => ({
|
||||
employee_id: personId.get(zahl(z.werte.personnel_number)!)!,
|
||||
first_name: txt(z.werte.first_name)!,
|
||||
last_name: txt(z.werte.last_name)!,
|
||||
relationship: txt(z.werte.relationship)!,
|
||||
birth_date: txt(z.werte.birth_date)!,
|
||||
sv_nummer: txt(z.werte.sv_nummer) ? normalizeSvnr(txt(z.werte.sv_nummer)!) : null,
|
||||
}));
|
||||
await einfuegen(tx, "employee_dependents", angehoerige);
|
||||
bericht.Angehörige = angehoerige.length;
|
||||
|
||||
// ── Protokoll ─────────────────────────────────────────────────
|
||||
// Ein Eintrag für den ganzen Vorgang, nicht einer je Zeile: 800 Zeilen
|
||||
// würden das Protokoll unlesbar machen, und der Vorgang ist ohnehin
|
||||
// untrennbar — er ist eine Transaktion.
|
||||
const zusammenfassung = Object.entries(bericht)
|
||||
.filter(([, n]) => n > 0)
|
||||
.map(([blatt, n]) => `${n} ${blatt}`)
|
||||
.join(", ");
|
||||
await tx
|
||||
.insertInto("audit_log")
|
||||
.values({
|
||||
actor_user_id: akteur.userId,
|
||||
actor_name: akteur.name,
|
||||
action: "Import",
|
||||
target_label: "Massenimport",
|
||||
details: zusammenfassung || "nichts angelegt",
|
||||
})
|
||||
.execute();
|
||||
|
||||
return bericht;
|
||||
}
|
||||
242
lib/import/parse.ts
Normal file
242
lib/import/parse.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
// Einlesen einer Importdatei — XLSX oder CSV.
|
||||
//
|
||||
// Zwei Dinge macht diese Datei und sonst nichts: aus Bytes werden benannte
|
||||
// Blätter mit Zeilen, und aus Zellen werden verlässliche Rohwerte. Was die
|
||||
// Werte *bedeuten* dürfen, steht in schema.ts; ob sie stimmen, entscheidet
|
||||
// validate.ts. Diese Trennung ist der Grund, warum sich das Format testen
|
||||
// lässt, ohne eine Datenbank oder eine Tabellenkalkulation zu brauchen.
|
||||
|
||||
import ExcelJS from "exceljs";
|
||||
|
||||
/** Eine Zeile, mit der Nummer aus der Datei — ohne die ist ein Fehler nutzlos. */
|
||||
export type ImportRow = {
|
||||
/** Zeilennummer wie in Excel angezeigt, Kopfzeile ist 1. */
|
||||
zeile: number;
|
||||
werte: Record<string, string>;
|
||||
};
|
||||
|
||||
export type ImportSheet = {
|
||||
name: string;
|
||||
spalten: string[];
|
||||
zeilen: ImportRow[];
|
||||
};
|
||||
|
||||
export type ParseResult = {
|
||||
blaetter: ImportSheet[];
|
||||
/** Probleme beim Lesen selbst — kaputte Datei, leeres Blatt, doppelte Spalte. */
|
||||
fehler: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Trennzeichen einer CSV-Datei bestimmen.
|
||||
*
|
||||
* Deutschsprachiges Excel schreibt Semikolon, weil das Komma das
|
||||
* Dezimaltrennzeichen ist. Eine feste Annahme auf `,` liest solche Dateien
|
||||
* als eine einzige Spalte ein — und das sieht dann aus wie „die Datei hat
|
||||
* keine der erwarteten Spalten", was in die Irre führt.
|
||||
*
|
||||
* Gezählt wird nur in der Kopfzeile, und nur ausserhalb von Anführungszeichen.
|
||||
*/
|
||||
export function trennzeichenErkennen(kopfzeile: string): string {
|
||||
const kandidaten = [";", ",", "\t", "|"];
|
||||
let bestes = ";";
|
||||
let meiste = -1;
|
||||
for (const k of kandidaten) {
|
||||
let anzahl = 0;
|
||||
let inAnfuehrung = false;
|
||||
for (let i = 0; i < kopfzeile.length; i++) {
|
||||
const c = kopfzeile[i];
|
||||
if (c === '"') inAnfuehrung = !inAnfuehrung;
|
||||
else if (c === k && !inAnfuehrung) anzahl++;
|
||||
}
|
||||
if (anzahl > meiste) {
|
||||
meiste = anzahl;
|
||||
bestes = k;
|
||||
}
|
||||
}
|
||||
return bestes;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV nach RFC 4180, mit den Abweichungen, die in der Praxis vorkommen:
|
||||
* Zeilenumbrüche innerhalb von Anführungszeichen, verdoppelte
|
||||
* Anführungszeichen als Escape, CRLF wie LF.
|
||||
*
|
||||
* Eine eigene Zerlegung statt einer Bibliothek, weil genau diese drei Fälle
|
||||
* das sind, woran naive Zerlegungen scheitern — und weil ein Feld mit einem
|
||||
* Semikolon darin (eine Adresse, eine Beschreibung) sonst still die Spalten
|
||||
* verschiebt und die Zeile plausibel falsch importiert wird.
|
||||
*/
|
||||
export function csvZerlegen(text: string, trennzeichen?: string): string[][] {
|
||||
const ohneBom = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
||||
const trenner = trennzeichen ?? trennzeichenErkennen(ohneBom.split(/\r?\n/, 1)[0] ?? "");
|
||||
|
||||
const zeilen: string[][] = [];
|
||||
let feld = "";
|
||||
let zeile: string[] = [];
|
||||
let inAnfuehrung = false;
|
||||
|
||||
for (let i = 0; i < ohneBom.length; i++) {
|
||||
const c = ohneBom[i];
|
||||
|
||||
if (inAnfuehrung) {
|
||||
if (c === '"') {
|
||||
if (ohneBom[i + 1] === '"') {
|
||||
feld += '"';
|
||||
i++;
|
||||
} else {
|
||||
inAnfuehrung = false;
|
||||
}
|
||||
} else {
|
||||
feld += c;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c === '"') {
|
||||
inAnfuehrung = true;
|
||||
} else if (c === trenner) {
|
||||
zeile.push(feld);
|
||||
feld = "";
|
||||
} else if (c === "\n") {
|
||||
zeile.push(feld);
|
||||
zeilen.push(zeile);
|
||||
zeile = [];
|
||||
feld = "";
|
||||
} else if (c !== "\r") {
|
||||
feld += c;
|
||||
}
|
||||
}
|
||||
|
||||
// Letzte Zeile ohne abschliessenden Umbruch.
|
||||
if (feld !== "" || zeile.length > 0) {
|
||||
zeile.push(feld);
|
||||
zeilen.push(zeile);
|
||||
}
|
||||
return zeilen;
|
||||
}
|
||||
|
||||
/** Leerzeichen weg, doppelte innen zusammenziehen — Kopfzeilen sind selten sauber. */
|
||||
function spaltenName(roh: unknown): string {
|
||||
return String(roh ?? "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Zellwert als Zeichenkette.
|
||||
*
|
||||
* Datumswerte werden hier **nicht** interpretiert, sondern als ISO-Tag
|
||||
* ausgegeben, wenn Excel sie bereits als Datum führt. Der Rest bleibt Text
|
||||
* und wird erst in schema.ts gedeutet — dort weiss man, ob eine Spalte ein
|
||||
* Datum sein soll, und kann einen Fehler melden statt zu raten.
|
||||
*/
|
||||
function zellText(wert: ExcelJS.CellValue): string {
|
||||
if (wert === null || wert === undefined) return "";
|
||||
if (wert instanceof Date) {
|
||||
// Excel führt Datumswerte ohne Zeitzone; toISOString() würde sie über UTC
|
||||
// schieben und in Österreich einen Tag zu früh ausgeben.
|
||||
const m = String(wert.getUTCMonth() + 1).padStart(2, "0");
|
||||
const t = String(wert.getUTCDate()).padStart(2, "0");
|
||||
return `${wert.getUTCFullYear()}-${m}-${t}`;
|
||||
}
|
||||
if (typeof wert === "object") {
|
||||
const o = wert as { text?: unknown; result?: unknown; richText?: { text: string }[]; error?: unknown };
|
||||
if (Array.isArray(o.richText)) return o.richText.map((t) => t.text).join("");
|
||||
// Formelzellen: das Ergebnis zählt, nicht die Formel. Eine Fehlerzelle
|
||||
// (#NV, #WERT!) wird als Text durchgereicht und fällt in der Prüfung auf.
|
||||
if (o.error !== undefined) return String(o.error);
|
||||
if (o.result !== undefined) return zellText(o.result as ExcelJS.CellValue);
|
||||
if (o.text !== undefined) return String(o.text);
|
||||
return "";
|
||||
}
|
||||
return String(wert);
|
||||
}
|
||||
|
||||
function zeilenAusMatrix(name: string, matrix: string[][]): { blatt: ImportSheet; fehler: string[] } {
|
||||
const fehler: string[] = [];
|
||||
const kopf = (matrix[0] ?? []).map(spaltenName);
|
||||
|
||||
// Doppelte Spaltennamen: die zweite überschriebe die erste stillschweigend.
|
||||
const gesehen = new Set<string>();
|
||||
for (const s of kopf) {
|
||||
if (!s) continue;
|
||||
if (gesehen.has(s)) fehler.push(`Blatt „${name}“: Spalte „${s}“ kommt mehrfach vor.`);
|
||||
gesehen.add(s);
|
||||
}
|
||||
|
||||
const zeilen: ImportRow[] = [];
|
||||
for (let i = 1; i < matrix.length; i++) {
|
||||
const roh = matrix[i];
|
||||
const werte: Record<string, string> = {};
|
||||
let leer = true;
|
||||
for (let j = 0; j < kopf.length; j++) {
|
||||
const spalte = kopf[j];
|
||||
if (!spalte) continue;
|
||||
const wert = (roh[j] ?? "").trim();
|
||||
werte[spalte] = wert;
|
||||
if (wert !== "") leer = false;
|
||||
}
|
||||
// Leerzeilen kommen in gepflegten Dateien ständig vor (Abstand, gelöschte
|
||||
// Einträge) und sind keine Fehler.
|
||||
if (!leer) zeilen.push({ zeile: i + 1, werte });
|
||||
}
|
||||
|
||||
return { blatt: { name, spalten: kopf.filter(Boolean), zeilen }, fehler };
|
||||
}
|
||||
|
||||
/** Liest eine XLSX-Mappe; jedes Arbeitsblatt wird ein Blatt. */
|
||||
export async function xlsxLesen(daten: ArrayBuffer): Promise<ParseResult> {
|
||||
const mappe = new ExcelJS.Workbook();
|
||||
try {
|
||||
await mappe.xlsx.load(daten);
|
||||
} catch {
|
||||
return { blaetter: [], fehler: ["Die Datei liess sich nicht als Excel-Mappe lesen."] };
|
||||
}
|
||||
|
||||
const blaetter: ImportSheet[] = [];
|
||||
const fehler: string[] = [];
|
||||
|
||||
mappe.eachSheet((arbeitsblatt) => {
|
||||
const matrix: string[][] = [];
|
||||
arbeitsblatt.eachRow({ includeEmpty: true }, (zeile) => {
|
||||
const werte: string[] = [];
|
||||
// `values` ist 1-basiert und hat an Position 0 eine Lücke.
|
||||
const roh = zeile.values as ExcelJS.CellValue[];
|
||||
for (let i = 1; i < roh.length; i++) werte.push(zellText(roh[i]));
|
||||
matrix.push(werte);
|
||||
});
|
||||
if (matrix.length === 0) return;
|
||||
const { blatt, fehler: f } = zeilenAusMatrix(arbeitsblatt.name.trim(), matrix);
|
||||
blaetter.push(blatt);
|
||||
fehler.push(...f);
|
||||
});
|
||||
|
||||
return { blaetter, fehler };
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest eine CSV-Datei als *ein* Blatt.
|
||||
*
|
||||
* Den Blattnamen liefert der Dateiname, weil eine CSV keinen kennt: aus
|
||||
* „Personen.csv" wird das Blatt „Personen". Damit lassen sich mehrere CSVs
|
||||
* genau wie die Blätter einer Mappe zusammensetzen.
|
||||
*/
|
||||
export function csvLesen(text: string, dateiname: string): ParseResult {
|
||||
const matrix = csvZerlegen(text);
|
||||
if (matrix.length === 0) return { blaetter: [], fehler: [`„${dateiname}“ ist leer.`] };
|
||||
const name = dateiname.replace(/\.[^.]+$/, "").trim();
|
||||
const { blatt, fehler } = zeilenAusMatrix(name, matrix);
|
||||
return { blaetter: [blatt], fehler };
|
||||
}
|
||||
|
||||
/** Erkennt am Dateinamen, welcher Leser zuständig ist. */
|
||||
export async function dateiLesen(dateiname: string, daten: ArrayBuffer): Promise<ParseResult> {
|
||||
const endung = dateiname.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] ?? "";
|
||||
if (endung === "xlsx" || endung === "xlsm") return xlsxLesen(daten);
|
||||
if (endung === "csv" || endung === "txt") return csvLesen(new TextDecoder("utf-8").decode(daten), dateiname);
|
||||
return {
|
||||
blaetter: [],
|
||||
fehler: [`„${dateiname}“: unbekannte Dateiendung. Erwartet werden .xlsx oder .csv.`],
|
||||
};
|
||||
}
|
||||
504
lib/import/schema.ts
Normal file
504
lib/import/schema.ts
Normal file
@@ -0,0 +1,504 @@
|
||||
import { ABSENCE_TYPES } from "@/lib/absence";
|
||||
import { TITLE_PREFIXES, TITLE_SUFFIXES } from "@/lib/titles";
|
||||
|
||||
// Was in einer Importdatei stehen darf.
|
||||
//
|
||||
// Diese Datei ist die einzige Quelle für drei Dinge, die sonst
|
||||
// auseinanderlaufen: die Prüfung beim Import, die Vorlage zum Herunterladen
|
||||
// und die Hilfetexte in der Oberfläche. Eine Spalte, die hier nicht steht,
|
||||
// gibt es nirgends — und eine, die hier dazukommt, taucht überall auf.
|
||||
//
|
||||
// Die Blattnamen sind zugleich die Dateinamen für den CSV-Weg: „Personen.csv"
|
||||
// wird als Blatt „Personen" gelesen.
|
||||
|
||||
export type FeldArt =
|
||||
| { art: "text" }
|
||||
| { art: "datum" }
|
||||
| { art: "zahl" }
|
||||
| { art: "ganzzahl" }
|
||||
| { art: "janein" }
|
||||
| { art: "liste"; werte?: readonly string[] }
|
||||
| { art: "auswahl"; werte: readonly string[] };
|
||||
|
||||
export type Spalte = {
|
||||
/** Überschrift in der Datei. */
|
||||
name: string;
|
||||
/** Feld im Datensatz, den der Lader schreibt. */
|
||||
ziel: string;
|
||||
pflicht: boolean;
|
||||
typ: FeldArt;
|
||||
hinweis: string;
|
||||
beispiel: string;
|
||||
};
|
||||
|
||||
export type BlattSchema = {
|
||||
name: string;
|
||||
zweck: string;
|
||||
/**
|
||||
* Spalte, an der eine Zeile erkennbar ist. Doppelte Werte darin sind ein
|
||||
* Fehler — sonst überschriebe die zweite Zeile die erste, und welche
|
||||
* gewinnt, hinge an der Reihenfolge in der Datei.
|
||||
*/
|
||||
schluessel: string | null;
|
||||
spalten: Spalte[];
|
||||
};
|
||||
|
||||
const WOCHENTAGE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"] as const;
|
||||
const VERHAELTNIS = ["Ehepartner:in", "Lebenspartner:in", "Kind", "Sonstige"] as const;
|
||||
const EREIGNIS = [
|
||||
"Eintritt",
|
||||
"Beförderung",
|
||||
"Versetzung",
|
||||
"Karenz",
|
||||
"Vertragsänderung",
|
||||
"Stammdatenänderung",
|
||||
"Austritt",
|
||||
"Wiedereintritt",
|
||||
"Reorganisation",
|
||||
"Gehaltsanpassung",
|
||||
"Rückkehr",
|
||||
] as const;
|
||||
|
||||
export const BLATT_STANDORTE: BlattSchema = {
|
||||
name: "Standorte",
|
||||
zweck: "Betriebsstätten, auf die sich Personen beziehen.",
|
||||
schluessel: "Bezeichnung",
|
||||
spalten: [
|
||||
{ name: "Bezeichnung", ziel: "name", pflicht: true, typ: { art: "text" }, hinweis: "Eindeutig.", beispiel: "Wien" },
|
||||
{
|
||||
name: "Land",
|
||||
ziel: "country",
|
||||
pflicht: true,
|
||||
typ: { art: "text" },
|
||||
hinweis: "Ausgeschrieben. Steuert unter anderem, ob eine SV-Nummer verlangt wird.",
|
||||
beispiel: "Österreich",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const BLATT_ORGANISATION: BlattSchema = {
|
||||
name: "Organisation",
|
||||
zweck: "Der Aufbau: Gesellschaft, Bereiche, Abteilungen, Teams.",
|
||||
schluessel: "Orgnummer",
|
||||
spalten: [
|
||||
{
|
||||
name: "Orgnummer",
|
||||
ziel: "org_number",
|
||||
pflicht: true,
|
||||
typ: { art: "text" },
|
||||
hinweis: "Eindeutig. Wird von Planstellen als Verweis benutzt.",
|
||||
beispiel: "10000001",
|
||||
},
|
||||
{ name: "Bezeichnung", ziel: "name", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Produktion" },
|
||||
{
|
||||
name: "Art",
|
||||
ziel: "unit_type",
|
||||
pflicht: true,
|
||||
typ: { art: "auswahl", werte: ["Gesellschaft", "Bereich", "Abteilung", "Team"] },
|
||||
hinweis: "Genau eine Zeile darf „Gesellschaft“ sein — sie ist die Wurzel.",
|
||||
beispiel: "Bereich",
|
||||
},
|
||||
{
|
||||
name: "Übergeordnet",
|
||||
ziel: "parent_org_number",
|
||||
pflicht: false,
|
||||
typ: { art: "text" },
|
||||
hinweis: "Orgnummer der übergeordneten Einheit. Nur bei „Gesellschaft“ leer.",
|
||||
beispiel: "10000000",
|
||||
},
|
||||
{
|
||||
name: "Gültig ab",
|
||||
ziel: "valid_from",
|
||||
pflicht: false,
|
||||
typ: { art: "datum" },
|
||||
hinweis: "Leer = heute.",
|
||||
beispiel: "01.01.2020",
|
||||
},
|
||||
{
|
||||
name: "Gültig bis",
|
||||
ziel: "valid_to",
|
||||
pflicht: false,
|
||||
typ: { art: "datum" },
|
||||
hinweis: "Leer = unbefristet.",
|
||||
beispiel: "",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const BLATT_JOBKATALOG: BlattSchema = {
|
||||
name: "Jobkatalog",
|
||||
zweck: "Die Tätigkeiten, auf die Planstellen verweisen.",
|
||||
schluessel: "Jobcode",
|
||||
spalten: [
|
||||
{ name: "Jobcode", ziel: "code", pflicht: true, typ: { art: "text" }, hinweis: "Eindeutig.", beispiel: "50000123" },
|
||||
{
|
||||
name: "Bezeichnung",
|
||||
ziel: "title",
|
||||
pflicht: true,
|
||||
typ: { art: "text" },
|
||||
hinweis: "",
|
||||
beispiel: "Maschinenführer:in",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const BLATT_PLANSTELLEN: BlattSchema = {
|
||||
name: "Planstellen",
|
||||
zweck: "Die Stellen selbst — unabhängig davon, wer sie besetzt.",
|
||||
schluessel: "Planstellennummer",
|
||||
spalten: [
|
||||
{
|
||||
name: "Planstellennummer",
|
||||
ziel: "position_number",
|
||||
pflicht: true,
|
||||
typ: { art: "text" },
|
||||
hinweis: "Eindeutig. Personen verweisen darauf.",
|
||||
beispiel: "60000124",
|
||||
},
|
||||
{
|
||||
name: "Orgnummer",
|
||||
ziel: "org_number",
|
||||
pflicht: true,
|
||||
typ: { art: "text" },
|
||||
hinweis: "Muss im Blatt „Organisation“ oder bereits im System stehen.",
|
||||
beispiel: "10000001",
|
||||
},
|
||||
{
|
||||
name: "Jobcode",
|
||||
ziel: "job_code",
|
||||
pflicht: true,
|
||||
typ: { art: "text" },
|
||||
hinweis: "Muss im Blatt „Jobkatalog“ oder bereits im System stehen.",
|
||||
beispiel: "50000123",
|
||||
},
|
||||
{
|
||||
name: "Leitung",
|
||||
ziel: "is_chief",
|
||||
pflicht: false,
|
||||
typ: { art: "janein" },
|
||||
hinweis: "Ja = Leitungsstelle der Einheit. Höchstens eine je Einheit.",
|
||||
beispiel: "nein",
|
||||
},
|
||||
{ name: "Gültig ab", ziel: "valid_from", pflicht: false, typ: { art: "datum" }, hinweis: "Leer = heute.", beispiel: "01.01.2020" },
|
||||
{ name: "Gültig bis", ziel: "valid_to", pflicht: false, typ: { art: "datum" }, hinweis: "", beispiel: "" },
|
||||
],
|
||||
};
|
||||
|
||||
export const BLATT_PERSONEN: BlattSchema = {
|
||||
name: "Personen",
|
||||
zweck: "Stammdaten, Vertrag und die Planstelle, auf der die Person sitzt.",
|
||||
schluessel: "Personalnummer",
|
||||
spalten: [
|
||||
{
|
||||
name: "Personalnummer",
|
||||
ziel: "personnel_number",
|
||||
pflicht: true,
|
||||
typ: { art: "ganzzahl" },
|
||||
hinweis: "Eindeutig. Historie und Angehörige verweisen darauf.",
|
||||
beispiel: "2219",
|
||||
},
|
||||
{ name: "Vorname", ziel: "first_name", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Sabine" },
|
||||
{ name: "Nachname", ziel: "last_name", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Aigner" },
|
||||
{
|
||||
name: "Geschlecht",
|
||||
ziel: "gender",
|
||||
pflicht: true,
|
||||
typ: { art: "auswahl", werte: ["m", "w"] },
|
||||
hinweis: "Wird für die SV-Nummer und die Anrede gebraucht.",
|
||||
beispiel: "w",
|
||||
},
|
||||
{ name: "Geburtsdatum", ziel: "birth_date", pflicht: true, typ: { art: "datum" }, hinweis: "", beispiel: "15.08.1968" },
|
||||
{
|
||||
name: "SV-Nummer",
|
||||
ziel: "sv_nummer",
|
||||
pflicht: false,
|
||||
typ: { art: "text" },
|
||||
hinweis: "Zehnstellig. Prüfziffer und Geburtsdatum müssen zusammenpassen.",
|
||||
beispiel: "7960 150868",
|
||||
},
|
||||
{
|
||||
name: "Staatsbürgerschaft",
|
||||
ziel: "nationality",
|
||||
pflicht: false,
|
||||
typ: { art: "text" },
|
||||
hinweis: "Leer = Österreich.",
|
||||
beispiel: "Österreich",
|
||||
},
|
||||
{
|
||||
name: "Private E-Mail",
|
||||
ziel: "email",
|
||||
pflicht: false,
|
||||
typ: { art: "text" },
|
||||
hinweis: "Freiwillig. Eindeutig, wenn angegeben.",
|
||||
beispiel: "s.aigner@example.at",
|
||||
},
|
||||
{ name: "Private Telefonnummer", ziel: "phone", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "+43 660 1234567" },
|
||||
{ name: "Adresse", ziel: "address", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "Hauptstraße 1" },
|
||||
{ name: "PLZ", ziel: "postal_code", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "1010" },
|
||||
{ name: "Ort", ziel: "city", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "Wien" },
|
||||
{ name: "Land", ziel: "address_country", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "Österreich" },
|
||||
{
|
||||
name: "Titel vorangestellt",
|
||||
ziel: "title_prefix",
|
||||
pflicht: false,
|
||||
// Feste Liste, weil die Datenbank eine CHECK-Bedingung darauf hat.
|
||||
// Ohne die Aufzählung hier bräche der Import erst beim Schreiben ab.
|
||||
typ: { art: "liste", werte: TITLE_PREFIXES },
|
||||
hinweis: "Mehrere mit Semikolon. In einer CSV die Zelle in Anführungszeichen setzen.",
|
||||
beispiel: "Mag.",
|
||||
},
|
||||
{
|
||||
name: "Titel nachgestellt",
|
||||
ziel: "title_suffix",
|
||||
pflicht: false,
|
||||
typ: { art: "liste", werte: TITLE_SUFFIXES },
|
||||
hinweis: "Mehrere mit Semikolon.",
|
||||
beispiel: "MSc",
|
||||
},
|
||||
{
|
||||
name: "Tätigkeit",
|
||||
ziel: "job_title",
|
||||
pflicht: true,
|
||||
typ: { art: "text" },
|
||||
hinweis: "Die Bezeichnung der Person; kann von der Planstelle abweichen.",
|
||||
beispiel: "Projektingenieur:in",
|
||||
},
|
||||
{
|
||||
name: "Standort",
|
||||
ziel: "location",
|
||||
pflicht: true,
|
||||
typ: { art: "text" },
|
||||
hinweis: "Muss im Blatt „Standorte“ oder bereits im System stehen.",
|
||||
beispiel: "Wien",
|
||||
},
|
||||
{
|
||||
name: "Planstellennummer",
|
||||
ziel: "position_number",
|
||||
pflicht: true,
|
||||
typ: { art: "text" },
|
||||
hinweis: "Muss im Blatt „Planstellen“ oder bereits im System stehen und frei sein.",
|
||||
beispiel: "60000124",
|
||||
},
|
||||
{ name: "Eintritt", ziel: "entry_date", pflicht: true, typ: { art: "datum" }, hinweis: "", beispiel: "01.03.2015" },
|
||||
{
|
||||
name: "Austritt",
|
||||
ziel: "exit_date",
|
||||
pflicht: false,
|
||||
typ: { art: "datum" },
|
||||
hinweis: "Leer = weiterhin beschäftigt. Muss nach dem Eintritt liegen.",
|
||||
beispiel: "",
|
||||
},
|
||||
{ name: "Austrittsgrund", ziel: "exit_reason", pflicht: false, typ: { art: "text" }, hinweis: "Pflicht, wenn ein Austritt steht.", beispiel: "" },
|
||||
{
|
||||
name: "Beschäftigung",
|
||||
ziel: "employment_type",
|
||||
pflicht: false,
|
||||
typ: { art: "auswahl", werte: ["Vollzeit", "Teilzeit"] },
|
||||
hinweis: "Leer = Vollzeit.",
|
||||
beispiel: "Vollzeit",
|
||||
},
|
||||
{ name: "Wochenstunden", ziel: "weekly_hours", pflicht: false, typ: { art: "zahl" }, hinweis: "Leer = 38,5.", beispiel: "38,5" },
|
||||
{
|
||||
name: "Arbeitstage",
|
||||
ziel: "work_days",
|
||||
pflicht: false,
|
||||
typ: { art: "liste", werte: WOCHENTAGE },
|
||||
hinweis: "Leer = Mo;Di;Mi;Do;Fr.",
|
||||
beispiel: "Mo;Di;Mi;Do;Fr",
|
||||
},
|
||||
{
|
||||
name: "Vertragsart",
|
||||
ziel: "contract_type",
|
||||
pflicht: false,
|
||||
typ: { art: "auswahl", werte: ["unbefristet", "befristet"] },
|
||||
hinweis: "Leer = unbefristet.",
|
||||
beispiel: "unbefristet",
|
||||
},
|
||||
{
|
||||
name: "Befristet bis",
|
||||
ziel: "contract_end_date",
|
||||
pflicht: false,
|
||||
typ: { art: "datum" },
|
||||
hinweis: "Pflicht bei „befristet“, sonst leer.",
|
||||
beispiel: "",
|
||||
},
|
||||
{
|
||||
name: "Verwendungsgruppe",
|
||||
ziel: "paygrade",
|
||||
pflicht: false,
|
||||
typ: { art: "auswahl", werte: ["A", "B", "C", "D", "E", "F"] },
|
||||
hinweis: "Leer = B.",
|
||||
beispiel: "C",
|
||||
},
|
||||
{
|
||||
name: "Kollektivvertrag",
|
||||
ziel: "collective_agreement",
|
||||
pflicht: false,
|
||||
typ: { art: "auswahl", werte: ["Handel", "Süßwaren"] },
|
||||
hinweis: "Leer = Handel.",
|
||||
beispiel: "Handel",
|
||||
},
|
||||
{
|
||||
name: "Beschäftigtengruppe",
|
||||
ziel: "worker_type",
|
||||
pflicht: false,
|
||||
typ: { art: "auswahl", werte: ["Angestellte:r", "Arbeiter:in"] },
|
||||
hinweis: "Leer = Angestellte:r.",
|
||||
beispiel: "Angestellte:r",
|
||||
},
|
||||
{ name: "Monatsgehalt brutto", ziel: "monthly_salary_gross", pflicht: false, typ: { art: "zahl" }, hinweis: "", beispiel: "3450,00" },
|
||||
{
|
||||
name: "Herkunft",
|
||||
ziel: "source",
|
||||
pflicht: false,
|
||||
typ: { art: "auswahl", werte: ["Intern", "Extern"] },
|
||||
hinweis: "Leer = Extern.",
|
||||
beispiel: "Extern",
|
||||
},
|
||||
{ name: "Betriebsrat", ziel: "is_betriebsrat", pflicht: false, typ: { art: "janein" }, hinweis: "Leer = nein.", beispiel: "nein" },
|
||||
{ name: "Dienstwagen", ziel: "has_dienstwagen", pflicht: false, typ: { art: "janein" }, hinweis: "", beispiel: "nein" },
|
||||
{
|
||||
name: "Antriebsart",
|
||||
ziel: "dienstwagen_art",
|
||||
pflicht: false,
|
||||
typ: { art: "auswahl", werte: ["Verbrenner", "Elektro"] },
|
||||
hinweis: "Pflicht, wenn ein Dienstwagen eingetragen ist; sonst leer lassen.",
|
||||
beispiel: "",
|
||||
},
|
||||
{
|
||||
name: "Notfallkontakt",
|
||||
ziel: "emergency_contact_name",
|
||||
pflicht: false,
|
||||
typ: { art: "text" },
|
||||
hinweis: "Name. Nur zusammen mit der Telefonnummer.",
|
||||
beispiel: "",
|
||||
},
|
||||
{
|
||||
name: "Notfallkontakt Telefon",
|
||||
ziel: "emergency_contact_phone",
|
||||
pflicht: false,
|
||||
typ: { art: "text" },
|
||||
hinweis: "Nur zusammen mit dem Namen.",
|
||||
beispiel: "",
|
||||
},
|
||||
{
|
||||
name: "Notfallkontakt Verhältnis",
|
||||
ziel: "emergency_contact_relation",
|
||||
pflicht: false,
|
||||
typ: { art: "text" },
|
||||
hinweis: "Zum Beispiel Gattin, Bruder, Freundin.",
|
||||
beispiel: "",
|
||||
},
|
||||
{ name: "Laterale Führung", ziel: "is_laterale_fuehrung", pflicht: false, typ: { art: "janein" }, hinweis: "", beispiel: "nein" },
|
||||
{ name: "C-Level", ziel: "is_c_level", pflicht: false, typ: { art: "janein" }, hinweis: "", beispiel: "nein" },
|
||||
{
|
||||
name: "Besonderer Kündigungsschutz",
|
||||
ziel: "has_kuendigungsschutz",
|
||||
pflicht: false,
|
||||
typ: { art: "janein" },
|
||||
hinweis: "Leer = nein.",
|
||||
beispiel: "nein",
|
||||
},
|
||||
{
|
||||
name: "Kündigungsschutz bis",
|
||||
ziel: "kuendigungsschutz_bis",
|
||||
pflicht: false,
|
||||
typ: { art: "datum" },
|
||||
hinweis: "Nur mit gesetztem Schutz; freiwillig.",
|
||||
beispiel: "",
|
||||
},
|
||||
{
|
||||
name: "Abwesenheit ab",
|
||||
ziel: "karenz_start_date",
|
||||
pflicht: false,
|
||||
typ: { art: "datum" },
|
||||
hinweis: "Beginn einer Langzeitabwesenheit.",
|
||||
beispiel: "",
|
||||
},
|
||||
{
|
||||
name: "Rückkehr geplant",
|
||||
ziel: "karenz_return_date",
|
||||
pflicht: false,
|
||||
typ: { art: "datum" },
|
||||
hinweis: "Pflicht, wenn „Abwesenheit ab“ steht.",
|
||||
beispiel: "",
|
||||
},
|
||||
{
|
||||
name: "Abwesenheitsart",
|
||||
ziel: "absence_type",
|
||||
pflicht: false,
|
||||
typ: { art: "auswahl", werte: ABSENCE_TYPES },
|
||||
hinweis: "Pflicht, wenn „Abwesenheit ab“ steht.",
|
||||
beispiel: "",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const BLATT_HISTORIE: BlattSchema = {
|
||||
name: "Historie",
|
||||
zweck: "Ereignisse je Person. Alles, was vor dem Eintritt liegt, wird abgewiesen.",
|
||||
schluessel: null,
|
||||
spalten: [
|
||||
{
|
||||
name: "Personalnummer",
|
||||
ziel: "personnel_number",
|
||||
pflicht: true,
|
||||
typ: { art: "ganzzahl" },
|
||||
hinweis: "Muss im Blatt „Personen“ oder bereits im System stehen.",
|
||||
beispiel: "2219",
|
||||
},
|
||||
{ name: "Datum", ziel: "event_date", pflicht: true, typ: { art: "datum" }, hinweis: "", beispiel: "01.03.2015" },
|
||||
{
|
||||
name: "Ereignis",
|
||||
ziel: "event_type",
|
||||
pflicht: true,
|
||||
typ: { art: "auswahl", werte: EREIGNIS },
|
||||
hinweis: "",
|
||||
beispiel: "Eintritt",
|
||||
},
|
||||
{ name: "Beschreibung", ziel: "description", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Eintritt als Projektingenieur:in" },
|
||||
],
|
||||
};
|
||||
|
||||
export const BLATT_ANGEHOERIGE: BlattSchema = {
|
||||
name: "Angehörige",
|
||||
zweck: "Angehörige je Person.",
|
||||
schluessel: null,
|
||||
spalten: [
|
||||
{ name: "Personalnummer", ziel: "personnel_number", pflicht: true, typ: { art: "ganzzahl" }, hinweis: "", beispiel: "2219" },
|
||||
{ name: "Vorname", ziel: "first_name", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Julian" },
|
||||
{ name: "Nachname", ziel: "last_name", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Aigner" },
|
||||
{
|
||||
name: "Verhältnis",
|
||||
ziel: "relationship",
|
||||
pflicht: true,
|
||||
typ: { art: "auswahl", werte: VERHAELTNIS },
|
||||
hinweis: "",
|
||||
beispiel: "Kind",
|
||||
},
|
||||
{ name: "Geburtsdatum", ziel: "birth_date", pflicht: true, typ: { art: "datum" }, hinweis: "", beispiel: "04.06.2014" },
|
||||
{ name: "SV-Nummer", ziel: "sv_nummer", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "" },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Reihenfolge = Ladereihenfolge.
|
||||
*
|
||||
* Standorte, Organisation und Jobkatalog müssen stehen, bevor Planstellen
|
||||
* darauf verweisen können; Personen brauchen Planstellen; Historie und
|
||||
* Angehörige brauchen Personen.
|
||||
*/
|
||||
export const BLAETTER: BlattSchema[] = [
|
||||
BLATT_STANDORTE,
|
||||
BLATT_ORGANISATION,
|
||||
BLATT_JOBKATALOG,
|
||||
BLATT_PLANSTELLEN,
|
||||
BLATT_PERSONEN,
|
||||
BLATT_HISTORIE,
|
||||
BLATT_ANGEHOERIGE,
|
||||
];
|
||||
|
||||
export function blattSchema(name: string): BlattSchema | undefined {
|
||||
const gesucht = name.trim().toLowerCase();
|
||||
return BLAETTER.find((b) => b.name.toLowerCase() === gesucht);
|
||||
}
|
||||
405
lib/import/validate.ts
Normal file
405
lib/import/validate.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
import { todayIso } from "@/lib/format";
|
||||
import { normalizeSvnr, svnrErrorMessage, validateSvnr } from "@/lib/svnr";
|
||||
import type { ImportSheet } from "./parse";
|
||||
import { BLAETTER, blattSchema, type BlattSchema, type Spalte } from "./schema";
|
||||
import { alsAufzaehlung, alsDatum, alsGanzzahl, alsJaNein, alsListe, alsZahl } from "./werte";
|
||||
|
||||
// Prüfung einer eingelesenen Datei.
|
||||
//
|
||||
// Zwei Grundsätze, beide bewusst:
|
||||
//
|
||||
// 1. **Es wird alles gemeldet, nicht das erste.** Wer eine Datei mit 800
|
||||
// Zeilen hochlädt, will nicht achthundertmal hochladen. Deshalb sammelt
|
||||
// jede Prüfung weiter, statt abzubrechen.
|
||||
//
|
||||
// 2. **Der Bestand kommt als Parameter, nicht aus der Datenbank.** Damit
|
||||
// bleibt diese Datei rein und ohne Verbindung testbar — und die
|
||||
// Abfragen stehen an einer Stelle, wo man sie sieht (load.ts).
|
||||
|
||||
export type Befund = {
|
||||
blatt: string;
|
||||
/** Zeilennummer wie in Excel; null für Probleme am ganzen Blatt. */
|
||||
zeile: number | null;
|
||||
spalte: string | null;
|
||||
wert?: string;
|
||||
meldung: string;
|
||||
};
|
||||
|
||||
/** Was bereits in der Datenbank steht — für Verweise und Doppelprüfungen. */
|
||||
export type Bestand = {
|
||||
standorte: Map<string, string>;
|
||||
orgNummern: Map<string, string>;
|
||||
jobCodes: Map<string, string>;
|
||||
/** Planstellennummer → { id, heuteBesetzt } */
|
||||
planstellen: Map<string, { id: string; besetzt: boolean }>;
|
||||
personalnummern: Map<number, string>;
|
||||
emails: Set<string>;
|
||||
svNummern: Set<string>;
|
||||
};
|
||||
|
||||
export const LEERER_BESTAND: Bestand = {
|
||||
standorte: new Map(),
|
||||
orgNummern: new Map(),
|
||||
jobCodes: new Map(),
|
||||
planstellen: new Map(),
|
||||
personalnummern: new Map(),
|
||||
emails: new Set(),
|
||||
svNummern: new Set(),
|
||||
};
|
||||
|
||||
export type Zeile = { zeile: number; werte: Record<string, unknown> };
|
||||
export type Datensatz = Record<string, Zeile[]>;
|
||||
|
||||
export type Pruefergebnis = {
|
||||
fehler: Befund[];
|
||||
hinweise: Befund[];
|
||||
datensatz: Datensatz;
|
||||
/** Was angelegt würde, je Blatt. */
|
||||
anzahl: Record<string, number>;
|
||||
};
|
||||
|
||||
function leseFeld(spalte: Spalte, roh: string): { wert: unknown; fehler: string | null } {
|
||||
if (roh === "") return { wert: null, fehler: null };
|
||||
|
||||
switch (spalte.typ.art) {
|
||||
case "text":
|
||||
return { wert: roh, fehler: null };
|
||||
case "datum": {
|
||||
const d = alsDatum(roh);
|
||||
return d ? { wert: d, fehler: null } : { wert: null, fehler: "Kein gültiges Datum. Erwartet: 31.12.2026 oder 2026-12-31." };
|
||||
}
|
||||
case "zahl": {
|
||||
const n = alsZahl(roh);
|
||||
return n !== null ? { wert: n, fehler: null } : { wert: null, fehler: "Keine Zahl." };
|
||||
}
|
||||
case "ganzzahl": {
|
||||
const n = alsGanzzahl(roh);
|
||||
return n !== null ? { wert: n, fehler: null } : { wert: null, fehler: "Keine ganze Zahl." };
|
||||
}
|
||||
case "janein": {
|
||||
const b = alsJaNein(roh);
|
||||
return b !== null ? { wert: b, fehler: null } : { wert: null, fehler: "Erwartet: ja oder nein." };
|
||||
}
|
||||
case "liste": {
|
||||
const l = alsListe(roh);
|
||||
if (spalte.typ.werte) {
|
||||
const erlaubt = spalte.typ.werte;
|
||||
const treffer: string[] = [];
|
||||
for (const t of l) {
|
||||
const k = alsAufzaehlung(t, erlaubt);
|
||||
if (!k) return { wert: null, fehler: `„${t}“ ist unbekannt. Erlaubt: ${erlaubt.join(", ")}.` };
|
||||
treffer.push(k);
|
||||
}
|
||||
return { wert: treffer, fehler: null };
|
||||
}
|
||||
return { wert: l, fehler: null };
|
||||
}
|
||||
case "auswahl": {
|
||||
const w = alsAufzaehlung(roh, spalte.typ.werte);
|
||||
return w ? { wert: w, fehler: null } : { wert: null, fehler: `Unbekannter Wert. Erlaubt: ${spalte.typ.werte.join(", ")}.` };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function blattLesen(schema: BlattSchema, blatt: ImportSheet, fehler: Befund[]): Zeile[] {
|
||||
const vorhanden = new Set(blatt.spalten);
|
||||
for (const s of schema.spalten) {
|
||||
if (s.pflicht && !vorhanden.has(s.name)) {
|
||||
fehler.push({ blatt: schema.name, zeile: null, spalte: s.name, meldung: "Pflichtspalte fehlt." });
|
||||
}
|
||||
}
|
||||
for (const s of blatt.spalten) {
|
||||
if (!schema.spalten.some((x) => x.name === s)) {
|
||||
fehler.push({ blatt: schema.name, zeile: 1, spalte: s, meldung: "Unbekannte Spalte — wird nicht übernommen." });
|
||||
}
|
||||
}
|
||||
|
||||
const zeilen: Zeile[] = [];
|
||||
const schluesselGesehen = new Map<string, number>();
|
||||
|
||||
for (const r of blatt.zeilen) {
|
||||
const werte: Record<string, unknown> = {};
|
||||
for (const s of schema.spalten) {
|
||||
const roh = (r.werte[s.name] ?? "").trim();
|
||||
if (roh === "" && s.pflicht) {
|
||||
fehler.push({ blatt: schema.name, zeile: r.zeile, spalte: s.name, meldung: "Pflichtfeld ist leer." });
|
||||
continue;
|
||||
}
|
||||
const { wert, fehler: f } = leseFeld(s, roh);
|
||||
if (f) fehler.push({ blatt: schema.name, zeile: r.zeile, spalte: s.name, wert: roh, meldung: f });
|
||||
else werte[s.ziel] = wert;
|
||||
}
|
||||
|
||||
if (schema.schluessel) {
|
||||
const sp = schema.spalten.find((x) => x.name === schema.schluessel)!;
|
||||
const k = werte[sp.ziel];
|
||||
if (k !== undefined && k !== null) {
|
||||
const schluessel = String(k);
|
||||
const zuvor = schluesselGesehen.get(schluessel);
|
||||
if (zuvor !== undefined) {
|
||||
fehler.push({
|
||||
blatt: schema.name,
|
||||
zeile: r.zeile,
|
||||
spalte: schema.schluessel,
|
||||
wert: schluessel,
|
||||
meldung: `Kommt bereits in Zeile ${zuvor} vor. Welche Zeile gälte, hinge an der Reihenfolge in der Datei.`,
|
||||
});
|
||||
} else {
|
||||
schluesselGesehen.set(schluessel, r.zeile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
zeilen.push({ zeile: r.zeile, werte });
|
||||
}
|
||||
return zeilen;
|
||||
}
|
||||
|
||||
const s = (v: unknown): string | null => (typeof v === "string" && v ? v : null);
|
||||
const n = (v: unknown): number | null => (typeof v === "number" ? v : null);
|
||||
|
||||
export function pruefe(blaetter: ImportSheet[], bestand: Bestand = LEERER_BESTAND): Pruefergebnis {
|
||||
const fehler: Befund[] = [];
|
||||
const hinweise: Befund[] = [];
|
||||
const datensatz: Datensatz = {};
|
||||
|
||||
for (const blatt of blaetter) {
|
||||
const schema = blattSchema(blatt.name);
|
||||
if (!schema) {
|
||||
hinweise.push({
|
||||
blatt: blatt.name,
|
||||
zeile: null,
|
||||
spalte: null,
|
||||
meldung: `Unbekanntes Blatt — wird übergangen. Erwartet: ${BLAETTER.map((b) => b.name).join(", ")}.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
datensatz[schema.name] = blattLesen(schema, blatt, fehler);
|
||||
}
|
||||
|
||||
const hole = (name: string) => datensatz[name] ?? [];
|
||||
const melde = (blatt: string, zeile: number, spalte: string | null, meldung: string, wert?: string) =>
|
||||
fehler.push({ blatt, zeile, spalte, wert, meldung });
|
||||
|
||||
// ── Standorte und Organisation ────────────────────────────────
|
||||
const standorte = new Set([...bestand.standorte.keys()]);
|
||||
for (const z of hole("Standorte")) {
|
||||
const name = s(z.werte.name);
|
||||
if (name) {
|
||||
if (bestand.standorte.has(name)) melde("Standorte", z.zeile, "Bezeichnung", "Gibt es bereits.", name);
|
||||
standorte.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
const orgNummern = new Set([...bestand.orgNummern.keys()]);
|
||||
const orgArten = new Map<string, string>();
|
||||
let wurzeln = 0;
|
||||
for (const z of hole("Organisation")) {
|
||||
const nr = s(z.werte.org_number);
|
||||
const art = s(z.werte.unit_type);
|
||||
if (nr) {
|
||||
if (bestand.orgNummern.has(nr)) melde("Organisation", z.zeile, "Orgnummer", "Gibt es bereits.", nr);
|
||||
orgNummern.add(nr);
|
||||
if (art) orgArten.set(nr, art);
|
||||
}
|
||||
if (art === "Gesellschaft") wurzeln++;
|
||||
}
|
||||
for (const z of hole("Organisation")) {
|
||||
const eltern = s(z.werte.parent_org_number);
|
||||
const art = s(z.werte.unit_type);
|
||||
if (art === "Gesellschaft") {
|
||||
if (eltern) melde("Organisation", z.zeile, "Übergeordnet", "Die Gesellschaft ist die Wurzel und hat nichts über sich.");
|
||||
} else if (!eltern) {
|
||||
melde("Organisation", z.zeile, "Übergeordnet", "Pflicht für alles ausser der Gesellschaft.");
|
||||
} else if (!orgNummern.has(eltern)) {
|
||||
melde("Organisation", z.zeile, "Übergeordnet", "Steht weder in der Datei noch im System.", eltern);
|
||||
} else if (eltern === s(z.werte.org_number)) {
|
||||
melde("Organisation", z.zeile, "Übergeordnet", "Eine Einheit kann sich nicht selbst übergeordnet sein.");
|
||||
}
|
||||
}
|
||||
if (wurzeln > 1) {
|
||||
fehler.push({ blatt: "Organisation", zeile: null, spalte: "Art", meldung: `Es gibt ${wurzeln} Zeilen „Gesellschaft“; genau eine ist erlaubt.` });
|
||||
}
|
||||
if (wurzeln === 0 && bestand.orgNummern.size === 0 && hole("Organisation").length > 0) {
|
||||
fehler.push({ blatt: "Organisation", zeile: null, spalte: "Art", meldung: "Keine Zeile „Gesellschaft“ — der Aufbau hätte keine Wurzel." });
|
||||
}
|
||||
|
||||
// ── Jobkatalog und Planstellen ────────────────────────────────
|
||||
const jobCodes = new Set([...bestand.jobCodes.keys()]);
|
||||
for (const z of hole("Jobkatalog")) {
|
||||
const code = s(z.werte.code);
|
||||
if (code) {
|
||||
if (bestand.jobCodes.has(code)) melde("Jobkatalog", z.zeile, "Jobcode", "Gibt es bereits.", code);
|
||||
jobCodes.add(code);
|
||||
}
|
||||
}
|
||||
|
||||
const planstellen = new Set([...bestand.planstellen.keys()]);
|
||||
const leitungJeEinheit = new Map<string, number>();
|
||||
for (const z of hole("Planstellen")) {
|
||||
const nr = s(z.werte.position_number);
|
||||
const org = s(z.werte.org_number);
|
||||
const job = s(z.werte.job_code);
|
||||
if (nr) {
|
||||
if (bestand.planstellen.has(nr)) melde("Planstellen", z.zeile, "Planstellennummer", "Gibt es bereits.", nr);
|
||||
planstellen.add(nr);
|
||||
}
|
||||
if (org && !orgNummern.has(org)) melde("Planstellen", z.zeile, "Orgnummer", "Steht weder in der Datei noch im System.", org);
|
||||
if (job && !jobCodes.has(job)) melde("Planstellen", z.zeile, "Jobcode", "Steht weder in der Datei noch im System.", job);
|
||||
if (z.werte.is_chief === true && org) {
|
||||
const anzahl = (leitungJeEinheit.get(org) ?? 0) + 1;
|
||||
leitungJeEinheit.set(org, anzahl);
|
||||
if (anzahl === 2) {
|
||||
melde("Planstellen", z.zeile, "Leitung", `Die Einheit ${org} hätte damit zwei Leitungsstellen.`);
|
||||
}
|
||||
}
|
||||
const von = s(z.werte.valid_from);
|
||||
const bis = s(z.werte.valid_to);
|
||||
if (von && bis && bis < von) melde("Planstellen", z.zeile, "Gültig bis", "Liegt vor „Gültig ab“.");
|
||||
}
|
||||
|
||||
// ── Personen ──────────────────────────────────────────────────
|
||||
const heute = todayIso();
|
||||
const personalnummern = new Set([...bestand.personalnummern.keys()]);
|
||||
const emails = new Set([...bestand.emails]);
|
||||
const svNummern = new Set([...bestand.svNummern]);
|
||||
const belegtePlanstellen = new Map<string, number>();
|
||||
const eintritte = new Map<number, string>();
|
||||
|
||||
for (const z of hole("Personen")) {
|
||||
const pnr = n(z.werte.personnel_number);
|
||||
const w = z.werte;
|
||||
|
||||
if (pnr !== null) {
|
||||
if (bestand.personalnummern.has(pnr)) melde("Personen", z.zeile, "Personalnummer", "Gibt es bereits im System.", String(pnr));
|
||||
personalnummern.add(pnr);
|
||||
}
|
||||
|
||||
const email = s(w.email)?.toLowerCase();
|
||||
if (email) {
|
||||
if (emails.has(email)) melde("Personen", z.zeile, "Private E-Mail", "Kommt bereits vor.", email);
|
||||
emails.add(email);
|
||||
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) melde("Personen", z.zeile, "Private E-Mail", "Sieht nicht wie eine Adresse aus.", email);
|
||||
}
|
||||
|
||||
const geburt = s(w.birth_date);
|
||||
const svRoh = s(w.sv_nummer);
|
||||
if (svRoh) {
|
||||
const f = validateSvnr(svRoh, geburt);
|
||||
if (f) melde("Personen", z.zeile, "SV-Nummer", svnrErrorMessage(f), svRoh);
|
||||
else {
|
||||
const norm = normalizeSvnr(svRoh);
|
||||
if (svNummern.has(norm)) melde("Personen", z.zeile, "SV-Nummer", "Kommt bereits vor.", svRoh);
|
||||
svNummern.add(norm);
|
||||
}
|
||||
}
|
||||
if (geburt && geburt > heute) melde("Personen", z.zeile, "Geburtsdatum", "Liegt in der Zukunft.", geburt);
|
||||
|
||||
const standort = s(w.location);
|
||||
if (standort && !standorte.has(standort)) melde("Personen", z.zeile, "Standort", "Steht weder in der Datei noch im System.", standort);
|
||||
|
||||
const stelle = s(w.position_number);
|
||||
if (stelle) {
|
||||
if (!planstellen.has(stelle)) {
|
||||
melde("Personen", z.zeile, "Planstellennummer", "Steht weder in der Datei noch im System.", stelle);
|
||||
} else if (bestand.planstellen.get(stelle)?.besetzt) {
|
||||
melde("Personen", z.zeile, "Planstellennummer", "Diese Planstelle ist bereits besetzt.", stelle);
|
||||
}
|
||||
const zuvor = belegtePlanstellen.get(stelle);
|
||||
// Doppelbesetzung ist in diesem Modell nicht bloss unsauber, sondern
|
||||
// verboten — die Datenbank hat dafür einen Teilindex.
|
||||
if (zuvor !== undefined) melde("Personen", z.zeile, "Planstellennummer", `Wird bereits in Zeile ${zuvor} besetzt.`, stelle);
|
||||
else belegtePlanstellen.set(stelle, z.zeile);
|
||||
}
|
||||
|
||||
const eintritt = s(w.entry_date);
|
||||
const austritt = s(w.exit_date);
|
||||
if (pnr !== null && eintritt) eintritte.set(pnr, eintritt);
|
||||
if (eintritt && geburt && eintritt <= geburt) melde("Personen", z.zeile, "Eintritt", "Liegt vor dem Geburtsdatum.", eintritt);
|
||||
// Gleicher Tag ist erlaubt — jemand, der den Dienst nicht antritt, tritt
|
||||
// am selben Tag ein und aus. Die Datenbank sieht das genauso.
|
||||
if (austritt && eintritt && austritt < eintritt) melde("Personen", z.zeile, "Austritt", "Liegt vor dem Eintritt.", austritt);
|
||||
if (austritt && !s(w.exit_reason)) melde("Personen", z.zeile, "Austrittsgrund", "Pflicht, sobald ein Austritt steht.");
|
||||
|
||||
if (s(w.contract_type) === "befristet" && !s(w.contract_end_date)) {
|
||||
melde("Personen", z.zeile, "Befristet bis", "Pflicht bei einem befristeten Vertrag.");
|
||||
}
|
||||
if (s(w.contract_type) !== "befristet" && s(w.contract_end_date)) {
|
||||
melde("Personen", z.zeile, "Befristet bis", "Nur bei „befristet“ erlaubt.");
|
||||
}
|
||||
|
||||
const abVon = s(w.karenz_start_date);
|
||||
if (abVon) {
|
||||
if (!s(w.karenz_return_date)) melde("Personen", z.zeile, "Rückkehr geplant", "Pflicht, sobald eine Abwesenheit beginnt.");
|
||||
if (!s(w.absence_type)) melde("Personen", z.zeile, "Abwesenheitsart", "Pflicht, sobald eine Abwesenheit beginnt.");
|
||||
if (eintritt && abVon < eintritt) melde("Personen", z.zeile, "Abwesenheit ab", "Liegt vor dem Eintritt.", abVon);
|
||||
}
|
||||
|
||||
if (abVon && s(w.karenz_return_date) && s(w.karenz_return_date)! < (eintritt ?? "")) {
|
||||
melde("Personen", z.zeile, "Rückkehr geplant", "Liegt vor dem Eintritt.", s(w.karenz_return_date)!);
|
||||
}
|
||||
|
||||
// Vollzeit bedeutet in diesem Kollektivvertrag genau 38,5 Stunden, und
|
||||
// Teilzeit alles darunter über null. Die Datenbank hat dafür eine
|
||||
// CHECK-Bedingung; ohne diese Prüfung bräche der Import erst beim
|
||||
// Schreiben ab — mit einer Meldung ohne Zeilennummer. Genau so ist der
|
||||
// erste Durchstich gescheitert.
|
||||
const stunden = n(w.weekly_hours);
|
||||
const beschaeftigung = s(w.employment_type) ?? "Vollzeit";
|
||||
if (stunden !== null) {
|
||||
if (beschaeftigung === "Vollzeit" && stunden !== 38.5) {
|
||||
melde("Personen", z.zeile, "Wochenstunden", "Vollzeit sind genau 38,5 Stunden. Für weniger „Teilzeit“ eintragen.", String(stunden));
|
||||
}
|
||||
if (beschaeftigung === "Teilzeit" && (stunden <= 0 || stunden >= 38.5)) {
|
||||
melde("Personen", z.zeile, "Wochenstunden", "Teilzeit liegt zwischen 0 und 38,5 Stunden.", String(stunden));
|
||||
}
|
||||
} else if (beschaeftigung === "Teilzeit") {
|
||||
melde("Personen", z.zeile, "Wochenstunden", "Pflicht bei Teilzeit — sonst gälten 38,5 und damit Vollzeit.");
|
||||
}
|
||||
|
||||
const tage = w.work_days;
|
||||
if (Array.isArray(tage) && tage.length === 0) {
|
||||
melde("Personen", z.zeile, "Arbeitstage", "Mindestens ein Tag. Leer lassen für Mo–Fr.");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Historie und Angehörige ───────────────────────────────────
|
||||
for (const z of hole("Historie")) {
|
||||
const pnr = n(z.werte.personnel_number);
|
||||
if (pnr === null) continue;
|
||||
if (!personalnummern.has(pnr)) {
|
||||
melde("Historie", z.zeile, "Personalnummer", "Steht weder in der Datei noch im System.", String(pnr));
|
||||
continue;
|
||||
}
|
||||
// Nur für Personen aus derselben Datei; für bereits vorhandene kennt
|
||||
// diese Funktion das Eintrittsdatum nicht, und die Datenbank prüft es
|
||||
// ohnehin ein zweites Mal.
|
||||
const eintritt = eintritte.get(pnr) ?? null;
|
||||
const datum = s(z.werte.event_date);
|
||||
// Die Datenbank weist das ohnehin ab (trg_history_not_before_entry) —
|
||||
// aber mitten im Einfügen und mit einer Meldung ohne Zeilennummer.
|
||||
if (eintritt && datum && datum < eintritt) {
|
||||
melde("Historie", z.zeile, "Datum", `Liegt vor dem Eintritt am ${eintritt}.`, datum);
|
||||
}
|
||||
}
|
||||
|
||||
for (const z of hole("Angehörige")) {
|
||||
const pnr = n(z.werte.personnel_number);
|
||||
if (pnr === null) continue;
|
||||
if (!personalnummern.has(pnr)) {
|
||||
melde("Angehörige", z.zeile, "Personalnummer", "Steht weder in der Datei noch im System.", String(pnr));
|
||||
continue;
|
||||
}
|
||||
const sv = s(z.werte.sv_nummer);
|
||||
const geb = s(z.werte.birth_date);
|
||||
if (sv) {
|
||||
const f = validateSvnr(sv, geb);
|
||||
if (f) melde("Angehörige", z.zeile, "SV-Nummer", svnrErrorMessage(f), sv);
|
||||
}
|
||||
}
|
||||
|
||||
const anzahl: Record<string, number> = {};
|
||||
for (const b of BLAETTER) anzahl[b.name] = (datensatz[b.name] ?? []).length;
|
||||
|
||||
return { fehler, hinweise, datensatz, anzahl };
|
||||
}
|
||||
125
lib/import/werte.ts
Normal file
125
lib/import/werte.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
// Aus einer Zelle wird ein Wert.
|
||||
//
|
||||
// Jede Funktion hier liefert entweder den Wert oder `null` — nie eine
|
||||
// Näherung. Was sich nicht eindeutig lesen lässt, ist ein Fehler, den die
|
||||
// Person in der Datei korrigieren soll. Raten wäre hier besonders teuer: aus
|
||||
// „03.08.26" könnte 2026-08-03 oder 2003-08-26 werden, und beides sähe im
|
||||
// Ergebnis unauffällig aus.
|
||||
|
||||
/** Kalendertag als „JJJJ-MM-TT". Ohne Zeitzone, weil ein Geburtstag keine hat. */
|
||||
export type IsoTag = string;
|
||||
|
||||
function tagAusTeilen(jahr: number, monat: number, tag: number): IsoTag | null {
|
||||
if (monat < 1 || monat > 12 || tag < 1 || tag > 31) return null;
|
||||
const d = new Date(Date.UTC(jahr, monat - 1, tag));
|
||||
// Fängt den 31. Februar: Date rechnet ihn stillschweigend in den 3. März um.
|
||||
if (d.getUTCFullYear() !== jahr || d.getUTCMonth() !== monat - 1 || d.getUTCDate() !== tag) return null;
|
||||
const m = String(monat).padStart(2, "0");
|
||||
const t = String(tag).padStart(2, "0");
|
||||
return `${jahr}-${m}-${t}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Datum aus einer Zelle.
|
||||
*
|
||||
* Angenommen werden ISO (2026-08-03), österreichisch (3.8.2026, 03.08.2026)
|
||||
* und mit Schrägstrich (03/08/2026). Zweistellige Jahre werden **abgelehnt**:
|
||||
* bei Geburtsdaten liegt das Jahrhundert nicht fest, und eine Regel wie
|
||||
* „unter 30 heisst 20xx" produziert lautlos Personen, die noch nicht geboren
|
||||
* sind.
|
||||
*/
|
||||
export function alsDatum(roh: string): IsoTag | null {
|
||||
const s = roh.trim();
|
||||
if (!s) return null;
|
||||
|
||||
const iso = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
|
||||
if (iso) return tagAusTeilen(Number(iso[1]), Number(iso[2]), Number(iso[3]));
|
||||
|
||||
const deutsch = s.match(/^(\d{1,2})[.\/](\d{1,2})[.\/](\d{4})\.?$/);
|
||||
if (deutsch) return tagAusTeilen(Number(deutsch[3]), Number(deutsch[2]), Number(deutsch[1]));
|
||||
|
||||
// Excel-Serienzahl — kommt vor, wenn die Spalte nicht als Datum formatiert
|
||||
// ist. Tag 1 ist der 01.01.1900, und Excel kennt einen 29.02.1900, den es
|
||||
// nie gab; ab Serie 60 muss deshalb ein Tag abgezogen werden.
|
||||
if (/^\d{1,6}$/.test(s)) {
|
||||
const serie = Number(s);
|
||||
if (serie >= 1 && serie < 100000) {
|
||||
const tage = serie >= 60 ? serie - 1 : serie;
|
||||
const d = new Date(Date.UTC(1899, 11, 31) + tage * 86400000);
|
||||
return tagAusTeilen(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zahl aus einer Zelle, mit Komma **oder** Punkt als Dezimaltrenner.
|
||||
*
|
||||
* „1.234,5" und „1,234.5" sind beide gebräuchlich und bedeuten dasselbe. Das
|
||||
* letzte Trennzeichen entscheidet, die übrigen sind Tausenderpunkte.
|
||||
*/
|
||||
export function alsZahl(roh: string): number | null {
|
||||
let s = roh.trim().replace(/\s/g, "");
|
||||
if (!s) return null;
|
||||
s = s.replace(/€|EUR/gi, "");
|
||||
|
||||
const letztesKomma = s.lastIndexOf(",");
|
||||
const letzterPunkt = s.lastIndexOf(".");
|
||||
if (letztesKomma >= 0 && letzterPunkt >= 0) {
|
||||
const dezimal = letztesKomma > letzterPunkt ? "," : ".";
|
||||
const tausender = dezimal === "," ? "." : ",";
|
||||
s = s.split(tausender).join("").replace(dezimal, ".");
|
||||
} else if (letztesKomma >= 0) {
|
||||
// Ein einzelnes Komma ist im deutschsprachigen Raum ein Dezimaltrenner,
|
||||
// auch bei „1,234" — das sind 1,234 und nicht 1234.
|
||||
s = s.replace(",", ".");
|
||||
}
|
||||
|
||||
if (!/^-?\d*\.?\d+$/.test(s)) return null;
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
export function alsGanzzahl(roh: string): number | null {
|
||||
const n = alsZahl(roh);
|
||||
return n !== null && Number.isInteger(n) ? n : null;
|
||||
}
|
||||
|
||||
const JA = new Set(["ja", "j", "x", "wahr", "true", "1", "y", "yes"]);
|
||||
const NEIN = new Set(["nein", "n", "falsch", "false", "0", "no", "-"]);
|
||||
|
||||
/** Ja/Nein aus einer Zelle. Alles andere ist ein Fehler, nicht „nein". */
|
||||
export function alsJaNein(roh: string): boolean | null {
|
||||
const s = roh.trim().toLowerCase();
|
||||
if (!s) return null;
|
||||
if (JA.has(s)) return true;
|
||||
if (NEIN.has(s)) return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste aus einer Zelle — für Titel und Arbeitstage.
|
||||
*
|
||||
* Getrennt wird an Semikolon oder Komma. Leere Glieder fallen weg, damit
|
||||
* „Mo, Di, " nicht in einem leeren Arbeitstag endet.
|
||||
*/
|
||||
export function alsListe(roh: string): string[] {
|
||||
return roh
|
||||
.split(/[;,]/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Einen Wert einer Aufzählung zuordnen, unabhängig von Gross- und
|
||||
* Kleinschreibung und von Leerzeichen.
|
||||
*
|
||||
* Zurück kommt die **kanonische** Schreibweise aus der Datenbank, nicht die
|
||||
* aus der Datei: „vollzeit" wird zu „Vollzeit", weil die Spalte ein enum ist
|
||||
* und alles andere die Zeile beim Einfügen abweisen würde.
|
||||
*/
|
||||
export function alsAufzaehlung<T extends string>(roh: string, erlaubt: readonly T[]): T | null {
|
||||
const s = roh.trim().toLowerCase();
|
||||
if (!s) return null;
|
||||
return erlaubt.find((w) => w.toLowerCase() === s) ?? null;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user