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
|
*.tsbuildinfo
|
||||||
.scratch_*
|
.scratch_*
|
||||||
.scratch_shots
|
.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
|
# ── Datenbank ────────────────────────────────────────────────────────
|
||||||
# build time). Anon-key access is still fully gated by RLS server-side.
|
#
|
||||||
NEXT_PUBLIC_SUPABASE_URL=
|
# Die Datenbank läuft als eigener Container (Dienst `db` in
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
# 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
|
# Womit die Anwendung sich verbindet. Im Compose-Netz heisst die Datenbank
|
||||||
# NEXT_PUBLIC_, never import outside lib/supabase/admin.ts (guarded by
|
# `db`; von aussen ist sie nicht erreichbar, es gibt bewusst keinen Port.
|
||||||
# `import "server-only"`), never log or return in an API response.
|
#
|
||||||
SUPABASE_SERVICE_ROLE_KEY=
|
# 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
|
# Der **direkte** Zugang für Migrationen (scripts/migrate.mjs). Als Verwalter,
|
||||||
# calls /api/cron/apply-pending-changes (set the same value in the Vercel
|
# weil Migrationen Schemaänderungen vornehmen, die die Anwendungsrolle nicht
|
||||||
# project's env vars). Generate with e.g. `openssl rand -hex 32`.
|
# 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=
|
CRON_SECRET=
|
||||||
|
|||||||
174
.github/workflows/ci.yml
vendored
174
.github/workflows/ci.yml
vendored
@@ -29,25 +29,45 @@ jobs:
|
|||||||
- name: Typecheck
|
- name: Typecheck
|
||||||
run: npm run typecheck
|
run: npm run typecheck
|
||||||
|
|
||||||
# Catches the drift that `tsc` cannot: lib/supabase/types.ts is
|
# Fängt die Abweichung, die `tsc` nicht sehen kann: lib/types.ts wird von
|
||||||
# hand-written, so a migration adding a column leaves it silently stale.
|
# Hand gepflegt, eine Migration mit einer neuen Spalte lässt sie also
|
||||||
|
# stillschweigend veralten.
|
||||||
- name: Schema/Typen-Abgleich
|
- name: Schema/Typen-Abgleich
|
||||||
run: npm run types:check
|
run: npm run types:check
|
||||||
|
|
||||||
- name: Unit- und Komponententests
|
- name: Unit- und Komponententests
|
||||||
run: npm test
|
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
|
- name: Build
|
||||||
run: npm run 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:
|
migrationen:
|
||||||
name: Integrationstests (echtes Postgres)
|
name: Migrationen auf leerer Datenbank
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -58,33 +78,117 @@ jobs:
|
|||||||
|
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
|
||||||
- uses: supabase/setup-cli@v1
|
# Das Abbild des Runners bringt keinen Postgres-Client mit; der
|
||||||
with:
|
# Schritt darunter ruft aber psql. Ohne das bricht er mit
|
||||||
version: latest
|
# "psql: command not found" ab, bevor die erste Datei laeuft.
|
||||||
|
- name: psql bereitstellen
|
||||||
# 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
|
|
||||||
run: |
|
run: |
|
||||||
supabase status -o env \
|
apt-get update -qq
|
||||||
--override-name api.url=NEXT_PUBLIC_SUPABASE_URL \
|
apt-get install -y -qq --no-install-recommends postgresql-client
|
||||||
--override-name auth.anon_key=NEXT_PUBLIC_SUPABASE_ANON_KEY \
|
psql --version
|
||||||
--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
|
|
||||||
|
|
||||||
- name: Seed
|
# Was das Schema von der Umgebung erwartet: die Anwendungsrolle ohne
|
||||||
run: node --env-file=.env.test.local supabase/seed.ts
|
# 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
|
# Der eigentliche Zweck dieses Jobs: jede Migration muss sich auf einer
|
||||||
run: npm run test:integration
|
# 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
|
# Zweiter Lauf: die Buchführung muss greifen. Meldet er etwas anderes als
|
||||||
if: failure()
|
# „nichts anzuwenden", verbucht der Läufer nicht richtig — und ein
|
||||||
run: supabase status && docker ps -a
|
# 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*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
# vercel
|
|
||||||
.vercel
|
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
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
|
# Kladden und Bildschirmfotos aus der Handprüfung — können Personendaten
|
||||||
# hand-written lib/supabase/types.ts — never itself imported
|
# oder Zugangsdaten enthalten; nie committen.
|
||||||
/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)
|
|
||||||
.scratch_*
|
.scratch_*
|
||||||
/.scratch_shots/
|
/.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
|
Die Anwendung läuft als Docker-Container auf einem eigenen Linux-Server,
|
||||||
`vercel.json`) stattdessen als Docker-Container auf einem beliebigen Server
|
zusammen mit ihrer Datenbank. Das Abbild ist umgebungsneutral — es gibt
|
||||||
läuft.
|
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
|
## Was wird containerisiert – und was nicht
|
||||||
|
|
||||||
- **Containerisiert:** nur die Next.js-App selbst (`Dockerfile`).
|
- **Containerisiert:** die Next.js-App (`Dockerfile`) **und die Datenbank**
|
||||||
- **Nicht containerisiert:** Supabase (Datenbank + Auth). Die App verbindet
|
(Dienst `db`, `postgres:17-alpine`). Beide zusammen in
|
||||||
sich per URL/Key zu einem bestehenden Supabase-Projekt (Cloud oder
|
`docker-compose.yml`; die Daten liegen im benannten Volume `db-daten`.
|
||||||
selbst gehostet) – das bleibt unverändert. `supabase/` in diesem Repo ist
|
- **Die Datenbank gehört zum Projekt.** Was eine gehostete Plattform sonst
|
||||||
nur die lokale Dev-/Migrations-Umgebung (`supabase start`), kein Teil des
|
beisteuert, bringt `deploy/db-init/` mit: die Anwendungsrolle ohne
|
||||||
Deployments.
|
BYPASSRLS, die zwei benutzten Erweiterungen und eine Attrappe des
|
||||||
- **Ersetzt:** der Vercel-Cron-Job aus `vercel.json` (täglich 03:00 Uhr,
|
`auth`-Schemas, die nur die Migrationen von Juni 2026 brauchen.
|
||||||
ruft `/api/cron/apply-pending-changes` auf, um fällige Versetzungen/
|
- Läuft die Datenbank woanders (Azure Flexible Server, RDS, eigenes Blech),
|
||||||
Beförderungen/Karenz/Reorg-Änderungen zu übernehmen). Da es außerhalb von
|
genügt es, `DATABASE_URL` dorthin zeigen zu lassen und den `db`-Dienst nicht
|
||||||
Vercel kein Vercel-Cron gibt, übernimmt das im `docker-compose.yml`
|
zu starten. Die Anwendung merkt keinen Unterschied.
|
||||||
enthaltene `cron`-Sidecar-Container diese Aufgabe mit demselben Schema
|
- **Ebenfalls nicht containerisiert:** die Anmeldung. Sie läuft über
|
||||||
und demselben Bearer-Secret, das die Route bereits erwartet.
|
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
|
## Voraussetzungen
|
||||||
|
|
||||||
- Docker + Docker Compose (v2, das im Docker Desktop/Docker Engine
|
- Docker + Docker Compose (v2, das in Docker Engine enthaltene
|
||||||
enthaltene `docker compose`) auf dem Zielserver.
|
`docker compose`) auf dem Zielserver. Sonst nichts — weder Node noch psql;
|
||||||
- Ein bestehendes Supabase-Projekt mit den Migrationen aus
|
was gebraucht wird, kommt aus Containern.
|
||||||
`supabase/migrations/` bereits eingespielt (`supabase db push` bzw. wie
|
|
||||||
bisher).
|
|
||||||
|
|
||||||
## 1. `.env` anlegen
|
## 1. `.env` anlegen
|
||||||
|
|
||||||
@@ -37,21 +80,24 @@ Werte eintragen:
|
|||||||
|
|
||||||
| Variable | Woher |
|
| Variable | Woher |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `NEXT_PUBLIC_SUPABASE_URL` | Supabase-Projekt → Settings → API |
|
| `POSTGRES_PASSWORD` | selbst erzeugen: `openssl rand -base64 24` — das des Verwalters |
|
||||||
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase-Projekt → Settings → API |
|
| `APP_DB_PASSWORD` | selbst erzeugen — das der Anwendungsrolle `alpenwerk_app` |
|
||||||
| `SUPABASE_SERVICE_ROLE_KEY` | Supabase-Projekt → Settings → API (geheim!) |
|
| `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` |
|
| `CRON_SECRET` | selbst generieren: `openssl rand -hex 32` |
|
||||||
|
|
||||||
|
Details zur Entra-Registrierung: [`docs/entra-sso.md`](docs/entra-sso.md).
|
||||||
|
|
||||||
Wichtig zum Verständnis:
|
Wichtig zum Verständnis:
|
||||||
|
|
||||||
- `NEXT_PUBLIC_*`-Variablen werden **beim Build** in das Browser-Bundle
|
- **Nichts davon wird in das Image eingebacken.** Es gibt keine
|
||||||
eingebacken (Next.js-Verhalten, nicht Docker-spezifisch). Ändern sich
|
`NEXT_PUBLIC_*`-Variablen mehr; alle Werte liest die Anwendung zur Laufzeit
|
||||||
diese Werte, muss das Image **neu gebaut** werden – ein reiner Container-
|
über `env_file`. Eine Änderung braucht deshalb nur einen Neustart, keinen
|
||||||
Neustart reicht nicht.
|
neuen Build — und dasselbe Image läuft in Test und Produktion.
|
||||||
- `SUPABASE_SERVICE_ROLE_KEY` und `CRON_SECRET` sind Server-only-Secrets.
|
|
||||||
Sie werden bewusst **nicht** als Build-Arg übergeben (das würde sie im
|
|
||||||
Image-Layer-History sichtbar machen), sondern erst zur Laufzeit über
|
|
||||||
`env_file` injiziert.
|
|
||||||
- `.env` steht schon in `.gitignore` – nicht committen.
|
- `.env` steht schon in `.gitignore` – nicht committen.
|
||||||
|
|
||||||
## 2. Bauen und lokal testen
|
## 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
|
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 pull && docker compose up -d` ausführen. Dafür in
|
||||||
`docker-compose.yml` zusätzlich `image: <registry>/<name>:<tag>` setzen und
|
`docker-compose.yml` zusätzlich `image: <registry>/<name>:<tag>` setzen.
|
||||||
den Build in der CI-Pipeline mit den `--build-arg`-Werten für
|
|
||||||
`NEXT_PUBLIC_*` laufen lassen.
|
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
|
## 4. Reverse Proxy + HTTPS
|
||||||
|
|
||||||
Next.js selbst sollte laut den offiziellen Docs **nicht** direkt exponiert
|
Next.js selbst sollte laut den offiziellen Docs **nicht** direkt exponiert
|
||||||
werden – ein Reverse Proxy übernimmt TLS, Rate-Limiting und Request-
|
werden – ein Reverse Proxy übernimmt TLS, Rate-Limiting und Request-
|
||||||
Validierung. Beispiel mit [Caddy](https://caddyserver.com/) (automatisches
|
Validierung.
|
||||||
HTTPS via Let's Encrypt):
|
|
||||||
|
|
||||||
```caddyfile
|
Fertige Konfiguration: [`deploy/Caddyfile`](deploy/Caddyfile) — mit
|
||||||
# /etc/caddy/Caddyfile
|
DNS-Challenge, weil der Server aus dem Internet nicht erreichbar ist (siehe
|
||||||
hr.example.com {
|
[oben](#betrieb-im-firmennetz--zwei-dinge-vorab)).
|
||||||
reverse_proxy localhost:3000
|
|
||||||
}
|
```bash
|
||||||
|
sudo cp deploy/Caddyfile /etc/caddy/Caddyfile
|
||||||
|
sudo systemctl reload caddy
|
||||||
```
|
```
|
||||||
|
|
||||||
`docker-compose.yml` published Port 3000 aktuell auf den Host – bei
|
`docker-compose.yml` veröffentlicht Port 3000 bewusst nur auf
|
||||||
Verwendung eines Reverse Proxys auf demselben Host kann das Publishing auf
|
`127.0.0.1` — die App ist also ausschliesslich über den Proxy erreichbar,
|
||||||
`127.0.0.1:3000:3000` eingeschränkt werden, damit der Container-Port nicht
|
nicht daneben unverschlüsselt.
|
||||||
direkt von außen erreichbar ist.
|
|
||||||
|
|
||||||
## 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
|
```bash
|
||||||
git pull
|
git pull
|
||||||
|
node --env-file=.env scripts/migrate.mjs
|
||||||
docker compose build
|
docker compose build
|
||||||
docker compose up -d
|
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
|
Single-Instance-Compose-Konfiguration normal. Für Zero-Downtime-Deployments
|
||||||
wäre eine zweite Instanz + Load Balancer nötig (siehe Abschnitt 6).
|
wäre eine zweite Instanz + Load Balancer nötig (siehe Abschnitt 6).
|
||||||
|
|
||||||
Datenbank-Migrationen (`supabase/migrations/*.sql`) werden weiterhin über
|
Der Migrationsschritt steht bewusst **vor** dem Neubau: der neue Code erwartet
|
||||||
die Supabase CLI gegen das Supabase-Projekt gefahren, unabhängig vom
|
das neue Schema, und umgekehrt liefe die neue Anwendung kurz gegen das alte.
|
||||||
App-Deployment:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
supabase db push
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. Hinweis bei mehreren Replicas
|
## 6. Hinweis bei mehreren Replicas
|
||||||
|
|
||||||
@@ -145,12 +344,35 @@ Single-Instance-Compose-Konfiguration ist das nicht nötig.
|
|||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
- **Login-Redirect-Loop / `proxy.ts` verhält sich falsch:** meist falsche
|
- **Push gemacht, aber kein Lauf startet:** meist die Labels. Der Lauf steht
|
||||||
`NEXT_PUBLIC_SUPABASE_URL`/`ANON_KEY` – Image neu bauen (siehe oben, diese
|
dann in Gitea unter *Actions* als „Waiting" — ohne Fehlermeldung, weil kein
|
||||||
Werte sind eingebacken).
|
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
|
- **Cron läuft nicht:** `docker compose logs cron` – prüft, ob
|
||||||
`/etc/crontabs/root` korrekt geschrieben wurde und ob `CRON_SECRET` in
|
`/etc/crontabs/root` korrekt geschrieben wurde und ob `CRON_SECRET` in
|
||||||
`.env` gesetzt ist (leer/fehlend führt serverseitig zu `401`).
|
`.env` gesetzt ist (leer/fehlend führt serverseitig zu `401`).
|
||||||
- **Healthcheck rot:** `docker compose logs app` – meist fehlende/falsche
|
- **Healthcheck rot:** `docker compose logs app` – meist `DATABASE_URL`
|
||||||
Supabase-Env-Variablen zur Laufzeit (`SUPABASE_SERVICE_ROLE_KEY`,
|
fehlend oder nicht erreichbar. Der Pool baut die Verbindung erst beim
|
||||||
Server-Komponenten).
|
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 --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Public env vars are inlined into the client bundle at build time, so they
|
# Keine Build-Argumente mehr: es gibt keine NEXT_PUBLIC_*-Werte mehr, die in
|
||||||
# must be available here, not just at runtime. Values are passed in via
|
# das Browser-Bundle eingebacken würden. Datenbank und Anmeldung sprechen
|
||||||
# --build-arg (see DEPLOYMENT.md).
|
# ausschliesslich den Server an, und dessen Zugangsdaten kommen zur Laufzeit.
|
||||||
ARG NEXT_PUBLIC_SUPABASE_URL
|
#
|
||||||
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
|
# Dadurch ist dieses Abbild umgebungsneutral: einmal gebaut, in Test und
|
||||||
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
# Produktion dasselbe. Vorher hätte jede Umgebung ihr eigenes gebraucht — und
|
||||||
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
# eine Baustrecke, die die Zugangsdaten schon zum Bauen kennt.
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
RUN npm run build
|
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
|
`node_modules/next/dist/docs/` konsultieren; sie sind maßgeblich, ältere
|
||||||
Anleitungen im Netz beschreiben teils überholte APIs.
|
Anleitungen im Netz beschreiben teils überholte APIs.
|
||||||
- React 19, TypeScript
|
- React 19, TypeScript
|
||||||
- Supabase (Postgres, Auth, RLS) — Datenhaltung liegt vollständig in
|
- PostgreSQL, angesprochen über Kysely und `pg` — die Datenhaltung liegt in
|
||||||
Supabase, nicht im Next.js-Prozess.
|
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
|
- Tailwind CSS v4
|
||||||
- Vitest — Unit- (Node), Komponenten- (jsdom) und Integrationstests
|
- Vitest — Unit- (Node), Komponenten- (jsdom) und Integrationstests
|
||||||
(gegen ein lokales Supabase)
|
(gegen eine erreichbare Datenbank)
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
@@ -38,14 +40,14 @@ cp .env.example .env.local # Werte eintragen, siehe unten
|
|||||||
npm run dev
|
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
|
```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
|
Danach zeigt `DATABASE_URL` in `.env.local` auf diese Datenbank.
|
||||||
Standard-Ports der lokalen Supabase-CLI abgestimmt.
|
|
||||||
|
|
||||||
## Umgebungsvariablen
|
## Umgebungsvariablen
|
||||||
|
|
||||||
@@ -54,14 +56,18 @@ Liste. Kurzfassung:
|
|||||||
|
|
||||||
| Variable | Sichtbarkeit | Zweck |
|
| Variable | Sichtbarkeit | Zweck |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `NEXT_PUBLIC_SUPABASE_URL` | Browser + Server | Supabase-Projekt-URL |
|
| `DATABASE_URL` | Nur Server | PostgreSQL-Verbindung. Die Rolle darf **kein** `BYPASSRLS` haben |
|
||||||
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Browser + Server | Anon-Key, RLS-gebunden |
|
| `DATABASE_SSL` | Nur Server | `false` für lokal/CI ohne TLS |
|
||||||
| `SUPABASE_SERVICE_ROLE_KEY` | **Nur Server** | Umgeht RLS vollständig — niemals im Browser-Bundle, niemals loggen |
|
| `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` |
|
| `CRON_SECRET` | Nur Server | Schützt `/api/cron/apply-pending-changes` |
|
||||||
|
|
||||||
`NEXT_PUBLIC_*`-Werte werden beim Build in das Client-Bundle eingebacken —
|
**Es gibt keine `NEXT_PUBLIC_*`-Variablen mehr.** Nichts wird in das
|
||||||
eine Änderung erfordert einen Rebuild, nicht nur einen Neustart (relevant
|
Browser-Bundle eingebacken, weil der Browser mit nichts ausser der Anwendung
|
||||||
für Docker-Deployments, siehe unten).
|
selbst spricht. Ein Docker-Abbild ist damit umgebungsneutral: einmal gebaut,
|
||||||
|
überall dasselbe — vorher brauchte jede Umgebung ihr eigenes.
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
@@ -73,9 +79,10 @@ für Docker-Deployments, siehe unten).
|
|||||||
| `npm run lint` | ESLint (`eslint-config-next`, Flat Config) |
|
| `npm run lint` | ESLint (`eslint-config-next`, Flat Config) |
|
||||||
| `npm run typecheck` | `tsc --noEmit` |
|
| `npm run typecheck` | `tsc --noEmit` |
|
||||||
| `npm run test` | Vitest, Unit-Tests (`tests/unit/**`) |
|
| `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 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
|
## Sicherheitsprinzipien
|
||||||
|
|
||||||
@@ -84,11 +91,16 @@ für Docker-Deployments, siehe unten).
|
|||||||
Ersatzkontrolle.
|
Ersatzkontrolle.
|
||||||
- **Ein Rollenmodell:** `profiles.role = 'hr'` + `profiles.is_active = true`,
|
- **Ein Rollenmodell:** `profiles.role = 'hr'` + `profiles.is_active = true`,
|
||||||
geprüft über die SQL-Funktion `is_hr_user()`. Kein Sub-Rollensystem —
|
geprüft über die SQL-Funktion `is_hr_user()`. Kein Sub-Rollensystem —
|
||||||
siehe [`docs/data-model.md`](docs/data-model.md#zugriffsmodell).
|
siehe [`docs/datenkatalog.md`](docs/datenkatalog.md#zugriffsschutz).
|
||||||
- **Service-Role-Key ist server-only.** Einzige Verwendung:
|
- **Es gibt keinen privilegierten Zugang mehr.** Der Dienstschlüssel, der RLS
|
||||||
`lib/supabase/admin.ts`, geschützt durch `import "server-only"` (macht
|
aushebelte, ist ersatzlos entfallen; auch der nächtliche Lauf benutzt
|
||||||
einen versehentlichen Client-Import zu einem Build-Fehler statt einem
|
dieselbe Rolle ohne `BYPASSRLS`. Was ohne angemeldete Person laufen muss,
|
||||||
Laufzeitproblem).
|
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
|
- **Audit-Log ist transaktional in der Datenbank**, nicht im App-Code: jede
|
||||||
mutierende SQL-Funktion schreibt ihren `audit_log`-Eintrag in derselben
|
mutierende SQL-Funktion schreibt ihren `audit_log`-Eintrag in derselben
|
||||||
Transaktion wie die Änderung selbst. Details und Prüfung siehe
|
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
|
`/api/cron/apply-pending-changes` wendet wirksam gewordene, zukunftsdatierte
|
||||||
Änderungen an (`pending_org_changes` → `apply_due_pending_changes()`).
|
Änderungen an (`pending_org_changes` → `apply_due_pending_changes()`).
|
||||||
|
|
||||||
- **Auf Vercel:** `vercel.json` definiert den täglichen Schedule; Vercel Cron
|
- Den Zeitplan hält der `cron`-Container aus `docker-compose.yml`: täglich
|
||||||
sendet `Authorization: Bearer <CRON_SECRET>` automatisch, wenn
|
03:00 Uhr, mit `Authorization: Bearer <CRON_SECRET>`.
|
||||||
`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.
|
|
||||||
- Fehlt `CRON_SECRET` oder stimmt der Header nicht, antwortet die Route mit
|
- Fehlt `CRON_SECRET` oder stimmt der Header nicht, antwortet die Route mit
|
||||||
`401` (nicht `500` — bewusst, siehe `tests/unit/security.test.ts`).
|
`401` (nicht `500` — bewusst, siehe `tests/unit/security.test.ts`).
|
||||||
|
|
||||||
## Supabase-Hinweise
|
## Datenbank
|
||||||
|
|
||||||
- Schema-Quelle der Wahrheit: `supabase/migrations/`. Menschlich lesbare
|
- Schema-Quelle der Wahrheit: `db/migrations/`. Menschlich lesbare Fassung,
|
||||||
Zusammenfassung: [`docs/data-model.md`](docs/data-model.md).
|
aus der laufenden Datenbank erzeugt:
|
||||||
- Migrationen einspielen: `supabase db push` (gegen das verlinkte Projekt)
|
[`docs/datenkatalog.md`](docs/datenkatalog.md).
|
||||||
bzw. `supabase start` + automatische Anwendung für lokale Entwicklung.
|
- Migrationen einspielen: `npm run migrate`. Der Läufer wendet nur die
|
||||||
- `supabase/seed.ts` und `.env.test.local` sind nur für lokale
|
fehlenden Dateien an, jede in ihrer eigenen Transaktion, und führt darüber
|
||||||
Entwicklung/Tests gedacht, nie für ein Produktivprojekt verwenden.
|
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
|
## Testing
|
||||||
|
|
||||||
- `npm run test` — schnell, keine externen Abhängigkeiten, läuft in CI.
|
- `npm run test` — schnell, keine externen Abhängigkeiten, läuft in CI.
|
||||||
- `npm run test:integration` — braucht eine laufende lokale Supabase-Instanz
|
- `npm run test:integration` — braucht eine erreichbare Datenbank
|
||||||
(`supabase start`) und `.env.test.local`; prüft RLS-Verhalten end-to-end
|
(`DATABASE_URL`). Geprüft werden Regeln, die es zweimal gibt: einmal als
|
||||||
(siehe `tests/integration/authorization.test.ts` für das HR-Only-Zugriffs-
|
SQL, einmal als TypeScript. Ohne `DATABASE_URL` überspringen sich die
|
||||||
modell).
|
Dateien, statt mit einem Verbindungsfehler abzubrechen.
|
||||||
- `npm run test:e2e` — Playwright gegen einen laufenden Dev-/Preview-Server.
|
- `npm run test:e2e` — Playwright gegen einen laufenden Dev-/Preview-Server.
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
Siehe [`DEPLOYMENT.md`](DEPLOYMENT.md) für Docker-basiertes Deployment
|
Siehe [`DEPLOYMENT.md`](DEPLOYMENT.md): Anwendung und Datenbank als Container,
|
||||||
(Dockerfile, docker-compose.yml, Reverse-Proxy/TLS, Cron-Ersatz, Updates).
|
Reverse-Proxy/TLS, der nächtliche Lauf, Migrationen und Updates.
|
||||||
Für Vercel: `vercel.json` ist bereits vorhanden; Env-Vars im
|
|
||||||
Vercel-Projekt setzen (siehe oben).
|
|
||||||
|
|
||||||
## Known TODOs vor Produktivbetrieb
|
## Known TODOs vor Produktivbetrieb
|
||||||
|
|
||||||
- **Content-Security-Policy fehlt noch** (`next.config.ts` setzt bewusst
|
- **Content-Security-Policy läuft im Nur-Bericht-Modus** (`next.config.ts`).
|
||||||
keine CSP — Skript-/Style-/Connect-Quellen sind noch nicht vollständig
|
Erzwungen wird sie erst, wenn die Meldungen sauber sind — eine geratene,
|
||||||
inventarisiert; ungeprüft geraten zu setzen riskiert, Hydration oder den
|
erzwungene Richtlinie blendet die Anwendung für alle aus.
|
||||||
Supabase-Client stillschweigend zu brechen).
|
- **Seed für die Integrationstests fehlt.** Er lag in der abgelösten
|
||||||
- **Lokale Scratch-Artefakte** (`.scratch_*`, `.scratch_shots/`) enthalten
|
Umgebung. Zwei der drei Integrationstests vergleichen Regeln über den
|
||||||
Screenshots/Hilfsskripte aus einer früheren manuellen Verifikation und
|
gesamten Bestand und brauchen dafür Daten; bis der Seed nachgezogen ist,
|
||||||
liegen noch im Arbeitsverzeichnis. Sie sind jetzt über `.gitignore`
|
laufen sie nur gegen eine bereits befüllte Datenbank, nicht in der CI.
|
||||||
ausgeschlossen; vor einem Produktiv-Handover sollten sie durchgesehen und
|
- **Sicherheitsprüfung des heutigen Aufbaus steht aus** — siehe
|
||||||
bei Bedarf gelöscht werden.
|
[`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).
|
- **Kein granulareres Rollenmodell** — aktuell HR-only (alles-oder-nichts).
|
||||||
Falls z. B. eine reine Lese-Rolle künftig gebraucht wird, gehört die
|
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
|
Erweiterung in eine neue Migration (`is_hr_user()`/RLS-Policies), nicht in
|
||||||
|
|||||||
@@ -1,24 +1,86 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { redirect } from "next/navigation";
|
import { AuthError } from "next-auth";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { signIn, signOut } from "@/auth";
|
||||||
|
|
||||||
export async function login(formData: FormData) {
|
// Zwei Anmeldewege, und der zweite ist auf Zeit.
|
||||||
const email = String(formData.get("email") ?? "");
|
//
|
||||||
const password = String(formData.get("password") ?? "");
|
// **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();
|
export async function signInWithEntra() {
|
||||||
const { error } = await supabase.auth.signInWithPassword({ email, password });
|
// Kehrt nicht zurück: signIn löst eine Weiterleitung aus, und die wirft in
|
||||||
|
// Next.js.
|
||||||
if (error) {
|
await signIn("microsoft-entra-id", { redirectTo: "/" });
|
||||||
redirect("/login?error=invalid_credentials");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
redirect("/");
|
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." };
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
export async function logout() {
|
||||||
const supabase = await createClient();
|
await signOut({ redirectTo: "/login" });
|
||||||
await supabase.auth.signOut();
|
|
||||||
redirect("/login");
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,67 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
|
import { currentUserId } from "@/lib/auth/session";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { withUser } from "@/lib/db";
|
||||||
import type { CollectiveAgreement, Database, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types";
|
import { callFunction, runMutation, type ActionResult, type MutationFn } from "@/lib/db/rpc";
|
||||||
|
import { OFFBOARDING_PUNKTE } from "@/lib/offboarding";
|
||||||
type ActionResult = { success: boolean; error?: string };
|
import { ONBOARDING_PUNKTE } from "@/lib/onboarding";
|
||||||
type MutationFn = keyof Database["public"]["Functions"];
|
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> {
|
async function callRpc(fn: MutationFn, payload: Record<string, unknown>, revalidate: string[]): Promise<ActionResult> {
|
||||||
const supabase = await createClient();
|
const result = await runMutation(await currentUserId(), fn, payload);
|
||||||
const { error } = await supabase.rpc(fn, { payload });
|
if (!result.success) return result;
|
||||||
if (error) return { success: false, error: error.message };
|
|
||||||
for (const path of revalidate) revalidatePath(path);
|
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: {
|
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;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
title_prefix?: string[];
|
title_prefix?: string[];
|
||||||
@@ -24,6 +69,24 @@ export async function hireEmployee(payload: {
|
|||||||
gender: "m" | "w";
|
gender: "m" | "w";
|
||||||
birth_date: string;
|
birth_date: string;
|
||||||
sv_nummer?: 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;
|
phone?: string;
|
||||||
position_id?: string;
|
position_id?: string;
|
||||||
team_id?: string;
|
team_id?: string;
|
||||||
@@ -34,39 +97,117 @@ export async function hireEmployee(payload: {
|
|||||||
contract_end_date?: string;
|
contract_end_date?: string;
|
||||||
employment_type?: "Vollzeit" | "Teilzeit";
|
employment_type?: "Vollzeit" | "Teilzeit";
|
||||||
weekly_hours?: number;
|
weekly_hours?: number;
|
||||||
paygrade?: "A" | "B" | "C" | "D" | "E" | "F";
|
paygrade?: PaygradeType;
|
||||||
source: "Intern" | "Extern";
|
source: "Intern" | "Extern";
|
||||||
worker_type?: WorkerType;
|
worker_type?: WorkerType;
|
||||||
collective_agreement?: CollectiveAgreement;
|
collective_agreement?: CollectiveAgreement;
|
||||||
work_days?: Weekday[];
|
work_days?: Weekday[];
|
||||||
is_betriebsrat?: boolean;
|
is_betriebsrat?: boolean;
|
||||||
has_dienstwagen?: 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_laterale_fuehrung?: boolean;
|
||||||
is_c_level?: 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 }> {
|
}): Promise<ActionResult & { employeeId?: string }> {
|
||||||
const supabase = await createClient();
|
// Einzige Mutation, deren Rückgabewert gebraucht wird: die neue
|
||||||
const { data, error } = await supabase.rpc("hire_employee", { payload });
|
// Personen-Kennung, damit die Oberfläche direkt auf die Akte springen kann.
|
||||||
if (error) return { success: false, error: error.message };
|
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("/employees");
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
revalidatePath("/positions");
|
revalidatePath("/positions");
|
||||||
return { success: true, employeeId: data as string };
|
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: {
|
export async function terminateEmployee(payload: {
|
||||||
employee_id: string;
|
employee_id: string;
|
||||||
exit_date: string;
|
exit_date: string;
|
||||||
exit_reason: 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;
|
note?: string;
|
||||||
}): Promise<ActionResult> {
|
}): Promise<ActionResult> {
|
||||||
|
// 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", "/"]);
|
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: {
|
export async function transferEmployee(payload: {
|
||||||
employee_id: string;
|
employee_id: string;
|
||||||
effective_date: string;
|
effective_date: string;
|
||||||
new_team_id: string;
|
/** Die Zielplanstelle; Bereich, Abteilung und Team ergeben sich aus ihrer Einheit. */
|
||||||
new_title?: string;
|
target_position_id: string;
|
||||||
}): Promise<ActionResult> {
|
}): Promise<ActionResult> {
|
||||||
return callRpc("transfer_employee", payload, [`/employees/${payload.employee_id}`, "/employees"]);
|
return callRpc("transfer_employee", payload, [`/employees/${payload.employee_id}`, "/employees"]);
|
||||||
}
|
}
|
||||||
@@ -75,7 +216,14 @@ export async function promoteEmployee(payload: {
|
|||||||
employee_id: string;
|
employee_id: string;
|
||||||
effective_date: string;
|
effective_date: string;
|
||||||
new_title: 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> {
|
}): Promise<ActionResult> {
|
||||||
return callRpc("promote_employee", payload, [`/employees/${payload.employee_id}`, "/employees"]);
|
return callRpc("promote_employee", payload, [`/employees/${payload.employee_id}`, "/employees"]);
|
||||||
}
|
}
|
||||||
@@ -103,6 +251,10 @@ export async function recordKarenzReturn(payload: {
|
|||||||
return_date: string;
|
return_date: string;
|
||||||
employment_mode: "unverändert" | "Vollzeit" | "Teilzeit";
|
employment_mode: "unverändert" | "Vollzeit" | "Teilzeit";
|
||||||
weekly_hours?: number;
|
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> {
|
}): Promise<ActionResult> {
|
||||||
return callRpc("record_karenz_return", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]);
|
return callRpc("record_karenz_return", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]);
|
||||||
}
|
}
|
||||||
@@ -112,13 +264,76 @@ export async function changeEmployeeData(payload: {
|
|||||||
effective_date: string;
|
effective_date: string;
|
||||||
person: Record<string, unknown>;
|
person: Record<string, unknown>;
|
||||||
contract: 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>;
|
role: Record<string, unknown>;
|
||||||
}): Promise<ActionResult> {
|
}): Promise<ActionResult> {
|
||||||
return callRpc("change_employee_data", payload, [`/employees/${payload.employee_id}`, "/employees"]);
|
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: {
|
export async function addEmployeeDependent(payload: {
|
||||||
@@ -133,6 +348,25 @@ export async function addEmployeeDependent(payload: {
|
|||||||
return callRpc("add_employee_dependent", payload, [`/employees/${payload.employee_id}`]);
|
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: {
|
export async function deleteEmployeeDependent(payload: {
|
||||||
dependent_id: string;
|
dependent_id: string;
|
||||||
employee_id: string;
|
employee_id: string;
|
||||||
@@ -141,6 +375,41 @@ export async function deleteEmployeeDependent(payload: {
|
|||||||
return callRpc("delete_employee_dependent", payload, [`/employees/${payload.employee_id}`]);
|
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: {
|
export async function addEmployeeNote(payload: {
|
||||||
employee_id: string;
|
employee_id: string;
|
||||||
category: NoteCategory;
|
category: NoteCategory;
|
||||||
@@ -153,23 +422,3 @@ export async function addEmployeeNote(payload: {
|
|||||||
export async function completeEmployeeNote(payload: { note_id: string; employee_id: string }): Promise<ActionResult> {
|
export async function completeEmployeeNote(payload: { note_id: string; employee_id: string }): Promise<ActionResult> {
|
||||||
return callRpc("complete_employee_note", payload, [`/employees/${payload.employee_id}`, "/"]);
|
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";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
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: {
|
export async function saveHireDraft(payload: {
|
||||||
id?: string;
|
id?: string;
|
||||||
step: number;
|
step: number;
|
||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
}): Promise<ActionResult & { id?: string }> {
|
}): Promise<ActionResult & { id?: string }> {
|
||||||
const supabase = await createClient();
|
const userId = await currentUserId();
|
||||||
const {
|
if (!userId) return { success: false, error: "Nicht angemeldet." };
|
||||||
data: { user },
|
|
||||||
} = await supabase.auth.getUser();
|
|
||||||
if (!user) return { success: false, error: "Nicht angemeldet." };
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
const id = await withUser(userId, async (tx) => {
|
||||||
if (payload.id) {
|
if (payload.id) {
|
||||||
const { error } = await supabase
|
// Wer schreiben darf, entscheidet die Regel hire_drafts_update:
|
||||||
.from("hire_drafts")
|
// der Entwurf muss meiner oder von einer hinzugewählten Person sein,
|
||||||
.update({ step: payload.step, payload: payload.data, updated_at: new Date().toISOString() })
|
// **und** die Sperre muss mir gehören.
|
||||||
.eq("id", payload.id);
|
//
|
||||||
if (error) return { success: false, error: error.message };
|
// Die Zahl der geänderten Zeilen wird trotzdem gelesen: eine Regel
|
||||||
revalidatePath("/");
|
// weist ein UPDATE nicht mit einem Fehler ab, sie lässt es ins Leere
|
||||||
return { success: true, id: payload.id };
|
// 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 { data, error } = await supabase
|
const row = await tx
|
||||||
.from("hire_drafts")
|
.insertInto("hire_drafts")
|
||||||
.insert({ created_by: user.id, step: payload.step, payload: payload.data })
|
.values({ created_by: userId, step: payload.step, payload: payload.data })
|
||||||
.select("id")
|
.returning("id")
|
||||||
.single();
|
.executeTakeFirstOrThrow();
|
||||||
if (error) return { success: false, error: error.message };
|
return row.id;
|
||||||
|
});
|
||||||
|
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
return { success: true, id: data.id };
|
return { success: true, id };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteHireDraft(id: string): Promise<ActionResult> {
|
export async function deleteHireDraft(id: string): Promise<ActionResult> {
|
||||||
const supabase = await createClient();
|
try {
|
||||||
const { error } = await supabase.from("hire_drafts").delete().eq("id", id);
|
// Auch hier die Zeilenzahl: die Regel hire_drafts_delete lässt ein
|
||||||
if (error) return { success: false, error: error.message };
|
// 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("/");
|
revalidatePath("/");
|
||||||
return { success: true };
|
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";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
|
import { currentUserId } from "@/lib/auth/session";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { kontierungZum, kostenstellenAbfrage, type KontierungsZeile } from "@/lib/cost-centers";
|
||||||
|
import { withUser } from "@/lib/db";
|
||||||
type ActionResult = { success: boolean; error?: string };
|
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", "/"];
|
const POSITION_PATHS = ["/positions", "/orgchart", "/"];
|
||||||
|
|
||||||
async function callRpc(
|
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>,
|
payload: Record<string, unknown>,
|
||||||
revalidate: string[]
|
revalidate: string[]
|
||||||
): Promise<ActionResult> {
|
): Promise<ActionResult> {
|
||||||
const supabase = await createClient();
|
const result = await runMutation(await currentUserId(), fn, payload);
|
||||||
const { error } = await supabase.rpc(fn, { payload });
|
if (!result.success) return result;
|
||||||
if (error) return { success: false, error: error.message };
|
|
||||||
for (const path of revalidate) revalidatePath(path);
|
for (const path of revalidate) revalidatePath(path);
|
||||||
return { success: true };
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createPosition(payload: {
|
export async function createPosition(payload: {
|
||||||
title: string;
|
org_unit_id: string;
|
||||||
superior_employee_id: string;
|
job_title: string;
|
||||||
is_lead: boolean;
|
is_chief: boolean;
|
||||||
team_id?: string;
|
|
||||||
valid_from: string;
|
valid_from: string;
|
||||||
}): Promise<ActionResult> {
|
}): Promise<ActionResult> {
|
||||||
return callRpc("create_position", payload, POSITION_PATHS);
|
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> {
|
export async function deletePosition(positionId: string): Promise<ActionResult> {
|
||||||
return callRpc("delete_position", { position_id: positionId }, POSITION_PATHS);
|
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
|
* Kontiert eine Planstelle auf eine andere Kostenstelle — zum Stichtag.
|
||||||
// position is itself a team lead (§2).
|
*
|
||||||
export async function searchSuperiors(query: string, forLeadPosition: boolean): Promise<SuperiorSearchResult[]> {
|
* Kein Feld im Änderungsdialog, sondern ein eigener Vorgang, weil es einer
|
||||||
const supabase = await createClient();
|
* ist: die laufende Kontierung wird beendet, die neue beginnt. Die alte
|
||||||
let q = supabase
|
* einfach zu überschreiben hiesse, die Kosten der Vergangenheit nachträglich
|
||||||
.from("employees")
|
* anderswohin zu buchen.
|
||||||
.select("id, first_name, last_name, job_title, division_id")
|
*/
|
||||||
.eq("status", "Aktiv")
|
export async function setPositionCostCenter(payload: {
|
||||||
.limit(20);
|
position_id: string;
|
||||||
q = forLeadPosition ? q.lte("org_level", 1) : q.eq("is_lead", true).eq("org_level", 2);
|
cost_center_id: string;
|
||||||
if (query.trim()) {
|
valid_from: string;
|
||||||
const term = sanitizeIlikeTerm(query.trim());
|
}): Promise<ActionResult> {
|
||||||
q = q.or(`first_name.ilike.%${term}%,last_name.ilike.%${term}%,job_title.ilike.%${term}%`);
|
return callRpc("set_position_cost_center", payload, POSITION_PATHS);
|
||||||
}
|
}
|
||||||
const { data } = await q;
|
|
||||||
return data ?? [];
|
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";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { currentUserId } from "@/lib/auth/session";
|
||||||
|
import { withUser } from "@/lib/db";
|
||||||
type ActionResult = { success: boolean; error?: string };
|
import type { ActionResult } from "@/lib/db/rpc";
|
||||||
|
|
||||||
export async function saveReport(payload: { name: string; config: Record<string, unknown> }): Promise<ActionResult> {
|
export async function saveReport(payload: { name: string; config: Record<string, unknown> }): Promise<ActionResult> {
|
||||||
const supabase = await createClient();
|
const userId = await currentUserId();
|
||||||
const {
|
if (!userId) return { success: false, error: "Nicht angemeldet." };
|
||||||
data: { user },
|
|
||||||
} = await supabase.auth.getUser();
|
|
||||||
if (!user) return { success: false, error: "Nicht angemeldet." };
|
|
||||||
|
|
||||||
const { error } = await supabase.from("saved_reports").insert({ created_by: user.id, name: payload.name, config: payload.config });
|
try {
|
||||||
if (error) return { success: false, error: error.message };
|
await withUser(userId, (tx) =>
|
||||||
|
tx.insertInto("saved_reports").values({ created_by: userId, name: payload.name, config: payload.config }).execute()
|
||||||
|
);
|
||||||
revalidatePath("/reports");
|
revalidatePath("/reports");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteReport(id: string): Promise<ActionResult> {
|
export async function deleteReport(id: string): Promise<ActionResult> {
|
||||||
const supabase = await createClient();
|
try {
|
||||||
const { error } = await supabase.from("saved_reports").delete().eq("id", id);
|
await withUser(await currentUserId(), (tx) => tx.deleteFrom("saved_reports").where("id", "=", id).execute());
|
||||||
if (error) return { success: false, error: error.message };
|
|
||||||
revalidatePath("/reports");
|
revalidatePath("/reports");
|
||||||
return { success: true };
|
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 Link from "next/link";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
import { AuditDetail } from "@/components/audit/AuditDetail";
|
||||||
import { AuditFilters } from "@/components/audit/AuditFilters";
|
import { AuditFilters } from "@/components/audit/AuditFilters";
|
||||||
import { CARD_CLASS } from "@/components/ui/Card";
|
import { CARD_CLASS } from "@/components/ui/Card";
|
||||||
import { Pagination } from "@/components/ui/Pagination";
|
import { Pagination } from "@/components/ui/Pagination";
|
||||||
import { actionBadgeStyle } from "@/lib/colors";
|
import { actionBadgeStyle } from "@/lib/colors";
|
||||||
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
|
import { currentUserId } from "@/lib/auth/session";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { withUser } from "@/lib/db";
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
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
|
// 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
|
// an unpinned formatter renders it in the *server's* zone — UTC in the
|
||||||
// on Vercel — so every entry would read an hour or two early for the people
|
// container — so every entry would read an hour or two early for the people
|
||||||
// the log is for.
|
// the log is for.
|
||||||
const dateTimeFormatter = new Intl.DateTimeFormat("de-AT", {
|
const dateTimeFormatter = new Intl.DateTimeFormat("de-AT", {
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
@@ -34,35 +35,58 @@ const dateTimeFormatter = new Intl.DateTimeFormat("de-AT", {
|
|||||||
|
|
||||||
export default async function AuditPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
export default async function AuditPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
const page = Math.max(1, Number(params.page ?? "1") || 1);
|
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
|
const { entries, count } = await withUser(await currentUserId(), async (tx) => {
|
||||||
.from("audit_log")
|
const base = () => {
|
||||||
.select("id, occurred_at, actor_name, action, target_label, target_employee_id, details", { count: "exact" })
|
let q = tx.selectFrom("audit_log");
|
||||||
.order("occurred_at", { ascending: false })
|
if (params.action) q = q.where("action", "=", params.action);
|
||||||
.range(from, to);
|
|
||||||
|
|
||||||
if (params.action) query = query.eq("action", params.action);
|
|
||||||
if (params.q) {
|
if (params.q) {
|
||||||
const q = sanitizeIlikeTerm(params.q.trim());
|
// Als Parameter gebunden statt in die Abfrage geschrieben: die
|
||||||
query = query.or(`target_label.ilike.%${q}%,details.ilike.%${q}%,actor_name.ilike.%${q}%`);
|
// 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;
|
||||||
|
};
|
||||||
|
|
||||||
const { data: entries, count } = await query;
|
const [entries, total] = await Promise.all([
|
||||||
const totalPages = Math.max(1, Math.ceil((count ?? 0) / PAGE_SIZE));
|
base()
|
||||||
|
.select(["id", "occurred_at", "actor_name", "action", "target_label", "target_employee_id", "details", "changes"])
|
||||||
|
// Nach id als zweitem Kriterium: bei gleichem Zeitstempel wäre die
|
||||||
|
// Reihenfolge sonst unbestimmt und ein Eintrag könnte auf zwei Seiten
|
||||||
|
// erscheinen oder auf keiner.
|
||||||
|
.orderBy("occurred_at", "desc")
|
||||||
|
.orderBy("id", "desc")
|
||||||
|
.limit(PAGE_SIZE)
|
||||||
|
.offset((page - 1) * PAGE_SIZE)
|
||||||
|
.execute(),
|
||||||
|
base()
|
||||||
|
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
|
||||||
|
.executeTakeFirst(),
|
||||||
|
]);
|
||||||
|
return { entries, count: Number(total?.anzahl ?? 0) };
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(count / PAGE_SIZE));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<AuditFilters />
|
<AuditFilters />
|
||||||
</Suspense>
|
</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">
|
<table className="w-full min-w-[800px] text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border bg-surface text-left text-[11px] font-bold uppercase tracking-wider text-ink-muted">
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{(entries ?? []).map((entry) => {
|
{entries.map((entry) => {
|
||||||
return (
|
return (
|
||||||
<tr key={entry.id} className="border-b border-border-subtle transition-colors last:border-0 hover:bg-brand-50">
|
<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">
|
<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
|
entry.target_label
|
||||||
)}
|
)}
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{(entries ?? []).length === 0 && (
|
{entries.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="px-4 py-8 text-center text-sm text-ink-muted">
|
<td colSpan={5} className="px-4 py-8 text-center text-sm text-ink-muted">
|
||||||
Keine Einträge gefunden.
|
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 { notFound } from "next/navigation";
|
||||||
import { EmployeeDetail } from "@/components/employees/EmployeeDetail";
|
import { EmployeeDetail } from "@/components/employees/EmployeeDetail";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { currentUserId } from "@/lib/auth/session";
|
||||||
import type { Database } from "@/lib/supabase/types";
|
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 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) {
|
export default async function EmployeeDetailPage({ params }: PageProps) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const supabase = await createClient();
|
const today = todayIso();
|
||||||
|
|
||||||
// Everything here keys off the id already in the URL, and the manager
|
// Was die Akte liest und in wie vielen Rundreisen, steht in
|
||||||
// comes back as an embedded resource on the employee row rather than as a
|
// lib/employee-detail-data.ts.
|
||||||
// follow-up query — so the page is one round trip instead of two. Measured
|
const data = await withUser(await currentUserId(), (tx) => loadEmployeeDetail(tx, id, today));
|
||||||
// against the hosted database that halved the data time (120ms -> 62ms,
|
|
||||||
// median of five), because a round trip costs more than these queries do.
|
|
||||||
//
|
|
||||||
// The hand-written Database type carries no relationship metadata
|
|
||||||
// (NoRelationships), so the embed is typed at the destructure below.
|
|
||||||
const [
|
|
||||||
{ data: employeeRow },
|
|
||||||
{ data: directReports },
|
|
||||||
{ data: history },
|
|
||||||
{ data: dependents },
|
|
||||||
{ data: notes },
|
|
||||||
{ data: divisions },
|
|
||||||
{ data: departments },
|
|
||||||
{ data: teams },
|
|
||||||
{ data: locations },
|
|
||||||
{ data: openPositions },
|
|
||||||
] = await Promise.all([
|
|
||||||
supabase.from("employees").select("*, manager:manager_id(id, first_name, last_name, job_title)").eq("id", id).single(),
|
|
||||||
supabase.from("employees").select("id, first_name, last_name, job_title, status").eq("manager_id", id).order("last_name"),
|
|
||||||
supabase
|
|
||||||
.from("employee_history")
|
|
||||||
.select("*")
|
|
||||||
.eq("employee_id", id)
|
|
||||||
.order("event_date", { ascending: false })
|
|
||||||
.order("created_at", { ascending: false }),
|
|
||||||
supabase.from("employee_dependents").select("*").eq("employee_id", id).order("created_at"),
|
|
||||||
supabase.from("employee_notes").select("*").eq("employee_id", id).order("created_at", { ascending: false }),
|
|
||||||
supabase.from("divisions").select("*").order("name"),
|
|
||||||
supabase.from("departments").select("*"),
|
|
||||||
supabase.from("teams").select("*"),
|
|
||||||
supabase.from("locations").select("*").order("name"),
|
|
||||||
supabase.from("positions").select("id, position_number, title, team_id, is_lead").eq("status", "open"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!employeeRow) notFound();
|
if (!data) notFound();
|
||||||
|
const { employee, line, reports, history, dependents, notes, orgMaps, placement, kostenstelle, onboarding, offboarding, openPositions, byId } = data;
|
||||||
// Split the embedded manager back off so EmployeeDetail keeps receiving a
|
|
||||||
// plain employees row plus a separate manager, unchanged.
|
|
||||||
const { manager, ...employee } = employeeRow as EmployeeWithManager;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmployeeDetail
|
<EmployeeDetail
|
||||||
employee={employee}
|
employee={employee}
|
||||||
manager={manager ?? null}
|
placement={
|
||||||
directReports={directReports ?? []}
|
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 ?? []}
|
history={history ?? []}
|
||||||
dependents={dependents ?? []}
|
dependents={dependents ?? []}
|
||||||
notes={notes ?? []}
|
notes={notes ?? []}
|
||||||
divisions={divisions ?? []}
|
locations={orgMaps.locationList}
|
||||||
departments={departments ?? []}
|
openPositions={openPositions}
|
||||||
teams={teams ?? []}
|
today={today}
|
||||||
locations={locations ?? []}
|
|
||||||
openPositions={openPositions ?? []}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,19 +5,74 @@ import { Avatar } from "@/components/ui/Avatar";
|
|||||||
import { CARD_CLASS } from "@/components/ui/Card";
|
import { CARD_CLASS } from "@/components/ui/Card";
|
||||||
import { Pagination } from "@/components/ui/Pagination";
|
import { Pagination } from "@/components/ui/Pagination";
|
||||||
import { StatusChip } from "@/components/ui/StatusChip";
|
import { StatusChip } from "@/components/ui/StatusChip";
|
||||||
import { applyDerivedStatusFilter } from "@/lib/employee-status-filter";
|
import { currentUserId } from "@/lib/auth/session";
|
||||||
import { fmtDate, todayIso } from "@/lib/format";
|
import { sql, withUser } from "@/lib/db";
|
||||||
import { breadcrumbFor, loadOrgMaps } from "@/lib/org";
|
import { jsonArrayFrom, jsonObjectFrom } from "@/lib/db/json";
|
||||||
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
|
import { istPersonalnummer, suchMuster, TRENNZEICHEN, TRENNZEICHEN_ERSATZ } from "@/lib/employee-search";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import {
|
||||||
import type { EmploymentStatus } from "@/lib/supabase/types";
|
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;
|
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 = {
|
type EmployeesPageProps = {
|
||||||
searchParams: Promise<SearchParams>;
|
searchParams: Promise<RohParams>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function pageHref(params: SearchParams, page: number): string {
|
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.division) sp.set("division", params.division);
|
||||||
if (params.status) sp.set("status", params.status);
|
if (params.status) sp.set("status", params.status);
|
||||||
if (params.location) sp.set("location", params.location);
|
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));
|
sp.set("page", String(page));
|
||||||
return `/employees?${sp.toString()}`;
|
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) {
|
export default async function EmployeesPage({ searchParams }: EmployeesPageProps) {
|
||||||
const params = await searchParams;
|
const params = glaetten(await searchParams);
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
const page = Math.max(1, Number(params.page ?? "1") || 1);
|
const page = Math.max(1, Number(params.page ?? "1") || 1);
|
||||||
const from = (page - 1) * PAGE_SIZE;
|
const sortFeld = parseFeld(params.sort);
|
||||||
const to = from + PAGE_SIZE - 1;
|
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
|
// Comma-separated, so a dashboard tile can link here with the same
|
||||||
// status set it counted rather than a narrower one.
|
// status set it counted rather than a narrower one.
|
||||||
const statuses = (params.status ?? "")
|
const statuses = (params.status ?? "")
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter((s): s is EmploymentStatus => (["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const).includes(s as EmploymentStatus));
|
.filter((s): s is EmploymentStatus => (["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const).includes(s as EmploymentStatus));
|
||||||
|
|
||||||
|
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
|
// Derived from the dates, not read off employees.status — see
|
||||||
// lib/employee-status-filter.ts for why the two can disagree.
|
// lib/employee-status-filter.ts for why the two can disagree.
|
||||||
query = applyDerivedStatusFilter(query, statuses, todayIso());
|
if (statuses.length > 0) {
|
||||||
if (params.location) query = query.eq("location_id", params.location);
|
q = q.where((eb) => derivedStatusFilter(eb, statuses, today) ?? eb.val(true));
|
||||||
|
}
|
||||||
|
|
||||||
// The org lookup tables are needed only to label the rows, so they load
|
if (params.location) q = q.where("location_id", "=", params.location);
|
||||||
// alongside the page of employees instead of before it — one round trip
|
return q;
|
||||||
// saved on a page that is otherwise two fast queries.
|
};
|
||||||
const [orgMaps, { data: employeesData, count }] = await Promise.all([loadOrgMaps(supabase), query]);
|
|
||||||
const employees = employeesData ?? [];
|
// Erst nachsehen, ob die Namen allein etwas hergeben. Nur wenn nicht,
|
||||||
const totalPages = Math.max(1, Math.ceil((count ?? 0) / PAGE_SIZE));
|
// 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 (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<EmployeeFilters divisions={orgMaps.divisionList} locations={orgMaps.locationList} />
|
<EmployeeFilters units={orgMaps.unitList} depthOf={orgMaps.depthOf} locations={orgMaps.locationList} />
|
||||||
</Suspense>
|
</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">
|
<table className="w-full min-w-[800px] text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border bg-surface text-left text-[11px] font-bold uppercase tracking-wider text-ink-muted">
|
<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>
|
{SORTIERFELDER.map((f) => {
|
||||||
<th className="px-4 py-2.5">Pers.-Nr.</th>
|
const aktiv = f.value === sortFeld;
|
||||||
<th className="px-4 py-2.5">Bereich/Team</th>
|
return (
|
||||||
<th className="px-4 py-2.5">Standort</th>
|
// `aria-sort` sagt einem Screenreader, welche Spalte die
|
||||||
<th className="px-4 py-2.5">Eintritt</th>
|
// Reihenfolge bestimmt und in welche Richtung — der Pfeil
|
||||||
<th className="px-4 py-2.5">Beschäftigung</th>
|
// allein ist für ihn nicht da.
|
||||||
<th className="px-4 py-2.5">Status</th>
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{employees.map((e) => {
|
{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;
|
const location = e.location_id ? orgMaps.locations.get(e.location_id) : undefined;
|
||||||
return (
|
return (
|
||||||
// border-subtle between rows: the full-strength border made
|
// 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} />
|
<Avatar firstName={e.first_name} lastName={e.last_name} />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate font-semibold text-ink">
|
<div className="truncate font-semibold text-ink">
|
||||||
{e.first_name} {e.last_name}
|
{fmtName(e.first_name, e.last_name)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
{/* tabular-nums keeps the numeric columns aligned down the
|
{/* tabular-nums keeps the numeric columns aligned down the
|
||||||
page instead of jittering per row. */}
|
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 tabular-nums text-ink-body">{e.personnel_number}</td>
|
||||||
<td className="px-4 py-2.5 text-ink-body">
|
{/* Nur die eigene Einheit, egal auf welcher Ebene sie hängt.
|
||||||
<div>{division?.name ?? "–"}</div>
|
Darüber stand der Bereich; der Kunde wollte ihn weg, weil
|
||||||
<div className="text-xs text-ink-muted">{team?.name ?? "–"}</div>
|
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>
|
||||||
<td className="px-4 py-2.5 text-ink-body">{location?.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>
|
<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>
|
{e.employment_type} · <span className="tabular-nums">{e.weekly_hours}h</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5">
|
<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>
|
</td>
|
||||||
</tr>
|
</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
|
// 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
|
// screen — no navigation, no way back, and in production just "a client-side
|
||||||
// exception occurred". `reset()` re-renders the segment, which is enough for
|
// 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 }) {
|
export default function AppError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.error("Route error:", error);
|
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 type { ReactNode } from "react";
|
||||||
import { HireWizardProvider } from "@/components/hire/HireWizardContext";
|
import { HireWizardProvider } from "@/components/hire/HireWizardContext";
|
||||||
import { AppShell } from "@/components/shell/AppShell";
|
import { AppShell } from "@/components/shell/AppShell";
|
||||||
import { loadOpenNotes } from "@/lib/notes";
|
import { currentUserId } from "@/lib/auth/session";
|
||||||
import { loadOpenPositions } from "@/lib/positions";
|
import { withUser } from "@/lib/db";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { loadShellData } from "@/lib/shell-data";
|
||||||
|
|
||||||
export default async function AppLayout({ children }: { children: ReactNode }) {
|
export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||||
const supabase = await createClient();
|
const userId = await currentUserId();
|
||||||
const {
|
if (!userId) redirect("/login");
|
||||||
data: { user },
|
|
||||||
} = await supabase.auth.getUser();
|
|
||||||
if (!user) redirect("/login");
|
|
||||||
|
|
||||||
// Defense in depth: proxy.ts already redirects any non-active-HR session
|
// Alles in *einer* Transaktion, weil nur dort der Sitzungskontext gilt —
|
||||||
// away before this layout ever renders. Re-checking here means a gap in
|
// und damit nebenbei auf einem einheitlichen Lesestand. Was dabei in wie
|
||||||
// the proxy matcher (or a future route added outside it) still fails
|
// vielen Rundreisen gelesen wird, steht in lib/shell-data.ts.
|
||||||
// closed instead of silently granting access — see docs/security.md.
|
const ergebnis = await withUser(userId, (tx) => loadShellData(tx, userId));
|
||||||
const { data: profile } = await supabase.from("profiles").select("full_name, email, role, is_active").eq("id", user.id).maybeSingle();
|
|
||||||
if (profile?.role !== "hr" || profile?.is_active !== true) redirect("/login");
|
|
||||||
|
|
||||||
const 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([
|
// Ohne den Grund in der Adresse stünde die Person vor einer wortlosen
|
||||||
loadOpenPositions(supabase),
|
// Anmeldeseite und versuchte es endlos erneut.
|
||||||
supabase.from("locations").select("id, name, country").order("name"),
|
if (ergebnis.status === "kein_zugang") redirect("/login?error=no_hr_access");
|
||||||
supabase.from("hire_drafts").select("id, step, payload, updated_at").eq("created_by", user.id).order("updated_at", { ascending: false }),
|
|
||||||
loadOpenNotes(supabase),
|
const data = ergebnis.daten;
|
||||||
]);
|
|
||||||
|
const userLabel = data.profile.full_name || data.profile.email || "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HireWizardProvider openPositions={openPositions} locations={locationsRes.data ?? []} drafts={draftsRes.data ?? []}>
|
<HireWizardProvider openPositions={data.openPositions} locations={data.locations} drafts={data.drafts}>
|
||||||
<AppShell userLabel={userLabel} openNotes={openNotes}>
|
<AppShell userLabel={userLabel} openNotes={data.openNotes} kollegen={data.kollegen}>
|
||||||
{children}
|
{children}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</HireWizardProvider>
|
</HireWizardProvider>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Every page in this group is server-rendered per request (they all read
|
// 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
|
// no feedback until the server answers — on the employee list, long enough
|
||||||
// to look broken.
|
// to look broken.
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { OrgChartClient } from "@/components/orgchart/OrgChartClient";
|
import { OrgChartClient } from "@/components/orgchart/OrgChartClient";
|
||||||
|
import type { OrgUnitNode } from "@/components/orgchart/types";
|
||||||
import { todayIso } from "@/lib/format";
|
import { todayIso } from "@/lib/format";
|
||||||
import { loadOrgAsOf } from "@/lib/orgchart-data";
|
import { loadOrgAsOf } from "@/lib/orgchart-data";
|
||||||
import { loadOpenPositions } from "@/lib/positions";
|
|
||||||
import { parseIsoDateParam } from "@/lib/reports";
|
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 };
|
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.
|
// 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 focusId = params.focus && UUID.test(params.focus) ? params.focus : null;
|
||||||
|
|
||||||
const supabase = await createClient();
|
const { org, units } = await withUser(await currentUserId(), async (tx) => {
|
||||||
|
const [org, units] = await Promise.all([
|
||||||
const [org, { data: divisions }, { data: departments }, { data: teams }, openPositions, { data: reorgScenarios }] =
|
loadOrgAsOf(tx, asOf),
|
||||||
await Promise.all([
|
tx
|
||||||
loadOrgAsOf(supabase, asOf),
|
.selectFrom("org_units")
|
||||||
supabase.from("divisions").select("*").order("name"),
|
// valid_from/valid_to nur hier: die Angaben zur Einheit zeigen sie,
|
||||||
supabase.from("departments").select("*"),
|
// der Druck braucht sie nicht.
|
||||||
supabase.from("teams").select("*"),
|
.select(["id", "org_number", "name", "parent_id", "unit_type", "valid_from", "valid_to"])
|
||||||
loadOpenPositions(supabase),
|
// Zum Stichtag — sonst zeigt die Struktursicht zum 01.01.2020
|
||||||
supabase
|
// Einheiten, die es damals nicht gab (D.06).
|
||||||
.from("reorg_scenarios")
|
.where("valid_from", "<=", asOf)
|
||||||
.select("id, name, effective_date, applied, applied_at")
|
.where((eb) => eb.or([eb("valid_to", "is", null), eb("valid_to", ">", asOf)]))
|
||||||
.eq("applied", true)
|
.orderBy("org_number")
|
||||||
.order("applied_at", { ascending: false })
|
.execute(),
|
||||||
.limit(5),
|
|
||||||
]);
|
]);
|
||||||
|
return { org, units };
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<OrgChartClient
|
<OrgChartClient
|
||||||
employees={org.employees}
|
employees={org.employees}
|
||||||
divisions={divisions ?? []}
|
units={units as OrgUnitNode[]}
|
||||||
departments={departments ?? []}
|
vacancies={org.vacancies}
|
||||||
teams={teams ?? []}
|
|
||||||
openPositions={openPositions}
|
|
||||||
reorgScenarios={reorgScenarios ?? []}
|
|
||||||
asOf={asOf}
|
asOf={asOf}
|
||||||
today={today}
|
today={today}
|
||||||
projectedCount={org.projectedCount}
|
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 { ChevronRight } from "lucide-react";
|
||||||
import Link from "next/link";
|
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 { DraftsCard } from "@/components/dashboard/DraftsCard";
|
||||||
import { Card, CARD_CLASS, CardTitle } from "@/components/ui/Card";
|
import { Card, CARD_CLASS, CardTitle } from "@/components/ui/Card";
|
||||||
import { actionBadgeStyle } from "@/lib/colors";
|
import { eventBadgeStyle, eventDotStyle } from "@/lib/colors";
|
||||||
import { addDaysIso, fmtDate, todayIso } from "@/lib/format";
|
import { istEingeschraenkt, parseArten, parseZeitraum } from "@/lib/dashboard-filter";
|
||||||
|
import { loadDashboardData } from "@/lib/dashboard-data";
|
||||||
|
import { addDaysIso, fmtName, todayIso } from "@/lib/format";
|
||||||
|
import { divisionOf } from "@/lib/org";
|
||||||
import { deriveStatusAsOf } from "@/lib/reports";
|
import { deriveStatusAsOf } from "@/lib/reports";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { currentUserId } from "@/lib/auth/session";
|
||||||
import { fetchAllRows } from "@/lib/supabase/query";
|
import { withUser } from "@/lib/db";
|
||||||
|
|
||||||
// Each KPI carries a colour already; the accent bar repeats it in a second
|
// 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
|
// 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" },
|
danger: { text: "text-danger-text", bar: "bg-danger-text" },
|
||||||
warning: { text: "text-warning-text", bar: "bg-warning-text" },
|
warning: { text: "text-warning-text", bar: "bg-warning-text" },
|
||||||
brand: { text: "text-brand-700", bar: "bg-brand-500" },
|
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> = {
|
/** Eine Kennzahl auf der Übersicht. */
|
||||||
Eintritt: "bg-success-text",
|
type Kachel = { label: string; value: string | number; tone: keyof typeof TONE; href: string };
|
||||||
Wiedereintritt: "bg-success-text",
|
|
||||||
Rückkehr: "bg-success-text",
|
/**
|
||||||
Austritt: "bg-danger-text",
|
* Wie viele Spalten die Überschrift einer Gruppe überspannt — nach der Zahl
|
||||||
Versetzung: "bg-info-text",
|
* ihrer Kacheln.
|
||||||
Beförderung: "bg-purple-text",
|
*
|
||||||
Reorganisation: "bg-purple-text",
|
* Ausgeschrieben und nicht als `span var(--kacheln)`: Tailwind erzeugt nur
|
||||||
Karenz: "bg-warning-text",
|
* Klassen, die als Zeichenkette im Quelltext stehen, und eine aus einer
|
||||||
Vertragsänderung: "bg-warning-text",
|
* Variablen berechnete Spannweite wäre zur Bauzeit nicht zu sehen. Die
|
||||||
Stammdatenänderung: "bg-warning-text",
|
* Tabelle deckt jede mögliche Gruppengrösse bis zur Rasterbreite ab; ein
|
||||||
Gehaltsanpassung: "bg-warning-text",
|
* 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;
|
// Die Tabelle, die hier stand, ist nach lib/colors.ts gewandert: Punkt und
|
||||||
|
// Chip derselben Zeile kamen aus zwei getrennten Verzeichnissen, und eines
|
||||||
export default async function DashboardPage() {
|
// davon war das falsche. Jetzt leiten beide aus EVENT_CATEGORY ab und koennen
|
||||||
const supabase = await createClient();
|
// nicht mehr auseinanderlaufen.
|
||||||
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: [] };
|
|
||||||
|
|
||||||
|
export default async function DashboardPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ tage?: string; arten?: string }>;
|
||||||
|
}) {
|
||||||
// Built as strings, not by round-tripping a local Date through
|
// Built as strings, not by round-tripping a local Date through
|
||||||
// toISOString(): in any positive-offset zone new Date(year, 0, 1) is still
|
// 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
|
// 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 today = todayIso();
|
||||||
const year = today.slice(0, 4);
|
const year = today.slice(0, 4);
|
||||||
const yearStart = `${year}-01-01`;
|
const yearStart = `${year}-01-01`;
|
||||||
const yearEnd = `${year}-12-31`;
|
// Das obere Ende des YTD-Fensters ist **heute**. Hier stand der 31.12., also
|
||||||
const in60Iso = addDaysIso(today, 60);
|
// 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
|
// Headcount, FTE, Karenz and the division bars all come from one full read
|
||||||
// and the *derived* status, not from the `employees.status` column.
|
// 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
|
// planned hire whose start date has passed, or a Karenz that ended without
|
||||||
// anyone recording the return, made the dashboard and the Berichte page
|
// anyone recording the return, made the dashboard and the Berichte page
|
||||||
// disagree about the same headcount. Same derivation, same numbers.
|
// disagree about the same headcount. Same derivation, same numbers.
|
||||||
// It also replaces four separate count queries with one.
|
const {
|
||||||
const [
|
drafts,
|
||||||
staffRows,
|
staffRows,
|
||||||
hiresYtdRes,
|
hiresYtd,
|
||||||
exitsYtdRes,
|
exitsYtd,
|
||||||
openPositionsRes,
|
openPositions,
|
||||||
divisionsRes,
|
orgMaps,
|
||||||
upcomingHiresRes,
|
placements,
|
||||||
upcomingExitsRes,
|
upcomingHires,
|
||||||
upcomingReturnsRes,
|
upcomingExits,
|
||||||
historyRes,
|
upcomingReturns,
|
||||||
] = await Promise.all([
|
upcomingNotes,
|
||||||
fetchAllRows(() =>
|
history,
|
||||||
supabase
|
} = await withUser(userId, (tx) => loadDashboardData(tx, { userId, today, yearStart, ytdBis, bisIso, arten }));
|
||||||
.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),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// "Aktiv" means status Aktiv — somebody on Karenz is employed but not
|
// "Aktiv" means status Aktiv — somebody on Karenz is employed but not
|
||||||
// active, and is counted by its own tile instead. FTE follows the same
|
// active, and is counted by its own tile instead. FTE follows the same
|
||||||
// set: Karenz contributes no capacity, so including it would overstate
|
// 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 karenzCount = staffRows.filter((row) => statusOf(row) === "Karenz").length;
|
||||||
const fte = activeStaff.reduce((sum, row) => sum + Number(row.weekly_hours), 0) / 38.5;
|
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>();
|
const headcountByDivision = new Map<string, number>();
|
||||||
for (const row of activeStaff) {
|
for (const row of activeStaff) {
|
||||||
if (!row.division_id) continue;
|
const division = divisionOf(orgMaps, placements.get(row.id)?.orgUnitId);
|
||||||
headcountByDivision.set(row.division_id, (headcountByDivision.get(row.division_id) ?? 0) + 1);
|
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 }))
|
.map((d) => ({ name: d.name, count: headcountByDivision.get(d.id) ?? 0 }))
|
||||||
.sort((a, b) => b.count - a.count);
|
.sort((a, b) => b.count - a.count);
|
||||||
const maxDivisionCount = Math.max(1, ...divisionBars.map((d) => d.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 upcomingAlle: AnstehendEintrag[] = [
|
||||||
const upcoming: UpcomingItem[] = [
|
...(upcomingHires).map((e) => ({
|
||||||
...(upcomingHiresRes.data ?? []).map((e) => ({
|
|
||||||
id: e.id,
|
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,
|
date: e.entry_date,
|
||||||
kind: "hire" as const,
|
kind: "hire" as const,
|
||||||
})),
|
})),
|
||||||
...(upcomingExitsRes.data ?? []).map((e) => ({
|
...(upcomingExits).map((e) => ({
|
||||||
id: e.id,
|
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!,
|
date: e.exit_date!,
|
||||||
kind: "exit" as const,
|
kind: "exit" as const,
|
||||||
})),
|
})),
|
||||||
...(upcomingReturnsRes.data ?? []).map((e) => ({
|
...(upcomingReturns).map((e) => ({
|
||||||
id: e.id,
|
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!,
|
date: e.karenz_return_date!,
|
||||||
kind: "return" as const,
|
kind: "return" as const,
|
||||||
})),
|
})),
|
||||||
]
|
...(upcomingNotes).map((n) => ({
|
||||||
.sort((a, b) => a.date.localeCompare(b.date))
|
id: n.id,
|
||||||
.slice(0, 8);
|
employeeId: n.employee_id!,
|
||||||
|
label: fmtName(n.first_name, n.last_name),
|
||||||
const historyEmployeeIds = Array.from(new Set((historyRes.data ?? []).map((h) => h.employee_id)));
|
hinweis: n.note_text,
|
||||||
const historyEmployeesRes = historyEmployeeIds.length
|
date: n.due_date!,
|
||||||
? await supabase.from("employees").select("id, first_name, last_name").in("id", historyEmployeeIds)
|
kind: "note" as const,
|
||||||
: { 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}`]));
|
].sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
|
||||||
// Each tile links to the view that shows what it counts, with the filters
|
// Each tile links to the view that shows what it counts, with the filters
|
||||||
// pre-applied.
|
// pre-applied.
|
||||||
@@ -193,50 +179,143 @@ export default async function DashboardPage() {
|
|||||||
// and rehire_employee sets entry_date but logs the event as
|
// and rehire_employee sets entry_date but logs the event as
|
||||||
// 'Wiedereintritt'. A year with rehires therefore shows a slightly higher
|
// 'Wiedereintritt'. A year with rehires therefore shows a slightly higher
|
||||||
// number on the tile than in the linked report.
|
// 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",
|
titel: "Personalstand",
|
||||||
value: activeCount,
|
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",
|
tone: "default",
|
||||||
href: "/employees?status=Aktiv",
|
// 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: "FTE", value: fte.toFixed(1), tone: "default", href: "/reports?mode=snapshot&measure=fte&status=Aktiv" },
|
{ 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: "Eintritte (Jahr)",
|
label: "Geplante Eintritte",
|
||||||
value: hiresYtdRes.count ?? 0,
|
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" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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",
|
tone: "success",
|
||||||
href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${yearEnd}`,
|
href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${ytdBis}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Austritte (Jahr)",
|
label: "Austritte (YTD)",
|
||||||
value: exitsYtdRes.count ?? 0,
|
value: exitsYtd,
|
||||||
tone: "danger",
|
tone: "danger",
|
||||||
href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`,
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{drafts && drafts.length > 0 && <DraftsCard drafts={drafts} />}
|
{drafts.length > 0 && <DraftsCard drafts={drafts} />}
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
{/* ── Ein Raster, nicht drei nebeneinandergestellte ──────────────
|
||||||
{kpis.map((kpi) => (
|
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
|
<Link
|
||||||
key={kpi.label}
|
key={kpi.label}
|
||||||
href={kpi.href}
|
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`}
|
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 />
|
<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
|
{/* Number first in the visual order: it is what the eye is
|
||||||
looking for, and the label only qualifies it. */}
|
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>
|
|
||||||
|
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">
|
<div className="mt-1.5 flex items-center gap-1 text-xs font-semibold leading-tight text-ink-muted">
|
||||||
{kpi.label}
|
{kpi.label}
|
||||||
<ChevronRight className="h-3 w-3 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" aria-hidden />
|
<ChevronRight className="h-3 w-3 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||||
@@ -266,38 +345,48 @@ export default async function DashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardTitle className="mb-1">Anstehend (60 Tage)</CardTitle>
|
<CardTitle className="mb-2">Anstehend ({zeitraum} Tage)</CardTitle>
|
||||||
<ul className="flex flex-col divide-y divide-border-subtle">
|
{/* useSearchParams braucht eine Suspense-Grenze; ohne sie fällt beim
|
||||||
{upcoming.map((item) => (
|
Bauen die ganze Seite auf Rendern zur Laufzeit zurück. */}
|
||||||
<li key={`${item.kind}-${item.id}`}>
|
<Suspense fallback={<div className="mb-2 h-9" />}>
|
||||||
<Link
|
<AnstehendFilter zeitraum={zeitraum} arten={arten} />
|
||||||
href={`/employees/${item.id}`}
|
</Suspense>
|
||||||
className="-mx-2 flex items-center justify-between gap-2 rounded px-2 py-2.5 text-sm hover:bg-surface"
|
<AnstehendListe
|
||||||
>
|
eintraege={upcomingAlle}
|
||||||
<span className="min-w-0">
|
today={today}
|
||||||
<span className="block truncate font-semibold text-ink">{item.label}</span>
|
leerText={
|
||||||
<span className="text-xs text-ink-muted">{KIND_LABEL[item.kind]}</span>
|
istEingeschraenkt(zeitraum, arten)
|
||||||
</span>
|
? "Zu dieser Auswahl steht nichts an."
|
||||||
<span className="shrink-0 text-xs font-semibold tabular-nums text-ink-muted">{fmtDate(item.date)}</span>
|
: "Keine anstehenden Ereignisse."
|
||||||
</Link>
|
}
|
||||||
</li>
|
/>
|
||||||
))}
|
|
||||||
{upcoming.length === 0 && <p className="py-2 text-sm text-ink-muted">Keine anstehenden Ereignisse.</p>}
|
|
||||||
</ul>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardTitle className="mb-1">Letzte Aktivitäten</CardTitle>
|
<CardTitle className="mb-1">Letzte Aktivitäten</CardTitle>
|
||||||
<ul className="flex flex-col divide-y divide-border-subtle">
|
<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">
|
<li key={h.id} className="flex gap-2.5 py-2.5">
|
||||||
{/* Dot aligned to the first line of text, not centred on the
|
{/* Dot aligned to the first line of text, not centred on the
|
||||||
whole row, so it stays put as descriptions wrap. */}
|
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="min-w-0 flex-1">
|
||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-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>
|
{/* Der Name führt in die Akte. Ohne den Link war die Karte
|
||||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${actionBadgeStyle(h.event_type)}`}>
|
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}
|
{h.event_type}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -305,7 +394,7 @@ export default async function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,21 +1,51 @@
|
|||||||
|
import type { UnitOption } from "@/components/positions/CreatePositionModal";
|
||||||
import { PositionsPageClient } from "@/components/positions/PositionsPageClient";
|
import { PositionsPageClient } from "@/components/positions/PositionsPageClient";
|
||||||
import { daysBetweenIso, toIsoDate } from "@/lib/format";
|
import { currentUserId } from "@/lib/auth/session";
|
||||||
import { loadOpenPositions } from "@/lib/positions";
|
import { kostenstellenAbfrage } from "@/lib/cost-centers";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
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() {
|
export default async function PositionsPage() {
|
||||||
const supabase = await createClient();
|
const today = todayIso();
|
||||||
|
|
||||||
// Teams are only needed for the "Position ausschreiben" dialog's team
|
const { openPositions, orgMaps, chiefRows, kostenstellen } = await withUser(await currentUserId(), async (tx) => {
|
||||||
// select. The division/department/team headcount overview this page used
|
// Alles, was ohne Vorwissen geht, in einer Rundreise (lib/db/json.ts).
|
||||||
// to render was dropped, and with it the two employee-wide aggregation
|
const g = await tx
|
||||||
// queries that fed it.
|
.selectNoFrom((eb) => [
|
||||||
const [openPositions, { data: teams }] = await Promise.all([
|
...orgMapsAbfragen(eb),
|
||||||
loadOpenPositions(supabase),
|
jsonArrayFrom(offeneStellenAbfrage(eb, today)).as("open"),
|
||||||
supabase.from("teams").select("*").order("name"),
|
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,
|
parseEventDateParam,
|
||||||
parseEventGroupDimension,
|
parseEventGroupDimension,
|
||||||
parseEventSplitDimension,
|
parseEventSplitDimension,
|
||||||
|
parseEventSubtype,
|
||||||
parseEventType,
|
parseEventType,
|
||||||
parseGroupDimension,
|
parseGroupDimension,
|
||||||
parseIsoDateParam,
|
parseIsoDateParam,
|
||||||
@@ -15,10 +16,15 @@ import {
|
|||||||
sumValues,
|
sumValues,
|
||||||
totalForRows,
|
totalForRows,
|
||||||
} from "@/lib/reports";
|
} from "@/lib/reports";
|
||||||
|
import { parseCriteria } from "@/lib/report-criteria";
|
||||||
import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data";
|
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;
|
mode?: string;
|
||||||
measure?: string;
|
measure?: string;
|
||||||
group?: string;
|
group?: string;
|
||||||
@@ -26,16 +32,15 @@ type SearchParams = {
|
|||||||
division?: string;
|
division?: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
employment?: string;
|
|
||||||
asOf?: string;
|
asOf?: string;
|
||||||
eventType?: string;
|
eventType?: string;
|
||||||
|
eventSubtype?: string;
|
||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function ReportsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
export default async function ReportsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
const supabase = await createClient();
|
|
||||||
const mode = parseMode(params.mode);
|
const mode = parseMode(params.mode);
|
||||||
|
|
||||||
// Both modes are parsed up front so the data load can start before
|
// Both modes are parsed up front so the data load can start before
|
||||||
@@ -45,22 +50,31 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
|||||||
const eventGroup = parseEventGroupDimension(params.group);
|
const eventGroup = parseEventGroupDimension(params.group);
|
||||||
const eventSplit = parseEventSplitDimension(params.split);
|
const eventSplit = parseEventSplitDimension(params.split);
|
||||||
const eventType = parseEventType(params.eventType);
|
const eventType = parseEventType(params.eventType);
|
||||||
|
const eventSubtype = parseEventSubtype(params.eventSubtype, eventType);
|
||||||
const from = parseEventDateParam(params.from);
|
const from = parseEventDateParam(params.from);
|
||||||
const to = parseEventDateParam(params.to);
|
const to = parseEventDateParam(params.to);
|
||||||
const measure = parseMeasure(params.measure);
|
const measure = parseMeasure(params.measure);
|
||||||
const group = parseGroupDimension(params.group);
|
const group = parseGroupDimension(params.group);
|
||||||
const split = parseSplitDimension(params.split);
|
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
|
// 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
|
// 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
|
// costs about as much as the query itself, which made this page's three
|
||||||
// sequential waves its dominant cost.
|
// sequential waves its dominant cost.
|
||||||
const [{ lookups, divisions, locations }, { data: userRes }, events, employees] = await Promise.all([
|
const userId = await currentUserId();
|
||||||
loadOrgLookups(supabase),
|
|
||||||
supabase.auth.getUser(),
|
// 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"
|
mode === "events"
|
||||||
? loadEventHistory(supabase, {
|
? loadEventHistory(tx, {
|
||||||
eventType: eventType ?? undefined,
|
eventType: eventType ?? undefined,
|
||||||
|
eventSubtype: eventSubtype ?? undefined,
|
||||||
division: params.division,
|
division: params.division,
|
||||||
location: params.location,
|
location: params.location,
|
||||||
from,
|
from,
|
||||||
@@ -68,21 +82,25 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
|||||||
})
|
})
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
mode === "snapshot"
|
mode === "snapshot"
|
||||||
? loadSnapshotEmployees(supabase, {
|
? loadSnapshotEmployees(tx, {
|
||||||
division: params.division,
|
division: params.division,
|
||||||
location: params.location,
|
location: params.location,
|
||||||
status: params.status,
|
status: params.status,
|
||||||
employment: params.employment,
|
criteria,
|
||||||
asOf,
|
asOf,
|
||||||
})
|
})
|
||||||
: Promise.resolve([]),
|
: 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 };
|
||||||
const user = userRes.user;
|
});
|
||||||
// Still a wave of its own: it needs the user id the call above resolves.
|
|
||||||
const { data: savedReports } = user
|
|
||||||
? await supabase.from("saved_reports").select("id, name, config").eq("created_by", user.id).order("created_at", { ascending: false })
|
|
||||||
: { data: [] };
|
|
||||||
|
|
||||||
if (mode === "events") {
|
if (mode === "events") {
|
||||||
const rows = aggregateEvents(events, eventGroup, eventSplit, lookups);
|
const rows = aggregateEvents(events, eventGroup, eventSplit, lookups);
|
||||||
@@ -94,13 +112,14 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
|||||||
eventGroup={eventGroup}
|
eventGroup={eventGroup}
|
||||||
eventSplit={eventSplit ?? ""}
|
eventSplit={eventSplit ?? ""}
|
||||||
eventType={eventType ?? ""}
|
eventType={eventType ?? ""}
|
||||||
|
eventSubtype={eventSubtype ?? ""}
|
||||||
eventFilters={{ division: params.division ?? "", location: params.location ?? "", from: from ?? "", to: to ?? "" }}
|
eventFilters={{ division: params.division ?? "", location: params.location ?? "", from: from ?? "", to: to ?? "" }}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
total={sumValues(rows)}
|
total={sumValues(rows)}
|
||||||
recordCount={events.length}
|
recordCount={events.length}
|
||||||
divisions={divisions}
|
units={units}
|
||||||
locations={locations}
|
locations={locations}
|
||||||
savedReports={savedReports ?? []}
|
savedReports={savedReports}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
@@ -120,14 +139,14 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
|||||||
division: params.division ?? "",
|
division: params.division ?? "",
|
||||||
location: params.location ?? "",
|
location: params.location ?? "",
|
||||||
status: params.status ?? "",
|
status: params.status ?? "",
|
||||||
employment: params.employment ?? "",
|
|
||||||
}}
|
}}
|
||||||
|
criteria={criteria}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
total={totalForRows(rows, measure)}
|
total={totalForRows(rows, measure)}
|
||||||
recordCount={employees.length}
|
recordCount={employees.length}
|
||||||
divisions={divisions}
|
units={units}
|
||||||
locations={locations}
|
locations={locations}
|
||||||
savedReports={savedReports ?? []}
|
savedReports={savedReports}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,31 @@
|
|||||||
import { login, logout } from "@/actions/auth";
|
import { EntraSignInButton } from "@/components/auth/EntraSignInButton";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { PasswortFormular } from "@/components/auth/PasswortFormular";
|
||||||
import { CONTROL_CLASS } from "@/components/ui/Field";
|
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
|
// 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
|
// looked up by code rather than whatever text ?error= carries. Reflecting the
|
||||||
// raw parameter let anyone put arbitrary wording ("Ihr Konto wurde gesperrt,
|
// 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 = {
|
const ERROR_MESSAGES = {
|
||||||
no_hr_access: "Kein HR-Zugriff. Bitte wenden Sie sich an eine:n bestehende:n HR-Benutzer:in.",
|
no_hr_access: {
|
||||||
invalid_credentials: "E-Mail oder Passwort ist falsch.",
|
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;
|
} as const;
|
||||||
|
|
||||||
type ErrorCode = keyof typeof ERROR_MESSAGES;
|
type ErrorCode = keyof typeof ERROR_MESSAGES;
|
||||||
@@ -19,60 +36,140 @@ type LoginPageProps = {
|
|||||||
|
|
||||||
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||||
const params = await searchParams;
|
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;
|
const error = code ? ERROR_MESSAGES[code] : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-surface px-4">
|
// dvh statt vh: auf iOS zählt vh die Adressleiste mit, wodurch die Karte
|
||||||
<div className="w-full max-w-sm rounded border border-border bg-white p-8 shadow-sm">
|
// im ersten Moment unter dem Faltenrand sitzt.
|
||||||
<h1 className="text-xl font-extrabold text-ink">Alpenwerk HR</h1>
|
<div className="min-h-dvh lg:grid lg:grid-cols-[1.05fr_1fr]">
|
||||||
<p className="mt-1 text-sm text-ink-muted">Melden Sie sich mit Ihrem Firmenkonto an.</p>
|
<BrandPanel />
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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 && (
|
{error && (
|
||||||
<div role="alert" className="mt-4 rounded bg-danger-bg px-3 py-2 text-sm text-danger-text">
|
<div role="alert" className="mt-6 rounded-md border border-danger-text/20 bg-danger-bg px-4 py-3">
|
||||||
{error}
|
<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" && (
|
{code === "no_hr_access" && (
|
||||||
<form action={logout} className="mt-2">
|
<form action={logout} className="mt-3">
|
||||||
<button type="submit" className="text-xs font-semibold underline hover:no-underline">
|
<button
|
||||||
Abmelden und mit anderem Konto versuchen
|
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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form action={login} className="mt-6 flex flex-col gap-4">
|
<form action={signInWithEntra} className="mt-7">
|
||||||
<div>
|
<EntraSignInButton />
|
||||||
<label htmlFor="email" className="mb-1 block text-sm font-semibold text-ink">
|
|
||||||
E-Mail
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
autoComplete="username"
|
|
||||||
className={CONTROL_CLASS}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="password" className="mb-1 block text-sm font-semibold text-ink">
|
|
||||||
Passwort
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
autoComplete="current-password"
|
|
||||||
className={CONTROL_CLASS}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button type="submit" fullWidth className="mt-2">
|
|
||||||
Anmelden
|
|
||||||
</Button>
|
|
||||||
</form>
|
</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>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<PasswortFormular />
|
||||||
|
</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>
|
</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 { 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
|
// Applies effective-dated changes (Versetzung/Beförderung/Karenz/Reorg/Daten
|
||||||
// ändern with a future "Wirksam ab" date) once their date has arrived — see
|
// ä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
|
// apply_due_pending_changes() in db/migrations.
|
||||||
// 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
|
// Gerufen wird das vom `cron`-Dienst aus docker-compose.yml, täglich um 03:00.
|
||||||
// service-role client (the one legitimate server-only use case for it).
|
// 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) {
|
export async function GET(request: NextRequest) {
|
||||||
const authHeader = request.headers.get("authorization");
|
const authHeader = request.headers.get("authorization");
|
||||||
if (!process.env.CRON_SECRET || authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
|
if (!process.env.CRON_SECRET || authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
|
||||||
return NextResponse.json({ error: "Nicht autorisiert." }, { status: 401 });
|
return NextResponse.json({ error: "Nicht autorisiert." }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabase = createAdminClient();
|
// Kein privilegierter Zugang mehr: derselbe Datenbankbenutzer ohne
|
||||||
const { data, error } = await supabase.rpc("apply_due_pending_changes");
|
// BYPASSRLS wie überall. apply_due_pending_changes ist SECURITY DEFINER
|
||||||
|
// und prüft selbst, was sie tut — der Dienstschlüssel, der RLS aushebelte,
|
||||||
if (error) {
|
// ist damit entfallen.
|
||||||
console.error("apply_due_pending_changes failed:", error);
|
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({ 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 { NextResponse, type NextRequest } from "next/server";
|
||||||
import { statusLabel } from "@/lib/absence";
|
import { statusLabel } from "@/lib/absence";
|
||||||
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
|
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
|
||||||
import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "@/lib/reports";
|
import { exportParameter, ladeExportMitarbeiter, type ExportMitarbeiter } from "@/lib/export-auswahl";
|
||||||
import { loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data";
|
import { sortiereWochentage } from "@/lib/wochentage";
|
||||||
import { requireHrUser } from "@/lib/supabase/auth";
|
import { deriveStatusAsOf, type OrgLookups } from "@/lib/reports";
|
||||||
import { fetchAllRows } from "@/lib/supabase/query";
|
import { requireHrUser } from "@/lib/auth/require-hr";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { withUser } from "@/lib/db";
|
||||||
import type { Database, EmploymentType, Weekday } from "@/lib/supabase/types";
|
|
||||||
|
|
||||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
type EmployeeRow = ExportMitarbeiter;
|
||||||
|
|
||||||
// Full raw data dump — every column on `employees`, not just the fields a
|
// Full raw data dump — every column on `employees`, not just the fields a
|
||||||
// pivot report groups by. Respects the same division/location/status/
|
// pivot report groups by. Respects the same division/location/status/
|
||||||
// employment filters as the Berichte page. With `asOf` (Stichtag), status
|
// employment filters as the Berichte page. Wer darin steht, entscheidet
|
||||||
// filtering happens against the *derived* status as of that date rather
|
// lib/export-auswahl.ts — dieselbe Auswahl wie im Honestly-Export.
|
||||||
// than the live `status` column — see deriveStatusAsOf.
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const supabase = await createClient();
|
const gate = await requireHrUser();
|
||||||
const denied = await requireHrUser(supabase);
|
if ("denied" in gate) return gate.denied;
|
||||||
if (denied) return denied;
|
|
||||||
|
|
||||||
const params = request.nextUrl.searchParams;
|
const { format, asOf, filters } = exportParameter(request.nextUrl.searchParams);
|
||||||
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
|
const { rows, lookups, managerName, dependentsCounts } = await withUser(gate.userId, (tx) =>
|
||||||
const asOf = parseIsoDateParam(params.get("asOf"));
|
ladeExportMitarbeiter(tx, { asOf, filters })
|
||||||
const filters: ReportFilters = {
|
);
|
||||||
division: params.get("division") ?? undefined,
|
|
||||||
location: params.get("location") ?? undefined,
|
|
||||||
status: params.get("status") ?? undefined,
|
|
||||||
employment: params.get("employment") ?? undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
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 columns = employeeExportColumns(lookups, managerName, dependentsCounts, asOf);
|
||||||
const filename = exportFilename("mitarbeiter-export", format);
|
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) });
|
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(
|
function employeeExportColumns(
|
||||||
lookups: OrgLookups,
|
lookups: OrgLookups,
|
||||||
managerName: Map<string, string>,
|
managerName: Map<string, string>,
|
||||||
@@ -75,34 +45,59 @@ function employeeExportColumns(
|
|||||||
{ header: "Geburtsdatum", get: (e) => e.birth_date, kind: "date" },
|
{ header: "Geburtsdatum", get: (e) => e.birth_date, kind: "date" },
|
||||||
{ header: "SV-Nummer", get: (e) => e.sv_nummer },
|
{ header: "SV-Nummer", get: (e) => e.sv_nummer },
|
||||||
{ header: "Staatsbürgerschaft", get: (e) => e.nationality },
|
{ 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: "Adresse", get: (e) => e.address },
|
||||||
{ header: "Postleitzahl", get: (e) => e.postal_code },
|
{ header: "Postleitzahl", get: (e) => e.postal_code },
|
||||||
{ header: "Ort", get: (e) => e.city },
|
{ header: "Ort", get: (e) => e.city },
|
||||||
{ header: "Wohnsitzland", get: (e) => e.address_country },
|
{ header: "Wohnsitzland", get: (e) => e.address_country },
|
||||||
{ header: "E-Mail", get: (e) => e.email },
|
{ header: "Private E-Mail", get: (e) => e.email },
|
||||||
{ header: "Telefon", get: (e) => e.phone },
|
{ header: "Firmen-E-Mail", get: (e) => e.company_email },
|
||||||
{ header: "Bereich", get: (e) => lookups.divisionName.get(e.division_id) ?? "" },
|
{ header: "Cornerstone-ID", get: (e) => e.cornerstone_id },
|
||||||
{ header: "Abteilung", get: (e) => (e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "") : "") },
|
{ header: "Private Telefonnummer", get: (e) => e.phone },
|
||||||
{ 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: "Standort", get: (e) => lookups.locationName.get(e.location_id) ?? "" },
|
||||||
{ header: "Position", get: (e) => e.job_title },
|
{ header: "Position", get: (e) => e.job_title },
|
||||||
{ header: "Vorgesetzte:r", get: (e) => (e.manager_id ? (managerName.get(e.manager_id) ?? "") : "") },
|
{ header: "Vorgesetzte:r", get: (e) => (e.manager_id ? (managerName.get(e.manager_id) ?? "") : "") },
|
||||||
{ header: "Führungskraft", get: (e) => e.is_lead },
|
{ header: "Planstelle", get: (e) => e.position_number },
|
||||||
{ header: "Org-Level", get: (e) => e.org_level },
|
{ header: "Leitungsplanstelle", get: (e) => e.is_chief },
|
||||||
{ header: "Beschäftigungsausmaß", get: (e) => e.employment_type },
|
{ header: "Beschäftigungsausmaß", get: (e) => e.employment_type },
|
||||||
{ header: "Wochenstunden", get: (e) => e.weekly_hours },
|
{ header: "Wochenstunden", get: (e) => e.weekly_hours },
|
||||||
// work_days is stored in click order (see RoleEmploymentFields), not
|
// Seit lib/wochentage.ts wird sortiert gespeichert; der Bestand kann aber
|
||||||
// guaranteed chronological — re-sort Mo→So for the export.
|
// noch unsortierte Zeilen aus der Zeit davor tragen. Hier bleibt es
|
||||||
{ header: "Arbeitstage", get: (e) => [...e.work_days].sort((a, b) => WEEKDAY_ORDER.indexOf(a as Weekday) - WEEKDAY_ORDER.indexOf(b as Weekday)).join(", ") },
|
// 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: "Vertragsart", get: (e) => e.contract_type },
|
||||||
{ header: "Befristet bis", get: (e) => e.contract_end_date, kind: "date" },
|
{ 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: "Kollektivvertrag", get: (e) => e.collective_agreement },
|
||||||
{ header: "Betriebsrat", get: (e) => e.is_betriebsrat },
|
{ header: "Betriebsrat", get: (e) => e.is_betriebsrat },
|
||||||
{ header: "Dienstwagen", get: (e) => e.has_dienstwagen },
|
{ 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: "Laterale Führung", get: (e) => e.is_laterale_fuehrung },
|
||||||
{ header: "C-Level", get: (e) => e.is_c_level },
|
{ 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 },
|
{ header: "Herkunft", get: (e) => e.source },
|
||||||
// The export shows the display name, not the raw enum value — a payroll
|
// The export shows the display name, not the raw enum value — a payroll
|
||||||
// hand-off saying "Karenz" for what the app calls Langzeitabwesenheit
|
// 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: "Eintrittsdatum", get: (e) => e.entry_date, kind: "date" },
|
||||||
{ header: "Austrittsdatum", get: (e) => e.exit_date, kind: "date" },
|
{ header: "Austrittsdatum", get: (e) => e.exit_date, kind: "date" },
|
||||||
{ header: "Austrittsgrund", get: (e) => e.exit_reason },
|
{ 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: "Abwesenheit ab", get: (e) => e.karenz_start_date, kind: "date" },
|
||||||
{ header: "Rückkehr geplant", get: (e) => e.karenz_return_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 },
|
{ header: "Anzahl Angehörige", get: (e) => dependentsCounts.get(e.id) ?? 0 },
|
||||||
|
|||||||
@@ -1,33 +1,46 @@
|
|||||||
import { NextResponse, type NextRequest } from "next/server";
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
|
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 { loadEventHistory, loadOrgLookups } from "@/lib/reports-data";
|
||||||
import { requireHrUser } from "@/lib/supabase/auth";
|
import { requireHrUser } from "@/lib/auth/require-hr";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { withUser } from "@/lib/db";
|
||||||
|
|
||||||
// Full raw event-log dump — one row per employee_history entry in the
|
// Full raw event-log dump — one row per employee_history entry in the
|
||||||
// selected period (default: current year), every event type unless one is
|
// selected period (default: current year), every event type unless one is
|
||||||
// picked, org columns resolved from each affected employee's current
|
// picked, org columns resolved from each affected employee's current
|
||||||
// placement (see loadEventHistory).
|
// placement (see loadEventHistory).
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const supabase = await createClient();
|
const gate = await requireHrUser();
|
||||||
const denied = await requireHrUser(supabase);
|
if ("denied" in gate) return gate.denied;
|
||||||
if (denied) return denied;
|
|
||||||
|
|
||||||
const params = request.nextUrl.searchParams;
|
const params = request.nextUrl.searchParams;
|
||||||
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
|
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
|
||||||
const eventType = parseEventType(params.get("eventType"));
|
const eventType = parseEventType(params.get("eventType"));
|
||||||
|
|
||||||
|
const { lookups, events } = await withUser(gate.userId, async (tx) => {
|
||||||
const [{ lookups }, events] = await Promise.all([
|
const [{ lookups }, events] = await Promise.all([
|
||||||
loadOrgLookups(supabase),
|
loadOrgLookups(tx),
|
||||||
loadEventHistory(supabase, {
|
loadEventHistory(tx, {
|
||||||
eventType: eventType ?? undefined,
|
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,
|
division: params.get("division") ?? undefined,
|
||||||
location: params.get("location") ?? undefined,
|
location: params.get("location") ?? undefined,
|
||||||
from: parseEventDateParam(params.get("from")),
|
from: parseEventDateParam(params.get("from")),
|
||||||
to: parseEventDateParam(params.get("to")),
|
to: parseEventDateParam(params.get("to")),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
return { lookups, events };
|
||||||
|
});
|
||||||
|
|
||||||
const columns = eventExportColumns(lookups);
|
const columns = eventExportColumns(lookups);
|
||||||
const filename = exportFilename(`ereignisse-${eventType ?? "alle"}`, format);
|
const filename = exportFilename(`ereignisse-${eventType ?? "alle"}`, format);
|
||||||
@@ -41,12 +54,15 @@ function eventExportColumns(lookups: OrgLookups): ExportColumn<ReportEvent>[] {
|
|||||||
return [
|
return [
|
||||||
{ header: "Datum", get: (e) => e.event_date, kind: "date" },
|
{ header: "Datum", get: (e) => e.event_date, kind: "date" },
|
||||||
{ header: "Ereignistyp", get: (e) => EVENT_TYPE_LABELS[e.event_type] ?? e.event_type },
|
{ 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: "Vorname", get: (e) => e.first_name },
|
||||||
{ header: "Nachname", get: (e) => e.last_name },
|
{ header: "Nachname", get: (e) => e.last_name },
|
||||||
{ header: "Position", get: (e) => e.job_title },
|
{ header: "Position", get: (e) => e.job_title },
|
||||||
{ header: "Bereich", get: (e) => lookups.divisionName.get(e.division_id) ?? "" },
|
{ header: "Bereich", get: (e) => (e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "") : "") },
|
||||||
{ header: "Abteilung", get: (e) => (e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "") : "") },
|
{ header: "Abteilung", get: (e) => (e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "") : "") },
|
||||||
{ header: "Team", get: (e) => (e.team_id ? (lookups.teamName.get(e.team_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: "Standort", get: (e) => lookups.locationName.get(e.location_id) ?? "" },
|
||||||
{ header: "Beschreibung", get: (e) => e.description },
|
{ 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 { NextResponse, type NextRequest } from "next/server";
|
||||||
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
|
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
|
||||||
|
import { parseCriteria } from "@/lib/report-criteria";
|
||||||
import {
|
import {
|
||||||
aggregateEvents,
|
aggregateEvents,
|
||||||
aggregateReport,
|
aggregateReport,
|
||||||
@@ -24,23 +25,24 @@ import {
|
|||||||
type ReportRow,
|
type ReportRow,
|
||||||
} from "@/lib/reports";
|
} from "@/lib/reports";
|
||||||
import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data";
|
import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data";
|
||||||
import { requireHrUser } from "@/lib/supabase/auth";
|
import { requireHrUser } from "@/lib/auth/require-hr";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { withUser } from "@/lib/db";
|
||||||
|
|
||||||
// Exports exactly the pivot table currently on screen (same mode/measure or
|
// Exports exactly the pivot table currently on screen (same mode/measure or
|
||||||
// event-type/group/split/filters, read from the query string the client
|
// 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
|
// already keeps in the URL) as a flat table — one row per group, one column
|
||||||
// per split value if a split is active.
|
// per split value if a split is active.
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const supabase = await createClient();
|
const gate = await requireHrUser();
|
||||||
const denied = await requireHrUser(supabase);
|
if ("denied" in gate) return gate.denied;
|
||||||
if (denied) return denied;
|
|
||||||
|
|
||||||
const params = request.nextUrl.searchParams;
|
const params = request.nextUrl.searchParams;
|
||||||
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
|
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
|
||||||
const mode = parseMode(params.get("mode"));
|
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 rows: ReportRow[];
|
||||||
let columns: ExportColumn<ReportRow>[];
|
let columns: ExportColumn<ReportRow>[];
|
||||||
let filenameBase: string;
|
let filenameBase: string;
|
||||||
@@ -49,7 +51,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const group = parseEventGroupDimension(params.get("group"));
|
const group = parseEventGroupDimension(params.get("group"));
|
||||||
const split = parseEventSplitDimension(params.get("split"));
|
const split = parseEventSplitDimension(params.get("split"));
|
||||||
const eventType = parseEventType(params.get("eventType"));
|
const eventType = parseEventType(params.get("eventType"));
|
||||||
const events = await loadEventHistory(supabase, {
|
const events = await loadEventHistory(tx, {
|
||||||
eventType: eventType ?? undefined,
|
eventType: eventType ?? undefined,
|
||||||
division: params.get("division") ?? undefined,
|
division: params.get("division") ?? undefined,
|
||||||
location: params.get("location") ?? undefined,
|
location: params.get("location") ?? undefined,
|
||||||
@@ -64,11 +66,11 @@ export async function GET(request: NextRequest) {
|
|||||||
const group = parseGroupDimension(params.get("group"));
|
const group = parseGroupDimension(params.get("group"));
|
||||||
const split = parseSplitDimension(params.get("split"));
|
const split = parseSplitDimension(params.get("split"));
|
||||||
const asOf = parseIsoDateParam(params.get("asOf"));
|
const asOf = parseIsoDateParam(params.get("asOf"));
|
||||||
const employees = await loadSnapshotEmployees(supabase, {
|
const employees = await loadSnapshotEmployees(tx, {
|
||||||
division: params.get("division") ?? undefined,
|
division: params.get("division") ?? undefined,
|
||||||
location: params.get("location") ?? undefined,
|
location: params.get("location") ?? undefined,
|
||||||
status: params.get("status") ?? undefined,
|
status: params.get("status") ?? undefined,
|
||||||
employment: params.get("employment") ?? undefined,
|
criteria: parseCriteria((k) => params.get(k)),
|
||||||
asOf,
|
asOf,
|
||||||
});
|
});
|
||||||
rows = aggregateReport(employees, measure, group, split, lookups, asOf);
|
rows = aggregateReport(employees, measure, group, split, lookups, asOf);
|
||||||
@@ -76,7 +78,11 @@ export async function GET(request: NextRequest) {
|
|||||||
filenameBase = `bericht-${measure}-${group}`;
|
filenameBase = `bericht-${measure}-${group}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { rows, columns, filenameBase };
|
||||||
|
});
|
||||||
|
|
||||||
const filename = exportFilename(filenameBase, format);
|
const filename = exportFilename(filenameBase, format);
|
||||||
|
|
||||||
const body = format === "xlsx" ? await toXlsx(rows, columns, "Bericht") : toCsv(rows, columns);
|
const body = format === "xlsx" ? await toXlsx(rows, columns, "Bericht") : toCsv(rows, columns);
|
||||||
// TS 5.9's Uint8Array<ArrayBufferLike> vs DOM's BlobPart/ArrayBuffer<> generic
|
// TS 5.9's Uint8Array<ArrayBufferLike> vs DOM's BlobPart/ArrayBuffer<> generic
|
||||||
// mismatch (microsoft/TypeScript#59417) — a real Uint8Array works fine here.
|
// 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
|
// (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
|
// the app's fonts, Tailwind layer or shared components — hence the inline
|
||||||
// styles. Kept deliberately plain; anything clever here can fail too.
|
// 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 }) {
|
export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.error("Global error:", error);
|
console.error("Global error:", error);
|
||||||
@@ -13,19 +19,19 @@ export default function GlobalError({ error, reset }: { error: Error & { digest?
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="de-AT">
|
<html lang="de-AT">
|
||||||
<body style={{ margin: 0, backgroundColor: "#f9f1f5", fontFamily: "system-ui, sans-serif", color: "#2d1c26" }}>
|
<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 #eedde6", borderRadius: 8 }}>
|
<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>
|
<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.
|
Es ist ein unerwarteter Fehler aufgetreten. Ihre Daten sind davon nicht betroffen.
|
||||||
</p>
|
</p>
|
||||||
{error.digest && (
|
{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
|
<button
|
||||||
onClick={reset}
|
onClick={reset}
|
||||||
style={{
|
style={{
|
||||||
background: "#d6046e",
|
background: "#164194",
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
border: "none",
|
border: "none",
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
|
|||||||
@@ -1,23 +1,74 @@
|
|||||||
@import "tailwindcss";
|
@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 {
|
@theme {
|
||||||
--color-brand-50: #fdf3f8;
|
/* ── Blau: alles, was man anfassen kann ── */
|
||||||
--color-brand-100: #fdeaf3;
|
--color-brand-50: #eef3fb;
|
||||||
--color-brand-200: #f6cfe2;
|
--color-brand-100: #dae4f5;
|
||||||
--color-brand-500: #d6046e;
|
--color-brand-200: #afc3e6;
|
||||||
--color-brand-600: #b0035a;
|
--color-brand-500: #164194; /* Manner Blau, CD §1.1 */
|
||||||
--color-brand-700: #a30354;
|
--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-surface: #fdf6f4;
|
||||||
--color-border-subtle: #f7e8ef;
|
|
||||||
|
|
||||||
--color-ink: #2d1c26;
|
--color-border: #f0dbd4;
|
||||||
--color-ink-body: #503b47;
|
--color-border-subtle: #f8ece8;
|
||||||
--color-ink-muted: #7a636f;
|
/* 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-bg: #dff6dd;
|
||||||
--color-success-text: #0e700e;
|
--color-success-text: #0e700e;
|
||||||
|
|
||||||
@@ -31,8 +82,11 @@
|
|||||||
--color-info-bg: #d7f0f0;
|
--color-info-bg: #d7f0f0;
|
||||||
--color-info-text: #00666d;
|
--color-info-text: #00666d;
|
||||||
|
|
||||||
--color-purple-bg: #f0ecf7;
|
/* Nachgezogen: gegen den alten Wert #f0ecf7 stand der Fehler-Chip bei
|
||||||
--color-purple-text: #5c2e91;
|
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
|
/* 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
|
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
|
/* 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
|
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. */
|
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);
|
Der Grundton folgt dem Rosa der Flaeche, nicht mehr dem alten Magenta. */
|
||||||
--shadow-card-hover: 0 2px 4px rgb(107 33 71 / 0.06), 0 4px 12px rgb(107 33 71 / 0.08);
|
--shadow-card: 0 1px 2px rgb(122 60 45 / 0.05), 0 1px 3px rgb(122 60 45 / 0.07);
|
||||||
--shadow-overlay: 0 8px 32px rgb(45 28 38 / 0.16);
|
--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 {
|
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 type { Metadata, Viewport } from "next";
|
||||||
import { Nunito } from "next/font/google";
|
import { Barlow } from "next/font/google";
|
||||||
import { ToastProvider } from "@/components/ui/Toast";
|
import { ToastProvider } from "@/components/ui/Toast";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const nunito = Nunito({
|
// ═══ Die Schrift ═══
|
||||||
variable: "--font-nunito",
|
//
|
||||||
subsets: ["latin"],
|
// 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"],
|
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
|
// 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.
|
// keeps the app's own name rather than the page title.
|
||||||
appleWebApp: { capable: true, title: "Alpenwerk HR", statusBarStyle: "default" },
|
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 = {
|
export const viewport: Viewport = {
|
||||||
@@ -23,7 +50,9 @@ export const viewport: Viewport = {
|
|||||||
// The layout extends under the notch and home indicator; every edge that
|
// The layout extends under the notch and home indicator; every edge that
|
||||||
// matters pads itself back out with env(safe-area-inset-*).
|
// matters pads itself back out with env(safe-area-inset-*).
|
||||||
viewportFit: "cover",
|
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({
|
export default function RootLayout({
|
||||||
@@ -32,7 +61,7 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
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">
|
<body className="flex min-h-dvh flex-col font-sans">
|
||||||
<ToastProvider>{children}</ToastProvider>
|
<ToastProvider>{children}</ToastProvider>
|
||||||
</body>
|
</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: "Karenz", label: "Langzeitabwesenheit" },
|
||||||
{ value: "Vertragsänderung", label: "Vertragsänderung" },
|
{ value: "Vertragsänderung", label: "Vertragsänderung" },
|
||||||
{ value: "Stammdatenänderung", label: "Stammdatenä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() {
|
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";
|
"use client";
|
||||||
|
|
||||||
import { Trash2 } from "lucide-react";
|
import { Lock, Trash2 } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTransition } from "react";
|
import { useTransition } from "react";
|
||||||
import { deleteHireDraft } from "@/actions/hireDrafts";
|
import { deleteHireDraft } from "@/actions/hireDrafts";
|
||||||
import { useHireWizard } from "@/components/hire/HireWizardContext";
|
import { useHireWizard } from "@/components/hire/HireWizardContext";
|
||||||
import { useToast } from "@/components/ui/Toast";
|
import { useToast } from "@/components/ui/Toast";
|
||||||
|
import type { Entwurf } from "@/lib/entwuerfe";
|
||||||
import { fmtDate } from "@/lib/format";
|
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 { openWizard } = useHireWizard();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -39,24 +53,46 @@ export function DraftsCard({ drafts }: { drafts: Draft[] }) {
|
|||||||
const lastName = typeof d.payload.lastName === "string" ? d.payload.lastName : "";
|
const lastName = typeof d.payload.lastName === "string" ? d.payload.lastName : "";
|
||||||
const name = [firstName, lastName].filter(Boolean).join(" ") || "Ohne Namen";
|
const name = [firstName, lastName].filter(Boolean).join(" ") || "Ohne Namen";
|
||||||
return (
|
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>
|
<div>
|
||||||
<span className="font-semibold text-ink">{name}</span>
|
<span className="font-semibold text-ink">{name}</span>
|
||||||
<span className="ml-2 text-xs text-ink-muted">Gespeichert am {fmtDate(d.updated_at)}</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>
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
{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
|
Fortsetzen
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{d.vonMir && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleDelete(d.id)}
|
onClick={() => handleDelete(d.id)}
|
||||||
disabled={pending}
|
disabled={pending || Boolean(d.gesperrtVon)}
|
||||||
aria-label="Entwurf löschen"
|
aria-label="Entwurf löschen"
|
||||||
className="text-ink-muted hover:text-danger-solid"
|
className="text-ink-muted hover:text-danger-solid disabled:opacity-40"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,34 +2,48 @@
|
|||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { addEmployeeDependent } from "@/actions/employees";
|
import { addEmployeeDependent, updateEmployeeDependent } from "@/actions/employees";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { SelectField, TextField } from "@/components/ui/Field";
|
import { SelectField, TextField } from "@/components/ui/Field";
|
||||||
import { Modal } from "@/components/ui/Modal";
|
import { Modal } from "@/components/ui/Modal";
|
||||||
import { useToast } from "@/components/ui/Toast";
|
import { useToast } from "@/components/ui/Toast";
|
||||||
import { todayIso } from "@/lib/format";
|
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"];
|
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({
|
export function AddDependentModal({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
employeeId,
|
employeeId,
|
||||||
defaultEffectiveDate,
|
defaultEffectiveDate,
|
||||||
|
dependent,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
employeeId: string;
|
employeeId: string;
|
||||||
defaultEffectiveDate?: string;
|
defaultEffectiveDate?: string;
|
||||||
|
/** Gesetzt: berichtigen statt anlegen. */
|
||||||
|
dependent?: Dependent;
|
||||||
}) {
|
}) {
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [firstName, setFirstName] = useState("");
|
const bearbeiten = Boolean(dependent);
|
||||||
const [lastName, setLastName] = useState("");
|
const [firstName, setFirstName] = useState(dependent?.first_name ?? "");
|
||||||
const [svNummer, setSvNummer] = useState("");
|
const [lastName, setLastName] = useState(dependent?.last_name ?? "");
|
||||||
const [birthDate, setBirthDate] = useState("");
|
const [svNummer, setSvNummer] = useState(dependent?.sv_nummer ?? "");
|
||||||
const [relationship, setRelationship] = useState<RelationshipType>("Kind");
|
const [birthDate, setBirthDate] = useState(dependent?.birth_date ?? "");
|
||||||
|
const [relationship, setRelationship] = useState<RelationshipType>(
|
||||||
|
(dependent?.relationship as RelationshipType) ?? "Kind"
|
||||||
|
);
|
||||||
const [effectiveDate, setEffectiveDate] = useState(defaultEffectiveDate ?? todayIso());
|
const [effectiveDate, setEffectiveDate] = useState(defaultEffectiveDate ?? todayIso());
|
||||||
const [prevDefaultEffectiveDate, setPrevDefaultEffectiveDate] = useState(defaultEffectiveDate);
|
const [prevDefaultEffectiveDate, setPrevDefaultEffectiveDate] = useState(defaultEffectiveDate);
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
@@ -53,12 +67,22 @@ export function AddDependentModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
if (!firstName || !lastName || !birthDate || !effectiveDate) {
|
if (!firstName || !lastName || !birthDate || (!bearbeiten && !effectiveDate)) {
|
||||||
showToast("Bitte alle Pflichtfelder ausfüllen.", "error");
|
showToast("Bitte alle Pflichtfelder ausfüllen.", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPending(true);
|
setPending(true);
|
||||||
const result = await addEmployeeDependent({
|
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,
|
employee_id: employeeId,
|
||||||
first_name: firstName,
|
first_name: firstName,
|
||||||
last_name: lastName,
|
last_name: lastName,
|
||||||
@@ -69,10 +93,10 @@ export function AddDependentModal({
|
|||||||
});
|
});
|
||||||
setPending(false);
|
setPending(false);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
showToast("Angehörige:r hinzugefügt.");
|
showToast(bearbeiten ? "Angaben berichtigt." : "Angehörige:r hinzugefügt.");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
onClose();
|
onClose();
|
||||||
reset();
|
if (!bearbeiten) reset();
|
||||||
} else {
|
} else {
|
||||||
showToast(result.error ?? "Fehler beim Speichern.", "error");
|
showToast(result.error ?? "Fehler beim Speichern.", "error");
|
||||||
}
|
}
|
||||||
@@ -82,20 +106,22 @@ export function AddDependentModal({
|
|||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title="Angehörige:n hinzufügen"
|
title={bearbeiten ? "Angehörige:n berichtigen" : "Angehörige:n hinzufügen"}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="ghost" onClick={onClose}>
|
<Button variant="ghost" onClick={onClose}>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSubmit} pending={pending}>
|
<Button onClick={handleSubmit} pending={pending}>
|
||||||
Hinzufügen
|
{bearbeiten ? "Speichern" : "Hinzufügen"}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
{!bearbeiten && (
|
||||||
<TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
|
<TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
|
||||||
|
)}
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<TextField label="Vorname" required value={firstName} onChange={setFirstName} />
|
<TextField label="Vorname" required value={firstName} onChange={setFirstName} />
|
||||||
<TextField label="Nachname" required value={lastName} onChange={setLastName} />
|
<TextField label="Nachname" required value={lastName} onChange={setLastName} />
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { deleteEmployeeDependent } from "@/actions/employees";
|
import { deleteEmployeeDependent } from "@/actions/employees";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { useToast } from "@/components/ui/Toast";
|
import { useToast } from "@/components/ui/Toast";
|
||||||
import { fmtDate, todayIso } from "@/lib/format";
|
import { fmtDate, todayIso } from "@/lib/format";
|
||||||
import type { Database } from "@/lib/supabase/types";
|
import type { Database } from "@/lib/types";
|
||||||
import { AddDependentModal } from "./AddDependentModal";
|
import { AddDependentModal } from "./AddDependentModal";
|
||||||
|
|
||||||
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
|
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
|
||||||
@@ -19,6 +19,7 @@ export function AngehoerigeSection({ employeeId, dependents, effectiveDate }: {
|
|||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [bearbeitet, setBearbeitet] = useState<Dependent | null>(null);
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
const resolvedEffectiveDate = effectiveDate || todayIso();
|
const resolvedEffectiveDate = effectiveDate || todayIso();
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ export function AngehoerigeSection({ employeeId, dependents, effectiveDate }: {
|
|||||||
<div className="overflow-x-auto rounded border border-border">
|
<div className="overflow-x-auto rounded border border-border">
|
||||||
<table className="w-full min-w-[600px] text-sm">
|
<table className="w-full min-w-[600px] text-sm">
|
||||||
<thead>
|
<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">Name</th>
|
||||||
<th className="px-4 py-2.5">Verhältnis</th>
|
<th className="px-4 py-2.5">Verhältnis</th>
|
||||||
<th className="px-4 py-2.5">SVNR</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">{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-ink-body">{fmtDate(d.birth_date)}</td>
|
||||||
<td className="px-4 py-2.5 text-right">
|
<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
|
<Button
|
||||||
variant="icon"
|
variant="icon"
|
||||||
onClick={() => handleDelete(d.id)}
|
onClick={() => handleDelete(d.id)}
|
||||||
@@ -88,6 +101,19 @@ export function AngehoerigeSection({ employeeId, dependents, effectiveDate }: {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<AddDependentModal open={modalOpen} onClose={() => setModalOpen(false)} employeeId={employeeId} defaultEffectiveDate={resolvedEffectiveDate} />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,61 +6,86 @@ import { useState } from "react";
|
|||||||
import { Avatar } from "@/components/ui/Avatar";
|
import { Avatar } from "@/components/ui/Avatar";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { StatusChip } from "@/components/ui/StatusChip";
|
import { StatusChip } from "@/components/ui/StatusChip";
|
||||||
|
import type { AufgabenStand } from "@/lib/checklist";
|
||||||
import { fmtFullName, tenure } from "@/lib/format";
|
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 { DatenAendernPanel } from "./panels/DatenAendernPanel";
|
||||||
import { KarenzPanel } from "./panels/KarenzPanel";
|
import { KarenzPanel } from "./panels/KarenzPanel";
|
||||||
import { PromotePanel } from "./panels/PromotePanel";
|
import { PromotePanel } from "./panels/PromotePanel";
|
||||||
import { RehirePanel } from "./panels/RehirePanel";
|
import { RehireWizard } from "../hire/RehireWizard";
|
||||||
import { TerminatePanel } from "./panels/TerminatePanel";
|
import { TerminatePanel } from "./panels/TerminatePanel";
|
||||||
import { TransferPanel } from "./panels/TransferPanel";
|
import { TransferPanel } from "./panels/TransferPanel";
|
||||||
import { HistorieTab } from "./tabs/HistorieTab";
|
import { HistorieTab } from "./tabs/HistorieTab";
|
||||||
import { NotizenTab } from "./tabs/NotizenTab";
|
import { NotizenTab } from "./tabs/NotizenTab";
|
||||||
|
import { OffboardingTab } from "./tabs/OffboardingTab";
|
||||||
|
import { OnboardingTab } from "./tabs/OnboardingTab";
|
||||||
import { OrganisationTab } from "./tabs/OrganisationTab";
|
import { OrganisationTab } from "./tabs/OrganisationTab";
|
||||||
import { StammdatenTab } from "./tabs/StammdatenTab";
|
import { StammdatenTab } from "./tabs/StammdatenTab";
|
||||||
import { VertragTab } from "./tabs/VertragTab";
|
import { VertragTab } from "./tabs/VertragTab";
|
||||||
|
|
||||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
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 Location = Database["public"]["Tables"]["locations"]["Row"];
|
||||||
type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"];
|
type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"];
|
||||||
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
|
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
|
||||||
type NoteRow = Database["public"]["Tables"]["employee_notes"]["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 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 = {
|
type EmployeeDetailProps = {
|
||||||
employee: EmployeeRow;
|
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;
|
manager: MiniEmployee | null;
|
||||||
|
/** Nur gesetzt, wenn die zuständige Leitung abwesend ist und vertreten wird. */
|
||||||
|
formalManager: MiniEmployee | null;
|
||||||
directReports: MiniEmployee[];
|
directReports: MiniEmployee[];
|
||||||
history: HistoryRow[];
|
history: HistoryRow[];
|
||||||
dependents: Dependent[];
|
dependents: Dependent[];
|
||||||
notes: NoteRow[];
|
notes: NoteRow[];
|
||||||
divisions: Division[];
|
|
||||||
departments: Department[];
|
|
||||||
teams: Team[];
|
|
||||||
locations: Location[];
|
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;
|
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) {
|
export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||||
const { employee, manager, directReports, history, dependents, notes, divisions, departments, teams, locations } = props;
|
const { employee, placement, breadcrumb, kostenstelle, onboarding, offboarding, manager, formalManager, directReports, history, dependents, notes, locations, openPositions, today } = props;
|
||||||
const [tab, setTab] = useState<(typeof TABS)[number]>("Stammdaten");
|
const [tab, setTab] = useState<Tab>("Stammdaten");
|
||||||
const [panel, setPanel] = useState<PanelType>(null);
|
const [panel, setPanel] = useState<PanelType>(null);
|
||||||
|
|
||||||
const division = divisions.find((d) => d.id === employee.division_id);
|
|
||||||
const team = employee.team_id ? teams.find((t) => t.id === employee.team_id) : undefined;
|
|
||||||
const department = team ? departments.find((d) => d.id === team.department_id) : undefined;
|
|
||||||
const breadcrumb = [division?.name, department?.name, team?.name].filter(Boolean).join(" › ") || "–";
|
|
||||||
const location = locations.find((l) => l.id === employee.location_id);
|
const 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 zeigtOffboarding = gehoertOffboarding(employee);
|
||||||
const canEditData = employee.status !== "Ausgetreten";
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
@@ -77,13 +102,22 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
|||||||
<h2 className="text-xl font-extrabold text-ink">
|
<h2 className="text-xl font-extrabold text-ink">
|
||||||
{fmtFullName(employee.first_name, employee.last_name, employee.title_prefix, employee.title_suffix)}
|
{fmtFullName(employee.first_name, employee.last_name, employee.title_prefix, employee.title_suffix)}
|
||||||
</h2>
|
</h2>
|
||||||
<StatusChip status={employee.status} entryDate={employee.entry_date} absenceType={employee.absence_type} />
|
<StatusChip employee={employee} asOf={today} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-ink-body">{employee.job_title}</p>
|
<p className="text-sm text-ink-body">{placement?.jobTitle ?? employee.job_title}</p>
|
||||||
<p className="text-xs text-ink-muted">{breadcrumb}</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">
|
<p className="mt-1 text-xs text-ink-muted">
|
||||||
Pers.-Nr. {employee.personnel_number}
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,25 +129,41 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
|||||||
<ActionButton icon={TrendingUp} label="Befördern" onClick={() => setPanel("promote")} />
|
<ActionButton icon={TrendingUp} label="Befördern" onClick={() => setPanel("promote")} />
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={Clock}
|
icon={Clock}
|
||||||
label={employee.status === "Karenz" ? "Abwesenheit verwalten" : "Langzeitabwesenheit"}
|
label={status === "Karenz" ? "Abwesenheit verwalten" : "Langzeitabwesenheit"}
|
||||||
onClick={() => setPanel("karenz")}
|
onClick={() => setPanel("karenz")}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{canEditData && <ActionButton icon={Pencil} label="Daten ändern" onClick={() => setPanel("daten")} />}
|
{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
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setPanel("terminate")}
|
onClick={() => setPanel("terminate")}
|
||||||
className="!border-danger-solid !text-danger-solid hover:!bg-danger-bg"
|
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>
|
</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")}>
|
<Button size="sm" onClick={() => setPanel("rehire")}>
|
||||||
<RotateCcw className="h-4 w-4" /> Wiedereinstellen
|
<RotateCcw className="h-4 w-4" /> Wiedereintritt
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -123,7 +173,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
|||||||
{/* Tabs, so the list gets the tablist role and each button says
|
{/* Tabs, so the list gets the tablist role and each button says
|
||||||
whether it is the selected one. */}
|
whether it is the selected one. */}
|
||||||
<div role="tablist" aria-label="Mitarbeiterdetails" className="flex gap-1 overflow-x-auto border-b border-border">
|
<div role="tablist" aria-label="Mitarbeiterdetails" className="flex gap-1 overflow-x-auto border-b border-border">
|
||||||
{TABS.map((t) => (
|
{tabs.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
role="tab"
|
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"
|
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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -142,32 +198,91 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
|||||||
{tab === "Stammdaten" && <StammdatenTab employee={employee} location={location} dependents={dependents} />}
|
{tab === "Stammdaten" && <StammdatenTab employee={employee} location={location} dependents={dependents} />}
|
||||||
{tab === "Vertrag" && <VertragTab employee={employee} />}
|
{tab === "Vertrag" && <VertragTab employee={employee} />}
|
||||||
{tab === "Organisation" && (
|
{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} />}
|
{tab === "HR-Notizen" && <NotizenTab employeeId={employee.id} notes={notes} />}
|
||||||
</div>
|
</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
|
<TransferPanel
|
||||||
|
key={`transfer-${employee.updated_at}`}
|
||||||
open={panel === "transfer"}
|
open={panel === "transfer"}
|
||||||
onClose={() => setPanel(null)}
|
onClose={() => setPanel(null)}
|
||||||
employee={employee}
|
employee={employee}
|
||||||
divisions={divisions}
|
openPositions={openPositions}
|
||||||
departments={departments}
|
/>
|
||||||
teams={teams}
|
<PromotePanel
|
||||||
currentTeamId={employee.team_id}
|
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
|
<DatenAendernPanel
|
||||||
|
key={`daten-${employee.updated_at}`}
|
||||||
open={panel === "daten"}
|
open={panel === "daten"}
|
||||||
onClose={() => setPanel(null)}
|
onClose={() => setPanel(null)}
|
||||||
employee={employee}
|
employee={employee}
|
||||||
dependents={dependents}
|
dependents={dependents}
|
||||||
locationCountry={location?.country}
|
locationCountry={location?.country}
|
||||||
/>
|
/>
|
||||||
<TerminatePanel open={panel === "terminate"} onClose={() => setPanel(null)} employee={employee} directReportCount={directReports.length} />
|
<TerminatePanel
|
||||||
<RehirePanel open={panel === "rehire"} onClose={() => setPanel(null)} employee={employee} />
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import { FILTER_SELECT_CLASS } from "@/components/ui/Field";
|
|||||||
import { SearchInput } from "@/components/ui/SearchInput";
|
import { SearchInput } from "@/components/ui/SearchInput";
|
||||||
|
|
||||||
type EmployeeFiltersProps = {
|
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 }[];
|
locations: { id: string; name: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -17,12 +19,16 @@ type EmployeeFiltersProps = {
|
|||||||
const STATUS_OPTIONS = [
|
const STATUS_OPTIONS = [
|
||||||
{ value: "Aktiv", label: "Aktiv" },
|
{ value: "Aktiv", label: "Aktiv" },
|
||||||
{ value: "Karenz", label: "Langzeitabwesenheit" },
|
{ 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: "Geplant", label: "Geplant" },
|
||||||
{ value: "Ausgetreten", label: "Ausgetreten" },
|
{ value: "Ausgetreten", label: "Ausgetreten" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function EmployeeFilters({ divisions, locations }: EmployeeFiltersProps) {
|
export function EmployeeFilters({ units, depthOf, locations }: EmployeeFiltersProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -55,16 +61,24 @@ export function EmployeeFilters({ divisions, locations }: EmployeeFiltersProps)
|
|||||||
{/* aria-label rather than a visible label: the filter bar is a single
|
{/* 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
|
horizontal row, and each select's first option already names it on
|
||||||
screen. */}
|
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
|
<select
|
||||||
aria-label="Nach Bereich filtern"
|
aria-label="Nach Organisationseinheit filtern"
|
||||||
defaultValue={searchParams.get("division") ?? ""}
|
defaultValue={searchParams.get("division") ?? ""}
|
||||||
onChange={(e) => updateParam("division", e.target.value)}
|
onChange={(e) => updateParam("division", e.target.value)}
|
||||||
className={FILTER_SELECT_CLASS}
|
className={FILTER_SELECT_CLASS}
|
||||||
>
|
>
|
||||||
<option value="">Alle Bereiche</option>
|
<option value="">Alle Einheiten</option>
|
||||||
{divisions.map((d) => (
|
{units
|
||||||
<option key={d.id} value={d.id}>
|
.filter((u) => u.unit_type !== "Gesellschaft")
|
||||||
{d.name}
|
.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{" ".repeat(Math.max(0, (depthOf.get(u.id) ?? 1) - 1) * 3)}
|
||||||
|
{u.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -94,6 +108,15 @@ export function EmployeeFilters({ divisions, locations }: EmployeeFiltersProps)
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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>
|
</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";
|
"use client";
|
||||||
|
|
||||||
import { SelectField } from "@/components/ui/Field";
|
import { SelectField, TextField } from "@/components/ui/Field";
|
||||||
import type { CollectiveAgreement, Weekday, WorkerType } from "@/lib/supabase/types";
|
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 = {
|
export type RoleEmploymentValue = {
|
||||||
workerType: WorkerType;
|
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;
|
collectiveAgreement: CollectiveAgreement;
|
||||||
workDays: Weekday[];
|
workDays: Weekday[];
|
||||||
isBetriebsrat: boolean;
|
isBetriebsrat: boolean;
|
||||||
hasDienstwagen: 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;
|
isLateraleFuehrung: boolean;
|
||||||
isCLevel: 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
|
// Shared by the hire wizard (StepVertrag) and DatenAendernPanel — both edit
|
||||||
// the same set of employees columns, just against different local state.
|
// the same set of employees columns, just against different local state.
|
||||||
export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymentValue; onChange: (patch: Partial<RoleEmploymentValue>) => void }) {
|
export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymentValue; onChange: (patch: Partial<RoleEmploymentValue>) => void }) {
|
||||||
function toggleWorkDay(day: Weekday) {
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<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
|
<SelectField
|
||||||
label="Angestellte:r / Arbeiter:in"
|
label="Beschäftigtengruppe"
|
||||||
dense
|
dense
|
||||||
value={value.workerType}
|
value={value.workerType}
|
||||||
onChange={(v) => onChange({ workerType: v as WorkerType })}
|
onChange={(v) => onChange({ workerType: v as WorkerType })}
|
||||||
options={[
|
options={[
|
||||||
{ value: "Angestellte:r", label: "Angestellte:r" },
|
{ value: "Angestellte:r", label: "Angestellte:r" },
|
||||||
{ value: "Arbeiter:in", label: "Arbeiter:in" },
|
{ 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
|
<SelectField
|
||||||
label="Kollektivvertrag"
|
label="Kollektivvertrag"
|
||||||
dense
|
dense
|
||||||
@@ -53,7 +159,7 @@ export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymen
|
|||||||
<fieldset>
|
<fieldset>
|
||||||
<legend className="mb-1 block text-xs font-semibold text-ink-muted">Arbeitstage</legend>
|
<legend className="mb-1 block text-xs font-semibold text-ink-muted">Arbeitstage</legend>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{WEEKDAYS.map((day) => (
|
{WOCHENTAGE.map((day) => (
|
||||||
<button
|
<button
|
||||||
key={day}
|
key={day}
|
||||||
type="button"
|
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 })} />
|
<input type="checkbox" checked={value.hasDienstwagen} onChange={(e) => onChange({ hasDienstwagen: e.target.checked })} />
|
||||||
Dienstwagen
|
Dienstwagen
|
||||||
</label>
|
</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">
|
<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 })} />
|
<input type="checkbox" checked={value.isLateraleFuehrung} onChange={(e) => onChange({ isLateraleFuehrung: e.target.checked })} />
|
||||||
Laterale Führung
|
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 })} />
|
<input type="checkbox" checked={value.isCLevel} onChange={(e) => onChange({ isCLevel: e.target.checked })} />
|
||||||
C-Level
|
C-Level
|
||||||
</label>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,10 +12,15 @@ import { CountryPicker } from "@/components/ui/CountryPicker";
|
|||||||
import { Field, SelectField, TextField } from "@/components/ui/Field";
|
import { Field, SelectField, TextField } from "@/components/ui/Field";
|
||||||
import { SlideOver } from "@/components/ui/SlideOver";
|
import { SlideOver } from "@/components/ui/SlideOver";
|
||||||
import { useToast } from "@/components/ui/Toast";
|
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 { fmtFullName, todayIso } from "@/lib/format";
|
||||||
import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr";
|
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 EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||||
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
|
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
|
||||||
@@ -52,30 +57,63 @@ export function DatenAendernPanel({
|
|||||||
const svNummerOk =
|
const svNummerOk =
|
||||||
!svNummer.trim() || !requiresAustrianSvnr(locationCountry) || isValidSvnr(svNummer, birthDate || null);
|
!svNummer.trim() || !requiresAustrianSvnr(locationCountry) || isValidSvnr(svNummer, birthDate || null);
|
||||||
const [nationality, setNationality] = useState(employee.nationality);
|
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 [address, setAddress] = useState(employee.address ?? "");
|
||||||
const [postalCode, setPostalCode] = useState(employee.postal_code ?? "");
|
const [postalCode, setPostalCode] = useState(employee.postal_code ?? "");
|
||||||
const [city, setCity] = useState(employee.city ?? "");
|
const [city, setCity] = useState(employee.city ?? "");
|
||||||
const [addressCountry, setAddressCountry] = useState(employee.address_country ?? "Österreich");
|
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 [phone, setPhone] = useState(employee.phone ?? "");
|
||||||
|
|
||||||
const [employmentType, setEmploymentType] = useState<EmploymentType>(employee.employment_type);
|
const [employmentType, setEmploymentType] = useState<EmploymentType>(employee.employment_type);
|
||||||
const [weeklyHours, setWeeklyHours] = useState(String(employee.weekly_hours));
|
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 [contractType, setContractType] = useState<ContractType>(employee.contract_type);
|
||||||
const [contractEndDate, setContractEndDate] = useState(employee.contract_end_date ?? "");
|
const [contractEndDate, setContractEndDate] = useState(employee.contract_end_date ?? "");
|
||||||
|
const [paygrade, setPaygrade] = useState<PaygradeType>(employee.paygrade);
|
||||||
|
|
||||||
const [role, setRole] = useState<RoleEmploymentValue>({
|
const [role, setRole] = useState<RoleEmploymentValue>({
|
||||||
workerType: employee.worker_type ?? "Angestellte:r",
|
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",
|
collectiveAgreement: employee.collective_agreement ?? "Handel",
|
||||||
workDays: employee.work_days ?? ["Mo", "Di", "Mi", "Do", "Fr"],
|
workDays: employee.work_days ?? ["Mo", "Di", "Mi", "Do", "Fr"],
|
||||||
isBetriebsrat: employee.is_betriebsrat ?? false,
|
isBetriebsrat: employee.is_betriebsrat ?? false,
|
||||||
hasDienstwagen: employee.has_dienstwagen ?? 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,
|
isLateraleFuehrung: employee.is_laterale_fuehrung ?? false,
|
||||||
isCLevel: employee.is_c_level ?? false,
|
isCLevel: employee.is_c_level ?? false,
|
||||||
});
|
});
|
||||||
function updateRole(patch: Partial<RoleEmploymentValue>) {
|
function updateRole(patch: Partial<RoleEmploymentValue>) {
|
||||||
setRole((prev) => ({ ...prev, ...patch }));
|
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) {
|
function handleEmploymentTypeChange(value: EmploymentType) {
|
||||||
setEmploymentType(value);
|
setEmploymentType(value);
|
||||||
@@ -112,25 +150,66 @@ export function DatenAendernPanel({
|
|||||||
birth_date: birthDate,
|
birth_date: birthDate,
|
||||||
sv_nummer: svNummer,
|
sv_nummer: svNummer,
|
||||||
nationality,
|
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,
|
address,
|
||||||
postal_code: postalCode,
|
postal_code: postalCode,
|
||||||
city,
|
city,
|
||||||
address_country: addressCountry,
|
address_country: addressCountry,
|
||||||
email,
|
// Getrimmt und als leere Zeichenkette durchgereicht: die Datenbank
|
||||||
phone,
|
// 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: {
|
contract: {
|
||||||
employment_type: employmentType,
|
employment_type: employmentType,
|
||||||
weekly_hours: Number(weeklyHours),
|
weekly_hours: Number(weeklyHours),
|
||||||
contract_type: contractType,
|
contract_type: contractType,
|
||||||
contract_end_date: contractType === "befristet" ? contractEndDate : "",
|
contract_end_date: contractType === "befristet" ? contractEndDate : "",
|
||||||
|
paygrade,
|
||||||
},
|
},
|
||||||
role: {
|
role: {
|
||||||
worker_type: role.workerType,
|
worker_type: role.workerType,
|
||||||
|
mitarbeiterart: role.mitarbeiterart,
|
||||||
|
source: role.source,
|
||||||
collective_agreement: role.collectiveAgreement,
|
collective_agreement: role.collectiveAgreement,
|
||||||
work_days: role.workDays,
|
work_days: role.workDays,
|
||||||
is_betriebsrat: role.isBetriebsrat,
|
is_betriebsrat: role.isBetriebsrat,
|
||||||
has_dienstwagen: role.hasDienstwagen,
|
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_laterale_fuehrung: role.isLateraleFuehrung,
|
||||||
is_c_level: role.isCLevel,
|
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…" />
|
<CountryPicker {...p} value={nationality} onChange={setNationality} countries={UN_COUNTRIES} placeholder="Staatsbürgerschaft suchen…" />
|
||||||
)}
|
)}
|
||||||
</Field>
|
</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} />
|
<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">
|
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] gap-3">
|
||||||
<TextField label="Postleitzahl" dense inputMode="numeric" value={postalCode} onChange={setPostalCode} />
|
<TextField label="Postleitzahl" dense inputMode="numeric" value={postalCode} onChange={setPostalCode} />
|
||||||
@@ -205,9 +312,12 @@ export function DatenAendernPanel({
|
|||||||
<Field label="Land" dense>
|
<Field label="Land" dense>
|
||||||
{(p) => <CountryPicker {...p} value={addressCountry} onChange={setAddressCountry} countries={UN_COUNTRIES} placeholder="Land suchen…" />}
|
{(p) => <CountryPicker {...p} value={addressCountry} onChange={setAddressCountry} countries={UN_COUNTRIES} placeholder="Land suchen…" />}
|
||||||
</Field>
|
</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} />
|
<TextField label="Telefon" dense type="tel" value={phone} onChange={setPhone} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -232,6 +342,26 @@ export function DatenAendernPanel({
|
|||||||
disabled={employmentType === "Vollzeit"}
|
disabled={employmentType === "Vollzeit"}
|
||||||
onChange={setWeeklyHours}
|
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
|
<SelectField
|
||||||
label="Vertragsart"
|
label="Vertragsart"
|
||||||
dense
|
dense
|
||||||
@@ -245,6 +375,18 @@ export function DatenAendernPanel({
|
|||||||
{contractType === "befristet" && (
|
{contractType === "befristet" && (
|
||||||
<TextField label="Befristet bis" required dense type="date" value={contractEndDate} onChange={setContractEndDate} />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -254,6 +396,22 @@ export function DatenAendernPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AngehoerigeSection employeeId={employee.id} dependents={dependents} effectiveDate={effectiveDate} />
|
<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>
|
</div>
|
||||||
</SlideOver>
|
</SlideOver>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,18 +8,29 @@ import { SelectField, TextField, TextareaField } from "@/components/ui/Field";
|
|||||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||||
import { SlideOver } from "@/components/ui/SlideOver";
|
import { SlideOver } from "@/components/ui/SlideOver";
|
||||||
import { useToast } from "@/components/ui/Toast";
|
import { useToast } from "@/components/ui/Toast";
|
||||||
import { ABSENCE_TYPES, absenceLabel } from "@/lib/absence";
|
import { ABSENCE_TYPES, absenceLabel, RUECKKEHR_GRUENDE } from "@/lib/absence";
|
||||||
import { fmtDate } from "@/lib/format";
|
import { fmtDate, fmtName } from "@/lib/format";
|
||||||
import type { Database } from "@/lib/supabase/types";
|
import type { Database, EmploymentStatus } from "@/lib/types";
|
||||||
|
|
||||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||||
type Mode = "adjust" | "return";
|
type Mode = "adjust" | "return";
|
||||||
type EmploymentMode = "unverändert" | "Vollzeit" | "Teilzeit";
|
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 { showToast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isOnKarenz = employee.status === "Karenz";
|
const isOnKarenz = status === "Karenz";
|
||||||
const [mode, setMode] = useState<Mode>("adjust");
|
const [mode, setMode] = useState<Mode>("adjust");
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
@@ -33,7 +44,18 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
|
|||||||
|
|
||||||
const [returnDate, setReturnDate] = useState("");
|
const [returnDate, setReturnDate] = useState("");
|
||||||
const [employmentMode, setEmploymentMode] = useState<EmploymentMode>("unverändert");
|
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 =
|
const diffDays =
|
||||||
isOnKarenz && employee.karenz_return_date && newReturnDate
|
isOnKarenz && employee.karenz_return_date && newReturnDate
|
||||||
@@ -89,16 +111,24 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
|
|||||||
showToast("Bitte Rückkehrdatum angeben.", "error");
|
showToast("Bitte Rückkehrdatum angeben.", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (employmentMode === "Teilzeit" && (Number(weeklyHours) <= 0 || Number(weeklyHours) >= 38.5)) {
|
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");
|
showToast("Wochenstunden müssen zwischen 0 und 38,5 liegen.", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
setPending(true);
|
setPending(true);
|
||||||
const result = await recordKarenzReturn({
|
const result = await recordKarenzReturn({
|
||||||
employee_id: employee.id,
|
employee_id: employee.id,
|
||||||
return_date: returnDate,
|
return_date: returnDate,
|
||||||
employment_mode: employmentMode,
|
employment_mode: employmentMode,
|
||||||
weekly_hours: employmentMode === "Teilzeit" ? Number(weeklyHours) : undefined,
|
weekly_hours: employmentMode === "Teilzeit" ? Number(weeklyHours) : undefined,
|
||||||
|
reduction_reason: employmentMode === "Teilzeit" ? reduktionsgrund || undefined : undefined,
|
||||||
|
teilzeit_bis: employmentMode === "Teilzeit" && reduktionsgrund ? teilzeitBis || undefined : undefined,
|
||||||
});
|
});
|
||||||
setPending(false);
|
setPending(false);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -115,7 +145,7 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
|
|||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={isOnKarenz ? "Langzeitabwesenheit verwalten" : "Langzeitabwesenheit erfassen"}
|
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={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="ghost" onClick={onClose}>
|
<Button variant="ghost" onClick={onClose}>
|
||||||
@@ -169,7 +199,7 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
|
|||||||
{mode === "adjust" && (
|
{mode === "adjust" && (
|
||||||
<>
|
<>
|
||||||
<p className="rounded bg-surface px-3 py-2 text-sm text-ink-body">
|
<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 "}
|
{" · Rückkehr "}
|
||||||
{fmtDate(employee.karenz_return_date)}
|
{fmtDate(employee.karenz_return_date)}
|
||||||
</p>
|
</p>
|
||||||
@@ -191,22 +221,46 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
|
|||||||
value={employmentMode}
|
value={employmentMode}
|
||||||
onChange={(v) => setEmploymentMode(v as EmploymentMode)}
|
onChange={(v) => setEmploymentMode(v as EmploymentMode)}
|
||||||
options={[
|
options={[
|
||||||
{ value: "unverändert", label: "unverändert" },
|
// Die Stunden, die zuletzt gearbeitet wurden, stehen in der
|
||||||
{ value: "Vollzeit", label: "Vollzeit (38,5h)" },
|
// Beschriftung. „unverändert" allein zwang dazu, in der
|
||||||
{ value: "Teilzeit", label: "Teilzeit-Elternteilzeit" },
|
// Akte nachzusehen, worauf man sich da einlässt.
|
||||||
|
{ value: "unverändert", label: `Wie vor Abwesenheit (${stundenText} h)` },
|
||||||
|
{ value: "Teilzeit", label: "Reduziert" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{employmentMode === "Teilzeit" && (
|
{employmentMode === "Teilzeit" && (
|
||||||
|
<>
|
||||||
<TextField
|
<TextField
|
||||||
label="Wochenstunden"
|
label="Reduzierte Wochenstunden"
|
||||||
required
|
required
|
||||||
type="number"
|
type="number"
|
||||||
step="0.5"
|
step="0.5"
|
||||||
max="38"
|
max="38"
|
||||||
value={weeklyHours}
|
value={weeklyHours}
|
||||||
onChange={setWeeklyHours}
|
onChange={setWeeklyHours}
|
||||||
|
placeholder={`weniger als ${stundenText}`}
|
||||||
hint="Muss unter 38,5 liegen."
|
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";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { promoteEmployee } from "@/actions/employees";
|
import { promoteEmployee } from "@/actions/employees";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { SelectField, TextField } from "@/components/ui/Field";
|
import { SelectField, TextField } from "@/components/ui/Field";
|
||||||
import { SlideOver } from "@/components/ui/SlideOver";
|
import { SlideOver } from "@/components/ui/SlideOver";
|
||||||
import { useToast } from "@/components/ui/Toast";
|
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"];
|
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||||
|
|
||||||
const PAYGRADES: { value: PaygradeType; label: string }[] = [
|
// Eine Beförderung kann auf derselben Planstelle stattfinden oder auf eine
|
||||||
{ value: "A", label: "A – Einstieg" },
|
// andere führen. Beides kommt vor: jemand wächst auf seiner Stelle, oder er
|
||||||
{ value: "B", label: "B – Qualifiziert" },
|
// rückt auf eine höhere — und dann ist die Zielstelle dieselbe Auswahl wie
|
||||||
{ value: "C", label: "C – Erfahren" },
|
// bei der Versetzung.
|
||||||
{ value: "D", label: "D – Spezialist:in" },
|
type Stellenwahl = "selbe" | "neue";
|
||||||
{ value: "E", label: "E – Teamleitung" },
|
|
||||||
{ value: "F", label: "F – Bereichsleitung / GF" },
|
|
||||||
];
|
|
||||||
|
|
||||||
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 { showToast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [effectiveDate, setEffectiveDate] = useState("");
|
const [effectiveDate, setEffectiveDate] = useState("");
|
||||||
const [newTitle, setNewTitle] = useState(employee.job_title);
|
const [newTitle, setNewTitle] = useState(employee.job_title);
|
||||||
const [paygrade, setPaygrade] = useState<PaygradeType>(employee.paygrade);
|
const [paygrade, setPaygrade] = useState<PaygradeType>(employee.paygrade);
|
||||||
|
const [stellenwahl, setStellenwahl] = useState<Stellenwahl>("selbe");
|
||||||
|
const [positionId, setPositionId] = useState("");
|
||||||
const [pending, setPending] = useState(false);
|
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() {
|
async function handleSubmit() {
|
||||||
if (!effectiveDate || !newTitle) {
|
if (!effectiveDate || !newTitle) {
|
||||||
showToast("Bitte alle Pflichtfelder ausfüllen.", "error");
|
showToast("Bitte alle Pflichtfelder ausfüllen.", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (stellenwahl === "neue" && !positionId) {
|
||||||
|
showToast("Bitte die neue Planstelle auswählen.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setPending(true);
|
setPending(true);
|
||||||
const result = await promoteEmployee({
|
const result = await promoteEmployee({
|
||||||
employee_id: employee.id,
|
employee_id: employee.id,
|
||||||
effective_date: effectiveDate,
|
effective_date: effectiveDate,
|
||||||
new_title: newTitle,
|
new_title: newTitle,
|
||||||
new_paygrade: paygrade,
|
new_paygrade: paygrade,
|
||||||
|
target_position_id: stellenwahl === "neue" ? positionId : undefined,
|
||||||
});
|
});
|
||||||
setPending(false);
|
setPending(false);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -55,7 +97,7 @@ export function PromotePanel({ open, onClose, employee }: { open: boolean; onClo
|
|||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title="Beförderung"
|
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={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="ghost" onClick={onClose}>
|
<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">
|
<div className="flex flex-col gap-4">
|
||||||
<TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
|
<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} />
|
<TextField label="Neue Position" required value={newTitle} onChange={setNewTitle} />
|
||||||
<SelectField
|
<SelectField
|
||||||
label="Paygrade"
|
label="Hay-Grade"
|
||||||
value={paygrade}
|
value={paygrade}
|
||||||
onChange={(v) => setPaygrade(v as PaygradeType)}
|
onChange={(v) => setPaygrade(v as PaygradeType)}
|
||||||
options={PAYGRADES}
|
options={HAY_GRADES}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SlideOver>
|
</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 { SelectField, TextField, TextareaField } from "@/components/ui/Field";
|
||||||
import { SlideOver } from "@/components/ui/SlideOver";
|
import { SlideOver } from "@/components/ui/SlideOver";
|
||||||
import { useToast } from "@/components/ui/Toast";
|
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"];
|
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||||
|
|
||||||
const EXIT_REASONS = ["Einvernehmliche Auflösung", "Kündigung AN", "Kündigung AG", "Befristungsablauf", "Pensionierung", "Entlassung"];
|
// Die Liste steht in lib/beendigung.ts, nicht mehr hier: der Berichtemanager
|
||||||
const CHECKLIST_ITEMS = ["IT-Zugänge deaktivieren", "Hardware retournieren", "ÖGK-Abmeldung", "Endabrechnung & Dienstzeugnis"];
|
// braucht sie ebenso, und zwei Listen liefen auseinander.
|
||||||
|
const NO_SHOW = BEENDIGUNG_NO_SHOW;
|
||||||
|
|
||||||
type TerminatePanelProps = {
|
type TerminatePanelProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
employee: EmployeeRow;
|
employee: EmployeeRow;
|
||||||
|
/** Der zum heutigen Tag abgeleitete Status — nicht `employee.status`, die Spalte hängt nach. */
|
||||||
|
status: EmploymentStatus;
|
||||||
directReportCount: number;
|
directReportCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TerminatePanel({ open, onClose, employee, directReportCount }: TerminatePanelProps) {
|
export function TerminatePanel({ open, onClose, employee, status, directReportCount }: TerminatePanelProps) {
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [exitDate, setExitDate] = useState("");
|
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 [note, setNote] = useState("");
|
||||||
const [checked, setChecked] = useState<boolean[]>(CHECKLIST_ITEMS.map(() => false));
|
|
||||||
const [pending, setPending] = useState(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() {
|
async function handleSubmit() {
|
||||||
if (!exitDate) {
|
if (!wirksamesDatum) {
|
||||||
showToast("Bitte ein Austrittsdatum angeben.", "error");
|
showToast("Bitte ein Austrittsdatum angeben.", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPending(true);
|
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);
|
setPending(false);
|
||||||
if (result.success) {
|
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();
|
router.refresh();
|
||||||
onClose();
|
onClose();
|
||||||
} else {
|
} else {
|
||||||
@@ -51,8 +80,8 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
|
|||||||
<SlideOver
|
<SlideOver
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title="Austritt"
|
title={istNoShow ? "Nicht angetreten" : "Austritt"}
|
||||||
subtitle={`${employee.first_name} ${employee.last_name} · ${employee.job_title}`}
|
subtitle={`${fmtName(employee.first_name, employee.last_name)} · ${employee.job_title}`}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="ghost" onClick={onClose}>
|
<Button variant="ghost" onClick={onClose}>
|
||||||
@@ -65,34 +94,89 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<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 && (
|
{directReportCount > 0 && (
|
||||||
<div className="rounded bg-warning-bg px-3 py-2 text-sm text-warning-text">
|
<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.
|
{directReportCount} direkte Berichte werden automatisch der nächsthöheren Führungskraft zugeordnet.
|
||||||
</div>
|
</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
|
<SelectField
|
||||||
label="Beendigungsart"
|
label="Beendigungsart"
|
||||||
value={reason}
|
value={reason}
|
||||||
onChange={setReason}
|
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} />
|
<TextareaField label="Anmerkung" rows={3} value={note} onChange={setNote} />
|
||||||
<fieldset>
|
{istNoShow && (
|
||||||
<legend className="mb-2 text-sm font-semibold text-ink">Offboarding-Checkliste</legend>
|
<p className="rounded bg-surface px-3 py-2 text-sm text-ink-body">
|
||||||
<div className="flex flex-col gap-2">
|
Die Planstelle wird wieder frei und gilt als nie besetzt. In allen Auswertungen zählt die Person an keinem
|
||||||
{CHECKLIST_ITEMS.map((item, i) => (
|
Stichtag als beschäftigt.
|
||||||
<label key={item} className="flex items-center gap-2 text-sm text-ink-body">
|
</p>
|
||||||
<input
|
)}
|
||||||
type="checkbox"
|
{/* Bei einem Nichtantritt wurde nichts ausgegeben, was zurückkäme —
|
||||||
checked={checked[i]}
|
deshalb entsteht dort auch keine Offboarding-Checkliste. Für einen
|
||||||
onChange={(e) => setChecked((prev) => prev.map((c, idx) => (idx === i ? e.target.checked : c)))}
|
echten Austritt legt terminateEmployee sie in derselben
|
||||||
/>
|
Transaktion an; hier steht nur die Ankündigung, damit niemand sie
|
||||||
{item}
|
hier sucht und nichts findet. */}
|
||||||
</label>
|
{!istNoShow && (
|
||||||
))}
|
<p className="rounded bg-surface px-3 py-2 text-sm text-ink-body">
|
||||||
</div>
|
Nach der Bestätigung steht im Reiter „Offboarding“ eine Checkliste zur Verfügung.
|
||||||
</fieldset>
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SlideOver>
|
</SlideOver>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,48 +7,52 @@ import { Button } from "@/components/ui/Button";
|
|||||||
import { SelectField, TextField } from "@/components/ui/Field";
|
import { SelectField, TextField } from "@/components/ui/Field";
|
||||||
import { SlideOver } from "@/components/ui/SlideOver";
|
import { SlideOver } from "@/components/ui/SlideOver";
|
||||||
import { useToast } from "@/components/ui/Toast";
|
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 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 = {
|
type TransferPanelProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
employee: EmployeeRow;
|
employee: EmployeeRow;
|
||||||
divisions: Division[];
|
openPositions: OpenPositionResolved[];
|
||||||
departments: Department[];
|
|
||||||
teams: Team[];
|
|
||||||
currentTeamId: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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 { showToast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [effectiveDate, setEffectiveDate] = useState("");
|
const [effectiveDate, setEffectiveDate] = useState("");
|
||||||
const [divisionId, setDivisionId] = useState(employee.division_id);
|
const [positionId, setPositionId] = useState("");
|
||||||
const [teamId, setTeamId] = useState(currentTeamId ?? "");
|
|
||||||
const [newTitle, setNewTitle] = useState("");
|
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
const teamsInDivision = useMemo(() => {
|
const options = 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));
|
openPositions
|
||||||
}, [departments, teams, divisionId]);
|
.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() {
|
async function handleSubmit() {
|
||||||
if (!effectiveDate || !teamId) {
|
if (!effectiveDate || !positionId) {
|
||||||
showToast("Bitte Datum und Zielteam angeben.", "error");
|
showToast("Bitte Datum und Zielplanstelle angeben.", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPending(true);
|
setPending(true);
|
||||||
const result = await transferEmployee({
|
const result = await transferEmployee({
|
||||||
employee_id: employee.id,
|
employee_id: employee.id,
|
||||||
effective_date: effectiveDate,
|
effective_date: effectiveDate,
|
||||||
new_team_id: teamId,
|
target_position_id: positionId,
|
||||||
new_title: newTitle || undefined,
|
|
||||||
});
|
});
|
||||||
setPending(false);
|
setPending(false);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -65,13 +69,13 @@ export function TransferPanel({ open, onClose, employee, divisions, departments,
|
|||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title="Versetzung"
|
title="Versetzung"
|
||||||
subtitle={`${employee.first_name} ${employee.last_name} · ${employee.job_title}`}
|
subtitle={`${fmtName(employee.first_name, employee.last_name)} · ${employee.job_title}`}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="ghost" onClick={onClose}>
|
<Button variant="ghost" onClick={onClose}>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSubmit} pending={pending}>
|
<Button onClick={handleSubmit} pending={pending} disabled={options.length === 0}>
|
||||||
Versetzen
|
Versetzen
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -79,31 +83,33 @@ export function TransferPanel({ open, onClose, employee, divisions, departments,
|
|||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
|
<TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
|
||||||
|
{options.length === 0 ? (
|
||||||
|
<p className="rounded border border-border bg-surface p-3 text-sm text-ink-body">
|
||||||
|
Es gibt derzeit keine unbesetzte Planstelle. Eine Versetzung setzt eine freie Zielplanstelle voraus — legen Sie
|
||||||
|
zuerst unter „Positionen“ eine an.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<SelectField
|
<SelectField
|
||||||
label="Neuer Bereich"
|
label="Zielplanstelle"
|
||||||
required
|
required
|
||||||
value={divisionId}
|
value={positionId}
|
||||||
onChange={(v) => {
|
onChange={setPositionId}
|
||||||
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…"
|
placeholder="Bitte wählen…"
|
||||||
options={teamsInDivision.map((t) => ({ value: t.id, label: t.name }))}
|
options={options}
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Neuer Titel (optional)"
|
|
||||||
value={newTitle}
|
|
||||||
onChange={setNewTitle}
|
|
||||||
placeholder={employee.job_title}
|
|
||||||
hint="Die neue Führungskraft wird automatisch anhand des Zielteams bestimmt."
|
|
||||||
/>
|
/>
|
||||||
|
{selected && (
|
||||||
|
<div className="rounded border border-border bg-surface p-3 text-sm text-ink-body">
|
||||||
|
<div className="font-semibold text-ink">{selected.title}</div>
|
||||||
|
<div className="text-xs text-ink-muted">{selected.orgLabel}</div>
|
||||||
|
<div className="mt-1 text-xs text-ink-muted">
|
||||||
|
{selected.is_chief ? "Leitungsplanstelle" : "Mitarbeiterplanstelle"}
|
||||||
|
{selected.managerName ? ` · berichtet an ${selected.managerName}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SlideOver>
|
</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 { 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"];
|
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 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) {
|
if (history.length === 0) {
|
||||||
return <p className="text-sm text-ink-muted">Keine Historieneinträge vorhanden.</p>;
|
return <p className="text-sm text-ink-muted">Keine Historieneinträge vorhanden.</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<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">
|
<ul className="flex flex-col divide-y divide-border">
|
||||||
{history.map((h) => {
|
{gefiltert.map((h) => {
|
||||||
const isFuture = h.event_date > today;
|
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 (
|
return (
|
||||||
<li key={h.id} className="py-3">
|
<li key={h.id} className="py-3">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<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={`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>
|
<span className="text-sm text-ink-muted">{fmtDate(h.event_date)}</span>
|
||||||
{isFuture && (
|
{isFuture && (
|
||||||
<span className="rounded-full bg-warning-bg px-2 py-0.5 text-xs font-semibold text-warning-text">
|
<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)}
|
⏱ zukünftig – wirksam ab {fmtDate(h.event_date)}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
<p className="mt-1 text-sm text-ink">{h.description}</p>
|
<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>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { SelectField, TextField, TextareaField } from "@/components/ui/Field";
|
|||||||
import { useToast } from "@/components/ui/Toast";
|
import { useToast } from "@/components/ui/Toast";
|
||||||
import { NOTE_CATEGORY_STYLES } from "@/lib/colors";
|
import { NOTE_CATEGORY_STYLES } from "@/lib/colors";
|
||||||
import { fmtDate } from "@/lib/format";
|
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"];
|
type Note = Database["public"]["Tables"]["employee_notes"]["Row"];
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ export function NotizenTab({ employeeId, notes }: { employeeId: string; notes: N
|
|||||||
{n.done ? (
|
{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-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>
|
</div>
|
||||||
<p className="mt-1 text-sm text-ink">{n.note_text}</p>
|
<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 Link from "next/link";
|
||||||
import { Avatar } from "@/components/ui/Avatar";
|
import { Avatar } from "@/components/ui/Avatar";
|
||||||
import { LINK_BUTTON_CLASS } from "@/components/ui/Button";
|
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 MiniEmployee = { id: string; first_name: string; last_name: string; job_title: string; status?: string };
|
||||||
|
|
||||||
type OrganisationTabProps = {
|
type OrganisationTabProps = {
|
||||||
employeeId: string;
|
employeeId: string;
|
||||||
manager: MiniEmployee | null;
|
manager: MiniEmployee | null;
|
||||||
|
/** Nur gesetzt, wenn die zuständige Leitung abwesend ist und vertreten wird. */
|
||||||
|
formalManager: MiniEmployee | null;
|
||||||
directReports: MiniEmployee[];
|
directReports: MiniEmployee[];
|
||||||
breadcrumb: string;
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-ink-muted">Organisationseinheit</h3>
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-ink-muted">Organisationseinheit</h3>
|
||||||
<p className="mt-1 text-sm text-ink">{breadcrumb}</p>
|
<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>
|
</div>
|
||||||
{/* ?focus= drives the same highlight/auto-expand path the org chart
|
{/* ?focus= drives the same highlight/auto-expand path the org chart
|
||||||
search already uses, so the person is unfolded and centred on
|
search already uses, so the person is unfolded and centred on
|
||||||
@@ -30,13 +66,21 @@ export function OrganisationTab({ employeeId, manager, directReports, breadcrumb
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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 ? (
|
{manager ? (
|
||||||
<Link href={`/employees/${manager.id}`} className="flex w-fit items-center gap-3 rounded border border-border p-3 hover:bg-surface">
|
<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} />
|
<Avatar firstName={manager.first_name} lastName={manager.last_name} />
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-semibold text-ink">
|
<div className="text-sm font-semibold text-ink">
|
||||||
{manager.first_name} {manager.last_name}
|
{fmtName(manager.first_name, manager.last_name)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-ink-muted">{manager.job_title}</div>
|
<div className="text-xs text-ink-muted">{manager.job_title}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,7 +99,7 @@ export function OrganisationTab({ employeeId, manager, directReports, breadcrumb
|
|||||||
<Avatar firstName={r.first_name} lastName={r.last_name} />
|
<Avatar firstName={r.first_name} lastName={r.last_name} />
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-semibold text-ink">
|
<div className="text-sm font-semibold text-ink">
|
||||||
{r.first_name} {r.last_name}
|
{fmtName(r.first_name, r.last_name)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-ink-muted">{r.job_title}</div>
|
<div className="text-xs text-ink-muted">{r.job_title}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,32 +1,65 @@
|
|||||||
import { AngehoerigeSection } from "@/components/employees/AngehoerigeSection";
|
import { AngehoerigeSection } from "@/components/employees/AngehoerigeSection";
|
||||||
|
import { NotfallkontaktSection } from "@/components/employees/NotfallkontaktSection";
|
||||||
|
import { brauchtAufenthaltstitel } from "@/lib/countries";
|
||||||
import { fmtAge, fmtDate } from "@/lib/format";
|
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 EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||||
type Location = Database["public"]["Tables"]["locations"]["Row"];
|
type Location = Database["public"]["Tables"]["locations"]["Row"];
|
||||||
type Dependent = Database["public"]["Tables"]["employee_dependents"]["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[] }) {
|
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
|
// Defensive against a DB that hasn't received the title_prefix/title_suffix
|
||||||
// migration yet — select("*") simply omits unknown columns, so these can
|
// migration yet — select("*") simply omits unknown columns, so these can
|
||||||
// be undefined rather than the empty array the column default implies.
|
// 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][] = [
|
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)`],
|
["Geburtsdatum", `${fmtDate(employee.birth_date)} (${fmtAge(employee.birth_date)} Jahre)`],
|
||||||
["SV-Nummer", employee.sv_nummer ?? "–"],
|
["SV-Nummer", employee.sv_nummer ?? "–"],
|
||||||
["Staatsbürgerschaft", employee.nationality],
|
["Staatsbürgerschaft", employee.nationality],
|
||||||
["E-Mail", employee.email],
|
// Nur, wo er verlangt ist. Bei einer österreichischen Staatsbürger-
|
||||||
["Telefon", employee.phone ?? "–"],
|
// schaft wäre die Zeile „Aufenthaltstitel: Nein" keine Auskunft,
|
||||||
["Standort", location ? `${location.name} (${location.country})` : "–"],
|
// sondern eine Frage, die sich nicht stellt.
|
||||||
["Adresse", formatAddress(employee)],
|
...(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 ?? "–"],
|
["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 (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
@@ -38,7 +71,17 @@ export function StammdatenTab({ employee, location, dependents }: { employee: Em
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<AngehoerigeSection employeeId={employee.id} dependents={dependents} />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,55 @@
|
|||||||
|
import { dienstwagenLabel } from "@/lib/dienstwagen";
|
||||||
import { fmtDate } from "@/lib/format";
|
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"];
|
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||||
|
|
||||||
const PAYGRADE_LABELS: Record<string, string> = {
|
/**
|
||||||
A: "A – Einstieg",
|
* Der Kündigungsschutz mit Grund und Zeitraum.
|
||||||
B: "B – Qualifiziert",
|
*
|
||||||
C: "C – Erfahren",
|
* Hier stand nur „bis TT.MM.JJJJ". Erfasst werden aber Personenkreis, Beginn
|
||||||
D: "D – Spezialist:in",
|
* und Ende — und der Personenkreis ist die eigentliche Auskunft: „bis 2030"
|
||||||
E: "E – Teamleitung",
|
* sagt nicht, warum. Gemeldet am 29.09. von Lara und im Testprotokoll (H.09).
|
||||||
F: "F – Bereichsleitung / GF",
|
*
|
||||||
};
|
* 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 }) {
|
export function VertragTab({ employee }: { employee: EmployeeRow }) {
|
||||||
const flags = [
|
// Früher stand hier eine einzige Zeile „Merkmale" mit allem, was zutraf,
|
||||||
employee.is_betriebsrat && "Betriebsrat",
|
// durch Kommas getrennt — und ein Gedankenstrich, wenn nichts zutraf. Damit
|
||||||
employee.has_dienstwagen && "Dienstwagen",
|
// liess sich nicht ablesen, ob jemand *keinen* Dienstwagen hat oder ob
|
||||||
employee.is_laterale_fuehrung && "Laterale Führung",
|
// niemand die Frage je beantwortet hat. Jedes Merkmal steht jetzt für sich,
|
||||||
employee.is_c_level && "C-Level",
|
// mit Ja oder Nein, wie jede andere Zeile auf diesem Blatt auch.
|
||||||
].filter(Boolean);
|
|
||||||
const rows: [string, string][] = [
|
const rows: [string, string][] = [
|
||||||
["Eintrittsdatum", fmtDate(employee.entry_date)],
|
["Eintrittsdatum", fmtDate(employee.entry_date)],
|
||||||
["Vertragsart", employee.contract_type === "befristet" ? `befristet bis ${fmtDate(employee.contract_end_date)}` : "unbefristet"],
|
["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],
|
["Beschäftigungsausmaß", employee.employment_type],
|
||||||
["Wochenstunden", `${employee.weekly_hours} h`],
|
["Wochenstunden", `${employee.weekly_hours} h`],
|
||||||
["Urlaubsanspruch", "25 Tage"],
|
["Urlaubsanspruch", "25 Tage"],
|
||||||
["Paygrade", PAYGRADE_LABELS[employee.paygrade] ?? employee.paygrade],
|
["Hay-Grade", hayGradeLabel(employee.paygrade)],
|
||||||
["Angestellte:r / Arbeiter:in", employee.worker_type ?? "–"],
|
["Beschäftigtengruppe", employee.worker_type ?? "–"],
|
||||||
["Arbeitstage", employee.work_days?.join(", ") || "–"],
|
["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)]);
|
if (employee.exit_date) rows.push(["Austrittsdatum", fmtDate(employee.exit_date)]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,29 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { hireEmployee } from "@/actions/employees";
|
import { addEmployeeDependent, hireEmployee } from "@/actions/employees";
|
||||||
import { deleteHireDraft, saveHireDraft } from "@/actions/hireDrafts";
|
import { deleteHireDraft, saveHireDraft } from "@/actions/hireDrafts";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Modal } from "@/components/ui/Modal";
|
import { Modal } from "@/components/ui/Modal";
|
||||||
import { useToast } from "@/components/ui/Toast";
|
import { useToast } from "@/components/ui/Toast";
|
||||||
|
import { GRUND_BEGUENSTIGT_BEHINDERT } from "@/lib/kuendigungsschutz";
|
||||||
import type { OpenPositionResolved } from "@/lib/positions";
|
import type { OpenPositionResolved } from "@/lib/positions";
|
||||||
import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr";
|
import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr";
|
||||||
|
import { StepAngehoerige } from "./StepAngehoerige";
|
||||||
|
import { StepNotfallkontakt } from "./StepNotfallkontakt";
|
||||||
import { StepPerson } from "./StepPerson";
|
import { StepPerson } from "./StepPerson";
|
||||||
import { StepPosition } from "./StepPosition";
|
import { StepPosition } from "./StepPosition";
|
||||||
import { StepSummary } from "./StepSummary";
|
import { StepSummary } from "./StepSummary";
|
||||||
import { StepVertrag } from "./StepVertrag";
|
import { StepVertrag } from "./StepVertrag";
|
||||||
import { EMPTY_HIRE_DRAFT, type HireDraftData } from "./types";
|
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 = {
|
type HireWizardProps = {
|
||||||
open: boolean;
|
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
|
// 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.
|
// 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 =
|
const svNummerOk =
|
||||||
!draft.svNummer.trim() ||
|
!draft.svNummer.trim() ||
|
||||||
!requiresAustrianSvnr(locations.find((l) => l.id === draft.locationId)?.country) ||
|
!requiresAustrianSvnr(locations.find((l) => l.id === draft.locationId)?.country) ||
|
||||||
isValidSvnr(draft.svNummer, draft.birthDate || null);
|
isValidSvnr(draft.svNummer, draft.birthDate || null);
|
||||||
|
|
||||||
const stepValid = [
|
// Am Namen des Schritts, nicht an seiner Nummer.
|
||||||
Boolean(draft.firstName && draft.lastName && draft.birthDate && draft.locationId) && svNummerOk,
|
//
|
||||||
Boolean(draft.positionId && draft.besetzung),
|
// Vorher war das eine Liste in derselben Reihenfolge wie STEP_LABELS, und
|
||||||
Boolean(draft.entryDate && draft.workDays.length > 0),
|
// die beiden mussten stumm zusammenpassen. Beim Vertauschen von Vertrag und
|
||||||
true,
|
// Angehörige wäre die Prüfung stehengeblieben, wo sie war: „Weiter" im
|
||||||
][step];
|
// 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() {
|
async function handleSaveDraft() {
|
||||||
const result = await saveHireDraft({ id: draftId, step, data: draft });
|
const result = await saveHireDraft({ id: draftId, step, data: draft });
|
||||||
@@ -77,8 +133,13 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
|||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
if (!selectedPosition || !draft.besetzung) return;
|
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);
|
setSubmitting(true);
|
||||||
const result = await hireEmployee({
|
const result = await hireEmployee({
|
||||||
|
personnel_number: Number(draft.personnelNumber),
|
||||||
first_name: draft.firstName,
|
first_name: draft.firstName,
|
||||||
last_name: draft.lastName,
|
last_name: draft.lastName,
|
||||||
title_prefix: draft.titlePrefix,
|
title_prefix: draft.titlePrefix,
|
||||||
@@ -86,6 +147,9 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
|||||||
gender: draft.gender,
|
gender: draft.gender,
|
||||||
birth_date: draft.birthDate,
|
birth_date: draft.birthDate,
|
||||||
sv_nummer: draft.svNummer || undefined,
|
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,
|
phone: draft.phone || undefined,
|
||||||
position_id: draft.positionId,
|
position_id: draft.positionId,
|
||||||
location_id: draft.locationId,
|
location_id: draft.locationId,
|
||||||
@@ -101,17 +165,65 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
|||||||
work_days: draft.workDays,
|
work_days: draft.workDays,
|
||||||
is_betriebsrat: draft.isBetriebsrat,
|
is_betriebsrat: draft.isBetriebsrat,
|
||||||
has_dienstwagen: draft.hasDienstwagen,
|
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_laterale_fuehrung: draft.isLateraleFuehrung,
|
||||||
is_c_level: draft.isCLevel,
|
is_c_level: draft.isCLevel,
|
||||||
});
|
});
|
||||||
|
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);
|
setSubmitting(false);
|
||||||
if (result.success) {
|
|
||||||
showToast(`${draft.firstName} ${draft.lastName} wurde eingestellt.`);
|
|
||||||
if (draftId) await deleteHireDraft(draftId);
|
if (draftId) await deleteHireDraft(draftId);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
onClose();
|
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 {
|
} else {
|
||||||
showToast(result.error ?? "Fehler beim Anlegen.", "error");
|
showToast(`${draft.firstName} ${draft.lastName} wurde eingestellt.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,12 +249,12 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
|||||||
Zurück
|
Zurück
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{step < 3 && (
|
{step < letzterSchritt && (
|
||||||
<Button onClick={() => setStep((s) => s + 1)} disabled={!stepValid}>
|
<Button onClick={() => setStep((s) => s + 1)} disabled={!stepValid}>
|
||||||
Weiter
|
Weiter
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{step === 3 && (
|
{step === letzterSchritt && (
|
||||||
<Button onClick={handleSubmit} pending={submitting}>
|
<Button onClick={handleSubmit} pending={submitting}>
|
||||||
Anlegen
|
Anlegen
|
||||||
</Button>
|
</Button>
|
||||||
@@ -174,10 +286,12 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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 === 1 && <StepPosition draft={draft} update={update} openPositions={openPositions} />}
|
||||||
{step === 2 && <StepVertrag draft={draft} update={update} />}
|
{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>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
"use client";
|
"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 type { OpenPositionResolved } from "@/lib/positions";
|
||||||
import { HireWizard } from "./HireWizard";
|
import { HireWizard } from "./HireWizard";
|
||||||
|
|
||||||
type Location = { id: string; name: string; country: string };
|
type Location = { id: string; name: string; country: string };
|
||||||
type HireDraft = { id: string; step: number; payload: Record<string, unknown> };
|
|
||||||
|
|
||||||
type OpenWizardOptions = { draftId?: string; positionId?: string };
|
type OpenWizardOptions = { draftId?: string; positionId?: string };
|
||||||
|
|
||||||
@@ -15,6 +18,16 @@ type HireWizardContextValue = {
|
|||||||
|
|
||||||
const HireWizardContext = createContext<HireWizardContextValue | null>(null);
|
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({
|
export function HireWizardProvider({
|
||||||
children,
|
children,
|
||||||
openPositions,
|
openPositions,
|
||||||
@@ -24,10 +37,12 @@ export function HireWizardProvider({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
openPositions: OpenPositionResolved[];
|
openPositions: OpenPositionResolved[];
|
||||||
locations: Location[];
|
locations: Location[];
|
||||||
drafts: HireDraft[];
|
drafts: Entwurf[];
|
||||||
}) {
|
}) {
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const router = useRouter();
|
||||||
const [open, setOpen] = useState(false);
|
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);
|
const [initialPositionId, setInitialPositionId] = useState<string | undefined>(undefined);
|
||||||
// Forces HireWizard to remount fresh each time it's opened, so its
|
// Forces HireWizard to remount fresh each time it's opened, so its
|
||||||
// internal draft/step state is (re-)initialized directly from the current
|
// internal draft/step state is (re-)initialized directly from the current
|
||||||
@@ -35,20 +50,73 @@ export function HireWizardProvider({
|
|||||||
// effect needed inside HireWizard itself.
|
// effect needed inside HireWizard itself.
|
||||||
const [openKey, setOpenKey] = useState(0);
|
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);
|
setResumeDraft(options?.draftId ? (drafts.find((d) => d.id === options.draftId) ?? null) : null);
|
||||||
setInitialPositionId(options?.positionId);
|
setInitialPositionId(options?.positionId);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
setOpenKey((k) => k + 1);
|
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 (
|
return (
|
||||||
<HireWizardContext.Provider value={{ openWizard }}>
|
<HireWizardContext.Provider value={{ openWizard }}>
|
||||||
{children}
|
{children}
|
||||||
<HireWizard
|
<HireWizard
|
||||||
key={openKey}
|
key={openKey}
|
||||||
open={open}
|
open={open}
|
||||||
onClose={() => setOpen(false)}
|
onClose={closeWizard}
|
||||||
openPositions={openPositions}
|
openPositions={openPositions}
|
||||||
locations={locations}
|
locations={locations}
|
||||||
resumeDraft={resumeDraft}
|
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 { SvNummerField } from "@/components/employees/SvNummerField";
|
||||||
import { TitleFields } from "@/components/employees/TitleFields";
|
import { TitleFields } from "@/components/employees/TitleFields";
|
||||||
import { SelectField, TextField } from "@/components/ui/Field";
|
import { SelectField, TextField } from "@/components/ui/Field";
|
||||||
|
import { PersonalnummerField } from "./PersonalnummerField";
|
||||||
import type { HireDraftData } from "./types";
|
import type { HireDraftData } from "./types";
|
||||||
|
|
||||||
type StepPersonProps = {
|
type StepPersonProps = {
|
||||||
draft: HireDraftData;
|
draft: HireDraftData;
|
||||||
update: (patch: Partial<HireDraftData>) => void;
|
update: (patch: Partial<HireDraftData>) => void;
|
||||||
locations: { id: string; name: string; country: string }[];
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<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">
|
<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="Vorname" required value={draft.firstName} onChange={(firstName) => update({ firstName })} />
|
||||||
<TextField label="Nachname" required value={draft.lastName} onChange={(lastName) => update({ lastName })} />
|
<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}
|
birthDate={draft.birthDate || null}
|
||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<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 })} />
|
{/* Die private Adresse, nicht die Firmenadresse — die entsteht erst
|
||||||
<TextField label="Telefon" type="tel" value={draft.phone} onChange={(phone) => update({ phone })} />
|
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>
|
</div>
|
||||||
<SelectField
|
<SelectField
|
||||||
label="Standort"
|
label="Standort"
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
|
import { dienstwagenLabel } from "@/lib/dienstwagen";
|
||||||
import { fmtDate, fmtFullName } from "@/lib/format";
|
import { fmtDate, fmtFullName } from "@/lib/format";
|
||||||
|
import { hayGradeLabel } from "@/lib/hay-grade";
|
||||||
import type { OpenPositionResolved } from "@/lib/positions";
|
import type { OpenPositionResolved } from "@/lib/positions";
|
||||||
import type { HireDraftData } from "./types";
|
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 = {
|
type StepSummaryProps = {
|
||||||
draft: HireDraftData;
|
draft: HireDraftData;
|
||||||
selectedPosition: OpenPositionResolved | null;
|
selectedPosition: OpenPositionResolved | null;
|
||||||
@@ -21,17 +14,25 @@ export function StepSummary({ draft, selectedPosition, locations }: StepSummaryP
|
|||||||
const location = locations.find((l) => l.id === draft.locationId);
|
const location = locations.find((l) => l.id === draft.locationId);
|
||||||
const flags = [
|
const flags = [
|
||||||
draft.isBetriebsrat && "Betriebsrat",
|
draft.isBetriebsrat && "Betriebsrat",
|
||||||
draft.hasDienstwagen && "Dienstwagen",
|
draft.hasDienstwagen && `Dienstwagen (${dienstwagenLabel(draft.dienstwagenArt)})`,
|
||||||
draft.isLateraleFuehrung && "Laterale Führung",
|
draft.isLateraleFuehrung && "Laterale Führung",
|
||||||
draft.isCLevel && "C-Level",
|
draft.isCLevel && "C-Level",
|
||||||
|
draft.hasKuendigungsschutz &&
|
||||||
|
`Besonderer Kündigungsschutz${draft.kuendigungsschutzBis ? ` bis ${fmtDate(draft.kuendigungsschutzBis)}` : ""}`,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
const notfall = [draft.emergencyContactName, draft.emergencyContactRelation && `(${draft.emergencyContactRelation})`, draft.emergencyContactPhone]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
const rows: [string, string][] = [
|
const rows: [string, string][] = [
|
||||||
|
["Personalnummer", draft.personnelNumber || "–"],
|
||||||
["Name", fmtFullName(draft.firstName, draft.lastName, draft.titlePrefix, draft.titleSuffix)],
|
["Name", fmtFullName(draft.firstName, draft.lastName, draft.titlePrefix, draft.titleSuffix)],
|
||||||
["Geschlecht", draft.gender === "m" ? "männlich" : "weiblich"],
|
["Geschlecht", draft.gender === "m" ? "männlich" : "weiblich"],
|
||||||
["Geburtsdatum", fmtDate(draft.birthDate)],
|
["Geburtsdatum", fmtDate(draft.birthDate)],
|
||||||
["SV-Nummer", draft.svNummer || "–"],
|
["SV-Nummer", draft.svNummer || "–"],
|
||||||
["E-Mail (privat)", draft.email || "–"],
|
["E-Mail (privat)", draft.email || "–"],
|
||||||
["Telefon", draft.phone || "–"],
|
["Telefon", draft.phone || "–"],
|
||||||
|
["Notfallkontakt", notfall || "–"],
|
||||||
["Standort", location ? `${location.name} (${location.country})` : "–"],
|
["Standort", location ? `${location.name} (${location.country})` : "–"],
|
||||||
["Position", selectedPosition ? `${selectedPosition.title} (${selectedPosition.position_number})` : "–"],
|
["Position", selectedPosition ? `${selectedPosition.title} (${selectedPosition.position_number})` : "–"],
|
||||||
["Organisationseinheit", selectedPosition?.orgLabel ?? "–"],
|
["Organisationseinheit", selectedPosition?.orgLabel ?? "–"],
|
||||||
@@ -40,8 +41,8 @@ export function StepSummary({ draft, selectedPosition, locations }: StepSummaryP
|
|||||||
["Eintrittsdatum", fmtDate(draft.entryDate)],
|
["Eintrittsdatum", fmtDate(draft.entryDate)],
|
||||||
["Vertragsart", draft.contractType === "befristet" ? `befristet bis ${fmtDate(draft.contractEndDate)}` : "unbefristet"],
|
["Vertragsart", draft.contractType === "befristet" ? `befristet bis ${fmtDate(draft.contractEndDate)}` : "unbefristet"],
|
||||||
["Beschäftigungsausmaß", `${draft.employmentType} (${draft.weeklyHours} h)`],
|
["Beschäftigungsausmaß", `${draft.employmentType} (${draft.weeklyHours} h)`],
|
||||||
["Paygrade", PAYGRADE_LABELS[draft.paygrade]],
|
["Hay-Grade", hayGradeLabel(draft.paygrade)],
|
||||||
["Angestellte:r / Arbeiter:in", draft.workerType],
|
["Beschäftigtengruppe", draft.workerType],
|
||||||
["Kollektivvertrag", draft.collectiveAgreement],
|
["Kollektivvertrag", draft.collectiveAgreement],
|
||||||
["Arbeitstage", draft.workDays.join(", ") || "–"],
|
["Arbeitstage", draft.workDays.join(", ") || "–"],
|
||||||
["Merkmale", flags.length > 0 ? flags.join(", ") : "–"],
|
["Merkmale", flags.length > 0 ? flags.join(", ") : "–"],
|
||||||
@@ -57,9 +58,6 @@ export function StepSummary({ draft, selectedPosition, locations }: StepSummaryP
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
import { RoleEmploymentFields } from "@/components/employees/RoleEmploymentFields";
|
import { RoleEmploymentFields } from "@/components/employees/RoleEmploymentFields";
|
||||||
import { SelectField, TextField } from "@/components/ui/Field";
|
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";
|
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 }) {
|
export function StepVertrag({ draft, update }: { draft: HireDraftData; update: (patch: Partial<HireDraftData>) => void }) {
|
||||||
function handleEmploymentTypeChange(value: HireDraftData["employmentType"]) {
|
function handleEmploymentTypeChange(value: HireDraftData["employmentType"]) {
|
||||||
update({
|
update({
|
||||||
@@ -63,12 +55,11 @@ export function StepVertrag({ draft, update }: { draft: HireDraftData; update: (
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<SelectField
|
<SelectField
|
||||||
label="Paygrade"
|
label="Hay-Grade"
|
||||||
required
|
required
|
||||||
value={draft.paygrade}
|
value={draft.paygrade}
|
||||||
onChange={(v) => update({ paygrade: v as PaygradeType })}
|
onChange={(v) => update({ paygrade: v as PaygradeType })}
|
||||||
options={PAYGRADES}
|
options={HAY_GRADES}
|
||||||
hint={PAYGRADES.find((p) => p.value === draft.paygrade)?.description}
|
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-ink-muted">Es gilt eine Probezeit von 1 Monat gemäß Kollektivvertrag.</p>
|
<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
|
// 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
|
// though both are NOT NULL on employees — added here (defaults keep them
|
||||||
// effectively "free" for the user, same treatment as the karenz-start gap).
|
// 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 = {
|
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;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
titlePrefix: string[];
|
titlePrefix: string[];
|
||||||
@@ -12,6 +35,9 @@ export type HireDraftData = {
|
|||||||
birthDate: string;
|
birthDate: string;
|
||||||
svNummer: string;
|
svNummer: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
companyEmail: string;
|
||||||
|
/** Kennung im Lernsystem Cornerstone. Freiwillig. */
|
||||||
|
cornerstoneId: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
locationId: string;
|
locationId: string;
|
||||||
positionId: string;
|
positionId: string;
|
||||||
@@ -27,11 +53,31 @@ export type HireDraftData = {
|
|||||||
workDays: Weekday[];
|
workDays: Weekday[];
|
||||||
isBetriebsrat: boolean;
|
isBetriebsrat: boolean;
|
||||||
hasDienstwagen: 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;
|
isLateraleFuehrung: boolean;
|
||||||
isCLevel: 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 = {
|
export const EMPTY_HIRE_DRAFT: HireDraftData = {
|
||||||
|
personnelNumber: "",
|
||||||
firstName: "",
|
firstName: "",
|
||||||
lastName: "",
|
lastName: "",
|
||||||
titlePrefix: [],
|
titlePrefix: [],
|
||||||
@@ -40,6 +86,8 @@ export const EMPTY_HIRE_DRAFT: HireDraftData = {
|
|||||||
birthDate: "",
|
birthDate: "",
|
||||||
svNummer: "",
|
svNummer: "",
|
||||||
email: "",
|
email: "",
|
||||||
|
companyEmail: "",
|
||||||
|
cornerstoneId: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
locationId: "",
|
locationId: "",
|
||||||
positionId: "",
|
positionId: "",
|
||||||
@@ -49,12 +97,26 @@ export const EMPTY_HIRE_DRAFT: HireDraftData = {
|
|||||||
contractEndDate: "",
|
contractEndDate: "",
|
||||||
employmentType: "Vollzeit",
|
employmentType: "Vollzeit",
|
||||||
weeklyHours: "38.5",
|
weeklyHours: "38.5",
|
||||||
paygrade: "B",
|
paygrade: HAY_GRADE_STANDARD,
|
||||||
workerType: "Angestellte:r",
|
workerType: "Angestellte:r",
|
||||||
collectiveAgreement: "Handel",
|
collectiveAgreement: "Handel",
|
||||||
workDays: ["Mo", "Di", "Mi", "Do", "Fr"],
|
workDays: ["Mo", "Di", "Mi", "Do", "Fr"],
|
||||||
isBetriebsrat: false,
|
isBetriebsrat: false,
|
||||||
hasDienstwagen: false,
|
hasDienstwagen: false,
|
||||||
|
dienstwagenArt: "Verbrenner",
|
||||||
|
emergencyContactName: "",
|
||||||
|
emergencyContactPhone: "",
|
||||||
|
emergencyContactRelation: "",
|
||||||
|
angehoerige: [],
|
||||||
isLateraleFuehrung: false,
|
isLateraleFuehrung: false,
|
||||||
isCLevel: 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 { CalendarClock } from "lucide-react";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/Button";
|
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";
|
import { fmtDate } from "@/lib/format";
|
||||||
|
|
||||||
type AsOfPickerProps = {
|
type AsOfPickerProps = {
|
||||||
@@ -27,6 +28,14 @@ export function AsOfPicker({ asOf, today, projectedCount, historyStartsAt }: AsO
|
|||||||
router.push(sp.size > 0 ? `${pathname}?${sp}` : pathname, { scroll: false });
|
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 isToday = asOf === today;
|
||||||
const isFuture = asOf > today;
|
const isFuture = asOf > today;
|
||||||
// Assignments only started being recorded when the history table was
|
// 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" />
|
<CalendarClock className="h-4 w-4 text-ink-muted" />
|
||||||
Stichtag
|
Stichtag
|
||||||
</label>
|
</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
|
<input
|
||||||
id="orgchart-asof"
|
id="orgchart-asof"
|
||||||
type="date"
|
type="date"
|
||||||
value={asOf}
|
value={entwurf}
|
||||||
onChange={(e) => setAsOf(e.target.value || undefined)}
|
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}
|
className={FILTER_SELECT_CLASS}
|
||||||
/>
|
/>
|
||||||
{!isToday && (
|
{!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 { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||||
import { LazyGraphOrgChart } from "./LazyGraphOrgChart";
|
import { LazyGraphOrgChart } from "./LazyGraphOrgChart";
|
||||||
import type { ChartNode, OrgEmployee } from "./types";
|
import type { ChartNode, OrgEmployee } from "./types";
|
||||||
|
import { fmtName } from "@/lib/format";
|
||||||
|
|
||||||
type ViewMode = "list" | "graph";
|
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
|
// Both badges name a person by id, so the lookup is shared rather than
|
||||||
// rebuilt per node.
|
// 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 nameOf = useCallback((id: string | null) => (id ? nameById.get(id) : undefined), [nameById]);
|
||||||
|
|
||||||
const matchIds = useMemo(() => {
|
const matchIds = useMemo(() => {
|
||||||
@@ -64,8 +65,17 @@ export function EmployeeTree({ employees, focusId = null }: { employees: OrgEmpl
|
|||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
const matches = new Set<string>();
|
const matches = new Set<string>();
|
||||||
for (const e of employees) {
|
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 (
|
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) ||
|
e.job_title.toLowerCase().includes(q) ||
|
||||||
String(e.personnel_number).includes(q)
|
String(e.personnel_number).includes(q)
|
||||||
) {
|
) {
|
||||||
@@ -118,7 +128,7 @@ export function EmployeeTree({ employees, focusId = null }: { employees: OrgEmpl
|
|||||||
return {
|
return {
|
||||||
id: e.id,
|
id: e.id,
|
||||||
kind: "person",
|
kind: "person",
|
||||||
label: `${e.first_name} ${e.last_name}`,
|
label: fmtName(e.first_name, e.last_name),
|
||||||
sublabel: e.job_title,
|
sublabel: e.job_title,
|
||||||
href: `/employees/${e.id}`,
|
href: `/employees/${e.id}`,
|
||||||
avatar: { firstName: e.first_name, lastName: e.last_name },
|
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 key={e.id}>
|
||||||
<div
|
<div
|
||||||
ref={e.id === focusId ? focusRef : undefined}
|
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 }}
|
style={{ paddingLeft: depth * 24 + 8 }}
|
||||||
>
|
>
|
||||||
{hasChildren ? (
|
{hasChildren ? (
|
||||||
@@ -159,7 +169,7 @@ export function EmployeeTree({ employees, focusId = null }: { employees: OrgEmpl
|
|||||||
<Avatar firstName={e.first_name} lastName={e.last_name} size="sm" />
|
<Avatar firstName={e.first_name} lastName={e.last_name} size="sm" />
|
||||||
<Link href={`/employees/${e.id}`} className="min-w-0 flex-1 hover:underline">
|
<Link href={`/employees/${e.id}`} className="min-w-0 flex-1 hover:underline">
|
||||||
<span className="text-sm font-semibold text-ink">
|
<span className="text-sm font-semibold text-ink">
|
||||||
{e.first_name} {e.last_name}
|
{fmtName(e.first_name, e.last_name)}
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-2 text-xs text-ink-muted">{e.job_title}</span>
|
<span className="ml-2 text-xs text-ink-muted">{e.job_title}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -23,11 +23,15 @@ import type { ChartNode, ChartNodeKind } from "./types";
|
|||||||
// object as a change and remounts every custom node otherwise.
|
// object as a change and remounts every custom node otherwise.
|
||||||
const NODE_TYPES = { orgNode: OrgChartNode };
|
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> = {
|
const MINIMAP_COLORS: Record<ChartNodeKind, string> = {
|
||||||
person: "#d6046e",
|
person: "#164194", // brand-500, Manner Blau
|
||||||
role: "#5c2e91",
|
role: "#54277f", // purple-text
|
||||||
group: "#c9b3c0",
|
group: "#756c6e", // ink-muted
|
||||||
vacancy: "#f6cfe2",
|
vacancy: "#f69686", // accent-500, Manner Rosa
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GraphOrgChartProps = {
|
export type GraphOrgChartProps = {
|
||||||
@@ -35,6 +39,10 @@ export type GraphOrgChartProps = {
|
|||||||
isExpanded: (id: string) => boolean;
|
isExpanded: (id: string) => boolean;
|
||||||
onToggle: (id: string) => void;
|
onToggle: (id: string) => void;
|
||||||
matchedIds?: Set<string> | null;
|
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) {
|
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 { visibleNodes, visibleEdges } = useMemo(() => collectVisible(tree, isExpanded), [tree, isExpanded]);
|
||||||
|
|
||||||
const { rfNodes, rfEdges } = useMemo(() => {
|
const { rfNodes, rfEdges } = useMemo(() => {
|
||||||
@@ -61,6 +77,9 @@ function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds }: GraphOrg
|
|||||||
hasChildren: n.children.length > 0,
|
hasChildren: n.children.length > 0,
|
||||||
childCount: n.children.length,
|
childCount: n.children.length,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
onAddUnit,
|
||||||
|
onDeleteUnit,
|
||||||
|
onSelectUnit,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
const rfEdges: Edge[] = visibleEdges.map((e) => ({
|
const rfEdges: Edge[] = visibleEdges.map((e) => ({
|
||||||
@@ -72,7 +91,7 @@ function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds }: GraphOrg
|
|||||||
style: { stroke: "#e3cddb", strokeWidth: 1.5 },
|
style: { stroke: "#e3cddb", strokeWidth: 1.5 },
|
||||||
}));
|
}));
|
||||||
return { rfNodes, rfEdges };
|
return { rfNodes, rfEdges };
|
||||||
}, [visibleNodes, visibleEdges, isExpanded, onToggle]);
|
}, [visibleNodes, visibleEdges, isExpanded, onToggle, onAddUnit, onDeleteUnit, onSelectUnit]);
|
||||||
|
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState(rfNodes);
|
const [nodes, setNodes, onNodesChange] = useNodesState(rfNodes);
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState(rfEdges);
|
const [edges, setEdges, onEdgesChange] = useEdgesState(rfEdges);
|
||||||
@@ -118,7 +137,7 @@ function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds }: GraphOrg
|
|||||||
fitView
|
fitView
|
||||||
fitViewOptions={{ padding: 0.2 }}
|
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} />
|
<Controls showInteractive={false} />
|
||||||
{/* Hidden under lg via CSS: on a phone it would cover a real
|
{/* Hidden under lg via CSS: on a phone it would cover a real
|
||||||
fraction of the canvas for little navigational benefit. */}
|
fraction of the canvas for little navigational benefit. */}
|
||||||
@@ -126,8 +145,8 @@ function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds }: GraphOrg
|
|||||||
pannable
|
pannable
|
||||||
zoomable
|
zoomable
|
||||||
ariaLabel="Übersichtskarte"
|
ariaLabel="Übersichtskarte"
|
||||||
maskColor="rgba(249, 241, 245, 0.75)"
|
maskColor="rgba(253, 246, 244, 0.75)"
|
||||||
nodeColor={(n) => MINIMAP_COLORS[(n.data as OrgChartNodeDataLike).chartNode.kind] ?? "#d6046e"}
|
nodeColor={(n) => MINIMAP_COLORS[(n.data as OrgChartNodeDataLike).chartNode.kind] ?? "#164194"}
|
||||||
nodeStrokeWidth={0}
|
nodeStrokeWidth={0}
|
||||||
nodeBorderRadius={3}
|
nodeBorderRadius={3}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,23 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { Printer } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||||
import type { OpenPositionResolved } from "@/lib/positions";
|
|
||||||
import { AsOfPicker } from "./AsOfPicker";
|
import { AsOfPicker } from "./AsOfPicker";
|
||||||
import { EmployeeTree } from "./EmployeeTree";
|
import { EmployeeTree } from "./EmployeeTree";
|
||||||
import { PositionTree } from "./PositionTree";
|
import { PositionTree } from "./PositionTree";
|
||||||
import { ReorgWorkbench } from "./ReorgWorkbench";
|
import type { OrgEmployee, OrgUnitNode, OrgVacancy } from "./types";
|
||||||
import type { OrgDepartment, OrgDivision, OrgEmployee, OrgTeam, ReorgScenarioSummary } from "./types";
|
|
||||||
|
|
||||||
type View = "ma" | "pos" | "reo";
|
type View = "ma" | "pos";
|
||||||
|
|
||||||
type OrgChartClientProps = {
|
type OrgChartClientProps = {
|
||||||
employees: OrgEmployee[];
|
employees: OrgEmployee[];
|
||||||
divisions: OrgDivision[];
|
units: OrgUnitNode[];
|
||||||
departments: OrgDepartment[];
|
vacancies: OrgVacancy[];
|
||||||
teams: OrgTeam[];
|
|
||||||
openPositions: OpenPositionResolved[];
|
|
||||||
reorgScenarios: ReorgScenarioSummary[];
|
|
||||||
asOf: string;
|
asOf: string;
|
||||||
today: string;
|
today: string;
|
||||||
projectedCount: number;
|
projectedCount: number;
|
||||||
@@ -28,11 +25,8 @@ type OrgChartClientProps = {
|
|||||||
|
|
||||||
export function OrgChartClient({
|
export function OrgChartClient({
|
||||||
employees,
|
employees,
|
||||||
divisions,
|
units,
|
||||||
departments,
|
vacancies,
|
||||||
teams,
|
|
||||||
openPositions,
|
|
||||||
reorgScenarios,
|
|
||||||
asOf,
|
asOf,
|
||||||
today,
|
today,
|
||||||
projectedCount,
|
projectedCount,
|
||||||
@@ -40,44 +34,33 @@ export function OrgChartClient({
|
|||||||
focusId,
|
focusId,
|
||||||
}: OrgChartClientProps) {
|
}: OrgChartClientProps) {
|
||||||
const [view, setView] = useState<View>("ma");
|
const [view, setView] = useState<View>("ma");
|
||||||
const isToday = asOf === today;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<SegmentedControl<View>
|
<SegmentedControl<View>
|
||||||
value={view}
|
value={view}
|
||||||
onChange={setView}
|
onChange={setView}
|
||||||
options={[
|
options={[
|
||||||
{ value: "ma", label: "Mitarbeiter" },
|
{ value: "ma", label: "Mitarbeiter" },
|
||||||
{ value: "pos", label: "Positionen" },
|
{ value: "pos", label: "Organisation" },
|
||||||
{ value: "reo", label: "Reorganisation" },
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
{/* 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 === "ma" && <EmployeeTree employees={employees} focusId={focusId} />}
|
||||||
{view === "pos" && (
|
{view === "pos" && <PositionTree employees={employees} units={units} vacancies={vacancies} />}
|
||||||
<PositionTree employees={employees} divisions={divisions} departments={departments} teams={teams} openPositions={openPositions} />
|
|
||||||
)}
|
|
||||||
{view === "reo" &&
|
|
||||||
(isToday ? (
|
|
||||||
<ReorgWorkbench employees={employees} divisions={divisions} departments={departments} teams={teams} reorgScenarios={reorgScenarios} />
|
|
||||||
) : (
|
|
||||||
// A reorg planned against a past or projected roster would be
|
|
||||||
// applied to the *live* org anyway — better to send the user back
|
|
||||||
// to today than to let them assemble moves from a roster that is
|
|
||||||
// not the one the change would hit.
|
|
||||||
<div className="rounded border border-border bg-white p-6 text-sm text-ink-body">
|
|
||||||
<p className="font-semibold text-ink">Reorganisation nur zum heutigen Stand</p>
|
|
||||||
<p className="mt-1 text-ink-muted">
|
|
||||||
Es ist ein abweichender Stichtag gewählt. Reorganisationen wirken immer auf die aktuelle Struktur — wechseln
|
|
||||||
Sie zurück auf „Heute“, um eine zu planen.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
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 Link from "next/link";
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { Avatar } from "@/components/ui/Avatar";
|
import { Avatar } from "@/components/ui/Avatar";
|
||||||
@@ -14,6 +14,11 @@ export type OrgChartNodeData = {
|
|||||||
hasChildren: boolean;
|
hasChildren: boolean;
|
||||||
childCount: number;
|
childCount: number;
|
||||||
onToggle: (id: string) => void;
|
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">;
|
export type OrgChartRFNode = Node<OrgChartNodeData, "orgNode">;
|
||||||
@@ -25,21 +30,25 @@ const KIND_ACCENT: Record<ChartNodeKind, string> = {
|
|||||||
person: "bg-brand-500",
|
person: "bg-brand-500",
|
||||||
role: "bg-purple-text",
|
role: "bg-purple-text",
|
||||||
group: "bg-ink-muted",
|
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> = {
|
const KIND_SHELL: Record<ChartNodeKind, string> = {
|
||||||
person: "border-border bg-white",
|
person: "border-border bg-white",
|
||||||
role: "border-border bg-white",
|
role: "border-border bg-white",
|
||||||
group: "border-border-subtle bg-surface",
|
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
|
// 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.
|
// required, not just tidy, to keep that smooth at a few hundred nodes.
|
||||||
export const OrgChartNode = memo(function OrgChartNode({ id, data }: NodeProps<OrgChartRFNode>) {
|
export const OrgChartNode = memo(function OrgChartNode({ id, data }: NodeProps<OrgChartRFNode>) {
|
||||||
const { chartNode, expanded, hasChildren, childCount, onToggle } = data;
|
const { chartNode, expanded, hasChildren, childCount, onToggle, onAddUnit, onDeleteUnit, onSelectUnit } = data;
|
||||||
const { kind, label, sublabel, avatar, href, vacant, totalReports, absent, coveredBy, coveringFor } = chartNode;
|
const { kind, label, sublabel, avatar, href, vacant, totalReports, absent, coveredBy, coveringFor, unitId } = chartNode;
|
||||||
const isMatch = chartNode.matched ?? false;
|
const isMatch = chartNode.matched ?? false;
|
||||||
const { width, height } = NODE_DIMENSIONS[kind];
|
const { width, height } = NODE_DIMENSIONS[kind];
|
||||||
|
|
||||||
@@ -48,7 +57,7 @@ export const OrgChartNode = memo(function OrgChartNode({ id, data }: NodeProps<O
|
|||||||
{kind === "person" && avatar ? (
|
{kind === "person" && avatar ? (
|
||||||
<Avatar firstName={avatar.firstName} lastName={avatar.lastName} size="sm" />
|
<Avatar firstName={avatar.firstName} lastName={avatar.lastName} size="sm" />
|
||||||
) : kind === "vacancy" ? (
|
) : 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" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
</span>
|
</span>
|
||||||
) : kind === "role" ? (
|
) : 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" />
|
<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. */}
|
{/* 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]}`} />
|
<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">
|
<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}
|
{content}
|
||||||
</Link>
|
</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>
|
<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