Compare commits
171 Commits
4c5bfc223f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 96c94642ba | |||
| d9966c9623 | |||
| 0039ce5069 | |||
| 38f8c99968 | |||
| fe858c6c24 | |||
| cf5ed5c6c6 | |||
| a0b9f6dc80 | |||
| 194b6d8916 | |||
| 89ba34c901 | |||
| 990ebdbc84 | |||
| be9ca4758f | |||
| 3664b9a435 | |||
| 2eda038c2e | |||
| ab50c21b8a | |||
| a3dde61ec1 | |||
| a6b6a7d67c | |||
| 8b7e32f14a | |||
| 771020af72 | |||
| 8ac4df17c1 | |||
| 38e35e2ff9 | |||
| 676cecbf15 | |||
| af2ad53ef9 | |||
| 677ca35df2 | |||
| 779beb4478 | |||
| c3e19606e2 | |||
| 6717412c9f | |||
| 410acfe01f | |||
| 478364e2d0 | |||
| e11544b8da | |||
| 520242e3c3 | |||
| 6d006e55fe | |||
| a1b4674a22 | |||
| c8f9ae8047 | |||
| 499d400fe1 | |||
| e6b7defae2 | |||
| e5aa527473 | |||
| 2bba1f40ac | |||
| 01c49ee56c | |||
| 37650ee9a4 | |||
| 93cb1c1699 | |||
| db02fb4762 | |||
| 5e35d6bf41 | |||
| 948ddf5c70 | |||
| 57acbfae9d | |||
| 1816ab68ad | |||
| 1baf8c2867 | |||
| 33d4582b30 | |||
| d08e4f86fe | |||
| 3e49be5fc1 | |||
| 683e7cc2d7 | |||
| 779d6116b2 | |||
| 7b7562799d | |||
| 8bb80d3bd2 | |||
| 6ab78d4b27 | |||
| 9ded472d25 | |||
| 4165f3f8a1 | |||
| bf7f59a959 | |||
| c46f18c37a | |||
| e727fac4b7 | |||
| 8c8777467c | |||
| 9ad2954970 | |||
| 4dc27bf212 | |||
| 05d56bf3b9 | |||
| b9da3f3411 | |||
| cf0ea51f47 | |||
| c25c16e372 | |||
| 8d0c9b4b65 | |||
| 1cbed1a8f5 | |||
| 7a33e493b5 | |||
| a0811d75f4 | |||
| b23ee17608 | |||
| 0fbafa9b95 | |||
| 76009d6db6 | |||
| d2d5e1dabb | |||
| 8016d317be | |||
| 3987e8f54f | |||
| f7a5c48615 | |||
| 99e4357c02 | |||
| f17d299045 | |||
| 2838919e42 | |||
| eeaf210e78 | |||
| e8e675fd07 | |||
| 6903013548 | |||
| eb25369d1d | |||
| 99b1df9735 | |||
| e1b69fb022 | |||
| c6cff9656e | |||
| cbde8cf3a8 | |||
| 206a7c6eba | |||
| 405d708bc4 | |||
| 029b63009f | |||
| a2f2f7075e | |||
| 17c7badd16 | |||
| d07dc80780 | |||
| 4ac516daa1 | |||
| b87c8ad64c | |||
| 5c310c3a58 | |||
| af947f094d | |||
| e958bb5c6b | |||
| 77d9a95f7f | |||
| d574d3c9d6 | |||
| fc0989debb | |||
| 19e3170b00 | |||
| 521e4ecf00 | |||
| f85731dde5 | |||
| 82d07f0d95 | |||
| bd990b7f2c | |||
| e30923e781 | |||
| 3a4f44318c | |||
| 10f8f1f2d5 | |||
| 91b2b3406b | |||
| 6957b95a97 | |||
| 16216c5537 | |||
| 861c47b757 | |||
| d2f4a7aab7 | |||
| 384bdb4fb3 | |||
| f5ace8af2e | |||
| e6554e7982 | |||
| 6681dcda77 | |||
| 0144267a59 | |||
| 8a76b3688f | |||
| a3fac47f47 | |||
| 08d2740690 | |||
| 9308096754 | |||
| 33d053ce75 | |||
| 272e8b1acf | |||
| 6297288c13 | |||
| 5f50cb97f3 | |||
| 00df973824 | |||
| 578ce696f0 | |||
| 917911457e | |||
| 64eb155dda | |||
| 0b8f874fa5 | |||
| f14f1cb8df | |||
| 3151f32404 | |||
| c23df08648 | |||
| 4eba557121 | |||
| 9d754359e0 | |||
| 53d5d41784 | |||
| f28fd2da60 | |||
| 27e0e8a8ce | |||
| 92685f0ac0 | |||
| d8a1fdf43b | |||
| 780f8fe2b7 | |||
| 56662c0775 | |||
| 61ccce5456 | |||
| ecbda3f3a5 | |||
| 27cfd6431f | |||
| 0d8af7cdf0 | |||
| a87c688c0a | |||
| e44f71a60d | |||
| 1271cef879 | |||
| 3926f1bb80 | |||
| 16b37244c8 | |||
| 73656461d1 | |||
| 99e50fbbf9 | |||
| 4d8e3f7154 | |||
| b9efe81bec | |||
| 2ba9b37aa7 | |||
| b3a0af2b8f | |||
| a66263a96e | |||
| 730521ee79 | |||
| acbb03c4f5 | |||
| ee1a38492c | |||
| 2cce101c4b | |||
| 27669e0359 | |||
| 4929252f45 | |||
| c2366e3408 | |||
| cce5c6b0ce | |||
| 35f17858d8 | |||
| 4b9c23472c |
@@ -15,6 +15,3 @@ npm-debug.log*
|
||||
*.tsbuildinfo
|
||||
.scratch_*
|
||||
.scratch_shots
|
||||
supabase/.branches
|
||||
supabase/.temp
|
||||
supabase/snippets
|
||||
|
||||
73
.env.example
73
.env.example
@@ -1,14 +1,65 @@
|
||||
# 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=
|
||||
# ── Datenbank ────────────────────────────────────────────────────────
|
||||
#
|
||||
# Die Datenbank läuft als eigener Container (Dienst `db` in
|
||||
# docker-compose.yml). Diese drei Werte richten ihn ein:
|
||||
#
|
||||
# POSTGRES_PASSWORD das des Verwalters (postgres) — nur für Migrationen,
|
||||
# Sicherungen und Wartung
|
||||
# APP_DB_PASSWORD das der Anwendungsrolle alpenwerk_app
|
||||
# POSTGRES_DB Name der Datenbank; Vorgabe alpenwerk
|
||||
#
|
||||
# Beide Passwörter selbst erzeugen: `openssl rand -base64 24`
|
||||
#
|
||||
# Sie werden nur beim **allerersten** Start ausgewertet, solange das
|
||||
# Datenverzeichnis leer ist. Ein späterer Wechsel braucht ein
|
||||
# `alter role … password …` in der laufenden Datenbank.
|
||||
POSTGRES_PASSWORD=
|
||||
APP_DB_PASSWORD=
|
||||
POSTGRES_DB=alpenwerk
|
||||
POSTGRES_USER=postgres
|
||||
|
||||
# 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=
|
||||
# Womit die Anwendung sich verbindet. Im Compose-Netz heisst die Datenbank
|
||||
# `db`; von aussen ist sie nicht erreichbar, es gibt bewusst keinen Port.
|
||||
#
|
||||
# Die Rolle in diesem String darf KEIN BYPASSRLS haben: fehlt der
|
||||
# Sitzungskontext, sollen die Policies nichts zurückgeben statt alles.
|
||||
# `alpenwerk_app` wird in deploy/db-init genau so angelegt.
|
||||
DATABASE_URL=postgresql://alpenwerk_app:<APP_DB_PASSWORD>@db:5432/alpenwerk
|
||||
# Im Compose-Netz läuft Postgres ohne TLS — die Verbindung verlässt den
|
||||
# Server nicht. Bei einer Datenbank ausserhalb diesen Wert entfernen.
|
||||
DATABASE_SSL=false
|
||||
# Verbindungen im Pool; Vorgabe 10.
|
||||
DATABASE_POOL_MAX=
|
||||
|
||||
# Shared secret Vercel Cron sends as `Authorization: Bearer <value>` when it
|
||||
# calls /api/cron/apply-pending-changes (set the same value in the Vercel
|
||||
# project's env vars). Generate with e.g. `openssl rand -hex 32`.
|
||||
# Der **direkte** Zugang für Migrationen (scripts/migrate.mjs). Als Verwalter,
|
||||
# weil Migrationen Schemaänderungen vornehmen, die die Anwendungsrolle nicht
|
||||
# darf. Bei Betrieb über docker compose wird er nicht gebraucht — der Dienst
|
||||
# `migrate` setzt ihn selbst aus POSTGRES_PASSWORD zusammen.
|
||||
MIGRATE_DATABASE_URL=
|
||||
|
||||
# ── 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=
|
||||
|
||||
# Gemeinsames Geheimnis, mit dem sich der nächtliche Lauf ausweist: der
|
||||
# `cron`-Container schickt es als `Authorization: Bearer <wert>` an
|
||||
# /api/cron/apply-pending-changes. Ohne angemeldete Person gibt es keine
|
||||
# Sitzung, an der die Route den Aufruf prüfen könnte.
|
||||
# Erzeugen mit: openssl rand -hex 32
|
||||
CRON_SECRET=
|
||||
|
||||
174
.github/workflows/ci.yml
vendored
174
.github/workflows/ci.yml
vendored
@@ -29,25 +29,45 @@ jobs:
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
# Catches the drift that `tsc` cannot: lib/supabase/types.ts is
|
||||
# hand-written, so a migration adding a column leaves it silently stale.
|
||||
# Fängt die Abweichung, die `tsc` nicht sehen kann: lib/types.ts wird von
|
||||
# Hand gepflegt, eine Migration mit einer neuen Spalte lässt sie also
|
||||
# stillschweigend veralten.
|
||||
- name: Schema/Typen-Abgleich
|
||||
run: npm run types:check
|
||||
|
||||
- name: Unit- und Komponententests
|
||||
run: npm test
|
||||
|
||||
# Ohne Umgebungsvariablen: seit dem Wegfall der Browser-Anbindung wird
|
||||
# zur Bauzeit nichts mehr aus der Umgebung gelesen, und das Abbild ist
|
||||
# für jede Umgebung dasselbe.
|
||||
- name: Build
|
||||
run: npm run build
|
||||
env:
|
||||
# Read at module scope by the Supabase browser client, so the build
|
||||
# needs them present — never the real project's values.
|
||||
NEXT_PUBLIC_SUPABASE_URL: http://127.0.0.1:54321
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: build-time-placeholder
|
||||
|
||||
integration:
|
||||
name: Integrationstests (echtes Postgres)
|
||||
migrationen:
|
||||
name: Migrationen auf leerer Datenbank
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Derselbe Stand wie im Betrieb. Ein eigener Dienst statt einer fremden
|
||||
# Plattform — die Datenbank gehört seit dem Umzug zum Projekt.
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_PASSWORD: ci
|
||||
POSTGRES_DB: alpenwerk
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
|
||||
env:
|
||||
MIGRATE_DATABASE_URL: postgresql://postgres:ci@postgres:5432/alpenwerk
|
||||
DATABASE_SSL: "false"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -58,33 +78,117 @@ jobs:
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- uses: supabase/setup-cli@v1
|
||||
with:
|
||||
version: latest
|
||||
|
||||
# Applies every migration to a fresh database — which also means a
|
||||
# migration that cannot be replayed from scratch fails here rather than
|
||||
# on a restore or a new environment.
|
||||
- name: Supabase starten
|
||||
run: supabase start
|
||||
|
||||
# `-o env` emits API_URL / ANON_KEY / SERVICE_ROLE_KEY; the app expects
|
||||
# them under its own names.
|
||||
- name: Testumgebung schreiben
|
||||
# Das Abbild des Runners bringt keinen Postgres-Client mit; der
|
||||
# Schritt darunter ruft aber psql. Ohne das bricht er mit
|
||||
# "psql: command not found" ab, bevor die erste Datei laeuft.
|
||||
- name: psql bereitstellen
|
||||
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)=' \
|
||||
| tr -d '"' > .env.test.local
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq --no-install-recommends postgresql-client
|
||||
psql --version
|
||||
|
||||
- name: Seed
|
||||
run: node --env-file=.env.test.local supabase/seed.ts
|
||||
# Was das Schema von der Umgebung erwartet: die Anwendungsrolle ohne
|
||||
# BYPASSRLS, zwei Erweiterungen und die Attrappe des `auth`-Schemas, ohne
|
||||
# die sich die Migrationen von Juni 2026 nicht abspielen lassen.
|
||||
- name: Rollen, Erweiterungen, auth-Attrappe
|
||||
env:
|
||||
PGPASSWORD: ci
|
||||
APP_DB_PASSWORD: ci-anwendungsrolle
|
||||
run: |
|
||||
for f in deploy/db-init/*.sql; do
|
||||
echo "── $f"
|
||||
psql -v ON_ERROR_STOP=1 -h postgres -U postgres -d alpenwerk -f "$f"
|
||||
done
|
||||
|
||||
- name: Integrationstests
|
||||
run: npm run test:integration
|
||||
# Der eigentliche Zweck dieses Jobs: jede Migration muss sich auf einer
|
||||
# leeren Datenbank abspielen lassen. Eine, die das nicht kann, fällt hier
|
||||
# auf — und nicht beim Wiederherstellen einer Sicherung oder beim
|
||||
# Aufsetzen einer neuen Umgebung.
|
||||
- name: Migrationen einspielen
|
||||
run: node scripts/migrate.mjs
|
||||
|
||||
- name: Supabase-Logs bei Fehlschlag
|
||||
if: failure()
|
||||
run: supabase status && docker ps -a
|
||||
# Zweiter Lauf: die Buchführung muss greifen. Meldet er etwas anderes als
|
||||
# „nichts anzuwenden", verbucht der Läufer nicht richtig — und ein
|
||||
# Ausrollen spielte Migrationen doppelt ein.
|
||||
- name: Zweiter Lauf ist ein Nichts
|
||||
run: |
|
||||
ausgabe=$(node scripts/migrate.mjs)
|
||||
echo "$ausgabe"
|
||||
echo "$ausgabe" | grep -q "^Nichts anzuwenden" \
|
||||
|| { echo "Der zweite Lauf wollte erneut anwenden — die Buchführung greift nicht."; exit 1; }
|
||||
|
||||
# Dass die Migrationen durchlaufen, heisst nicht, dass das Ergebnis
|
||||
# stimmt. Bis hierher prüft dieser Job nur, dass keine Datei abbricht —
|
||||
# und genau daran ist ein Fehler vorbeigekommen: vier Tabellen aus dem
|
||||
# August (Kostenstellen, On-/Offboarding) standen ohne Zeilenschutz da,
|
||||
# weil sie sich auf den Ereignis-Trigger `ensure_rls` verliessen, den
|
||||
# eine spätere Migration erst anlegt. Alle Dateien liefen sauber durch,
|
||||
# die Policies standen im Katalog, und ausgewertet wurde keine davon.
|
||||
#
|
||||
# Deshalb wird ab hier der **Zustand** geprüft, nicht der Durchlauf.
|
||||
# Nachgezogen hat das 20260908100000; dort steht dieselbe Gegenprobe.
|
||||
# Sie läuft aber nur einmal, beim Anwenden jener Datei. Der Schritt hier
|
||||
# läuft bei jedem Push gegen eine frisch aufgebaute Datenbank und fängt
|
||||
# deshalb auch die *nächste* Tabelle, die es wieder vergisst.
|
||||
- name: Zugriffsschutz nach dem Lauf
|
||||
env:
|
||||
PGPASSWORD: ci
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
frage() {
|
||||
psql -v ON_ERROR_STOP=1 -h postgres -U postgres -d alpenwerk -At -c "$1"
|
||||
}
|
||||
|
||||
# relkind in ('r','p'): gewöhnliche und partitionierte Tabellen.
|
||||
ohne_rls=$(frage "
|
||||
select coalesce(string_agg(c.relname, ', ' order by c.relname), '')
|
||||
from pg_class c
|
||||
join pg_namespace n on n.oid = c.relnamespace
|
||||
where n.nspname = 'public'
|
||||
and c.relkind in ('r', 'p')
|
||||
and not c.relrowsecurity")
|
||||
|
||||
# RLS ohne Policy ist nicht offen, sondern leer — kein Leck, aber
|
||||
# eine Tabelle, aus der nie etwas zurückkommt.
|
||||
ohne_policy=$(frage "
|
||||
select coalesce(string_agg(c.relname, ', ' order by c.relname), '')
|
||||
from pg_class c
|
||||
join pg_namespace n on n.oid = c.relnamespace
|
||||
where n.nspname = 'public'
|
||||
and c.relkind in ('r', 'p')
|
||||
and not exists (select 1 from pg_policy p where p.polrelid = c.oid)")
|
||||
|
||||
# Das Netz selbst: fehlt oder schläft es, ist die nächste neue
|
||||
# Tabelle wieder ungeschützt.
|
||||
netz=$(frage "
|
||||
select count(*) from pg_event_trigger
|
||||
where evtname = 'ensure_rls' and evtenabled <> 'D'")
|
||||
|
||||
fehler=0
|
||||
if [ -n "$ohne_rls" ]; then
|
||||
echo "Ohne Row Level Security: $ohne_rls"
|
||||
fehler=1
|
||||
fi
|
||||
if [ -n "$ohne_policy" ]; then
|
||||
echo "Mit Row Level Security, aber ohne jede Policy: $ohne_policy"
|
||||
fehler=1
|
||||
fi
|
||||
if [ "$netz" != "1" ]; then
|
||||
echo "Der Ereignis-Trigger ensure_rls fehlt oder ist abgeschaltet."
|
||||
fehler=1
|
||||
fi
|
||||
|
||||
if [ "$fehler" -ne 0 ]; then
|
||||
echo
|
||||
echo "Jede Tabelle in public braucht RLS und mindestens eine Policy."
|
||||
echo "Nachziehen in einer neuen Migration, nicht in einer bereits angewendeten."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Zugriffsschutz vollstaendig: jede Tabelle mit RLS und Policy, ensure_rls aktiv."
|
||||
|
||||
# Die Integrationstests brauchen einen befüllten Bestand; der Seed lag
|
||||
# in der abgelösten Umgebung und ist noch nicht nachgezogen (siehe
|
||||
# README, offene Punkte). Bis dahin laufen sie gegen eine erreichbare
|
||||
# Datenbank von Hand, nicht hier.
|
||||
|
||||
191
.github/workflows/deploy.yml
vendored
Normal file
191
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,191 @@
|
||||
name: Deploy
|
||||
|
||||
# Warum diese Datei unter .github/workflows liegt und nicht unter .gitea/:
|
||||
#
|
||||
# Gitea Actions liest `.gitea/workflows`, **und nur wenn dieses Verzeichnis
|
||||
# fehlt**, ersatzweise `.github/workflows`. Ein neu angelegtes `.gitea/`
|
||||
# würde also ci.yml stillschweigend abschalten — der Lauf verschwände
|
||||
# einfach, ohne Fehlermeldung. Solange beide Dateien hier liegen, sieht Gitea
|
||||
# beide. (Auf GitHub bliebe dieser Workflow hängen: es gibt dort keinen
|
||||
# Runner mit dem Label `self-hosted`. Das Repository liegt aber ohnehin auf
|
||||
# git.elycon.solutions.)
|
||||
|
||||
# Nur von Hand auslösbar (Actions → Deploy → „Run workflow").
|
||||
#
|
||||
# Lief einmal bei jedem Push auf master. Das ist zurückgenommen: die
|
||||
# Voraussetzungen dafür sind nicht erfüllt — es sind keine Secrets hinterlegt,
|
||||
# und ob der Job-Container den Docker-Dienst des Hosts überhaupt erreicht, ist
|
||||
# ungeprüft. Bei jedem Push zu scheitern erzieht dazu, rote Läufe zu
|
||||
# übersehen; dann lieber gar nicht starten.
|
||||
#
|
||||
# Nach dem Umzug auf den eigenen db-Container stimmt hier ausserdem noch
|
||||
# nicht alles: die Secrets unten kennen die neuen Werte (POSTGRES_PASSWORD,
|
||||
# APP_DB_PASSWORD) nicht, und Migrationen laufen jetzt über den Dienst
|
||||
# `migrate` statt über einen eigenen Node-Schritt. Wer das wieder scharf
|
||||
# schaltet, muss beides nachziehen.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
# Zwei Pushes kurz hintereinander sollen nicht zwei Deploys übereinander
|
||||
# fahren. Der laufende wird **nicht** abgebrochen — mitten im `docker compose
|
||||
# up` abgeschnitten zu werden ist der eine Zustand, den man nicht will.
|
||||
concurrency:
|
||||
group: deploy-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Migrationen und Container
|
||||
# `ubuntu-latest`, weil der vorhandene Runner (elycon-runner-01) genau
|
||||
# diese Bezeichnung anbietet — neben `docker`. Ein Label, das kein Runner
|
||||
# führt, lässt den Lauf wortlos in der Warteschlange stehen: kein Fehler,
|
||||
# keine Meldung, nur „Waiting".
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# ── Vorprüfung ──────────────────────────────────────────────────
|
||||
#
|
||||
# Dieser Job läuft in einem Container, den der Runner startet — nicht
|
||||
# auf dem Server selbst. Ob er den Docker-Dienst des **Hosts** erreicht,
|
||||
# hängt davon ab, wie der Runner konfiguriert ist; von aussen ist das
|
||||
# nicht zu sehen. Statt es zu raten, wird es hier gemessen und im
|
||||
# Fehlerfall gesagt, was fehlt — sonst scheitert der Lauf erst drei
|
||||
# Schritte später an einer Meldung, die niemandem weiterhilft.
|
||||
- name: Erreicht dieser Job den Docker-Dienst des Servers?
|
||||
run: |
|
||||
set -u
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo 'Im Job-Container gibt es kein "docker".'
|
||||
echo
|
||||
echo "Zwei Wege:"
|
||||
echo " a) Im Runner (config.yaml) ein Abbild verwenden, das die Docker-CLI"
|
||||
echo " mitbringt, und den Socket durchreichen — siehe DEPLOYMENT.md §5."
|
||||
echo " b) Statt lokal per SSH auf den Server ausrollen."
|
||||
exit 1
|
||||
fi
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
echo "Docker-CLI vorhanden, aber kein Dienst erreichbar."
|
||||
echo
|
||||
echo "Dem Job-Container fehlt der Socket des Hosts. In der config.yaml des"
|
||||
echo "Runners:"
|
||||
echo " container:"
|
||||
echo " options: -v /var/run/docker.sock:/var/run/docker.sock"
|
||||
echo " valid_volumes: [/var/run/docker.sock]"
|
||||
echo "Danach den Runner neu starten. Alternative: Deploy per SSH."
|
||||
exit 1
|
||||
fi
|
||||
docker compose version
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
# Nur was der Migrationsläufer braucht (`pg`), nicht der ganze Baum:
|
||||
# gebaut wird die Anwendung im Container, nicht hier.
|
||||
- name: Abhängigkeiten
|
||||
run: npm ci --omit=dev --ignore-scripts
|
||||
|
||||
# ── .env aus den Gitea-Secrets ──────────────────────────────────
|
||||
#
|
||||
# Nicht die .env vom Server lesen: der Job läuft in einem eigenen
|
||||
# Container, und dessen Dateisystem ist nicht das des Hosts. Die Werte
|
||||
# kommen deshalb aus den Repository-Secrets und werden hier
|
||||
# zusammengesetzt. Sie landen in keinem Abbild — docker compose reicht
|
||||
# sie zur Laufzeit an den Container weiter.
|
||||
- name: .env schreiben
|
||||
env:
|
||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||
MIGRATE_DATABASE_URL: ${{ secrets.MIGRATE_DATABASE_URL }}
|
||||
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
|
||||
AUTH_URL: ${{ secrets.AUTH_URL }}
|
||||
AUTH_MICROSOFT_ENTRA_ID_ID: ${{ secrets.AUTH_MICROSOFT_ENTRA_ID_ID }}
|
||||
AUTH_MICROSOFT_ENTRA_ID_SECRET: ${{ secrets.AUTH_MICROSOFT_ENTRA_ID_SECRET }}
|
||||
AUTH_MICROSOFT_ENTRA_ID_ISSUER: ${{ secrets.AUTH_MICROSOFT_ENTRA_ID_ISSUER }}
|
||||
CRON_SECRET: ${{ secrets.CRON_SECRET }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
fehlend=""
|
||||
for name in DATABASE_URL MIGRATE_DATABASE_URL AUTH_SECRET AUTH_URL \
|
||||
AUTH_MICROSOFT_ENTRA_ID_ID AUTH_MICROSOFT_ENTRA_ID_SECRET \
|
||||
AUTH_MICROSOFT_ENTRA_ID_ISSUER CRON_SECRET; do
|
||||
eval "wert=\${$name:-}"
|
||||
[ -n "$wert" ] || fehlend="$fehlend $name"
|
||||
done
|
||||
if [ -n "$fehlend" ]; then
|
||||
echo "Diese Secrets fehlen im Repository (Settings → Actions → Secrets):$fehlend"
|
||||
exit 1
|
||||
fi
|
||||
# printf statt echo: ein Wert, der mit - beginnt, wäre sonst ein Schalter.
|
||||
{
|
||||
printf 'DATABASE_URL=%s\n' "$DATABASE_URL"
|
||||
printf 'AUTH_SECRET=%s\n' "$AUTH_SECRET"
|
||||
printf 'AUTH_URL=%s\n' "$AUTH_URL"
|
||||
printf 'AUTH_MICROSOFT_ENTRA_ID_ID=%s\n' "$AUTH_MICROSOFT_ENTRA_ID_ID"
|
||||
printf 'AUTH_MICROSOFT_ENTRA_ID_SECRET=%s\n' "$AUTH_MICROSOFT_ENTRA_ID_SECRET"
|
||||
printf 'AUTH_MICROSOFT_ENTRA_ID_ISSUER=%s\n' "$AUTH_MICROSOFT_ENTRA_ID_ISSUER"
|
||||
printf 'CRON_SECRET=%s\n' "$CRON_SECRET"
|
||||
} > .env
|
||||
chmod 600 .env
|
||||
|
||||
# ── Migrationen ─────────────────────────────────────────────────
|
||||
#
|
||||
# Vor dem Neustart, nicht danach: der neue Code erwartet das neue
|
||||
# Schema. Umgekehrt liefe die neue Anwendung kurz gegen das alte und
|
||||
# fiele über fehlende Spalten.
|
||||
#
|
||||
# Erst zeigen, was ansteht — das steht dann im Protokoll des Laufs, auch
|
||||
# wenn danach etwas schiefgeht.
|
||||
- name: Ausstehende Migrationen zeigen
|
||||
env:
|
||||
MIGRATE_DATABASE_URL: ${{ secrets.MIGRATE_DATABASE_URL }}
|
||||
run: node scripts/migrate.mjs --dry-run
|
||||
|
||||
- name: Migrationen anwenden
|
||||
env:
|
||||
MIGRATE_DATABASE_URL: ${{ secrets.MIGRATE_DATABASE_URL }}
|
||||
run: node scripts/migrate.mjs
|
||||
|
||||
# ── Container ───────────────────────────────────────────────────
|
||||
#
|
||||
# Läuft gegen den Docker-Dienst des Hosts (der Runner-Container hat
|
||||
# dessen Socket eingehängt). Der Projektname wird ausdrücklich gesetzt:
|
||||
# sonst leitet ihn Compose vom Verzeichnisnamen ab, und der ist im
|
||||
# Arbeitsverzeichnis des Runners ein anderer als bei der ersten
|
||||
# Installation von Hand — es entstünde ein zweiter Stapel daneben,
|
||||
# während der alte weiterläuft.
|
||||
- name: Bauen und starten
|
||||
env:
|
||||
COMPOSE_PROJECT_NAME: ${{ vars.COMPOSE_PROJECT_NAME || 'alpenwerk-hr' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker compose build
|
||||
docker compose up -d --remove-orphans
|
||||
|
||||
# ── Nachweis ────────────────────────────────────────────────────
|
||||
#
|
||||
# Ohne diesen Schritt gilt ein Deploy als erfolgreich, sobald der
|
||||
# Container *gestartet* ist — auch wenn die Anwendung darin sofort
|
||||
# abstürzt. Gewartet wird auf den Healthcheck aus dem Dockerfile
|
||||
# (GET /login), nicht auf „läuft".
|
||||
- name: Warten, bis die Anwendung antwortet
|
||||
env:
|
||||
COMPOSE_PROJECT_NAME: ${{ vars.COMPOSE_PROJECT_NAME || 'alpenwerk-hr' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for versuch in $(seq 1 30); do
|
||||
zustand=$(docker compose ps --format '{{.Health}}' app | head -1)
|
||||
case "$zustand" in
|
||||
healthy) echo "Gesund nach $versuch Versuchen."; exit 0 ;;
|
||||
unhealthy) echo "Container meldet unhealthy."; break ;;
|
||||
esac
|
||||
sleep 4
|
||||
done
|
||||
echo "Anwendung ist nicht gesund geworden. Letzte Ausgaben:"
|
||||
docker compose ps
|
||||
docker compose logs --tail 80 app
|
||||
exit 1
|
||||
|
||||
- name: Aufräumen
|
||||
if: always()
|
||||
run: rm -f .env
|
||||
24
.gitignore
vendored
24
.gitignore
vendored
@@ -34,23 +34,21 @@ yarn-error.log*
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# supabase CLI local dev (generated, project-machine-specific)
|
||||
/supabase/.branches
|
||||
/supabase/.temp
|
||||
/supabase/snippets
|
||||
|
||||
# scratch output of `npm run types:generate`, for comparing against the
|
||||
# hand-written lib/supabase/types.ts — never itself imported
|
||||
/lib/supabase/types.generated.ts
|
||||
|
||||
# local scratch scripts/screenshots (ad-hoc verification against a real or
|
||||
# seeded DB — can carry HR data or use SUPABASE_SERVICE_ROLE_KEY; never commit)
|
||||
# Kladden und Bildschirmfotos aus der Handprüfung — können Personendaten
|
||||
# oder Zugangsdaten enthalten; nie committen.
|
||||
.scratch_*
|
||||
/.scratch_shots/
|
||||
|
||||
# Datenbank-Sicherungen (enthalten Personendaten) – nie committen.
|
||||
.backups/
|
||||
|
||||
# Datenabzuege aus dem Umzug – enthalten Personendaten
|
||||
|
||||
# Datenbank-Abzuege – enthalten Personendaten, nie committen.
|
||||
*.sql.gz
|
||||
/*.sql
|
||||
|
||||
344
DEPLOYMENT.md
344
DEPLOYMENT.md
@@ -1,31 +1,74 @@
|
||||
# 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.
|
||||
Die Anwendung läuft als Docker-Container auf einem eigenen Linux-Server,
|
||||
zusammen mit ihrer Datenbank. Das Abbild ist umgebungsneutral — es gibt
|
||||
keine Werte, die beim Bauen eingebacken werden; alles kommt zur Laufzeit
|
||||
aus `.env`.
|
||||
|
||||
Zu klären, bevor es losgeht: 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)).
|
||||
|
||||
## 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.
|
||||
- **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
|
||||
Vercel kein Vercel-Cron gibt, übernimmt das im `docker-compose.yml`
|
||||
enthaltene `cron`-Sidecar-Container diese Aufgabe mit demselben Schema
|
||||
und demselben Bearer-Secret, das die Route bereits erwartet.
|
||||
- **Containerisiert:** die Next.js-App (`Dockerfile`) **und die Datenbank**
|
||||
(Dienst `db`, `postgres:17-alpine`). Beide zusammen in
|
||||
`docker-compose.yml`; die Daten liegen im benannten Volume `db-daten`.
|
||||
- **Die Datenbank gehört zum Projekt.** Was eine gehostete Plattform sonst
|
||||
beisteuert, bringt `deploy/db-init/` mit: die Anwendungsrolle ohne
|
||||
BYPASSRLS, die zwei benutzten Erweiterungen und eine Attrappe des
|
||||
`auth`-Schemas, die nur die Migrationen von Juni 2026 brauchen.
|
||||
- Läuft die Datenbank woanders (Azure Flexible Server, RDS, eigenes Blech),
|
||||
genügt es, `DATABASE_URL` dorthin zeigen zu lassen und den `db`-Dienst nicht
|
||||
zu starten. Die Anwendung merkt keinen Unterschied.
|
||||
- **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.
|
||||
- **Der nächtliche Lauf** ist ein eigener Container (`cron`): täglich 03:00
|
||||
Uhr ruft er `/api/cron/apply-pending-changes` auf, um fällige Versetzungen,
|
||||
Beförderungen, Karenz- und Reorg-Änderungen zu übernehmen. Ausgewiesen wird
|
||||
der Aufruf über `CRON_SECRET` — es gibt dabei keine angemeldete Person.
|
||||
|
||||
## 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 |
|
||||
|
||||
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, braucht es dafür einen Proxy.
|
||||
|
||||
Die Datenbank taucht hier nicht mehr auf: sie läuft als Container daneben, im
|
||||
selben Compose-Netz. Nach aussen geht dafür nichts.
|
||||
|
||||
**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
|
||||
enthaltene `docker compose`) auf dem Zielserver.
|
||||
- Ein bestehendes Supabase-Projekt mit den Migrationen aus
|
||||
`supabase/migrations/` bereits eingespielt (`supabase db push` bzw. wie
|
||||
bisher).
|
||||
- Docker + Docker Compose (v2, das in Docker Engine enthaltene
|
||||
`docker compose`) auf dem Zielserver. Sonst nichts — weder Node noch psql;
|
||||
was gebraucht wird, kommt aus Containern.
|
||||
|
||||
## 1. `.env` anlegen
|
||||
|
||||
@@ -37,21 +80,24 @@ 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!) |
|
||||
| `POSTGRES_PASSWORD` | selbst erzeugen: `openssl rand -base64 24` — das des Verwalters |
|
||||
| `APP_DB_PASSWORD` | selbst erzeugen — das der Anwendungsrolle `alpenwerk_app` |
|
||||
| `DATABASE_URL` | `postgresql://alpenwerk_app:<APP_DB_PASSWORD>@db:5432/alpenwerk`. Die Rolle darf **kein** `BYPASSRLS` haben — `deploy/db-init` legt sie genau so an |
|
||||
| `DATABASE_SSL` | `false` im Compose-Netz; die Verbindung verlässt den Server nicht |
|
||||
| `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,35 +128,193 @@ 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.
|
||||
|
||||
### Bei einer leeren Datenbank
|
||||
|
||||
Der `db`-Container legt beim ersten Start Rollen und Erweiterungen an; das
|
||||
Schema kommt danach aus den Migrationen:
|
||||
|
||||
```bash
|
||||
docker compose up -d db
|
||||
docker compose run --rm migrate # legt Tabellen, Funktionen, Policies an
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
`migrate` steht im Profil `tools` und läuft bei `docker compose up` nicht mit.
|
||||
Auf dem Host wird dafür weder Node noch psql gebraucht — nur Docker.
|
||||
|
||||
### Was am `auth`-Schema übrigbleibt
|
||||
|
||||
`deploy/db-init/01-auth-attrappe.sql` legt ein leeres `auth`-Schema an. Es wird
|
||||
im Betrieb **nicht** gebraucht: kein Fremdschlüssel, keine Policy, keine
|
||||
Spaltenvorgabe verweist darauf (geprüft). Gebraucht wird es nur beim Abspielen
|
||||
der Migrationen von Juni 2026, die damals noch an `auth.users` hingen. Die
|
||||
Alternative wäre, jene Dateien umzuschreiben — also Geschichte zu fälschen: sie
|
||||
beschreiben, was damals galt.
|
||||
|
||||
## 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.
|
||||
|
||||
## 5. Updates ausrollen
|
||||
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. Automatisch ausrollen bei einem Push (Gitea Actions)
|
||||
|
||||
Ein Push auf `master` baut und startet die Anwendung auf dem Server neu.
|
||||
Zuständig ist [`.github/workflows/deploy.yml`](.github/workflows/deploy.yml).
|
||||
|
||||
> **Warum `.github/` und nicht `.gitea/`:** Gitea Actions liest
|
||||
> `.gitea/workflows` — und nur wenn dieses Verzeichnis **fehlt**, ersatzweise
|
||||
> `.github/workflows`. Ein neu angelegtes `.gitea/workflows/` würde also
|
||||
> `ci.yml` stillschweigend abschalten. Solange beide Dateien unter `.github/`
|
||||
> liegen, sieht Gitea beide.
|
||||
|
||||
### a) Der Runner
|
||||
|
||||
Verwendet wird der bestehende, globale Runner **`elycon-runner-01`** (act_runner
|
||||
v0.2.11, Labels `docker` und `ubuntu-latest`). Ein eigener ist nicht nötig.
|
||||
|
||||
Beide Workflows laufen deshalb auf `ubuntu-latest`. **Ein Label, das kein
|
||||
Runner führt, lässt den Lauf wortlos in der Warteschlange stehen** — kein
|
||||
Fehler, keine Meldung, nur „Waiting". Das ist der häufigste Grund, warum
|
||||
scheinbar nichts passiert.
|
||||
|
||||
#### Der Job läuft im Container, nicht auf dem Server
|
||||
|
||||
Das ist die Stelle, an der es klemmen kann. act_runner startet je Job einen
|
||||
eigenen Container; der Docker-Socket des **Hosts** ist darin nur, wenn der
|
||||
Runner so konfiguriert ist:
|
||||
|
||||
```yaml
|
||||
# config.yaml des Runners
|
||||
container:
|
||||
options: -v /var/run/docker.sock:/var/run/docker.sock
|
||||
valid_volumes:
|
||||
- /var/run/docker.sock
|
||||
```
|
||||
|
||||
Zusätzlich muss das Job-Abbild die Docker-CLI mitbringen (die üblichen
|
||||
`runner-images`/`catthehacker`-Abbilder tun das).
|
||||
|
||||
Der erste Schritt des Deploy-Workflows prüft genau das und bricht mit einer
|
||||
Anleitung ab, wenn etwas fehlt — statt drei Schritte später an einer Meldung zu
|
||||
scheitern, mit der niemand etwas anfangen kann.
|
||||
|
||||
> Der durchgereichte Socket gibt dem Job faktisch Wurzelrechte auf dem Host.
|
||||
> Bei einem Deploy-Runner ist das der Zweck der Übung, aber es heisst auch: wer
|
||||
> in dieses Repository schreiben darf, darf auf diesem Server alles. Bei einem
|
||||
> **globalen** Runner gilt das für jedes Repository der Instanz, das denselben
|
||||
> Socket benutzt. Ist das zu weit gefasst, ist der Weg über SSH der richtige:
|
||||
> ein Schlüssel als Repository-Secret, der nur auf den einen Server und nur auf
|
||||
> das Deploy-Verzeichnis zeigt.
|
||||
|
||||
### b) Secrets hinterlegen
|
||||
|
||||
Gitea → Repository → *Settings* → *Actions* → *Secrets*. Dieselben Werte wie in
|
||||
[Abschnitt 1](#1-env-anlegen); der Workflow baut daraus zur Laufzeit die `.env`
|
||||
und löscht sie danach wieder.
|
||||
|
||||
| Secret | Anmerkung |
|
||||
|---|---|
|
||||
| `DATABASE_URL` | Verbindung der Anwendungsrolle — die benutzt die Anwendung |
|
||||
| `MIGRATE_DATABASE_URL` | Zugang als Verwalter — nur für die Migrationen |
|
||||
| `AUTH_SECRET` | |
|
||||
| `AUTH_URL` | z. B. `https://hr.elycon.solutions` |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_ID` | |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_ISSUER` | |
|
||||
| `CRON_SECRET` | |
|
||||
|
||||
Warum zwei Verbindungsstrings: der Transaktions-Pooler bricht bei einer
|
||||
Migration ab, die mehrere Anweisungen in einer Transaktion bündelt. Migrationen
|
||||
gehören über die direkte Verbindung, der laufende Betrieb über den Pooler.
|
||||
|
||||
Zusätzlich unter *Variables* (kein Secret, nur eine Einstellung):
|
||||
|
||||
| Variable | Vorgabe | Wofür |
|
||||
|---|---|---|
|
||||
| `COMPOSE_PROJECT_NAME` | `alpenwerk-hr` | Muss zum bestehenden Stapel passen — sonst entsteht ein zweiter daneben |
|
||||
|
||||
Den bestehenden Namen zeigt auf dem Server:
|
||||
|
||||
```bash
|
||||
docker compose ls
|
||||
```
|
||||
|
||||
### c) Was der Lauf tut
|
||||
|
||||
1. `.env` aus den Secrets schreiben (fehlt eines, bricht er mit Namen ab).
|
||||
2. **Migrationen anwenden** — erst `--dry-run` fürs Protokoll, dann echt.
|
||||
3. `docker compose build` und `up -d` gegen den Docker-Dienst des Hosts.
|
||||
4. Auf `healthy` warten (bis zu zwei Minuten). Wird die Anwendung nicht gesund,
|
||||
schlägt der Lauf fehl und hängt die letzten 80 Logzeilen an.
|
||||
5. `.env` wieder löschen.
|
||||
|
||||
Der Workflow ist **nicht** an `ci.yml` gekoppelt: ein Push auf `master` rollt
|
||||
aus, auch wenn die Tests parallel noch laufen. Soll erst nach grünen Tests
|
||||
ausgerollt werden, gehört der `deploy`-Job mit `needs: [check]` in `ci.yml` —
|
||||
dann allerdings braucht der Runner auch das Label, mit dem `check` läuft.
|
||||
|
||||
## 5a. Migrationen
|
||||
|
||||
Eingespielt werden sie von [`scripts/migrate.mjs`](scripts/migrate.mjs) —
|
||||
demselben Läufer, den auch der Deploy benutzt:
|
||||
|
||||
```bash
|
||||
node --env-file=.env scripts/migrate.mjs --dry-run # was stünde an
|
||||
node --env-file=.env scripts/migrate.mjs # anwenden
|
||||
```
|
||||
|
||||
Buch geführt wird in `migrationen.schema_migrations`: eine Zeile je
|
||||
angewendeter Datei, mit ihrem Text. Der Läufer wendet nur an, was dort
|
||||
fehlt.
|
||||
|
||||
> **Einmalig, im August 2026 bereits erledigt:** Die Migrationen wurden bis
|
||||
> dahin von Hand eingespielt, die Buchführungstabelle existierte gar nicht. Ein
|
||||
> automatischer Lauf hätte deshalb alle 65 Dateien erneut gegen die
|
||||
> produktive Datenbank gespielt — inklusive `initial_schema` und der
|
||||
> OM-Umstellung. Vor dem Einschalten wurde einmal
|
||||
> `node scripts/migrate.mjs --baseline` gefahren: das verbucht alles Vorhandene
|
||||
> als angewendet, **ohne es auszuführen**. Wer eine weitere Umgebung aufsetzt,
|
||||
> deren Datenbank schon steht, braucht denselben Schritt.
|
||||
|
||||
## 5b. Updates von Hand ausrollen
|
||||
|
||||
Falls der Runner nicht läuft oder ein Stand ausser der Reihe gebraucht wird:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
node --env-file=.env scripts/migrate.mjs
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
@@ -119,13 +323,8 @@ Kurzer Downtime-Moment beim Neustart des `app`-Containers ist bei dieser
|
||||
Single-Instance-Compose-Konfiguration normal. Für Zero-Downtime-Deployments
|
||||
wäre eine zweite Instanz + Load Balancer nötig (siehe Abschnitt 6).
|
||||
|
||||
Datenbank-Migrationen (`supabase/migrations/*.sql`) werden weiterhin über
|
||||
die Supabase CLI gegen das Supabase-Projekt gefahren, unabhängig vom
|
||||
App-Deployment:
|
||||
|
||||
```bash
|
||||
supabase db push
|
||||
```
|
||||
Der Migrationsschritt steht bewusst **vor** dem Neubau: der neue Code erwartet
|
||||
das neue Schema, und umgekehrt liefe die neue Anwendung kurz gegen das alte.
|
||||
|
||||
## 6. Hinweis bei mehreren Replicas
|
||||
|
||||
@@ -145,12 +344,35 @@ 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).
|
||||
- **Push gemacht, aber kein Lauf startet:** meist die Labels. Der Lauf steht
|
||||
dann in Gitea unter *Actions* als „Waiting" — ohne Fehlermeldung, weil kein
|
||||
Runner das verlangte Label anbietet. Beide Workflows verlangen
|
||||
`ubuntu-latest`; `elycon-runner-01` führt es. Welche Labels registriert sind,
|
||||
zeigt Gitea → *Site Administration* → *Actions* → *Runners*.
|
||||
- **Deploy läuft, aber es entsteht ein zweiter Stapel:** `COMPOSE_PROJECT_NAME`
|
||||
passt nicht zum bestehenden. Auf dem Server `docker compose ls` — der dort
|
||||
gelistete Name gehört als Variable ins Repository. Bis dahin läuft die alte
|
||||
Instanz weiter und beansprucht Port 3000; die neue scheitert daran.
|
||||
- **`Cannot connect to the Docker daemon` im Deploy:** dem Runner-Container
|
||||
fehlt der Socket. Er braucht `-v /var/run/docker.sock:/var/run/docker.sock`.
|
||||
- **Migration schlägt im Deploy fehl:** der Lauf bricht ab, bevor Container
|
||||
angefasst werden — die alte Version läuft also weiter. Die fehlgeschlagene
|
||||
Datei wurde vollständig zurückgerollt und **nicht** verbucht; nach der
|
||||
Korrektur reicht ein erneuter Push.
|
||||
|
||||
- **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).
|
||||
- **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
|
||||
|
||||
111
README.md
111
README.md
@@ -24,11 +24,13 @@ auf explizit aktivierte HR-Benutzer:innen beschränkt (siehe
|
||||
`node_modules/next/dist/docs/` konsultieren; sie sind maßgeblich, ältere
|
||||
Anleitungen im Netz beschreiben teils überholte APIs.
|
||||
- React 19, TypeScript
|
||||
- Supabase (Postgres, Auth, RLS) — Datenhaltung liegt vollständig in
|
||||
Supabase, nicht im Next.js-Prozess.
|
||||
- PostgreSQL, angesprochen über Kysely und `pg` — die Datenhaltung liegt in
|
||||
der Datenbank, nicht im Next.js-Prozess. Der Zugriff läuft ausschliesslich
|
||||
über `withUser()` (`lib/db/`), das den Sitzungskontext setzt.
|
||||
- Auth.js gegen Microsoft Entra ID — keine eigenen Passwörter.
|
||||
- Tailwind CSS v4
|
||||
- Vitest — Unit- (Node), Komponenten- (jsdom) und Integrationstests
|
||||
(gegen ein lokales Supabase)
|
||||
(gegen eine erreichbare Datenbank)
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -38,14 +40,14 @@ cp .env.example .env.local # Werte eintragen, siehe unten
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Für lokale Supabase-Entwicklung (statt gegen ein Cloud-Projekt):
|
||||
Für eine lokale Datenbank genügt der Container aus `docker-compose.yml`:
|
||||
|
||||
```bash
|
||||
supabase start # startet lokalen Postgres/Auth/Studio-Stack
|
||||
docker compose up -d db
|
||||
docker compose run --rm migrate # spielt db/migrations/ ein
|
||||
```
|
||||
|
||||
`supabase/config.toml` und `.env.test.local` sind bereits auf die
|
||||
Standard-Ports der lokalen Supabase-CLI abgestimmt.
|
||||
Danach zeigt `DATABASE_URL` in `.env.local` auf diese Datenbank.
|
||||
|
||||
## Umgebungsvariablen
|
||||
|
||||
@@ -54,14 +56,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
|
||||
|
||||
@@ -73,9 +79,10 @@ für Docker-Deployments, siehe unten).
|
||||
| `npm run lint` | ESLint (`eslint-config-next`, Flat Config) |
|
||||
| `npm run typecheck` | `tsc --noEmit` |
|
||||
| `npm run test` | Vitest, Unit-Tests (`tests/unit/**`) |
|
||||
| `npm run test:integration` | Vitest gegen eine echte (lokale) Supabase-Instanz — braucht `supabase start` und `.env.test.local` |
|
||||
| `npm run test:integration` | Vitest gegen eine erreichbare Datenbank; überspringt sich ohne `DATABASE_URL` |
|
||||
| `npm run test:e2e` | Playwright |
|
||||
| `npm run check` | lint + typecheck + test + build in Folge |
|
||||
| `npm run migrate` | Ausstehende Migrationen einspielen (`--dry-run`, `--baseline`) |
|
||||
| `npm run check` | lint + typecheck + types:check + test + build in Folge — dieselben Schritte wie der CI-Job „check" |
|
||||
|
||||
## Sicherheitsprinzipien
|
||||
|
||||
@@ -84,11 +91,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
|
||||
@@ -101,51 +113,50 @@ für Docker-Deployments, siehe unten).
|
||||
`/api/cron/apply-pending-changes` wendet wirksam gewordene, zukunftsdatierte
|
||||
Änderungen an (`pending_org_changes` → `apply_due_pending_changes()`).
|
||||
|
||||
- **Auf Vercel:** `vercel.json` definiert den täglichen Schedule; Vercel Cron
|
||||
sendet `Authorization: Bearer <CRON_SECRET>` automatisch, wenn
|
||||
`CRON_SECRET` in den Projekt-Env-Vars gesetzt ist.
|
||||
- **Außerhalb von Vercel (Docker):** kein Vercel Cron verfügbar — siehe
|
||||
[`DEPLOYMENT.md`](DEPLOYMENT.md) für den Cron-Sidecar-Container, der
|
||||
denselben Endpoint mit demselben Schema aufruft.
|
||||
- Den Zeitplan hält der `cron`-Container aus `docker-compose.yml`: täglich
|
||||
03:00 Uhr, mit `Authorization: Bearer <CRON_SECRET>`.
|
||||
- Fehlt `CRON_SECRET` oder stimmt der Header nicht, antwortet die Route mit
|
||||
`401` (nicht `500` — bewusst, siehe `tests/unit/security.test.ts`).
|
||||
|
||||
## Supabase-Hinweise
|
||||
## Datenbank
|
||||
|
||||
- Schema-Quelle der Wahrheit: `supabase/migrations/`. Menschlich lesbare
|
||||
Zusammenfassung: [`docs/data-model.md`](docs/data-model.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
|
||||
Entwicklung/Tests gedacht, nie für ein Produktivprojekt verwenden.
|
||||
- Schema-Quelle der Wahrheit: `db/migrations/`. Menschlich lesbare Fassung,
|
||||
aus der laufenden Datenbank erzeugt:
|
||||
[`docs/datenkatalog.md`](docs/datenkatalog.md).
|
||||
- Migrationen einspielen: `npm run migrate`. Der Läufer wendet nur die
|
||||
fehlenden Dateien an, jede in ihrer eigenen Transaktion, und führt darüber
|
||||
Buch in `migrationen.schema_migrations`.
|
||||
- `lib/types.ts` wird von Hand gepflegt. `npm run types:check` hält sie
|
||||
Spalte für Spalte gegen die Migrationen.
|
||||
|
||||
## Testing
|
||||
|
||||
- `npm run test` — schnell, keine externen Abhängigkeiten, läuft in CI.
|
||||
- `npm run test:integration` — braucht eine laufende lokale Supabase-Instanz
|
||||
(`supabase start`) und `.env.test.local`; prüft RLS-Verhalten end-to-end
|
||||
(siehe `tests/integration/authorization.test.ts` für das HR-Only-Zugriffs-
|
||||
modell).
|
||||
- `npm run test:integration` — braucht eine erreichbare Datenbank
|
||||
(`DATABASE_URL`). Geprüft werden Regeln, die es zweimal gibt: einmal als
|
||||
SQL, einmal als TypeScript. Ohne `DATABASE_URL` überspringen sich die
|
||||
Dateien, statt mit einem Verbindungsfehler abzubrechen.
|
||||
- `npm run test:e2e` — Playwright gegen einen laufenden Dev-/Preview-Server.
|
||||
|
||||
## Deployment
|
||||
|
||||
Siehe [`DEPLOYMENT.md`](DEPLOYMENT.md) für Docker-basiertes Deployment
|
||||
(Dockerfile, docker-compose.yml, Reverse-Proxy/TLS, Cron-Ersatz, Updates).
|
||||
Für Vercel: `vercel.json` ist bereits vorhanden; Env-Vars im
|
||||
Vercel-Projekt setzen (siehe oben).
|
||||
Siehe [`DEPLOYMENT.md`](DEPLOYMENT.md): Anwendung und Datenbank als Container,
|
||||
Reverse-Proxy/TLS, der nächtliche Lauf, Migrationen und Updates.
|
||||
|
||||
## Known TODOs vor Produktivbetrieb
|
||||
|
||||
- **Content-Security-Policy fehlt noch** (`next.config.ts` setzt bewusst
|
||||
keine CSP — Skript-/Style-/Connect-Quellen sind noch nicht vollständig
|
||||
inventarisiert; ungeprüft geraten zu setzen riskiert, Hydration oder den
|
||||
Supabase-Client stillschweigend zu brechen).
|
||||
- **Lokale Scratch-Artefakte** (`.scratch_*`, `.scratch_shots/`) enthalten
|
||||
Screenshots/Hilfsskripte aus einer früheren manuellen Verifikation und
|
||||
liegen noch im Arbeitsverzeichnis. Sie sind jetzt über `.gitignore`
|
||||
ausgeschlossen; vor einem Produktiv-Handover sollten sie durchgesehen und
|
||||
bei Bedarf gelöscht werden.
|
||||
- **Content-Security-Policy läuft im Nur-Bericht-Modus** (`next.config.ts`).
|
||||
Erzwungen wird sie erst, wenn die Meldungen sauber sind — eine geratene,
|
||||
erzwungene Richtlinie blendet die Anwendung für alle aus.
|
||||
- **Seed für die Integrationstests fehlt.** Er lag in der abgelösten
|
||||
Umgebung. Zwei der drei Integrationstests vergleichen Regeln über den
|
||||
gesamten Bestand und brauchen dafür Daten; bis der Seed nachgezogen ist,
|
||||
laufen sie nur gegen eine bereits befüllte Datenbank, nicht in der CI.
|
||||
- **Sicherheitsprüfung des heutigen Aufbaus steht aus** — siehe
|
||||
[`docs/security-review.md`](docs/security-review.md).
|
||||
- **Bildschirmfotos unter `.scratch_shots/`** stammen aus einer früheren
|
||||
Handprüfung. Sie sind über `.gitignore` ausgeschlossen; vor der Übergabe
|
||||
durchsehen und löschen.
|
||||
- **Kein granulareres Rollenmodell** — aktuell HR-only (alles-oder-nichts).
|
||||
Falls z. B. eine reine Lese-Rolle künftig gebraucht wird, gehört die
|
||||
Erweiterung in eine neue Migration (`is_hr_user()`/RLS-Policies), nicht in
|
||||
|
||||
@@ -1,24 +1,86 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { AuthError } from "next-auth";
|
||||
import { signIn, signOut } from "@/auth";
|
||||
|
||||
export async function login(formData: FormData) {
|
||||
const email = String(formData.get("email") ?? "");
|
||||
const password = String(formData.get("password") ?? "");
|
||||
// Zwei Anmeldewege, und der zweite ist auf Zeit.
|
||||
//
|
||||
// **Entra ID ist der Hauptweg.** Was der Mandant vorgibt — Mehrfaktor,
|
||||
// bedingter Zugriff, Sperrung beim Austritt — gilt nur auf diesem Weg. Ein
|
||||
// zweiter Weg daneben hebelt all das aus, und deshalb stand hier bis zum
|
||||
// 08.09.2026, dass es ihn bewusst nicht gibt.
|
||||
//
|
||||
// **Warum es ihn jetzt trotzdem gibt.** Fünf Mitarbeiterinnen des Kunden
|
||||
// (@manner.com) sollen die Anwendung testen. Manner hat einen eigenen
|
||||
// Entra-Mandanten; eine Gasteinladung müsste deren IT einrichten und dauert
|
||||
// Wochen. Die Daten im System sind zu diesem Zeitpunkt synthetisch.
|
||||
//
|
||||
// **Was den Weg begrenzt** — nichts davon steht hier, alles in der Datenbank
|
||||
// (Migration 20260908120000), weil eine Regel im Anwendungscode die Regel
|
||||
// wäre, die sich umgehen lässt:
|
||||
//
|
||||
// • Zwang zum Wechsel beim ersten Mal; solange er aussteht, liefert
|
||||
// is_hr_user() false und damit gibt keine einzige Policy eine Zeile her
|
||||
// • sieben Tage Frist für einen nie benutzten Zugang
|
||||
// • Sperre für 15 Minuten nach fünf Fehlversuchen
|
||||
// • Enddatum 08.10.2026, als Prüfbedingung festgenagelt
|
||||
// (chk_passwort_pfad_endet) — danach nimmt die Anmeldung kein Passwort
|
||||
// mehr an, ohne dass sich jemand erinnern muss
|
||||
//
|
||||
// **Vor den echten Manner-Daten muss der Passwortpfad weg sein.** Das ist die
|
||||
// Bedingung, unter der er entstanden ist, nicht eine Empfehlung.
|
||||
//
|
||||
// Die Herkunft muss hier nicht 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 });
|
||||
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: "/" });
|
||||
}
|
||||
|
||||
if (error) {
|
||||
redirect("/login?error=invalid_credentials");
|
||||
export type AnmeldeZustand = { fehler: string | null };
|
||||
|
||||
/**
|
||||
* Anmeldung mit E-Mail und Passwort.
|
||||
*
|
||||
* Es gibt **eine** Fehlermeldung für jeden Fehlschlag. Ob die Adresse
|
||||
* unbekannt, das Passwort falsch, das Konto gesperrt oder die Frist abgelaufen
|
||||
* ist, steht im Protokoll und nicht auf dem Bildschirm: das Anmeldeformular
|
||||
* steht offen im Internet, und eine Meldung, die zwischen „gibt es nicht" und
|
||||
* „falsches Passwort" unterscheidet, ist ein Verzeichnis der Belegschaft.
|
||||
* app_passwort_pruefen() nennt aus demselben Grund keinen Grund.
|
||||
*/
|
||||
export async function anmeldenMitPasswort(
|
||||
_zustand: AnmeldeZustand,
|
||||
formular: FormData
|
||||
): Promise<AnmeldeZustand> {
|
||||
const email = String(formular.get("email") ?? "").trim();
|
||||
const passwort = String(formular.get("passwort") ?? "");
|
||||
|
||||
if (!email || !passwort) {
|
||||
return { fehler: "Bitte E-Mail-Adresse und Passwort eingeben." };
|
||||
}
|
||||
|
||||
redirect("/");
|
||||
try {
|
||||
await signIn("passwort", { email, passwort, redirectTo: "/" });
|
||||
} catch (fehler) {
|
||||
// Bei Erfolg wirft signIn die Weiterleitung von Next.js. Die darf hier
|
||||
// **nicht** hängenbleiben, sonst endet die Anmeldung auf der Anmeldeseite,
|
||||
// obwohl das Cookie längst gesetzt ist. Nur ein AuthError ist ein
|
||||
// Fehlschlag; alles andere gehört weitergereicht.
|
||||
if (fehler instanceof AuthError) {
|
||||
return { fehler: "E-Mail-Adresse oder Passwort stimmt nicht." };
|
||||
}
|
||||
throw fehler;
|
||||
}
|
||||
|
||||
// Unerreichbar — signIn kehrt bei Erfolg nicht zurück. Steht da, weil der
|
||||
// Typprüfer einen Rückgabewert auf jedem Pfad verlangt.
|
||||
return { fehler: null };
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
const supabase = await createClient();
|
||||
await supabase.auth.signOut();
|
||||
redirect("/login");
|
||||
await signOut({ redirectTo: "/login" });
|
||||
}
|
||||
|
||||
@@ -1,22 +1,67 @@
|
||||
"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 { OFFBOARDING_PUNKTE } from "@/lib/offboarding";
|
||||
import { ONBOARDING_PUNKTE } from "@/lib/onboarding";
|
||||
import type { CollectiveAgreement, DienstwagenArt, NoteCategory, PaygradeType, RelationshipType, Weekday, WorkerType } from "@/lib/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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist diese Personalnummer noch frei?
|
||||
*
|
||||
* ── Warum das trotz der Prüfung in der Datenbank hier steht ──────────
|
||||
*
|
||||
* `hire_employee` weist eine vergebene Nummer ab, und das bleibt die
|
||||
* verbindliche Prüfung: zwischen dieser Frage und dem Anlegen können Sekunden
|
||||
* liegen, und in denen kann jemand anderes dieselbe Nummer vergeben. Der
|
||||
* Unique-Index ist die einzige Stelle, die das sicher ausschliesst.
|
||||
*
|
||||
* Nur kommt diese Abweisung ganz am Ende — nach Position, Vertrag,
|
||||
* Angehörigen, Notfallkontakt, sechs Schritten Eingabe. Die Nummer steht im
|
||||
* *ersten* Feld des *ersten* Schritts. Wer sie vertippt, erfährt es
|
||||
* frühestens nach fünf Minuten und darf dann suchen, welche der Angaben
|
||||
* gemeint war.
|
||||
*
|
||||
* Deshalb hier die frühe Auskunft und dort die Entscheidung. Es ist keine
|
||||
* doppelte Wahrheit, sondern dieselbe Frage zu zwei Zeitpunkten — und wenn
|
||||
* die späte Antwort einmal abweicht, gewinnt sie.
|
||||
*
|
||||
* Kein `ActionResult`: eine vergebene Nummer ist kein Fehler des Aufrufs,
|
||||
* sondern die Antwort auf die Frage.
|
||||
*/
|
||||
export async function personalnummerVergeben(nummer: number): Promise<{ vergeben: boolean; name?: string }> {
|
||||
if (!Number.isInteger(nummer) || nummer <= 0) return { vergeben: false };
|
||||
return withUser(await currentUserId(), async (tx) => {
|
||||
const treffer = await tx
|
||||
.selectFrom("employees")
|
||||
.select(["first_name", "last_name"])
|
||||
.where("personnel_number", "=", nummer)
|
||||
.executeTakeFirst();
|
||||
// Der Name wird mitgegeben, weil „bereits vergeben" allein die Frage
|
||||
// aufwirft, an wen — und die Antwort ist meistens der Grund: dieselbe
|
||||
// Person ist schon angelegt, oder es war ein Zahlendreher.
|
||||
return treffer ? { vergeben: true, name: `${treffer.first_name} ${treffer.last_name}` } : { vergeben: false };
|
||||
});
|
||||
}
|
||||
|
||||
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 +69,24 @@ 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;
|
||||
/**
|
||||
* Die **dienstliche** Adresse, freiwillig und eindeutig.
|
||||
*
|
||||
* Eigenes Feld neben der privaten, weil nur diese das Haus verlassen darf:
|
||||
* der Export für die Mitarbeiterbefragung geht an einen fremden Anbieter,
|
||||
* der in unserem Namen Einladungen verschickt.
|
||||
*/
|
||||
company_email?: string;
|
||||
/** Kennung im Lernsystem Cornerstone. Freiwillig. */
|
||||
cornerstone_id?: string;
|
||||
phone?: string;
|
||||
position_id?: string;
|
||||
team_id?: string;
|
||||
@@ -34,39 +97,117 @@ export async function hireEmployee(payload: {
|
||||
contract_end_date?: string;
|
||||
employment_type?: "Vollzeit" | "Teilzeit";
|
||||
weekly_hours?: number;
|
||||
paygrade?: "A" | "B" | "C" | "D" | "E" | "F";
|
||||
paygrade?: PaygradeType;
|
||||
source: "Intern" | "Extern";
|
||||
worker_type?: WorkerType;
|
||||
collective_agreement?: CollectiveAgreement;
|
||||
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;
|
||||
/** Form der Beschäftigung — chk_mitarbeiterart. Leer heisst „unverändert". */
|
||||
mitarbeiterart?: string;
|
||||
has_kuendigungsschutz?: boolean;
|
||||
/** Nur mit dem Kennzeichen zusammen — so verlangt es chk_kuendigungsschutz_bis. */
|
||||
kuendigungsschutz_bis?: string | null;
|
||||
/** Ebenfalls nur mit dem Kennzeichen — chk_kuendigungsschutz_grund. */
|
||||
kuendigungsschutz_grund?: string | null;
|
||||
kuendigungsschutz_ab?: string | null;
|
||||
ist_beguenstigt_behindert?: boolean;
|
||||
/** Nur mit dem Kennzeichen — chk_behinderung. Als Text, wie er aus dem Feld kommt. */
|
||||
behinderung_grad?: string | null;
|
||||
behinderung_ab?: string | null;
|
||||
behinderung_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(), async (tx) => {
|
||||
const id = (await callFunction(tx, "hire_employee", payload as Record<string, unknown>)) as string;
|
||||
// In derselben Transaktion: eine Einstellung ohne Checkliste wäre eine
|
||||
// halb erfasste Einstellung, und sie später nachzureichen hiesse, dass
|
||||
// jemand daran denken muss.
|
||||
await callFunction(tx, "start_onboarding", { employee_id: id, item_keys: ONBOARDING_PUNKTE.map((x) => x.key) });
|
||||
return id;
|
||||
});
|
||||
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: {
|
||||
employee_id: string;
|
||||
exit_date: string;
|
||||
exit_reason: string;
|
||||
/**
|
||||
* Freiwillig oder unfreiwillig — leer heisst „nicht erfasst".
|
||||
*
|
||||
* Eigene Angabe und nicht aus `exit_reason` abgeleitet: die einvernehmliche
|
||||
* Auflösung kann von beiden Seiten ausgehen (siehe lib/beendigung.ts).
|
||||
*/
|
||||
austrittsart?: string;
|
||||
note?: string;
|
||||
}): Promise<ActionResult> {
|
||||
return callRpc("terminate_employee", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]);
|
||||
// No Show ist kein Austritt im gewohnten Sinn — die Person hat nie
|
||||
// angefangen. Dafür gibt es nichts offzuboarden: keinen IT-Zugang, der
|
||||
// eingerichtet wurde, keine GKK-Anmeldung, kein Dienstzettel. Die Liste
|
||||
// entstünde leer und wäre ein Etikett ohne Inhalt.
|
||||
if (payload.exit_reason === "No Show") {
|
||||
return callRpc("terminate_employee", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]);
|
||||
}
|
||||
|
||||
try {
|
||||
await withUser(await currentUserId(), async (tx) => {
|
||||
await callFunction(tx, "terminate_employee", payload as unknown as Record<string, unknown>);
|
||||
// In derselben Transaktion: ein Austritt ohne Checkliste wäre ein
|
||||
// halb erfasster Austritt, und sie später anzulegen hiesse, dass
|
||||
// jemand daran denken muss.
|
||||
await callFunction(tx, "start_offboarding", {
|
||||
employee_id: payload.employee_id,
|
||||
item_keys: OFFBOARDING_PUNKTE.map((x) => x.key),
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
||||
}
|
||||
for (const path of [`/employees/${payload.employee_id}`, "/employees", "/"]) revalidatePath(path);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** Einen Punkt der Offboarding-Checkliste festhalten — Haken, Wert oder Kommentar. */
|
||||
export async function setOffboardingTask(payload: {
|
||||
employee_id: string;
|
||||
item_key: string;
|
||||
erledigt?: boolean;
|
||||
wert?: string | null;
|
||||
kommentar?: string | null;
|
||||
}): Promise<ActionResult> {
|
||||
return callRpc("set_offboarding_task", payload, [`/employees/${payload.employee_id}`]);
|
||||
}
|
||||
|
||||
/** Legt die Offboarding-Checkliste nachträglich an — für Austritte von vor dieser Liste. */
|
||||
export async function startOffboarding(employeeId: string): Promise<ActionResult> {
|
||||
return callRpc(
|
||||
"start_offboarding",
|
||||
{ employee_id: employeeId, item_keys: OFFBOARDING_PUNKTE.map((x) => x.key) },
|
||||
[`/employees/${employeeId}`]
|
||||
);
|
||||
}
|
||||
|
||||
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"]);
|
||||
}
|
||||
@@ -75,7 +216,14 @@ export async function promoteEmployee(payload: {
|
||||
employee_id: string;
|
||||
effective_date: string;
|
||||
new_title: string;
|
||||
new_paygrade?: "A" | "B" | "C" | "D" | "E" | "F";
|
||||
new_paygrade?: PaygradeType;
|
||||
/**
|
||||
* Nur, wenn die Beförderung zugleich auf eine andere Planstelle führt.
|
||||
*
|
||||
* Fehlt sie, bleibt die Person auf ihrer bisherigen — das ist der andere
|
||||
* der beiden Fälle, nicht ein vergessenes Feld.
|
||||
*/
|
||||
target_position_id?: string;
|
||||
}): Promise<ActionResult> {
|
||||
return callRpc("promote_employee", payload, [`/employees/${payload.employee_id}`, "/employees"]);
|
||||
}
|
||||
@@ -103,6 +251,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", "/"]);
|
||||
}
|
||||
@@ -112,13 +264,76 @@ export async function changeEmployeeData(payload: {
|
||||
effective_date: string;
|
||||
person: Record<string, unknown>;
|
||||
contract: Record<string, unknown>;
|
||||
// `role` trägt seit 20260917120000 auch `source`. Wechselt sie von
|
||||
// „Extern" auf „Intern", schreibt change_employee_data zusätzlich das
|
||||
// Ereignis „Übernahme" — der umgekehrte Weg erzeugt keines.
|
||||
role: Record<string, unknown>;
|
||||
}): Promise<ActionResult> {
|
||||
return callRpc("change_employee_data", payload, [`/employees/${payload.employee_id}`, "/employees"]);
|
||||
}
|
||||
|
||||
export async function rehireEmployee(payload: { employee_id: string; rehire_date: string }): Promise<ActionResult> {
|
||||
return callRpc("rehire_employee", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]);
|
||||
/**
|
||||
* `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;
|
||||
company_email?: string;
|
||||
cornerstone_id?: string;
|
||||
rehire_date: string;
|
||||
position_id: string;
|
||||
/**
|
||||
* Die Stammdaten, wie sie der Assistent zeigt — alle freiwillig.
|
||||
*
|
||||
* Fehlt ein Schlüssel, bleibt der bestehende Wert stehen. Das hält den
|
||||
* schlanken Aufruf (nur Datum und Planstelle) am Leben und erlaubt dem
|
||||
* Assistenten trotzdem, den ganzen Satz zu schicken, ohne dass danach eine
|
||||
* zweite Transaktion nötig wäre — siehe Migration 20260917130000.
|
||||
*/
|
||||
[feld: string]: unknown;
|
||||
}): Promise<ActionResult> {
|
||||
try {
|
||||
await withUser(await currentUserId(), async (tx) => {
|
||||
await callFunction(tx, "rehire_employee", payload as unknown as Record<string, unknown>);
|
||||
// Auch bei der Wiedereinstellung: Dienstzettel, Bankverbindung und
|
||||
// E-Card sind wieder zu erledigen. Die Punkte von damals stehen noch da
|
||||
// und bleiben stehen — start_onboarding legt nur an, was fehlt, statt
|
||||
// einen alten Haken zu löschen. Was wirklich neu zu tun ist, entscheidet
|
||||
// HR an der Liste; ein Programm an ihrer Stelle würde raten.
|
||||
await callFunction(tx, "start_onboarding", {
|
||||
employee_id: payload.employee_id,
|
||||
item_keys: ONBOARDING_PUNKTE.map((x) => x.key),
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
||||
}
|
||||
for (const path of [`/employees/${payload.employee_id}`, "/employees", "/"]) revalidatePath(path);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** Einen Punkt der Checkliste festhalten — Haken, Wert oder Kommentar. */
|
||||
export async function setOnboardingTask(payload: {
|
||||
employee_id: string;
|
||||
item_key: string;
|
||||
erledigt?: boolean;
|
||||
wert?: string | null;
|
||||
kommentar?: string | null;
|
||||
}): Promise<ActionResult> {
|
||||
return callRpc("set_onboarding_task", payload, [`/employees/${payload.employee_id}`]);
|
||||
}
|
||||
|
||||
/** Legt die Checkliste nachträglich an — für Personen von vor dieser Liste. */
|
||||
export async function startOnboarding(employeeId: string): Promise<ActionResult> {
|
||||
return callRpc(
|
||||
"start_onboarding",
|
||||
{ employee_id: employeeId, item_keys: ONBOARDING_PUNKTE.map((x) => x.key) },
|
||||
[`/employees/${employeeId}`]
|
||||
);
|
||||
}
|
||||
|
||||
export async function addEmployeeDependent(payload: {
|
||||
@@ -133,6 +348,25 @@ export async function addEmployeeDependent(payload: {
|
||||
return callRpc("add_employee_dependent", payload, [`/employees/${payload.employee_id}`]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Angabe berichtigen — ohne Stichtag.
|
||||
*
|
||||
* Hinzufügen und Entfernen tragen einen: dort passiert etwas zu einem
|
||||
* Zeitpunkt. Eine Berichtigung nicht — der Wert war schon vorher falsch, und
|
||||
* ein „wirksam ab" hiesse, die Person habe bis dahin anders geheissen.
|
||||
*/
|
||||
export async function updateEmployeeDependent(payload: {
|
||||
dependent_id: string;
|
||||
employee_id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
relationship: RelationshipType;
|
||||
sv_nummer?: string;
|
||||
birth_date: string;
|
||||
}): Promise<ActionResult> {
|
||||
return callRpc("update_employee_dependent", payload, [`/employees/${payload.employee_id}`]);
|
||||
}
|
||||
|
||||
export async function deleteEmployeeDependent(payload: {
|
||||
dependent_id: string;
|
||||
employee_id: string;
|
||||
@@ -141,6 +375,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 +422,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,139 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { currentUserId, requireUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
import type { ActionResult } from "@/lib/db/rpc";
|
||||
|
||||
type ActionResult = { success: boolean; error?: string };
|
||||
/**
|
||||
* Den Entwurf für mich belegen — beim Öffnen und danach im Takt.
|
||||
*
|
||||
* Die Regel hire_drafts_update lässt die Zeile nur durch, wenn die Sperre
|
||||
* frei, meine oder abgelaufen ist. Trifft das Schreiben keine Zeile, hat sie
|
||||
* jemand anderes offen; das ist kein Fehler, sondern die Auskunft.
|
||||
*
|
||||
* Dieselbe Funktion frischt die Sperre auf: sie schreibt locked_at neu,
|
||||
* solange sie mir gehört. Der Assistent ruft sie darum im Takt, sonst liefe
|
||||
* die Frist einem langen Ausfüllen davon.
|
||||
*
|
||||
* updated_at bleibt unberührt. Eine Sperre ist keine Änderung am Entwurf —
|
||||
* bewegte sie den Zeitstempel, sortierte sich die Liste um und „Gespeichert
|
||||
* am" logge.
|
||||
*/
|
||||
export async function entwurfSperren(id: string): Promise<ActionResult> {
|
||||
const userId = await requireUserId();
|
||||
try {
|
||||
const ergebnis = await withUser(userId, (tx) =>
|
||||
tx
|
||||
.updateTable("hire_drafts")
|
||||
.set({ locked_by: userId, locked_at: new Date().toISOString() })
|
||||
.where("id", "=", id)
|
||||
.executeTakeFirst()
|
||||
);
|
||||
if (ergebnis.numUpdatedRows === 0n) {
|
||||
return { success: false, error: "Dieser Entwurf wird gerade von jemand anderem bearbeitet." };
|
||||
}
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
||||
}
|
||||
revalidatePath("/");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Sperre zurückgeben.
|
||||
*
|
||||
* Die Bedingung auf locked_by steht hier und nicht nur in der Regel: die
|
||||
* Regel liesse auch das Freigeben einer *abgelaufenen fremden* Sperre zu,
|
||||
* und das wäre ein stiller Griff in die Arbeit von jemandem, der gerade
|
||||
* wieder aufgefrischt hat.
|
||||
*
|
||||
* Ein Scheitern bleibt ohne Meldung: die Frist räumt die Sperre ohnehin ab,
|
||||
* und es gäbe nichts, was die Person daraufhin tun könnte.
|
||||
*/
|
||||
export async function entwurfFreigeben(id: string): Promise<ActionResult> {
|
||||
const userId = await requireUserId();
|
||||
try {
|
||||
await withUser(userId, (tx) =>
|
||||
tx
|
||||
.updateTable("hire_drafts")
|
||||
.set({ locked_by: null, locked_at: null })
|
||||
.where("id", "=", id)
|
||||
.where("locked_by", "=", userId)
|
||||
.execute()
|
||||
);
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
||||
}
|
||||
revalidatePath("/");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
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) {
|
||||
// Wer schreiben darf, entscheidet die Regel hire_drafts_update:
|
||||
// der Entwurf muss meiner oder von einer hinzugewählten Person sein,
|
||||
// **und** die Sperre muss mir gehören.
|
||||
//
|
||||
// Die Zahl der geänderten Zeilen wird trotzdem gelesen: eine Regel
|
||||
// weist ein UPDATE nicht mit einem Fehler ab, sie lässt es ins Leere
|
||||
// laufen. Ohne diese Prüfung meldete die Anwendung „gespeichert", und
|
||||
// gespeichert wäre nichts — der ärgerlichste Fall überhaupt, weil die
|
||||
// Person den Assistenten daraufhin beruhigt zumacht.
|
||||
const ergebnis = await tx
|
||||
.updateTable("hire_drafts")
|
||||
.set({ step: payload.step, payload: payload.data, updated_at: new Date().toISOString() })
|
||||
.where("id", "=", payload.id)
|
||||
.executeTakeFirst();
|
||||
if (ergebnis.numUpdatedRows === 0n) {
|
||||
throw new Error(
|
||||
"Der Entwurf liess sich nicht speichern: er wird gerade von jemand anderem bearbeitet oder wurde inzwischen gelöscht."
|
||||
);
|
||||
}
|
||||
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 {
|
||||
// Auch hier die Zeilenzahl: die Regel hire_drafts_delete lässt ein
|
||||
// Löschen gegen eine fremde Sperre leer laufen statt es abzuweisen.
|
||||
// „Entwurf gelöscht" über einem Entwurf, der noch dasteht, wäre die
|
||||
// schlechtere Auskunft.
|
||||
await withUser(await currentUserId(), async (tx) => {
|
||||
const ergebnis = await tx.deleteFrom("hire_drafts").where("id", "=", id).executeTakeFirst();
|
||||
if (ergebnis.numDeletedRows === 0n) {
|
||||
throw new Error(
|
||||
"Der Entwurf liess sich nicht löschen: er wird gerade von jemand anderem bearbeitet oder war schon weg."
|
||||
);
|
||||
}
|
||||
});
|
||||
revalidatePath("/");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
||||
}
|
||||
}
|
||||
|
||||
66
actions/notes.ts
Normal file
66
actions/notes.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
import type { ActionResult } from "@/lib/db/rpc";
|
||||
|
||||
/**
|
||||
* Eine Kollegin oder einen Kollegen hinzuwählen oder abwählen.
|
||||
*
|
||||
* Die Auswahl steuert zwei Listen: die Notizen in der Glocke und die
|
||||
* Entwürfe auf der Übersicht. Der Name der Funktion nennt nur die erste,
|
||||
* weil die Einstellung in der Glocke sitzt — was sie bewirkt, steht an der
|
||||
* Einstellung selbst.
|
||||
*
|
||||
* Kein Aufruf einer SQL-Funktion und kein Protokolleintrag, anders als bei
|
||||
* allem, was Personaldaten ändert: das hier ist eine persönliche
|
||||
* Anzeigeeinstellung. Ein Prüfprotokoll, das jeden Haken mitschreibt, machte
|
||||
* die Suche nach echten Änderungen mühsamer, ohne etwas nachzuweisen.
|
||||
* Dasselbe Muster wie bei gespeicherten Auswertungen und Entwürfen
|
||||
* (actions/reports.ts, actions/hireDrafts.ts).
|
||||
*
|
||||
* Abgesichert ist es trotzdem: die Regel `colleague_subscriptions_owner` lässt nur Zeilen
|
||||
* zu, deren `user_id` die angemeldete Person ist. Eine fremde Einstellung
|
||||
* liesse sich auch mit erfundenen Werten nicht schreiben.
|
||||
*/
|
||||
export async function setNotizSichtbarkeit(payload: {
|
||||
kollegeId: string;
|
||||
sichtbar: boolean;
|
||||
}): Promise<ActionResult> {
|
||||
const userId = await requireUserId();
|
||||
|
||||
// Die eigenen Notizen sind ohnehin immer dabei. Die Prüfbedingung der
|
||||
// Tabelle weist das ab; hier kommt die Meldung heraus, die jemand lesen
|
||||
// kann, statt einer Verletzungsmeldung aus der Datenbank.
|
||||
if (payload.kollegeId === userId) {
|
||||
return { success: false, error: "Die eigenen Notizen sind immer dabei." };
|
||||
}
|
||||
|
||||
try {
|
||||
await withUser(userId, async (tx) => {
|
||||
if (payload.sichtbar) {
|
||||
// `on conflict do nothing`: zweimal dasselbe Hinzuwählen ist kein
|
||||
// Fehler, sondern derselbe Wunsch — etwa wenn zwei Reiter offen sind.
|
||||
await tx
|
||||
.insertInto("colleague_subscriptions")
|
||||
.values({ user_id: userId, author_user_id: payload.kollegeId })
|
||||
.onConflict((oc) => oc.columns(["user_id", "author_user_id"]).doNothing())
|
||||
.execute();
|
||||
} else {
|
||||
await tx
|
||||
.deleteFrom("colleague_subscriptions")
|
||||
.where("user_id", "=", userId)
|
||||
.where("author_user_id", "=", payload.kollegeId)
|
||||
.execute();
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
||||
}
|
||||
|
||||
// Die Glocke steckt in der Hülle jeder Seite, die Karte „Anstehend" auf der
|
||||
// Übersicht. Beide zeigen dieselbe Menge und müssen gemeinsam nachziehen.
|
||||
revalidatePath("/", "layout");
|
||||
return { success: true };
|
||||
}
|
||||
47
actions/org.ts
Normal file
47
actions/org.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { runMutation, type ActionResult } from "@/lib/db/rpc";
|
||||
|
||||
/**
|
||||
* Eine Organisationseinheit unter einer bestehenden anlegen.
|
||||
*
|
||||
* Bewusst die einzige Änderung an der Struktur, die es aus der Anwendung
|
||||
* heraus gibt. Umbenennen, verschieben und schliessen fehlen nicht aus
|
||||
* Zeitmangel: org_units.parent_id trägt kein Datum, ein Verschieben änderte
|
||||
* damit auch jede Auswertung auf einen vergangenen Stichtag. Siehe Migration
|
||||
* 20260929120000.
|
||||
*/
|
||||
const ORG_PFADE = ["/orgchart", "/positions", "/"];
|
||||
|
||||
export async function createOrgUnit(payload: {
|
||||
parent_id: string;
|
||||
org_number: string;
|
||||
name: string;
|
||||
unit_type: "Bereich" | "Abteilung" | "Team";
|
||||
valid_from: string;
|
||||
/** Leer lassen heisst: die Einheit entsteht ohne Leitung. */
|
||||
leitung_taetigkeit?: string;
|
||||
}): Promise<ActionResult> {
|
||||
const result = await runMutation(await currentUserId(), "create_org_unit", payload);
|
||||
if (!result.success) return result;
|
||||
// Die Einheit taucht in drei Ansichten auf, und in allen dreien wäre sie
|
||||
// sonst erst nach dem nächsten harten Neuladen zu sehen.
|
||||
for (const path of ORG_PFADE) revalidatePath(path);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Organisationseinheit entfernen.
|
||||
*
|
||||
* Geht nur, solange nichts darunter hängt und nie etwas hing. Alles andere
|
||||
* weist die Datenbankfunktion mit dem Grund ab — ein Schliessen zum Stichtag
|
||||
* gibt es nicht, weil org_units nirgends gegen den Stichtag gelesen wird.
|
||||
*/
|
||||
export async function deleteOrgUnit(orgUnitId: string): Promise<ActionResult> {
|
||||
const result = await runMutation(await currentUserId(), "delete_org_unit", { org_unit_id: orgUnitId });
|
||||
if (!result.success) return result;
|
||||
for (const path of ORG_PFADE) revalidatePath(path);
|
||||
return result;
|
||||
}
|
||||
61
actions/passwort.ts
Normal file
61
actions/passwort.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { runMutation } from "@/lib/db/rpc";
|
||||
|
||||
// Das eigene Passwort wechseln.
|
||||
//
|
||||
// Getrennt von actions/auth.ts, weil es etwas anderes ist: dort geht es um das
|
||||
// Herstellen einer Sitzung, hier um eine Änderung an den eigenen Daten. Und
|
||||
// getrennt von actions/benutzer.ts (Benutzerverwaltung), weil diese Aktion die
|
||||
// einzige ist, die **ohne** HR-Rechte funktionieren muss.
|
||||
|
||||
export type WechselZustand = { fehler: string | null };
|
||||
|
||||
/**
|
||||
* Wechselt das Passwort der angemeldeten Person.
|
||||
*
|
||||
* Kein `revalidatePath`: der Weg danach ist eine Weiterleitung durch die
|
||||
* Komponente, und es gibt keine zwischengespeicherte Seite, die den alten Stand
|
||||
* zeigen könnte.
|
||||
*
|
||||
* Die Meldungen kommen aus app_passwort_aendern() und sind für die Oberfläche
|
||||
* geschrieben („Das bisherige Passwort stimmt nicht.") — sie werden
|
||||
* durchgereicht wie bei jeder anderen Mutation.
|
||||
*/
|
||||
export async function passwortAendern(
|
||||
_zustand: WechselZustand,
|
||||
formular: FormData
|
||||
): Promise<WechselZustand> {
|
||||
const altes = String(formular.get("altes_passwort") ?? "");
|
||||
const neues = String(formular.get("neues_passwort") ?? "");
|
||||
const wiederholung = String(formular.get("wiederholung") ?? "");
|
||||
|
||||
if (!altes || !neues) {
|
||||
return { fehler: "Bitte das bisherige und das neue Passwort eingeben." };
|
||||
}
|
||||
|
||||
// Diese eine Prüfung gehört hierher und nicht in die Datenbank: die
|
||||
// Wiederholung ist ein Bedienelement gegen Vertippen, kein Datum. Die
|
||||
// Datenbank sieht sie nie und soll sie auch nicht sehen.
|
||||
if (neues !== wiederholung) {
|
||||
return { fehler: "Die beiden neuen Passwörter stimmen nicht überein." };
|
||||
}
|
||||
|
||||
const ergebnis = await runMutation(await currentUserId(), "app_passwort_aendern", {
|
||||
altes_passwort: altes,
|
||||
neues_passwort: neues,
|
||||
});
|
||||
|
||||
if (!ergebnis.success) {
|
||||
return { fehler: ergebnis.error ?? "Unbekannter Fehler." };
|
||||
}
|
||||
|
||||
// Ab hier ist der Wechsel erledigt, is_hr_user() liefert wieder true und die
|
||||
// Anwendung ist offen. Die Weiterleitung steht in der Aktion und nicht in der
|
||||
// Komponente: `redirect()` wirft, die Aktion kehrt also nicht zurück, und es
|
||||
// gibt keinen Zwischenzustand, in dem das Formular noch einmal abgeschickt
|
||||
// werden könnte.
|
||||
redirect("/");
|
||||
}
|
||||
@@ -1,56 +1,174 @@
|
||||
"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 { kontierungZum, kostenstellenAbfrage, type KontierungsZeile } from "@/lib/cost-centers";
|
||||
import { withUser } from "@/lib/db";
|
||||
import { jsonArrayFrom } from "@/lib/db/json";
|
||||
import { runMutation, type ActionResult } from "@/lib/db/rpc";
|
||||
import { fmtName, todayIso } from "@/lib/format";
|
||||
import type { PlanstelleZumAendern } from "@/lib/positions";
|
||||
|
||||
const POSITION_PATHS = ["/positions", "/orgchart", "/"];
|
||||
|
||||
async function callRpc(
|
||||
fn: "create_position" | "delete_position",
|
||||
fn: "create_position" | "clone_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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Planstelle als Kopie einer bestehenden anlegen.
|
||||
*
|
||||
* Kein eigenes Formular: Einheit, Tätigkeit und Kontierung kommen von der
|
||||
* Vorlage, getippt wird nichts. Genau das ist der Zweck — zwölf gleiche
|
||||
* Planstellen von Hand anzulegen erzeugt zwölf Gelegenheiten, die Tätigkeit
|
||||
* unterschiedlich zu schreiben, und ab der zweiten Schreibweise ist jede
|
||||
* Auswertung nach Tätigkeit falsch.
|
||||
*
|
||||
* Dass Leitungsplanstellen ausgenommen sind, entscheidet die SQL-Funktion und
|
||||
* nicht diese Stelle. Die Oberfläche blendet den Knopf dort aus, aber das ist
|
||||
* eine Bequemlichkeit, keine Regel.
|
||||
*/
|
||||
export async function clonePosition(payload: { position_id: string; valid_from?: string }): Promise<ActionResult> {
|
||||
return callRpc("clone_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);
|
||||
}
|
||||
|
||||
export type PlanstelleDerEinheit = PlanstelleZumAendern & {
|
||||
/** Wer am Stichtag darauf sitzt; null heisst unbesetzt. */
|
||||
besetztVon: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Die Planstellen einer Organisationseinheit — **auch die besetzten**.
|
||||
*
|
||||
* Gelesen, nicht geschrieben, und erst beim Öffnen der Einheit: der Bestand
|
||||
* zählt 788 Planstellen, und sie alle in jede Zeichnung des Organigramms zu
|
||||
* legen hiesse, für achtzig Einheiten zu laden, was man für eine braucht.
|
||||
*
|
||||
* Der Grund, warum es diese Abfrage überhaupt gibt: die Seite Positionen zeigt
|
||||
* nur unbesetzte Stellen, und der Änderungsdialog war allein von dort aus
|
||||
* erreichbar. Die Kontierung einer besetzten Planstelle liess sich damit
|
||||
* nirgends ändern.
|
||||
*
|
||||
* Kein Schutzgatter im Anwendungscode: die Zeilenschutz-Regeln geben einer
|
||||
* Person ohne HR-Recht nichts zurück, und das ist die Grenze, die zählt.
|
||||
*/
|
||||
export async function ladePlanstellenDerEinheit(orgUnitId: string): Promise<{
|
||||
planstellen: PlanstelleDerEinheit[];
|
||||
kostenstellen: { id: string; code: string; name: string }[];
|
||||
/** Vom Server: der Dialog rechnet sonst mit der Zone des Browsers. */
|
||||
heute: string;
|
||||
}> {
|
||||
const heute = todayIso();
|
||||
|
||||
const g = await withUser(await currentUserId(), (tx) =>
|
||||
tx
|
||||
.selectNoFrom((eb) => [
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("om_positions as p")
|
||||
.innerJoin("jobs as j", "j.id", "p.job_id")
|
||||
.select(["p.id", "p.position_number", "p.org_unit_id", "p.is_chief", "p.valid_from", "p.valid_to", "j.title"])
|
||||
.where("p.org_unit_id", "=", orgUnitId)
|
||||
.orderBy("p.position_number")
|
||||
).as("stellen"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("position_assignments as a")
|
||||
.innerJoin("om_positions as p", "p.id", "a.position_id")
|
||||
.innerJoin("employees as e", "e.id", "a.employee_id")
|
||||
.select(["a.position_id", "e.first_name", "e.last_name"])
|
||||
.where("p.org_unit_id", "=", orgUnitId)
|
||||
.where((x) => x.or([x("a.valid_to", "is", null), x("a.valid_to", ">", heute)]))
|
||||
).as("besetzungen"),
|
||||
// Nicht kontierungenAbfrage(): die filtert über eine Liste von
|
||||
// Planstellen, die hier erst aus der ersten Teilabfrage käme — und das
|
||||
// wäre eine zweite Rundreise für dieselbe Auskunft.
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("position_cost_centers as z")
|
||||
.innerJoin("cost_centers as k", "k.id", "z.cost_center_id")
|
||||
.innerJoin("om_positions as p", "p.id", "z.position_id")
|
||||
.select(["z.position_id", "z.cost_center_id", "k.code", "k.name", "z.valid_from", "z.valid_to"])
|
||||
.where("p.org_unit_id", "=", orgUnitId)
|
||||
.orderBy("z.valid_from")
|
||||
).as("kontierungen"),
|
||||
jsonArrayFrom(kostenstellenAbfrage(eb, heute)).as("kostenstellen"),
|
||||
])
|
||||
.executeTakeFirstOrThrow()
|
||||
);
|
||||
|
||||
const kontierung = kontierungZum(g.kontierungen as KontierungsZeile[], heute);
|
||||
const inhaber = new Map(g.besetzungen.map((b) => [b.position_id, fmtName(b.first_name, b.last_name)]));
|
||||
|
||||
return {
|
||||
planstellen: g.stellen.map((p) => ({
|
||||
id: p.id,
|
||||
position_number: p.position_number,
|
||||
title: p.title,
|
||||
org_unit_id: p.org_unit_id,
|
||||
is_chief: p.is_chief,
|
||||
valid_from: p.valid_from,
|
||||
valid_to: p.valid_to,
|
||||
kostenstelle: kontierung.get(p.id) ?? null,
|
||||
besetztVon: inhaber.get(p.id) ?? null,
|
||||
})),
|
||||
kostenstellen: g.kostenstellen,
|
||||
heute,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -20,8 +21,8 @@ function pageHref(params: SearchParams, page: number): string {
|
||||
}
|
||||
|
||||
// Pinned to Vienna and built once: audit_log.occurred_at is a timestamptz, and
|
||||
// an unpinned formatter renders it in the *server's* zone — UTC in Docker and
|
||||
// on Vercel — so every entry would read an hour or two early for the people
|
||||
// an unpinned formatter renders it in the *server's* zone — UTC in the
|
||||
// container — so every entry would read an hour or two early for the people
|
||||
// the log is for.
|
||||
const dateTimeFormatter = new Intl.DateTimeFormat("de-AT", {
|
||||
day: "2-digit",
|
||||
@@ -34,35 +35,58 @@ 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}`}>
|
||||
{/* `overflow-y-hidden` gehört zwingend dazu und ist keine Vorsicht:
|
||||
nach CSS wird eine auf `visible` stehende Achse auf `auto`
|
||||
hochgestuft, sobald die andere nicht `visible` ist. `overflow-x-auto`
|
||||
allein macht aus der Karte also einen Scrollbereich in **beiden**
|
||||
Richtungen — mit einer senkrechten Leiste, die niemand bestellt hat.
|
||||
Geklemmt wird dabei nichts: die Karte ist so hoch wie ihr Inhalt. */}
|
||||
<div className={`overflow-x-auto overflow-y-hidden ${CARD_CLASS}`}>
|
||||
<table className="w-full min-w-[800px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface text-left text-[11px] font-bold uppercase tracking-wider text-ink-muted">
|
||||
@@ -74,7 +98,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 +122,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.
|
||||
|
||||
54
app/(app)/employees/[id]/checkliste/page.tsx
Normal file
54
app/(app)/employees/[id]/checkliste/page.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { PrintChecklist } from "@/components/employees/PrintChecklist";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { artTitel, checklistDateiname, parseArt } from "@/lib/checklist-print";
|
||||
import { loadChecklistPrint } from "@/lib/checklist-print-data";
|
||||
import { withUser } from "@/lib/db";
|
||||
import { todayIso } from "@/lib/format";
|
||||
import { OFFBOARDING_GRUPPEN } from "@/lib/offboarding";
|
||||
import { ONBOARDING_GRUPPEN } from "@/lib/onboarding";
|
||||
|
||||
// Eigene Route statt eines Druckstils auf dem Reiter.
|
||||
//
|
||||
// Der Reiter ist zum Arbeiten gebaut: Haken zum Anklicken, Eingabefelder,
|
||||
// Kommentarknöpfe. Auf Papier ist davon nichts nützlich. Hier entsteht aus
|
||||
// denselben Daten ein Blatt, das den Stand festhält — mit Kopf, Fortschritt
|
||||
// und Platz für zwei Unterschriften.
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ art?: string }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Der Titel ist zugleich der Dateivorschlag im Druckdialog. Er wird hier
|
||||
* gesetzt *und* in der Komponente noch einmal — hier für den Tab und den Fall,
|
||||
* dass jemand die Seite direkt druckt, dort, weil der Titel aus den geladenen
|
||||
* Daten kommt und die Metadaten sonst ein zweites Mal laden müssten.
|
||||
*/
|
||||
export async function generateMetadata({ params, searchParams }: Props) {
|
||||
const [{ id }, sp] = await Promise.all([params, searchParams]);
|
||||
const art = parseArt(sp.art);
|
||||
const daten = await withUser(await currentUserId(), (tx) => loadChecklistPrint(tx, id, art));
|
||||
if (!daten) return { title: artTitel(art) };
|
||||
return { title: checklistDateiname(art, daten.kopf, todayIso()) };
|
||||
}
|
||||
|
||||
export default async function ChecklistPrintPage({ params, searchParams }: Props) {
|
||||
const [{ id }, sp] = await Promise.all([params, searchParams]);
|
||||
const art = parseArt(sp.art);
|
||||
const today = todayIso();
|
||||
|
||||
const daten = await withUser(await currentUserId(), (tx) => loadChecklistPrint(tx, id, art, today));
|
||||
if (!daten) notFound();
|
||||
|
||||
return (
|
||||
<PrintChecklist
|
||||
art={art}
|
||||
kopf={daten.kopf}
|
||||
gruppen={art === "offboarding" ? OFFBOARDING_GRUPPEN : ONBOARDING_GRUPPEN}
|
||||
staende={daten.staende}
|
||||
today={today}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +1,57 @@
|
||||
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, onboarding, offboarding, 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}
|
||||
onboarding={onboarding}
|
||||
offboarding={offboarding}
|
||||
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}
|
||||
today={today}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,19 +5,74 @@ 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 type { EmploymentStatus } from "@/lib/supabase/types";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { sql, withUser } from "@/lib/db";
|
||||
import { jsonArrayFrom, jsonObjectFrom } from "@/lib/db/json";
|
||||
import { istPersonalnummer, suchMuster, TRENNZEICHEN, TRENNZEICHEN_ERSATZ } from "@/lib/employee-search";
|
||||
import {
|
||||
SORTIERFELDER,
|
||||
naechsteRichtung,
|
||||
sortiere,
|
||||
parseFeld,
|
||||
parseRichtung,
|
||||
type Richtung,
|
||||
type Sortierfeld,
|
||||
} from "@/lib/employee-sort";
|
||||
import { derivedStatusFilter } from "@/lib/employee-status-filter";
|
||||
import { fmtDate, fmtName, todayIso } from "@/lib/format";
|
||||
import { breadcrumbLabel, loadOrgMaps, subtreeOf, unitOf, type OrgEb } from "@/lib/org";
|
||||
import { loadPlacements } from "@/lib/placement";
|
||||
import type { EmploymentStatus } from "@/lib/types";
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
type SearchParams = { q?: string; division?: string; status?: string; location?: string; page?: string };
|
||||
/**
|
||||
* Die Parameter, nachdem sie geglättet sind — je einer, oder keiner.
|
||||
*
|
||||
* Was Next tatsächlich liefert, steht in RohParams: derselbe Name zweimal in
|
||||
* der Adresse wird dort zu einem Array. Diese Seite hat das nicht erwartet
|
||||
* und ist daran gescheitert — eine Kachel der Übersicht verwies auf
|
||||
* `?status=Aktiv&status=Karenz`, und `params.status.split(",")` lief gegen
|
||||
* ein Array. Sichtbar war davon nur „Diese Ansicht konnte nicht geladen
|
||||
* werden" mit einer Fehlerkennung.
|
||||
*
|
||||
* Die Kachel ist berichtigt (sie schreibt jetzt `status=Aktiv,Karenz`), aber
|
||||
* eine Adresse kommt nicht nur aus der eigenen Anwendung: sie steht in
|
||||
* Lesezeichen, in Verknüpfungen, in E-Mails. Deshalb wird hier geglättet
|
||||
* statt sich darauf zu verlassen, dass niemand zweimal denselben Namen
|
||||
* schreibt.
|
||||
*/
|
||||
type SearchParams = {
|
||||
q?: string;
|
||||
division?: string;
|
||||
status?: string;
|
||||
location?: string;
|
||||
sort?: string;
|
||||
dir?: string;
|
||||
page?: string;
|
||||
};
|
||||
|
||||
type RohParams = Record<string, string | string[] | undefined>;
|
||||
|
||||
/** Der erste Wert eines Parameters — auch wenn er mehrfach in der Adresse steht. */
|
||||
function einWert(wert: string | string[] | undefined): string | undefined {
|
||||
return Array.isArray(wert) ? wert[0] : wert;
|
||||
}
|
||||
|
||||
function glaetten(roh: RohParams): SearchParams {
|
||||
return {
|
||||
q: einWert(roh.q),
|
||||
division: einWert(roh.division),
|
||||
status: einWert(roh.status),
|
||||
location: einWert(roh.location),
|
||||
sort: einWert(roh.sort),
|
||||
dir: einWert(roh.dir),
|
||||
page: einWert(roh.page),
|
||||
};
|
||||
}
|
||||
|
||||
type EmployeesPageProps = {
|
||||
searchParams: Promise<SearchParams>;
|
||||
searchParams: Promise<RohParams>;
|
||||
};
|
||||
|
||||
function pageHref(params: SearchParams, page: number): string {
|
||||
@@ -26,78 +81,272 @@ function pageHref(params: SearchParams, page: number): string {
|
||||
if (params.division) sp.set("division", params.division);
|
||||
if (params.status) sp.set("status", params.status);
|
||||
if (params.location) sp.set("location", params.location);
|
||||
// Ohne das kippte die Liste beim Blättern zurück auf den Standard, und
|
||||
// Seite 2 zeigte Namen, die auf Seite 1 schon standen.
|
||||
if (params.sort) sp.set("sort", params.sort);
|
||||
if (params.dir) sp.set("dir", params.dir);
|
||||
sp.set("page", String(page));
|
||||
return `/employees?${sp.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Adresse hinter einem Spaltenkopf.
|
||||
*
|
||||
* Ohne `page`: nach dem Umsortieren steht auf Seite 7 etwas völlig anderes
|
||||
* als vorher. Wer sortiert, will von vorne anfangen.
|
||||
*/
|
||||
function sortHref(params: SearchParams, feld: Sortierfeld, aktuell: Sortierfeld, richtung: Richtung): string {
|
||||
const sp = new URLSearchParams();
|
||||
if (params.q) sp.set("q", params.q);
|
||||
if (params.division) sp.set("division", params.division);
|
||||
if (params.status) sp.set("status", params.status);
|
||||
if (params.location) sp.set("location", params.location);
|
||||
sp.set("sort", feld);
|
||||
sp.set("dir", naechsteRichtung(aktuell, richtung, feld));
|
||||
return `/employees?${sp.toString()}`;
|
||||
}
|
||||
|
||||
export default async function EmployeesPage({ searchParams }: EmployeesPageProps) {
|
||||
const params = await searchParams;
|
||||
const supabase = await createClient();
|
||||
|
||||
const params = glaetten(await searchParams);
|
||||
const page = Math.max(1, Number(params.page ?? "1") || 1);
|
||||
const from = (page - 1) * PAGE_SIZE;
|
||||
const to = from + PAGE_SIZE - 1;
|
||||
const sortFeld = parseFeld(params.sort);
|
||||
const sortRichtung = parseRichtung(params.dir);
|
||||
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")
|
||||
// Laufend **oder noch bevorstehend**: `valid_to is null` allein
|
||||
// liess jede Person mit vorgemerktem Austritt aus der Einheit
|
||||
// verschwinden — terminate_employee setzt das Ende schon beim
|
||||
// Erfassen, Monate vor dem Tag. Gemeldet im Test vom 29.09.
|
||||
// (C.09). Das Ende ist ausschliessend, deshalb `>` und nicht
|
||||
// `>=`. Kein `valid_from <= heute`: ein geplanter Eintritt
|
||||
// gehört in die Liste, sonst fiele er aus dem Einheitenfilter,
|
||||
// obwohl der Status „Geplant" ihn ausdrücklich führt.
|
||||
.where((e) => e.or([e("a.valid_to", "is", null), e("a.valid_to", ">", today)]))
|
||||
.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.
|
||||
// Die Trennzeichen stehen in lib/employee-search.ts, weil die
|
||||
// Eingabe an denselben zerlegt werden muss — standen sie nur hier,
|
||||
// fand „Müller-Weiß" nichts (C.08).
|
||||
const heuhaufen = nurNamen
|
||||
? sql<string>`translate(lower(first_name || ' ' || last_name), ${TRENNZEICHEN}, ${TRENNZEICHEN_ERSATZ})`
|
||||
: sql<string>`translate(lower(first_name || ' ' || last_name || ' ' || job_title), ${TRENNZEICHEN}, ${TRENNZEICHEN_ERSATZ})`;
|
||||
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",
|
||||
// Nicht `status`: der Chip leitet ab, wie der Filter es tut —
|
||||
// siehe components/ui/StatusChip.tsx. Drei Spalten mehr auf
|
||||
// fünfzehn Zeilen, keine zusätzliche Rundreise.
|
||||
"exit_date",
|
||||
"karenz_start_date",
|
||||
"karenz_return_date",
|
||||
"absence_type",
|
||||
])
|
||||
.$call((q) => sortiere(q, sortFeld, sortRichtung, today))
|
||||
.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}`}>
|
||||
{/* Zur zweiten Achse siehe den Kommentar auf der Protokollseite: ohne
|
||||
`overflow-y-hidden` stuft CSS sie auf `auto` hoch, und die Karte
|
||||
bekommt eine senkrechte Leiste, die nichts bewirkt. */}
|
||||
<div className={`overflow-x-auto overflow-y-hidden ${CARD_CLASS}`}>
|
||||
<table className="w-full min-w-[800px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface text-left text-[11px] font-bold uppercase tracking-wider text-ink-muted">
|
||||
<th className="px-4 py-2.5">Mitarbeiter:in</th>
|
||||
<th className="px-4 py-2.5">Pers.-Nr.</th>
|
||||
<th className="px-4 py-2.5">Bereich/Team</th>
|
||||
<th className="px-4 py-2.5">Standort</th>
|
||||
<th className="px-4 py-2.5">Eintritt</th>
|
||||
<th className="px-4 py-2.5">Beschäftigung</th>
|
||||
<th className="px-4 py-2.5">Status</th>
|
||||
{SORTIERFELDER.map((f) => {
|
||||
const aktiv = f.value === sortFeld;
|
||||
return (
|
||||
// `aria-sort` sagt einem Screenreader, welche Spalte die
|
||||
// Reihenfolge bestimmt und in welche Richtung — der Pfeil
|
||||
// allein ist für ihn nicht da.
|
||||
<th
|
||||
key={f.value}
|
||||
scope="col"
|
||||
aria-sort={aktiv ? (sortRichtung === "asc" ? "ascending" : "descending") : "none"}
|
||||
className="px-4 py-2.5"
|
||||
>
|
||||
<Link
|
||||
href={sortHref(params, f.value, sortFeld, sortRichtung)}
|
||||
// `title` nennt aus, was der Pfeil andeutet: was der
|
||||
// nächste Klick tut, nicht was gerade gilt.
|
||||
title={
|
||||
aktiv && sortRichtung === "asc"
|
||||
? `${f.label} absteigend sortieren`
|
||||
: `${f.label} aufsteigend sortieren`
|
||||
}
|
||||
className={`group inline-flex items-center gap-1 rounded transition-colors hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
|
||||
aktiv ? "text-ink" : ""
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
{/* Der Pfeil steht immer da, nur blass, solange die
|
||||
Spalte nicht sortiert: sonst springt die Kopfzeile
|
||||
beim Klicken um eine Pfeilbreite. */}
|
||||
<span aria-hidden className={aktiv ? "text-brand-600" : "text-ink-muted/30 group-hover:text-ink-muted"}>
|
||||
{aktiv && sortRichtung === "desc" ? "▼" : "▲"}
|
||||
</span>
|
||||
</Link>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{employees.map((e) => {
|
||||
const { division, team } = breadcrumbFor(orgMaps, e.division_id, e.team_id);
|
||||
const placement = placements.get(e.id);
|
||||
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,18 +360,26 @@ 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>
|
||||
{/* tabular-nums keeps the numeric columns aligned down the
|
||||
page instead of jittering per row. */}
|
||||
<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>
|
||||
{/* Nur die eigene Einheit, egal auf welcher Ebene sie hängt.
|
||||
Darüber stand der Bereich; der Kunde wollte ihn weg, weil
|
||||
seine Bereiche CEO, CFO, COO heissen und über jedem Namen
|
||||
dasselbe wiederholten. Der ganze Weg von oben steht
|
||||
weiterhin im `title` — eine Einheit wie „Shopleitung"
|
||||
sagt allein nicht, welche gemeint ist. */}
|
||||
<td
|
||||
className="px-4 py-2.5 text-ink-body"
|
||||
title={breadcrumbLabel(orgMaps, placement?.orgUnitId)}
|
||||
>
|
||||
{unit?.name ?? "–"}
|
||||
</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>
|
||||
@@ -130,7 +387,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
||||
{e.employment_type} · <span className="tabular-nums">{e.weekly_hours}h</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<StatusChip status={e.status} entryDate={e.entry_date} absenceType={e.absence_type} />
|
||||
<StatusChip employee={e} asOf={today} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Button, LINK_BUTTON_CLASS } from "@/components/ui/Button";
|
||||
// Without this file a failed render drops the user on Next.js's own error
|
||||
// screen — no navigation, no way back, and in production just "a client-side
|
||||
// exception occurred". `reset()` re-renders the segment, which is enough for
|
||||
// the common case of a transient Supabase timeout.
|
||||
// the common case of a transient database timeout.
|
||||
export default function AppError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
useEffect(() => {
|
||||
console.error("Route error:", error);
|
||||
|
||||
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,41 @@ 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 ergebnis = await withUser(userId, (tx) => loadShellData(tx, userId));
|
||||
|
||||
const userLabel = profile.full_name || profile.email || user.email || "";
|
||||
// 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.
|
||||
//
|
||||
// Beide Umleitungen sind Bedienkomfort, keine Absicherung: wer sie umgeht,
|
||||
// bekommt trotzdem keine Zeile, weil die RLS-Policies dieselbe Frage stellen
|
||||
// (is_hr_user()). Ohne sie stünde die Person nur vor einer leeren Anwendung
|
||||
// und wüsste nicht, warum.
|
||||
if (ergebnis.status === "passwort_wechseln") redirect("/passwort-aendern");
|
||||
|
||||
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),
|
||||
]);
|
||||
// Ohne den Grund in der Adresse stünde die Person vor einer wortlosen
|
||||
// Anmeldeseite und versuchte es endlos erneut.
|
||||
if (ergebnis.status === "kein_zugang") redirect("/login?error=no_hr_access");
|
||||
|
||||
const data = ergebnis.daten;
|
||||
|
||||
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} kollegen={data.kollegen}>
|
||||
{children}
|
||||
</AppShell>
|
||||
</HireWizardProvider>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Every page in this group is server-rendered per request (they all read
|
||||
// from Supabase), so without this the browser sits on the previous page with
|
||||
// from the database), so without this the browser sits on the previous page with
|
||||
// no feedback until the server answers — on the employee list, long enough
|
||||
// to look broken.
|
||||
export default function Loading() {
|
||||
|
||||
@@ -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,30 @@ 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")
|
||||
// valid_from/valid_to nur hier: die Angaben zur Einheit zeigen sie,
|
||||
// der Druck braucht sie nicht.
|
||||
.select(["id", "org_number", "name", "parent_id", "unit_type", "valid_from", "valid_to"])
|
||||
// Zum Stichtag — sonst zeigt die Struktursicht zum 01.01.2020
|
||||
// Einheiten, die es damals nicht gab (D.06).
|
||||
.where("valid_from", "<=", asOf)
|
||||
.where((eb) => eb.or([eb("valid_to", "is", null), eb("valid_to", ">", asOf)]))
|
||||
.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,18 @@
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Fragment, Suspense } from "react";
|
||||
import { AnstehendFilter } from "@/components/dashboard/AnstehendFilter";
|
||||
import { AnstehendListe, type AnstehendEintrag } from "@/components/dashboard/AnstehendListe";
|
||||
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 { eventBadgeStyle, eventDotStyle } from "@/lib/colors";
|
||||
import { istEingeschraenkt, parseArten, parseZeitraum } from "@/lib/dashboard-filter";
|
||||
import { loadDashboardData } from "@/lib/dashboard-data";
|
||||
import { addDaysIso, 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
|
||||
@@ -17,37 +23,46 @@ const TONE: Record<string, { text: string; bar: string }> = {
|
||||
danger: { text: "text-danger-text", bar: "bg-danger-text" },
|
||||
warning: { text: "text-warning-text", bar: "bg-warning-text" },
|
||||
brand: { text: "text-brand-700", bar: "bg-brand-500" },
|
||||
// Geplante Eintritte: eine Ankündigung, kein Befund. „info" ist der Ton,
|
||||
// den die Statuschips für „Geplant" ohnehin tragen.
|
||||
info: { text: "text-info-text", bar: "bg-info-text" },
|
||||
};
|
||||
|
||||
const DOT_STYLES: Record<string, string> = {
|
||||
Eintritt: "bg-success-text",
|
||||
Wiedereintritt: "bg-success-text",
|
||||
Rückkehr: "bg-success-text",
|
||||
Austritt: "bg-danger-text",
|
||||
Versetzung: "bg-info-text",
|
||||
Beförderung: "bg-purple-text",
|
||||
Reorganisation: "bg-purple-text",
|
||||
Karenz: "bg-warning-text",
|
||||
Vertragsänderung: "bg-warning-text",
|
||||
Stammdatenänderung: "bg-warning-text",
|
||||
Gehaltsanpassung: "bg-warning-text",
|
||||
/** Eine Kennzahl auf der Übersicht. */
|
||||
type Kachel = { label: string; value: string | number; tone: keyof typeof TONE; href: string };
|
||||
|
||||
/**
|
||||
* Wie viele Spalten die Überschrift einer Gruppe überspannt — nach der Zahl
|
||||
* ihrer Kacheln.
|
||||
*
|
||||
* Ausgeschrieben und nicht als `span var(--kacheln)`: Tailwind erzeugt nur
|
||||
* Klassen, die als Zeichenkette im Quelltext stehen, und eine aus einer
|
||||
* Variablen berechnete Spannweite wäre zur Bauzeit nicht zu sehen. Die
|
||||
* Tabelle deckt jede mögliche Gruppengrösse bis zur Rasterbreite ab; ein
|
||||
* Zugriff daneben gäbe `undefined` und damit eine Überschrift über einer
|
||||
* einzigen Spalte — auffällig genug, um bemerkt zu werden.
|
||||
*/
|
||||
const SPALTEN_SPAN: Record<number, string> = {
|
||||
1: "xl:col-span-1",
|
||||
2: "xl:col-span-2",
|
||||
3: "xl:col-span-3",
|
||||
4: "xl:col-span-4",
|
||||
5: "xl:col-span-5",
|
||||
6: "xl:col-span-6",
|
||||
7: "xl:col-span-7",
|
||||
8: "xl:col-span-8",
|
||||
};
|
||||
|
||||
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: [] };
|
||||
// Die Tabelle, die hier stand, ist nach lib/colors.ts gewandert: Punkt und
|
||||
// Chip derselben Zeile kamen aus zwei getrennten Verzeichnissen, und eines
|
||||
// davon war das falsche. Jetzt leiten beide aus EVENT_CATEGORY ab und koennen
|
||||
// nicht mehr auseinanderlaufen.
|
||||
|
||||
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
|
||||
@@ -55,8 +70,20 @@ export default async function DashboardPage() {
|
||||
const today = todayIso();
|
||||
const year = today.slice(0, 4);
|
||||
const yearStart = `${year}-01-01`;
|
||||
const yearEnd = `${year}-12-31`;
|
||||
const in60Iso = addDaysIso(today, 60);
|
||||
// Das obere Ende des YTD-Fensters ist **heute**. Hier stand der 31.12., also
|
||||
// das ganze Kalenderjahr — womit die Kacheln auch zählten, was erst
|
||||
// bevorsteht (B.02).
|
||||
const ytdBis = today;
|
||||
|
||||
// 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 +93,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, ytdBis, 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 +121,53 @@ 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) => ({
|
||||
const upcomingAlle: AnstehendEintrag[] = [
|
||||
...(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);
|
||||
|
||||
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}`]));
|
||||
...(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));
|
||||
|
||||
// Each tile links to the view that shows what it counts, with the filters
|
||||
// pre-applied.
|
||||
@@ -193,49 +179,142 @@ export default async function DashboardPage() {
|
||||
// and rehire_employee sets entry_date but logs the event as
|
||||
// 'Wiedereintritt'. A year with rehires therefore shows a slightly higher
|
||||
// number on the tile than in the linked report.
|
||||
const kpis = [
|
||||
// ── Warum die Kacheln in Gruppen stehen ────────────────────────────
|
||||
//
|
||||
// Acht Zahlen nebeneinander sind acht Zahlen. Sie beantworten aber drei
|
||||
// verschiedene Fragen: wie viele sind da, was hat sich bewegt, was ist
|
||||
// offen. Ohne Überschrift muss man jede Beschriftung einzeln lesen, um das
|
||||
// herauszufinden — mit ihr sieht man es, bevor man liest.
|
||||
//
|
||||
// Die Einteilung und die Reihenfolge kommen aus einem Entwurf des Kunden
|
||||
// (E-Mail vom 16.09.2026). „Aktives Dienstverhältnis" steht darin bewusst
|
||||
// **vorn**: es ist die Bezugsgrösse fast jeder Personalkennzahl, und wer
|
||||
// eine Quote bildet, greift zuerst danach.
|
||||
const kpiGruppen: { titel: string; kacheln: Kachel[] }[] = [
|
||||
{
|
||||
label: "Aktive Mitarbeiter:innen",
|
||||
value: activeCount,
|
||||
tone: "default",
|
||||
href: "/employees?status=Aktiv",
|
||||
},
|
||||
{ label: "FTE", value: fte.toFixed(1), tone: "default", href: "/reports?mode=snapshot&measure=fte&status=Aktiv" },
|
||||
{
|
||||
label: "Eintritte (Jahr)",
|
||||
value: hiresYtdRes.count ?? 0,
|
||||
tone: "success",
|
||||
href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${yearEnd}`,
|
||||
titel: "Personalstand",
|
||||
kacheln: [
|
||||
// Nicht dasselbe wie „Aktive Mitarbeiter:innen" daneben: dort steht,
|
||||
// wer heute arbeitet, hier, mit wem ein Vertrag läuft —
|
||||
// Langzeitabwesende eingeschlossen. Sichtbar waren 805 und 10,
|
||||
// addieren musste man selbst.
|
||||
// Die Namen der beiden stehen so fest: Max am 30.09. — „Es gibt zwei
|
||||
// Headcounts: Headcount Aktiv (nur Aktive) und Headcount Aktives
|
||||
// Dienstverhältnis (Aktiv + Langzeitabwesenheit)". Vorher hiess nur die
|
||||
// rechte Kachel „(HC)", und im Bericht stand daneben „Headcount" für
|
||||
// eine dritte Zahl — dasselbe Wort für Verschiedenes (N.02).
|
||||
{
|
||||
label: "Headcount Aktives Dienstverhältnis",
|
||||
value: activeCount + karenzCount,
|
||||
tone: "default",
|
||||
// Mit Komma, nicht zweimal `status=`: die Liste liest den Parameter
|
||||
// als *eine* Zeichenkette und trennt selbst. Zweimal übergeben macht
|
||||
// Next daraus ein Array, und die Seite scheiterte an `.split(",")`.
|
||||
href: "/employees?status=Aktiv,Karenz",
|
||||
},
|
||||
{ label: "Headcount Aktiv", value: activeCount, tone: "default", href: "/employees?status=Aktiv" },
|
||||
{ label: "Langzeitabwesende", value: karenzCount, tone: "warning", href: "/employees?status=Karenz" },
|
||||
// Ohne Zeitgrenze — anders als die Karte „Anstehend" darunter, die nur
|
||||
// den eingestellten Vorschauzeitraum zeigt. Ein Eintritt in vier
|
||||
// Monaten ist vereinbart und zählt, auch wenn er dort nicht auftaucht.
|
||||
{
|
||||
label: "Geplante Eintritte",
|
||||
value: staffRows.filter((row) => statusOf(row) === "Geplant").length,
|
||||
tone: "info",
|
||||
href: "/employees?status=Geplant",
|
||||
},
|
||||
{ label: "Aktive FTE", value: fte.toFixed(1), tone: "default", href: "/reports?mode=snapshot&measure=fte&status=Aktiv" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Austritte (Jahr)",
|
||||
value: exitsYtdRes.count ?? 0,
|
||||
tone: "danger",
|
||||
href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`,
|
||||
titel: "Personalbewegung",
|
||||
kacheln: [
|
||||
{
|
||||
// YTD: seit Jahresbeginn bis heute, nicht das ganze Kalenderjahr.
|
||||
// Der Verweis trägt denselben Zeitraum — sonst zeigte der Bericht
|
||||
// eine andere Zahl als die Kachel, über die man ihn geöffnet hat.
|
||||
label: "Eintritte (YTD)",
|
||||
value: hiresYtd,
|
||||
tone: "success",
|
||||
href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${ytdBis}`,
|
||||
},
|
||||
{
|
||||
label: "Austritte (YTD)",
|
||||
value: exitsYtd,
|
||||
tone: "danger",
|
||||
href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${ytdBis}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
titel: "Recruiting & Vakanzen",
|
||||
kacheln: [{ label: "Offene Positionen", value: openPositions.length, tone: "brand", href: "/positions" }],
|
||||
},
|
||||
{ label: "Langzeitabwesend", value: karenzCount, tone: "warning", href: "/employees?status=Karenz" },
|
||||
{ label: "Offene Positionen", value: openPositionsRes.count ?? 0, tone: "brand", href: "/positions" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{drafts && drafts.length > 0 && <DraftsCard drafts={drafts} />}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{kpis.map((kpi) => (
|
||||
<Link
|
||||
key={kpi.label}
|
||||
href={kpi.href}
|
||||
className={`${CARD_CLASS} group relative overflow-hidden p-4 pl-5 transition-shadow hover:shadow-[var(--shadow-card-hover)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500`}
|
||||
>
|
||||
<span className={`absolute inset-y-0 left-0 w-1 ${TONE[kpi.tone].bar}`} aria-hidden />
|
||||
{/* Number first in the visual order: it is what the eye is
|
||||
looking for, and the label only qualifies it. */}
|
||||
<div className={`text-3xl font-extrabold leading-none tabular-nums ${TONE[kpi.tone].text}`}>{kpi.value}</div>
|
||||
<div className="mt-1.5 flex items-center gap-1 text-xs font-semibold leading-tight text-ink-muted">
|
||||
{kpi.label}
|
||||
<ChevronRight className="h-3 w-3 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" aria-hidden />
|
||||
</div>
|
||||
</Link>
|
||||
{drafts.length > 0 && <DraftsCard drafts={drafts} />}
|
||||
{/* ── Ein Raster, nicht drei nebeneinandergestellte ──────────────
|
||||
Alle acht Kacheln liegen in **einem** Raster mit acht gleichen
|
||||
Spalten und einem einzigen Abstandswert. Nur so sind sie gleich
|
||||
breit und gleich weit auseinander — unabhängig davon, zu welcher
|
||||
Gruppe sie gehören.
|
||||
|
||||
Die beiden Anläufe davor stellten drei eigene Raster nebeneinander.
|
||||
Das ging zweimal schief: erst bekam jede Gruppe ein Drittel der
|
||||
Breite (fünf Kacheln gequetscht, eine mit demselben Platz), dann
|
||||
einen Anteil nach Kachelzahl — da stimmten die Breiten fast, aber
|
||||
eben nur fast: eine Gruppe mit fünf Kacheln hat vier Abstände in
|
||||
sich, eine mit einer keinen, und dieser Unterschied verteilt sich
|
||||
auf die Kachelbreiten. Gleichmässig wird es erst, wenn alle Kacheln
|
||||
demselben Raster angehören.
|
||||
|
||||
Die Überschriften sitzen in Zeile 1 und überspannen die Spalten
|
||||
ihrer Gruppe, die Kacheln in Zeile 2. Beide Zeilen entstehen aus
|
||||
derselben DOM-Reihenfolge (Überschrift, ihre Kacheln, nächste
|
||||
Überschrift …) — die ist für Vorlesegeräte die richtige, und die
|
||||
ausdrückliche Zeilenangabe sortiert sie fürs Auge.
|
||||
|
||||
Unterhalb der grossen Breite gibt es keine Zeilenangabe: dann fliesst
|
||||
alles der Reihe nach, die Überschrift über die volle Breite und ihre
|
||||
Kacheln zu zweit darunter. Acht nebeneinander wären auf einem Laptop
|
||||
unlesbar schmal. */}
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-3 xl:grid-cols-8 xl:gap-y-1.5">
|
||||
{kpiGruppen.map((gruppe) => (
|
||||
<Fragment key={gruppe.titel}>
|
||||
<h2
|
||||
className={`mt-2 self-end text-[11px] font-bold uppercase tracking-wider text-ink-muted
|
||||
col-span-2 xl:mt-0 xl:[grid-row:1] ${SPALTEN_SPAN[gruppe.kacheln.length]}`}
|
||||
>
|
||||
{gruppe.titel}
|
||||
</h2>
|
||||
{gruppe.kacheln.map((kpi) => (
|
||||
<Link
|
||||
key={kpi.label}
|
||||
href={kpi.href}
|
||||
className={`${CARD_CLASS} group relative overflow-hidden p-4 pl-5 transition-shadow xl:[grid-row:2] hover:shadow-[var(--shadow-card-hover)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500`}
|
||||
>
|
||||
<span className={`absolute inset-y-0 left-0 w-1 ${TONE[kpi.tone].bar}`} aria-hidden />
|
||||
{/* Number first in the visual order: it is what the eye is
|
||||
looking for, and the label only qualifies it.
|
||||
|
||||
Kleiner, sobald alle acht in einer Reihe stehen: dort
|
||||
bleiben je Kachel gut 110 px, und „748.4" braucht in 30 px
|
||||
Schriftgrad mehr davon, als nach dem Innenabstand übrig
|
||||
ist. Die Kachel hat `overflow-hidden` — die Zahl wäre also
|
||||
nicht zu breit, sondern abgeschnitten, und eine
|
||||
abgeschnittene Kennzahl ist schlimmer als eine kleine. */}
|
||||
<div className={`text-3xl font-extrabold leading-none tabular-nums xl:text-2xl ${TONE[kpi.tone].text}`}>
|
||||
{kpi.value}
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-1 text-xs font-semibold leading-tight text-ink-muted">
|
||||
{kpi.label}
|
||||
<ChevronRight className="h-3 w-3 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" aria-hidden />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -266,38 +345,48 @@ export default async function DashboardPage() {
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle className="mb-1">Anstehend (60 Tage)</CardTitle>
|
||||
<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>}
|
||||
</ul>
|
||||
<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>
|
||||
<AnstehendListe
|
||||
eintraege={upcomingAlle}
|
||||
today={today}
|
||||
leerText={
|
||||
istEingeschraenkt(zeitraum, arten)
|
||||
? "Zu dieser Auswahl steht nichts an."
|
||||
: "Keine anstehenden Ereignisse."
|
||||
}
|
||||
/>
|
||||
</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 />
|
||||
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${eventDotStyle(h.event_type)}`} 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={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${actionBadgeStyle(h.event_type)}`}>
|
||||
{/* Der Name führt in die Akte. Ohne den Link war die Karte
|
||||
eine Sackgasse: man sah, dass etwas passiert ist, und
|
||||
musste die Person danach in der Liste suchen. */}
|
||||
{h.first_name && h.last_name ? (
|
||||
<Link
|
||||
href={`/employees/${h.employee_id}`}
|
||||
className="rounded text-sm font-semibold text-ink hover:text-brand-700 hover:underline
|
||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
{fmtName(h.first_name, h.last_name)}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-sm font-semibold text-ink">Unbekannt</span>
|
||||
)}
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${eventBadgeStyle(h.event_type)}`}>
|
||||
{h.event_type}
|
||||
</span>
|
||||
</div>
|
||||
@@ -305,7 +394,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} />;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
parseEventDateParam,
|
||||
parseEventGroupDimension,
|
||||
parseEventSplitDimension,
|
||||
parseEventSubtype,
|
||||
parseEventType,
|
||||
parseGroupDimension,
|
||||
parseIsoDateParam,
|
||||
@@ -15,10 +16,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,16 +32,15 @@ type SearchParams = {
|
||||
division?: string;
|
||||
location?: string;
|
||||
status?: string;
|
||||
employment?: string;
|
||||
asOf?: string;
|
||||
eventType?: string;
|
||||
eventSubtype?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
};
|
||||
|
||||
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
|
||||
@@ -45,44 +50,57 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
||||
const eventGroup = parseEventGroupDimension(params.group);
|
||||
const eventSplit = parseEventSplitDimension(params.split);
|
||||
const eventType = parseEventType(params.eventType);
|
||||
const eventSubtype = parseEventSubtype(params.eventSubtype, eventType);
|
||||
const from = parseEventDateParam(params.from);
|
||||
const to = parseEventDateParam(params.to);
|
||||
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,
|
||||
eventSubtype: eventSubtype ?? 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);
|
||||
@@ -94,13 +112,14 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
||||
eventGroup={eventGroup}
|
||||
eventSplit={eventSplit ?? ""}
|
||||
eventType={eventType ?? ""}
|
||||
eventSubtype={eventSubtype ?? ""}
|
||||
eventFilters={{ division: params.division ?? "", location: params.location ?? "", from: from ?? "", to: to ?? "" }}
|
||||
rows={rows}
|
||||
total={sumValues(rows)}
|
||||
recordCount={events.length}
|
||||
divisions={divisions}
|
||||
units={units}
|
||||
locations={locations}
|
||||
savedReports={savedReports ?? []}
|
||||
savedReports={savedReports}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
@@ -120,14 +139,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,31 @@
|
||||
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 { PasswortFormular } from "@/components/auth/PasswortFormular";
|
||||
import { AppWortmarke } from "@/components/brand/Logo";
|
||||
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.",
|
||||
},
|
||||
// Auth.js' eigener Code für eine gescheiterte Passwortanmeldung. Im Regelfall
|
||||
// kommt er hier nicht an — die Server Action fängt den Fehlschlag ab und
|
||||
// zeigt ihn am Formular. Ohne diesen Eintrag fiele er aber auf `sso_failed`
|
||||
// zurück, und dort stünde „über das Firmenkonto", was mit dem tatsächlichen
|
||||
// Weg nichts zu tun hätte.
|
||||
CredentialsSignin: {
|
||||
title: "Anmeldung fehlgeschlagen",
|
||||
body: "E-Mail-Adresse oder Passwort stimmt nicht.",
|
||||
},
|
||||
} as const;
|
||||
|
||||
type ErrorCode = keyof typeof ERROR_MESSAGES;
|
||||
@@ -19,60 +36,140 @@ 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">
|
||||
{/* Ohne Versatz: hier steht das Logo auf Weiss und bekommt nach §4.1
|
||||
sein rosa Feld, dessen Kante mit der Ueberschrift darunter
|
||||
fluchten soll. */}
|
||||
<div className="lg:hidden">
|
||||
<AppWortmarke hoehe={26} textKlasse="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}
|
||||
/>
|
||||
<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>
|
||||
|
||||
{/* Der Passwortweg steht bewusst *unter* dem Firmenkonto und ohne
|
||||
eigene Überschrift: er ist die Ausnahme für die Testphase, nicht
|
||||
die zweite gleichwertige Möglichkeit. Wer ein Firmenkonto hat,
|
||||
soll oben klicken. */}
|
||||
<div className="mt-6 flex items-center gap-3" aria-hidden>
|
||||
<span className="h-px flex-1 bg-border-subtle" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-ink-muted">oder</span>
|
||||
<span className="h-px flex-1 bg-border-subtle" />
|
||||
</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 className="mt-6">
|
||||
<PasswortFormular />
|
||||
</div>
|
||||
<Button type="submit" fullWidth className="mt-2">
|
||||
Anmelden
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<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 (
|
||||
// Rosa und nicht Blau: Rosa ist die Farbe, in der die Marke auftritt, und
|
||||
// die Flaeche ist das Groesste, was diese Seite zu vergeben hat. Blau war
|
||||
// hier doppelt falsch — es drehte das Verhaeltnis der beiden Markenfarben
|
||||
// um, und das Logo bringt sein eigenes rosa Feld mit (§1.2), das auf Blau
|
||||
// wie ein aufgeklebtes Rechteck stand. Auf Rosa geht es in die Flaeche
|
||||
// ueber, und genau das meint §3.1 mit „in einem grossflaechigen rosa
|
||||
// Umfeld eingebettet".
|
||||
<aside className="relative hidden overflow-hidden bg-accent-500 lg:flex lg:flex-col lg:justify-between lg:p-12">
|
||||
{/* Ein feines Raster und eine leichte Tiefe zur unteren Ecke — genug
|
||||
Struktur, damit die Fläche nicht wie ein Farbfehler wirkt, aber ohne
|
||||
Bilddatei und ohne von der einen Schaltfläche gegenüber abzulenken.
|
||||
Beides aus ink: Weiss verschwindet auf Rosa, und die Lichtblende, die
|
||||
hier vorher lag, hellte die Markenfarbe so weit auf, dass sie kaum
|
||||
noch die Markenfarbe war. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(45rem 45rem at 100% 100%, rgb(35 31 32 / 0.08), transparent 60%)," +
|
||||
"linear-gradient(rgb(35 31 32 / 0.04) 1px, transparent 1px)," +
|
||||
"linear-gradient(90deg, rgb(35 31 32 / 0.04) 1px, transparent 1px)",
|
||||
backgroundSize: "auto, 3rem 3rem, 3rem 3rem",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* -ml-3 gleicht den Freiraum des Logos aus (hoehe 36 / 3 = 12 px),
|
||||
damit der Schriftzug buendig mit dem Text darunter beginnt. */}
|
||||
{/* Alles Geschriebene hier steht in brand-700. Weiss faellt auf Manner
|
||||
Rosa mit 2.18:1 aus, und ink traegt zwar (7.47:1), sieht aber aus wie
|
||||
Text, der aus Versehen auf der Marke gelandet ist. Dunkelblau auf
|
||||
Rosa (6.60:1) ist dieselbe Paarung, aus der der Schriftzug selbst
|
||||
besteht. Die Abstufung macht Groesse und Gewicht, nicht Transparenz:
|
||||
eine aufgehellte Schrift waere genau das, was hier durchfaellt. */}
|
||||
<div className="relative -ml-3">
|
||||
<AppWortmarke hoehe={36} aufRosa textKlasse="text-brand-700" />
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-md">
|
||||
<p className="text-3xl font-extrabold leading-tight tracking-tight text-brand-700">
|
||||
Alles im Blick. Alles Manner.
|
||||
</p>
|
||||
<p className="mt-4 text-sm leading-relaxed text-brand-700">
|
||||
Stammdaten, Planstellen und Berichtslinien der Alpenwerk Industrie GmbH — jederzeit auch zu einem beliebigen
|
||||
Stichtag.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="relative text-xs font-semibold text-brand-700">Interne Anwendung · Zugriff nur für die Personalabteilung</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
// Die vier Quadrate, die hier als Platzhalter-Bildmarke standen, sind
|
||||
// entfallen: an ihrer Stelle steht jetzt das Manner-Logo
|
||||
// (components/brand/Logo.tsx).
|
||||
|
||||
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,25 +1,29 @@
|
||||
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
|
||||
// apply_due_pending_changes() in supabase/migrations. Runs as a Vercel Cron
|
||||
// job (see vercel.json), not on behalf of any HR user, so it authenticates
|
||||
// via a shared secret rather than a Supabase session and uses the
|
||||
// service-role client (the one legitimate server-only use case for it).
|
||||
// apply_due_pending_changes() in db/migrations.
|
||||
//
|
||||
// Gerufen wird das vom `cron`-Dienst aus docker-compose.yml, täglich um 03:00.
|
||||
// Nicht im Namen einer HR-Person: es gibt keine angemeldete Sitzung, deshalb
|
||||
// weist sich der Aufruf mit einem gemeinsamen Geheimnis aus (CRON_SECRET).
|
||||
export async function GET(request: NextRequest) {
|
||||
const authHeader = request.headers.get("authorization");
|
||||
if (!process.env.CRON_SECRET || authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
|
||||
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 });
|
||||
}
|
||||
|
||||
46
app/api/export/cornerstone/route.ts
Normal file
46
app/api/export/cornerstone/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { baueCornerstoneZeile, CORNERSTONE_CSV, cornerstoneSpalten, type CornerstoneKontext } from "@/lib/cornerstone";
|
||||
import { loadKontierungen } from "@/lib/cost-centers";
|
||||
import { exportFilename, exportResponseHeaders, toCsv, toXlsx } from "@/lib/export";
|
||||
import { exportParameter, ladeExportMitarbeiter } from "@/lib/export-auswahl";
|
||||
import { todayIso } from "@/lib/format";
|
||||
import { requireHrUser } from "@/lib/auth/require-hr";
|
||||
import { withUser } from "@/lib/db";
|
||||
|
||||
// Der Export für Cornerstone — Spalten und Zuordnung stehen in
|
||||
// lib/cornerstone.ts.
|
||||
//
|
||||
// Dieselben Personen wie im vollständigen Export daneben, mit denselben
|
||||
// Filtern (lib/export-auswahl.ts). Wer nur die Aktiven einspielen will,
|
||||
// stellt das auf der Berichtsseite ein, bevor er exportiert.
|
||||
export async function GET(request: NextRequest) {
|
||||
const gate = await requireHrUser();
|
||||
if ("denied" in gate) return gate.denied;
|
||||
|
||||
const { format, asOf, filters } = exportParameter(request.nextUrl.searchParams);
|
||||
const stichtag = asOf ?? todayIso();
|
||||
|
||||
const { zeilen } = await withUser(gate.userId, async (tx) => {
|
||||
const { rows, managerKennung } = await ladeExportMitarbeiter(tx, { asOf, filters });
|
||||
|
||||
// Die eine Angabe, die die gemeinsame Auswahl nicht mitbringt: die
|
||||
// Kostenstelle hängt an der Planstelle, nicht an der Person. Erst jetzt,
|
||||
// weil erst jetzt feststeht, um welche Planstellen es geht.
|
||||
const positionIds = [...new Set(rows.map((r) => r.position_id).filter((id): id is string => Boolean(id)))];
|
||||
const kontext: CornerstoneKontext = {
|
||||
managerKennung,
|
||||
kostenstelle: await loadKontierungen(tx, { asOf: stichtag, positionIds }),
|
||||
};
|
||||
|
||||
return { zeilen: rows.map((r) => baueCornerstoneZeile(r, kontext)) };
|
||||
});
|
||||
|
||||
const filename = exportFilename("cornerstone-export", format);
|
||||
// Form und Spalten kommen beide aus lib/cornerstone.ts — siehe dort.
|
||||
const body =
|
||||
format === "xlsx"
|
||||
? await toXlsx(zeilen, cornerstoneSpalten(), "Worksheet")
|
||||
: toCsv(zeilen, cornerstoneSpalten(), CORNERSTONE_CSV);
|
||||
// Derselbe Umweg über Blob wie in den anderen Exporten (TS#59417).
|
||||
return new NextResponse(new Blob([body as BlobPart]), { headers: exportResponseHeaders(filename, format) });
|
||||
}
|
||||
@@ -1,55 +1,27 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { statusLabel } from "@/lib/absence";
|
||||
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
|
||||
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 { exportParameter, ladeExportMitarbeiter, type ExportMitarbeiter } from "@/lib/export-auswahl";
|
||||
import { sortiereWochentage } from "@/lib/wochentage";
|
||||
import { deriveStatusAsOf, type OrgLookups } from "@/lib/reports";
|
||||
import { requireHrUser } from "@/lib/auth/require-hr";
|
||||
import { withUser } from "@/lib/db";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
type EmployeeRow = ExportMitarbeiter;
|
||||
|
||||
// Full raw data dump — every column on `employees`, not just the fields a
|
||||
// pivot report groups by. Respects the same division/location/status/
|
||||
// employment filters as the Berichte page. With `asOf` (Stichtag), status
|
||||
// filtering happens against the *derived* status as of that date rather
|
||||
// than the live `status` column — see deriveStatusAsOf.
|
||||
// employment filters as the Berichte page. Wer darin steht, entscheidet
|
||||
// lib/export-auswahl.ts — dieselbe Auswahl wie im Honestly-Export.
|
||||
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 asOf = parseIsoDateParam(params.get("asOf"));
|
||||
const filters: ReportFilters = {
|
||||
division: params.get("division") ?? undefined,
|
||||
location: params.get("location") ?? undefined,
|
||||
status: params.get("status") ?? undefined,
|
||||
employment: params.get("employment") ?? undefined,
|
||||
};
|
||||
const { format, asOf, filters } = exportParameter(request.nextUrl.searchParams);
|
||||
const { rows, lookups, managerName, dependentsCounts } = await withUser(gate.userId, (tx) =>
|
||||
ladeExportMitarbeiter(tx, { asOf, filters })
|
||||
);
|
||||
|
||||
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 [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 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 columns = employeeExportColumns(lookups, managerName, dependentsCounts, asOf);
|
||||
const filename = exportFilename("mitarbeiter-export", format);
|
||||
|
||||
@@ -59,8 +31,6 @@ export async function GET(request: NextRequest) {
|
||||
return new NextResponse(new Blob([body as BlobPart]), { headers: exportResponseHeaders(filename, format) });
|
||||
}
|
||||
|
||||
const WEEKDAY_ORDER: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||
|
||||
function employeeExportColumns(
|
||||
lookups: OrgLookups,
|
||||
managerName: Map<string, string>,
|
||||
@@ -75,34 +45,59 @@ 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: "Firmen-E-Mail", get: (e) => e.company_email },
|
||||
{ header: "Cornerstone-ID", get: (e) => e.cornerstone_id },
|
||||
{ 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
|
||||
// guaranteed chronological — re-sort Mo→So for the export.
|
||||
{ header: "Arbeitstage", get: (e) => [...e.work_days].sort((a, b) => WEEKDAY_ORDER.indexOf(a as Weekday) - WEEKDAY_ORDER.indexOf(b as Weekday)).join(", ") },
|
||||
// Seit lib/wochentage.ts wird sortiert gespeichert; der Bestand kann aber
|
||||
// noch unsortierte Zeilen aus der Zeit davor tragen. Hier bleibt es
|
||||
// deshalb stehen — im Export kostet es nichts und macht die Spalte
|
||||
// unabhängig davon, wann eine Zeile zuletzt gespeichert wurde.
|
||||
{ header: "Arbeitstage", get: (e) => sortiereWochentage(e.work_days).join(", ") },
|
||||
{ header: "Vertragsart", get: (e) => e.contract_type },
|
||||
{ header: "Befristet bis", get: (e) => e.contract_end_date, kind: "date" },
|
||||
{ header: "Angestellte:r / Arbeiter:in", get: (e) => e.worker_type },
|
||||
{ header: "Beschäftigtengruppe", get: (e) => e.worker_type },
|
||||
{ header: "Mitarbeiterart", get: (e) => e.mitarbeiterart },
|
||||
{ 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: "Personenkreis", get: (e) => e.kuendigungsschutz_grund ?? "" },
|
||||
{ header: "Kündigungsschutz ab", get: (e) => e.kuendigungsschutz_ab, kind: "date" },
|
||||
{ header: "Kündigungsschutz bis", get: (e) => e.kuendigungsschutz_bis, kind: "date" },
|
||||
// Der Bericht, den der Workshop verlangt hat (Anforderung 5a): wer ist
|
||||
// begünstigt behindert, mit wieviel Prozent, ab und bis wann. Vier
|
||||
// Spalten statt einer zusammengesetzten — in Excel wird danach gefiltert
|
||||
// und summiert, und ein Text wie „50% seit 2019" liesse beides nicht zu.
|
||||
{ header: "Begünstigt behindert", get: (e) => e.ist_beguenstigt_behindert },
|
||||
{ header: "Grad der Behinderung (%)", get: (e) => e.behinderung_grad ?? "" },
|
||||
{ header: "Bescheid ab", get: (e) => e.behinderung_ab, kind: "date" },
|
||||
{ header: "Bescheid bis", get: (e) => e.behinderung_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 },
|
||||
{ header: "Hay-Grade", get: (e) => e.paygrade },
|
||||
{ header: "Herkunft", get: (e) => e.source },
|
||||
// The export shows the display name, not the raw enum value — a payroll
|
||||
// hand-off saying "Karenz" for what the app calls Langzeitabwesenheit
|
||||
@@ -112,6 +107,9 @@ function employeeExportColumns(
|
||||
{ header: "Eintrittsdatum", get: (e) => e.entry_date, kind: "date" },
|
||||
{ header: "Austrittsdatum", get: (e) => e.exit_date, kind: "date" },
|
||||
{ header: "Austrittsgrund", get: (e) => e.exit_reason },
|
||||
// Eigene Spalte neben dem Grund: aus ihm folgt sie nicht. Leer heisst
|
||||
// „nicht erfasst" und nicht „weder noch".
|
||||
{ header: "Freiwillig/unfreiwillig", get: (e) => e.austrittsart ?? "" },
|
||||
{ header: "Abwesenheit ab", get: (e) => e.karenz_start_date, kind: "date" },
|
||||
{ header: "Rückkehr geplant", get: (e) => e.karenz_return_date, kind: "date" },
|
||||
{ header: "Anzahl Angehörige", get: (e) => dependentsCounts.get(e.id) ?? 0 },
|
||||
|
||||
@@ -1,33 +1,46 @@
|
||||
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 {
|
||||
EVENT_TYPE_LABELS,
|
||||
parseEventDateParam,
|
||||
parseEventSubtype,
|
||||
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,
|
||||
// Ohne diese Zeile exportierte die Schaltfläche neben der Auswertung
|
||||
// etwas anderes, als auf dem Bildschirm steht — die Einschränkung auf
|
||||
// die Beendigungsart fiele beim Export still weg.
|
||||
eventSubtype: parseEventSubtype(params.get("eventSubtype"), 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);
|
||||
@@ -41,12 +54,15 @@ function eventExportColumns(lookups: OrgLookups): ExportColumn<ReportEvent>[] {
|
||||
return [
|
||||
{ header: "Datum", get: (e) => e.event_date, kind: "date" },
|
||||
{ header: "Ereignistyp", get: (e) => EVENT_TYPE_LABELS[e.event_type] ?? e.event_type },
|
||||
// Beendigungsart bzw. Art der Langzeitabwesenheit. Leer bei allen
|
||||
// Ereignissen, die keinen Untertyp kennen — eine Versetzung hat keinen.
|
||||
{ header: "Untertyp", get: (e) => e.subtype ?? "" },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
29
app/api/export/honestly/route.ts
Normal file
29
app/api/export/honestly/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { exportFilename, exportResponseHeaders, toCsv, toXlsx } from "@/lib/export";
|
||||
import { exportParameter, ladeExportMitarbeiter } from "@/lib/export-auswahl";
|
||||
import { baueHonestlyZeilen, honestlySpalten } from "@/lib/honestly";
|
||||
import { requireHrUser } from "@/lib/auth/require-hr";
|
||||
import { withUser } from "@/lib/db";
|
||||
|
||||
// Der Export für die Mitarbeiterbefragung bei Honestly — Spalten und feste
|
||||
// Werte stehen in lib/honestly.ts.
|
||||
//
|
||||
// Dieselben Personen wie im vollständigen Export daneben, mit denselben
|
||||
// Filtern (lib/export-auswahl.ts). Wer eine Befragung nur für einen Bereich
|
||||
// oder nur für Aktive will, stellt das auf der Berichtsseite ein, bevor er
|
||||
// exportiert.
|
||||
export async function GET(request: NextRequest) {
|
||||
const gate = await requireHrUser();
|
||||
if ("denied" in gate) return gate.denied;
|
||||
|
||||
const { format, asOf, filters } = exportParameter(request.nextUrl.searchParams);
|
||||
const { rows, lookups, orgMaps } = await withUser(gate.userId, (tx) => ladeExportMitarbeiter(tx, { asOf, filters }));
|
||||
|
||||
const { zeilen, tiefe } = baueHonestlyZeilen(rows, orgMaps, lookups.locationName);
|
||||
const spalten = honestlySpalten(tiefe);
|
||||
const filename = exportFilename("honestly-export", format);
|
||||
|
||||
const body = format === "xlsx" ? await toXlsx(zeilen, spalten, "Honestly") : toCsv(zeilen, spalten);
|
||||
// Derselbe Umweg über Blob wie im vollständigen Export (TS#59417).
|
||||
return new NextResponse(new Blob([body as BlobPart]), { headers: exportResponseHeaders(filename, format) });
|
||||
}
|
||||
45
app/api/export/lolyo/route.ts
Normal file
45
app/api/export/lolyo/route.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { exportFilename, exportResponseHeaders, toCsv, toXlsx } from "@/lib/export";
|
||||
import { exportParameter, ladeExportMitarbeiter } from "@/lib/export-auswahl";
|
||||
import { baueLolyoZeile, LOLYO_CSV, lolyoSpalten } from "@/lib/lolyo";
|
||||
import { requireHrUser } from "@/lib/auth/require-hr";
|
||||
import { withUser } from "@/lib/db";
|
||||
|
||||
// Der Export für die Mitarbeiter-App LOLYO — Spalten und feste Werte stehen in
|
||||
// lib/lolyo.ts.
|
||||
//
|
||||
// Dieselben Personen wie im vollständigen Export daneben, mit denselben Filtern
|
||||
// (lib/export-auswahl.ts). Wer nur einen Bereich oder nur die Aktiven anlegen
|
||||
// will, stellt das auf der Berichtsseite ein, bevor er exportiert.
|
||||
|
||||
/**
|
||||
* Das gemeinsame Startpasswort, mit dem die Konten in LOLYO angelegt werden.
|
||||
*
|
||||
* Steht bewusst **hier** und nicht in lib/lolyo.ts: eine Route ist serverseitig
|
||||
* und gelangt nie in ein Bündel, das der Browser lädt. Eine Konstante in einer
|
||||
* lib-Datei wäre nur so lange sicher, wie niemand sie aus einem Client-Bauteil
|
||||
* heraus einbindet — und das sähe man dem Bündel nicht an. Dieselbe Überlegung
|
||||
* steht in lib/passwort.ts.
|
||||
*
|
||||
* Es ist dasselbe Passwort wie in Alpenwerk, so bestellt. Damit gilt hier auch
|
||||
* dasselbe: es ist allen gemeinsam und damit bekannt, und es trägt so lange,
|
||||
* bis die Person es wechselt. Die erzeugte Datei ist deshalb kein Anhang, den
|
||||
* man weiterleitet.
|
||||
*/
|
||||
const STARTPASSWORT = "Initialpasswort2026!";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const gate = await requireHrUser();
|
||||
if ("denied" in gate) return gate.denied;
|
||||
|
||||
const { format, asOf, filters } = exportParameter(request.nextUrl.searchParams);
|
||||
const { rows } = await withUser(gate.userId, (tx) => ladeExportMitarbeiter(tx, { asOf, filters }));
|
||||
|
||||
const zeilen = rows.map((r) => baueLolyoZeile(r, STARTPASSWORT));
|
||||
const spalten = lolyoSpalten();
|
||||
const filename = exportFilename("lolyo-export", format);
|
||||
|
||||
const body = format === "xlsx" ? await toXlsx(zeilen, spalten, "LOLYO") : toCsv(zeilen, spalten, LOLYO_CSV);
|
||||
// Derselbe Umweg über Blob wie im vollständigen Export (TS#59417).
|
||||
return new NextResponse(new Blob([body as BlobPart]), { headers: exportResponseHeaders(filename, format) });
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
// Im eigenen Container wirkt diese Angabe nicht — sie richtet sich an
|
||||
// serverlose Plattformen, die einen Aufruf nach Ablauf abschneiden. Sie bleibt
|
||||
// als Absichtserklärung stehen: ein Import, der länger als eine Minute
|
||||
// braucht, ist einer, der in Teilen laufen sollte.
|
||||
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") });
|
||||
}
|
||||
BIN
app/apple-icon.png
Normal file
BIN
app/apple-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
BIN
app/favicon.ico
BIN
app/favicon.ico
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 5.4 KiB |
@@ -6,6 +6,12 @@ import { useEffect } from "react";
|
||||
// (app)/error.tsx is not mounted yet. It replaces <html>, so it cannot use
|
||||
// the app's fonts, Tailwind layer or shared components — hence the inline
|
||||
// styles. Kept deliberately plain; anything clever here can fail too.
|
||||
//
|
||||
// Die Farben sind dieselben wie in app/globals.css, nur von Hand: surface,
|
||||
// border, ink, ink-body, ink-muted und brand-500. Sie stehen hier doppelt,
|
||||
// weil diese Seite gerade dann erscheint, wenn das Stylesheet nicht
|
||||
// getragen hat — eine Variable waere hier genau das, worauf man sich nicht
|
||||
// verlassen kann.
|
||||
export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
useEffect(() => {
|
||||
console.error("Global error:", error);
|
||||
@@ -13,19 +19,19 @@ export default function GlobalError({ error, reset }: { error: Error & { digest?
|
||||
|
||||
return (
|
||||
<html lang="de-AT">
|
||||
<body style={{ margin: 0, backgroundColor: "#f9f1f5", fontFamily: "system-ui, sans-serif", color: "#2d1c26" }}>
|
||||
<div style={{ maxWidth: 480, margin: "15vh auto", padding: 24, background: "#fff", border: "1px solid #eedde6", borderRadius: 8 }}>
|
||||
<body style={{ margin: 0, backgroundColor: "#fdf6f4", fontFamily: "system-ui, sans-serif", color: "#231f20" }}>
|
||||
<div style={{ maxWidth: 480, margin: "15vh auto", padding: 24, background: "#fff", border: "1px solid #f0dbd4", borderRadius: 8 }}>
|
||||
<h1 style={{ fontSize: 18, margin: "0 0 8px" }}>Die Anwendung konnte nicht geladen werden</h1>
|
||||
<p style={{ fontSize: 14, lineHeight: 1.5, color: "#503b47", margin: "0 0 16px" }}>
|
||||
<p style={{ fontSize: 14, lineHeight: 1.5, color: "#4b4446", margin: "0 0 16px" }}>
|
||||
Es ist ein unerwarteter Fehler aufgetreten. Ihre Daten sind davon nicht betroffen.
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p style={{ fontSize: 12, fontFamily: "monospace", color: "#7a636f", margin: "0 0 16px" }}>Fehlerkennung: {error.digest}</p>
|
||||
<p style={{ fontSize: 12, fontFamily: "monospace", color: "#756c6e", margin: "0 0 16px" }}>Fehlerkennung: {error.digest}</p>
|
||||
)}
|
||||
<button
|
||||
onClick={reset}
|
||||
style={{
|
||||
background: "#d6046e",
|
||||
background: "#164194",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: 4,
|
||||
|
||||
@@ -1,23 +1,74 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Design tokens from spec §7 (ported from tailwind.config.ts for Tailwind v4) */
|
||||
/* ═══ Farben nach dem Manner CD Manual 2022 ═══════════════════════════
|
||||
§1.1 kennt genau zwei Markenfarben:
|
||||
|
||||
Manner Rosa #F69686 CMYK 0/50/40/0 sRGB 246 150 134
|
||||
Manner Blau #164194 CMYK 100/80/0/0 sRGB 22 65 148 (Reflex Blue)
|
||||
|
||||
Beide behalten ihren Wert unveraendert; die Abstufungen daneben sind
|
||||
Aufhellungen und Abdunklungen davon, weil eine Oberflaeche Zwischentoene
|
||||
fuer Hover, Chips und Flaechen braucht und das Manual nur fuer den Druck
|
||||
geschrieben ist.
|
||||
|
||||
── Warum Blau die Interaktion traegt und Rosa die Flaeche ──
|
||||
Nachgerechnet nach WCAG 2.1:
|
||||
|
||||
Weiss auf Rosa 2.18:1 faellt durch, auch fuer Grossschrift
|
||||
Schwarz auf Rosa 7.47:1 AAA
|
||||
Weiss auf Blau 9.47:1 AAA
|
||||
Blau auf Weiss 9.47:1 AAA
|
||||
|
||||
Rosa kann also keine Schrift tragen — weder weisse noch, bei 4.34:1,
|
||||
blaue Fliesschrift. Es ist eine Flaeche, auf der Dunkles steht. Blau
|
||||
traegt in beide Richtungen und wird damit zur Farbe von Knopf, Link und
|
||||
Fokusring. Das ist keine Auslegung des Manuals, sondern das, was die
|
||||
beiden Farben physikalisch hergeben. */
|
||||
@theme {
|
||||
--color-brand-50: #fdf3f8;
|
||||
--color-brand-100: #fdeaf3;
|
||||
--color-brand-200: #f6cfe2;
|
||||
--color-brand-500: #d6046e;
|
||||
--color-brand-600: #b0035a;
|
||||
--color-brand-700: #a30354;
|
||||
/* ── Blau: alles, was man anfassen kann ── */
|
||||
--color-brand-50: #eef3fb;
|
||||
--color-brand-100: #dae4f5;
|
||||
--color-brand-200: #afc3e6;
|
||||
--color-brand-500: #164194; /* Manner Blau, CD §1.1 */
|
||||
--color-brand-600: #11326f;
|
||||
--color-brand-700: #0d2857;
|
||||
|
||||
--color-surface: #f9f1f5;
|
||||
/* ── Rosa: alles, was die Marke traegt ──
|
||||
accent-500 ist der Markenwert und bleibt Flaeche. Die Schrift darauf
|
||||
ist ink (7.47:1), nie Weiss. */
|
||||
--color-accent-50: #fef5f2;
|
||||
--color-accent-100: #fde7e1;
|
||||
--color-accent-200: #facec2;
|
||||
--color-accent-500: #f69686; /* Manner Rosa, CD §1.1 */
|
||||
--color-accent-600: #d9604b;
|
||||
--color-accent-700: #a8402e;
|
||||
|
||||
--color-border: #eedde6;
|
||||
--color-border-subtle: #f7e8ef;
|
||||
--color-surface: #fdf6f4;
|
||||
|
||||
--color-ink: #2d1c26;
|
||||
--color-ink-body: #503b47;
|
||||
--color-ink-muted: #7a636f;
|
||||
--color-border: #f0dbd4;
|
||||
--color-border-subtle: #f8ece8;
|
||||
/* Fuer die Umrandung von Eingabefeldern. Der dekorative Kartenrahmen
|
||||
darueber darf blass sein; die Grenze eines Bedienelements muss nach
|
||||
WCAG 1.4.11 3:1 erreichen, und das tut dieser Ton mit 3.56:1 gegen
|
||||
Weiss und 3.34:1 gegen die Seitenflaeche. */
|
||||
--color-border-strong: #a67f70;
|
||||
|
||||
/* Das Schwarz aus dem Manual selbst (#231F20), nicht reines Schwarz. */
|
||||
--color-ink: #231f20;
|
||||
--color-ink-body: #4b4446;
|
||||
--color-ink-muted: #756c6e;
|
||||
|
||||
/* ── Funktionsfarben ──
|
||||
Sie bleiben, was sie sind. Das Manual regelt die Identitaet, nicht die
|
||||
Rueckmeldung: Rot heisst Fehler, weil die Benutzerin das mitbringt,
|
||||
nicht weil es jemand entworfen hat. In einem System, in dem ein Knopf
|
||||
einen Austritt erfasst, waere eine markenkonforme Fehlerfarbe kein
|
||||
Stil, sondern ein Mangel.
|
||||
|
||||
`info` bleibt bewusst tuerkis statt blau zu werden: Blau heisst ab
|
||||
jetzt „hier kann man klicken", und ein Hinweischip in derselben Farbe
|
||||
saehe bedienbar aus. Gemessen kommt dazu, dass ein blaues info dem
|
||||
violetten Chip zu nahe kaeme (dE 6.8 — nicht unterscheidbar). */
|
||||
--color-success-bg: #dff6dd;
|
||||
--color-success-text: #0e700e;
|
||||
|
||||
@@ -31,8 +82,11 @@
|
||||
--color-info-bg: #d7f0f0;
|
||||
--color-info-text: #00666d;
|
||||
|
||||
--color-purple-bg: #f0ecf7;
|
||||
--color-purple-text: #5c2e91;
|
||||
/* Nachgezogen: gegen den alten Wert #f0ecf7 stand der Fehler-Chip bei
|
||||
dE 8.0 — „Austritt" und „Befoerderung" waren im Vorbeigehen dieselbe
|
||||
blasse Flaeche. Jetzt dE 15.0 bei 7.9:1 Kontrast. */
|
||||
--color-purple-bg: #e4d9f5;
|
||||
--color-purple-text: #54277f;
|
||||
|
||||
/* A bare `--radius` in Tailwind v4 collapses the whole scale onto one
|
||||
value — `rounded` and `rounded-lg` both came out at 8px, so a chip, an
|
||||
@@ -46,12 +100,17 @@
|
||||
|
||||
/* Warm, brand-tinted rather than neutral grey: a pure black shadow over
|
||||
the pink surface reads as dirt. Kept shallow — this is a data-dense
|
||||
admin UI, the depth only has to separate a card from the page. */
|
||||
--shadow-card: 0 1px 2px rgb(107 33 71 / 0.04), 0 1px 3px rgb(107 33 71 / 0.06);
|
||||
--shadow-card-hover: 0 2px 4px rgb(107 33 71 / 0.06), 0 4px 12px rgb(107 33 71 / 0.08);
|
||||
--shadow-overlay: 0 8px 32px rgb(45 28 38 / 0.16);
|
||||
admin UI, the depth only has to separate a card from the page.
|
||||
Der Grundton folgt dem Rosa der Flaeche, nicht mehr dem alten Magenta. */
|
||||
--shadow-card: 0 1px 2px rgb(122 60 45 / 0.05), 0 1px 3px rgb(122 60 45 / 0.07);
|
||||
--shadow-card-hover: 0 2px 4px rgb(122 60 45 / 0.07), 0 4px 12px rgb(122 60 45 / 0.09);
|
||||
--shadow-overlay: 0 8px 32px rgb(35 31 32 / 0.18);
|
||||
|
||||
--font-sans: var(--font-nunito), system-ui, sans-serif;
|
||||
/* Siehe app/layout.tsx: DIN waere die Schrift des Manuals, ist aber nicht
|
||||
als Webfont lizenziert; geladen wird Barlow als naechstliegender freier
|
||||
Ersatz. Die Ersatzkette bleibt neutral — faellt der Webfont aus, soll
|
||||
keine gerundete Systemschrift einspringen. */
|
||||
--font-sans: var(--font-din), "Segoe UI", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
315
app/icon.svg
Normal file
315
app/icon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 73 KiB |
@@ -1,11 +1,33 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Nunito } from "next/font/google";
|
||||
import { Barlow } from "next/font/google";
|
||||
import { ToastProvider } from "@/components/ui/Toast";
|
||||
import "./globals.css";
|
||||
|
||||
const nunito = Nunito({
|
||||
variable: "--font-nunito",
|
||||
subsets: ["latin"],
|
||||
// ═══ Die Schrift ═══
|
||||
//
|
||||
// Das CD Manual ist selbst in DIN gesetzt: nachgezaehlt tragen DIN-Regular
|
||||
// 79 % und DIN-Bold 16 % des Textes, zusammen 95 %. Helvetica Neue taucht nur
|
||||
// in den Visitenkarten-Mustern und im Claim auf.
|
||||
//
|
||||
// DIN laesst sich hier trotzdem nicht einsetzen: FF DIN und DIN Next sind
|
||||
// lizenzpflichtig, und eine Druck-Lizenz deckt die Auslieferung als Webfont
|
||||
// nicht ab — das ist eine eigene, nach Domain oder Abrufen bemessene Lizenz.
|
||||
// Die freien Nachbauten der DIN 1451 sind Schilderschriften: enge Punzen,
|
||||
// wenige Schnitte, und bei 14 px in einer Tabelle mit achthundert Zeilen
|
||||
// schlechter lesbar als das, was sie ersetzen sollen.
|
||||
//
|
||||
// Barlow ist die naechstliegende freie Schrift mit UI-Tauglichkeit: dieselbe
|
||||
// Grotesk-Familie, schmale Proportionen, geringer Strichkontrast, gerade
|
||||
// Abschluesse — und neun Schnitte mit vollem Latin-Extended, also allen
|
||||
// Umlauten.
|
||||
//
|
||||
// Die Variable heisst nach der Absicht und nicht nach dem Ersatz: liegt
|
||||
// eines Tages eine Web-Lizenz fuer DIN vor, ist der Wechsel diese eine
|
||||
// Deklaration und sonst nichts.
|
||||
const din = Barlow({
|
||||
variable: "--font-din",
|
||||
// latin-ext wegen der Umlaute und des scharfen S, die in Namen vorkommen.
|
||||
subsets: ["latin", "latin-ext"],
|
||||
weight: ["400", "600", "700", "800"],
|
||||
});
|
||||
|
||||
@@ -15,6 +37,11 @@ export const metadata: Metadata = {
|
||||
// Added to the home screen on iOS this opens without Safari's chrome and
|
||||
// keeps the app's own name rather than the page title.
|
||||
appleWebApp: { capable: true, title: "Alpenwerk HR", statusBarStyle: "default" },
|
||||
// app/favicon.ico, app/icon.svg und app/apple-icon.png findet Next von
|
||||
// selbst; die Datei-Konventionen brauchen keinen Eintrag hier. Der Verweis
|
||||
// auf das Manifest dagegen schon — app/manifest.webmanifest wird sonst
|
||||
// zwar ausgeliefert, aber von keiner Seite genannt.
|
||||
manifest: "/manifest.webmanifest",
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
@@ -23,7 +50,9 @@ export const viewport: Viewport = {
|
||||
// The layout extends under the notch and home indicator; every edge that
|
||||
// matters pads itself back out with env(safe-area-inset-*).
|
||||
viewportFit: "cover",
|
||||
themeColor: "#ffffff",
|
||||
// Manner Rosa (CD §1.1): die Farbe, in der mobile Browser ihre eigene
|
||||
// Leiste ueber der Seite einfaerben. Weiss war hier nur die Vorgabe.
|
||||
themeColor: "#F69686",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -32,7 +61,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="de-AT" className={`${nunito.variable} h-full antialiased`}>
|
||||
<html lang="de-AT" className={`${din.variable} h-full antialiased`}>
|
||||
<body className="flex min-h-dvh flex-col font-sans">
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</body>
|
||||
|
||||
29
app/manifest.webmanifest
Normal file
29
app/manifest.webmanifest
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "Alpenwerk HR",
|
||||
"short_name": "Alpenwerk HR",
|
||||
"description": "Interne HR-Stammdatenverwaltung",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/brand/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/brand/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/brand/icon-512-maskable.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#F69686",
|
||||
"background_color": "#FDF6F4",
|
||||
"display": "standalone",
|
||||
"start_url": "/"
|
||||
}
|
||||
57
app/passwort-aendern/page.tsx
Normal file
57
app/passwort-aendern/page.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { PasswortAendernFormular } from "@/components/auth/PasswortAendernFormular";
|
||||
import { CARD_CLASS } from "@/components/ui/Card";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { sql, withUser } from "@/lib/db";
|
||||
|
||||
// Diese Seite liegt **ausserhalb** der Gruppe (app), und das ist der Punkt.
|
||||
//
|
||||
// app/(app)/layout.tsx lässt nur durch, wer aktive HR-Person ist — und genau
|
||||
// das ist man nicht, solange ein Passwortwechsel aussteht: is_hr_user() liefert
|
||||
// dann false (Migration 20260908120000). Läge diese Seite unter (app), wäre sie
|
||||
// für die einzigen Menschen gesperrt, die sie brauchen.
|
||||
//
|
||||
// Angemeldet sein muss man trotzdem: darum kümmert sich proxy.ts, der jede
|
||||
// Adresse ausser /login und /api/auth hinter eine Sitzung stellt. Die Prüfung
|
||||
// unten ist die zweite Linie für den Fall, dass jemand den Matcher ändert.
|
||||
|
||||
export const metadata = { title: "Passwort ändern" };
|
||||
|
||||
export default async function PasswortAendernPage() {
|
||||
const userId = await currentUserId();
|
||||
if (!userId) redirect("/login");
|
||||
|
||||
// Erzwungen oder freiwillig — davon hängt nur die Beschriftung ab und ob es
|
||||
// einen Weg zurück gibt.
|
||||
//
|
||||
// Ob dieses Konto überhaupt ein Passwort hat, wird hier *nicht* gefragt:
|
||||
// app_passwoerter ist für die Anwendungsrolle unerreichbar (Policy
|
||||
// `using (false)`), und eine Auskunftsfunktion nur für diese Anzeige wäre
|
||||
// eine Funktion zu viel. Wer kein Passwort hat, bekommt beim Absenden die
|
||||
// Meldung aus app_passwort_aendern().
|
||||
const erzwungen = await withUser(userId, async (tx) => {
|
||||
const ergebnis = await sql<{ wechseln: boolean }>`select app_muss_passwort_wechseln() as wechseln`.execute(tx);
|
||||
return ergebnis.rows[0]?.wechseln === true;
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="flex min-h-dvh items-center justify-center px-6 py-12">
|
||||
<div className="w-full max-w-sm">
|
||||
<h1 className="text-2xl font-extrabold tracking-tight text-ink">Passwort ändern</h1>
|
||||
|
||||
{erzwungen ? (
|
||||
<p className="mt-1.5 text-sm text-ink-muted">
|
||||
Ihr Zugang wurde mit einem gemeinsamen Startpasswort eingerichtet. Solange es gilt, bleiben alle
|
||||
Personaldaten verschlossen — bitte vergeben Sie jetzt ein eigenes.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1.5 text-sm text-ink-muted">Sie können hier jederzeit ein neues Passwort vergeben.</p>
|
||||
)}
|
||||
|
||||
<div className={`mt-7 p-5 ${CARD_CLASS}`}>
|
||||
<PasswortAendernFormular erzwungen={erzwungen} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
134
auth.ts
Normal file
134
auth.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import "server-only";
|
||||
import NextAuth from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
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.
|
||||
//
|
||||
// Seit 08.09.2026 steht daneben ein zweiter Weg: Anmeldung mit Passwort, für
|
||||
// die Testphase und mit eingebautem Ende (siehe Migration 20260908120000).
|
||||
// Auch er gehört **hierher** und nicht nach lib/auth/config.ts, denn auch er
|
||||
// spricht mit der Datenbank. Der Proxy sieht ihn nicht und braucht ihn nicht:
|
||||
// er liest nur das Sitzungscookie, und das ist ein signiertes Token, dessen
|
||||
// Entschlüsselung von der Anbieterliste unabhängig ist. Welcher Weg zur
|
||||
// Sitzung geführt hat, steht darin nicht — und muss es auch nicht.
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anmeldung mit Passwort.
|
||||
*
|
||||
* Die Prüfung selbst steht vollständig in app_passwort_pruefen(): Sperre nach
|
||||
* fünf Fehlversuchen, Frist für das Initialpasswort, Enddatum des
|
||||
* Passwortpfads, Protokolleintrag. Hier bleibt nur das Weiterreichen — und
|
||||
* das ist Absicht. Eine zweite Fassung der Regeln im Anwendungscode wäre die
|
||||
* Fassung, die zuerst veraltet, und sie liesse sich umgehen, indem jemand die
|
||||
* Datenbankfunktion direkt aufruft.
|
||||
*
|
||||
* Die Funktion liefert eine Kennung oder null und **nie einen Grund**. Was
|
||||
* hier ankommt, reicht deshalb nicht aus, um zu unterscheiden, ob die Adresse
|
||||
* unbekannt, das Passwort falsch oder das Konto gesperrt war. Genau so soll
|
||||
* die Anmeldemaske antworten.
|
||||
*/
|
||||
const passwortAnbieter = Credentials({
|
||||
id: "passwort",
|
||||
name: "Passwort",
|
||||
credentials: {
|
||||
email: { label: "E-Mail", type: "email" },
|
||||
passwort: { label: "Passwort", type: "password" },
|
||||
},
|
||||
async authorize(eingabe) {
|
||||
const email = typeof eingabe?.email === "string" ? eingabe.email : "";
|
||||
const passwort = typeof eingabe?.passwort === "string" ? eingabe.passwort : "";
|
||||
if (!email || !passwort) return null;
|
||||
|
||||
// asSystem, weil es noch keine angemeldete Person gibt — die entsteht ja
|
||||
// erst aus dem Ergebnis. Dieselbe Rolle ohne BYPASSRLS wie überall; die
|
||||
// Funktion ist SECURITY DEFINER und darf genau das eine.
|
||||
const zeile = await asSystem(async (tx) => {
|
||||
const ergebnis = await sql<{ id: string | null }>`
|
||||
select app_passwort_pruefen(${email}, ${passwort}) as id
|
||||
`.execute(tx);
|
||||
return ergebnis.rows[0];
|
||||
});
|
||||
|
||||
if (!zeile?.id) return null;
|
||||
return { id: zeile.id, email, name: null };
|
||||
},
|
||||
});
|
||||
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth(() => {
|
||||
const base = authConfig();
|
||||
return {
|
||||
...base,
|
||||
// Entra bleibt der erste Eintrag und damit der Hauptweg. Die Basisliste
|
||||
// wird ergänzt, nicht ersetzt: `providers: [passwortAnbieter]` hätte die
|
||||
// Firmenanmeldung stillschweigend entfernt.
|
||||
providers: [...base.providers, passwortAnbieter],
|
||||
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, user }) {
|
||||
// Drei Fälle, und die Reihenfolge trägt sie:
|
||||
//
|
||||
// 1. Entra, erster Durchlauf → `profile` liegt vor (und `user` auch)
|
||||
// 2. Passwort, erster Durchlauf → nur `user`, aus authorize()
|
||||
// 3. jeder weitere Aufruf → keins von beiden, Token durchreichen
|
||||
//
|
||||
// Deshalb wird `profile` zuerst geprüft: bei der Anmeldung über Entra
|
||||
// ist `user` ebenfalls gesetzt, und eine Prüfung auf `user` zuerst
|
||||
// führte den Entra-Weg an app_upsert_user() vorbei — die Kennung wäre
|
||||
// dann die `oid` statt app_users.id, und keine einzige Policy fände
|
||||
// dazu eine Zeile.
|
||||
if (!profile) {
|
||||
if (user?.id) token.uid = user.id;
|
||||
return token;
|
||||
}
|
||||
|
||||
// `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.
|
||||
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/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-accent-100 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,11 @@ const ACTIONS: { value: string; label: string }[] = [
|
||||
{ value: "Karenz", label: "Langzeitabwesenheit" },
|
||||
{ value: "Vertragsänderung", label: "Vertragsänderung" },
|
||||
{ value: "Stammdatenänderung", label: "Stammdatenänderung" },
|
||||
{ value: "Gehaltsanpassung", label: "Gehaltsanpassung" },
|
||||
// „Gehaltsanpassung" steht nicht mehr zur Auswahl: das Gehalt ist aus dem
|
||||
// Funktionsumfang genommen (es wird in Loga geführt), keine Funktion
|
||||
// schreibt diese Art mehr, und ein Filter, der immer leer ausgeht, sieht
|
||||
// aus wie ein Fehler. Der Wert bleibt im Aufzählungstyp — alte Einträge
|
||||
// aus der Zeit davor sollen lesbar bleiben.
|
||||
];
|
||||
|
||||
export function AuditFilters() {
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
105
components/auth/PasswortAendernFormular.tsx
Normal file
105
components/auth/PasswortAendernFormular.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useActionState, useState } from "react";
|
||||
import { passwortAendern, type WechselZustand } from "@/actions/passwort";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { CONTROL_CLASS, Field } from "@/components/ui/Field";
|
||||
import { PASSWORT_REGELN, passwortBeanstandung } from "@/lib/passwort";
|
||||
|
||||
// Das eigene Passwort wechseln.
|
||||
//
|
||||
// Die Regeln stehen schon beim Tippen da, statt erst nach dem Absenden — beim
|
||||
// erzwungenen Wechsel ist das der erste Bildschirm, den jemand in dieser
|
||||
// Anwendung sieht, und dreimal abgewiesen zu werden ist ein schlechter Anfang.
|
||||
// Abgewiesen oder angenommen wird trotzdem in der Datenbank; hier steht nur
|
||||
// eine Vorabanzeige (lib/passwort.ts).
|
||||
|
||||
const START: WechselZustand = { fehler: null };
|
||||
|
||||
export function PasswortAendernFormular({ erzwungen }: { erzwungen: boolean }) {
|
||||
const [zustand, aktion, laeuft] = useActionState(passwortAendern, START);
|
||||
const [neues, setNeues] = useState("");
|
||||
const [wiederholung, setWiederholung] = useState("");
|
||||
|
||||
// Erst beanstanden, wenn etwas dasteht: ein leeres Feld ist noch kein Fehler.
|
||||
const beanstandung = neues ? passwortBeanstandung(neues) : null;
|
||||
const passtNichtZusammen = wiederholung.length > 0 && neues !== wiederholung;
|
||||
|
||||
return (
|
||||
<form action={aktion} className="flex flex-col gap-4">
|
||||
{zustand.fehler && (
|
||||
<p role="alert" className="rounded-md border border-danger-text/20 bg-danger-bg px-3 py-2 text-sm text-danger-text">
|
||||
{zustand.fehler}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Field label="Bisheriges Passwort">
|
||||
{(p) => (
|
||||
<input {...p} name="altes_passwort" type="password" required autoComplete="current-password" className={CONTROL_CLASS} />
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Neues Passwort"
|
||||
error={beanstandung}
|
||||
hint={`Erforderlich: ${PASSWORT_REGELN.join(", ")}.`}
|
||||
>
|
||||
{(p) => (
|
||||
<input
|
||||
{...p}
|
||||
name="neues_passwort"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
value={neues}
|
||||
onChange={(e) => setNeues(e.target.value)}
|
||||
className={CONTROL_CLASS}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="Neues Passwort wiederholen" error={passtNichtZusammen ? "Die beiden Eingaben stimmen nicht überein." : null}>
|
||||
{(p) => (
|
||||
<input
|
||||
{...p}
|
||||
name="wiederholung"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
value={wiederholung}
|
||||
onChange={(e) => setWiederholung(e.target.value)}
|
||||
className={CONTROL_CLASS}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{/* Gesperrt, solange die Vorabanzeige etwas zu beanstanden hat. Das ist
|
||||
Bequemlichkeit, keine Absicherung — wer die Schaltfläche freischaltet,
|
||||
landet in app_passwort_regeln() und wird dort abgewiesen. */}
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
disabled={Boolean(beanstandung) || passtNichtZusammen}
|
||||
pending={laeuft}
|
||||
pendingLabel="Wird gespeichert…"
|
||||
>
|
||||
Passwort ändern
|
||||
</Button>
|
||||
|
||||
{/* `Link` und nicht `<a>`: ein gewöhnlicher Verweis auf eine Seite dieser
|
||||
Anwendung lädt das Dokument neu und damit das ganze Bündel — der Weg
|
||||
zurück fühlte sich an wie ein Neustart. eslint-config-next weist das
|
||||
ab, und zu Recht. */}
|
||||
{!erzwungen && (
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded text-center text-xs text-ink-muted underline underline-offset-2 hover:no-underline
|
||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
Zurück zur Anwendung
|
||||
</Link>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
54
components/auth/PasswortFormular.tsx
Normal file
54
components/auth/PasswortFormular.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { anmeldenMitPasswort, type AnmeldeZustand } from "@/actions/auth";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { CONTROL_CLASS, Field } from "@/components/ui/Field";
|
||||
|
||||
// Anmeldung mit Passwort — der zweite Weg neben dem Firmenkonto, auf Zeit
|
||||
// (siehe actions/auth.ts).
|
||||
//
|
||||
// Die Meldung kommt aus der Server Action und ist für jeden Fehlschlag
|
||||
// dieselbe. Kein `autoFocus`: die Seite bietet zwei Wege an, und der obere ist
|
||||
// der gemeinte — den Cursor unten hineinzusetzen kehrte das um.
|
||||
|
||||
const START: AnmeldeZustand = { fehler: null };
|
||||
|
||||
export function PasswortFormular() {
|
||||
const [zustand, aktion, laeuft] = useActionState(anmeldenMitPasswort, START);
|
||||
|
||||
return (
|
||||
<form action={aktion} className="flex flex-col gap-3">
|
||||
{zustand.fehler && (
|
||||
<p role="alert" className="rounded-md border border-danger-text/20 bg-danger-bg px-3 py-2 text-sm text-danger-text">
|
||||
{zustand.fehler}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Field label="E-Mail-Adresse">
|
||||
{(p) => (
|
||||
<input
|
||||
{...p}
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
// `username`, nicht `email`: nur so bietet der Passwortspeicher des
|
||||
// Browsers das Paar aus Adresse und Passwort gemeinsam an.
|
||||
autoComplete="username"
|
||||
className={CONTROL_CLASS}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="Passwort">
|
||||
{(p) => (
|
||||
<input {...p} name="passwort" type="password" required autoComplete="current-password" className={CONTROL_CLASS} />
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Button type="submit" fullWidth pending={laeuft} pendingLabel="Anmelden…">
|
||||
Anmelden
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
94
components/brand/Logo.tsx
Normal file
94
components/brand/Logo.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
// Das Manner-Logo und die Wortmarke der Anwendung — an einer Stelle, weil
|
||||
// Kopfleiste und Anmeldeseite dasselbe zeigen sollen.
|
||||
//
|
||||
// ═══ Welches Logo ═══
|
||||
//
|
||||
// Verwendet wird das **Markenlogo** (CD §4.1): der Schriftzug allein, ohne
|
||||
// den Stephansdom-Medaillon. Das Unternehmenslogo (§3.1) waere nach §3.2 das
|
||||
// formal richtige, wo „Josef Manner & Comp. AG" als Absender auftritt — es
|
||||
// steht hier aber nicht zur Verfuegung.
|
||||
//
|
||||
// ═══ Eine Datei, zwei Faelle ═══
|
||||
//
|
||||
// Die Vorlage ist ein PNG mit durchsichtigem Grund: nur der Schriftzug, blau
|
||||
// (#164194, der Wert aus §1.1) mit weisser Kontur, kein Feld darunter.
|
||||
//
|
||||
// §1.2 laesst den Schriftzug ausschliesslich zur Gaenze auf Rosa zu, und das
|
||||
// Manual kennt dafuer zwei Lagen:
|
||||
//
|
||||
// • **In einem grossflaechigen rosa Umfeld** (§3.1) wird er „mit genuegend
|
||||
// Freiraum rundum integriert" — die Flaeche ist schon da. Das ist
|
||||
// `aufRosa`: nur Freiraum, kein eigener Grund.
|
||||
//
|
||||
// • **Auf Weiss** (§4.1) gehoert er „in ein rechteckiges rosa Feld, das den
|
||||
// Regeln des Freiraums folgt". Dieses Feld zeichnet das Bauteil selbst,
|
||||
// aus derselben Polsterung.
|
||||
//
|
||||
// Vorher lagen dafuer zwei SVG nebeneinander, eines mit eingebackenem Feld
|
||||
// und eines ohne. Das war eine Datei zu viel — und die Fassung mit Feld liess
|
||||
// sich auf einer rosa Flaeche nicht sauber unterbringen, weil ihr Rechteck
|
||||
// sichtbar blieb, sobald ueber der Flaeche noch irgendetwas lag.
|
||||
//
|
||||
// Als <img> und nicht inline: der Browser holt die Datei einmal und nimmt sie
|
||||
// danach aus dem Zwischenspeicher, statt sie in jeder Seitenauslieferung
|
||||
// mitzuschleppen.
|
||||
|
||||
/** Seitenverhaeltnis der Vorlage: 1203 x 484 Pixel. */
|
||||
const VERHAELTNIS = 1203 / 484;
|
||||
|
||||
export function MannerLogo({
|
||||
hoehe = 28,
|
||||
aufRosa = false,
|
||||
className = "",
|
||||
}: {
|
||||
hoehe?: number;
|
||||
/** Fuer rosa Untergruende: ohne eigenes Feld, nur mit Freiraum. */
|
||||
aufRosa?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const breite = Math.round(hoehe * VERHAELTNIS);
|
||||
// Der Freiraum nach §4.1: X/3, wobei X als Hoehe des Schriftzugs gelesen
|
||||
// ist. Er steckt hier und nicht in den Aufrufstellen — sonst haengt seine
|
||||
// Einhaltung daran, dass jede einzelne daran denkt.
|
||||
const freiraum = Math.round(hoehe / 3);
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{ padding: freiraum }}
|
||||
// Ohne Eckenrundung: §4.1 spricht von einem rechteckigen Feld.
|
||||
className={`inline-flex shrink-0 items-center ${aufRosa ? "" : "bg-accent-500"} ${className}`}
|
||||
>
|
||||
{/* next/image bringt Groessenvarianten und Ladelogik fuer Fotos mit; fuer
|
||||
eine Bildmarke fester Groesse ist beides Ballast. */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/brand/manner-logo.png" alt="Manner" width={breite} height={hoehe} style={{ width: breite, height: hoehe }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logo plus Produktname.
|
||||
*
|
||||
* Die beiden gehoeren zusammen und meinen Verschiedenes: das Logo sagt, wem
|
||||
* das System gehoert, der Name sagt, was es ist.
|
||||
*/
|
||||
export function AppWortmarke({
|
||||
hoehe = 26,
|
||||
aufRosa = false,
|
||||
textKlasse = "text-ink",
|
||||
className = "",
|
||||
}: {
|
||||
hoehe?: number;
|
||||
aufRosa?: boolean;
|
||||
textKlasse?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
// Nur ein schmaler Abstand: den Hauptteil der Trennung leistet der
|
||||
// Freiraum des Logos, der ohnehin frei bleiben muss.
|
||||
<span className={`flex items-center gap-2 ${className}`}>
|
||||
<MannerLogo hoehe={hoehe} aufRosa={aufRosa} />
|
||||
<span className={`text-base font-extrabold tracking-tight ${textKlasse}`}>Alpenwerk HR</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
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 { ANSTEHEND_STYLES } 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 ? ANSTEHEND_STYLES[a.value] : "bg-surface text-ink-muted hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{a.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
components/dashboard/AnstehendListe.tsx
Normal file
148
components/dashboard/AnstehendListe.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
|
||||
export const KIND_LABEL = {
|
||||
hire: "Eintritt",
|
||||
exit: "Austritt",
|
||||
return: "Rückkehr aus Abwesenheit",
|
||||
note: "Wiedervorlage",
|
||||
} as const;
|
||||
|
||||
export type AnstehendEintrag = {
|
||||
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;
|
||||
};
|
||||
|
||||
/** Wie viele Zeilen die Karte zeigt, bevor aufgeklappt wird. */
|
||||
export const VORSCHAU = 8;
|
||||
|
||||
/**
|
||||
* Die Karte bleibt eine Übersicht — acht Zeilen, und darunter steht, wie
|
||||
* viele es insgesamt sind. Neu ist, dass diese Zeile ein Knopf ist: wer alle
|
||||
* sehen will, klappt sie auf, statt den Zeitraum enger zu stellen und die
|
||||
* Antwort damit zu verändern.
|
||||
*
|
||||
* Das geschieht im Browser und nicht über die Adresse, anders als bei
|
||||
* Zeitraum und Art: die Einträge sind bereits alle geladen, es gibt nichts
|
||||
* nachzuholen. Ein Umweg über den Server brächte eine Wartezeit für Daten,
|
||||
* die schon da sind — und einen Eintrag im Verlauf für etwas, das niemand
|
||||
* zurückgehen will.
|
||||
*/
|
||||
export function AnstehendListe({
|
||||
eintraege,
|
||||
today,
|
||||
leerText,
|
||||
}: {
|
||||
eintraege: AnstehendEintrag[];
|
||||
today: string;
|
||||
leerText: string;
|
||||
}) {
|
||||
const [alle, setAlle] = useState(false);
|
||||
// Namensfilter aus dem Workshop (Anforderung 2). Im Browser und nicht über
|
||||
// die Adresse, aus demselben Grund wie das Aufklappen: die Einträge sind
|
||||
// vollständig geladen, es gibt nichts nachzuholen.
|
||||
const [suche, setSuche] = useState("");
|
||||
const begriff = suche.trim().toLowerCase();
|
||||
// Wortweise und am Wortanfang — dieselbe Regel wie in der Mitarbeiterliste,
|
||||
// damit „gru" Gruber findet und nicht auch jede Notiz mit „Arbeitsgruppe".
|
||||
// Der Hinweis wird bewusst nicht durchsucht: gesucht wird nach Personen.
|
||||
const gefiltert = begriff
|
||||
? eintraege.filter((e) =>
|
||||
e.label
|
||||
.toLowerCase()
|
||||
.split(/[\s,;/-]+/)
|
||||
.some((wort) => wort.startsWith(begriff))
|
||||
)
|
||||
: eintraege;
|
||||
const sichtbar = alle ? gefiltert : gefiltert.slice(0, VORSCHAU);
|
||||
const weitere = gefiltert.length - sichtbar.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Immer da, sobald überhaupt etwas ansteht.
|
||||
*
|
||||
Zuerst stand hier eine Schwelle — erst ab neun Einträgen, weil man
|
||||
darunter die ganze Liste ohnehin sieht. In der Bedienung war das
|
||||
falsch: das Feld erschien bei 180 Tagen und verschwand bei 30, und
|
||||
ein Bedienelement, das je nach Zeitraum da ist oder nicht, wirkt wie
|
||||
ein Fehler. Wer sucht, sucht auch in sieben Zeilen. */}
|
||||
{eintraege.length > 0 && (
|
||||
<input
|
||||
type="search"
|
||||
value={suche}
|
||||
onChange={(e) => setSuche(e.target.value)}
|
||||
placeholder="Name filtern…"
|
||||
aria-label="Anstehende Ereignisse nach Name filtern"
|
||||
className="mb-2 w-full rounded border border-border-strong bg-white px-2.5 py-1.5 text-sm text-ink
|
||||
placeholder:text-ink-muted focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
|
||||
/>
|
||||
)}
|
||||
<ul className="flex flex-col divide-y divide-border-subtle">
|
||||
{sichtbar.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>
|
||||
);
|
||||
})}
|
||||
{eintraege.length === 0 && <p className="py-2 text-sm text-ink-muted">{leerText}</p>}
|
||||
{/* Ein leeres Ergebnis mit Suchbegriff ist etwas anderes als eine
|
||||
leere Karte: der Zeitraum stimmt, nur der Name passt nicht. */}
|
||||
{eintraege.length > 0 && gefiltert.length === 0 && (
|
||||
<p className="py-2 text-sm text-ink-muted">
|
||||
Kein Name passt zu „{suche.trim()}“. {eintraege.length}{" "}
|
||||
{eintraege.length === 1 ? "Ereignis steht" : "Ereignisse stehen"} an.
|
||||
</p>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{/* Der Knopf steht nur da, wenn es etwas zu holen gibt. Ist die Liste
|
||||
aufgeklappt, führt er wieder zurück — sonst bliebe die Karte für den
|
||||
Rest der Sitzung lang, auch wenn jemand nur einmal nachsehen
|
||||
wollte. */}
|
||||
{(weitere > 0 || alle) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAlle((v) => !v)}
|
||||
aria-expanded={alle}
|
||||
className="mt-2 rounded text-xs font-semibold text-brand-700 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
{alle
|
||||
? "Weniger anzeigen"
|
||||
: `… und ${weitere} ${weitere === 1 ? "weiteres Ereignis" : "weitere Ereignisse"} anzeigen`}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Lock, Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
import { deleteHireDraft } from "@/actions/hireDrafts";
|
||||
import { useHireWizard } from "@/components/hire/HireWizardContext";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import type { Entwurf } from "@/lib/entwuerfe";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
|
||||
type Draft = { id: string; step: number; payload: Record<string, unknown>; updated_at: string };
|
||||
// Angefangene Neueinstellungen — die eigenen und, wer in der Glocke
|
||||
// hinzugewählt ist, auch dessen.
|
||||
//
|
||||
// Fortsetzen geht bei beiden: wer vertreten wird, soll die angefangene
|
||||
// Einstellung zu Ende bringen können. Aber immer nur eine Person zugleich —
|
||||
// der Entwurf ist ein einziges JSONB-Feld, zwei gleichzeitig Schreibende
|
||||
// überschrieben einander vollständig, und die Zweite merkte nichts davon.
|
||||
// Die Sperre dazu steht in der Datenbank (20260910160000); hier steht nur,
|
||||
// was sie für die Zeile bedeutet.
|
||||
//
|
||||
// Löschen bleibt beim Eigentümer. Die Regel liesse mehr zu — sie muss, weil
|
||||
// der Assistent den Entwurf nach dem Anlegen selbst wegräumt —, aber eine
|
||||
// Schaltfläche, die fremde halbfertige Arbeit wegwirft, gehört nicht neben
|
||||
// eine, die sie fortsetzt.
|
||||
|
||||
export function DraftsCard({ drafts }: { drafts: Draft[] }) {
|
||||
export function DraftsCard({ drafts }: { drafts: Entwurf[] }) {
|
||||
const { openWizard } = useHireWizard();
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
@@ -39,24 +53,46 @@ export function DraftsCard({ drafts }: { drafts: Draft[] }) {
|
||||
const lastName = typeof d.payload.lastName === "string" ? d.payload.lastName : "";
|
||||
const name = [firstName, lastName].filter(Boolean).join(" ") || "Ohne Namen";
|
||||
return (
|
||||
<li key={d.id} className="flex items-center justify-between py-2 text-sm">
|
||||
<li key={d.id} className="flex items-center justify-between gap-3 py-2 text-sm">
|
||||
<div>
|
||||
<span className="font-semibold text-ink">{name}</span>
|
||||
<span className="ml-2 text-xs text-ink-muted">Gespeichert am {fmtDate(d.updated_at)}</span>
|
||||
{/* An jeder Zeile, wie an den Notizen in der Glocke: seit
|
||||
fremde Entwürfe dazwischenstehen, ist ohne die Angabe nicht
|
||||
zu sehen, welcher wessen ist. „von mir" statt des eigenen
|
||||
Namens — den kennt man. */}
|
||||
<span className="ml-2 text-xs text-ink-muted">{d.vonMir ? "von mir" : `von ${d.autor}`}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" onClick={() => openWizard({ draftId: d.id })} className="text-xs font-semibold text-brand-700 hover:underline">
|
||||
Fortsetzen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(d.id)}
|
||||
disabled={pending}
|
||||
aria-label="Entwurf löschen"
|
||||
className="text-ink-muted hover:text-danger-solid"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
{d.gesperrtVon ? (
|
||||
// Mit dem Grund daneben, nicht bloss abgeschaltet: eine
|
||||
// graue Schaltfläche ohne Erklärung liest sich wie ein
|
||||
// Fehler, und die nächste Handlung wäre, es gleich noch
|
||||
// einmal zu versuchen.
|
||||
<span className="flex shrink-0 items-center gap-1 text-xs text-ink-muted">
|
||||
<Lock className="h-3.5 w-3.5" />
|
||||
{d.gesperrtVon} bearbeitet gerade
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openWizard({ draftId: d.id })}
|
||||
className="text-xs font-semibold text-brand-700 hover:underline"
|
||||
>
|
||||
Fortsetzen
|
||||
</button>
|
||||
)}
|
||||
{d.vonMir && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(d.id)}
|
||||
disabled={pending || Boolean(d.gesperrtVon)}
|
||||
aria-label="Entwurf löschen"
|
||||
className="text-ink-muted hover:text-danger-solid disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -2,34 +2,48 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { addEmployeeDependent } from "@/actions/employees";
|
||||
import { addEmployeeDependent, updateEmployeeDependent } from "@/actions/employees";
|
||||
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 { todayIso } from "@/lib/format";
|
||||
import type { RelationshipType } from "@/lib/supabase/types";
|
||||
import type { Database, RelationshipType } from "@/lib/types";
|
||||
|
||||
const RELATIONSHIPS: RelationshipType[] = ["Ehepartner:in", "Lebenspartner:in", "Kind", "Sonstige"];
|
||||
|
||||
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
|
||||
|
||||
// Dasselbe Fenster für Anlegen und Berichtigen.
|
||||
//
|
||||
// Der Unterschied ist nicht die Maske, sondern der Stichtag: beim Anlegen
|
||||
// passiert etwas zu einem Zeitpunkt, beim Berichtigen war die Angabe schon
|
||||
// vorher falsch. Deshalb steht „Wirksam ab" nur im ersten Fall — ein Datum
|
||||
// bei einer Korrektur hiesse, die Person habe bis dahin anders geheissen.
|
||||
export function AddDependentModal({
|
||||
open,
|
||||
onClose,
|
||||
employeeId,
|
||||
defaultEffectiveDate,
|
||||
dependent,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
employeeId: string;
|
||||
defaultEffectiveDate?: string;
|
||||
/** Gesetzt: berichtigen statt anlegen. */
|
||||
dependent?: Dependent;
|
||||
}) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [firstName, setFirstName] = useState("");
|
||||
const [lastName, setLastName] = useState("");
|
||||
const [svNummer, setSvNummer] = useState("");
|
||||
const [birthDate, setBirthDate] = useState("");
|
||||
const [relationship, setRelationship] = useState<RelationshipType>("Kind");
|
||||
const bearbeiten = Boolean(dependent);
|
||||
const [firstName, setFirstName] = useState(dependent?.first_name ?? "");
|
||||
const [lastName, setLastName] = useState(dependent?.last_name ?? "");
|
||||
const [svNummer, setSvNummer] = useState(dependent?.sv_nummer ?? "");
|
||||
const [birthDate, setBirthDate] = useState(dependent?.birth_date ?? "");
|
||||
const [relationship, setRelationship] = useState<RelationshipType>(
|
||||
(dependent?.relationship as RelationshipType) ?? "Kind"
|
||||
);
|
||||
const [effectiveDate, setEffectiveDate] = useState(defaultEffectiveDate ?? todayIso());
|
||||
const [prevDefaultEffectiveDate, setPrevDefaultEffectiveDate] = useState(defaultEffectiveDate);
|
||||
const [pending, setPending] = useState(false);
|
||||
@@ -53,26 +67,36 @@ export function AddDependentModal({
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!firstName || !lastName || !birthDate || !effectiveDate) {
|
||||
if (!firstName || !lastName || !birthDate || (!bearbeiten && !effectiveDate)) {
|
||||
showToast("Bitte alle Pflichtfelder ausfüllen.", "error");
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
const result = await addEmployeeDependent({
|
||||
employee_id: employeeId,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
relationship,
|
||||
sv_nummer: svNummer || undefined,
|
||||
birth_date: birthDate,
|
||||
effective_date: effectiveDate,
|
||||
});
|
||||
const result = bearbeiten
|
||||
? await updateEmployeeDependent({
|
||||
dependent_id: dependent!.id,
|
||||
employee_id: employeeId,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
relationship,
|
||||
sv_nummer: svNummer,
|
||||
birth_date: birthDate,
|
||||
})
|
||||
: await addEmployeeDependent({
|
||||
employee_id: employeeId,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
relationship,
|
||||
sv_nummer: svNummer || undefined,
|
||||
birth_date: birthDate,
|
||||
effective_date: effectiveDate,
|
||||
});
|
||||
setPending(false);
|
||||
if (result.success) {
|
||||
showToast("Angehörige:r hinzugefügt.");
|
||||
showToast(bearbeiten ? "Angaben berichtigt." : "Angehörige:r hinzugefügt.");
|
||||
router.refresh();
|
||||
onClose();
|
||||
reset();
|
||||
if (!bearbeiten) reset();
|
||||
} else {
|
||||
showToast(result.error ?? "Fehler beim Speichern.", "error");
|
||||
}
|
||||
@@ -82,20 +106,22 @@ export function AddDependentModal({
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Angehörige:n hinzufügen"
|
||||
title={bearbeiten ? "Angehörige:n berichtigen" : "Angehörige:n hinzufügen"}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} pending={pending}>
|
||||
Hinzufügen
|
||||
{bearbeiten ? "Speichern" : "Hinzufügen"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
|
||||
{!bearbeiten && (
|
||||
<TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<TextField label="Vorname" required value={firstName} onChange={setFirstName} />
|
||||
<TextField label="Nachname" required value={lastName} onChange={setLastName} />
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { deleteEmployeeDependent } from "@/actions/employees";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { fmtDate, todayIso } from "@/lib/format";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
import type { Database } from "@/lib/types";
|
||||
import { AddDependentModal } from "./AddDependentModal";
|
||||
|
||||
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
|
||||
@@ -19,6 +19,7 @@ export function AngehoerigeSection({ employeeId, dependents, effectiveDate }: {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [bearbeitet, setBearbeitet] = useState<Dependent | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const resolvedEffectiveDate = effectiveDate || todayIso();
|
||||
|
||||
@@ -52,7 +53,7 @@ export function AngehoerigeSection({ employeeId, dependents, effectiveDate }: {
|
||||
<div className="overflow-x-auto rounded border border-border">
|
||||
<table className="w-full min-w-[600px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-brand-50 text-left text-xs font-semibold uppercase tracking-wide text-ink-muted">
|
||||
<tr className="border-b border-border bg-accent-50 text-left text-xs font-semibold uppercase tracking-wide text-ink-muted">
|
||||
<th className="px-4 py-2.5">Name</th>
|
||||
<th className="px-4 py-2.5">Verhältnis</th>
|
||||
<th className="px-4 py-2.5">SVNR</th>
|
||||
@@ -70,6 +71,18 @@ export function AngehoerigeSection({ employeeId, dependents, effectiveDate }: {
|
||||
<td className="px-4 py-2.5 text-ink-body">{d.sv_nummer ?? "–"}</td>
|
||||
<td className="px-4 py-2.5 text-ink-body">{fmtDate(d.birth_date)}</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
{/* Berichtigen statt löschen-und-neu-anlegen: ein
|
||||
Tippfehler hinterliess sonst zwei Einträge in der
|
||||
Akte, und der erste sagte „entfernt", was nicht
|
||||
stimmte. */}
|
||||
<Button
|
||||
variant="icon"
|
||||
onClick={() => setBearbeitet(d)}
|
||||
aria-label={`${d.first_name} ${d.last_name} berichtigen`}
|
||||
className="hover:!text-brand-700"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="icon"
|
||||
onClick={() => handleDelete(d.id)}
|
||||
@@ -88,6 +101,19 @@ export function AngehoerigeSection({ employeeId, dependents, effectiveDate }: {
|
||||
)}
|
||||
|
||||
<AddDependentModal open={modalOpen} onClose={() => setModalOpen(false)} employeeId={employeeId} defaultEffectiveDate={resolvedEffectiveDate} />
|
||||
|
||||
{/* Eigene Einbindung mit `key`: das Fenster bleibt sonst montiert und
|
||||
zeigte beim zweiten Öffnen die Felder der zuvor bearbeiteten
|
||||
Person. */}
|
||||
{bearbeitet && (
|
||||
<AddDependentModal
|
||||
key={bearbeitet.id}
|
||||
open
|
||||
onClose={() => setBearbeitet(null)}
|
||||
employeeId={employeeId}
|
||||
dependent={bearbeitet}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,61 +6,86 @@ import { useState } from "react";
|
||||
import { Avatar } from "@/components/ui/Avatar";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { StatusChip } from "@/components/ui/StatusChip";
|
||||
import type { AufgabenStand } from "@/lib/checklist";
|
||||
import { fmtFullName, tenure } from "@/lib/format";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
import { fortschritt as fortschrittOffboarding, gehoertOffboarding } from "@/lib/offboarding";
|
||||
import { fortschritt as fortschrittOnboarding } from "@/lib/onboarding";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import { deriveStatusAsOf } from "@/lib/reports";
|
||||
import type { Database } from "@/lib/types";
|
||||
import { DatenAendernPanel } from "./panels/DatenAendernPanel";
|
||||
import { KarenzPanel } from "./panels/KarenzPanel";
|
||||
import { PromotePanel } from "./panels/PromotePanel";
|
||||
import { RehirePanel } from "./panels/RehirePanel";
|
||||
import { RehireWizard } from "../hire/RehireWizard";
|
||||
import { TerminatePanel } from "./panels/TerminatePanel";
|
||||
import { TransferPanel } from "./panels/TransferPanel";
|
||||
import { HistorieTab } from "./tabs/HistorieTab";
|
||||
import { NotizenTab } from "./tabs/NotizenTab";
|
||||
import { OffboardingTab } from "./tabs/OffboardingTab";
|
||||
import { OnboardingTab } from "./tabs/OnboardingTab";
|
||||
import { OrganisationTab } from "./tabs/OrganisationTab";
|
||||
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;
|
||||
/** Der Stand der Onboarding-Checkliste; leer heisst: es gibt keine. */
|
||||
onboarding: AufgabenStand[];
|
||||
/** Der Stand der Offboarding-Checkliste; leer heisst: es gibt keine. */
|
||||
offboarding: AufgabenStand[];
|
||||
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[];
|
||||
/** Der Tag, zu dem die Akte gelesen wurde — derselbe wie in loadEmployeeDetail. */
|
||||
today: string;
|
||||
};
|
||||
|
||||
type PanelType = "transfer" | "promote" | "karenz" | "daten" | "terminate" | "rehire" | null;
|
||||
const TABS = ["Stammdaten", "Vertrag", "Organisation", "Historie", "HR-Notizen"] as const;
|
||||
const ALLE_TABS = ["Stammdaten", "Vertrag", "Organisation", "Onboarding", "Offboarding", "Historie", "HR-Notizen"] as const;
|
||||
type Tab = (typeof ALLE_TABS)[number];
|
||||
|
||||
export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
const { employee, manager, directReports, history, dependents, notes, divisions, departments, teams, locations } = props;
|
||||
const [tab, setTab] = useState<(typeof TABS)[number]>("Stammdaten");
|
||||
const { employee, placement, breadcrumb, kostenstelle, onboarding, offboarding, manager, formalManager, directReports, history, dependents, notes, locations, openPositions, today } = props;
|
||||
const [tab, setTab] = useState<Tab>("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);
|
||||
// Am Reiter steht, was noch aussteht — sonst müsste man hineinsehen, um zu
|
||||
// erfahren, dass nichts zu tun ist.
|
||||
const offeneOnboarding = onboarding.length > 0 ? fortschrittOnboarding(onboarding).gesamt - fortschrittOnboarding(onboarding).erledigt : 0;
|
||||
const offeneOffboarding = offboarding.length > 0 ? fortschrittOffboarding(offboarding).gesamt - fortschrittOffboarding(offboarding).erledigt : 0;
|
||||
|
||||
const isActive = employee.status === "Aktiv" || employee.status === "Karenz";
|
||||
const canEditData = employee.status !== "Ausgetreten";
|
||||
const zeigtOffboarding = gehoertOffboarding(employee);
|
||||
const tabs = ALLE_TABS.filter((t) => t !== "Offboarding" || zeigtOffboarding);
|
||||
|
||||
// Abgeleitet, nicht aus `employee.status` gelesen. Die Spalte hängt nach,
|
||||
// wenn ein Austritt mit einem damals künftigen Datum erfasst wurde — es gibt
|
||||
// keinen Lauf, der sie später nachzieht (Migration 20260814100000). Hier
|
||||
// entschied sie nicht nur über eine Beschriftung, sondern über die Knöpfe:
|
||||
// an einer Person, die seit zwei Wochen ausgetreten ist, stand „Austritt"
|
||||
// weiter zur Verfügung, und „Daten ändern" ebenfalls.
|
||||
const status = deriveStatusAsOf(employee, today);
|
||||
const isActive = status === "Aktiv" || status === "Karenz";
|
||||
const canEditData = status !== "Ausgetreten";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -77,13 +102,22 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
<h2 className="text-xl font-extrabold text-ink">
|
||||
{fmtFullName(employee.first_name, employee.last_name, employee.title_prefix, employee.title_suffix)}
|
||||
</h2>
|
||||
<StatusChip status={employee.status} entryDate={employee.entry_date} absenceType={employee.absence_type} />
|
||||
<StatusChip employee={employee} asOf={today} />
|
||||
</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)}</>}
|
||||
{status !== "Geplant" && <> · Zugehörigkeit: {tenure(employee.entry_date, employee.exit_date)}</>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,25 +129,41 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
<ActionButton icon={TrendingUp} label="Befördern" onClick={() => setPanel("promote")} />
|
||||
<ActionButton
|
||||
icon={Clock}
|
||||
label={employee.status === "Karenz" ? "Abwesenheit verwalten" : "Langzeitabwesenheit"}
|
||||
label={status === "Karenz" ? "Abwesenheit verwalten" : "Langzeitabwesenheit"}
|
||||
onClick={() => setPanel("karenz")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{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 || 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" />
|
||||
{status === "Geplant" ? "Nicht angetreten" : "Austritt"}
|
||||
</Button>
|
||||
)}
|
||||
{employee.status === "Ausgetreten" && (
|
||||
{/* Der Knopf hing an `employee.status` — der Spalte, die
|
||||
nachhängt. An einer Person, deren Austritt erfasst und
|
||||
inzwischen vollzogen war, stand dort weiter „Aktiv", und der
|
||||
Wiedereintritt war schlicht nicht erreichbar. Der Kunde hat
|
||||
ihn deshalb für verschwunden gehalten („ich dachte, das ist
|
||||
schon implementiert, das war mal drin").
|
||||
*
|
||||
Beim Statusfix waren nur `isActive` und `canEditData`
|
||||
umgestellt worden — die fünf einzelnen Abfragen in dieser
|
||||
Datei blieben stehen. Jetzt liest keine mehr die Spalte. */}
|
||||
{status === "Ausgetreten" && (
|
||||
<Button size="sm" onClick={() => setPanel("rehire")}>
|
||||
<RotateCcw className="h-4 w-4" /> Wiedereinstellen
|
||||
<RotateCcw className="h-4 w-4" /> Wiedereintritt
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -123,7 +173,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
{/* Tabs, so the list gets the tablist role and each button says
|
||||
whether it is the selected one. */}
|
||||
<div role="tablist" aria-label="Mitarbeiterdetails" className="flex gap-1 overflow-x-auto border-b border-border">
|
||||
{TABS.map((t) => (
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
role="tab"
|
||||
@@ -133,7 +183,13 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
tab === t ? "border-brand-500 text-brand-700" : "border-transparent text-ink-muted hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{t === "HR-Notizen" ? `HR-Notizen ${notes.length}` : t}
|
||||
{t === "HR-Notizen"
|
||||
? `HR-Notizen ${notes.length}`
|
||||
: t === "Onboarding" && offeneOnboarding > 0
|
||||
? `Onboarding ${offeneOnboarding}`
|
||||
: t === "Offboarding" && offeneOffboarding > 0
|
||||
? `Offboarding ${offeneOffboarding}`
|
||||
: t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -142,32 +198,91 @@ 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 === "Onboarding" && (
|
||||
<OnboardingTab employeeId={employee.id} staende={onboarding} vorhanden={onboarding.length > 0} />
|
||||
)}
|
||||
{tab === "Offboarding" && zeigtOffboarding && (
|
||||
<OffboardingTab employeeId={employee.id} staende={offboarding} vorhanden={offboarding.length > 0} />
|
||||
)}
|
||||
{tab === "Historie" && <HistorieTab history={history} employeeId={employee.id} />}
|
||||
{tab === "HR-Notizen" && <NotizenTab employeeId={employee.id} notes={notes} />}
|
||||
</div>
|
||||
|
||||
{/* Der `key` an jedem Panel ist kein Feinschliff, sondern der Unterschied
|
||||
zwischen „zeigt Altes" und „schreibt Altes zurück".
|
||||
|
||||
Die Panels bleiben eingebunden, damit ihr Ein- und Ausfahren laufen
|
||||
kann. Sie belegen ihre Felder aber mit useState(employee.…) vor, und
|
||||
das läuft nur beim ersten Aufbau. Nach einer Beförderung bringt
|
||||
router.refresh() zwar die frische Akte herein — der Zustand im Panel
|
||||
bleibt der von vorhin. Beim nächsten Speichern geht er als Ganzes an
|
||||
change_employee_data, und der eben gesetzte Hay-Grade steht wieder
|
||||
auf dem alten Wert. Gemeldet im Test vom 29.09. (H.05).
|
||||
|
||||
employees.updated_at wechselt bei jeder Änderung an der Zeile
|
||||
(Trigger trg_employees_touch_updated_at), also genau dann, wenn die
|
||||
Vorbelegung neu zu lesen ist — und sonst nie. Der Wiedereintritts-
|
||||
Assistent darunter macht es seit jeher so. */}
|
||||
<TransferPanel
|
||||
key={`transfer-${employee.updated_at}`}
|
||||
open={panel === "transfer"}
|
||||
onClose={() => setPanel(null)}
|
||||
employee={employee}
|
||||
divisions={divisions}
|
||||
departments={departments}
|
||||
teams={teams}
|
||||
currentTeamId={employee.team_id}
|
||||
openPositions={openPositions}
|
||||
/>
|
||||
<PromotePanel
|
||||
key={`promote-${employee.updated_at}`}
|
||||
open={panel === "promote"}
|
||||
onClose={() => setPanel(null)}
|
||||
employee={employee}
|
||||
openPositions={openPositions}
|
||||
/>
|
||||
<KarenzPanel
|
||||
key={`karenz-${employee.updated_at}`}
|
||||
open={panel === "karenz"}
|
||||
onClose={() => setPanel(null)}
|
||||
employee={employee}
|
||||
status={status}
|
||||
/>
|
||||
<PromotePanel open={panel === "promote"} onClose={() => setPanel(null)} employee={employee} />
|
||||
<KarenzPanel open={panel === "karenz"} onClose={() => setPanel(null)} employee={employee} />
|
||||
<DatenAendernPanel
|
||||
key={`daten-${employee.updated_at}`}
|
||||
open={panel === "daten"}
|
||||
onClose={() => setPanel(null)}
|
||||
employee={employee}
|
||||
dependents={dependents}
|
||||
locationCountry={location?.country}
|
||||
/>
|
||||
<TerminatePanel open={panel === "terminate"} onClose={() => setPanel(null)} employee={employee} directReportCount={directReports.length} />
|
||||
<RehirePanel open={panel === "rehire"} onClose={() => setPanel(null)} employee={employee} />
|
||||
<TerminatePanel
|
||||
key={`terminate-${employee.updated_at}`}
|
||||
open={panel === "terminate"}
|
||||
onClose={() => setPanel(null)}
|
||||
employee={employee}
|
||||
status={status}
|
||||
directReportCount={directReports.length}
|
||||
/>
|
||||
{/* Der `key` sorgt fürs Zurücksetzen: beim Öffnen entsteht der
|
||||
Assistent neu und liest die Vorbelegung frisch aus der Akte. Ohne
|
||||
ihn trüge er nach einem Abbruch die halb ausgefüllten Angaben des
|
||||
vorigen Anlaufs. */}
|
||||
{panel === "rehire" && (
|
||||
<RehireWizard
|
||||
key={employee.updated_at}
|
||||
open
|
||||
onClose={() => setPanel(null)}
|
||||
employee={employee}
|
||||
openPositions={openPositions}
|
||||
locations={locations}
|
||||
/>
|
||||
)}
|
||||
</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 }[];
|
||||
};
|
||||
|
||||
@@ -17,12 +19,16 @@ type EmployeeFiltersProps = {
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "Aktiv", label: "Aktiv" },
|
||||
{ value: "Karenz", label: "Langzeitabwesenheit" },
|
||||
{ value: "Aktiv,Karenz", label: "Aktiv + Langzeitabwesenheit (beschäftigt)" },
|
||||
// Der Name der Kachel zuerst, die Bestandteile in Klammern dahinter: die
|
||||
// Kachel „Aktives Dienstverhältnis" verlinkt genau auf diese Auswahl, und
|
||||
// wer ihr folgt, soll oben dieselbe Bezeichnung wiederfinden statt eine
|
||||
// zweite für dieselbe Sache.
|
||||
{ value: "Aktiv,Karenz", label: "Aktives Dienstverhältnis (Aktiv + Langzeitabwesenheit)" },
|
||||
{ value: "Geplant", label: "Geplant" },
|
||||
{ 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 +61,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 +108,15 @@ export function EmployeeFilters({ divisions, locations }: EmployeeFiltersProps)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* Sortiert wird nicht hier, sondern an den Spaltenköpfen der Liste.
|
||||
Diese Leiste schränkt ein, *was* zu sehen ist; die Reihenfolge
|
||||
gehört an die Spalte, die sie bestimmt. */}
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
234
components/employees/HistorieBearbeiten.tsx
Normal file
234
components/employees/HistorieBearbeiten.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
"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 { auswahlFuer } from "@/lib/historie-felder";
|
||||
import type { AuditChange } from "@/lib/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">
|
||||
{/* Führt das Feld eine feste Liste, wird sie auch hier
|
||||
angeboten: getippt entstünde sonst ein Wert, den keine
|
||||
Auswertung mehr findet — die Berichtigung schreibt in
|
||||
dieselbe Spalte wie das Formular, nur ohne dessen
|
||||
Prüfung. Welche Felder das sind, steht in
|
||||
lib/historie-felder.ts. */}
|
||||
{auswahlFuer(c.feld) ? (
|
||||
<select
|
||||
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 bg-white px-2 py-1 text-sm text-ink
|
||||
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
|
||||
>
|
||||
<option value="">leer</option>
|
||||
{auswahlFuer(c.feld)!.map((w) => (
|
||||
<option key={w} value={w}>
|
||||
{w}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
118
components/employees/NotfallkontaktModal.tsx
Normal file
118
components/employees/NotfallkontaktModal.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { changeEmployeeData } from "@/actions/employees";
|
||||
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 { todayIso } from "@/lib/format";
|
||||
import { EMERGENCY_RELATIONS } from "@/lib/types";
|
||||
|
||||
// Der Notfallkontakt, ohne den Umweg über „Daten ändern".
|
||||
//
|
||||
// Geschrieben wird über dieselbe Funktion wie dort: change_employee_data mit
|
||||
// **nur** den drei Feldern im Abschnitt `person`. Das ist keine Abkürzung,
|
||||
// sondern die Bedingung dafür, dass der Eintrag in der Historie und im
|
||||
// Protokoll landet wie jede andere Stammdatenänderung. Die Datenbank fasst
|
||||
// ausschliesslich die Felder an, die im Aufruf stehen — sie prüft auf das
|
||||
// Vorhandensein des Schlüssels, nicht auf seinen Wert (Migration
|
||||
// 20260917120000). Adresse, E-Mail und der Rest bleiben deshalb unberührt.
|
||||
|
||||
export type Notfallkontakt = {
|
||||
name: string | null;
|
||||
telefon: string | null;
|
||||
verhaeltnis: string | null;
|
||||
};
|
||||
|
||||
export function NotfallkontaktModal({
|
||||
open,
|
||||
onClose,
|
||||
employeeId,
|
||||
kontakt,
|
||||
effectiveDate,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
employeeId: string;
|
||||
kontakt: Notfallkontakt;
|
||||
/** Aus „Daten ändern"; ohne Angabe gilt heute. */
|
||||
effectiveDate?: string;
|
||||
}) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState(kontakt.name ?? "");
|
||||
const [telefon, setTelefon] = useState(kontakt.telefon ?? "");
|
||||
const [verhaeltnis, setVerhaeltnis] = useState(kontakt.verhaeltnis ?? "");
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const vorhanden = Boolean(kontakt.name);
|
||||
|
||||
async function handleSubmit() {
|
||||
// Name und Telefonnummer gehören zusammen: ein Name ohne Nummer hilft im
|
||||
// Ernstfall niemandem, eine Nummer ohne Namen sagt nicht, wen man dran
|
||||
// hat.
|
||||
if (!name.trim() || !telefon.trim()) {
|
||||
showToast("Name und Telefonnummer sind nötig.", "error");
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
const result = await changeEmployeeData({
|
||||
employee_id: employeeId,
|
||||
effective_date: effectiveDate || todayIso(),
|
||||
person: {
|
||||
emergency_contact_name: name.trim(),
|
||||
emergency_contact_phone: telefon.trim(),
|
||||
emergency_contact_relation: verhaeltnis.trim(),
|
||||
},
|
||||
contract: {},
|
||||
role: {},
|
||||
});
|
||||
setPending(false);
|
||||
if (result.success) {
|
||||
showToast(vorhanden ? "Notfallkontakt geändert." : "Notfallkontakt hinterlegt.");
|
||||
router.refresh();
|
||||
onClose();
|
||||
} else {
|
||||
showToast(result.error ?? "Fehler beim Speichern.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={vorhanden ? "Notfallkontakt ändern" : "Notfallkontakt hinterlegen"}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} pending={pending}>
|
||||
Speichern
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* Kein „Wirksam ab": ein Notfallkontakt gilt ab sofort. Das Feld stand
|
||||
hier, weil die Datenbankfunktion einen Stichtag kennt — bei einer
|
||||
Telefonnummer, die im Ernstfall gewählt wird, ist ein Datum in der
|
||||
Zukunft keine sinnvolle Angabe, sondern eine Frage zu viel. */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<TextField label="Name" required value={name} onChange={setName} />
|
||||
<TextField label="Telefonnummer" required type="tel" value={telefon} onChange={setTelefon} />
|
||||
{/* Dieselbe Liste wie im Einstellungsassistenten (EMERGENCY_RELATIONS)
|
||||
und kein Freitext: sonst stünden „Gattin", „Ehefrau" und „Frau"
|
||||
nebeneinander und liessen sich nicht auswerten. */}
|
||||
<SelectField
|
||||
label="Verhältnis"
|
||||
value={verhaeltnis}
|
||||
onChange={setVerhaeltnis}
|
||||
placeholder="Bitte wählen…"
|
||||
options={EMERGENCY_RELATIONS.map((r) => ({ value: r, label: r }))}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
156
components/employees/NotfallkontaktSection.tsx
Normal file
156
components/employees/NotfallkontaktSection.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { changeEmployeeData } from "@/actions/employees";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { todayIso } from "@/lib/format";
|
||||
import { NotfallkontaktModal, type Notfallkontakt } from "./NotfallkontaktModal";
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Der Knopf steht hier, weil er bei den Angehörigen darüber auch steht. Ohne
|
||||
// ihn führte der einzige Weg über „Daten ändern" — ein Formular über alle
|
||||
// Stammdaten, um eine Telefonnummer einzutragen.
|
||||
|
||||
export function NotfallkontaktSection({
|
||||
employeeId,
|
||||
kontakt,
|
||||
effectiveDate,
|
||||
}: {
|
||||
employeeId: string;
|
||||
kontakt: Notfallkontakt;
|
||||
/**
|
||||
* Nur, wenn der Abschnitt in „Daten ändern" steht: dann treibt dessen
|
||||
* „Wirksam ab" auch diesen Vorgang — wie bei den Angehörigen darüber.
|
||||
* Ohne Angabe gilt heute.
|
||||
*/
|
||||
effectiveDate?: string;
|
||||
}) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [entfernt, setEntfernt] = useState(false);
|
||||
const stichtag = effectiveDate || todayIso();
|
||||
|
||||
const vorhanden = Boolean(kontakt.name);
|
||||
|
||||
async function handleEntfernen() {
|
||||
setEntfernt(true);
|
||||
// Leere Zeichenketten, nicht ausgelassene Felder: die Datenbank macht
|
||||
// daraus null. Würden die Schlüssel fehlen, bliebe der Kontakt stehen.
|
||||
const result = await changeEmployeeData({
|
||||
employee_id: employeeId,
|
||||
effective_date: stichtag,
|
||||
person: { emergency_contact_name: "", emergency_contact_phone: "", emergency_contact_relation: "" },
|
||||
contract: {},
|
||||
role: {},
|
||||
});
|
||||
setEntfernt(false);
|
||||
if (result.success) {
|
||||
showToast("Notfallkontakt entfernt.");
|
||||
router.refresh();
|
||||
} else {
|
||||
showToast(result.error ?? "Fehler beim Entfernen.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-border pt-6">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wide text-brand-700">Notfallkontakt</h3>
|
||||
{/* Nur, solange keiner hinterlegt ist: es gibt genau einen. Ein
|
||||
zweites „Hinzufügen" daneben verspräche eine Liste, die es hier
|
||||
nicht gibt — geändert und entfernt wird in der Zeile selbst. */}
|
||||
{!vorhanden && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="!px-1 text-brand-700 hover:!bg-transparent hover:underline"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> Hinzufügen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dieselbe Tabelle wie bei den Angehörigen darüber, Zeile für Zeile
|
||||
derselbe Aufbau. Vorher stand hier eine Beschreibungsliste — fachlich
|
||||
dasselbe, optisch ein zweiter Baustil im selben Reiter. Zwei Listen
|
||||
von Personen im Umfeld sollen gleich aussehen. */}
|
||||
{vorhanden ? (
|
||||
<div className="overflow-x-auto rounded border border-border">
|
||||
<table className="w-full min-w-[600px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-accent-50 text-left text-xs font-semibold uppercase tracking-wide text-ink-muted">
|
||||
<th className="px-4 py-2.5">Name</th>
|
||||
<th className="px-4 py-2.5">Telefon</th>
|
||||
<th className="px-4 py-2.5">Verhältnis</th>
|
||||
<th className="px-4 py-2.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b border-border last:border-0">
|
||||
<td className="px-4 py-2.5 font-semibold text-ink">{kontakt.name}</td>
|
||||
<td className="px-4 py-2.5 text-ink-body">
|
||||
{kontakt.telefon ? (
|
||||
// Wählbar: im Ernstfall greift jemand in Eile danach.
|
||||
<a
|
||||
href={`tel:${kontakt.telefon.replace(/\s/g, "")}`}
|
||||
className="rounded font-semibold hover:text-brand-700 hover:underline
|
||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
{kontakt.telefon}
|
||||
</a>
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-ink-body">{kontakt.verhaeltnis || "–"}</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<Button
|
||||
variant="icon"
|
||||
onClick={() => setModalOpen(true)}
|
||||
aria-label="Notfallkontakt ändern"
|
||||
className="hover:!text-brand-700"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="icon"
|
||||
onClick={handleEntfernen}
|
||||
pending={entfernt}
|
||||
aria-label="Notfallkontakt entfernen"
|
||||
className="hover:!text-danger-solid"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-ink-muted">Kein Notfallkontakt hinterlegt.</p>
|
||||
)}
|
||||
|
||||
{/* `key` setzt die Felder auf den gespeicherten Stand zurück, sobald
|
||||
sich der Kontakt geändert hat — das Fenster bleibt sonst montiert
|
||||
und zeigte beim zweiten Öffnen den alten Entwurf. */}
|
||||
<NotfallkontaktModal
|
||||
key={`${kontakt.name ?? ""}|${kontakt.telefon ?? ""}|${kontakt.verhaeltnis ?? ""}`}
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
employeeId={employeeId}
|
||||
kontakt={kontakt}
|
||||
effectiveDate={stichtag}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
195
components/employees/PrintChecklist.tsx
Normal file
195
components/employees/PrintChecklist.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
import { Printer } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import {
|
||||
alleReichungen,
|
||||
fortschrittVon,
|
||||
istErledigt,
|
||||
type AufgabenGruppe,
|
||||
type AufgabenStand,
|
||||
} from "@/lib/checklist";
|
||||
import { artTitel, checklistDateiname, type ChecklistArt } from "@/lib/checklist-print";
|
||||
import type { ChecklistKopf } from "@/lib/checklist-print-data";
|
||||
import { fmtDate, fmtName } from "@/lib/format";
|
||||
|
||||
// Der Ausdruck einer Checkliste — eine Seite, zum Abheften oder Mitnehmen.
|
||||
//
|
||||
// Warum überhaupt Papier, wenn die Liste in der Anwendung steht: sie wird
|
||||
// besprochen. Beim Eintrittsgespräch liegt sie auf dem Tisch, beim Austritt
|
||||
// geht sie an die Lohnverrechnung, und in einer Personalakte auf Papier fehlt
|
||||
// sie sonst. Gedruckt wird deshalb der **Stand**, mit Datum des Auszugs — kein
|
||||
// Formular zum Ausfüllen.
|
||||
//
|
||||
// Erzeugt wird kein PDF im Server: gedruckt wird über den Browser, wie beim
|
||||
// Organigramm. Der Grund ist derselbe — eine PDF-Bibliothek müsste Schriften,
|
||||
// Umbrüche und Seitenränder selbst nachbauen, und das Ergebnis wäre ein
|
||||
// zweites Aussehen neben dem der Anwendung. Der Dateiname im Speichern-Dialog
|
||||
// kommt aus dem Dokumenttitel; den setzt diese Seite auf die Konvention.
|
||||
|
||||
const GLYPH_AN = "☒";
|
||||
const GLYPH_AUS = "☐";
|
||||
|
||||
export function PrintChecklist({
|
||||
art,
|
||||
kopf,
|
||||
gruppen,
|
||||
staende,
|
||||
today,
|
||||
}: {
|
||||
art: ChecklistArt;
|
||||
kopf: ChecklistKopf;
|
||||
gruppen: readonly AufgabenGruppe[];
|
||||
staende: AufgabenStand[];
|
||||
/** Vom Server: das Auszugsdatum gehört nicht an die Uhr des Browsers. */
|
||||
today: string;
|
||||
}) {
|
||||
const dateiname = checklistDateiname(art, kopf, today);
|
||||
|
||||
// Der Titel ist der Vorschlag im Druckdialog. Ohne ihn hiesse die Datei nach
|
||||
// der Adresse der Seite, also etwa „employees-…-checkliste".
|
||||
useEffect(() => {
|
||||
const vorher = document.title;
|
||||
document.title = dateiname;
|
||||
return () => {
|
||||
document.title = vorher;
|
||||
};
|
||||
}, [dateiname]);
|
||||
|
||||
const punkte = alleReichungen(gruppen);
|
||||
const karte = new Map(staende.map((s) => [s.item_key, s]));
|
||||
const stand = fortschrittVon(punkte, staende);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<style>{`
|
||||
@page { size: A4 portrait; margin: 14mm 14mm 12mm; }
|
||||
@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; padding: 0 !important; width: auto !important; min-height: 0 !important;
|
||||
}
|
||||
/* Eine Gruppe soll nicht mitten in der Überschrift umbrechen, und
|
||||
eine Zeile nicht zwischen Haken und Kommentar. */
|
||||
.gruppe { break-inside: avoid; }
|
||||
.punkt { break-inside: avoid; }
|
||||
}
|
||||
/* Zweispaltig, damit 25 Punkte auf ein Blatt gehen. Als Spaltensatz und
|
||||
nicht als Gitter: die Punkte fliessen dann von der linken in die
|
||||
rechte Spalte, statt dass eine Spalte halb leer bleibt. */
|
||||
.spalten { column-count: 2; column-gap: 10mm; }
|
||||
`}</style>
|
||||
|
||||
<div className="nur-bildschirm flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm text-ink-muted">
|
||||
So kommt es aus dem Drucker. Vorgeschlagener Dateiname:{" "}
|
||||
<span className="font-semibold text-ink">{dateiname}.pdf</span>
|
||||
</p>
|
||||
<Button onClick={() => window.print()}>
|
||||
<Printer className="h-4 w-4" />
|
||||
Als PDF speichern
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<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 Flächen hinter den Gruppentiteln — in Chrome unter „Weitere Einstellungen“.
|
||||
</p>
|
||||
|
||||
<div className="blatt mx-auto w-[210mm] rounded border border-border bg-white p-[14mm] shadow-[var(--shadow-card)]">
|
||||
{/* ── Kopf ───────────────────────────────────────────── */}
|
||||
<div className="flex items-start justify-between gap-6 border-b-2 border-ink pb-2">
|
||||
<div>
|
||||
<h1 className="text-[15pt] font-extrabold leading-tight text-ink">{artTitel(art)}</h1>
|
||||
<p className="text-[9pt] text-ink-muted">Alpenwerk Industrie GmbH</p>
|
||||
</div>
|
||||
<p className="text-right text-[8pt] leading-snug text-ink-muted">
|
||||
Auszug vom
|
||||
<span className="block text-[10pt] font-semibold tabular-nums text-ink">{fmtDate(today)}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl className="mt-3 grid grid-cols-[auto_1fr_auto_1fr] gap-x-4 gap-y-1 text-[9pt]">
|
||||
<dt className="font-semibold text-ink-muted">Pers.-Nr.</dt>
|
||||
<dd className="tabular-nums text-ink">{kopf.personnel_number}</dd>
|
||||
<dt className="font-semibold text-ink-muted">Eintritt</dt>
|
||||
<dd className="tabular-nums text-ink">{fmtDate(kopf.entry_date)}</dd>
|
||||
|
||||
<dt className="font-semibold text-ink-muted">Name</dt>
|
||||
<dd className="font-semibold text-ink">{fmtName(kopf.first_name, kopf.last_name)}</dd>
|
||||
<dt className="font-semibold text-ink-muted">Position</dt>
|
||||
<dd className="text-ink">
|
||||
{kopf.position_title ?? kopf.job_title}
|
||||
{kopf.position_number && <span className="text-ink-muted"> · Planstelle {kopf.position_number}</span>}
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
{/* ── Fortschritt ────────────────────────────────────── */}
|
||||
<div className="mt-3 flex items-baseline justify-between border-t border-border pt-2 text-[9pt]">
|
||||
<span className="font-semibold text-ink">
|
||||
{stand.erledigt} von {stand.gesamt} erledigt
|
||||
</span>
|
||||
{stand.offen.length > 0 && (
|
||||
<span className="text-ink-muted">
|
||||
{stand.offen.length} offen
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Die Punkte ─────────────────────────────────────── */}
|
||||
<div className="spalten mt-3">
|
||||
{gruppen.map((gruppe) => (
|
||||
<div key={gruppe.key} className="gruppe mb-3">
|
||||
<h2 className="mb-1 bg-surface px-1.5 py-0.5 text-[8pt] font-bold uppercase tracking-wide text-ink-body">
|
||||
{gruppe.label}
|
||||
</h2>
|
||||
<ul>
|
||||
{gruppe.punkte.map((punkt) => {
|
||||
const s = karte.get(punkt.key);
|
||||
const fertig = istErledigt(punkt, s);
|
||||
return (
|
||||
<li key={punkt.key} className="punkt flex gap-1.5 py-[2pt] text-[9pt] leading-snug">
|
||||
<span aria-hidden className="shrink-0 text-[10pt] leading-none text-ink">
|
||||
{fertig ? GLYPH_AN : GLYPH_AUS}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className={fertig ? "text-ink" : "text-ink-body"}>{punkt.label}</span>
|
||||
{/* Bei Ja/Nein und Text steht der Wert daneben — ein
|
||||
Haken allein sagte dort nicht, was erfasst wurde. */}
|
||||
{punkt.art !== "haken" && (
|
||||
<span className="font-semibold text-ink">
|
||||
{": "}
|
||||
{s?.wert ?? "—"}
|
||||
</span>
|
||||
)}
|
||||
{s?.kommentar && (
|
||||
<span className="block text-[8pt] italic text-ink-body">{s.kommentar}</span>
|
||||
)}
|
||||
{s?.updated_by_name && fertig && (
|
||||
<span className="block text-[7pt] text-ink-muted">
|
||||
{s.updated_by_name}
|
||||
{s.updated_at ? ` · ${fmtDate(s.updated_at)}` : ""}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Hier standen zwei Unterschriftenfelder — „Datum, Unterschrift
|
||||
Mitarbeiter:in" und „… Personalabteilung". Der Kunde hat sie
|
||||
gestrichen (Anforderung 3 aus dem Workshop): das Blatt ist eine
|
||||
Arbeitsliste für die Personalabteilung und kein Dokument, das
|
||||
jemand gegenzeichnet. Wer eine Aufgabe abgehakt hat, steht ohnehin
|
||||
an der Aufgabe selbst. */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +1,146 @@
|
||||
"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 {
|
||||
GRUND_BEGUENSTIGT_BEHINDERT,
|
||||
KUENDIGUNGSSCHUTZ_GRUENDE,
|
||||
} from "@/lib/kuendigungsschutz";
|
||||
import { MITARBEITERARTEN } from "@/lib/mitarbeiterart";
|
||||
import { sortiereWochentage, WOCHENTAGE } from "@/lib/wochentage";
|
||||
import type { CollectiveAgreement, DienstwagenArt, Mitarbeiterart, SourceType, Weekday, WorkerType } from "@/lib/types";
|
||||
|
||||
const WEEKDAYS: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||
|
||||
export type RoleEmploymentValue = {
|
||||
workerType: WorkerType;
|
||||
/**
|
||||
* Form der Beschäftigung — zweite Achse neben der Gruppe. Ein Praktikant
|
||||
* kann Arbeiter oder Angestellter sein; in einem Feld liesse sich nur eines
|
||||
* von beidem sagen.
|
||||
*/
|
||||
mitarbeiterart: Mitarbeiterart;
|
||||
/**
|
||||
* Extern oder intern besetzt.
|
||||
*
|
||||
* Wurde bisher nur bei der Neueinstellung gesetzt und war danach
|
||||
* unerreichbar — eine Angabe, die mit dem ersten Tag erstarrt, obwohl
|
||||
* gerade ihr Wechsel der Vorgang ist, um den es geht: von extern auf
|
||||
* intern ist eine **Übernahme** und wird als eigenes Ereignis protokolliert
|
||||
* (Migration 20260917120000).
|
||||
*
|
||||
* Nicht gesetzt heisst: dieses Formular fragt die Besetzungsart nicht. So
|
||||
* ist es im Einstellungsassistenten — dort steht sie im Schritt „Position",
|
||||
* wo sie hingehört, und zweimal danach zu fragen wäre eine Einladung, zwei
|
||||
* verschiedene Antworten zu geben.
|
||||
*/
|
||||
source?: SourceType;
|
||||
collectiveAgreement: CollectiveAgreement;
|
||||
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;
|
||||
/**
|
||||
* Der Personenkreis — freiwillig, aus demselben Grund wie das Enddatum:
|
||||
* der Bestand trägt das Kennzeichen seit 20260814120000 ohne Grund, und ein
|
||||
* Pflichtfeld hätte jede Änderung an diesen Zeilen blockiert.
|
||||
*/
|
||||
kuendigungsschutzGrund: string;
|
||||
/** Beginn des Schutzes — ebenfalls freiwillig. */
|
||||
kuendigungsschutzAb: string;
|
||||
/**
|
||||
* Begünstigte Behinderung. Eigenes Kennzeichen und nicht aus dem
|
||||
* Personenkreis abgeleitet: die Begünstigung besteht unabhängig davon, ob
|
||||
* sie als Kündigungsschutz geführt wird. Die Oberfläche stellt den
|
||||
* Zusammenhang her, die Datenbank koppelt die beiden nicht.
|
||||
*/
|
||||
istBeguenstigtBehindert: boolean;
|
||||
/** Grad in Prozent laut Bescheid. Als Text, weil ein leeres Zahlenfeld sonst 0 wäre. */
|
||||
behinderungGrad: string;
|
||||
behinderungAb: string;
|
||||
behinderungBis: string;
|
||||
};
|
||||
|
||||
// Shared by the hire wizard (StepVertrag) and DatenAendernPanel — both edit
|
||||
// the same set of employees columns, just against different local state.
|
||||
export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymentValue; onChange: (patch: Partial<RoleEmploymentValue>) => void }) {
|
||||
function toggleWorkDay(day: Weekday) {
|
||||
onChange({ workDays: value.workDays.includes(day) ? value.workDays.filter((d) => d !== day) : [...value.workDays, day] });
|
||||
// Sortiert und nicht angehängt: sonst hinge die gespeicherte Reihenfolge
|
||||
// davon ab, in welcher die Knöpfe angeklickt wurden. „Mo, Di" und „Di,
|
||||
// Mo" waren damit zwei Werte für dieselbe Aussage, und wer die Tage nur
|
||||
// nachsah und wieder herstellte, erzeugte eine Vertragsänderung in der
|
||||
// Akte über nichts. Siehe lib/wochentage.ts.
|
||||
const neu = value.workDays.includes(day) ? value.workDays.filter((d) => d !== day) : [...value.workDays, day];
|
||||
onChange({ workDays: sortiereWochentage(neu) });
|
||||
}
|
||||
|
||||
// Der Personenkreis zieht das Kennzeichen mit: wer „Begünstigte behinderte
|
||||
// ArbeitnehmerInnen" als Grund wählt, hat die Frage damit schon beantwortet.
|
||||
// Eine Hilfe der Oberfläche, keine Bedingung der Datenbank — abwählen lässt
|
||||
// es sich weiterhin.
|
||||
const istBehindert = value.istBeguenstigtBehindert || value.kuendigungsschutzGrund === GRUND_BEGUENSTIGT_BEHINDERT;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{/* „Beschäftigtengruppe" statt „Angestellte:r / Arbeiter:in": ein
|
||||
Feld, das nach seinen Werten heisst, wird falsch, sobald ein
|
||||
dritter dazukommt. Den Namen führt der Import schon länger. */}
|
||||
<SelectField
|
||||
label="Angestellte:r / Arbeiter:in"
|
||||
label="Beschäftigtengruppe"
|
||||
dense
|
||||
value={value.workerType}
|
||||
onChange={(v) => onChange({ workerType: v as WorkerType })}
|
||||
options={[
|
||||
{ value: "Angestellte:r", label: "Angestellte:r" },
|
||||
{ value: "Arbeiter:in", label: "Arbeiter:in" },
|
||||
{ value: "Lehrling", label: "Lehrling" },
|
||||
]}
|
||||
/>
|
||||
{/* Zweites Feld und nicht dieselbe Liste erweitert: die Gruppe sagt,
|
||||
*als was* jemand beschäftigt ist, die Art *in welcher Form*. Beides
|
||||
gilt gleichzeitig — ein Praktikant ist Arbeiter oder Angestellter,
|
||||
nicht statt dessen. */}
|
||||
<SelectField
|
||||
label="Mitarbeiterart"
|
||||
dense
|
||||
value={value.mitarbeiterart}
|
||||
onChange={(v) => onChange({ mitarbeiterart: v as Mitarbeiterart })}
|
||||
options={MITARBEITERARTEN.map((a) => ({ value: a, label: a }))}
|
||||
/>
|
||||
{/* Der Wechsel von extern auf intern ist eine Übernahme und wird als
|
||||
eigenes Ereignis protokolliert — der Hinweis steht am Feld, damit
|
||||
niemand ihn erst im Protokoll entdeckt. Der umgekehrte Weg
|
||||
erzeugt nichts; das ist Absicht und keine Lücke. */}
|
||||
{value.source !== undefined && (
|
||||
<SelectField
|
||||
label="Besetzungsart"
|
||||
dense
|
||||
value={value.source}
|
||||
onChange={(v) => onChange({ source: v as SourceType })}
|
||||
options={[
|
||||
{ value: "Extern", label: "Extern" },
|
||||
{ value: "Intern", label: "Intern" },
|
||||
]}
|
||||
hint={value.source === "Extern" ? "Ein Wechsel auf „Intern“ wird als Übernahme protokolliert." : undefined}
|
||||
/>
|
||||
)}
|
||||
<SelectField
|
||||
label="Kollektivvertrag"
|
||||
dense
|
||||
@@ -53,7 +159,7 @@ export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymen
|
||||
<fieldset>
|
||||
<legend className="mb-1 block text-xs font-semibold text-ink-muted">Arbeitstage</legend>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{WEEKDAYS.map((day) => (
|
||||
{WOCHENTAGE.map((day) => (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
@@ -78,6 +184,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 +207,97 @@ 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 && (
|
||||
<>
|
||||
<SelectField
|
||||
label="Grund"
|
||||
dense
|
||||
value={value.kuendigungsschutzGrund}
|
||||
onChange={(v) => onChange({ kuendigungsschutzGrund: v })}
|
||||
options={[
|
||||
{ value: "", label: "Nicht erfasst" },
|
||||
...KUENDIGUNGSSCHUTZ_GRUENDE.map((g) => ({ value: g, label: g })),
|
||||
]}
|
||||
hint="Bestimmt, wer einer Beendigung zustimmen muss."
|
||||
/>
|
||||
<TextField
|
||||
label="Geschützt ab"
|
||||
dense
|
||||
type="date"
|
||||
value={value.kuendigungsschutzAb}
|
||||
onChange={(v) => onChange({ kuendigungsschutzAb: v })}
|
||||
hint="Optional."
|
||||
/>
|
||||
<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."
|
||||
/>
|
||||
|
||||
{/* ── Begünstigte Behinderung ────────────────────────────
|
||||
Als Unterpunkt des Kündigungsschutzes und eingerückt — sie ist
|
||||
einer der zwölf Personenkreise und stand vorher als eigener
|
||||
Block daneben, was sie wie ein zweites, unabhängiges Thema
|
||||
aussehen liess.
|
||||
*
|
||||
In der Datenbank bleibt sie trotzdem ein eigenes Kennzeichen
|
||||
mit eigenen Feldern, und das mit Absicht: eine Bedingung
|
||||
„Grad nur bei diesem Personenkreis" liesse jede Korrektur am
|
||||
Personenkreis scheitern, solange der Grad noch dransteht —
|
||||
also genau beim Geradebiegen eines Fehlers. Was hier
|
||||
zusammengehört, muss dort nicht aneinandergekettet sein. */}
|
||||
<div className="ml-1 flex flex-col gap-2 border-l-2 border-border-subtle pl-3">
|
||||
<label className="flex items-center gap-2 text-sm text-ink-body">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={istBehindert}
|
||||
onChange={(e) => onChange({ istBeguenstigtBehindert: e.target.checked })}
|
||||
/>
|
||||
Begünstigt behindert
|
||||
</label>
|
||||
{istBehindert && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<TextField
|
||||
label="Grad der Behinderung"
|
||||
dense
|
||||
type="number"
|
||||
value={value.behinderungGrad}
|
||||
onChange={(v) => onChange({ behinderungGrad: v })}
|
||||
/>
|
||||
<TextField
|
||||
label="Bescheid ab"
|
||||
dense
|
||||
type="date"
|
||||
value={value.behinderungAb}
|
||||
onChange={(v) => onChange({ behinderungAb: v })}
|
||||
/>
|
||||
<TextField
|
||||
label="Bescheid bis"
|
||||
dense
|
||||
type="date"
|
||||
value={value.behinderungBis}
|
||||
onChange={(v) => onChange({ behinderungBis: v })}
|
||||
hint="Leer heisst unbefristet."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,10 +12,15 @@ 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 { HAY_GRADES } from "@/lib/hay-grade";
|
||||
import { STUNDEN_GRUENDE } from "@/lib/absence";
|
||||
import { GRUND_BEGUENSTIGT_BEHINDERT } from "@/lib/kuendigungsschutz";
|
||||
import { MITARBEITERART_STANDARD } from "@/lib/mitarbeiterart";
|
||||
import { fmtFullName, todayIso } from "@/lib/format";
|
||||
import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr";
|
||||
import type { ContractType, Database, EmploymentType, GenderType } from "@/lib/supabase/types";
|
||||
import { NotfallkontaktSection } from "@/components/employees/NotfallkontaktSection";
|
||||
import type { ContractType, Database, EmploymentType, GenderType, PaygradeType } from "@/lib/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
|
||||
@@ -52,30 +57,63 @@ 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 [companyEmail, setCompanyEmail] = useState(employee.company_email ?? "");
|
||||
const [cornerstoneId, setCornerstoneId] = useState(employee.cornerstone_id ?? "");
|
||||
const [phone, setPhone] = useState(employee.phone ?? "");
|
||||
|
||||
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 ?? "");
|
||||
const [paygrade, setPaygrade] = useState<PaygradeType>(employee.paygrade);
|
||||
|
||||
const [role, setRole] = useState<RoleEmploymentValue>({
|
||||
workerType: employee.worker_type ?? "Angestellte:r",
|
||||
mitarbeiterart: employee.mitarbeiterart ?? MITARBEITERART_STANDARD,
|
||||
// Hier gesetzt und im Assistenten nicht: dort steht die Besetzungsart im
|
||||
// Schritt „Position". Ihr Wechsel von extern auf intern ist eine
|
||||
// Übernahme und wird als eigenes Ereignis protokolliert.
|
||||
source: employee.source ?? "Extern",
|
||||
collectiveAgreement: employee.collective_agreement ?? "Handel",
|
||||
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 ?? "",
|
||||
kuendigungsschutzGrund: employee.kuendigungsschutz_grund ?? "",
|
||||
kuendigungsschutzAb: employee.kuendigungsschutz_ab ?? "",
|
||||
istBeguenstigtBehindert: employee.ist_beguenstigt_behindert ?? false,
|
||||
// Als Text und nicht als Zahl: ein leeres Zahlenfeld wäre 0, und 0
|
||||
// bedeutet hier „kein Bescheid", nicht „Grad null".
|
||||
behinderungGrad: employee.behinderung_grad === null ? "" : String(employee.behinderung_grad),
|
||||
behinderungAb: employee.behinderung_ab ?? "",
|
||||
behinderungBis: employee.behinderung_bis ?? "",
|
||||
dienstwagenArt: employee.dienstwagen_art ?? "Verbrenner",
|
||||
isLateraleFuehrung: employee.is_laterale_fuehrung ?? false,
|
||||
isCLevel: employee.is_c_level ?? false,
|
||||
});
|
||||
function updateRole(patch: Partial<RoleEmploymentValue>) {
|
||||
setRole((prev) => ({ ...prev, ...patch }));
|
||||
}
|
||||
// Dieselbe Regel wie in RoleEmploymentFields: der Personenkreis zieht das
|
||||
// Kennzeichen mit. Hier noch einmal, weil nicht das Formular speichert,
|
||||
// sondern diese Seite — und was gezeigt wird, muss auch abgeschickt werden.
|
||||
const istBehindert = role.istBeguenstigtBehindert || role.kuendigungsschutzGrund === GRUND_BEGUENSTIGT_BEHINDERT;
|
||||
|
||||
function handleEmploymentTypeChange(value: EmploymentType) {
|
||||
setEmploymentType(value);
|
||||
@@ -112,25 +150,66 @@ 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,
|
||||
// Getrimmt und als leere Zeichenkette durchgereicht: die Datenbank
|
||||
// macht daraus null (nullif), damit sich eine Angabe auch wieder
|
||||
// entfernen lässt. Ungetrimmt käme ein Feld, in dem nur ein
|
||||
// Leerzeichen stehen geblieben ist, als Inhalt in der Spalte an.
|
||||
email: email.trim(),
|
||||
company_email: companyEmail.trim(),
|
||||
cornerstone_id: cornerstoneId.trim(),
|
||||
phone: phone.trim(),
|
||||
// Der Notfallkontakt läuft über seinen eigenen Abschnitt weiter unten
|
||||
// — wie die Angehörigen. Stünde er zusätzlich hier, schrieben zwei
|
||||
// Stellen dasselbe Feld, und welche gewinnt, hinge an der
|
||||
// Reihenfolge.
|
||||
},
|
||||
contract: {
|
||||
employment_type: employmentType,
|
||||
weekly_hours: Number(weeklyHours),
|
||||
contract_type: contractType,
|
||||
contract_end_date: contractType === "befristet" ? contractEndDate : "",
|
||||
paygrade,
|
||||
},
|
||||
role: {
|
||||
worker_type: role.workerType,
|
||||
mitarbeiterart: role.mitarbeiterart,
|
||||
source: role.source,
|
||||
collective_agreement: role.collectiveAgreement,
|
||||
work_days: role.workDays,
|
||||
is_betriebsrat: role.isBetriebsrat,
|
||||
has_dienstwagen: role.hasDienstwagen,
|
||||
dienstwagen_art: role.hasDienstwagen ? role.dienstwagenArt : "",
|
||||
has_kuendigungsschutz: role.hasKuendigungsschutz,
|
||||
// Leer statt des Wertes, wenn das Kennzeichen weg ist: die Bedingung
|
||||
// in der Datenbank duldet keinen Rest ohne Bezug, und die Funktion
|
||||
// räumt ihn auf demselben Weg weg wie beim Dienstwagen.
|
||||
kuendigungsschutz_bis: role.hasKuendigungsschutz ? role.kuendigungsschutzBis : "",
|
||||
kuendigungsschutz_grund: role.hasKuendigungsschutz ? role.kuendigungsschutzGrund : "",
|
||||
kuendigungsschutz_ab: role.hasKuendigungsschutz ? role.kuendigungsschutzAb : "",
|
||||
ist_beguenstigt_behindert: istBehindert,
|
||||
behinderung_grad: istBehindert ? role.behinderungGrad : "",
|
||||
behinderung_ab: istBehindert ? role.behinderungAb : "",
|
||||
behinderung_bis: istBehindert ? role.behinderungBis : "",
|
||||
// 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 +276,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} />
|
||||
@@ -205,9 +312,12 @@ export function DatenAendernPanel({
|
||||
<Field label="Land" dense>
|
||||
{(p) => <CountryPicker {...p} value={addressCountry} onChange={setAddressCountry} countries={UN_COUNTRIES} placeholder="Land suchen…" />}
|
||||
</Field>
|
||||
<TextField label="E-Mail" dense type="email" value={email} onChange={setEmail} />
|
||||
<TextField label="E-Mail (privat)" dense type="email" value={email} onChange={setEmail} />
|
||||
<TextField label="Firmen-E-Mail" dense type="email" value={companyEmail} onChange={setCompanyEmail} />
|
||||
<TextField label="Cornerstone-ID" dense value={cornerstoneId} onChange={setCornerstoneId} />
|
||||
<TextField label="Telefon" dense type="tel" value={phone} onChange={setPhone} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -232,6 +342,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
|
||||
@@ -245,6 +375,18 @@ export function DatenAendernPanel({
|
||||
{contractType === "befristet" && (
|
||||
<TextField label="Befristet bis" required dense type="date" value={contractEndDate} onChange={setContractEndDate} />
|
||||
)}
|
||||
{/* Auch über die Beförderung zu setzen — und trotzdem hier. Eine
|
||||
Umstufung ist nicht immer eine Beförderung: „da stand von
|
||||
Anfang an die falsche Stufe" ist eine Richtigstellung, und die
|
||||
soll keine Planstelle wechseln und kein Ereignis „Beförderung"
|
||||
in der Historie hinterlassen. */}
|
||||
<SelectField
|
||||
label="Hay-Grade"
|
||||
dense
|
||||
value={paygrade}
|
||||
onChange={(v) => setPaygrade(v as PaygradeType)}
|
||||
options={HAY_GRADES}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -254,6 +396,22 @@ export function DatenAendernPanel({
|
||||
</div>
|
||||
|
||||
<AngehoerigeSection employeeId={employee.id} dependents={dependents} effectiveDate={effectiveDate} />
|
||||
|
||||
{/* Derselbe Abschnitt wie im Reiter „Stammdaten", unmittelbar unter den
|
||||
Angehörigen — beides Personen im Umfeld, beides dieselbe Tabelle
|
||||
mit denselben Knöpfen. Hier standen drei Eingabefelder: fachlich
|
||||
dasselbe, aber daneben eine zweite Bauform für denselben Zweck.
|
||||
Beide Abschnitte laufen jetzt über das „Wirksam ab" dieses
|
||||
Formulars. */}
|
||||
<NotfallkontaktSection
|
||||
employeeId={employee.id}
|
||||
effectiveDate={effectiveDate}
|
||||
kontakt={{
|
||||
name: employee.emergency_contact_name,
|
||||
telefon: employee.emergency_contact_phone,
|
||||
verhaeltnis: employee.emergency_contact_relation,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SlideOver>
|
||||
);
|
||||
|
||||
@@ -8,18 +8,29 @@ 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 type { Database } from "@/lib/supabase/types";
|
||||
import { ABSENCE_TYPES, absenceLabel, RUECKKEHR_GRUENDE } from "@/lib/absence";
|
||||
import { fmtDate, fmtName } from "@/lib/format";
|
||||
import type { Database, EmploymentStatus } from "@/lib/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
type Mode = "adjust" | "return";
|
||||
type EmploymentMode = "unverändert" | "Vollzeit" | "Teilzeit";
|
||||
|
||||
export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClose: () => void; employee: EmployeeRow }) {
|
||||
export function KarenzPanel({
|
||||
open,
|
||||
onClose,
|
||||
employee,
|
||||
status,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
employee: EmployeeRow;
|
||||
/** Der zum heutigen Tag abgeleitete Status — nicht `employee.status`, die Spalte hängt nach. */
|
||||
status: EmploymentStatus;
|
||||
}) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const isOnKarenz = employee.status === "Karenz";
|
||||
const isOnKarenz = status === "Karenz";
|
||||
const [mode, setMode] = useState<Mode>("adjust");
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
@@ -33,7 +44,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 +111,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 +127,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 +145,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}>
|
||||
@@ -169,7 +199,7 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
|
||||
{mode === "adjust" && (
|
||||
<>
|
||||
<p className="rounded bg-surface px-3 py-2 text-sm text-ink-body">
|
||||
<span className="font-semibold">{absenceLabel(employee.status, employee.absence_type)}</span>
|
||||
<span className="font-semibold">{absenceLabel(status, employee.absence_type)}</span>
|
||||
{" · Rückkehr "}
|
||||
{fmtDate(employee.karenz_return_date)}
|
||||
</p>
|
||||
@@ -191,22 +221,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."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,44 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { promoteEmployee } from "@/actions/employees";
|
||||
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 { Database, PaygradeType } from "@/lib/supabase/types";
|
||||
import { HAY_GRADES } from "@/lib/hay-grade";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import type { Database, PaygradeType } from "@/lib/types";
|
||||
import { fmtName } from "@/lib/format";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
|
||||
const PAYGRADES: { value: PaygradeType; label: string }[] = [
|
||||
{ value: "A", label: "A – Einstieg" },
|
||||
{ value: "B", label: "B – Qualifiziert" },
|
||||
{ value: "C", label: "C – Erfahren" },
|
||||
{ value: "D", label: "D – Spezialist:in" },
|
||||
{ value: "E", label: "E – Teamleitung" },
|
||||
{ value: "F", label: "F – Bereichsleitung / GF" },
|
||||
];
|
||||
// Eine Beförderung kann auf derselben Planstelle stattfinden oder auf eine
|
||||
// andere führen. Beides kommt vor: jemand wächst auf seiner Stelle, oder er
|
||||
// rückt auf eine höhere — und dann ist die Zielstelle dieselbe Auswahl wie
|
||||
// bei der Versetzung.
|
||||
type Stellenwahl = "selbe" | "neue";
|
||||
|
||||
export function PromotePanel({ open, onClose, employee }: { open: boolean; onClose: () => void; employee: EmployeeRow }) {
|
||||
export function PromotePanel({
|
||||
open,
|
||||
onClose,
|
||||
employee,
|
||||
openPositions,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
employee: EmployeeRow;
|
||||
openPositions: OpenPositionResolved[];
|
||||
}) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [effectiveDate, setEffectiveDate] = useState("");
|
||||
const [newTitle, setNewTitle] = useState(employee.job_title);
|
||||
const [paygrade, setPaygrade] = useState<PaygradeType>(employee.paygrade);
|
||||
const [stellenwahl, setStellenwahl] = useState<Stellenwahl>("selbe");
|
||||
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 Stelle wählen schlägt ihre Tätigkeit als neue Bezeichnung vor.
|
||||
*
|
||||
* Nur ein Vorschlag: `employees.job_title` darf von der Planstelle
|
||||
* abweichen, und wer eine eigene Bezeichnung tippt, soll sie behalten.
|
||||
* Deshalb wird nur überschrieben, solange das Feld unverändert auf dem
|
||||
* alten Titel steht.
|
||||
*/
|
||||
function stelleWaehlen(id: string) {
|
||||
setPositionId(id);
|
||||
const stelle = openPositions.find((p) => p.id === id);
|
||||
if (stelle && newTitle === employee.job_title) setNewTitle(stelle.title);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!effectiveDate || !newTitle) {
|
||||
showToast("Bitte alle Pflichtfelder ausfüllen.", "error");
|
||||
return;
|
||||
}
|
||||
if (stellenwahl === "neue" && !positionId) {
|
||||
showToast("Bitte die neue Planstelle auswählen.", "error");
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
const result = await promoteEmployee({
|
||||
employee_id: employee.id,
|
||||
effective_date: effectiveDate,
|
||||
new_title: newTitle,
|
||||
new_paygrade: paygrade,
|
||||
target_position_id: stellenwahl === "neue" ? positionId : undefined,
|
||||
});
|
||||
setPending(false);
|
||||
if (result.success) {
|
||||
@@ -55,7 +97,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}>
|
||||
@@ -71,12 +113,62 @@ export function PromotePanel({ open, onClose, employee }: { open: boolean; onClo
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
|
||||
|
||||
<fieldset>
|
||||
<legend className="mb-1.5 text-sm font-semibold text-ink-body">Planstelle</legend>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="flex items-center gap-2 text-sm text-ink-body">
|
||||
<input
|
||||
type="radio"
|
||||
name="stellenwahl"
|
||||
checked={stellenwahl === "selbe"}
|
||||
onChange={() => setStellenwahl("selbe")}
|
||||
/>
|
||||
Selbe Planstelle
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-ink-body">
|
||||
<input
|
||||
type="radio"
|
||||
name="stellenwahl"
|
||||
checked={stellenwahl === "neue"}
|
||||
onChange={() => setStellenwahl("neue")}
|
||||
disabled={options.length === 0}
|
||||
/>
|
||||
Neue Planstelle
|
||||
{options.length === 0 && <span className="text-xs text-ink-muted">(derzeit keine freie)</span>}
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{stellenwahl === "neue" && (
|
||||
<>
|
||||
<SelectField
|
||||
label="Neue Planstelle"
|
||||
required
|
||||
value={positionId}
|
||||
onChange={stelleWaehlen}
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<TextField label="Neue Position" required value={newTitle} onChange={setNewTitle} />
|
||||
<SelectField
|
||||
label="Paygrade"
|
||||
label="Hay-Grade"
|
||||
value={paygrade}
|
||||
onChange={(v) => setPaygrade(v as PaygradeType)}
|
||||
options={PAYGRADES}
|
||||
options={HAY_GRADES}
|
||||
/>
|
||||
</div>
|
||||
</SlideOver>
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { rehireEmployee } from "@/actions/employees";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { TextField } from "@/components/ui/Field";
|
||||
import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
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 }) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [rehireDate, setRehireDate] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!rehireDate) {
|
||||
showToast("Bitte ein Wiedereintrittsdatum angeben.", "error");
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
const result = await rehireEmployee({ employee_id: employee.id, rehire_date: rehireDate });
|
||||
setPending(false);
|
||||
if (result.success) {
|
||||
showToast(`${employee.first_name} ${employee.last_name} wurde wiedereingestellt.`);
|
||||
router.refresh();
|
||||
onClose();
|
||||
} else {
|
||||
showToast(result.error ?? "Fehler beim Speichern.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SlideOver
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Wiedereinstellung"
|
||||
subtitle={`${employee.first_name} ${employee.last_name}`}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} pending={pending}>
|
||||
Wiedereinstellen
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded border border-border bg-surface p-3 text-sm">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-ink-muted">Letzte Position</p>
|
||||
<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} />
|
||||
</div>
|
||||
</SlideOver>
|
||||
);
|
||||
}
|
||||
@@ -7,39 +7,68 @@ import { Button } from "@/components/ui/Button";
|
||||
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 { AUSTRITTSART_LABELS, AUSTRITTSARTEN, BEENDIGUNG_NO_SHOW, BEENDIGUNGSART_WERTE } from "@/lib/beendigung";
|
||||
import type { Austrittsart, Database, EmploymentStatus } from "@/lib/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"];
|
||||
const CHECKLIST_ITEMS = ["IT-Zugänge deaktivieren", "Hardware retournieren", "ÖGK-Abmeldung", "Endabrechnung & Dienstzeugnis"];
|
||||
// Die Liste steht in lib/beendigung.ts, nicht mehr hier: der Berichtemanager
|
||||
// braucht sie ebenso, und zwei Listen liefen auseinander.
|
||||
const NO_SHOW = BEENDIGUNG_NO_SHOW;
|
||||
|
||||
type TerminatePanelProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
employee: EmployeeRow;
|
||||
/** Der zum heutigen Tag abgeleitete Status — nicht `employee.status`, die Spalte hängt nach. */
|
||||
status: EmploymentStatus;
|
||||
directReportCount: number;
|
||||
};
|
||||
|
||||
export function TerminatePanel({ open, onClose, employee, directReportCount }: TerminatePanelProps) {
|
||||
export function TerminatePanel({ open, onClose, employee, status, directReportCount }: TerminatePanelProps) {
|
||||
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(status === "Geplant" ? NO_SHOW : BEENDIGUNGSART_WERTE[0]);
|
||||
// Leer als Vorbelegung: die Angabe ist freiwillig, und eine vorausgewählte
|
||||
// Seite wäre eine Behauptung, die niemand getroffen hat.
|
||||
const [austrittsart, setAustrittsart] = useState<Austrittsart | "">("");
|
||||
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,
|
||||
// Leer statt undefined: die SQL-Funktion macht daraus null, und null
|
||||
// heisst „nicht erfasst" — das ist der ehrliche Wert.
|
||||
austrittsart,
|
||||
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 +80,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,34 +94,89 @@ 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} />
|
||||
{/* ── Zwei Angaben, die einander nicht bestimmen ──────────────
|
||||
Erst die Beendigungsart, darunter der Anstoss. Beide Listen sind
|
||||
vollständig und keine schränkt die andere ein.
|
||||
|
||||
Der erste Entwurf hatte es umgekehrt: oben die Austrittsart, und
|
||||
sie filterte die Beendigungsarten darunter, weil der Anstoss aus
|
||||
der Art ableitbar schien. In Österreich ist er das nicht — die
|
||||
einvernehmliche Auflösung ist der Regelfall und kann von beiden
|
||||
Seiten ausgehen. Die Begründung steht in lib/beendigung.ts.
|
||||
|
||||
Unter der Beendigungsart stand ausserdem der abgeleitete Anstoss
|
||||
als Hinweis. Er erschien von selbst und sah aus wie ein Fehler des
|
||||
Formulars; er ist weg. */}
|
||||
<SelectField
|
||||
label="Beendigungsart"
|
||||
value={reason}
|
||||
onChange={setReason}
|
||||
options={EXIT_REASONS.map((r) => ({ value: r, label: r }))}
|
||||
options={BEENDIGUNGSART_WERTE.map((r) => ({ value: r, label: r === NO_SHOW ? "No Show (nicht angetreten)" : r }))}
|
||||
/>
|
||||
<SelectField
|
||||
label="Freiwillig oder unfreiwillig"
|
||||
value={austrittsart}
|
||||
onChange={(v) => setAustrittsart((v || "") as Austrittsart | "")}
|
||||
options={[
|
||||
// „Nicht erfasst" als Vorbelegung und nicht eine der beiden
|
||||
// Seiten: ein Befristungsablauf geschieht auf niemandes
|
||||
// Betreiben, und ein Nichtantritt ist gar kein Austritt. Eine
|
||||
// erzwungene Antwort wäre dort eine erfundene Zahl.
|
||||
{ value: "", label: "Nicht erfasst" },
|
||||
...AUSTRITTSARTEN.map((a) => ({ value: a, label: AUSTRITTSART_LABELS[a] })),
|
||||
]}
|
||||
hint="Für die Fluktuationsauswertung. Lässt sich aus der Beendigungsart nicht ableiten."
|
||||
/>
|
||||
<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>
|
||||
<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) => (
|
||||
<label key={item} className="flex items-center gap-2 text-sm text-ink-body">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked[i]}
|
||||
onChange={(e) => setChecked((prev) => prev.map((c, idx) => (idx === i ? e.target.checked : c)))}
|
||||
/>
|
||||
{item}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</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 —
|
||||
deshalb entsteht dort auch keine Offboarding-Checkliste. Für einen
|
||||
echten Austritt legt terminateEmployee sie in derselben
|
||||
Transaktion an; hier steht nur die Ankündigung, damit niemand sie
|
||||
hier sucht und nichts findet. */}
|
||||
{!istNoShow && (
|
||||
<p className="rounded bg-surface px-3 py-2 text-sm text-ink-body">
|
||||
Nach der Bestätigung steht im Reiter „Offboarding“ eine Checkliste zur Verfügung.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SlideOver>
|
||||
);
|
||||
|
||||
@@ -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 { Database } from "@/lib/supabase/types";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import type { Database } from "@/lib/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>
|
||||
);
|
||||
|
||||
270
components/employees/tabs/ChecklistPanel.tsx
Normal file
270
components/employees/tabs/ChecklistPanel.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
"use client";
|
||||
|
||||
import { MessageSquarePlus, Printer } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Button, LINK_BUTTON_CLASS } from "@/components/ui/Button";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import {
|
||||
alleReichungen,
|
||||
fortschrittVon,
|
||||
istErledigt,
|
||||
type AufgabenGruppe,
|
||||
type AufgabenPunkt,
|
||||
type AufgabenStand,
|
||||
} from "@/lib/checklist";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import type { ActionResult } from "@/lib/db/rpc";
|
||||
|
||||
// Die Checkliste, die bisher ein Blatt Papier war — geteilt zwischen
|
||||
// Onboarding und Offboarding. Beide sind dieselbe Sache mit anderen Punkten:
|
||||
// eine Liste, die sich abhaken, mit Ja/Nein beantworten oder mit einem Wert
|
||||
// füllen lässt, mit Kommentar, und mit einem Vermerk, **wer** wann etwas
|
||||
// eingetragen hat — auf dem Blatt stand nur ein Haken.
|
||||
//
|
||||
// Gespeichert wird beim Klick, nicht beim Absenden. Eine Checkliste wird über
|
||||
// Tage abgearbeitet, oft zwischen zwei anderen Dingen; ein „Speichern"-Knopf
|
||||
// am Ende wäre die Stelle, an der ein halber Vormittag verlorengeht.
|
||||
|
||||
type Props = {
|
||||
gruppen: readonly AufgabenGruppe[];
|
||||
staende: AufgabenStand[];
|
||||
/** Ob überhaupt eine Liste existiert. */
|
||||
vorhanden: boolean;
|
||||
/** Text, solange keine Liste existiert. */
|
||||
leerText: string;
|
||||
anlegenLabel: string;
|
||||
/** Ziel für den Ausdruck — eine eigene Seite, siehe PrintChecklist. */
|
||||
druckHref: string;
|
||||
onSpeichern: (
|
||||
itemKey: string,
|
||||
teil: { erledigt?: boolean; wert?: string | null; kommentar?: string | null }
|
||||
) => Promise<ActionResult>;
|
||||
onAnlegen: () => Promise<ActionResult>;
|
||||
};
|
||||
|
||||
export function ChecklistPanel({
|
||||
gruppen,
|
||||
staende,
|
||||
vorhanden,
|
||||
leerText,
|
||||
anlegenLabel,
|
||||
druckHref,
|
||||
onSpeichern,
|
||||
onAnlegen,
|
||||
}: Props) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [laeuft, setLaeuft] = useState<string | null>(null);
|
||||
const [kommentarOffen, setKommentarOffen] = useState<Set<string>>(new Set());
|
||||
|
||||
const punkte = alleReichungen(gruppen);
|
||||
const karte = new Map(staende.map((s) => [s.item_key, s]));
|
||||
const stand = fortschrittVon(punkte, staende);
|
||||
|
||||
async function speichern(key: string, teil: { erledigt?: boolean; wert?: string | null; kommentar?: string | null }) {
|
||||
setLaeuft(key);
|
||||
const ergebnis = await onSpeichern(key, teil);
|
||||
setLaeuft(null);
|
||||
if (ergebnis.success) router.refresh();
|
||||
else showToast(ergebnis.error ?? "Konnte nicht gespeichert werden.", "error");
|
||||
}
|
||||
|
||||
async function listeAnlegen() {
|
||||
setLaeuft("neu");
|
||||
const ergebnis = await onAnlegen();
|
||||
setLaeuft(null);
|
||||
if (ergebnis.success) {
|
||||
showToast("Checkliste angelegt.");
|
||||
router.refresh();
|
||||
} else {
|
||||
showToast(ergebnis.error ?? "Konnte nicht angelegt werden.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
if (!vorhanden) {
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<p className="text-sm text-ink-body">{leerText}</p>
|
||||
<Button onClick={listeAnlegen} pending={laeuft === "neu"}>
|
||||
{anlegenLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const anteil = Math.round((stand.erledigt / stand.gesamt) * 100);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex justify-end">
|
||||
{/* Neuer Tab: der Ausdruck ist eine eigene Seite, und wer ihn schliesst,
|
||||
steht wieder in der Akte statt auf einer leeren Historie. */}
|
||||
<Link href={druckHref} target="_blank" className={LINK_BUTTON_CLASS}>
|
||||
<Printer className="h-4 w-4" />
|
||||
Als PDF speichern
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-border bg-surface px-3 py-2.5">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<span className="text-sm font-semibold text-ink">
|
||||
{stand.erledigt} von {stand.gesamt} erledigt
|
||||
</span>
|
||||
<span className="text-xs text-ink-muted">
|
||||
{stand.offen.length === 0 ? "Vollständig." : `Offen: ${stand.offen.slice(0, 3).join(", ")}`}
|
||||
{stand.offen.length > 3 ? ` und ${stand.offen.length - 3} weitere` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-white">
|
||||
<div
|
||||
className={`h-full rounded-full ${anteil === 100 ? "bg-success-text" : "bg-brand-500"}`}
|
||||
style={{ width: `${Math.max(2, anteil)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{gruppen.map((gruppe) => (
|
||||
<div key={gruppe.key}>
|
||||
<h3 className="text-xs font-bold uppercase tracking-wide text-ink-muted">{gruppe.label}</h3>
|
||||
<ul className="mt-2 flex flex-col divide-y divide-border-subtle">
|
||||
{gruppe.punkte.map((punkt) => (
|
||||
<Zeile
|
||||
key={punkt.key}
|
||||
punkt={punkt}
|
||||
stand={karte.get(punkt.key)}
|
||||
laeuft={laeuft === punkt.key}
|
||||
kommentarOffen={kommentarOffen.has(punkt.key)}
|
||||
onKommentarOeffnen={() =>
|
||||
setKommentarOffen((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(punkt.key);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
onSpeichern={(teil) => speichern(punkt.key, teil)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Zeile({
|
||||
punkt,
|
||||
stand,
|
||||
laeuft,
|
||||
kommentarOffen,
|
||||
onKommentarOeffnen,
|
||||
onSpeichern,
|
||||
}: {
|
||||
punkt: AufgabenPunkt;
|
||||
stand: AufgabenStand | undefined;
|
||||
laeuft: boolean;
|
||||
kommentarOffen: boolean;
|
||||
onKommentarOeffnen: () => void;
|
||||
onSpeichern: (teil: { erledigt?: boolean; wert?: string | null; kommentar?: string | null }) => void;
|
||||
}) {
|
||||
const erledigt = istErledigt(punkt, stand);
|
||||
const [text, setText] = useState(stand?.wert ?? "");
|
||||
const [kommentar, setKommentar] = useState(stand?.kommentar ?? "");
|
||||
const zeigeKommentar = kommentarOffen || Boolean(stand?.kommentar);
|
||||
|
||||
return (
|
||||
<li className="py-2.5">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className={`text-sm ${erledigt ? "text-ink-muted line-through decoration-ink-muted/40" : "text-ink"}`}>
|
||||
{punkt.label}
|
||||
</span>
|
||||
{punkt.hinweis && <span className="block text-xs text-ink-muted">{punkt.hinweis}</span>}
|
||||
</div>
|
||||
|
||||
{punkt.art === "haken" && (
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={punkt.label}
|
||||
checked={stand?.erledigt ?? false}
|
||||
disabled={laeuft}
|
||||
onChange={(e) => onSpeichern({ erledigt: e.target.checked })}
|
||||
className="h-4 w-4 shrink-0 rounded border-border text-brand-600
|
||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
/>
|
||||
)}
|
||||
|
||||
{punkt.art === "janein" && (
|
||||
// Zwei Knöpfe statt eines Hakens: „nein" ist ein erhobener Befund,
|
||||
// „noch nicht gefragt" nicht. Ein Haken könnte das nicht sagen.
|
||||
<span className="flex shrink-0 gap-1">
|
||||
{(["ja", "nein"] as const).map((wert) => (
|
||||
<button
|
||||
key={wert}
|
||||
type="button"
|
||||
disabled={laeuft}
|
||||
aria-pressed={stand?.wert === wert}
|
||||
onClick={() => onSpeichern({ wert: stand?.wert === wert ? null : wert })}
|
||||
className={`rounded-full px-2.5 py-0.5 text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
|
||||
stand?.wert === wert ? "bg-brand-500 text-white" : "bg-surface text-ink-muted hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{wert === "ja" ? "Ja" : "Nein"}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{punkt.art === "text" && (
|
||||
<input
|
||||
type="text"
|
||||
aria-label={`${punkt.label} — Wert`}
|
||||
value={text}
|
||||
disabled={laeuft}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={() => text !== (stand?.wert ?? "") && onSpeichern({ wert: text || null })}
|
||||
placeholder="Wert"
|
||||
className="w-28 shrink-0 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"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!zeigeKommentar && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onKommentarOeffnen}
|
||||
aria-label={`Kommentar zu ${punkt.label}`}
|
||||
className="shrink-0 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"
|
||||
>
|
||||
<MessageSquarePlus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{zeigeKommentar && (
|
||||
<input
|
||||
type="text"
|
||||
aria-label={`Kommentar zu ${punkt.label}`}
|
||||
value={kommentar}
|
||||
disabled={laeuft}
|
||||
onChange={(e) => setKommentar(e.target.value)}
|
||||
onBlur={() => kommentar !== (stand?.kommentar ?? "") && onSpeichern({ kommentar: kommentar || null })}
|
||||
placeholder="Kommentar …"
|
||||
className="mt-1.5 w-full rounded border border-border bg-surface px-2 py-1 text-xs text-ink-body
|
||||
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Wer und wann — der Unterschied zum Blatt Papier, auf dem nur der
|
||||
Haken stand. */}
|
||||
{stand?.updated_by_name && (erledigt || stand.kommentar) && (
|
||||
<p className="mt-1 text-[11px] text-ink-muted">
|
||||
{stand.updated_by_name}
|
||||
{stand.updated_at ? ` · ${fmtDate(stand.updated_at)}` : ""}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +1,208 @@
|
||||
import { actionBadgeStyle } from "@/lib/colors";
|
||||
"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 { eventBadgeStyle } from "@/lib/colors";
|
||||
import { fmtDate, todayIso } from "@/lib/format";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
import { darfBearbeitetWerden, darfKorrigiertWerden, loeschVorschau } from "@/lib/history";
|
||||
import type { Database } from "@/lib/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: "Vergangen" },
|
||||
];
|
||||
|
||||
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) ? eventBadgeStyle(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 ${eventBadgeStyle(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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { SelectField, TextField, TextareaField } from "@/components/ui/Field";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { NOTE_CATEGORY_STYLES } from "@/lib/colors";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import type { Database, NoteCategory } from "@/lib/supabase/types";
|
||||
import type { Database, NoteCategory } from "@/lib/types";
|
||||
|
||||
type Note = Database["public"]["Tables"]["employee_notes"]["Row"];
|
||||
|
||||
@@ -103,7 +103,7 @@ export function NotizenTab({ employeeId, notes }: { employeeId: string; notes: N
|
||||
{n.done ? (
|
||||
<span className="rounded-full bg-success-bg px-2 py-0.5 text-xs font-semibold text-success-text">Erledigt</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-brand-100 px-2 py-0.5 text-xs font-semibold text-brand-700">Offen</span>
|
||||
<span className="rounded-full bg-accent-200 px-2 py-0.5 text-xs font-semibold text-brand-700">Offen</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-ink">{n.note_text}</p>
|
||||
|
||||
33
components/employees/tabs/OffboardingTab.tsx
Normal file
33
components/employees/tabs/OffboardingTab.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { setOffboardingTask, startOffboarding } from "@/actions/employees";
|
||||
import { OFFBOARDING_GRUPPEN } from "@/lib/offboarding";
|
||||
import type { AufgabenStand } from "@/lib/checklist";
|
||||
import { ChecklistPanel } from "./ChecklistPanel";
|
||||
|
||||
// Dünne Bindung — siehe OnboardingTab.tsx, dieselbe Mechanik für den anderen
|
||||
// Weg.
|
||||
|
||||
export function OffboardingTab({
|
||||
employeeId,
|
||||
staende,
|
||||
vorhanden,
|
||||
}: {
|
||||
employeeId: string;
|
||||
staende: AufgabenStand[];
|
||||
/** Ob überhaupt eine Liste existiert — sie entsteht mit dem Austritt. */
|
||||
vorhanden: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ChecklistPanel
|
||||
gruppen={OFFBOARDING_GRUPPEN}
|
||||
staende={staende}
|
||||
vorhanden={vorhanden}
|
||||
leerText="Für diese Person gibt es keine Offboarding-Checkliste. Für einen No Show wird keine angelegt — wer nie angetreten ist, hat nichts offzuboarden."
|
||||
anlegenLabel="Checkliste anlegen"
|
||||
druckHref={`/employees/${employeeId}/checkliste?art=offboarding`}
|
||||
onSpeichern={(itemKey, teil) => setOffboardingTask({ employee_id: employeeId, item_key: itemKey, ...teil })}
|
||||
onAnlegen={() => startOffboarding(employeeId)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
34
components/employees/tabs/OnboardingTab.tsx
Normal file
34
components/employees/tabs/OnboardingTab.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { setOnboardingTask, startOnboarding } from "@/actions/employees";
|
||||
import { ONBOARDING_GRUPPEN } from "@/lib/onboarding";
|
||||
import type { AufgabenStand } from "@/lib/checklist";
|
||||
import { ChecklistPanel } from "./ChecklistPanel";
|
||||
|
||||
// Dünne Bindung: die Mechanik der Checkliste steht in ChecklistPanel, geteilt
|
||||
// mit OffboardingTab. Hier steht nur, welches Verzeichnis gilt und welche
|
||||
// Server-Aktionen es speichern.
|
||||
|
||||
export function OnboardingTab({
|
||||
employeeId,
|
||||
staende,
|
||||
vorhanden,
|
||||
}: {
|
||||
employeeId: string;
|
||||
staende: AufgabenStand[];
|
||||
/** Ob überhaupt eine Liste existiert — sie entsteht mit dem Eintritt. */
|
||||
vorhanden: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ChecklistPanel
|
||||
gruppen={ONBOARDING_GRUPPEN}
|
||||
staende={staende}
|
||||
vorhanden={vorhanden}
|
||||
leerText="Für diese Person gibt es keine Onboarding-Checkliste. Sie entsteht mit einer Einstellung oder Wiedereinstellung — wer davor eingetreten ist, hat keine."
|
||||
anlegenLabel="Checkliste anlegen"
|
||||
druckHref={`/employees/${employeeId}/checkliste?art=onboarding`}
|
||||
onSpeichern={(itemKey, teil) => setOnboardingTask({ employee_id: employeeId, item_key: itemKey, ...teil })}
|
||||
onAnlegen={() => startOnboarding(employeeId)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,23 +2,59 @@ 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) {
|
||||
// Die Niederlassung stand hier kurzzeitig neben Einheit und Kostenstelle —
|
||||
// unsere Auslegung von „Niederlassungen auch in Zuordnung einfügen"
|
||||
// (Anforderung 8). Im Gespräch am 17.09.2026 hat der Kunde sie hier wieder
|
||||
// gestrichen: „nimm's mal hier raus, ich glaub, da brauchen wir's nicht
|
||||
// drin." Sie steht weiterhin im Vertragsblatt.
|
||||
//
|
||||
// Was mit der Anforderung gemeint war, ist damit weiterhin offen — siehe
|
||||
// docs/rueckfragen-workshop-2026-09.md.
|
||||
|
||||
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 +66,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 +99,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,32 +1,65 @@
|
||||
import { AngehoerigeSection } from "@/components/employees/AngehoerigeSection";
|
||||
import { NotfallkontaktSection } from "@/components/employees/NotfallkontaktSection";
|
||||
import { brauchtAufenthaltstitel } from "@/lib/countries";
|
||||
import { fmtAge, fmtDate } from "@/lib/format";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
import type { Database } from "@/lib/types";
|
||||
|
||||
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 ?? "–"],
|
||||
["Firmen-E-Mail", employee.company_email ?? "–"],
|
||||
["Cornerstone-ID", employee.cornerstone_id ?? "–"],
|
||||
["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 +71,17 @@ export function StammdatenTab({ employee, location, dependents }: { employee: Em
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<AngehoerigeSection employeeId={employee.id} dependents={dependents} />
|
||||
|
||||
<NotfallkontaktSection
|
||||
employeeId={employee.id}
|
||||
kontakt={{
|
||||
name: employee.emergency_contact_name,
|
||||
telefon: employee.emergency_contact_phone,
|
||||
verhaeltnis: employee.emergency_contact_relation,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,55 @@
|
||||
import { dienstwagenLabel } from "@/lib/dienstwagen";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
import { hayGradeLabel } from "@/lib/hay-grade";
|
||||
import type { Database } from "@/lib/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
|
||||
const PAYGRADE_LABELS: Record<string, string> = {
|
||||
A: "A – Einstieg",
|
||||
B: "B – Qualifiziert",
|
||||
C: "C – Erfahren",
|
||||
D: "D – Spezialist:in",
|
||||
E: "E – Teamleitung",
|
||||
F: "F – Bereichsleitung / GF",
|
||||
};
|
||||
/**
|
||||
* Der Kündigungsschutz mit Grund und Zeitraum.
|
||||
*
|
||||
* Hier stand nur „bis TT.MM.JJJJ". Erfasst werden aber Personenkreis, Beginn
|
||||
* und Ende — und der Personenkreis ist die eigentliche Auskunft: „bis 2030"
|
||||
* sagt nicht, warum. Gemeldet am 29.09. von Lara und im Testprotokoll (H.09).
|
||||
*
|
||||
* Die Wortwahl für den einfachsten Fall bleibt: steht nur das Kennzeichen da,
|
||||
* heisst es weiter „Ja (Ende offen)".
|
||||
*/
|
||||
function kuendigungsschutzText(e: EmployeeRow): string {
|
||||
if (!e.has_kuendigungsschutz) return "Nein";
|
||||
const teile = [
|
||||
e.kuendigungsschutz_grund,
|
||||
e.kuendigungsschutz_ab ? `ab ${fmtDate(e.kuendigungsschutz_ab)}` : null,
|
||||
e.kuendigungsschutz_bis ? `bis ${fmtDate(e.kuendigungsschutz_bis)}` : null,
|
||||
].filter((t): t is string => Boolean(t));
|
||||
|
||||
if (teile.length === 0) return "Ja (Ende offen)";
|
||||
return e.kuendigungsschutz_bis ? teile.join(" · ") : `${teile.join(" · ")} · Ende offen`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Begünstigung als eigene Zeile, nicht an den Kündigungsschutz gehängt.
|
||||
*
|
||||
* Sie führt zwar oft zu besonderem Kündigungsschutz, ist aber ein eigener
|
||||
* Bescheid — und der Grad gehört zur Behinderung, nicht zum Schutz.
|
||||
*/
|
||||
function behinderungText(e: EmployeeRow): string {
|
||||
if (!e.ist_beguenstigt_behindert) return "Nein";
|
||||
const teile = [
|
||||
e.behinderung_grad !== null ? `${e.behinderung_grad} %` : null,
|
||||
e.behinderung_ab ? `Bescheid ab ${fmtDate(e.behinderung_ab)}` : null,
|
||||
e.behinderung_bis ? `bis ${fmtDate(e.behinderung_bis)}` : null,
|
||||
].filter((t): t is string => Boolean(t));
|
||||
|
||||
return teile.length > 0 ? teile.join(" · ") : "Ja";
|
||||
}
|
||||
|
||||
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"],
|
||||
@@ -26,10 +57,38 @@ export function VertragTab({ employee }: { employee: EmployeeRow }) {
|
||||
["Beschäftigungsausmaß", employee.employment_type],
|
||||
["Wochenstunden", `${employee.weekly_hours} h`],
|
||||
["Urlaubsanspruch", "25 Tage"],
|
||||
["Paygrade", PAYGRADE_LABELS[employee.paygrade] ?? employee.paygrade],
|
||||
["Angestellte:r / Arbeiter:in", employee.worker_type ?? "–"],
|
||||
["Hay-Grade", hayGradeLabel(employee.paygrade)],
|
||||
["Beschäftigtengruppe", 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)`
|
||||
: "–",
|
||||
],
|
||||
// Grund und Zeitraum stehen dabei, nicht nur das Enddatum. Gemeldet am
|
||||
// 29.09.: erfasst werden sieben Felder — Personenkreis, Beginn, Ende,
|
||||
// Behinderung mit Grad und Bescheidzeitraum —, sichtbar war in der Akte
|
||||
// nur „bis TT.MM.JJJJ". Wer den Grund brauchte, fand ihn nur in der
|
||||
// Historie oder über einen Bericht.
|
||||
["Besonderer Kündigungsschutz", kuendigungsschutzText(employee)],
|
||||
// Eigene Zeile und nicht an den Kündigungsschutz gehängt: die Begünstigung
|
||||
// ist ein Bescheid für sich. Sie führt zwar oft zu besonderem
|
||||
// Kündigungsschutz, ist aber nicht dasselbe, und der Grad gehört zur
|
||||
// Behinderung, nicht zum Schutz.
|
||||
["Begünstigt behindert", behinderungText(employee)],
|
||||
];
|
||||
if (employee.exit_date) rows.push(["Austrittsdatum", fmtDate(employee.exit_date)]);
|
||||
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, 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 { GRUND_BEGUENSTIGT_BEHINDERT } from "@/lib/kuendigungsschutz";
|
||||
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"];
|
||||
// Vertrag vor Angehörige: was den Vertrag ausmacht — Eintritt, Arbeitstage,
|
||||
// Befristung — steht auf dem Papier, das vor dem Gespräch da ist. Angehörige
|
||||
// bringt die Person mit, oft erst am ersten Tag. Bis September 2026 stand es
|
||||
// andersherum, und der freiwillige Schritt lag vor dem, der die Einstellung
|
||||
// überhaupt trägt.
|
||||
const STEP_LABELS = ["Person", "Position", "Vertrag", "Angehörige", "Notfallkontakt", "Zusammenfassung"] as const;
|
||||
|
||||
type HireWizardProps = {
|
||||
open: boolean;
|
||||
@@ -52,17 +60,65 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
||||
|
||||
// Blocks step 1 rather than letting the hire fail at the RPC: the SVNR
|
||||
// trigger rejects a bad number, and by then the user is three steps on.
|
||||
// Dieselbe Überlegung wie bei der SV-Nummer, nur dass die Antwort aus der
|
||||
// Datenbank kommt: eine vergebene Personalnummer wies bisher erst
|
||||
// hire_employee ab — nach sechs Schritten Eingabe, und die Nummer steht im
|
||||
// ersten Feld des ersten Schritts.
|
||||
//
|
||||
// Gemerkt wird die *Nummer*, die als vergeben zurückkam, nicht ein Ja/Nein.
|
||||
// So ist die Sperre eine Ableitung aus dem, was im Feld steht, und muss
|
||||
// beim Weitertippen nicht zurückgesetzt werden — ein Zurücksetzen, das man
|
||||
// vergessen kann, sperrt sonst ein Formular ohne sichtbaren Grund.
|
||||
const [vergebeneNummer, setVergebeneNummer] = useState<number | null>(null);
|
||||
const nummerFrei = Number(draft.personnelNumber) !== vergebeneNummer;
|
||||
const merkeBefund = useCallback(
|
||||
(befund: { nummer: number; vergeben: boolean }) => setVergebeneNummer(befund.vergeben ? befund.nummer : null),
|
||||
[]
|
||||
);
|
||||
|
||||
const svNummerOk =
|
||||
!draft.svNummer.trim() ||
|
||||
!requiresAustrianSvnr(locations.find((l) => l.id === draft.locationId)?.country) ||
|
||||
isValidSvnr(draft.svNummer, draft.birthDate || null);
|
||||
|
||||
const stepValid = [
|
||||
Boolean(draft.firstName && draft.lastName && draft.birthDate && draft.locationId) && svNummerOk,
|
||||
Boolean(draft.positionId && draft.besetzung),
|
||||
Boolean(draft.entryDate && draft.workDays.length > 0),
|
||||
true,
|
||||
][step];
|
||||
// Am Namen des Schritts, nicht an seiner Nummer.
|
||||
//
|
||||
// Vorher war das eine Liste in derselben Reihenfolge wie STEP_LABELS, und
|
||||
// die beiden mussten stumm zusammenpassen. Beim Vertauschen von Vertrag und
|
||||
// Angehörige wäre die Prüfung stehengeblieben, wo sie war: „Weiter" im
|
||||
// Vertrag hätte die Angehörigen geprüft und ein leeres Eintrittsdatum
|
||||
// durchgelassen — bis die Datenbank es am Ende abweist.
|
||||
const pruefung: Record<(typeof STEP_LABELS)[number], boolean> = {
|
||||
// E-Mail gehört zu den Pflichtfeldern, weil die Spalte NOT NULL ist. Ohne
|
||||
// die Prüfung hier bricht erst die Datenbank ab — nach allen Eingaben.
|
||||
// Die private E-Mail-Adresse steht bewusst nicht mehr darunter: sie ist
|
||||
// freiwillig, seit die Spalte NULL zulässt.
|
||||
Person:
|
||||
Boolean(
|
||||
draft.personnelNumber.trim() && draft.firstName && draft.lastName && draft.birthDate && draft.locationId
|
||||
) && svNummerOk && nummerFrei,
|
||||
Position: Boolean(draft.positionId && draft.besetzung),
|
||||
Vertrag: Boolean(draft.entryDate && draft.workDays.length > 0),
|
||||
// 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.
|
||||
Angehörige: draft.angehoerige.every(
|
||||
(a) =>
|
||||
a.firstName.trim() &&
|
||||
a.lastName.trim() &&
|
||||
a.birthDate &&
|
||||
(!a.svNummer.trim() || isValidSvnr(a.svNummer, a.birthDate || null))
|
||||
),
|
||||
// Notfallkontakt: freiwillig, aber Name und Nummer nur gemeinsam — die
|
||||
// Datenbank weist eines ohne das andere ab (chk_emergency_contact).
|
||||
Notfallkontakt:
|
||||
Boolean(draft.emergencyContactName.trim()) === Boolean(draft.emergencyContactPhone.trim()),
|
||||
Zusammenfassung: true,
|
||||
};
|
||||
const stepValid = pruefung[STEP_LABELS[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 });
|
||||
@@ -77,8 +133,13 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!selectedPosition || !draft.besetzung) return;
|
||||
// Dieselbe Regel wie im Formular (RoleEmploymentFields): der
|
||||
// Personenkreis zieht das Kennzeichen mit. Was gezeigt wurde, muss auch
|
||||
// abgeschickt werden.
|
||||
const istBehindert = draft.istBeguenstigtBehindert || draft.kuendigungsschutzGrund === GRUND_BEGUENSTIGT_BEHINDERT;
|
||||
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 +147,9 @@ 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,
|
||||
company_email: draft.companyEmail.trim() || undefined,
|
||||
cornerstone_id: draft.cornerstoneId.trim() || undefined,
|
||||
phone: draft.phone || undefined,
|
||||
position_id: draft.positionId,
|
||||
location_id: draft.locationId,
|
||||
@@ -101,17 +165,65 @@ 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,
|
||||
mitarbeiterart: draft.mitarbeiterart,
|
||||
has_kuendigungsschutz: draft.hasKuendigungsschutz,
|
||||
// Ohne Schutz kein Enddatum, kein Beginn, kein Personenkreis — die
|
||||
// CHECKs lassen es nicht anders zu.
|
||||
kuendigungsschutz_bis: draft.hasKuendigungsschutz ? draft.kuendigungsschutzBis || null : null,
|
||||
kuendigungsschutz_grund: draft.hasKuendigungsschutz ? draft.kuendigungsschutzGrund || null : null,
|
||||
kuendigungsschutz_ab: draft.hasKuendigungsschutz ? draft.kuendigungsschutzAb || null : null,
|
||||
ist_beguenstigt_behindert: istBehindert,
|
||||
behinderung_grad: istBehindert ? draft.behinderungGrad || null : null,
|
||||
behinderung_ab: istBehindert ? draft.behinderungAb || null : null,
|
||||
behinderung_bis: istBehindert ? draft.behinderungBis || 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 +249,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>
|
||||
@@ -174,10 +286,12 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
||||
))}
|
||||
</div>
|
||||
|
||||
{step === 0 && <StepPerson draft={draft} update={update} locations={locations} />}
|
||||
{step === 0 && <StepPerson draft={draft} update={update} locations={locations} onNummerBefund={merkeBefund} />}
|
||||
{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 === 3 && <StepAngehoerige draft={draft} update={update} />}
|
||||
{step === 4 && <StepNotfallkontakt draft={draft} update={update} />}
|
||||
{step === 5 && <StepSummary draft={draft} selectedPosition={selectedPosition} locations={locations} />}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { entwurfFreigeben, entwurfSperren } from "@/actions/hireDrafts";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import type { Entwurf } from "@/lib/entwuerfe";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import { HireWizard } from "./HireWizard";
|
||||
|
||||
type Location = { id: string; name: string; country: string };
|
||||
type HireDraft = { id: string; step: number; payload: Record<string, unknown> };
|
||||
|
||||
type OpenWizardOptions = { draftId?: string; positionId?: string };
|
||||
|
||||
@@ -15,6 +18,16 @@ type HireWizardContextValue = {
|
||||
|
||||
const HireWizardContext = createContext<HireWizardContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Wie oft die Sperre aufgefrischt wird, solange der Assistent offen ist.
|
||||
*
|
||||
* Deutlich kürzer als die Frist in der Datenbank (15 Minuten,
|
||||
* app_entwurf_sperrfrist): zwischen zwei Takten darf eine Antwort ausfallen,
|
||||
* ohne dass die Sperre wegläuft. Wer eine halbe Stunde an einem Entwurf
|
||||
* sitzt, soll ihn nicht auf halbem Weg an eine Kollegin verlieren.
|
||||
*/
|
||||
const TAKT_MS = 5 * 60 * 1000;
|
||||
|
||||
export function HireWizardProvider({
|
||||
children,
|
||||
openPositions,
|
||||
@@ -24,10 +37,12 @@ export function HireWizardProvider({
|
||||
children: ReactNode;
|
||||
openPositions: OpenPositionResolved[];
|
||||
locations: Location[];
|
||||
drafts: HireDraft[];
|
||||
drafts: Entwurf[];
|
||||
}) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [resumeDraft, setResumeDraft] = useState<HireDraft | null>(null);
|
||||
const [resumeDraft, setResumeDraft] = useState<Entwurf | null>(null);
|
||||
const [initialPositionId, setInitialPositionId] = useState<string | undefined>(undefined);
|
||||
// Forces HireWizard to remount fresh each time it's opened, so its
|
||||
// internal draft/step state is (re-)initialized directly from the current
|
||||
@@ -35,20 +50,73 @@ export function HireWizardProvider({
|
||||
// effect needed inside HireWizard itself.
|
||||
const [openKey, setOpenKey] = useState(0);
|
||||
|
||||
function openWizard(options?: OpenWizardOptions) {
|
||||
// Die Kennung des Entwurfs, dessen Sperre wir halten. Als Ref, weil sie im
|
||||
// Aufräumen des Effekts gebraucht wird — über den Zustand gelesen wäre es
|
||||
// dort der Stand von vorhin.
|
||||
const gesperrt = useRef<string | null>(null);
|
||||
|
||||
async function openWizard(options?: OpenWizardOptions) {
|
||||
// Ohne Entwurf gibt es nichts zu sperren: eine neue Einstellung entsteht
|
||||
// erst beim Speichern.
|
||||
if (options?.draftId) {
|
||||
const result = await entwurfSperren(options.draftId);
|
||||
if (!result.success) {
|
||||
showToast(result.error ?? "Der Entwurf lässt sich gerade nicht öffnen.", "error");
|
||||
// Die Liste holt sich den Stand: wer die Sperre hält, steht danach
|
||||
// an der Zeile, statt dass nur eine Meldung aufblitzt.
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
gesperrt.current = options.draftId;
|
||||
}
|
||||
setResumeDraft(options?.draftId ? (drafts.find((d) => d.id === options.draftId) ?? null) : null);
|
||||
setInitialPositionId(options?.positionId);
|
||||
setOpen(true);
|
||||
setOpenKey((k) => k + 1);
|
||||
}
|
||||
|
||||
function closeWizard() {
|
||||
setOpen(false);
|
||||
freigeben();
|
||||
}
|
||||
|
||||
function freigeben() {
|
||||
const id = gesperrt.current;
|
||||
if (!id) return;
|
||||
gesperrt.current = null;
|
||||
void entwurfFreigeben(id);
|
||||
}
|
||||
|
||||
// Auffrischen, solange der Assistent offen ist. Ohne das liefe die Frist
|
||||
// einem langen Ausfüllen davon, und das Speichern am Ende fände seine
|
||||
// eigene Sperre abgelaufen.
|
||||
//
|
||||
// `!open` ist doppelt gemoppelt: freigeben() räumt beim Zumachen auch
|
||||
// gesperrt.current weg, und daran scheitert der Takt ohnehin. Es steht
|
||||
// trotzdem da, weil es die Bedingung ausspricht, um die es geht — dass
|
||||
// nach dem Zumachen nichts mehr aufgefrischt wird, hängt sonst an einer
|
||||
// Zuweisung drei Funktionen weiter oben.
|
||||
useEffect(() => {
|
||||
if (!open || !gesperrt.current) return;
|
||||
const timer = setInterval(() => {
|
||||
if (gesperrt.current) void entwurfSperren(gesperrt.current);
|
||||
}, TAKT_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [open, openKey]);
|
||||
|
||||
// Beim Verlassen der Seite: dieselbe Freigabe wie beim Schliessen. Sie
|
||||
// erreicht den Server nicht immer — ein zugeklappter Laptop schickt nichts
|
||||
// mehr. Deshalb ist sie die Höflichkeit und nicht die Absicherung; die
|
||||
// Absicherung ist die Frist in der Datenbank.
|
||||
useEffect(() => () => freigeben(), []);
|
||||
|
||||
return (
|
||||
<HireWizardContext.Provider value={{ openWizard }}>
|
||||
{children}
|
||||
<HireWizard
|
||||
key={openKey}
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
onClose={closeWizard}
|
||||
openPositions={openPositions}
|
||||
locations={locations}
|
||||
resumeDraft={resumeDraft}
|
||||
|
||||
99
components/hire/PersonalnummerField.tsx
Normal file
99
components/hire/PersonalnummerField.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { personalnummerVergeben } from "@/actions/employees";
|
||||
import { TextField } from "@/components/ui/Field";
|
||||
|
||||
// Die Personalnummer wird eingegeben, nicht vergeben — sie muss mit Loga und
|
||||
// Interflex übereinstimmen. Genau deshalb kommt sie von aussen, und genau
|
||||
// deshalb kann sie schon vergeben sein: an dieselbe Person, die jemand ein
|
||||
// zweites Mal anlegt, oder an eine ganz andere nach einem Zahlendreher.
|
||||
//
|
||||
// Die Datenbank weist das ab (hire_employee prüft es eigens), aber erst beim
|
||||
// Anlegen — nach sechs Schritten Eingabe. Bis dahin ist die falsche Nummer
|
||||
// schon durch alle Formulare gereist.
|
||||
//
|
||||
// ── Wann gefragt wird ───────────────────────────────────────────────
|
||||
//
|
||||
// Nicht bei jedem Tastendruck: „3", „34", „347" wären drei Fragen an die
|
||||
// Datenbank für eine Eingabe, und die ersten beiden hätten eine Antwort auf
|
||||
// eine Nummer, die niemand meint. Stattdessen eine halbe Sekunde Ruhe
|
||||
// abwarten — lange genug, dass eine vierstellige Nummer am Stück eingetippt
|
||||
// eine einzige Frage ergibt, kurz genug, dass die Antwort da ist, bevor der
|
||||
// Blick zum nächsten Feld wandert.
|
||||
//
|
||||
// Die Antwort kann veralten, während sie unterwegs ist. Deshalb zählt jede
|
||||
// Anfrage mit und nur die jüngste darf schreiben: sonst überschriebe die
|
||||
// langsamere Antwort auf „347" die schnellere auf „3471".
|
||||
//
|
||||
// ── Warum der Befund an der Nummer hängt und nicht an einem Ja/Nein ──
|
||||
//
|
||||
// Gespeichert wird **die geprüfte Nummer** mitsamt ihrem Ergebnis, nicht
|
||||
// bloss „frei" oder „vergeben". Damit ist jede Anzeige eine Ableitung aus
|
||||
// dem, was gerade im Feld steht — es gibt keinen Zustand, der zurückgesetzt
|
||||
// werden müsste, wenn die Eingabe sich ändert, und deshalb auch keine Stelle,
|
||||
// an der das Zurücksetzen vergessen werden kann.
|
||||
|
||||
const WARTEZEIT_MS = 500;
|
||||
|
||||
type Befund = { nummer: number; vergeben: boolean; name?: string };
|
||||
|
||||
export function PersonalnummerField({
|
||||
wert,
|
||||
onChange,
|
||||
onBefund,
|
||||
}: {
|
||||
wert: string;
|
||||
onChange: (wert: string) => void;
|
||||
/** Meldet dem Assistenten die geprüfte Nummer und ihr Ergebnis. */
|
||||
onBefund: (befund: Befund) => void;
|
||||
}) {
|
||||
const [befund, setBefund] = useState<Befund | null>(null);
|
||||
const laufendeNr = useRef(0);
|
||||
|
||||
const nummer = Number(wert);
|
||||
const gueltig = Boolean(wert.trim()) && Number.isInteger(nummer) && nummer > 0;
|
||||
// Nur ein Befund zu *dieser* Nummer zählt. Steht im Feld inzwischen etwas
|
||||
// anderes, ist die alte Auskunft gegenstandslos.
|
||||
const passend = gueltig && befund?.nummer === nummer ? befund : null;
|
||||
const laeuft = gueltig && !passend;
|
||||
|
||||
useEffect(() => {
|
||||
if (!gueltig) return;
|
||||
|
||||
const meine = ++laufendeNr.current;
|
||||
const zeit = setTimeout(async () => {
|
||||
let antwort: { vergeben: boolean; name?: string };
|
||||
try {
|
||||
antwort = await personalnummerVergeben(nummer);
|
||||
} catch {
|
||||
// Keine Auskunft ist kein Hindernis: die Datenbank entscheidet beim
|
||||
// Anlegen ohnehin. Eine Meldung über eine gescheiterte *Vorab*-Prüfung
|
||||
// wäre für die Eingebende nur Lärm.
|
||||
antwort = { vergeben: false };
|
||||
}
|
||||
if (meine !== laufendeNr.current) return;
|
||||
const neu: Befund = { nummer, ...antwort };
|
||||
setBefund(neu);
|
||||
onBefund(neu);
|
||||
}, WARTEZEIT_MS);
|
||||
|
||||
return () => clearTimeout(zeit);
|
||||
}, [gueltig, nummer, onBefund]);
|
||||
|
||||
return (
|
||||
<TextField
|
||||
label="Personalnummer"
|
||||
required
|
||||
inputMode="numeric"
|
||||
value={wert}
|
||||
onChange={(v) => onChange(v.replace(/\D/g, ""))}
|
||||
error={
|
||||
passend?.vergeben
|
||||
? `Diese Personalnummer ist bereits vergeben${passend.name ? ` — an ${passend.name}` : ""}.`
|
||||
: undefined
|
||||
}
|
||||
hint={laeuft ? "Wird geprüft…" : "Muss mit Loga und Interflex übereinstimmen. Wird nicht automatisch vergeben."}
|
||||
/>
|
||||
);
|
||||
}
|
||||
241
components/hire/RehireWizard.tsx
Normal file
241
components/hire/RehireWizard.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { rehireEmployee } from "@/actions/employees";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { fmtName } from "@/lib/format";
|
||||
import { GRUND_BEGUENSTIGT_BEHINDERT } from "@/lib/kuendigungsschutz";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr";
|
||||
import type { Database } from "@/lib/types";
|
||||
import { vorbelegungAus } from "@/lib/wiedereintritt";
|
||||
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";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
|
||||
// Der Wiedereintritt als Assistent — derselbe Weg wie eine Neueinstellung,
|
||||
// nur vorbefüllt.
|
||||
//
|
||||
// ── Warum nicht der kleine Dialog von vorher ────────────────────────
|
||||
//
|
||||
// Der fragte Datum und Planstelle und liess alles andere stehen, wie es beim
|
||||
// Austritt war. Nach zwei Jahren Abwesenheit ist das selten noch richtig: die
|
||||
// Anschrift, die Wochenstunden, der Kollektivvertrag, oft auch der Name. Wer
|
||||
// das bemerkte, musste die Person erst wiedereinstellen und danach „Daten
|
||||
// ändern" öffnen — zwei Vorgänge für einen, und in der Akte steht dann eine
|
||||
// Vertragsänderung am Tag des Wiedereintritts, die niemand vorgenommen hat.
|
||||
//
|
||||
// ── Warum ein eigenes Bauteil und kein Schalter im HireWizard ───────
|
||||
//
|
||||
// Die beiden Abläufe sehen gleich aus und sind es nicht: hier gibt es keinen
|
||||
// Entwurf zu speichern, keine Angehörigen anzulegen (die stehen schon in der
|
||||
// Akte), keine Personalnummer zu prüfen, und am Ende ruft er eine andere
|
||||
// Funktion. Ein HireWizard mit `modus === "rehire"` an sechs Stellen wäre
|
||||
// schwerer zu lesen als zwei Abläufe, die sich die Schritte teilen — und die
|
||||
// Schritte sind der Teil, der wirklich geteilt gehört.
|
||||
//
|
||||
// ── Warum alles in einem Aufruf geht ────────────────────────────────
|
||||
//
|
||||
// `rehire_employee` nimmt den ganzen Satz entgegen und schreibt ihn in einer
|
||||
// Transaktion (Migration 20260917130000). Erst wiedereinstellen und dann
|
||||
// ändern wären zwei Transaktionen, und scheitert die zweite, steht die Person
|
||||
// wieder im Dienst — mit den Daten von damals und ohne dass es jemand merkt.
|
||||
|
||||
const STEP_LABELS = ["Person", "Position", "Vertrag", "Notfallkontakt", "Zusammenfassung"] as const;
|
||||
|
||||
type RehireWizardProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
employee: EmployeeRow;
|
||||
openPositions: OpenPositionResolved[];
|
||||
locations: { id: string; name: string; country: string }[];
|
||||
};
|
||||
|
||||
export function RehireWizard({ open, onClose, employee, openPositions, locations }: RehireWizardProps) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState(0);
|
||||
// Die Akte remountet dieses Bauteil über einen wechselnden `key`, sobald es
|
||||
// frisch geöffnet wird — diese Initialisierung ist damit das Zurücksetzen,
|
||||
// ganz ohne Effekt.
|
||||
const [draft, setDraft] = useState<HireDraftData>(() => ({
|
||||
...EMPTY_HIRE_DRAFT,
|
||||
...vorbelegungAus(employee),
|
||||
// Planstelle und Eintrittsdatum bleiben leer: die alte Stelle kann
|
||||
// besetzt oder entfallen sein, und das neue Datum ist die eigentliche
|
||||
// Angabe dieses Vorgangs. Siehe lib/wiedereintritt.ts.
|
||||
positionId: "",
|
||||
entryDate: "",
|
||||
angehoerige: [],
|
||||
}));
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const selectedPosition = useMemo(
|
||||
() => openPositions.find((p) => p.id === draft.positionId) ?? null,
|
||||
[openPositions, draft.positionId]
|
||||
);
|
||||
|
||||
function update(patch: Partial<HireDraftData>) {
|
||||
setDraft((prev) => ({ ...prev, ...patch }));
|
||||
}
|
||||
|
||||
const svNummerOk =
|
||||
!draft.svNummer.trim() ||
|
||||
!requiresAustrianSvnr(locations.find((l) => l.id === draft.locationId)?.country) ||
|
||||
isValidSvnr(draft.svNummer, draft.birthDate || null);
|
||||
|
||||
// Dieselben Bedingungen wie bei einer Neueinstellung, abzüglich der
|
||||
// Personalnummer: sie steht fest und wird nicht geprüft.
|
||||
const pruefung: Record<(typeof STEP_LABELS)[number], boolean> = {
|
||||
Person: Boolean(draft.firstName && draft.lastName && draft.birthDate && draft.locationId) && svNummerOk,
|
||||
Position: Boolean(draft.positionId && draft.besetzung),
|
||||
Vertrag: Boolean(draft.entryDate && draft.workDays.length > 0),
|
||||
Notfallkontakt: Boolean(draft.emergencyContactName.trim()) === Boolean(draft.emergencyContactPhone.trim()),
|
||||
Zusammenfassung: true,
|
||||
};
|
||||
const stepValid = pruefung[STEP_LABELS[step]];
|
||||
const letzterSchritt = STEP_LABELS.length - 1;
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!draft.positionId || !draft.entryDate) return;
|
||||
// Dieselbe Regel wie im Formular: der Personenkreis zieht das Kennzeichen
|
||||
// mit. Was gezeigt wurde, muss auch abgeschickt werden.
|
||||
const istBehindert = draft.istBeguenstigtBehindert || draft.kuendigungsschutzGrund === GRUND_BEGUENSTIGT_BEHINDERT;
|
||||
setSubmitting(true);
|
||||
|
||||
const result = await rehireEmployee({
|
||||
employee_id: employee.id,
|
||||
rehire_date: draft.entryDate,
|
||||
position_id: draft.positionId,
|
||||
|
||||
first_name: draft.firstName,
|
||||
last_name: draft.lastName,
|
||||
title_prefix: draft.titlePrefix,
|
||||
title_suffix: draft.titleSuffix,
|
||||
gender: draft.gender,
|
||||
birth_date: draft.birthDate,
|
||||
sv_nummer: draft.svNummer.trim(),
|
||||
email: draft.email.trim(),
|
||||
company_email: draft.companyEmail.trim(),
|
||||
cornerstone_id: draft.cornerstoneId.trim(),
|
||||
phone: draft.phone.trim(),
|
||||
location_id: draft.locationId,
|
||||
// Anschrift, Staatsbürgerschaft und Aufenthaltstitel stehen bewusst
|
||||
// nicht hier: der Assistent zeigt sie nicht, und was er nicht zeigt,
|
||||
// darf er nicht schicken. rehire_employee lässt jedes nicht
|
||||
// übermittelte Feld unangetastet stehen.
|
||||
emergency_contact_name: draft.emergencyContactName.trim(),
|
||||
emergency_contact_phone: draft.emergencyContactPhone.trim(),
|
||||
emergency_contact_relation: draft.emergencyContactRelation.trim(),
|
||||
|
||||
employment_type: draft.employmentType,
|
||||
weekly_hours: Number(draft.weeklyHours),
|
||||
contract_type: draft.contractType,
|
||||
contract_end_date: draft.contractEndDate,
|
||||
paygrade: draft.paygrade,
|
||||
source: draft.besetzung,
|
||||
|
||||
worker_type: draft.workerType,
|
||||
mitarbeiterart: draft.mitarbeiterart,
|
||||
collective_agreement: draft.collectiveAgreement,
|
||||
work_days: draft.workDays,
|
||||
is_betriebsrat: draft.isBetriebsrat,
|
||||
has_dienstwagen: draft.hasDienstwagen,
|
||||
dienstwagen_art: draft.hasDienstwagen ? draft.dienstwagenArt : "",
|
||||
is_laterale_fuehrung: draft.isLateraleFuehrung,
|
||||
is_c_level: draft.isCLevel,
|
||||
|
||||
has_kuendigungsschutz: draft.hasKuendigungsschutz,
|
||||
kuendigungsschutz_grund: draft.hasKuendigungsschutz ? draft.kuendigungsschutzGrund : "",
|
||||
kuendigungsschutz_ab: draft.hasKuendigungsschutz ? draft.kuendigungsschutzAb : "",
|
||||
kuendigungsschutz_bis: draft.hasKuendigungsschutz ? draft.kuendigungsschutzBis : "",
|
||||
ist_beguenstigt_behindert: istBehindert,
|
||||
behinderung_grad: istBehindert ? draft.behinderungGrad : "",
|
||||
behinderung_ab: istBehindert ? draft.behinderungAb : "",
|
||||
behinderung_bis: istBehindert ? draft.behinderungBis : "",
|
||||
});
|
||||
|
||||
setSubmitting(false);
|
||||
if (!result.success) {
|
||||
showToast(result.error ?? "Fehler beim Speichern.", "error");
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
onClose();
|
||||
showToast(`${fmtName(draft.firstName, draft.lastName)} wurde wiedereingestellt.`);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Wiedereintritt"
|
||||
widthClassName="max-w-2xl"
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
{step > 0 && (
|
||||
<Button variant="secondary" onClick={() => setStep((s) => s - 1)}>
|
||||
Zurück
|
||||
</Button>
|
||||
)}
|
||||
{step < letzterSchritt && (
|
||||
<Button onClick={() => setStep((s) => s + 1)} disabled={!stepValid}>
|
||||
Weiter
|
||||
</Button>
|
||||
)}
|
||||
{step === letzterSchritt && (
|
||||
<Button onClick={handleSubmit} pending={submitting}>
|
||||
Wiedereinstellen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p className="mb-4 rounded bg-surface px-3 py-2 text-sm text-ink-body">
|
||||
Die Angaben stammen aus der bestehenden Akte und lassen sich hier ändern. Planstelle und Eintrittsdatum sind neu
|
||||
zu wählen; Angehörige stehen bereits in der Akte.
|
||||
</p>
|
||||
|
||||
<div className="mb-6 flex items-center justify-center gap-3">
|
||||
{STEP_LABELS.map((label, i) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
disabled={i >= step}
|
||||
aria-current={i === step ? "step" : undefined}
|
||||
onClick={() => i < step && setStep(i)}
|
||||
className={`flex items-center gap-2 rounded text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 disabled:cursor-default ${
|
||||
i === step ? "text-brand-700" : i < step ? "text-ink-body" : "text-ink-muted"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`flex h-6 w-6 items-center justify-center rounded-full ${i <= step ? "bg-brand-500 text-white" : "bg-surface text-ink-muted"}`}
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{step === 0 && <StepPerson draft={draft} update={update} locations={locations} nummerGesperrt />}
|
||||
{step === 1 && <StepPosition draft={draft} update={update} openPositions={openPositions} />}
|
||||
{step === 2 && <StepVertrag draft={draft} update={update} />}
|
||||
{step === 3 && <StepNotfallkontakt draft={draft} update={update} />}
|
||||
{step === 4 && <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/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/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>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,44 @@
|
||||
import { SvNummerField } from "@/components/employees/SvNummerField";
|
||||
import { TitleFields } from "@/components/employees/TitleFields";
|
||||
import { SelectField, TextField } from "@/components/ui/Field";
|
||||
import { PersonalnummerField } from "./PersonalnummerField";
|
||||
import type { HireDraftData } from "./types";
|
||||
|
||||
type StepPersonProps = {
|
||||
draft: HireDraftData;
|
||||
update: (patch: Partial<HireDraftData>) => void;
|
||||
locations: { id: string; name: string; country: string }[];
|
||||
/** Meldet die geprüfte Personalnummer und ihr Ergebnis — steuert „Weiter". */
|
||||
onNummerBefund?: (befund: { nummer: number; vergeben: boolean }) => void;
|
||||
/**
|
||||
* Beim Wiedereintritt: die Nummer steht fest und wird nur gezeigt.
|
||||
*
|
||||
* Zwei Gründe. Es ist dieselbe Person und sie behält ihre Nummer — und die
|
||||
* Prüfung auf Dubletten fände hier zwangsläufig einen Treffer, nämlich sie
|
||||
* selbst, und sperrte das Formular mit einer Meldung, die stimmt und
|
||||
* trotzdem in die Irre führt.
|
||||
*/
|
||||
nummerGesperrt?: boolean;
|
||||
};
|
||||
|
||||
export function StepPerson({ draft, update, locations }: StepPersonProps) {
|
||||
export function StepPerson({ draft, update, locations, onNummerBefund, nummerGesperrt }: StepPersonProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{nummerGesperrt ? (
|
||||
<TextField
|
||||
label="Personalnummer"
|
||||
value={draft.personnelNumber}
|
||||
onChange={() => {}}
|
||||
disabled
|
||||
hint="Bleibt bei einem Wiedereintritt dieselbe."
|
||||
/>
|
||||
) : (
|
||||
<PersonalnummerField
|
||||
wert={draft.personnelNumber}
|
||||
onChange={(personnelNumber) => update({ personnelNumber })}
|
||||
onBefund={onNummerBefund ?? (() => {})}
|
||||
/>
|
||||
)}
|
||||
<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 +64,23 @@ 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="Firmen-E-Mail"
|
||||
type="email"
|
||||
value={draft.companyEmail}
|
||||
onChange={(companyEmail) => update({ companyEmail })}
|
||||
/>
|
||||
<TextField
|
||||
label="Cornerstone-ID"
|
||||
value={draft.cornerstoneId}
|
||||
onChange={(cornerstoneId) => update({ cornerstoneId })}
|
||||
hint="Freiwillig. Füllt im Cornerstone-Export „User ID“ und „Username“."
|
||||
/>
|
||||
<TextField label="Private Telefonnummer" type="tel" value={draft.phone} onChange={(phone) => update({ phone })} />
|
||||
</div>
|
||||
<SelectField
|
||||
label="Standort"
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { dienstwagenLabel } from "@/lib/dienstwagen";
|
||||
import { fmtDate, fmtFullName } from "@/lib/format";
|
||||
import { hayGradeLabel } from "@/lib/hay-grade";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import type { HireDraftData } from "./types";
|
||||
|
||||
const PAYGRADE_LABELS: Record<string, string> = {
|
||||
A: "A – Einstieg",
|
||||
B: "B – Qualifiziert",
|
||||
C: "C – Erfahren",
|
||||
D: "D – Spezialist:in",
|
||||
E: "E – Teamleitung",
|
||||
F: "F – Bereichsleitung / GF",
|
||||
};
|
||||
|
||||
type StepSummaryProps = {
|
||||
draft: HireDraftData;
|
||||
selectedPosition: OpenPositionResolved | null;
|
||||
@@ -21,17 +14,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 ?? "–"],
|
||||
@@ -40,8 +41,8 @@ export function StepSummary({ draft, selectedPosition, locations }: StepSummaryP
|
||||
["Eintrittsdatum", fmtDate(draft.entryDate)],
|
||||
["Vertragsart", draft.contractType === "befristet" ? `befristet bis ${fmtDate(draft.contractEndDate)}` : "unbefristet"],
|
||||
["Beschäftigungsausmaß", `${draft.employmentType} (${draft.weeklyHours} h)`],
|
||||
["Paygrade", PAYGRADE_LABELS[draft.paygrade]],
|
||||
["Angestellte:r / Arbeiter:in", draft.workerType],
|
||||
["Hay-Grade", hayGradeLabel(draft.paygrade)],
|
||||
["Beschäftigtengruppe", draft.workerType],
|
||||
["Kollektivvertrag", draft.collectiveAgreement],
|
||||
["Arbeitstage", draft.workDays.join(", ") || "–"],
|
||||
["Merkmale", flags.length > 0 ? flags.join(", ") : "–"],
|
||||
@@ -57,9 +58,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,17 +1,9 @@
|
||||
import { RoleEmploymentFields } from "@/components/employees/RoleEmploymentFields";
|
||||
import { SelectField, TextField } from "@/components/ui/Field";
|
||||
import type { PaygradeType } from "@/lib/supabase/types";
|
||||
import { HAY_GRADES } from "@/lib/hay-grade";
|
||||
import type { PaygradeType } from "@/lib/types";
|
||||
import type { HireDraftData } from "./types";
|
||||
|
||||
const PAYGRADES: { value: PaygradeType; label: string; description: string }[] = [
|
||||
{ value: "A", label: "A – Einstieg", description: "Berufseinsteiger:innen ohne einschlägige Erfahrung" },
|
||||
{ value: "B", label: "B – Qualifiziert", description: "Fachkräfte mit abgeschlossener Ausbildung" },
|
||||
{ value: "C", label: "C – Erfahren", description: "Mehrjährige einschlägige Berufserfahrung" },
|
||||
{ value: "D", label: "D – Spezialist:in", description: "Vertiefte Fachexpertise" },
|
||||
{ value: "E", label: "E – Teamleitung", description: "Fachliche und disziplinäre Führung eines Teams" },
|
||||
{ value: "F", label: "F – Bereichsleitung / GF", description: "Führung eines Bereichs bzw. Geschäftsführung" },
|
||||
];
|
||||
|
||||
export function StepVertrag({ draft, update }: { draft: HireDraftData; update: (patch: Partial<HireDraftData>) => void }) {
|
||||
function handleEmploymentTypeChange(value: HireDraftData["employmentType"]) {
|
||||
update({
|
||||
@@ -63,12 +55,11 @@ export function StepVertrag({ draft, update }: { draft: HireDraftData; update: (
|
||||
/>
|
||||
</div>
|
||||
<SelectField
|
||||
label="Paygrade"
|
||||
label="Hay-Grade"
|
||||
required
|
||||
value={draft.paygrade}
|
||||
onChange={(v) => update({ paygrade: v as PaygradeType })}
|
||||
options={PAYGRADES}
|
||||
hint={PAYGRADES.find((p) => p.value === draft.paygrade)?.description}
|
||||
options={HAY_GRADES}
|
||||
/>
|
||||
<p className="text-xs text-ink-muted">Es gilt eine Probezeit von 1 Monat gemäß Kollektivvertrag.</p>
|
||||
|
||||
|
||||
@@ -1,9 +1,32 @@
|
||||
import type { CollectiveAgreement, ContractType, EmploymentType, GenderType, PaygradeType, Weekday, WorkerType } from "@/lib/supabase/types";
|
||||
import { HAY_GRADE_STANDARD } from "@/lib/hay-grade";
|
||||
import { MITARBEITERART_STANDARD } from "@/lib/mitarbeiterart";
|
||||
import type { CollectiveAgreement, ContractType, DienstwagenArt, EmploymentType, GenderType, Mitarbeiterart, PaygradeType, RelationshipType, Weekday, WorkerType } from "@/lib/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[];
|
||||
@@ -12,6 +35,9 @@ export type HireDraftData = {
|
||||
birthDate: string;
|
||||
svNummer: string;
|
||||
email: string;
|
||||
companyEmail: string;
|
||||
/** Kennung im Lernsystem Cornerstone. Freiwillig. */
|
||||
cornerstoneId: string;
|
||||
phone: string;
|
||||
locationId: string;
|
||||
positionId: string;
|
||||
@@ -27,11 +53,31 @@ 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;
|
||||
/** Form der Beschäftigung — zweite Achse neben der Beschäftigtengruppe. */
|
||||
mitarbeiterart: Mitarbeiterart;
|
||||
hasKuendigungsschutz: boolean;
|
||||
/** Freiwillig — leer heisst „bis auf Weiteres". */
|
||||
kuendigungsschutzBis: string;
|
||||
/** Personenkreis nach dem besonderen Kündigungsschutz; leer heisst „nicht erfasst". */
|
||||
kuendigungsschutzGrund: string;
|
||||
kuendigungsschutzAb: string;
|
||||
istBeguenstigtBehindert: boolean;
|
||||
/** Grad in Prozent als Text — ein leeres Zahlenfeld wäre sonst 0. */
|
||||
behinderungGrad: string;
|
||||
behinderungAb: string;
|
||||
behinderungBis: string;
|
||||
};
|
||||
|
||||
export const EMPTY_HIRE_DRAFT: HireDraftData = {
|
||||
personnelNumber: "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
titlePrefix: [],
|
||||
@@ -40,6 +86,8 @@ export const EMPTY_HIRE_DRAFT: HireDraftData = {
|
||||
birthDate: "",
|
||||
svNummer: "",
|
||||
email: "",
|
||||
companyEmail: "",
|
||||
cornerstoneId: "",
|
||||
phone: "",
|
||||
locationId: "",
|
||||
positionId: "",
|
||||
@@ -49,12 +97,26 @@ export const EMPTY_HIRE_DRAFT: HireDraftData = {
|
||||
contractEndDate: "",
|
||||
employmentType: "Vollzeit",
|
||||
weeklyHours: "38.5",
|
||||
paygrade: "B",
|
||||
paygrade: HAY_GRADE_STANDARD,
|
||||
workerType: "Angestellte:r",
|
||||
collectiveAgreement: "Handel",
|
||||
workDays: ["Mo", "Di", "Mi", "Do", "Fr"],
|
||||
isBetriebsrat: false,
|
||||
hasDienstwagen: false,
|
||||
dienstwagenArt: "Verbrenner",
|
||||
emergencyContactName: "",
|
||||
emergencyContactPhone: "",
|
||||
emergencyContactRelation: "",
|
||||
angehoerige: [],
|
||||
isLateraleFuehrung: false,
|
||||
isCLevel: false,
|
||||
mitarbeiterart: MITARBEITERART_STANDARD,
|
||||
hasKuendigungsschutz: false,
|
||||
kuendigungsschutzBis: "",
|
||||
kuendigungsschutzGrund: "",
|
||||
kuendigungsschutzAb: "",
|
||||
istBeguenstigtBehindert: false,
|
||||
behinderungGrad: "",
|
||||
behinderungAb: "",
|
||||
behinderungBis: "",
|
||||
};
|
||||
|
||||
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 overflow-y-hidden ${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>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { CalendarClock } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { FILTER_SELECT_CLASS } from "@/components/ui/Field";
|
||||
import { FILTER_SELECT_CLASS, istMeldbaresDatum } from "@/components/ui/Field";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
|
||||
type AsOfPickerProps = {
|
||||
@@ -27,6 +28,14 @@ export function AsOfPicker({ asOf, today, projectedCount, historyStartsAt }: AsO
|
||||
router.push(sp.size > 0 ? `${pathname}?${sp}` : pathname, { scroll: false });
|
||||
}
|
||||
|
||||
// Angeglichen während des Renderns, nicht in einem Effekt — siehe DateField.
|
||||
const [entwurf, setEntwurf] = useState(asOf);
|
||||
const [zuletzt, setZuletzt] = useState(asOf);
|
||||
if (asOf !== zuletzt) {
|
||||
setZuletzt(asOf);
|
||||
setEntwurf(asOf);
|
||||
}
|
||||
|
||||
const isToday = asOf === today;
|
||||
const isFuture = asOf > today;
|
||||
// Assignments only started being recorded when the history table was
|
||||
@@ -41,11 +50,23 @@ export function AsOfPicker({ asOf, today, projectedCount, historyStartsAt }: AsO
|
||||
<CalendarClock className="h-4 w-4 text-ink-muted" />
|
||||
Stichtag
|
||||
</label>
|
||||
{/* Der Tippstand bleibt hier, gemeldet wird nur ein vollständiges
|
||||
Datum: jede Änderung ist eine Navigation, und beim Tippen der
|
||||
Jahreszahl entstehen unterwegs 0002, 0020 und 0202 — jede davon
|
||||
lud die Seite neu und setzte das Feld mitten im Tippen zurück.
|
||||
Dieselbe Regel wie in DateField, dort steht auch der lange Grund. */}
|
||||
<input
|
||||
id="orgchart-asof"
|
||||
type="date"
|
||||
value={asOf}
|
||||
onChange={(e) => setAsOf(e.target.value || undefined)}
|
||||
value={entwurf}
|
||||
onChange={(e) => {
|
||||
const wert = e.target.value;
|
||||
setEntwurf(wert);
|
||||
if (istMeldbaresDatum(wert) && wert !== asOf) setAsOf(wert || undefined);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (entwurf !== asOf && istMeldbaresDatum(entwurf)) setAsOf(entwurf || undefined);
|
||||
}}
|
||||
className={FILTER_SELECT_CLASS}
|
||||
/>
|
||||
{!isToday && (
|
||||
|
||||
126
components/orgchart/CreateOrgUnitModal.tsx
Normal file
126
components/orgchart/CreateOrgUnitModal.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { createOrgUnit } from "@/actions/org";
|
||||
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 { todayIso } from "@/lib/format";
|
||||
import { naechsteOrgnummer } from "@/lib/org-nummer";
|
||||
import type { OrgUnitNode } from "./types";
|
||||
|
||||
type Art = "Bereich" | "Abteilung" | "Team";
|
||||
|
||||
// „Gesellschaft“ fehlt in der Auswahl mit Absicht: es gibt genau eine, sie ist
|
||||
// die Wurzel, und unter einer anderen Einheit wäre sie ein Etikett, das seiner
|
||||
// Stelle im Baum widerspricht. Die Datenbankfunktion weist es ohnehin ab — die
|
||||
// fehlende Zeile erspart den Weg dorthin.
|
||||
const ARTEN: { value: Art; label: string }[] = [
|
||||
{ value: "Bereich", label: "Bereich" },
|
||||
{ value: "Abteilung", label: "Abteilung" },
|
||||
{ value: "Team", label: "Team" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Die Art, die unter einer Einheit am wahrscheinlichsten ist: eine Stufe
|
||||
* feiner als die darüber. Nur eine Vorbelegung — die Tiefe des Baums ist frei,
|
||||
* und eine Abteilung unter einer Abteilung ist erlaubt.
|
||||
*/
|
||||
function vorschlagArt(eltern: OrgUnitNode["unit_type"]): Art {
|
||||
if (eltern === "Gesellschaft") return "Bereich";
|
||||
if (eltern === "Bereich") return "Abteilung";
|
||||
return "Team";
|
||||
}
|
||||
|
||||
export function CreateOrgUnitModal({
|
||||
parent,
|
||||
units,
|
||||
onClose,
|
||||
}: {
|
||||
parent: OrgUnitNode;
|
||||
units: OrgUnitNode[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [orgNumber, setOrgNumber] = useState(() => naechsteOrgnummer(units.map((u) => u.org_number)) ?? "");
|
||||
const [name, setName] = useState("");
|
||||
const [art, setArt] = useState<Art>(() => vorschlagArt(parent.unit_type));
|
||||
// **Nicht** der Stichtag der Ansicht. Wer sich das Organigramm zum letzten
|
||||
// Jahresende ansieht und hier auf Plus drückt, will in aller Regel eine
|
||||
// Einheit von heute anlegen und keine rückdatierte. Ein stillschweigend
|
||||
// übernommenes Datum aus der Ansicht wäre die Art von Vorbelegung, die
|
||||
// niemand liest und die hinterher niemand erklären kann.
|
||||
const [validFrom, setValidFrom] = useState(todayIso);
|
||||
const [leitung, setLeitung] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!orgNumber.trim() || !name.trim() || !validFrom) {
|
||||
showToast("Orgnummer, Bezeichnung und Gültigkeitsbeginn sind Pflicht.", "error");
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
const result = await createOrgUnit({
|
||||
parent_id: parent.id,
|
||||
org_number: orgNumber.trim(),
|
||||
name: name.trim(),
|
||||
unit_type: art,
|
||||
valid_from: validFrom,
|
||||
leitung_taetigkeit: leitung.trim() || undefined,
|
||||
});
|
||||
setPending(false);
|
||||
if (result.success) {
|
||||
showToast(leitung.trim() ? "Einheit samt Leitungsplanstelle angelegt." : "Einheit angelegt.");
|
||||
router.refresh();
|
||||
onClose();
|
||||
} else {
|
||||
showToast(result.error ?? "Fehler beim Anlegen.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
title="Einheit anlegen"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} pending={pending}>
|
||||
Anlegen
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="rounded-md bg-surface px-3 py-2 text-xs leading-relaxed text-ink-muted">
|
||||
Unter <span className="font-semibold text-ink-body">{parent.org_number} · {parent.name}</span>.
|
||||
Bestehende Einheiten lassen sich nicht hierher verschieben — die neue entsteht leer.
|
||||
</p>
|
||||
|
||||
<TextField
|
||||
label="Orgnummer"
|
||||
required
|
||||
value={orgNumber}
|
||||
onChange={setOrgNumber}
|
||||
hint="Vorgeschlagen wird die nächste freie Nummer der bestehenden Reihe. Sie muss zum führenden System passen."
|
||||
/>
|
||||
<TextField label="Bezeichnung" required value={name} onChange={setName} />
|
||||
<SelectField label="Art" required value={art} onChange={(v) => setArt(v as Art)} options={ARTEN} />
|
||||
<TextField label="Gültig ab" required type="date" value={validFrom} onChange={setValidFrom} />
|
||||
|
||||
<TextField
|
||||
label="Leitungsplanstelle (optional)"
|
||||
value={leitung}
|
||||
onChange={setLeitung}
|
||||
hint="Tätigkeit der Leitung, z. B. „Abteilungsleitung Einkauf“. Leer lassen, dann entsteht die Einheit ohne Leitung und ist im Organigramm als vakant zu sehen."
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
@@ -146,7 +156,7 @@ export function EmployeeTree({ employees, focusId = null }: { employees: OrgEmpl
|
||||
<div key={e.id}>
|
||||
<div
|
||||
ref={e.id === focusId ? focusRef : undefined}
|
||||
className={`flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface ${isMatch ? "bg-brand-100" : ""}`}
|
||||
className={`flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface ${isMatch ? "bg-accent-200" : ""}`}
|
||||
style={{ paddingLeft: depth * 24 + 8 }}
|
||||
>
|
||||
{hasChildren ? (
|
||||
@@ -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>
|
||||
|
||||
@@ -23,11 +23,15 @@ import type { ChartNode, ChartNodeKind } from "./types";
|
||||
// object as a change and remounts every custom node otherwise.
|
||||
const NODE_TYPES = { orgNode: OrgChartNode };
|
||||
|
||||
// React Flow nimmt echte Farbwerte, keine Klassen — deshalb stehen die
|
||||
// Token hier als Literal. Sie folgen KIND_ACCENT in OrgChartNode; laeuft das
|
||||
// eine dem anderen davon, zeigt die Uebersichtskarte andere Farben als die
|
||||
// Karten, ueber die sie liegt.
|
||||
const MINIMAP_COLORS: Record<ChartNodeKind, string> = {
|
||||
person: "#d6046e",
|
||||
role: "#5c2e91",
|
||||
group: "#c9b3c0",
|
||||
vacancy: "#f6cfe2",
|
||||
person: "#164194", // brand-500, Manner Blau
|
||||
role: "#54277f", // purple-text
|
||||
group: "#756c6e", // ink-muted
|
||||
vacancy: "#f69686", // accent-500, Manner Rosa
|
||||
};
|
||||
|
||||
export type GraphOrgChartProps = {
|
||||
@@ -35,6 +39,10 @@ export type GraphOrgChartProps = {
|
||||
isExpanded: (id: string) => boolean;
|
||||
onToggle: (id: string) => void;
|
||||
matchedIds?: Set<string> | null;
|
||||
/** Nur die Struktursicht reicht sie herein; in der Mitarbeitersicht gibt es keine Einheiten. */
|
||||
onAddUnit?: (unitId: string) => void;
|
||||
onDeleteUnit?: (unitId: string) => void;
|
||||
onSelectUnit?: (unitId: string) => void;
|
||||
};
|
||||
|
||||
export function GraphOrgChart(props: GraphOrgChartProps) {
|
||||
@@ -45,7 +53,15 @@ export function GraphOrgChart(props: GraphOrgChartProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds }: GraphOrgChartProps) {
|
||||
function GraphOrgChartInner({
|
||||
tree,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
matchedIds,
|
||||
onAddUnit,
|
||||
onDeleteUnit,
|
||||
onSelectUnit,
|
||||
}: GraphOrgChartProps) {
|
||||
const { visibleNodes, visibleEdges } = useMemo(() => collectVisible(tree, isExpanded), [tree, isExpanded]);
|
||||
|
||||
const { rfNodes, rfEdges } = useMemo(() => {
|
||||
@@ -61,6 +77,9 @@ function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds }: GraphOrg
|
||||
hasChildren: n.children.length > 0,
|
||||
childCount: n.children.length,
|
||||
onToggle,
|
||||
onAddUnit,
|
||||
onDeleteUnit,
|
||||
onSelectUnit,
|
||||
},
|
||||
}));
|
||||
const rfEdges: Edge[] = visibleEdges.map((e) => ({
|
||||
@@ -72,7 +91,7 @@ function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds }: GraphOrg
|
||||
style: { stroke: "#e3cddb", strokeWidth: 1.5 },
|
||||
}));
|
||||
return { rfNodes, rfEdges };
|
||||
}, [visibleNodes, visibleEdges, isExpanded, onToggle]);
|
||||
}, [visibleNodes, visibleEdges, isExpanded, onToggle, onAddUnit, onDeleteUnit, onSelectUnit]);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(rfNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(rfEdges);
|
||||
@@ -118,7 +137,7 @@ function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds }: GraphOrg
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={22} size={1.4} color="#eedde6" />
|
||||
<Background variant={BackgroundVariant.Dots} gap={22} size={1.4} color="#f0dbd4" />
|
||||
<Controls showInteractive={false} />
|
||||
{/* Hidden under lg via CSS: on a phone it would cover a real
|
||||
fraction of the canvas for little navigational benefit. */}
|
||||
@@ -126,8 +145,8 @@ function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds }: GraphOrg
|
||||
pannable
|
||||
zoomable
|
||||
ariaLabel="Übersichtskarte"
|
||||
maskColor="rgba(249, 241, 245, 0.75)"
|
||||
nodeColor={(n) => MINIMAP_COLORS[(n.data as OrgChartNodeDataLike).chartNode.kind] ?? "#d6046e"}
|
||||
maskColor="rgba(253, 246, 244, 0.75)"
|
||||
nodeColor={(n) => MINIMAP_COLORS[(n.data as OrgChartNodeDataLike).chartNode.kind] ?? "#164194"}
|
||||
nodeStrokeWidth={0}
|
||||
nodeBorderRadius={3}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
||||
import { Building2, ChevronDown, Plus, UserRound } from "lucide-react";
|
||||
import { Building2, ChevronDown, Plus, Trash2, UserRound } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { memo } from "react";
|
||||
import { Avatar } from "@/components/ui/Avatar";
|
||||
@@ -14,6 +14,11 @@ export type OrgChartNodeData = {
|
||||
hasChildren: boolean;
|
||||
childCount: number;
|
||||
onToggle: (id: string) => void;
|
||||
/** Nur in der Struktursicht gesetzt — ohne sie bleibt die Karte unverändert. */
|
||||
onAddUnit?: (unitId: string) => void;
|
||||
onDeleteUnit?: (unitId: string) => void;
|
||||
/** Klick auf die Karte einer Einheit — öffnet ihre Angaben. */
|
||||
onSelectUnit?: (unitId: string) => void;
|
||||
};
|
||||
|
||||
export type OrgChartRFNode = Node<OrgChartNodeData, "orgNode">;
|
||||
@@ -25,21 +30,25 @@ const KIND_ACCENT: Record<ChartNodeKind, string> = {
|
||||
person: "bg-brand-500",
|
||||
role: "bg-purple-text",
|
||||
group: "bg-ink-muted",
|
||||
vacancy: "bg-brand-200",
|
||||
// Rosa statt einer blassen Blaustufe: eine offene Stelle ist kein
|
||||
// schwaecheres Abbild einer Person, sondern etwas anderes. Gegen das
|
||||
// Blau der Person ist der Unterschied auch dann noch zu sehen, wenn
|
||||
// herausgezoomt nur noch die Streifen uebrig sind.
|
||||
vacancy: "bg-accent-500",
|
||||
};
|
||||
|
||||
const KIND_SHELL: Record<ChartNodeKind, string> = {
|
||||
person: "border-border bg-white",
|
||||
role: "border-border bg-white",
|
||||
group: "border-border-subtle bg-surface",
|
||||
vacancy: "border-dashed border-brand-200 bg-brand-50",
|
||||
vacancy: "border-dashed border-accent-500 bg-accent-50",
|
||||
};
|
||||
|
||||
// React Flow re-renders node components on every pan/zoom frame — memo is
|
||||
// required, not just tidy, to keep that smooth at a few hundred nodes.
|
||||
export const OrgChartNode = memo(function OrgChartNode({ id, data }: NodeProps<OrgChartRFNode>) {
|
||||
const { chartNode, expanded, hasChildren, childCount, onToggle } = data;
|
||||
const { kind, label, sublabel, avatar, href, vacant, totalReports, absent, coveredBy, coveringFor } = chartNode;
|
||||
const { chartNode, expanded, hasChildren, childCount, onToggle, onAddUnit, onDeleteUnit, onSelectUnit } = data;
|
||||
const { kind, label, sublabel, avatar, href, vacant, totalReports, absent, coveredBy, coveringFor, unitId } = chartNode;
|
||||
const isMatch = chartNode.matched ?? false;
|
||||
const { width, height } = NODE_DIMENSIONS[kind];
|
||||
|
||||
@@ -48,7 +57,7 @@ export const OrgChartNode = memo(function OrgChartNode({ id, data }: NodeProps<O
|
||||
{kind === "person" && avatar ? (
|
||||
<Avatar firstName={avatar.firstName} lastName={avatar.lastName} size="sm" />
|
||||
) : kind === "vacancy" ? (
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-dashed border-brand-200 text-brand-500">
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-dashed border-accent-500 text-accent-700">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
) : kind === "role" ? (
|
||||
@@ -103,6 +112,50 @@ export const OrgChartNode = memo(function OrgChartNode({ id, data }: NodeProps<O
|
||||
>
|
||||
<Handle type="target" position={Position.Top} isConnectable={false} className="!invisible" />
|
||||
|
||||
{/* Erscheint erst beim Überfahren der Karte: auf achtzig Einheiten wären
|
||||
achtzig ständig sichtbare Pluszeichen ein Muster und kein Angebot.
|
||||
Auf Geräten ohne Zeigegerät bleibt er stehen — dort gibt es kein
|
||||
Überfahren, und unsichtbar hiesse dann unerreichbar. */}
|
||||
{unitId && (onAddUnit || onDeleteUnit) && (
|
||||
<span
|
||||
className="nodrag nopan absolute -right-2.5 -top-2.5 z-10 flex items-center gap-1
|
||||
opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100
|
||||
[@media(hover:none)]:opacity-100"
|
||||
>
|
||||
{/* Der Papierkorb nur am Blatt. Eine Einheit mit Kindern lässt sich
|
||||
ohnehin nicht entfernen — der Knopf wäre dort ein Angebot, das
|
||||
beim Klick eine Absage erteilt, und das ist schlechter als kein
|
||||
Knopf. Die Datenbank prüft es trotzdem: sie sieht auch
|
||||
geschlossene Planstellen, die hier gar nicht gezeichnet sind. */}
|
||||
{onDeleteUnit && !hasChildren && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteUnit(unitId)}
|
||||
aria-label={`Einheit ${label} entfernen`}
|
||||
title="Einheit entfernen"
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full border border-border bg-white text-ink-muted shadow-sm
|
||||
transition-colors hover:border-danger-solid hover:bg-danger-solid hover:text-white
|
||||
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
{onAddUnit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAddUnit(unitId)}
|
||||
aria-label={`Einheit unter ${label} anlegen`}
|
||||
title="Untergeordnete Einheit anlegen"
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full border border-border bg-white text-ink-muted shadow-sm
|
||||
transition-colors hover:border-brand-500 hover:bg-brand-500 hover:text-white
|
||||
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Accent stripe, inset so it follows the card's rounded corner. */}
|
||||
<span className={`absolute inset-y-1.5 left-0 w-1 rounded-r ${KIND_ACCENT[kind]}`} />
|
||||
|
||||
@@ -110,6 +163,18 @@ export const OrgChartNode = memo(function OrgChartNode({ id, data }: NodeProps<O
|
||||
<Link href={href} className="nodrag nopan flex min-w-0 flex-1 items-center gap-2.5 py-2 pl-3.5 pr-3">
|
||||
{content}
|
||||
</Link>
|
||||
) : unitId && onSelectUnit ? (
|
||||
// Die ganze Karte, nicht ein Symbol darin: bei achtzig Einheiten ist
|
||||
// die Fläche das Ziel, das man trifft, ohne hinzusehen.
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectUnit(unitId)}
|
||||
aria-label={`Angaben zu ${label}`}
|
||||
className="nodrag nopan flex min-w-0 flex-1 items-center gap-2.5 py-2 pl-3.5 pr-3 text-left
|
||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2.5 py-2 pl-3.5 pr-3">{content}</div>
|
||||
)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user