Compare commits
57 Commits
feat/sap-o
...
7b7562799d
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 4c5bfc223f |
@@ -15,6 +15,3 @@ npm-debug.log*
|
||||
*.tsbuildinfo
|
||||
.scratch_*
|
||||
.scratch_shots
|
||||
supabase/.branches
|
||||
supabase/.temp
|
||||
supabase/snippets
|
||||
|
||||
55
.env.example
55
.env.example
@@ -1,23 +1,42 @@
|
||||
# Direkter PostgreSQL-Zugang. Die Anwendung spricht künftig unmittelbar mit
|
||||
# der Datenbank statt über eine API-Schicht — damit läuft sie auf jedem
|
||||
# PostgreSQL ab 15 (Azure Flexible Server, RDS, Cloud SQL, eigenes Blech).
|
||||
# ── Datenbank ────────────────────────────────────────────────────────
|
||||
#
|
||||
# Die Datenbank läuft als eigener Container (Dienst `db` in
|
||||
# docker-compose.yml). Diese drei Werte richten ihn ein:
|
||||
#
|
||||
# POSTGRES_PASSWORD das des Verwalters (postgres) — nur für Migrationen,
|
||||
# Sicherungen und Wartung
|
||||
# APP_DB_PASSWORD das der Anwendungsrolle alpenwerk_app
|
||||
# POSTGRES_DB Name der Datenbank; Vorgabe alpenwerk
|
||||
#
|
||||
# Beide Passwörter selbst erzeugen: `openssl rand -base64 24`
|
||||
#
|
||||
# Sie werden nur beim **allerersten** Start ausgewertet, solange das
|
||||
# Datenverzeichnis leer ist. Ein späterer Wechsel braucht ein
|
||||
# `alter role … password …` in der laufenden Datenbank.
|
||||
POSTGRES_PASSWORD=
|
||||
APP_DB_PASSWORD=
|
||||
POSTGRES_DB=alpenwerk
|
||||
POSTGRES_USER=postgres
|
||||
|
||||
# Womit die Anwendung sich verbindet. Im Compose-Netz heisst die Datenbank
|
||||
# `db`; von aussen ist sie nicht erreichbar, es gibt bewusst keinen Port.
|
||||
#
|
||||
# Die Rolle in diesem String darf KEIN BYPASSRLS haben: fehlt der
|
||||
# Sitzungskontext, sollen die Policies nichts zurückgeben statt alles.
|
||||
#
|
||||
# Hinter einem Verbindungspooler (Supabase Supavisor, PgBouncer) den
|
||||
# TRANSAKTIONS-Modus nehmen, nicht den Sitzungs-Modus — bei Supabase Port
|
||||
# 6543 statt 5432. Jede Abfrage dieser Anwendung läuft ohnehin in einer
|
||||
# Transaktion, und der Sitzungskontext wird transaktionslokal gesetzt; beides
|
||||
# passt genau dazu. Der Sitzungs-Modus belegt dagegen je Client eine feste
|
||||
# Verbindung und ist bei Supabase auf 15 begrenzt — danach antwortet die
|
||||
# Anwendung nur noch mit „max clients reached".
|
||||
DATABASE_URL=
|
||||
# Auf "false" setzen, wenn die Datenbank ohne TLS läuft (lokal, CI).
|
||||
DATABASE_SSL=
|
||||
# `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=
|
||||
|
||||
# Der **direkte** Zugang für Migrationen (scripts/migrate.mjs). Als Verwalter,
|
||||
# weil Migrationen Schemaänderungen vornehmen, die die Anwendungsrolle nicht
|
||||
# darf. Bei Betrieb über docker compose wird er nicht gebraucht — der Dienst
|
||||
# `migrate` setzt ihn selbst aus POSTGRES_PASSWORD zusammen.
|
||||
MIGRATE_DATABASE_URL=
|
||||
|
||||
# ── Anmeldung (Auth.js + Microsoft Entra ID) ─────────────────────────
|
||||
# Schlüssel, mit dem das Sitzungscookie signiert und verschlüsselt wird.
|
||||
# Erzeugen mit `npx auth secret` oder `openssl rand -base64 32`. Ein Wechsel
|
||||
@@ -38,7 +57,9 @@ AUTH_MICROSOFT_ENTRA_ID_ISSUER=https://login.microsoftonline.com/<verzeichnis-id
|
||||
# Auth.js die Rückruf-Adresse aus den Request-Headern.
|
||||
AUTH_URL=
|
||||
|
||||
# Shared secret Vercel Cron sends as `Authorization: Bearer <value>` when it
|
||||
# calls /api/cron/apply-pending-changes (set the same value in the Vercel
|
||||
# project's env vars). Generate with e.g. `openssl rand -hex 32`.
|
||||
# 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=
|
||||
|
||||
181
.github/workflows/ci.yml
vendored
181
.github/workflows/ci.yml
vendored
@@ -29,25 +29,45 @@ jobs:
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
# Catches the drift that `tsc` cannot: lib/supabase/types.ts is
|
||||
# hand-written, so a migration adding a column leaves it silently stale.
|
||||
# Fängt die Abweichung, die `tsc` nicht sehen kann: lib/types.ts wird von
|
||||
# Hand gepflegt, eine Migration mit einer neuen Spalte lässt sie also
|
||||
# stillschweigend veralten.
|
||||
- name: Schema/Typen-Abgleich
|
||||
run: npm run types:check
|
||||
|
||||
- name: Unit- und Komponententests
|
||||
run: npm test
|
||||
|
||||
# Ohne Umgebungsvariablen: seit dem Wegfall der Browser-Anbindung wird
|
||||
# zur Bauzeit nichts mehr aus der Umgebung gelesen, und das Abbild ist
|
||||
# für jede Umgebung dasselbe.
|
||||
- name: Build
|
||||
run: npm run build
|
||||
env:
|
||||
# Read at module scope by the Supabase browser client, so the build
|
||||
# needs them present — never the real project's values.
|
||||
NEXT_PUBLIC_SUPABASE_URL: http://127.0.0.1:54321
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: build-time-placeholder
|
||||
|
||||
integration:
|
||||
name: Integrationstests (echtes Postgres)
|
||||
migrationen:
|
||||
name: Migrationen auf leerer Datenbank
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Derselbe Stand wie im Betrieb. Ein eigener Dienst statt einer fremden
|
||||
# Plattform — die Datenbank gehört seit dem Umzug zum Projekt.
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_PASSWORD: ci
|
||||
POSTGRES_DB: alpenwerk
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
|
||||
env:
|
||||
MIGRATE_DATABASE_URL: postgresql://postgres:ci@postgres:5432/alpenwerk
|
||||
DATABASE_SSL: "false"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -58,40 +78,117 @@ jobs:
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- uses: supabase/setup-cli@v1
|
||||
with:
|
||||
version: latest
|
||||
|
||||
# Applies every migration to a fresh database — which also means a
|
||||
# migration that cannot be replayed from scratch fails here rather than
|
||||
# on a restore or a new environment.
|
||||
- name: Supabase starten
|
||||
run: supabase start
|
||||
|
||||
# `-o env` emits API_URL / ANON_KEY / SERVICE_ROLE_KEY / DB_URL; the app
|
||||
# expects them under its own names. DATABASE_URL ist der direkte
|
||||
# Postgres-Zugang — den braucht die neue Zugriffsschicht (lib/db) und
|
||||
# vor allem der Nachweis zum Sitzungskontext.
|
||||
- name: Testumgebung schreiben
|
||||
# Das Abbild des Runners bringt keinen Postgres-Client mit; der
|
||||
# Schritt darunter ruft aber psql. Ohne das bricht er mit
|
||||
# "psql: command not found" ab, bevor die erste Datei laeuft.
|
||||
- name: psql bereitstellen
|
||||
run: |
|
||||
supabase status -o env \
|
||||
--override-name api.url=NEXT_PUBLIC_SUPABASE_URL \
|
||||
--override-name auth.anon_key=NEXT_PUBLIC_SUPABASE_ANON_KEY \
|
||||
--override-name auth.service_role_key=SUPABASE_SERVICE_ROLE_KEY \
|
||||
--override-name db.url=DATABASE_URL \
|
||||
| grep -E '^(NEXT_PUBLIC_SUPABASE_URL|NEXT_PUBLIC_SUPABASE_ANON_KEY|SUPABASE_SERVICE_ROLE_KEY|DATABASE_URL)=' \
|
||||
| tr -d '"' > .env.test.local
|
||||
# Lokal läuft Postgres ohne TLS; ohne das versucht `pg` es trotzdem.
|
||||
echo "DATABASE_SSL=false" >> .env.test.local
|
||||
grep -q '^DATABASE_URL=' .env.test.local \
|
||||
|| { echo "DATABASE_URL wurde nicht geschrieben — der Sitzungskontext-Nachweis liefe ins Leere."; exit 1; }
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq --no-install-recommends postgresql-client
|
||||
psql --version
|
||||
|
||||
- name: Seed
|
||||
run: node --env-file=.env.test.local supabase/seed.ts
|
||||
# Was das Schema von der Umgebung erwartet: die Anwendungsrolle ohne
|
||||
# BYPASSRLS, zwei Erweiterungen und die Attrappe des `auth`-Schemas, ohne
|
||||
# die sich die Migrationen von Juni 2026 nicht abspielen lassen.
|
||||
- name: Rollen, Erweiterungen, auth-Attrappe
|
||||
env:
|
||||
PGPASSWORD: ci
|
||||
APP_DB_PASSWORD: ci-anwendungsrolle
|
||||
run: |
|
||||
for f in deploy/db-init/*.sql; do
|
||||
echo "── $f"
|
||||
psql -v ON_ERROR_STOP=1 -h postgres -U postgres -d alpenwerk -f "$f"
|
||||
done
|
||||
|
||||
- name: Integrationstests
|
||||
run: npm run test:integration
|
||||
# Der eigentliche Zweck dieses Jobs: jede Migration muss sich auf einer
|
||||
# leeren Datenbank abspielen lassen. Eine, die das nicht kann, fällt hier
|
||||
# auf — und nicht beim Wiederherstellen einer Sicherung oder beim
|
||||
# Aufsetzen einer neuen Umgebung.
|
||||
- name: Migrationen einspielen
|
||||
run: node scripts/migrate.mjs
|
||||
|
||||
- name: Supabase-Logs bei Fehlschlag
|
||||
if: failure()
|
||||
run: supabase status && docker ps -a
|
||||
# Zweiter Lauf: die Buchführung muss greifen. Meldet er etwas anderes als
|
||||
# „nichts anzuwenden", verbucht der Läufer nicht richtig — und ein
|
||||
# Ausrollen spielte Migrationen doppelt ein.
|
||||
- name: Zweiter Lauf ist ein Nichts
|
||||
run: |
|
||||
ausgabe=$(node scripts/migrate.mjs)
|
||||
echo "$ausgabe"
|
||||
echo "$ausgabe" | grep -q "^Nichts anzuwenden" \
|
||||
|| { echo "Der zweite Lauf wollte erneut anwenden — die Buchführung greift nicht."; exit 1; }
|
||||
|
||||
# Dass die Migrationen durchlaufen, heisst nicht, dass das Ergebnis
|
||||
# stimmt. Bis hierher prüft dieser Job nur, dass keine Datei abbricht —
|
||||
# und genau daran ist ein Fehler vorbeigekommen: vier Tabellen aus dem
|
||||
# August (Kostenstellen, On-/Offboarding) standen ohne Zeilenschutz da,
|
||||
# weil sie sich auf den Ereignis-Trigger `ensure_rls` verliessen, den
|
||||
# eine spätere Migration erst anlegt. Alle Dateien liefen sauber durch,
|
||||
# die Policies standen im Katalog, und ausgewertet wurde keine davon.
|
||||
#
|
||||
# Deshalb wird ab hier der **Zustand** geprüft, nicht der Durchlauf.
|
||||
# Nachgezogen hat das 20260908100000; dort steht dieselbe Gegenprobe.
|
||||
# Sie läuft aber nur einmal, beim Anwenden jener Datei. Der Schritt hier
|
||||
# läuft bei jedem Push gegen eine frisch aufgebaute Datenbank und fängt
|
||||
# deshalb auch die *nächste* Tabelle, die es wieder vergisst.
|
||||
- name: Zugriffsschutz nach dem Lauf
|
||||
env:
|
||||
PGPASSWORD: ci
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
frage() {
|
||||
psql -v ON_ERROR_STOP=1 -h postgres -U postgres -d alpenwerk -At -c "$1"
|
||||
}
|
||||
|
||||
# relkind in ('r','p'): gewöhnliche und partitionierte Tabellen.
|
||||
ohne_rls=$(frage "
|
||||
select coalesce(string_agg(c.relname, ', ' order by c.relname), '')
|
||||
from pg_class c
|
||||
join pg_namespace n on n.oid = c.relnamespace
|
||||
where n.nspname = 'public'
|
||||
and c.relkind in ('r', 'p')
|
||||
and not c.relrowsecurity")
|
||||
|
||||
# RLS ohne Policy ist nicht offen, sondern leer — kein Leck, aber
|
||||
# eine Tabelle, aus der nie etwas zurückkommt.
|
||||
ohne_policy=$(frage "
|
||||
select coalesce(string_agg(c.relname, ', ' order by c.relname), '')
|
||||
from pg_class c
|
||||
join pg_namespace n on n.oid = c.relnamespace
|
||||
where n.nspname = 'public'
|
||||
and c.relkind in ('r', 'p')
|
||||
and not exists (select 1 from pg_policy p where p.polrelid = c.oid)")
|
||||
|
||||
# Das Netz selbst: fehlt oder schläft es, ist die nächste neue
|
||||
# Tabelle wieder ungeschützt.
|
||||
netz=$(frage "
|
||||
select count(*) from pg_event_trigger
|
||||
where evtname = 'ensure_rls' and evtenabled <> 'D'")
|
||||
|
||||
fehler=0
|
||||
if [ -n "$ohne_rls" ]; then
|
||||
echo "Ohne Row Level Security: $ohne_rls"
|
||||
fehler=1
|
||||
fi
|
||||
if [ -n "$ohne_policy" ]; then
|
||||
echo "Mit Row Level Security, aber ohne jede Policy: $ohne_policy"
|
||||
fehler=1
|
||||
fi
|
||||
if [ "$netz" != "1" ]; then
|
||||
echo "Der Ereignis-Trigger ensure_rls fehlt oder ist abgeschaltet."
|
||||
fehler=1
|
||||
fi
|
||||
|
||||
if [ "$fehler" -ne 0 ]; then
|
||||
echo
|
||||
echo "Jede Tabelle in public braucht RLS und mindestens eine Policy."
|
||||
echo "Nachziehen in einer neuen Migration, nicht in einer bereits angewendeten."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Zugriffsschutz vollstaendig: jede Tabelle mit RLS und Policy, ensure_rls aktiv."
|
||||
|
||||
# Die Integrationstests brauchen einen befüllten Bestand; der Seed lag
|
||||
# in der abgelösten Umgebung und ist noch nicht nachgezogen (siehe
|
||||
# README, offene Punkte). Bis dahin laufen sie gegen eine erreichbare
|
||||
# Datenbank von Hand, nicht hier.
|
||||
|
||||
191
.github/workflows/deploy.yml
vendored
Normal file
191
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,191 @@
|
||||
name: Deploy
|
||||
|
||||
# Warum diese Datei unter .github/workflows liegt und nicht unter .gitea/:
|
||||
#
|
||||
# Gitea Actions liest `.gitea/workflows`, **und nur wenn dieses Verzeichnis
|
||||
# fehlt**, ersatzweise `.github/workflows`. Ein neu angelegtes `.gitea/`
|
||||
# würde also ci.yml stillschweigend abschalten — der Lauf verschwände
|
||||
# einfach, ohne Fehlermeldung. Solange beide Dateien hier liegen, sieht Gitea
|
||||
# beide. (Auf GitHub bliebe dieser Workflow hängen: es gibt dort keinen
|
||||
# Runner mit dem Label `self-hosted`. Das Repository liegt aber ohnehin auf
|
||||
# git.elycon.solutions.)
|
||||
|
||||
# Nur von Hand auslösbar (Actions → Deploy → „Run workflow").
|
||||
#
|
||||
# Lief einmal bei jedem Push auf master. Das ist zurückgenommen: die
|
||||
# Voraussetzungen dafür sind nicht erfüllt — es sind keine Secrets hinterlegt,
|
||||
# und ob der Job-Container den Docker-Dienst des Hosts überhaupt erreicht, ist
|
||||
# ungeprüft. Bei jedem Push zu scheitern erzieht dazu, rote Läufe zu
|
||||
# übersehen; dann lieber gar nicht starten.
|
||||
#
|
||||
# Nach dem Umzug auf den eigenen db-Container stimmt hier ausserdem noch
|
||||
# nicht alles: die Secrets unten kennen die neuen Werte (POSTGRES_PASSWORD,
|
||||
# APP_DB_PASSWORD) nicht, und Migrationen laufen jetzt über den Dienst
|
||||
# `migrate` statt über einen eigenen Node-Schritt. Wer das wieder scharf
|
||||
# schaltet, muss beides nachziehen.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
# Zwei Pushes kurz hintereinander sollen nicht zwei Deploys übereinander
|
||||
# fahren. Der laufende wird **nicht** abgebrochen — mitten im `docker compose
|
||||
# up` abgeschnitten zu werden ist der eine Zustand, den man nicht will.
|
||||
concurrency:
|
||||
group: deploy-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Migrationen und Container
|
||||
# `ubuntu-latest`, weil der vorhandene Runner (elycon-runner-01) genau
|
||||
# diese Bezeichnung anbietet — neben `docker`. Ein Label, das kein Runner
|
||||
# führt, lässt den Lauf wortlos in der Warteschlange stehen: kein Fehler,
|
||||
# keine Meldung, nur „Waiting".
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# ── Vorprüfung ──────────────────────────────────────────────────
|
||||
#
|
||||
# Dieser Job läuft in einem Container, den der Runner startet — nicht
|
||||
# auf dem Server selbst. Ob er den Docker-Dienst des **Hosts** erreicht,
|
||||
# hängt davon ab, wie der Runner konfiguriert ist; von aussen ist das
|
||||
# nicht zu sehen. Statt es zu raten, wird es hier gemessen und im
|
||||
# Fehlerfall gesagt, was fehlt — sonst scheitert der Lauf erst drei
|
||||
# Schritte später an einer Meldung, die niemandem weiterhilft.
|
||||
- name: Erreicht dieser Job den Docker-Dienst des Servers?
|
||||
run: |
|
||||
set -u
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo 'Im Job-Container gibt es kein "docker".'
|
||||
echo
|
||||
echo "Zwei Wege:"
|
||||
echo " a) Im Runner (config.yaml) ein Abbild verwenden, das die Docker-CLI"
|
||||
echo " mitbringt, und den Socket durchreichen — siehe DEPLOYMENT.md §5."
|
||||
echo " b) Statt lokal per SSH auf den Server ausrollen."
|
||||
exit 1
|
||||
fi
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
echo "Docker-CLI vorhanden, aber kein Dienst erreichbar."
|
||||
echo
|
||||
echo "Dem Job-Container fehlt der Socket des Hosts. In der config.yaml des"
|
||||
echo "Runners:"
|
||||
echo " container:"
|
||||
echo " options: -v /var/run/docker.sock:/var/run/docker.sock"
|
||||
echo " valid_volumes: [/var/run/docker.sock]"
|
||||
echo "Danach den Runner neu starten. Alternative: Deploy per SSH."
|
||||
exit 1
|
||||
fi
|
||||
docker compose version
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
# Nur was der Migrationsläufer braucht (`pg`), nicht der ganze Baum:
|
||||
# gebaut wird die Anwendung im Container, nicht hier.
|
||||
- name: Abhängigkeiten
|
||||
run: npm ci --omit=dev --ignore-scripts
|
||||
|
||||
# ── .env aus den Gitea-Secrets ──────────────────────────────────
|
||||
#
|
||||
# Nicht die .env vom Server lesen: der Job läuft in einem eigenen
|
||||
# Container, und dessen Dateisystem ist nicht das des Hosts. Die Werte
|
||||
# kommen deshalb aus den Repository-Secrets und werden hier
|
||||
# zusammengesetzt. Sie landen in keinem Abbild — docker compose reicht
|
||||
# sie zur Laufzeit an den Container weiter.
|
||||
- name: .env schreiben
|
||||
env:
|
||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||
MIGRATE_DATABASE_URL: ${{ secrets.MIGRATE_DATABASE_URL }}
|
||||
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
|
||||
AUTH_URL: ${{ secrets.AUTH_URL }}
|
||||
AUTH_MICROSOFT_ENTRA_ID_ID: ${{ secrets.AUTH_MICROSOFT_ENTRA_ID_ID }}
|
||||
AUTH_MICROSOFT_ENTRA_ID_SECRET: ${{ secrets.AUTH_MICROSOFT_ENTRA_ID_SECRET }}
|
||||
AUTH_MICROSOFT_ENTRA_ID_ISSUER: ${{ secrets.AUTH_MICROSOFT_ENTRA_ID_ISSUER }}
|
||||
CRON_SECRET: ${{ secrets.CRON_SECRET }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
fehlend=""
|
||||
for name in DATABASE_URL MIGRATE_DATABASE_URL AUTH_SECRET AUTH_URL \
|
||||
AUTH_MICROSOFT_ENTRA_ID_ID AUTH_MICROSOFT_ENTRA_ID_SECRET \
|
||||
AUTH_MICROSOFT_ENTRA_ID_ISSUER CRON_SECRET; do
|
||||
eval "wert=\${$name:-}"
|
||||
[ -n "$wert" ] || fehlend="$fehlend $name"
|
||||
done
|
||||
if [ -n "$fehlend" ]; then
|
||||
echo "Diese Secrets fehlen im Repository (Settings → Actions → Secrets):$fehlend"
|
||||
exit 1
|
||||
fi
|
||||
# printf statt echo: ein Wert, der mit - beginnt, wäre sonst ein Schalter.
|
||||
{
|
||||
printf 'DATABASE_URL=%s\n' "$DATABASE_URL"
|
||||
printf 'AUTH_SECRET=%s\n' "$AUTH_SECRET"
|
||||
printf 'AUTH_URL=%s\n' "$AUTH_URL"
|
||||
printf 'AUTH_MICROSOFT_ENTRA_ID_ID=%s\n' "$AUTH_MICROSOFT_ENTRA_ID_ID"
|
||||
printf 'AUTH_MICROSOFT_ENTRA_ID_SECRET=%s\n' "$AUTH_MICROSOFT_ENTRA_ID_SECRET"
|
||||
printf 'AUTH_MICROSOFT_ENTRA_ID_ISSUER=%s\n' "$AUTH_MICROSOFT_ENTRA_ID_ISSUER"
|
||||
printf 'CRON_SECRET=%s\n' "$CRON_SECRET"
|
||||
} > .env
|
||||
chmod 600 .env
|
||||
|
||||
# ── Migrationen ─────────────────────────────────────────────────
|
||||
#
|
||||
# Vor dem Neustart, nicht danach: der neue Code erwartet das neue
|
||||
# Schema. Umgekehrt liefe die neue Anwendung kurz gegen das alte und
|
||||
# fiele über fehlende Spalten.
|
||||
#
|
||||
# Erst zeigen, was ansteht — das steht dann im Protokoll des Laufs, auch
|
||||
# wenn danach etwas schiefgeht.
|
||||
- name: Ausstehende Migrationen zeigen
|
||||
env:
|
||||
MIGRATE_DATABASE_URL: ${{ secrets.MIGRATE_DATABASE_URL }}
|
||||
run: node scripts/migrate.mjs --dry-run
|
||||
|
||||
- name: Migrationen anwenden
|
||||
env:
|
||||
MIGRATE_DATABASE_URL: ${{ secrets.MIGRATE_DATABASE_URL }}
|
||||
run: node scripts/migrate.mjs
|
||||
|
||||
# ── Container ───────────────────────────────────────────────────
|
||||
#
|
||||
# Läuft gegen den Docker-Dienst des Hosts (der Runner-Container hat
|
||||
# dessen Socket eingehängt). Der Projektname wird ausdrücklich gesetzt:
|
||||
# sonst leitet ihn Compose vom Verzeichnisnamen ab, und der ist im
|
||||
# Arbeitsverzeichnis des Runners ein anderer als bei der ersten
|
||||
# Installation von Hand — es entstünde ein zweiter Stapel daneben,
|
||||
# während der alte weiterläuft.
|
||||
- name: Bauen und starten
|
||||
env:
|
||||
COMPOSE_PROJECT_NAME: ${{ vars.COMPOSE_PROJECT_NAME || 'alpenwerk-hr' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker compose build
|
||||
docker compose up -d --remove-orphans
|
||||
|
||||
# ── Nachweis ────────────────────────────────────────────────────
|
||||
#
|
||||
# Ohne diesen Schritt gilt ein Deploy als erfolgreich, sobald der
|
||||
# Container *gestartet* ist — auch wenn die Anwendung darin sofort
|
||||
# abstürzt. Gewartet wird auf den Healthcheck aus dem Dockerfile
|
||||
# (GET /login), nicht auf „läuft".
|
||||
- name: Warten, bis die Anwendung antwortet
|
||||
env:
|
||||
COMPOSE_PROJECT_NAME: ${{ vars.COMPOSE_PROJECT_NAME || 'alpenwerk-hr' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for versuch in $(seq 1 30); do
|
||||
zustand=$(docker compose ps --format '{{.Health}}' app | head -1)
|
||||
case "$zustand" in
|
||||
healthy) echo "Gesund nach $versuch Versuchen."; exit 0 ;;
|
||||
unhealthy) echo "Container meldet unhealthy."; break ;;
|
||||
esac
|
||||
sleep 4
|
||||
done
|
||||
echo "Anwendung ist nicht gesund geworden. Letzte Ausgaben:"
|
||||
docker compose ps
|
||||
docker compose logs --tail 80 app
|
||||
exit 1
|
||||
|
||||
- name: Aufräumen
|
||||
if: always()
|
||||
run: rm -f .env
|
||||
21
.gitignore
vendored
21
.gitignore
vendored
@@ -34,26 +34,21 @@ yarn-error.log*
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# supabase CLI local dev (generated, project-machine-specific)
|
||||
/supabase/.branches
|
||||
/supabase/.temp
|
||||
/supabase/snippets
|
||||
|
||||
# scratch output of `npm run types:generate`, for comparing against the
|
||||
# hand-written lib/supabase/types.ts — never itself imported
|
||||
/lib/supabase/types.generated.ts
|
||||
|
||||
# local scratch scripts/screenshots (ad-hoc verification against a real or
|
||||
# seeded DB — can carry HR data or use SUPABASE_SERVICE_ROLE_KEY; never commit)
|
||||
# Kladden und Bildschirmfotos aus der Handprüfung — können Personendaten
|
||||
# oder Zugangsdaten enthalten; nie committen.
|
||||
.scratch_*
|
||||
/.scratch_shots/
|
||||
|
||||
# Datenbank-Sicherungen (enthalten Personendaten) – nie committen.
|
||||
.backups/
|
||||
|
||||
# Datenabzuege aus dem Umzug – enthalten Personendaten
|
||||
|
||||
# Datenbank-Abzuege – enthalten Personendaten, nie committen.
|
||||
*.sql.gz
|
||||
/*.sql
|
||||
|
||||
296
DEPLOYMENT.md
296
DEPLOYMENT.md
@@ -1,89 +1,34 @@
|
||||
# Deployment
|
||||
|
||||
Zwei Wege, beide unterstützt. Das Abbild ist umgebungsneutral — es gibt keine
|
||||
Werte mehr, die beim Bauen eingebacken werden —, ein Wechsel ist also
|
||||
jederzeit möglich.
|
||||
Die Anwendung läuft als Docker-Container auf einem eigenen Linux-Server,
|
||||
zusammen mit ihrer Datenbank. Das Abbild ist umgebungsneutral — es gibt
|
||||
keine Werte, die beim Bauen eingebacken werden; alles kommt zur Laufzeit
|
||||
aus `.env`.
|
||||
|
||||
| | passt, wenn |
|
||||
|---|---|
|
||||
| [Vercel](#vercel) | ihr nichts betreiben wollt; schnellster Weg |
|
||||
| [Docker](#deployment-mit-docker) | es in eure eigene Infrastruktur soll |
|
||||
|
||||
**In beiden Fällen gleich:** die Umgebungsvariablen aus [Abschnitt 1](#1-env-anlegen),
|
||||
die Umleitungs-URI in der Entra-Registrierung, und dass eine neue Person nach
|
||||
ihrer ersten Anmeldung eine `profiles`-Zeile braucht (siehe
|
||||
[docs/entra-sso.md](docs/entra-sso.md)).
|
||||
|
||||
## Vercel
|
||||
|
||||
Das Repository liegt auf `git.elycon.solutions` — einem selbst betriebenen
|
||||
Git. **Vercels Git-Anbindung kann nur GitHub, GitLab und Bitbucket**, dieses
|
||||
Repository lässt sich dort also nicht verknüpfen. Zwei Möglichkeiten:
|
||||
|
||||
### a) Von der Arbeitsstation ausrollen (ohne GitHub)
|
||||
|
||||
```bash
|
||||
npx vercel login
|
||||
npx vercel link
|
||||
npx vercel --prod
|
||||
```
|
||||
|
||||
Funktioniert mit jedem Repository. Der Preis: kein automatisches Ausrollen
|
||||
bei einem Push — jede Veröffentlichung ist ein bewusster Befehl. Für zwei
|
||||
Personen ist das eher Vorteil als Nachteil.
|
||||
|
||||
### b) Zusätzlich nach GitHub spiegeln
|
||||
|
||||
```bash
|
||||
git remote add github git@github.com:<konto>/alpenwerk-hr.git
|
||||
git push github feat/sap-om-org-model
|
||||
```
|
||||
|
||||
Danach das GitHub-Repository in Vercel verbinden. Ab dann rollt jeder Push
|
||||
aus. Zwei Fernziele bedeuten aber auch: beide müssen gepflegt werden.
|
||||
|
||||
### Danach
|
||||
|
||||
1. **Umgebungsvariablen** im Vercel-Projekt setzen (Settings → Environment
|
||||
Variables), dieselben wie in [Abschnitt 1](#1-env-anlegen). `AUTH_URL` ist
|
||||
nicht nötig, Vercel setzt den Host selbst.
|
||||
2. **Umleitungs-URI** in der Entra-Registrierung ergänzen:
|
||||
`https://<projekt>.vercel.app/api/auth/callback/microsoft-entra-id`
|
||||
3. Der nächtliche Lauf ist über `vercel.json` bereits eingerichtet.
|
||||
|
||||
Zwei Eigenheiten der Plattform, die im Code berücksichtigt sind:
|
||||
`output: "standalone"` entfällt dort automatisch (Vercel baut selbst), und
|
||||
`/api/import` ist auf 60 Sekunden begrenzt — die Obergrenze des kostenlosen
|
||||
Tarifs. Im Pro-Tarif liessen sich 300 setzen, falls eine Importdatei mit
|
||||
vielen tausend Zeilen ansteht.
|
||||
|
||||
Der Verbindungspool passt zu serverlosen Aufrufen, **weil** `DATABASE_URL`
|
||||
auf den Transaktions-Modus zeigt (Port 6543). Mit dem Sitzungs-Modus wären
|
||||
die 15 Verbindungen des Tarifs nach wenigen gleichzeitigen Aufrufen
|
||||
verbraucht.
|
||||
|
||||
## Deployment mit Docker
|
||||
|
||||
Dieser Guide beschreibt, wie die App stattdessen als Docker-Container auf
|
||||
einem eigenen Linux-Server läuft.
|
||||
Zu klären, bevor es losgeht: die Umgebungsvariablen aus
|
||||
[Abschnitt 1](#1-env-anlegen), die Umleitungs-URI in der
|
||||
Entra-Registrierung, und dass eine neue Person nach ihrer ersten Anmeldung
|
||||
eine `profiles`-Zeile braucht (siehe [docs/entra-sso.md](docs/entra-sso.md)).
|
||||
|
||||
## Was wird containerisiert – und was nicht
|
||||
|
||||
- **Containerisiert:** nur die Next.js-App selbst (`Dockerfile`).
|
||||
- **Nicht containerisiert:** die Datenbank. Die App verbindet sich über
|
||||
`DATABASE_URL` zu einem beliebigen PostgreSQL ab 15 — heute ein
|
||||
Supabase-Projekt, genauso möglich sind Azure Flexible Server, RDS,
|
||||
Cloud SQL oder eigenes Blech. `supabase/` in diesem Repo ist die
|
||||
Migrations- und Entwicklungsumgebung, kein Teil des Deployments.
|
||||
- **Containerisiert:** die Next.js-App (`Dockerfile`) **und die Datenbank**
|
||||
(Dienst `db`, `postgres:17-alpine`). Beide zusammen in
|
||||
`docker-compose.yml`; die Daten liegen im benannten Volume `db-daten`.
|
||||
- **Die Datenbank gehört zum Projekt.** Was eine gehostete Plattform sonst
|
||||
beisteuert, bringt `deploy/db-init/` mit: die Anwendungsrolle ohne
|
||||
BYPASSRLS, die zwei benutzten Erweiterungen und eine Attrappe des
|
||||
`auth`-Schemas, die nur die Migrationen von Juni 2026 brauchen.
|
||||
- Läuft die Datenbank woanders (Azure Flexible Server, RDS, eigenes Blech),
|
||||
genügt es, `DATABASE_URL` dorthin zeigen zu lassen und den `db`-Dienst nicht
|
||||
zu starten. Die Anwendung merkt keinen Unterschied.
|
||||
- **Ebenfalls nicht containerisiert:** die Anmeldung. Sie läuft über
|
||||
Microsoft Entra ID; die App hält nur das Sitzungscookie (Auth.js). Es gibt
|
||||
keinen Anmeldedienst, der mit ausgerollt werden müsste.
|
||||
- **Ersetzt:** der Vercel-Cron-Job aus `vercel.json` (täglich 03:00 Uhr,
|
||||
ruft `/api/cron/apply-pending-changes` auf, um fällige Versetzungen/
|
||||
Beförderungen/Karenz/Reorg-Änderungen zu übernehmen). Da es außerhalb von
|
||||
Vercel kein Vercel-Cron gibt, übernimmt das im `docker-compose.yml`
|
||||
enthaltene `cron`-Sidecar-Container diese Aufgabe mit demselben Schema
|
||||
und demselben Bearer-Secret, das die Route bereits erwartet.
|
||||
- **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
|
||||
|
||||
@@ -97,12 +42,13 @@ nichts, ausgehend zwingend:
|
||||
| Ziel | Wofür | Ohne das |
|
||||
|---|---|---|
|
||||
| `login.microsoftonline.com` (443) | Auth.js tauscht den Anmeldecode **serverseitig** gegen ein Token und lädt die Konfiguration des Ausstellers | keine Anmeldung möglich |
|
||||
| Die Datenbank (Supabase: `*.pooler.supabase.com`, 6543) | jede Abfrage | die App startet, zeigt aber nichts |
|
||||
|
||||
Dass die Anmeldung im Browser der Person stattfindet, genügt **nicht** — der
|
||||
Tausch von Code gegen Token läuft vom Server aus. Liegt die VM in einem
|
||||
abgeschotteten Netz, ist entweder ein Proxy nötig oder eine PostgreSQL-
|
||||
Instanz im selben Netz statt Supabase.
|
||||
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
|
||||
@@ -120,11 +66,9 @@ bei zwanzig nicht.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- Docker + Docker Compose (v2, das im Docker Desktop/Docker Engine
|
||||
enthaltene `docker compose`) auf dem Zielserver.
|
||||
- Ein bestehendes Supabase-Projekt mit den Migrationen aus
|
||||
`supabase/migrations/` bereits eingespielt (`supabase db push` bzw. wie
|
||||
bisher).
|
||||
- Docker + Docker Compose (v2, das in Docker Engine enthaltene
|
||||
`docker compose`) auf dem Zielserver. Sonst nichts — weder Node noch psql;
|
||||
was gebraucht wird, kommt aus Containern.
|
||||
|
||||
## 1. `.env` anlegen
|
||||
|
||||
@@ -136,8 +80,10 @@ Werte eintragen:
|
||||
|
||||
| Variable | Woher |
|
||||
|---|---|
|
||||
| `DATABASE_URL` | Verbindungsstring der PostgreSQL-Instanz. Die Rolle darf **kein** `BYPASSRLS` haben; hinter einem Pooler den **Transaktions-Modus** (bei Supabase Port 6543) |
|
||||
| `DATABASE_SSL` | nur setzen (`false`), wenn die Datenbank ohne TLS läuft |
|
||||
| `POSTGRES_PASSWORD` | selbst erzeugen: `openssl rand -base64 24` — das des Verwalters |
|
||||
| `APP_DB_PASSWORD` | selbst erzeugen — das der Anwendungsrolle `alpenwerk_app` |
|
||||
| `DATABASE_URL` | `postgresql://alpenwerk_app:<APP_DB_PASSWORD>@db:5432/alpenwerk`. Die Rolle darf **kein** `BYPASSRLS` haben — `deploy/db-init` legt sie genau so an |
|
||||
| `DATABASE_SSL` | `false` im Compose-Netz; die Verbindung verlässt den Server nicht |
|
||||
| `AUTH_SECRET` | selbst generieren: `openssl rand -base64 32` |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_ID` | Entra-Portal → App-Registrierung → Übersicht |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | Entra-Portal → Zertifikate & Geheimnisse (nur einmal sichtbar!) |
|
||||
@@ -190,6 +136,29 @@ Build-Argumente braucht es dabei **keine**: das Abbild enthält keine
|
||||
umgebungsabhängigen Werte mehr, alles kommt zur Laufzeit aus `.env`.
|
||||
Dasselbe Abbild läuft damit in Test und Produktion.
|
||||
|
||||
### Bei einer leeren Datenbank
|
||||
|
||||
Der `db`-Container legt beim ersten Start Rollen und Erweiterungen an; das
|
||||
Schema kommt danach aus den Migrationen:
|
||||
|
||||
```bash
|
||||
docker compose up -d db
|
||||
docker compose run --rm migrate # legt Tabellen, Funktionen, Policies an
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
`migrate` steht im Profil `tools` und läuft bei `docker compose up` nicht mit.
|
||||
Auf dem Host wird dafür weder Node noch psql gebraucht — nur Docker.
|
||||
|
||||
### Was am `auth`-Schema übrigbleibt
|
||||
|
||||
`deploy/db-init/01-auth-attrappe.sql` legt ein leeres `auth`-Schema an. Es wird
|
||||
im Betrieb **nicht** gebraucht: kein Fremdschlüssel, keine Policy, keine
|
||||
Spaltenvorgabe verweist darauf (geprüft). Gebraucht wird es nur beim Abspielen
|
||||
der Migrationen von Juni 2026, die damals noch an `auth.users` hingen. Die
|
||||
Alternative wäre, jene Dateien umzuschreiben — also Geschichte zu fälschen: sie
|
||||
beschreiben, was damals galt.
|
||||
|
||||
## 4. Reverse Proxy + HTTPS
|
||||
|
||||
Next.js selbst sollte laut den offiziellen Docs **nicht** direkt exponiert
|
||||
@@ -219,10 +188,133 @@ Ohne diesen Wert baut Auth.js seine Rückruf-Adresse aus dem, was der
|
||||
Container sieht — und das ist hinter dem Proxy nicht der Name, den der
|
||||
Browser benutzt hat.
|
||||
|
||||
## 5. Updates ausrollen
|
||||
## 5. Automatisch ausrollen bei einem Push (Gitea Actions)
|
||||
|
||||
Ein Push auf `master` baut und startet die Anwendung auf dem Server neu.
|
||||
Zuständig ist [`.github/workflows/deploy.yml`](.github/workflows/deploy.yml).
|
||||
|
||||
> **Warum `.github/` und nicht `.gitea/`:** Gitea Actions liest
|
||||
> `.gitea/workflows` — und nur wenn dieses Verzeichnis **fehlt**, ersatzweise
|
||||
> `.github/workflows`. Ein neu angelegtes `.gitea/workflows/` würde also
|
||||
> `ci.yml` stillschweigend abschalten. Solange beide Dateien unter `.github/`
|
||||
> liegen, sieht Gitea beide.
|
||||
|
||||
### a) Der Runner
|
||||
|
||||
Verwendet wird der bestehende, globale Runner **`elycon-runner-01`** (act_runner
|
||||
v0.2.11, Labels `docker` und `ubuntu-latest`). Ein eigener ist nicht nötig.
|
||||
|
||||
Beide Workflows laufen deshalb auf `ubuntu-latest`. **Ein Label, das kein
|
||||
Runner führt, lässt den Lauf wortlos in der Warteschlange stehen** — kein
|
||||
Fehler, keine Meldung, nur „Waiting". Das ist der häufigste Grund, warum
|
||||
scheinbar nichts passiert.
|
||||
|
||||
#### Der Job läuft im Container, nicht auf dem Server
|
||||
|
||||
Das ist die Stelle, an der es klemmen kann. act_runner startet je Job einen
|
||||
eigenen Container; der Docker-Socket des **Hosts** ist darin nur, wenn der
|
||||
Runner so konfiguriert ist:
|
||||
|
||||
```yaml
|
||||
# config.yaml des Runners
|
||||
container:
|
||||
options: -v /var/run/docker.sock:/var/run/docker.sock
|
||||
valid_volumes:
|
||||
- /var/run/docker.sock
|
||||
```
|
||||
|
||||
Zusätzlich muss das Job-Abbild die Docker-CLI mitbringen (die üblichen
|
||||
`runner-images`/`catthehacker`-Abbilder tun das).
|
||||
|
||||
Der erste Schritt des Deploy-Workflows prüft genau das und bricht mit einer
|
||||
Anleitung ab, wenn etwas fehlt — statt drei Schritte später an einer Meldung zu
|
||||
scheitern, mit der niemand etwas anfangen kann.
|
||||
|
||||
> Der durchgereichte Socket gibt dem Job faktisch Wurzelrechte auf dem Host.
|
||||
> Bei einem Deploy-Runner ist das der Zweck der Übung, aber es heisst auch: wer
|
||||
> in dieses Repository schreiben darf, darf auf diesem Server alles. Bei einem
|
||||
> **globalen** Runner gilt das für jedes Repository der Instanz, das denselben
|
||||
> Socket benutzt. Ist das zu weit gefasst, ist der Weg über SSH der richtige:
|
||||
> ein Schlüssel als Repository-Secret, der nur auf den einen Server und nur auf
|
||||
> das Deploy-Verzeichnis zeigt.
|
||||
|
||||
### b) Secrets hinterlegen
|
||||
|
||||
Gitea → Repository → *Settings* → *Actions* → *Secrets*. Dieselben Werte wie in
|
||||
[Abschnitt 1](#1-env-anlegen); der Workflow baut daraus zur Laufzeit die `.env`
|
||||
und löscht sie danach wieder.
|
||||
|
||||
| Secret | Anmerkung |
|
||||
|---|---|
|
||||
| `DATABASE_URL` | Verbindung der Anwendungsrolle — die benutzt die Anwendung |
|
||||
| `MIGRATE_DATABASE_URL` | Zugang als Verwalter — nur für die Migrationen |
|
||||
| `AUTH_SECRET` | |
|
||||
| `AUTH_URL` | z. B. `https://hr.elycon.solutions` |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_ID` | |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_ISSUER` | |
|
||||
| `CRON_SECRET` | |
|
||||
|
||||
Warum zwei Verbindungsstrings: der Transaktions-Pooler bricht bei einer
|
||||
Migration ab, die mehrere Anweisungen in einer Transaktion bündelt. Migrationen
|
||||
gehören über die direkte Verbindung, der laufende Betrieb über den Pooler.
|
||||
|
||||
Zusätzlich unter *Variables* (kein Secret, nur eine Einstellung):
|
||||
|
||||
| Variable | Vorgabe | Wofür |
|
||||
|---|---|---|
|
||||
| `COMPOSE_PROJECT_NAME` | `alpenwerk-hr` | Muss zum bestehenden Stapel passen — sonst entsteht ein zweiter daneben |
|
||||
|
||||
Den bestehenden Namen zeigt auf dem Server:
|
||||
|
||||
```bash
|
||||
docker compose ls
|
||||
```
|
||||
|
||||
### c) Was der Lauf tut
|
||||
|
||||
1. `.env` aus den Secrets schreiben (fehlt eines, bricht er mit Namen ab).
|
||||
2. **Migrationen anwenden** — erst `--dry-run` fürs Protokoll, dann echt.
|
||||
3. `docker compose build` und `up -d` gegen den Docker-Dienst des Hosts.
|
||||
4. Auf `healthy` warten (bis zu zwei Minuten). Wird die Anwendung nicht gesund,
|
||||
schlägt der Lauf fehl und hängt die letzten 80 Logzeilen an.
|
||||
5. `.env` wieder löschen.
|
||||
|
||||
Der Workflow ist **nicht** an `ci.yml` gekoppelt: ein Push auf `master` rollt
|
||||
aus, auch wenn die Tests parallel noch laufen. Soll erst nach grünen Tests
|
||||
ausgerollt werden, gehört der `deploy`-Job mit `needs: [check]` in `ci.yml` —
|
||||
dann allerdings braucht der Runner auch das Label, mit dem `check` läuft.
|
||||
|
||||
## 5a. Migrationen
|
||||
|
||||
Eingespielt werden sie von [`scripts/migrate.mjs`](scripts/migrate.mjs) —
|
||||
demselben Läufer, den auch der Deploy benutzt:
|
||||
|
||||
```bash
|
||||
node --env-file=.env scripts/migrate.mjs --dry-run # was stünde an
|
||||
node --env-file=.env scripts/migrate.mjs # anwenden
|
||||
```
|
||||
|
||||
Buch geführt wird in `migrationen.schema_migrations`: eine Zeile je
|
||||
angewendeter Datei, mit ihrem Text. Der Läufer wendet nur an, was dort
|
||||
fehlt.
|
||||
|
||||
> **Einmalig, im August 2026 bereits erledigt:** Die Migrationen wurden bis
|
||||
> dahin von Hand eingespielt, die Buchführungstabelle existierte gar nicht. Ein
|
||||
> automatischer Lauf hätte deshalb alle 65 Dateien erneut gegen die
|
||||
> produktive Datenbank gespielt — inklusive `initial_schema` und der
|
||||
> OM-Umstellung. Vor dem Einschalten wurde einmal
|
||||
> `node scripts/migrate.mjs --baseline` gefahren: das verbucht alles Vorhandene
|
||||
> als angewendet, **ohne es auszuführen**. Wer eine weitere Umgebung aufsetzt,
|
||||
> deren Datenbank schon steht, braucht denselben Schritt.
|
||||
|
||||
## 5b. Updates von Hand ausrollen
|
||||
|
||||
Falls der Runner nicht läuft oder ein Stand ausser der Reihe gebraucht wird:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
node --env-file=.env scripts/migrate.mjs
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
@@ -231,13 +323,8 @@ Kurzer Downtime-Moment beim Neustart des `app`-Containers ist bei dieser
|
||||
Single-Instance-Compose-Konfiguration normal. Für Zero-Downtime-Deployments
|
||||
wäre eine zweite Instanz + Load Balancer nötig (siehe Abschnitt 6).
|
||||
|
||||
Datenbank-Migrationen (`supabase/migrations/*.sql`) werden weiterhin über
|
||||
die Supabase CLI gegen das Supabase-Projekt gefahren, unabhängig vom
|
||||
App-Deployment:
|
||||
|
||||
```bash
|
||||
supabase db push
|
||||
```
|
||||
Der Migrationsschritt steht bewusst **vor** dem Neubau: der neue Code erwartet
|
||||
das neue Schema, und umgekehrt liefe die neue Anwendung kurz gegen das alte.
|
||||
|
||||
## 6. Hinweis bei mehreren Replicas
|
||||
|
||||
@@ -257,6 +344,22 @@ Single-Instance-Compose-Konfiguration ist das nicht nötig.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Push gemacht, aber kein Lauf startet:** meist die Labels. Der Lauf steht
|
||||
dann in Gitea unter *Actions* als „Waiting" — ohne Fehlermeldung, weil kein
|
||||
Runner das verlangte Label anbietet. Beide Workflows verlangen
|
||||
`ubuntu-latest`; `elycon-runner-01` führt es. Welche Labels registriert sind,
|
||||
zeigt Gitea → *Site Administration* → *Actions* → *Runners*.
|
||||
- **Deploy läuft, aber es entsteht ein zweiter Stapel:** `COMPOSE_PROJECT_NAME`
|
||||
passt nicht zum bestehenden. Auf dem Server `docker compose ls` — der dort
|
||||
gelistete Name gehört als Variable ins Repository. Bis dahin läuft die alte
|
||||
Instanz weiter und beansprucht Port 3000; die neue scheitert daran.
|
||||
- **`Cannot connect to the Docker daemon` im Deploy:** dem Runner-Container
|
||||
fehlt der Socket. Er braucht `-v /var/run/docker.sock:/var/run/docker.sock`.
|
||||
- **Migration schlägt im Deploy fehl:** der Lauf bricht ab, bevor Container
|
||||
angefasst werden — die alte Version läuft also weiter. Die fehlgeschlagene
|
||||
Datei wurde vollständig zurückgerollt und **nicht** verbucht; nach der
|
||||
Korrektur reicht ein erneuter Push.
|
||||
|
||||
- **Anmeldung endet auf `/login?error=…`:** die Umleitungs-URI in der
|
||||
Entra-Registrierung muss exakt
|
||||
`https://<host>/api/auth/callback/microsoft-entra-id` lauten. Steht die
|
||||
@@ -269,9 +372,6 @@ Single-Instance-Compose-Konfiguration ist das nicht nötig.
|
||||
- **Cron läuft nicht:** `docker compose logs cron` – prüft, ob
|
||||
`/etc/crontabs/root` korrekt geschrieben wurde und ob `CRON_SECRET` in
|
||||
`.env` gesetzt ist (leer/fehlend führt serverseitig zu `401`).
|
||||
- **`max clients reached in session mode`:** der Verbindungsstring zeigt auf
|
||||
den Sitzungs-Modus des Poolers. Auf den Transaktions-Modus wechseln (bei
|
||||
Supabase Port 6543).
|
||||
- **Healthcheck rot:** `docker compose logs app` – meist `DATABASE_URL`
|
||||
fehlend oder nicht erreichbar. Der Pool baut die Verbindung erst beim
|
||||
ersten Zugriff auf, der Fehler steht deshalb im Log der Anfrage, nicht im
|
||||
|
||||
81
README.md
81
README.md
@@ -1,4 +1,4 @@
|
||||
# Manner HR Master
|
||||
# Alpenwerk HR Master
|
||||
|
||||
Interne HR-Stammdatenverwaltung: Mitarbeiter:innen, Organisationsstruktur
|
||||
(Bereich/Abteilung/Team), Planstellen, Neueinstellungen, Versetzungen/
|
||||
@@ -24,11 +24,13 @@ auf explizit aktivierte HR-Benutzer:innen beschränkt (siehe
|
||||
`node_modules/next/dist/docs/` konsultieren; sie sind maßgeblich, ältere
|
||||
Anleitungen im Netz beschreiben teils überholte APIs.
|
||||
- React 19, TypeScript
|
||||
- Supabase (Postgres, Auth, RLS) — Datenhaltung liegt vollständig in
|
||||
Supabase, nicht im Next.js-Prozess.
|
||||
- PostgreSQL, angesprochen über Kysely und `pg` — die Datenhaltung liegt in
|
||||
der Datenbank, nicht im Next.js-Prozess. Der Zugriff läuft ausschliesslich
|
||||
über `withUser()` (`lib/db/`), das den Sitzungskontext setzt.
|
||||
- Auth.js gegen Microsoft Entra ID — keine eigenen Passwörter.
|
||||
- Tailwind CSS v4
|
||||
- Vitest — Unit- (Node), Komponenten- (jsdom) und Integrationstests
|
||||
(gegen ein lokales Supabase)
|
||||
(gegen eine erreichbare Datenbank)
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -38,14 +40,14 @@ cp .env.example .env.local # Werte eintragen, siehe unten
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Für lokale Supabase-Entwicklung (statt gegen ein Cloud-Projekt):
|
||||
Für eine lokale Datenbank genügt der Container aus `docker-compose.yml`:
|
||||
|
||||
```bash
|
||||
supabase start # startet lokalen Postgres/Auth/Studio-Stack
|
||||
docker compose up -d db
|
||||
docker compose run --rm migrate # spielt db/migrations/ ein
|
||||
```
|
||||
|
||||
`supabase/config.toml` und `.env.test.local` sind bereits auf die
|
||||
Standard-Ports der lokalen Supabase-CLI abgestimmt.
|
||||
Danach zeigt `DATABASE_URL` in `.env.local` auf diese Datenbank.
|
||||
|
||||
## Umgebungsvariablen
|
||||
|
||||
@@ -77,9 +79,10 @@ selbst spricht. Ein Docker-Abbild ist damit umgebungsneutral: einmal gebaut,
|
||||
| `npm run lint` | ESLint (`eslint-config-next`, Flat Config) |
|
||||
| `npm run typecheck` | `tsc --noEmit` |
|
||||
| `npm run test` | Vitest, Unit-Tests (`tests/unit/**`) |
|
||||
| `npm run test:integration` | Vitest gegen eine echte (lokale) Supabase-Instanz — braucht `supabase start` und `.env.test.local` |
|
||||
| `npm run test:integration` | Vitest gegen eine erreichbare Datenbank; überspringt sich ohne `DATABASE_URL` |
|
||||
| `npm run test:e2e` | Playwright |
|
||||
| `npm run check` | lint + typecheck + test + build in Folge |
|
||||
| `npm run migrate` | Ausstehende Migrationen einspielen (`--dry-run`, `--baseline`) |
|
||||
| `npm run check` | lint + typecheck + types:check + test + build in Folge — dieselben Schritte wie der CI-Job „check" |
|
||||
|
||||
## Sicherheitsprinzipien
|
||||
|
||||
@@ -110,52 +113,50 @@ selbst spricht. Ein Docker-Abbild ist damit umgebungsneutral: einmal gebaut,
|
||||
`/api/cron/apply-pending-changes` wendet wirksam gewordene, zukunftsdatierte
|
||||
Änderungen an (`pending_org_changes` → `apply_due_pending_changes()`).
|
||||
|
||||
- **Auf Vercel:** `vercel.json` definiert den täglichen Schedule; Vercel Cron
|
||||
sendet `Authorization: Bearer <CRON_SECRET>` automatisch, wenn
|
||||
`CRON_SECRET` in den Projekt-Env-Vars gesetzt ist.
|
||||
- **Außerhalb von Vercel (Docker):** kein Vercel Cron verfügbar — siehe
|
||||
[`DEPLOYMENT.md`](DEPLOYMENT.md) für den Cron-Sidecar-Container, der
|
||||
denselben Endpoint mit demselben Schema aufruft.
|
||||
- Den Zeitplan hält der `cron`-Container aus `docker-compose.yml`: täglich
|
||||
03:00 Uhr, mit `Authorization: Bearer <CRON_SECRET>`.
|
||||
- Fehlt `CRON_SECRET` oder stimmt der Header nicht, antwortet die Route mit
|
||||
`401` (nicht `500` — bewusst, siehe `tests/unit/security.test.ts`).
|
||||
|
||||
## Supabase-Hinweise
|
||||
## Datenbank
|
||||
|
||||
- Schema-Quelle der Wahrheit: `supabase/migrations/`. Menschlich lesbare
|
||||
Fassung, aus der laufenden Datenbank erzeugt:
|
||||
- Schema-Quelle der Wahrheit: `db/migrations/`. Menschlich lesbare Fassung,
|
||||
aus der laufenden Datenbank erzeugt:
|
||||
[`docs/datenkatalog.md`](docs/datenkatalog.md).
|
||||
- Migrationen einspielen: `supabase db push` (gegen das verlinkte Projekt)
|
||||
bzw. `supabase start` + automatische Anwendung für lokale Entwicklung.
|
||||
- `supabase/seed.ts` und `.env.test.local` sind nur für lokale
|
||||
Entwicklung/Tests gedacht, nie für ein Produktivprojekt verwenden.
|
||||
- Migrationen einspielen: `npm run migrate`. Der Läufer wendet nur die
|
||||
fehlenden Dateien an, jede in ihrer eigenen Transaktion, und führt darüber
|
||||
Buch in `migrationen.schema_migrations`.
|
||||
- `lib/types.ts` wird von Hand gepflegt. `npm run types:check` hält sie
|
||||
Spalte für Spalte gegen die Migrationen.
|
||||
|
||||
## Testing
|
||||
|
||||
- `npm run test` — schnell, keine externen Abhängigkeiten, läuft in CI.
|
||||
- `npm run test:integration` — braucht eine laufende lokale Supabase-Instanz
|
||||
(`supabase start`) und `.env.test.local`; prüft RLS-Verhalten end-to-end
|
||||
(siehe `tests/integration/authorization.test.ts` für das HR-Only-Zugriffs-
|
||||
modell).
|
||||
- `npm run test:integration` — braucht eine erreichbare Datenbank
|
||||
(`DATABASE_URL`). Geprüft werden Regeln, die es zweimal gibt: einmal als
|
||||
SQL, einmal als TypeScript. Ohne `DATABASE_URL` überspringen sich die
|
||||
Dateien, statt mit einem Verbindungsfehler abzubrechen.
|
||||
- `npm run test:e2e` — Playwright gegen einen laufenden Dev-/Preview-Server.
|
||||
|
||||
## Deployment
|
||||
|
||||
Siehe [`DEPLOYMENT.md`](DEPLOYMENT.md) für Docker-basiertes Deployment
|
||||
(Dockerfile, docker-compose.yml, Reverse-Proxy/TLS, Cron-Ersatz, Updates).
|
||||
Für Vercel: `vercel.json` ist bereits vorhanden; Env-Vars im
|
||||
Vercel-Projekt setzen (siehe oben).
|
||||
Siehe [`DEPLOYMENT.md`](DEPLOYMENT.md): Anwendung und Datenbank als Container,
|
||||
Reverse-Proxy/TLS, der nächtliche Lauf, Migrationen und Updates.
|
||||
|
||||
## Known TODOs vor Produktivbetrieb
|
||||
|
||||
- **Content-Security-Policy fehlt noch** (`next.config.ts` setzt bewusst
|
||||
keine CSP — Skript-/Style-/Connect-Quellen sind noch nicht vollständig
|
||||
inventarisiert; ungeprüft geraten zu setzen riskiert, Hydration oder den
|
||||
Supabase-Client stillschweigend zu brechen).
|
||||
- **Lokale Scratch-Artefakte** (`.scratch_*`, `.scratch_shots/`) enthalten
|
||||
Screenshots/Hilfsskripte aus einer früheren manuellen Verifikation und
|
||||
liegen noch im Arbeitsverzeichnis. Sie sind jetzt über `.gitignore`
|
||||
ausgeschlossen; vor einem Produktiv-Handover sollten sie durchgesehen und
|
||||
bei Bedarf gelöscht werden.
|
||||
- **Content-Security-Policy läuft im Nur-Bericht-Modus** (`next.config.ts`).
|
||||
Erzwungen wird sie erst, wenn die Meldungen sauber sind — eine geratene,
|
||||
erzwungene Richtlinie blendet die Anwendung für alle aus.
|
||||
- **Seed für die Integrationstests fehlt.** Er lag in der abgelösten
|
||||
Umgebung. Zwei der drei Integrationstests vergleichen Regeln über den
|
||||
gesamten Bestand und brauchen dafür Daten; bis der Seed nachgezogen ist,
|
||||
laufen sie nur gegen eine bereits befüllte Datenbank, nicht in der CI.
|
||||
- **Sicherheitsprüfung des heutigen Aufbaus steht aus** — siehe
|
||||
[`docs/security-review.md`](docs/security-review.md).
|
||||
- **Bildschirmfotos unter `.scratch_shots/`** stammen aus einer früheren
|
||||
Handprüfung. Sie sind über `.gitignore` ausgeschlossen; vor der Übergabe
|
||||
durchsehen und löschen.
|
||||
- **Kein granulareres Rollenmodell** — aktuell HR-only (alles-oder-nichts).
|
||||
Falls z. B. eine reine Lese-Rolle künftig gebraucht wird, gehört die
|
||||
Erweiterung in eine neue Migration (`is_hr_user()`/RLS-Policies), nicht in
|
||||
|
||||
@@ -1,15 +1,38 @@
|
||||
"use server";
|
||||
|
||||
import { AuthError } from "next-auth";
|
||||
import { signIn, signOut } from "@/auth";
|
||||
|
||||
// Anmeldung ausschliesslich über Entra ID. Es gibt bewusst keinen
|
||||
// Passwort-Pfad: ein zweiter Anmeldeweg neben dem Firmenkonto hebelt jede
|
||||
// Vorgabe des Mandanten aus — Mehrfaktor, bedingten Zugriff, Sperrung beim
|
||||
// Austritt.
|
||||
// Zwei Anmeldewege, und der zweite ist auf Zeit.
|
||||
//
|
||||
// Die Herkunft muss hier nicht mehr aus dem Request geholt werden: Auth.js
|
||||
// baut die Rückruf-Adresse selbst und akzeptiert nur Ziele auf demselben
|
||||
// Host. Ein untergeschobener Host läuft also weiterhin ins Leere.
|
||||
// **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.
|
||||
|
||||
export async function signInWithEntra() {
|
||||
// Kehrt nicht zurück: signIn löst eine Weiterleitung aus, und die wirft in
|
||||
@@ -17,6 +40,47 @@ export async function signInWithEntra() {
|
||||
await signIn("microsoft-entra-id", { redirectTo: "/" });
|
||||
}
|
||||
|
||||
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() {
|
||||
await signOut({ redirectTo: "/login" });
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import { revalidatePath } from "next/cache";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
import { callFunction, runMutation, type ActionResult, type MutationFn } from "@/lib/db/rpc";
|
||||
import type { CollectiveAgreement, DienstwagenArt, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types";
|
||||
import { OFFBOARDING_PUNKTE } from "@/lib/offboarding";
|
||||
import { ONBOARDING_PUNKTE } from "@/lib/onboarding";
|
||||
import type { CollectiveAgreement, DienstwagenArt, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/types";
|
||||
|
||||
async function callRpc(fn: MutationFn, payload: Record<string, unknown>, revalidate: string[]): Promise<ActionResult> {
|
||||
const result = await runMutation(await currentUserId(), fn, payload);
|
||||
@@ -13,6 +15,44 @@ async function callRpc(fn: MutationFn, payload: Record<string, unknown>, revalid
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist diese Personalnummer noch frei?
|
||||
*
|
||||
* ── Warum das trotz der Prüfung in der Datenbank hier steht ──────────
|
||||
*
|
||||
* `hire_employee` weist eine vergebene Nummer ab, und das bleibt die
|
||||
* verbindliche Prüfung: zwischen dieser Frage und dem Anlegen können Sekunden
|
||||
* liegen, und in denen kann jemand anderes dieselbe Nummer vergeben. Der
|
||||
* Unique-Index ist die einzige Stelle, die das sicher ausschliesst.
|
||||
*
|
||||
* Nur kommt diese Abweisung ganz am Ende — nach Position, Vertrag,
|
||||
* Angehörigen, Notfallkontakt, sechs Schritten Eingabe. Die Nummer steht im
|
||||
* *ersten* Feld des *ersten* Schritts. Wer sie vertippt, erfährt es
|
||||
* frühestens nach fünf Minuten und darf dann suchen, welche der Angaben
|
||||
* gemeint war.
|
||||
*
|
||||
* Deshalb hier die frühe Auskunft und dort die Entscheidung. Es ist keine
|
||||
* doppelte Wahrheit, sondern dieselbe Frage zu zwei Zeitpunkten — und wenn
|
||||
* die späte Antwort einmal abweicht, gewinnt sie.
|
||||
*
|
||||
* Kein `ActionResult`: eine vergebene Nummer ist kein Fehler des Aufrufs,
|
||||
* sondern die Antwort auf die Frage.
|
||||
*/
|
||||
export async function personalnummerVergeben(nummer: number): Promise<{ vergeben: boolean; name?: string }> {
|
||||
if (!Number.isInteger(nummer) || nummer <= 0) return { vergeben: false };
|
||||
return withUser(await currentUserId(), async (tx) => {
|
||||
const treffer = await tx
|
||||
.selectFrom("employees")
|
||||
.select(["first_name", "last_name"])
|
||||
.where("personnel_number", "=", nummer)
|
||||
.executeTakeFirst();
|
||||
// Der Name wird mitgegeben, weil „bereits vergeben" allein die Frage
|
||||
// aufwirft, an wen — und die Antwort ist meistens der Grund: dieselbe
|
||||
// Person ist schon angelegt, oder es war ein Zahlendreher.
|
||||
return treffer ? { vergeben: true, name: `${treffer.first_name} ${treffer.last_name}` } : { vergeben: false };
|
||||
});
|
||||
}
|
||||
|
||||
export async function hireEmployee(payload: {
|
||||
/**
|
||||
* Wird eingegeben, nicht vergeben.
|
||||
@@ -60,16 +100,31 @@ export async function hireEmployee(payload: {
|
||||
emergency_contact_relation?: string;
|
||||
is_laterale_fuehrung?: boolean;
|
||||
is_c_level?: boolean;
|
||||
/** Form der Beschäftigung — chk_mitarbeiterart. Leer heisst „unverändert". */
|
||||
mitarbeiterart?: string;
|
||||
has_kuendigungsschutz?: boolean;
|
||||
/** Nur mit dem Kennzeichen zusammen — so verlangt es chk_kuendigungsschutz_bis. */
|
||||
kuendigungsschutz_bis?: string | null;
|
||||
/** Ebenfalls nur mit dem Kennzeichen — chk_kuendigungsschutz_grund. */
|
||||
kuendigungsschutz_grund?: string | null;
|
||||
kuendigungsschutz_ab?: string | null;
|
||||
ist_beguenstigt_behindert?: boolean;
|
||||
/** Nur mit dem Kennzeichen — chk_behinderung. Als Text, wie er aus dem Feld kommt. */
|
||||
behinderung_grad?: string | null;
|
||||
behinderung_ab?: string | null;
|
||||
behinderung_bis?: string | null;
|
||||
}): Promise<ActionResult & { employeeId?: string }> {
|
||||
// Einzige Mutation, deren Rückgabewert gebraucht wird: die neue
|
||||
// Personen-Kennung, damit die Oberfläche direkt auf die Akte springen kann.
|
||||
try {
|
||||
const employeeId = await withUser(await currentUserId(), (tx) =>
|
||||
callFunction(tx, "hire_employee", payload as Record<string, unknown>)
|
||||
);
|
||||
const employeeId = await withUser(await currentUserId(), async (tx) => {
|
||||
const id = (await callFunction(tx, "hire_employee", payload as Record<string, unknown>)) as string;
|
||||
// In derselben Transaktion: eine Einstellung ohne Checkliste wäre eine
|
||||
// halb erfasste Einstellung, und sie später nachzureichen hiesse, dass
|
||||
// jemand daran denken muss.
|
||||
await callFunction(tx, "start_onboarding", { employee_id: id, item_keys: ONBOARDING_PUNKTE.map((x) => x.key) });
|
||||
return id;
|
||||
});
|
||||
revalidatePath("/employees");
|
||||
revalidatePath("/");
|
||||
revalidatePath("/positions");
|
||||
@@ -83,9 +138,59 @@ export async function terminateEmployee(payload: {
|
||||
employee_id: string;
|
||||
exit_date: string;
|
||||
exit_reason: string;
|
||||
/**
|
||||
* Freiwillig oder unfreiwillig — leer heisst „nicht erfasst".
|
||||
*
|
||||
* Eigene Angabe und nicht aus `exit_reason` abgeleitet: die einvernehmliche
|
||||
* Auflösung kann von beiden Seiten ausgehen (siehe lib/beendigung.ts).
|
||||
*/
|
||||
austrittsart?: string;
|
||||
note?: string;
|
||||
}): Promise<ActionResult> {
|
||||
return callRpc("terminate_employee", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]);
|
||||
// No Show ist kein Austritt im gewohnten Sinn — die Person hat nie
|
||||
// angefangen. Dafür gibt es nichts offzuboarden: keinen IT-Zugang, der
|
||||
// eingerichtet wurde, keine GKK-Anmeldung, kein Dienstzettel. Die Liste
|
||||
// entstünde leer und wäre ein Etikett ohne Inhalt.
|
||||
if (payload.exit_reason === "No Show") {
|
||||
return callRpc("terminate_employee", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]);
|
||||
}
|
||||
|
||||
try {
|
||||
await withUser(await currentUserId(), async (tx) => {
|
||||
await callFunction(tx, "terminate_employee", payload as unknown as Record<string, unknown>);
|
||||
// In derselben Transaktion: ein Austritt ohne Checkliste wäre ein
|
||||
// halb erfasster Austritt, und sie später anzulegen hiesse, dass
|
||||
// jemand daran denken muss.
|
||||
await callFunction(tx, "start_offboarding", {
|
||||
employee_id: payload.employee_id,
|
||||
item_keys: OFFBOARDING_PUNKTE.map((x) => x.key),
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
||||
}
|
||||
for (const path of [`/employees/${payload.employee_id}`, "/employees", "/"]) revalidatePath(path);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** Einen Punkt der Offboarding-Checkliste festhalten — Haken, Wert oder Kommentar. */
|
||||
export async function setOffboardingTask(payload: {
|
||||
employee_id: string;
|
||||
item_key: string;
|
||||
erledigt?: boolean;
|
||||
wert?: string | null;
|
||||
kommentar?: string | null;
|
||||
}): Promise<ActionResult> {
|
||||
return callRpc("set_offboarding_task", payload, [`/employees/${payload.employee_id}`]);
|
||||
}
|
||||
|
||||
/** Legt die Offboarding-Checkliste nachträglich an — für Austritte von vor dieser Liste. */
|
||||
export async function startOffboarding(employeeId: string): Promise<ActionResult> {
|
||||
return callRpc(
|
||||
"start_offboarding",
|
||||
{ employee_id: employeeId, item_keys: OFFBOARDING_PUNKTE.map((x) => x.key) },
|
||||
[`/employees/${employeeId}`]
|
||||
);
|
||||
}
|
||||
|
||||
export async function transferEmployee(payload: {
|
||||
@@ -142,6 +247,9 @@ export async function changeEmployeeData(payload: {
|
||||
effective_date: string;
|
||||
person: Record<string, unknown>;
|
||||
contract: Record<string, unknown>;
|
||||
// `role` trägt seit 20260917120000 auch `source`. Wechselt sie von
|
||||
// „Extern" auf „Intern", schreibt change_employee_data zusätzlich das
|
||||
// Ereignis „Übernahme" — der umgekehrte Weg erzeugt keines.
|
||||
role: Record<string, unknown>;
|
||||
}): Promise<ActionResult> {
|
||||
return callRpc("change_employee_data", payload, [`/employees/${payload.employee_id}`, "/employees"]);
|
||||
@@ -159,8 +267,54 @@ export async function rehireEmployee(payload: {
|
||||
employee_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> {
|
||||
return callRpc("rehire_employee", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]);
|
||||
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: {
|
||||
|
||||
@@ -1,10 +1,74 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { currentUserId, requireUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
import type { ActionResult } from "@/lib/db/rpc";
|
||||
|
||||
/**
|
||||
* Den Entwurf für mich belegen — beim Öffnen und danach im Takt.
|
||||
*
|
||||
* Die Regel hire_drafts_update lässt die Zeile nur durch, wenn die Sperre
|
||||
* frei, meine oder abgelaufen ist. Trifft das Schreiben keine Zeile, hat sie
|
||||
* jemand anderes offen; das ist kein Fehler, sondern die Auskunft.
|
||||
*
|
||||
* Dieselbe Funktion frischt die Sperre auf: sie schreibt locked_at neu,
|
||||
* solange sie mir gehört. Der Assistent ruft sie darum im Takt, sonst liefe
|
||||
* die Frist einem langen Ausfüllen davon.
|
||||
*
|
||||
* updated_at bleibt unberührt. Eine Sperre ist keine Änderung am Entwurf —
|
||||
* bewegte sie den Zeitstempel, sortierte sich die Liste um und „Gespeichert
|
||||
* am" logge.
|
||||
*/
|
||||
export async function entwurfSperren(id: string): Promise<ActionResult> {
|
||||
const userId = await requireUserId();
|
||||
try {
|
||||
const ergebnis = await withUser(userId, (tx) =>
|
||||
tx
|
||||
.updateTable("hire_drafts")
|
||||
.set({ locked_by: userId, locked_at: new Date().toISOString() })
|
||||
.where("id", "=", id)
|
||||
.executeTakeFirst()
|
||||
);
|
||||
if (ergebnis.numUpdatedRows === 0n) {
|
||||
return { success: false, error: "Dieser Entwurf wird gerade von jemand anderem bearbeitet." };
|
||||
}
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
||||
}
|
||||
revalidatePath("/");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Sperre zurückgeben.
|
||||
*
|
||||
* Die Bedingung auf locked_by steht hier und nicht nur in der Regel: die
|
||||
* Regel liesse auch das Freigeben einer *abgelaufenen fremden* Sperre zu,
|
||||
* und das wäre ein stiller Griff in die Arbeit von jemandem, der gerade
|
||||
* wieder aufgefrischt hat.
|
||||
*
|
||||
* Ein Scheitern bleibt ohne Meldung: die Frist räumt die Sperre ohnehin ab,
|
||||
* und es gäbe nichts, was die Person daraufhin tun könnte.
|
||||
*/
|
||||
export async function entwurfFreigeben(id: string): Promise<ActionResult> {
|
||||
const userId = await requireUserId();
|
||||
try {
|
||||
await withUser(userId, (tx) =>
|
||||
tx
|
||||
.updateTable("hire_drafts")
|
||||
.set({ locked_by: null, locked_at: null })
|
||||
.where("id", "=", id)
|
||||
.where("locked_by", "=", userId)
|
||||
.execute()
|
||||
);
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
||||
}
|
||||
revalidatePath("/");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function saveHireDraft(payload: {
|
||||
id?: string;
|
||||
step: number;
|
||||
@@ -16,13 +80,25 @@ export async function saveHireDraft(payload: {
|
||||
try {
|
||||
const id = await withUser(userId, async (tx) => {
|
||||
if (payload.id) {
|
||||
// Ob die Zeile der aufrufenden Person gehört, entscheidet die
|
||||
// Policy hire_drafts_owner — nicht eine Prüfung hier.
|
||||
await tx
|
||||
// Wer schreiben darf, entscheidet die Regel hire_drafts_update:
|
||||
// der Entwurf muss meiner oder von einer hinzugewählten Person sein,
|
||||
// **und** die Sperre muss mir gehören.
|
||||
//
|
||||
// Die Zahl der geänderten Zeilen wird trotzdem gelesen: eine Regel
|
||||
// weist ein UPDATE nicht mit einem Fehler ab, sie lässt es ins Leere
|
||||
// laufen. Ohne diese Prüfung meldete die Anwendung „gespeichert", und
|
||||
// gespeichert wäre nichts — der ärgerlichste Fall überhaupt, weil die
|
||||
// Person den Assistenten daraufhin beruhigt zumacht.
|
||||
const ergebnis = await tx
|
||||
.updateTable("hire_drafts")
|
||||
.set({ step: payload.step, payload: payload.data, updated_at: new Date().toISOString() })
|
||||
.where("id", "=", payload.id)
|
||||
.execute();
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -43,7 +119,18 @@ export async function saveHireDraft(payload: {
|
||||
|
||||
export async function deleteHireDraft(id: string): Promise<ActionResult> {
|
||||
try {
|
||||
await withUser(await currentUserId(), (tx) => tx.deleteFrom("hire_drafts").where("id", "=", id).execute());
|
||||
// Auch hier die Zeilenzahl: die Regel hire_drafts_delete lässt ein
|
||||
// Löschen gegen eine fremde Sperre leer laufen statt es abzuweisen.
|
||||
// „Entwurf gelöscht" über einem Entwurf, der noch dasteht, wäre die
|
||||
// schlechtere Auskunft.
|
||||
await withUser(await currentUserId(), async (tx) => {
|
||||
const ergebnis = await tx.deleteFrom("hire_drafts").where("id", "=", id).executeTakeFirst();
|
||||
if (ergebnis.numDeletedRows === 0n) {
|
||||
throw new Error(
|
||||
"Der Entwurf liess sich nicht löschen: er wird gerade von jemand anderem bearbeitet oder war schon weg."
|
||||
);
|
||||
}
|
||||
});
|
||||
revalidatePath("/");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
|
||||
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 };
|
||||
}
|
||||
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("/");
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { runMutation, type ActionResult } from "@/lib/db/rpc";
|
||||
const POSITION_PATHS = ["/positions", "/orgchart", "/"];
|
||||
|
||||
async function callRpc(
|
||||
fn: "create_position" | "update_position" | "delete_position" | "set_position_cost_center",
|
||||
fn: "create_position" | "clone_position" | "update_position" | "delete_position" | "set_position_cost_center",
|
||||
payload: Record<string, unknown>,
|
||||
revalidate: string[]
|
||||
): Promise<ActionResult> {
|
||||
@@ -26,6 +26,23 @@ export async function createPosition(payload: {
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -21,8 +21,8 @@ function pageHref(params: SearchParams, page: number): string {
|
||||
}
|
||||
|
||||
// Pinned to Vienna and built once: audit_log.occurred_at is a timestamptz, and
|
||||
// an unpinned formatter renders it in the *server's* zone — UTC in Docker and
|
||||
// on Vercel — so every entry would read an hour or two early for the people
|
||||
// an unpinned formatter renders it in the *server's* zone — UTC in the
|
||||
// container — so every entry would read an hour or two early for the people
|
||||
// the log is for.
|
||||
const dateTimeFormatter = new Intl.DateTimeFormat("de-AT", {
|
||||
day: "2-digit",
|
||||
@@ -80,7 +80,13 @@ export default async function AuditPage({ searchParams }: { searchParams: Promis
|
||||
</Suspense>
|
||||
<p className="text-sm text-ink-muted">{count} Einträge</p>
|
||||
|
||||
<div className={`overflow-x-auto ${CARD_CLASS}`}>
|
||||
{/* `overflow-y-hidden` gehört zwingend dazu und ist keine Vorsicht:
|
||||
nach CSS wird eine auf `visible` stehende Achse auf `auto`
|
||||
hochgestuft, sobald die andere nicht `visible` ist. `overflow-x-auto`
|
||||
allein macht aus der Karte also einen Scrollbereich in **beiden**
|
||||
Richtungen — mit einer senkrechten Leiste, die niemand bestellt hat.
|
||||
Geklemmt wird dabei nichts: die Karte ist so hoch wie ihr Inhalt. */}
|
||||
<div className={`overflow-x-auto overflow-y-hidden ${CARD_CLASS}`}>
|
||||
<table className="w-full min-w-[800px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface text-left text-[11px] font-bold uppercase tracking-wider text-ink-muted">
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
|
||||
const data = await withUser(await currentUserId(), (tx) => loadEmployeeDetail(tx, id, today));
|
||||
|
||||
if (!data) notFound();
|
||||
const { employee, line, reports, history, dependents, notes, orgMaps, placement, kostenstelle, openPositions, byId } = data;
|
||||
const { employee, line, reports, history, dependents, notes, orgMaps, placement, kostenstelle, onboarding, offboarding, openPositions, byId } = data;
|
||||
|
||||
return (
|
||||
<EmployeeDetail
|
||||
@@ -32,6 +32,8 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
|
||||
}
|
||||
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.
|
||||
@@ -49,6 +51,7 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
|
||||
notes={notes ?? []}
|
||||
locations={orgMaps.locationList}
|
||||
openPositions={openPositions}
|
||||
today={today}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,24 +9,70 @@ import { currentUserId } from "@/lib/auth/session";
|
||||
import { sql, withUser } from "@/lib/db";
|
||||
import { jsonArrayFrom, jsonObjectFrom } from "@/lib/db/json";
|
||||
import { istPersonalnummer, suchMuster } from "@/lib/employee-search";
|
||||
import {
|
||||
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, divisionOf, loadOrgMaps, subtreeOf, unitOf, type OrgEb } from "@/lib/org";
|
||||
import { loadPlacements } from "@/lib/placement";
|
||||
import type { EmploymentStatus } from "@/lib/supabase/types";
|
||||
import type { EmploymentStatus } from "@/lib/types";
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
/**
|
||||
* Die Parameter, nachdem sie geglättet sind — je einer, oder keiner.
|
||||
*
|
||||
* Was Next tatsächlich liefert, steht in RohParams: derselbe Name zweimal in
|
||||
* der Adresse wird dort zu einem Array. Diese Seite hat das nicht erwartet
|
||||
* und ist daran gescheitert — eine Kachel der Übersicht verwies auf
|
||||
* `?status=Aktiv&status=Karenz`, und `params.status.split(",")` lief gegen
|
||||
* ein Array. Sichtbar war davon nur „Diese Ansicht konnte nicht geladen
|
||||
* werden" mit einer Fehlerkennung.
|
||||
*
|
||||
* Die Kachel ist berichtigt (sie schreibt jetzt `status=Aktiv,Karenz`), aber
|
||||
* eine Adresse kommt nicht nur aus der eigenen Anwendung: sie steht in
|
||||
* Lesezeichen, in Verknüpfungen, in E-Mails. Deshalb wird hier geglättet
|
||||
* statt sich darauf zu verlassen, dass niemand zweimal denselben Namen
|
||||
* schreibt.
|
||||
*/
|
||||
type SearchParams = {
|
||||
q?: string;
|
||||
division?: string;
|
||||
status?: string;
|
||||
location?: string;
|
||||
sort?: string;
|
||||
dir?: string;
|
||||
page?: string;
|
||||
};
|
||||
|
||||
type RohParams = Record<string, string | string[] | undefined>;
|
||||
|
||||
/** Der erste Wert eines Parameters — auch wenn er mehrfach in der Adresse steht. */
|
||||
function einWert(wert: string | string[] | undefined): string | undefined {
|
||||
return Array.isArray(wert) ? wert[0] : wert;
|
||||
}
|
||||
|
||||
function glaetten(roh: RohParams): SearchParams {
|
||||
return {
|
||||
q: einWert(roh.q),
|
||||
division: einWert(roh.division),
|
||||
status: einWert(roh.status),
|
||||
location: einWert(roh.location),
|
||||
sort: einWert(roh.sort),
|
||||
dir: einWert(roh.dir),
|
||||
page: einWert(roh.page),
|
||||
};
|
||||
}
|
||||
|
||||
type EmployeesPageProps = {
|
||||
searchParams: Promise<SearchParams>;
|
||||
searchParams: Promise<RohParams>;
|
||||
};
|
||||
|
||||
function pageHref(params: SearchParams, page: number): string {
|
||||
@@ -35,13 +81,36 @@ function pageHref(params: SearchParams, page: number): string {
|
||||
if (params.division) sp.set("division", params.division);
|
||||
if (params.status) sp.set("status", params.status);
|
||||
if (params.location) sp.set("location", params.location);
|
||||
// Ohne das kippte die Liste beim Blättern zurück auf den Standard, und
|
||||
// Seite 2 zeigte Namen, die auf Seite 1 schon standen.
|
||||
if (params.sort) sp.set("sort", params.sort);
|
||||
if (params.dir) sp.set("dir", params.dir);
|
||||
sp.set("page", String(page));
|
||||
return `/employees?${sp.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Adresse hinter einem Spaltenkopf.
|
||||
*
|
||||
* Ohne `page`: nach dem Umsortieren steht auf Seite 7 etwas völlig anderes
|
||||
* als vorher. Wer sortiert, will von vorne anfangen.
|
||||
*/
|
||||
function sortHref(params: SearchParams, feld: Sortierfeld, aktuell: Sortierfeld, richtung: Richtung): string {
|
||||
const sp = new URLSearchParams();
|
||||
if (params.q) sp.set("q", params.q);
|
||||
if (params.division) sp.set("division", params.division);
|
||||
if (params.status) sp.set("status", params.status);
|
||||
if (params.location) sp.set("location", params.location);
|
||||
sp.set("sort", feld);
|
||||
sp.set("dir", naechsteRichtung(aktuell, richtung, feld));
|
||||
return `/employees?${sp.toString()}`;
|
||||
}
|
||||
|
||||
export default async function EmployeesPage({ searchParams }: EmployeesPageProps) {
|
||||
const params = await searchParams;
|
||||
const params = glaetten(await searchParams);
|
||||
const page = Math.max(1, Number(params.page ?? "1") || 1);
|
||||
const sortFeld = parseFeld(params.sort);
|
||||
const sortRichtung = parseRichtung(params.dir);
|
||||
const today = todayIso();
|
||||
|
||||
// Comma-separated, so a dashboard tile can link here with the same
|
||||
@@ -171,14 +240,15 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
||||
"entry_date",
|
||||
"employment_type",
|
||||
"weekly_hours",
|
||||
"status",
|
||||
// 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",
|
||||
])
|
||||
// Nach id als zweitem Kriterium: bei gleichem Nachnamen wäre die
|
||||
// Reihenfolge sonst unbestimmt, und dieselbe Person könnte auf zwei
|
||||
// Seiten erscheinen oder auf keiner.
|
||||
.orderBy("last_name")
|
||||
.orderBy("id")
|
||||
.$call((q) => sortiere(q, sortFeld, sortRichtung, today))
|
||||
.limit(PAGE_SIZE)
|
||||
.offset((page - 1) * PAGE_SIZE)
|
||||
).as("rows"),
|
||||
@@ -217,17 +287,49 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className={`overflow-x-auto ${CARD_CLASS}`}>
|
||||
{/* Zur zweiten Achse siehe den Kommentar auf der Protokollseite: ohne
|
||||
`overflow-y-hidden` stuft CSS sie auf `auto` hoch, und die Karte
|
||||
bekommt eine senkrechte Leiste, die nichts bewirkt. */}
|
||||
<div className={`overflow-x-auto overflow-y-hidden ${CARD_CLASS}`}>
|
||||
<table className="w-full min-w-[800px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface text-left text-[11px] font-bold uppercase tracking-wider text-ink-muted">
|
||||
<th className="px-4 py-2.5">Mitarbeiter:in</th>
|
||||
<th className="px-4 py-2.5">Pers.-Nr.</th>
|
||||
<th className="px-4 py-2.5">Bereich/Team</th>
|
||||
<th className="px-4 py-2.5">Standort</th>
|
||||
<th className="px-4 py-2.5">Eintritt</th>
|
||||
<th className="px-4 py-2.5">Beschäftigung</th>
|
||||
<th className="px-4 py-2.5">Status</th>
|
||||
{SORTIERFELDER.map((f) => {
|
||||
const aktiv = f.value === sortFeld;
|
||||
return (
|
||||
// `aria-sort` sagt einem Screenreader, welche Spalte die
|
||||
// Reihenfolge bestimmt und in welche Richtung — der Pfeil
|
||||
// allein ist für ihn nicht da.
|
||||
<th
|
||||
key={f.value}
|
||||
scope="col"
|
||||
aria-sort={aktiv ? (sortRichtung === "asc" ? "ascending" : "descending") : "none"}
|
||||
className="px-4 py-2.5"
|
||||
>
|
||||
<Link
|
||||
href={sortHref(params, f.value, sortFeld, sortRichtung)}
|
||||
// `title` nennt aus, was der Pfeil andeutet: was der
|
||||
// nächste Klick tut, nicht was gerade gilt.
|
||||
title={
|
||||
aktiv && sortRichtung === "asc"
|
||||
? `${f.label} absteigend sortieren`
|
||||
: `${f.label} aufsteigend sortieren`
|
||||
}
|
||||
className={`group inline-flex items-center gap-1 rounded transition-colors hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
|
||||
aktiv ? "text-ink" : ""
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
{/* Der Pfeil steht immer da, nur blass, solange die
|
||||
Spalte nicht sortiert: sonst springt die Kopfzeile
|
||||
beim Klicken um eine Pfeilbreite. */}
|
||||
<span aria-hidden className={aktiv ? "text-brand-600" : "text-ink-muted/30 group-hover:text-ink-muted"}>
|
||||
{aktiv && sortRichtung === "desc" ? "▼" : "▲"}
|
||||
</span>
|
||||
</Link>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -272,7 +374,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
||||
{e.employment_type} · <span className="tabular-nums">{e.weekly_hours}h</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<StatusChip status={e.status} entryDate={e.entry_date} absenceType={e.absence_type} />
|
||||
<StatusChip employee={e} asOf={today} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Button, LINK_BUTTON_CLASS } from "@/components/ui/Button";
|
||||
// Without this file a failed render drops the user on Next.js's own error
|
||||
// screen — no navigation, no way back, and in production just "a client-side
|
||||
// exception occurred". `reset()` re-renders the segment, which is enough for
|
||||
// the common case of a transient Supabase timeout.
|
||||
// the common case of a transient database timeout.
|
||||
export default function AppError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
useEffect(() => {
|
||||
console.error("Route error:", error);
|
||||
|
||||
@@ -13,21 +13,30 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
// Alles in *einer* Transaktion, weil nur dort der Sitzungskontext gilt —
|
||||
// und damit nebenbei auf einem einheitlichen Lesestand. Was dabei in wie
|
||||
// vielen Rundreisen gelesen wird, steht in lib/shell-data.ts.
|
||||
const data = await withUser(userId, (tx) => loadShellData(tx, userId));
|
||||
const ergebnis = await withUser(userId, (tx) => loadShellData(tx, userId));
|
||||
|
||||
// `data` ist null, wenn die Person angemeldet, aber nicht freigeschaltet
|
||||
// ist. Hier — und nicht im Proxy — fällt diese Entscheidung: der Proxy hat
|
||||
// keine Datenbankverbindung. Sie wird bei jedem Aufbau frisch gestellt, eine
|
||||
// Hier — und nicht im Proxy — fällt diese Entscheidung: der Proxy hat keine
|
||||
// Datenbankverbindung. Sie wird bei jedem Aufbau frisch gestellt, eine
|
||||
// entzogene Freischaltung wirkt also sofort statt erst mit dem nächsten
|
||||
// Sitzungstoken. Ohne den Grund in der Adresse stünde die Person vor einer
|
||||
// wortlosen Anmeldeseite und versuchte es endlos erneut.
|
||||
if (!data) redirect("/login?error=no_hr_access");
|
||||
// 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");
|
||||
|
||||
// Ohne den Grund in der Adresse stünde die Person vor einer wortlosen
|
||||
// Anmeldeseite und versuchte es endlos erneut.
|
||||
if (ergebnis.status === "kein_zugang") redirect("/login?error=no_hr_access");
|
||||
|
||||
const data = ergebnis.daten;
|
||||
|
||||
const userLabel = data.profile.full_name || data.profile.email || "";
|
||||
|
||||
return (
|
||||
<HireWizardProvider openPositions={data.openPositions} locations={data.locations} drafts={data.drafts}>
|
||||
<AppShell userLabel={userLabel} openNotes={data.openNotes}>
|
||||
<AppShell userLabel={userLabel} openNotes={data.openNotes} kollegen={data.kollegen}>
|
||||
{children}
|
||||
</AppShell>
|
||||
</HireWizardProvider>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Every page in this group is server-rendered per request (they all read
|
||||
// from Supabase), so without this the browser sits on the previous page with
|
||||
// from the database), so without this the browser sits on the previous page with
|
||||
// no feedback until the server answers — on the employee list, long enough
|
||||
// to look broken.
|
||||
export default function Loading() {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { Fragment, Suspense } from "react";
|
||||
import { AnstehendFilter } from "@/components/dashboard/AnstehendFilter";
|
||||
import { AnstehendListe, type AnstehendEintrag } from "@/components/dashboard/AnstehendListe";
|
||||
import { DraftsCard } from "@/components/dashboard/DraftsCard";
|
||||
import { Card, CARD_CLASS, CardTitle } from "@/components/ui/Card";
|
||||
import { actionBadgeStyle } from "@/lib/colors";
|
||||
import { eventBadgeStyle, eventDotStyle } from "@/lib/colors";
|
||||
import { istEingeschraenkt, parseArten, parseZeitraum } from "@/lib/dashboard-filter";
|
||||
import { loadDashboardData } from "@/lib/dashboard-data";
|
||||
import { addDaysIso, fmtDate, fmtName, todayIso } from "@/lib/format";
|
||||
import { addDaysIso, fmtName, todayIso } from "@/lib/format";
|
||||
import { divisionOf } from "@/lib/org";
|
||||
import { deriveStatusAsOf } from "@/lib/reports";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
@@ -22,28 +23,40 @@ const TONE: Record<string, { text: string; bar: string }> = {
|
||||
danger: { text: "text-danger-text", bar: "bg-danger-text" },
|
||||
warning: { text: "text-warning-text", bar: "bg-warning-text" },
|
||||
brand: { text: "text-brand-700", bar: "bg-brand-500" },
|
||||
// Geplante Eintritte: eine Ankündigung, kein Befund. „info" ist der Ton,
|
||||
// den die Statuschips für „Geplant" ohnehin tragen.
|
||||
info: { text: "text-info-text", bar: "bg-info-text" },
|
||||
};
|
||||
|
||||
const DOT_STYLES: Record<string, string> = {
|
||||
Eintritt: "bg-success-text",
|
||||
Wiedereintritt: "bg-success-text",
|
||||
Rückkehr: "bg-success-text",
|
||||
Austritt: "bg-danger-text",
|
||||
Versetzung: "bg-info-text",
|
||||
Beförderung: "bg-purple-text",
|
||||
Reorganisation: "bg-purple-text",
|
||||
Karenz: "bg-warning-text",
|
||||
Vertragsänderung: "bg-warning-text",
|
||||
Stammdatenänderung: "bg-warning-text",
|
||||
Gehaltsanpassung: "bg-warning-text",
|
||||
/** Eine Kennzahl auf der Übersicht. */
|
||||
type Kachel = { label: string; value: string | number; tone: keyof typeof TONE; href: string };
|
||||
|
||||
/**
|
||||
* Wie viele Spalten die Überschrift einer Gruppe überspannt — nach der Zahl
|
||||
* ihrer Kacheln.
|
||||
*
|
||||
* Ausgeschrieben und nicht als `span var(--kacheln)`: Tailwind erzeugt nur
|
||||
* Klassen, die als Zeichenkette im Quelltext stehen, und eine aus einer
|
||||
* Variablen berechnete Spannweite wäre zur Bauzeit nicht zu sehen. Die
|
||||
* Tabelle deckt jede mögliche Gruppengrösse bis zur Rasterbreite ab; ein
|
||||
* Zugriff daneben gäbe `undefined` und damit eine Überschrift über einer
|
||||
* einzigen Spalte — auffällig genug, um bemerkt zu werden.
|
||||
*/
|
||||
const SPALTEN_SPAN: Record<number, string> = {
|
||||
1: "xl:col-span-1",
|
||||
2: "xl:col-span-2",
|
||||
3: "xl:col-span-3",
|
||||
4: "xl:col-span-4",
|
||||
5: "xl:col-span-5",
|
||||
6: "xl:col-span-6",
|
||||
7: "xl:col-span-7",
|
||||
8: "xl:col-span-8",
|
||||
};
|
||||
|
||||
const KIND_LABEL = {
|
||||
hire: "Eintritt",
|
||||
exit: "Austritt",
|
||||
return: "Rückkehr aus Abwesenheit",
|
||||
note: "Wiedervorlage",
|
||||
} 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
|
||||
// davon war das falsche. Jetzt leiten beide aus EVENT_CATEGORY ab und koennen
|
||||
// nicht mehr auseinanderlaufen.
|
||||
|
||||
export default async function DashboardPage({
|
||||
searchParams,
|
||||
@@ -121,17 +134,7 @@ export default async function DashboardPage({
|
||||
.sort((a, b) => b.count - a.count);
|
||||
const maxDivisionCount = Math.max(1, ...divisionBars.map((d) => d.count));
|
||||
|
||||
type UpcomingItem = {
|
||||
id: string;
|
||||
/** Ziel des Klicks — bei einer Wiedervorlage die Akte, nicht die Notiz. */
|
||||
employeeId: string;
|
||||
label: string;
|
||||
/** Zweite Zeile: bei einer Wiedervorlage der Notiztext statt der Art. */
|
||||
hinweis?: string;
|
||||
date: string;
|
||||
kind: keyof typeof KIND_LABEL;
|
||||
};
|
||||
const upcomingAlle: UpcomingItem[] = [
|
||||
const upcomingAlle: AnstehendEintrag[] = [
|
||||
...(upcomingHires).map((e) => ({
|
||||
id: e.id,
|
||||
employeeId: e.id,
|
||||
@@ -163,12 +166,6 @@ export default async function DashboardPage({
|
||||
})),
|
||||
].sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
// Die Karte bleibt eine Übersicht, keine Liste: acht Zeilen, und darunter
|
||||
// steht, wie viele es insgesamt sind. Wer alle sehen will, filtert enger
|
||||
// oder geht in die Mitarbeiterliste.
|
||||
const upcoming = upcomingAlle.slice(0, 8);
|
||||
const weitere = upcomingAlle.length - upcoming.length;
|
||||
|
||||
// Each tile links to the view that shows what it counts, with the filters
|
||||
// pre-applied.
|
||||
//
|
||||
@@ -179,49 +176,136 @@ export default async function DashboardPage({
|
||||
// and rehire_employee sets entry_date but logs the event as
|
||||
// 'Wiedereintritt'. A year with rehires therefore shows a slightly higher
|
||||
// number on the tile than in the linked report.
|
||||
const kpis = [
|
||||
// ── Warum die Kacheln in Gruppen stehen ────────────────────────────
|
||||
//
|
||||
// Acht Zahlen nebeneinander sind acht Zahlen. Sie beantworten aber drei
|
||||
// verschiedene Fragen: wie viele sind da, was hat sich bewegt, was ist
|
||||
// offen. Ohne Überschrift muss man jede Beschriftung einzeln lesen, um das
|
||||
// herauszufinden — mit ihr sieht man es, bevor man liest.
|
||||
//
|
||||
// Die Einteilung und die Reihenfolge kommen aus einem Entwurf des Kunden
|
||||
// (E-Mail vom 16.09.2026). „Aktives Dienstverhältnis" steht darin bewusst
|
||||
// **vorn**: es ist die Bezugsgrösse fast jeder Personalkennzahl, und wer
|
||||
// eine Quote bildet, greift zuerst danach.
|
||||
const kpiGruppen: { titel: string; kacheln: Kachel[] }[] = [
|
||||
{
|
||||
label: "Aktive Mitarbeiter:innen",
|
||||
value: activeCount,
|
||||
tone: "default",
|
||||
href: "/employees?status=Aktiv",
|
||||
},
|
||||
{ label: "FTE", value: fte.toFixed(1), tone: "default", href: "/reports?mode=snapshot&measure=fte&status=Aktiv" },
|
||||
{
|
||||
label: "Eintritte (Jahr)",
|
||||
value: hiresYtd,
|
||||
tone: "success",
|
||||
href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${yearEnd}`,
|
||||
titel: "Personalstand",
|
||||
kacheln: [
|
||||
// Nicht dasselbe wie „Aktive Mitarbeiter:innen" daneben: dort steht,
|
||||
// wer heute arbeitet, hier, mit wem ein Vertrag läuft —
|
||||
// Langzeitabwesende eingeschlossen. Sichtbar waren 805 und 10,
|
||||
// addieren musste man selbst.
|
||||
{
|
||||
label: "Aktives Dienstverhältnis",
|
||||
value: activeCount + karenzCount,
|
||||
tone: "default",
|
||||
// Mit Komma, nicht zweimal `status=`: die Liste liest den Parameter
|
||||
// als *eine* Zeichenkette und trennt selbst. Zweimal übergeben macht
|
||||
// Next daraus ein Array, und die Seite scheiterte an `.split(",")`.
|
||||
href: "/employees?status=Aktiv,Karenz",
|
||||
},
|
||||
// „(HC)" für Headcount — der Unterschied zur Kachel links ist genau
|
||||
// der, den die Abkürzung benennt: Köpfe, die heute arbeiten.
|
||||
{ label: "Aktive Mitarbeiter:innen (HC)", value: activeCount, tone: "default", href: "/employees?status=Aktiv" },
|
||||
{ label: "Langzeitabwesend", value: karenzCount, tone: "warning", href: "/employees?status=Karenz" },
|
||||
// Ohne Zeitgrenze — anders als die Karte „Anstehend" darunter, die nur
|
||||
// den eingestellten Vorschauzeitraum zeigt. Ein Eintritt in vier
|
||||
// Monaten ist vereinbart und zählt, auch wenn er dort nicht auftaucht.
|
||||
{
|
||||
label: "Geplante Eintritte",
|
||||
value: staffRows.filter((row) => statusOf(row) === "Geplant").length,
|
||||
tone: "info",
|
||||
href: "/employees?status=Geplant",
|
||||
},
|
||||
{ label: "Aktive FTE", value: fte.toFixed(1), tone: "default", href: "/reports?mode=snapshot&measure=fte&status=Aktiv" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Austritte (Jahr)",
|
||||
value: exitsYtd,
|
||||
tone: "danger",
|
||||
href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`,
|
||||
titel: "Personalbewegung",
|
||||
kacheln: [
|
||||
{
|
||||
label: "Eintritte (Jahr)",
|
||||
value: hiresYtd,
|
||||
tone: "success",
|
||||
href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${yearEnd}`,
|
||||
},
|
||||
{
|
||||
label: "Austritte (Jahr)",
|
||||
value: exitsYtd,
|
||||
tone: "danger",
|
||||
href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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: openPositions.length, tone: "brand", href: "/positions" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{drafts && drafts.length > 0 && <DraftsCard drafts={drafts} />}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{kpis.map((kpi) => (
|
||||
<Link
|
||||
key={kpi.label}
|
||||
href={kpi.href}
|
||||
className={`${CARD_CLASS} group relative overflow-hidden p-4 pl-5 transition-shadow hover:shadow-[var(--shadow-card-hover)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500`}
|
||||
>
|
||||
<span className={`absolute inset-y-0 left-0 w-1 ${TONE[kpi.tone].bar}`} aria-hidden />
|
||||
{/* Number first in the visual order: it is what the eye is
|
||||
looking for, and the label only qualifies it. */}
|
||||
<div className={`text-3xl font-extrabold leading-none tabular-nums ${TONE[kpi.tone].text}`}>{kpi.value}</div>
|
||||
<div className="mt-1.5 flex items-center gap-1 text-xs font-semibold leading-tight text-ink-muted">
|
||||
{kpi.label}
|
||||
<ChevronRight className="h-3 w-3 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" aria-hidden />
|
||||
</div>
|
||||
</Link>
|
||||
{drafts.length > 0 && <DraftsCard drafts={drafts} />}
|
||||
{/* ── Ein Raster, nicht drei nebeneinandergestellte ──────────────
|
||||
Alle acht Kacheln liegen in **einem** Raster mit acht gleichen
|
||||
Spalten und einem einzigen Abstandswert. Nur so sind sie gleich
|
||||
breit und gleich weit auseinander — unabhängig davon, zu welcher
|
||||
Gruppe sie gehören.
|
||||
|
||||
Die beiden Anläufe davor stellten drei eigene Raster nebeneinander.
|
||||
Das ging zweimal schief: erst bekam jede Gruppe ein Drittel der
|
||||
Breite (fünf Kacheln gequetscht, eine mit demselben Platz), dann
|
||||
einen Anteil nach Kachelzahl — da stimmten die Breiten fast, aber
|
||||
eben nur fast: eine Gruppe mit fünf Kacheln hat vier Abstände in
|
||||
sich, eine mit einer keinen, und dieser Unterschied verteilt sich
|
||||
auf die Kachelbreiten. Gleichmässig wird es erst, wenn alle Kacheln
|
||||
demselben Raster angehören.
|
||||
|
||||
Die Überschriften sitzen in Zeile 1 und überspannen die Spalten
|
||||
ihrer Gruppe, die Kacheln in Zeile 2. Beide Zeilen entstehen aus
|
||||
derselben DOM-Reihenfolge (Überschrift, ihre Kacheln, nächste
|
||||
Überschrift …) — die ist für Vorlesegeräte die richtige, und die
|
||||
ausdrückliche Zeilenangabe sortiert sie fürs Auge.
|
||||
|
||||
Unterhalb der grossen Breite gibt es keine Zeilenangabe: dann fliesst
|
||||
alles der Reihe nach, die Überschrift über die volle Breite und ihre
|
||||
Kacheln zu zweit darunter. Acht nebeneinander wären auf einem Laptop
|
||||
unlesbar schmal. */}
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-3 xl:grid-cols-8 xl:gap-y-1.5">
|
||||
{kpiGruppen.map((gruppe) => (
|
||||
<Fragment key={gruppe.titel}>
|
||||
<h2
|
||||
className={`mt-2 self-end text-[11px] font-bold uppercase tracking-wider text-ink-muted
|
||||
col-span-2 xl:mt-0 xl:[grid-row:1] ${SPALTEN_SPAN[gruppe.kacheln.length]}`}
|
||||
>
|
||||
{gruppe.titel}
|
||||
</h2>
|
||||
{gruppe.kacheln.map((kpi) => (
|
||||
<Link
|
||||
key={kpi.label}
|
||||
href={kpi.href}
|
||||
className={`${CARD_CLASS} group relative overflow-hidden p-4 pl-5 transition-shadow xl:[grid-row:2] hover:shadow-[var(--shadow-card-hover)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500`}
|
||||
>
|
||||
<span className={`absolute inset-y-0 left-0 w-1 ${TONE[kpi.tone].bar}`} aria-hidden />
|
||||
{/* Number first in the visual order: it is what the eye is
|
||||
looking for, and the label only qualifies it.
|
||||
|
||||
Kleiner, sobald alle acht in einer Reihe stehen: dort
|
||||
bleiben je Kachel gut 110 px, und „748.4" braucht in 30 px
|
||||
Schriftgrad mehr davon, als nach dem Innenabstand übrig
|
||||
ist. Die Kachel hat `overflow-hidden` — die Zahl wäre also
|
||||
nicht zu breit, sondern abgeschnitten, und eine
|
||||
abgeschnittene Kennzahl ist schlimmer als eine kleine. */}
|
||||
<div className={`text-3xl font-extrabold leading-none tabular-nums xl:text-2xl ${TONE[kpi.tone].text}`}>
|
||||
{kpi.value}
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-1 text-xs font-semibold leading-tight text-ink-muted">
|
||||
{kpi.label}
|
||||
<ChevronRight className="h-3 w-3 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" aria-hidden />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -258,49 +342,15 @@ export default async function DashboardPage({
|
||||
<Suspense fallback={<div className="mb-2 h-9" />}>
|
||||
<AnstehendFilter zeitraum={zeitraum} arten={arten} />
|
||||
</Suspense>
|
||||
<ul className="flex flex-col divide-y divide-border-subtle">
|
||||
{upcoming.map((item) => {
|
||||
// Überfällig gibt es nur bei Wiedervorlagen: die anderen Arten
|
||||
// haben eine untere Grenze, eine offene Aufgabe nicht.
|
||||
const ueberfaellig = item.date < today;
|
||||
return (
|
||||
<li key={`${item.kind}-${item.id}`}>
|
||||
<Link
|
||||
href={`/employees/${item.employeeId}`}
|
||||
className="-mx-2 flex items-center justify-between gap-2 rounded px-2 py-2.5 text-sm hover:bg-surface"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-semibold text-ink">{item.label}</span>
|
||||
<span className="block truncate text-xs text-ink-muted">
|
||||
{KIND_LABEL[item.kind]}
|
||||
{item.hinweis ? ` — ${item.hinweis}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={`shrink-0 text-xs font-semibold tabular-nums ${
|
||||
ueberfaellig ? "text-danger-text" : "text-ink-muted"
|
||||
}`}
|
||||
>
|
||||
{ueberfaellig ? "überfällig " : ""}
|
||||
{fmtDate(item.date)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{upcoming.length === 0 && (
|
||||
<p className="py-2 text-sm text-ink-muted">
|
||||
{istEingeschraenkt(zeitraum, arten)
|
||||
? "Zu dieser Auswahl steht nichts an."
|
||||
: "Keine anstehenden Ereignisse."}
|
||||
</p>
|
||||
)}
|
||||
</ul>
|
||||
{weitere > 0 && (
|
||||
<p className="mt-2 text-xs text-ink-muted">
|
||||
… und {weitere} {weitere === 1 ? "weiteres Ereignis" : "weitere Ereignisse"} in diesem Zeitraum.
|
||||
</p>
|
||||
)}
|
||||
<AnstehendListe
|
||||
eintraege={upcomingAlle}
|
||||
today={today}
|
||||
leerText={
|
||||
istEingeschraenkt(zeitraum, arten)
|
||||
? "Zu dieser Auswahl steht nichts an."
|
||||
: "Keine anstehenden Ereignisse."
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
@@ -310,11 +360,11 @@ export default async function DashboardPage({
|
||||
<li key={h.id} className="flex gap-2.5 py-2.5">
|
||||
{/* Dot aligned to the first line of text, not centred on the
|
||||
whole row, so it stays put as descriptions wrap. */}
|
||||
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${DOT_STYLES[h.event_type] ?? "bg-ink-muted"}`} aria-hidden />
|
||||
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${eventDotStyle(h.event_type)}`} aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="text-sm font-semibold text-ink">{h.first_name && h.last_name ? fmtName(h.first_name, h.last_name) : "Unbekannt"}</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${actionBadgeStyle(h.event_type)}`}>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${eventBadgeStyle(h.event_type)}`}>
|
||||
{h.event_type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
parseEventDateParam,
|
||||
parseEventGroupDimension,
|
||||
parseEventSplitDimension,
|
||||
parseEventSubtype,
|
||||
parseEventType,
|
||||
parseGroupDimension,
|
||||
parseIsoDateParam,
|
||||
@@ -33,6 +34,7 @@ type SearchParams = Record<string, string | string[] | undefined> & {
|
||||
status?: string;
|
||||
asOf?: string;
|
||||
eventType?: string;
|
||||
eventSubtype?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
};
|
||||
@@ -48,6 +50,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
||||
const eventGroup = parseEventGroupDimension(params.group);
|
||||
const eventSplit = parseEventSplitDimension(params.split);
|
||||
const eventType = parseEventType(params.eventType);
|
||||
const eventSubtype = parseEventSubtype(params.eventSubtype, eventType);
|
||||
const from = parseEventDateParam(params.from);
|
||||
const to = parseEventDateParam(params.to);
|
||||
const measure = parseMeasure(params.measure);
|
||||
@@ -71,6 +74,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
||||
mode === "events"
|
||||
? loadEventHistory(tx, {
|
||||
eventType: eventType ?? undefined,
|
||||
eventSubtype: eventSubtype ?? undefined,
|
||||
division: params.division,
|
||||
location: params.location,
|
||||
from,
|
||||
@@ -108,6 +112,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
||||
eventGroup={eventGroup}
|
||||
eventSplit={eventSplit ?? ""}
|
||||
eventType={eventType ?? ""}
|
||||
eventSubtype={eventSubtype ?? ""}
|
||||
eventFilters={{ division: params.division ?? "", location: params.location ?? "", from: from ?? "", to: to ?? "" }}
|
||||
rows={rows}
|
||||
total={sumValues(rows)}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { EntraSignInButton } from "@/components/auth/EntraSignInButton";
|
||||
import { PasswortFormular } from "@/components/auth/PasswortFormular";
|
||||
import { AppWortmarke } from "@/components/brand/Logo";
|
||||
import { logout, signInWithEntra } from "@/actions/auth";
|
||||
|
||||
// The query string is attacker-controlled, so the login page renders a message
|
||||
@@ -15,6 +17,15 @@ const ERROR_MESSAGES = {
|
||||
title: "Anmeldung fehlgeschlagen",
|
||||
body: "Die Anmeldung über das Firmenkonto konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut.",
|
||||
},
|
||||
// Auth.js' eigener Code für eine gescheiterte Passwortanmeldung. Im Regelfall
|
||||
// kommt er hier nicht an — die Server Action fängt den Fehlschlag ab und
|
||||
// zeigt ihn am Formular. Ohne diesen Eintrag fiele er aber auf `sso_failed`
|
||||
// zurück, und dort stünde „über das Firmenkonto", was mit dem tatsächlichen
|
||||
// Weg nichts zu tun hätte.
|
||||
CredentialsSignin: {
|
||||
title: "Anmeldung fehlgeschlagen",
|
||||
body: "E-Mail-Adresse oder Passwort stimmt nicht.",
|
||||
},
|
||||
} as const;
|
||||
|
||||
type ErrorCode = keyof typeof ERROR_MESSAGES;
|
||||
@@ -46,8 +57,11 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
|
||||
<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">
|
||||
<Wordmark className="text-ink" />
|
||||
<AppWortmarke hoehe={26} textKlasse="text-ink" />
|
||||
</div>
|
||||
|
||||
<h2 className="mt-8 text-2xl font-extrabold tracking-tight text-ink lg:mt-0">Anmelden</h2>
|
||||
@@ -77,6 +91,20 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
<EntraSignInButton />
|
||||
</form>
|
||||
|
||||
{/* Der Passwortweg steht bewusst *unter* dem Firmenkonto und ohne
|
||||
eigene Überschrift: er ist die Ausnahme für die Testphase, nicht
|
||||
die zweite gleichwertige Möglichkeit. Wer ein Firmenkonto hat,
|
||||
soll oben klicken. */}
|
||||
<div className="mt-6 flex items-center gap-3" aria-hidden>
|
||||
<span className="h-px flex-1 bg-border-subtle" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-ink-muted">oder</span>
|
||||
<span className="h-px flex-1 bg-border-subtle" />
|
||||
</div>
|
||||
|
||||
<div 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.
|
||||
@@ -89,55 +117,59 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
|
||||
function BrandPanel() {
|
||||
return (
|
||||
<aside className="relative hidden overflow-hidden bg-brand-700 lg:flex lg:flex-col lg:justify-between lg:p-12">
|
||||
{/* Zwei weiche Lichtpunkte und ein feines Raster — genug Struktur, damit
|
||||
die Fläche nicht wie ein Farbfehler wirkt, aber ohne Bilddatei und
|
||||
ohne von der einen Schaltfläche gegenüber abzulenken. */}
|
||||
// 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(60rem 40rem at 15% 0%, rgb(255 255 255 / 0.16), transparent 60%)," +
|
||||
"radial-gradient(40rem 40rem at 100% 100%, rgb(255 255 255 / 0.10), transparent 55%)," +
|
||||
"linear-gradient(rgb(255 255 255 / 0.05) 1px, transparent 1px)," +
|
||||
"linear-gradient(90deg, rgb(255 255 255 / 0.05) 1px, transparent 1px)",
|
||||
backgroundSize: "auto, auto, 3rem 3rem, 3rem 3rem",
|
||||
"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",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<Wordmark className="text-white" />
|
||||
{/* -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-white">
|
||||
<p className="text-3xl font-extrabold leading-tight tracking-tight text-brand-700">
|
||||
Die Organisation, so wie sie heute wirklich aussieht.
|
||||
</p>
|
||||
<p className="mt-4 text-sm leading-relaxed text-white/70">
|
||||
<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 text-white/50">Interne Anwendung · Zugriff nur für die Personalabteilung</p>
|
||||
<p className="relative text-xs font-semibold text-brand-700">Interne Anwendung · Zugriff nur für die Personalabteilung</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function Wordmark({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2.5 ${className}`}>
|
||||
{/* Vier Quadrate wie ein Organigramm-Ausschnitt: eine Wurzel, darunter
|
||||
drei Einheiten. */}
|
||||
<svg viewBox="0 0 24 24" className="h-7 w-7" aria-hidden="true">
|
||||
<rect x="9" y="2" width="6" height="6" rx="1.5" fill="currentColor" />
|
||||
<rect x="1" y="16" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.55" />
|
||||
<rect x="9" y="16" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.75" />
|
||||
<rect x="17" y="16" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.55" />
|
||||
<path d="M12 8v4M4 16v-4h16v4" stroke="currentColor" strokeWidth="1.5" fill="none" opacity="0.5" />
|
||||
</svg>
|
||||
<span className="text-lg font-extrabold tracking-tight">Alpenwerk HR</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Die vier Quadrate, die hier als Platzhalter-Bildmarke standen, sind
|
||||
// entfallen: an ihrer Stelle steht jetzt das Manner-Logo
|
||||
// (components/brand/Logo.tsx).
|
||||
|
||||
@@ -4,10 +4,11 @@ import { callFunction } from "@/lib/db/rpc";
|
||||
|
||||
// Applies effective-dated changes (Versetzung/Beförderung/Karenz/Reorg/Daten
|
||||
// ändern with a future "Wirksam ab" date) once their date has arrived — see
|
||||
// apply_due_pending_changes() in supabase/migrations. Runs as a Vercel Cron
|
||||
// job (see vercel.json), not on behalf of any HR user, so it authenticates
|
||||
// via a shared secret rather than a Supabase session and uses the
|
||||
// service-role client (the one legitimate server-only use case for it).
|
||||
// apply_due_pending_changes() in db/migrations.
|
||||
//
|
||||
// Gerufen wird das vom `cron`-Dienst aus docker-compose.yml, täglich um 03:00.
|
||||
// Nicht im Namen einer HR-Person: es gibt keine angemeldete Sitzung, deshalb
|
||||
// weist sich der Aufruf mit einem gemeinsamen Geheimnis aus (CRON_SECRET).
|
||||
export async function GET(request: NextRequest) {
|
||||
const authHeader = request.headers.get("authorization");
|
||||
if (!process.env.CRON_SECRET || authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
|
||||
|
||||
@@ -5,11 +5,13 @@ import { fmtName, todayIso } from "@/lib/format";
|
||||
import { subtreeOf } from "@/lib/org";
|
||||
import { loadPlacements, loadReportingLineMap } from "@/lib/placement";
|
||||
import { LEERE_CRITERIA, parseCriteria, passtImSpeicher } from "@/lib/report-criteria";
|
||||
import { derivedStatusFilter } from "@/lib/employee-status-filter";
|
||||
import { sortiereWochentage } from "@/lib/wochentage";
|
||||
import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "@/lib/reports";
|
||||
import { applyCriteria, loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data";
|
||||
import { requireHrUser } from "@/lib/auth/require-hr";
|
||||
import { withUser } from "@/lib/db";
|
||||
import type { Database, Weekday } from "@/lib/supabase/types";
|
||||
import type { Database } from "@/lib/types";
|
||||
|
||||
// Die Rohzeile plus die Einordnung, die nicht mehr auf ihr steht: sie kommt
|
||||
// über die Planstelle und die abgeleitete Berichtslinie.
|
||||
@@ -49,7 +51,15 @@ export async function GET(request: NextRequest) {
|
||||
function employeeQuery() {
|
||||
let q = tx.selectFrom("employees").selectAll().orderBy("last_name").orderBy("id");
|
||||
if (filters.location) q = q.where("location_id", "=", filters.location);
|
||||
if (!asOf) q = q.where("status", "in", statuses);
|
||||
// Ohne Stichtag wurde hier bisher über die Spalte `status` gefiltert,
|
||||
// mit Stichtag weiter unten über die Ableitung. Das sind zwei
|
||||
// verschiedene Antworten auf dieselbe Frage: die Spalte hängt nach,
|
||||
// sobald ein Austritt mit einem damals künftigen Datum erfasst wurde
|
||||
// (Migration 20260814100000 zieht sie nicht nach). Der Export nach
|
||||
// „Ausgetreten" liess damit genau die Leute aus, die gerade
|
||||
// ausgetreten sind. Jetzt beidemale dieselbe Regel, nur einmal in SQL
|
||||
// und einmal im Speicher.
|
||||
if (!asOf) q = q.where((eb) => derivedStatusFilter(eb, statuses, stichtag) ?? eb.val(true));
|
||||
// Dieselben Bedingungen wie im Bericht daneben — sonst stimmt die
|
||||
// Zahl auf dem Bildschirm nicht mit der Zeilenzahl im Export überein.
|
||||
return applyCriteria(q, filters.criteria ?? LEERE_CRITERIA);
|
||||
@@ -105,8 +115,6 @@ export async function GET(request: NextRequest) {
|
||||
return new NextResponse(new Blob([body as BlobPart]), { headers: exportResponseHeaders(filename, format) });
|
||||
}
|
||||
|
||||
const WEEKDAY_ORDER: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||
|
||||
function employeeExportColumns(
|
||||
lookups: OrgLookups,
|
||||
managerName: Map<string, string>,
|
||||
@@ -139,18 +147,31 @@ function employeeExportColumns(
|
||||
{ header: "Leitungsplanstelle", get: (e) => e.is_chief },
|
||||
{ header: "Beschäftigungsausmaß", get: (e) => e.employment_type },
|
||||
{ header: "Wochenstunden", get: (e) => e.weekly_hours },
|
||||
// work_days is stored in click order (see RoleEmploymentFields), not
|
||||
// guaranteed chronological — re-sort Mo→So for the export.
|
||||
{ header: "Arbeitstage", get: (e) => [...e.work_days].sort((a, b) => WEEKDAY_ORDER.indexOf(a as Weekday) - WEEKDAY_ORDER.indexOf(b as Weekday)).join(", ") },
|
||||
// Seit lib/wochentage.ts wird sortiert gespeichert; der Bestand kann aber
|
||||
// noch unsortierte Zeilen aus der Zeit davor tragen. Hier bleibt es
|
||||
// deshalb stehen — im Export kostet es nichts und macht die Spalte
|
||||
// unabhängig davon, wann eine Zeile zuletzt gespeichert wurde.
|
||||
{ header: "Arbeitstage", get: (e) => sortiereWochentage(e.work_days).join(", ") },
|
||||
{ header: "Vertragsart", get: (e) => e.contract_type },
|
||||
{ header: "Befristet bis", get: (e) => e.contract_end_date, kind: "date" },
|
||||
{ header: "Angestellte:r / Arbeiter:in", get: (e) => e.worker_type },
|
||||
{ header: "Beschäftigtengruppe", get: (e) => e.worker_type },
|
||||
{ header: "Mitarbeiterart", get: (e) => e.mitarbeiterart },
|
||||
{ header: "Kollektivvertrag", get: (e) => e.collective_agreement },
|
||||
{ header: "Betriebsrat", get: (e) => e.is_betriebsrat },
|
||||
{ header: "Dienstwagen", get: (e) => e.has_dienstwagen },
|
||||
{ header: "Antriebsart", get: (e) => e.dienstwagen_art ?? "" },
|
||||
{ header: "Besonderer Kündigungsschutz", get: (e) => e.has_kuendigungsschutz },
|
||||
{ header: "Personenkreis", get: (e) => e.kuendigungsschutz_grund ?? "" },
|
||||
{ header: "Kündigungsschutz ab", get: (e) => e.kuendigungsschutz_ab, kind: "date" },
|
||||
{ header: "Kündigungsschutz bis", get: (e) => e.kuendigungsschutz_bis, kind: "date" },
|
||||
// Der Bericht, den der Workshop verlangt hat (Anforderung 5a): wer ist
|
||||
// begünstigt behindert, mit wieviel Prozent, ab und bis wann. Vier
|
||||
// Spalten statt einer zusammengesetzten — in Excel wird danach gefiltert
|
||||
// und summiert, und ein Text wie „50% seit 2019" liesse beides nicht zu.
|
||||
{ header: "Begünstigt behindert", get: (e) => e.ist_beguenstigt_behindert },
|
||||
{ header: "Grad der Behinderung (%)", get: (e) => e.behinderung_grad ?? "" },
|
||||
{ header: "Bescheid ab", get: (e) => e.behinderung_ab, kind: "date" },
|
||||
{ header: "Bescheid bis", get: (e) => e.behinderung_bis, kind: "date" },
|
||||
{ header: "Teilzeitvariante", get: (e) => e.teilzeit_art ?? "" },
|
||||
{ header: "Teilzeit bis", get: (e) => e.teilzeit_bis, kind: "date" },
|
||||
{ header: "Notfallkontakt", get: (e) => e.emergency_contact_name ?? "" },
|
||||
@@ -168,6 +189,9 @@ function employeeExportColumns(
|
||||
{ header: "Eintrittsdatum", get: (e) => e.entry_date, kind: "date" },
|
||||
{ header: "Austrittsdatum", get: (e) => e.exit_date, kind: "date" },
|
||||
{ header: "Austrittsgrund", get: (e) => e.exit_reason },
|
||||
// Eigene Spalte neben dem Grund: aus ihm folgt sie nicht. Leer heisst
|
||||
// „nicht erfasst" und nicht „weder noch".
|
||||
{ header: "Freiwillig/unfreiwillig", get: (e) => e.austrittsart ?? "" },
|
||||
{ header: "Abwesenheit ab", get: (e) => e.karenz_start_date, kind: "date" },
|
||||
{ header: "Rückkehr geplant", get: (e) => e.karenz_return_date, kind: "date" },
|
||||
{ header: "Anzahl Angehörige", get: (e) => dependentsCounts.get(e.id) ?? 0 },
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
|
||||
import { EVENT_TYPE_LABELS, parseEventDateParam, parseEventType, type OrgLookups, type ReportEvent } from "@/lib/reports";
|
||||
import {
|
||||
EVENT_TYPE_LABELS,
|
||||
parseEventDateParam,
|
||||
parseEventSubtype,
|
||||
parseEventType,
|
||||
type OrgLookups,
|
||||
type ReportEvent,
|
||||
} from "@/lib/reports";
|
||||
import { loadEventHistory, loadOrgLookups } from "@/lib/reports-data";
|
||||
import { requireHrUser } from "@/lib/auth/require-hr";
|
||||
import { withUser } from "@/lib/db";
|
||||
@@ -22,6 +29,10 @@ export async function GET(request: NextRequest) {
|
||||
loadOrgLookups(tx),
|
||||
loadEventHistory(tx, {
|
||||
eventType: eventType ?? undefined,
|
||||
// Ohne diese Zeile exportierte die Schaltfläche neben der Auswertung
|
||||
// etwas anderes, als auf dem Bildschirm steht — die Einschränkung auf
|
||||
// die Beendigungsart fiele beim Export still weg.
|
||||
eventSubtype: parseEventSubtype(params.get("eventSubtype"), eventType) ?? undefined,
|
||||
division: params.get("division") ?? undefined,
|
||||
location: params.get("location") ?? undefined,
|
||||
from: parseEventDateParam(params.get("from")),
|
||||
@@ -43,6 +54,9 @@ function eventExportColumns(lookups: OrgLookups): ExportColumn<ReportEvent>[] {
|
||||
return [
|
||||
{ header: "Datum", get: (e) => e.event_date, kind: "date" },
|
||||
{ header: "Ereignistyp", get: (e) => EVENT_TYPE_LABELS[e.event_type] ?? e.event_type },
|
||||
// Beendigungsart bzw. Art der Langzeitabwesenheit. Leer bei allen
|
||||
// Ereignissen, die keinen Untertyp kennen — eine Versetzung hat keinen.
|
||||
{ header: "Untertyp", get: (e) => e.subtype ?? "" },
|
||||
{ header: "Vorname", get: (e) => e.first_name },
|
||||
{ header: "Nachname", get: (e) => e.last_name },
|
||||
{ header: "Position", get: (e) => e.job_title },
|
||||
|
||||
@@ -25,10 +25,10 @@ class Rueckabwicklung extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// 60 Sekunden, weil das die Obergrenze im kostenlosen Vercel-Tarif ist —
|
||||
// ein höherer Wert lässt sich dort nicht ausrollen. Auf einem eigenen Server
|
||||
// gilt die Angabe ohnehin nicht, und im Pro-Tarif liesse sie sich auf 300
|
||||
// heben, falls eine Datei mit vielen tausend Zeilen ansteht.
|
||||
// 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 = {
|
||||
|
||||
BIN
app/apple-icon.png
Normal file
BIN
app/apple-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
BIN
app/favicon.ico
BIN
app/favicon.ico
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 5.4 KiB |
@@ -6,6 +6,12 @@ import { useEffect } from "react";
|
||||
// (app)/error.tsx is not mounted yet. It replaces <html>, so it cannot use
|
||||
// the app's fonts, Tailwind layer or shared components — hence the inline
|
||||
// styles. Kept deliberately plain; anything clever here can fail too.
|
||||
//
|
||||
// Die Farben sind dieselben wie in app/globals.css, nur von Hand: surface,
|
||||
// border, ink, ink-body, ink-muted und brand-500. Sie stehen hier doppelt,
|
||||
// weil diese Seite gerade dann erscheint, wenn das Stylesheet nicht
|
||||
// getragen hat — eine Variable waere hier genau das, worauf man sich nicht
|
||||
// verlassen kann.
|
||||
export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
useEffect(() => {
|
||||
console.error("Global error:", error);
|
||||
@@ -13,19 +19,19 @@ export default function GlobalError({ error, reset }: { error: Error & { digest?
|
||||
|
||||
return (
|
||||
<html lang="de-AT">
|
||||
<body style={{ margin: 0, backgroundColor: "#f9f1f5", fontFamily: "system-ui, sans-serif", color: "#2d1c26" }}>
|
||||
<div style={{ maxWidth: 480, margin: "15vh auto", padding: 24, background: "#fff", border: "1px solid #eedde6", borderRadius: 8 }}>
|
||||
<body style={{ margin: 0, backgroundColor: "#fdf6f4", fontFamily: "system-ui, sans-serif", color: "#231f20" }}>
|
||||
<div style={{ maxWidth: 480, margin: "15vh auto", padding: 24, background: "#fff", border: "1px solid #f0dbd4", borderRadius: 8 }}>
|
||||
<h1 style={{ fontSize: 18, margin: "0 0 8px" }}>Die Anwendung konnte nicht geladen werden</h1>
|
||||
<p style={{ fontSize: 14, lineHeight: 1.5, color: "#503b47", margin: "0 0 16px" }}>
|
||||
<p style={{ fontSize: 14, lineHeight: 1.5, color: "#4b4446", margin: "0 0 16px" }}>
|
||||
Es ist ein unerwarteter Fehler aufgetreten. Ihre Daten sind davon nicht betroffen.
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p style={{ fontSize: 12, fontFamily: "monospace", color: "#7a636f", margin: "0 0 16px" }}>Fehlerkennung: {error.digest}</p>
|
||||
<p style={{ fontSize: 12, fontFamily: "monospace", color: "#756c6e", margin: "0 0 16px" }}>Fehlerkennung: {error.digest}</p>
|
||||
)}
|
||||
<button
|
||||
onClick={reset}
|
||||
style={{
|
||||
background: "#d6046e",
|
||||
background: "#164194",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: 4,
|
||||
|
||||
@@ -1,23 +1,74 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Design tokens from spec §7 (ported from tailwind.config.ts for Tailwind v4) */
|
||||
/* ═══ Farben nach dem Manner CD Manual 2022 ═══════════════════════════
|
||||
§1.1 kennt genau zwei Markenfarben:
|
||||
|
||||
Manner Rosa #F69686 CMYK 0/50/40/0 sRGB 246 150 134
|
||||
Manner Blau #164194 CMYK 100/80/0/0 sRGB 22 65 148 (Reflex Blue)
|
||||
|
||||
Beide behalten ihren Wert unveraendert; die Abstufungen daneben sind
|
||||
Aufhellungen und Abdunklungen davon, weil eine Oberflaeche Zwischentoene
|
||||
fuer Hover, Chips und Flaechen braucht und das Manual nur fuer den Druck
|
||||
geschrieben ist.
|
||||
|
||||
── Warum Blau die Interaktion traegt und Rosa die Flaeche ──
|
||||
Nachgerechnet nach WCAG 2.1:
|
||||
|
||||
Weiss auf Rosa 2.18:1 faellt durch, auch fuer Grossschrift
|
||||
Schwarz auf Rosa 7.47:1 AAA
|
||||
Weiss auf Blau 9.47:1 AAA
|
||||
Blau auf Weiss 9.47:1 AAA
|
||||
|
||||
Rosa kann also keine Schrift tragen — weder weisse noch, bei 4.34:1,
|
||||
blaue Fliesschrift. Es ist eine Flaeche, auf der Dunkles steht. Blau
|
||||
traegt in beide Richtungen und wird damit zur Farbe von Knopf, Link und
|
||||
Fokusring. Das ist keine Auslegung des Manuals, sondern das, was die
|
||||
beiden Farben physikalisch hergeben. */
|
||||
@theme {
|
||||
--color-brand-50: #fdf3f8;
|
||||
--color-brand-100: #fdeaf3;
|
||||
--color-brand-200: #f6cfe2;
|
||||
--color-brand-500: #d6046e;
|
||||
--color-brand-600: #b0035a;
|
||||
--color-brand-700: #a30354;
|
||||
/* ── Blau: alles, was man anfassen kann ── */
|
||||
--color-brand-50: #eef3fb;
|
||||
--color-brand-100: #dae4f5;
|
||||
--color-brand-200: #afc3e6;
|
||||
--color-brand-500: #164194; /* Manner Blau, CD §1.1 */
|
||||
--color-brand-600: #11326f;
|
||||
--color-brand-700: #0d2857;
|
||||
|
||||
--color-surface: #f9f1f5;
|
||||
/* ── Rosa: alles, was die Marke traegt ──
|
||||
accent-500 ist der Markenwert und bleibt Flaeche. Die Schrift darauf
|
||||
ist ink (7.47:1), nie Weiss. */
|
||||
--color-accent-50: #fef5f2;
|
||||
--color-accent-100: #fde7e1;
|
||||
--color-accent-200: #facec2;
|
||||
--color-accent-500: #f69686; /* Manner Rosa, CD §1.1 */
|
||||
--color-accent-600: #d9604b;
|
||||
--color-accent-700: #a8402e;
|
||||
|
||||
--color-border: #eedde6;
|
||||
--color-border-subtle: #f7e8ef;
|
||||
--color-surface: #fdf6f4;
|
||||
|
||||
--color-ink: #2d1c26;
|
||||
--color-ink-body: #503b47;
|
||||
--color-ink-muted: #7a636f;
|
||||
--color-border: #f0dbd4;
|
||||
--color-border-subtle: #f8ece8;
|
||||
/* Fuer die Umrandung von Eingabefeldern. Der dekorative Kartenrahmen
|
||||
darueber darf blass sein; die Grenze eines Bedienelements muss nach
|
||||
WCAG 1.4.11 3:1 erreichen, und das tut dieser Ton mit 3.56:1 gegen
|
||||
Weiss und 3.34:1 gegen die Seitenflaeche. */
|
||||
--color-border-strong: #a67f70;
|
||||
|
||||
/* Das Schwarz aus dem Manual selbst (#231F20), nicht reines Schwarz. */
|
||||
--color-ink: #231f20;
|
||||
--color-ink-body: #4b4446;
|
||||
--color-ink-muted: #756c6e;
|
||||
|
||||
/* ── Funktionsfarben ──
|
||||
Sie bleiben, was sie sind. Das Manual regelt die Identitaet, nicht die
|
||||
Rueckmeldung: Rot heisst Fehler, weil die Benutzerin das mitbringt,
|
||||
nicht weil es jemand entworfen hat. In einem System, in dem ein Knopf
|
||||
einen Austritt erfasst, waere eine markenkonforme Fehlerfarbe kein
|
||||
Stil, sondern ein Mangel.
|
||||
|
||||
`info` bleibt bewusst tuerkis statt blau zu werden: Blau heisst ab
|
||||
jetzt „hier kann man klicken", und ein Hinweischip in derselben Farbe
|
||||
saehe bedienbar aus. Gemessen kommt dazu, dass ein blaues info dem
|
||||
violetten Chip zu nahe kaeme (dE 6.8 — nicht unterscheidbar). */
|
||||
--color-success-bg: #dff6dd;
|
||||
--color-success-text: #0e700e;
|
||||
|
||||
@@ -31,8 +82,11 @@
|
||||
--color-info-bg: #d7f0f0;
|
||||
--color-info-text: #00666d;
|
||||
|
||||
--color-purple-bg: #f0ecf7;
|
||||
--color-purple-text: #5c2e91;
|
||||
/* Nachgezogen: gegen den alten Wert #f0ecf7 stand der Fehler-Chip bei
|
||||
dE 8.0 — „Austritt" und „Befoerderung" waren im Vorbeigehen dieselbe
|
||||
blasse Flaeche. Jetzt dE 15.0 bei 7.9:1 Kontrast. */
|
||||
--color-purple-bg: #e4d9f5;
|
||||
--color-purple-text: #54277f;
|
||||
|
||||
/* A bare `--radius` in Tailwind v4 collapses the whole scale onto one
|
||||
value — `rounded` and `rounded-lg` both came out at 8px, so a chip, an
|
||||
@@ -46,12 +100,17 @@
|
||||
|
||||
/* Warm, brand-tinted rather than neutral grey: a pure black shadow over
|
||||
the pink surface reads as dirt. Kept shallow — this is a data-dense
|
||||
admin UI, the depth only has to separate a card from the page. */
|
||||
--shadow-card: 0 1px 2px rgb(107 33 71 / 0.04), 0 1px 3px rgb(107 33 71 / 0.06);
|
||||
--shadow-card-hover: 0 2px 4px rgb(107 33 71 / 0.06), 0 4px 12px rgb(107 33 71 / 0.08);
|
||||
--shadow-overlay: 0 8px 32px rgb(45 28 38 / 0.16);
|
||||
admin UI, the depth only has to separate a card from the page.
|
||||
Der Grundton folgt dem Rosa der Flaeche, nicht mehr dem alten Magenta. */
|
||||
--shadow-card: 0 1px 2px rgb(122 60 45 / 0.05), 0 1px 3px rgb(122 60 45 / 0.07);
|
||||
--shadow-card-hover: 0 2px 4px rgb(122 60 45 / 0.07), 0 4px 12px rgb(122 60 45 / 0.09);
|
||||
--shadow-overlay: 0 8px 32px rgb(35 31 32 / 0.18);
|
||||
|
||||
--font-sans: var(--font-nunito), system-ui, sans-serif;
|
||||
/* Siehe app/layout.tsx: DIN waere die Schrift des Manuals, ist aber nicht
|
||||
als Webfont lizenziert; geladen wird Barlow als naechstliegender freier
|
||||
Ersatz. Die Ersatzkette bleibt neutral — faellt der Webfont aus, soll
|
||||
keine gerundete Systemschrift einspringen. */
|
||||
--font-sans: var(--font-din), "Segoe UI", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
315
app/icon.svg
Normal file
315
app/icon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 73 KiB |
@@ -1,11 +1,33 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Nunito } from "next/font/google";
|
||||
import { Barlow } from "next/font/google";
|
||||
import { ToastProvider } from "@/components/ui/Toast";
|
||||
import "./globals.css";
|
||||
|
||||
const nunito = Nunito({
|
||||
variable: "--font-nunito",
|
||||
subsets: ["latin"],
|
||||
// ═══ Die Schrift ═══
|
||||
//
|
||||
// Das CD Manual ist selbst in DIN gesetzt: nachgezaehlt tragen DIN-Regular
|
||||
// 79 % und DIN-Bold 16 % des Textes, zusammen 95 %. Helvetica Neue taucht nur
|
||||
// in den Visitenkarten-Mustern und im Claim auf.
|
||||
//
|
||||
// DIN laesst sich hier trotzdem nicht einsetzen: FF DIN und DIN Next sind
|
||||
// lizenzpflichtig, und eine Druck-Lizenz deckt die Auslieferung als Webfont
|
||||
// nicht ab — das ist eine eigene, nach Domain oder Abrufen bemessene Lizenz.
|
||||
// Die freien Nachbauten der DIN 1451 sind Schilderschriften: enge Punzen,
|
||||
// wenige Schnitte, und bei 14 px in einer Tabelle mit achthundert Zeilen
|
||||
// schlechter lesbar als das, was sie ersetzen sollen.
|
||||
//
|
||||
// Barlow ist die naechstliegende freie Schrift mit UI-Tauglichkeit: dieselbe
|
||||
// Grotesk-Familie, schmale Proportionen, geringer Strichkontrast, gerade
|
||||
// Abschluesse — und neun Schnitte mit vollem Latin-Extended, also allen
|
||||
// Umlauten.
|
||||
//
|
||||
// Die Variable heisst nach der Absicht und nicht nach dem Ersatz: liegt
|
||||
// eines Tages eine Web-Lizenz fuer DIN vor, ist der Wechsel diese eine
|
||||
// Deklaration und sonst nichts.
|
||||
const din = Barlow({
|
||||
variable: "--font-din",
|
||||
// latin-ext wegen der Umlaute und des scharfen S, die in Namen vorkommen.
|
||||
subsets: ["latin", "latin-ext"],
|
||||
weight: ["400", "600", "700", "800"],
|
||||
});
|
||||
|
||||
@@ -15,6 +37,11 @@ export const metadata: Metadata = {
|
||||
// Added to the home screen on iOS this opens without Safari's chrome and
|
||||
// keeps the app's own name rather than the page title.
|
||||
appleWebApp: { capable: true, title: "Alpenwerk HR", statusBarStyle: "default" },
|
||||
// app/favicon.ico, app/icon.svg und app/apple-icon.png findet Next von
|
||||
// selbst; die Datei-Konventionen brauchen keinen Eintrag hier. Der Verweis
|
||||
// auf das Manifest dagegen schon — app/manifest.webmanifest wird sonst
|
||||
// zwar ausgeliefert, aber von keiner Seite genannt.
|
||||
manifest: "/manifest.webmanifest",
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
@@ -23,7 +50,9 @@ export const viewport: Viewport = {
|
||||
// The layout extends under the notch and home indicator; every edge that
|
||||
// matters pads itself back out with env(safe-area-inset-*).
|
||||
viewportFit: "cover",
|
||||
themeColor: "#ffffff",
|
||||
// Manner Rosa (CD §1.1): die Farbe, in der mobile Browser ihre eigene
|
||||
// Leiste ueber der Seite einfaerben. Weiss war hier nur die Vorgabe.
|
||||
themeColor: "#F69686",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -32,7 +61,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="de-AT" className={`${nunito.variable} h-full antialiased`}>
|
||||
<html lang="de-AT" className={`${din.variable} h-full antialiased`}>
|
||||
<body className="flex min-h-dvh flex-col font-sans">
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</body>
|
||||
|
||||
29
app/manifest.webmanifest
Normal file
29
app/manifest.webmanifest
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "Alpenwerk HR",
|
||||
"short_name": "Alpenwerk HR",
|
||||
"description": "Interne HR-Stammdatenverwaltung",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/brand/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/brand/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/brand/icon-512-maskable.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#F69686",
|
||||
"background_color": "#FDF6F4",
|
||||
"display": "standalone",
|
||||
"start_url": "/"
|
||||
}
|
||||
57
app/passwort-aendern/page.tsx
Normal file
57
app/passwort-aendern/page.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { PasswortAendernFormular } from "@/components/auth/PasswortAendernFormular";
|
||||
import { CARD_CLASS } from "@/components/ui/Card";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { sql, withUser } from "@/lib/db";
|
||||
|
||||
// Diese Seite liegt **ausserhalb** der Gruppe (app), und das ist der Punkt.
|
||||
//
|
||||
// app/(app)/layout.tsx lässt nur durch, wer aktive HR-Person ist — und genau
|
||||
// das ist man nicht, solange ein Passwortwechsel aussteht: is_hr_user() liefert
|
||||
// dann false (Migration 20260908120000). Läge diese Seite unter (app), wäre sie
|
||||
// für die einzigen Menschen gesperrt, die sie brauchen.
|
||||
//
|
||||
// Angemeldet sein muss man trotzdem: darum kümmert sich proxy.ts, der jede
|
||||
// Adresse ausser /login und /api/auth hinter eine Sitzung stellt. Die Prüfung
|
||||
// unten ist die zweite Linie für den Fall, dass jemand den Matcher ändert.
|
||||
|
||||
export const metadata = { title: "Passwort ändern" };
|
||||
|
||||
export default async function PasswortAendernPage() {
|
||||
const userId = await currentUserId();
|
||||
if (!userId) redirect("/login");
|
||||
|
||||
// Erzwungen oder freiwillig — davon hängt nur die Beschriftung ab und ob es
|
||||
// einen Weg zurück gibt.
|
||||
//
|
||||
// Ob dieses Konto überhaupt ein Passwort hat, wird hier *nicht* gefragt:
|
||||
// app_passwoerter ist für die Anwendungsrolle unerreichbar (Policy
|
||||
// `using (false)`), und eine Auskunftsfunktion nur für diese Anzeige wäre
|
||||
// eine Funktion zu viel. Wer kein Passwort hat, bekommt beim Absenden die
|
||||
// Meldung aus app_passwort_aendern().
|
||||
const erzwungen = await withUser(userId, async (tx) => {
|
||||
const ergebnis = await sql<{ wechseln: boolean }>`select app_muss_passwort_wechseln() as wechseln`.execute(tx);
|
||||
return ergebnis.rows[0]?.wechseln === true;
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="flex min-h-dvh items-center justify-center px-6 py-12">
|
||||
<div className="w-full max-w-sm">
|
||||
<h1 className="text-2xl font-extrabold tracking-tight text-ink">Passwort ändern</h1>
|
||||
|
||||
{erzwungen ? (
|
||||
<p className="mt-1.5 text-sm text-ink-muted">
|
||||
Ihr Zugang wurde mit einem gemeinsamen Startpasswort eingerichtet. Solange es gilt, bleiben alle
|
||||
Personaldaten verschlossen — bitte vergeben Sie jetzt ein eigenes.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1.5 text-sm text-ink-muted">Sie können hier jederzeit ein neues Passwort vergeben.</p>
|
||||
)}
|
||||
|
||||
<div className={`mt-7 p-5 ${CARD_CLASS}`}>
|
||||
<PasswortAendernFormular erzwungen={erzwungen} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
75
auth.ts
75
auth.ts
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
|
||||
@@ -9,6 +10,14 @@ import { asSystem, sql } from "@/lib/db";
|
||||
// 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,
|
||||
@@ -31,21 +40,81 @@ async function upsertAppUser(externalId: string, email: string, fullName: string
|
||||
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 }) {
|
||||
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.
|
||||
if (!profile) return token;
|
||||
|
||||
const externalId = typeof profile.oid === "string" ? profile.oid : null;
|
||||
const email = [profile.email, profile.preferred_username, profile.upn].find(
|
||||
(v): v is string => typeof v === "string" && v.length > 0
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useState } from "react";
|
||||
import { AenderungsTabelle } from "@/components/ui/AenderungsTabelle";
|
||||
import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { actionBadgeStyle } from "@/lib/colors";
|
||||
import type { AuditChange } from "@/lib/supabase/types";
|
||||
import type { AuditChange } from "@/lib/types";
|
||||
|
||||
// Eine Protokollzeile zum Aufklappen.
|
||||
//
|
||||
@@ -49,7 +49,7 @@ export function AuditDetail({ eintrag }: { eintrag: AuditEintrag }) {
|
||||
>
|
||||
<span>{eintrag.details ?? "–"}</span>
|
||||
{anzahl > 0 && (
|
||||
<span className="ml-2 whitespace-nowrap rounded-full bg-brand-50 px-2 py-0.5 text-[11px] font-semibold text-brand-700">
|
||||
<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>
|
||||
)}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
import { actionBadgeStyle } from "@/lib/colors";
|
||||
import { ANSTEHEND_STYLES } from "@/lib/colors";
|
||||
import {
|
||||
ANSTEHEND_ARTEN,
|
||||
STANDARD_ZEITRAUM,
|
||||
@@ -57,7 +57,7 @@ export function AnstehendFilter({ zeitraum, arten }: { zeitraum: Zeitraum; arten
|
||||
onClick={() => artUmschalten(a.value)}
|
||||
aria-pressed={aktiv}
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
|
||||
aktiv ? actionBadgeStyle(a.label) : "bg-surface text-ink-muted hover:text-ink"
|
||||
aktiv ? ANSTEHEND_STYLES[a.value] : "bg-surface text-ink-muted hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{a.label}
|
||||
|
||||
148
components/dashboard/AnstehendListe.tsx
Normal file
148
components/dashboard/AnstehendListe.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
|
||||
export const KIND_LABEL = {
|
||||
hire: "Eintritt",
|
||||
exit: "Austritt",
|
||||
return: "Rückkehr aus Abwesenheit",
|
||||
note: "Wiedervorlage",
|
||||
} as const;
|
||||
|
||||
export type AnstehendEintrag = {
|
||||
id: string;
|
||||
/** Ziel des Klicks — bei einer Wiedervorlage die Akte, nicht die Notiz. */
|
||||
employeeId: string;
|
||||
label: string;
|
||||
/** Zweite Zeile: bei einer Wiedervorlage der Notiztext statt der Art. */
|
||||
hinweis?: string;
|
||||
date: string;
|
||||
kind: keyof typeof KIND_LABEL;
|
||||
};
|
||||
|
||||
/** Wie viele Zeilen die Karte zeigt, bevor aufgeklappt wird. */
|
||||
export const VORSCHAU = 8;
|
||||
|
||||
/**
|
||||
* Die Karte bleibt eine Übersicht — acht Zeilen, und darunter steht, wie
|
||||
* viele es insgesamt sind. Neu ist, dass diese Zeile ein Knopf ist: wer alle
|
||||
* sehen will, klappt sie auf, statt den Zeitraum enger zu stellen und die
|
||||
* Antwort damit zu verändern.
|
||||
*
|
||||
* Das geschieht im Browser und nicht über die Adresse, anders als bei
|
||||
* Zeitraum und Art: die Einträge sind bereits alle geladen, es gibt nichts
|
||||
* nachzuholen. Ein Umweg über den Server brächte eine Wartezeit für Daten,
|
||||
* die schon da sind — und einen Eintrag im Verlauf für etwas, das niemand
|
||||
* zurückgehen will.
|
||||
*/
|
||||
export function AnstehendListe({
|
||||
eintraege,
|
||||
today,
|
||||
leerText,
|
||||
}: {
|
||||
eintraege: AnstehendEintrag[];
|
||||
today: string;
|
||||
leerText: string;
|
||||
}) {
|
||||
const [alle, setAlle] = useState(false);
|
||||
// Namensfilter aus dem Workshop (Anforderung 2). Im Browser und nicht über
|
||||
// die Adresse, aus demselben Grund wie das Aufklappen: die Einträge sind
|
||||
// vollständig geladen, es gibt nichts nachzuholen.
|
||||
const [suche, setSuche] = useState("");
|
||||
const begriff = suche.trim().toLowerCase();
|
||||
// Wortweise und am Wortanfang — dieselbe Regel wie in der Mitarbeiterliste,
|
||||
// damit „gru" Gruber findet und nicht auch jede Notiz mit „Arbeitsgruppe".
|
||||
// Der Hinweis wird bewusst nicht durchsucht: gesucht wird nach Personen.
|
||||
const gefiltert = begriff
|
||||
? eintraege.filter((e) =>
|
||||
e.label
|
||||
.toLowerCase()
|
||||
.split(/[\s,;/-]+/)
|
||||
.some((wort) => wort.startsWith(begriff))
|
||||
)
|
||||
: eintraege;
|
||||
const sichtbar = alle ? gefiltert : gefiltert.slice(0, VORSCHAU);
|
||||
const weitere = gefiltert.length - sichtbar.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Immer da, sobald überhaupt etwas ansteht.
|
||||
*
|
||||
Zuerst stand hier eine Schwelle — erst ab neun Einträgen, weil man
|
||||
darunter die ganze Liste ohnehin sieht. In der Bedienung war das
|
||||
falsch: das Feld erschien bei 180 Tagen und verschwand bei 30, und
|
||||
ein Bedienelement, das je nach Zeitraum da ist oder nicht, wirkt wie
|
||||
ein Fehler. Wer sucht, sucht auch in sieben Zeilen. */}
|
||||
{eintraege.length > 0 && (
|
||||
<input
|
||||
type="search"
|
||||
value={suche}
|
||||
onChange={(e) => setSuche(e.target.value)}
|
||||
placeholder="Name filtern…"
|
||||
aria-label="Anstehende Ereignisse nach Name filtern"
|
||||
className="mb-2 w-full rounded border border-border-strong bg-white px-2.5 py-1.5 text-sm text-ink
|
||||
placeholder:text-ink-muted focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
|
||||
/>
|
||||
)}
|
||||
<ul className="flex flex-col divide-y divide-border-subtle">
|
||||
{sichtbar.map((item) => {
|
||||
// Überfällig gibt es nur bei Wiedervorlagen: die anderen Arten
|
||||
// haben eine untere Grenze, eine offene Aufgabe nicht.
|
||||
const ueberfaellig = item.date < today;
|
||||
return (
|
||||
<li key={`${item.kind}-${item.id}`}>
|
||||
<Link
|
||||
href={`/employees/${item.employeeId}`}
|
||||
className="-mx-2 flex items-center justify-between gap-2 rounded px-2 py-2.5 text-sm hover:bg-surface"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-semibold text-ink">{item.label}</span>
|
||||
<span className="block truncate text-xs text-ink-muted">
|
||||
{KIND_LABEL[item.kind]}
|
||||
{item.hinweis ? ` — ${item.hinweis}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={`shrink-0 text-xs font-semibold tabular-nums ${
|
||||
ueberfaellig ? "text-danger-text" : "text-ink-muted"
|
||||
}`}
|
||||
>
|
||||
{ueberfaellig ? "überfällig " : ""}
|
||||
{fmtDate(item.date)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{eintraege.length === 0 && <p className="py-2 text-sm text-ink-muted">{leerText}</p>}
|
||||
{/* Ein leeres Ergebnis mit Suchbegriff ist etwas anderes als eine
|
||||
leere Karte: der Zeitraum stimmt, nur der Name passt nicht. */}
|
||||
{eintraege.length > 0 && gefiltert.length === 0 && (
|
||||
<p className="py-2 text-sm text-ink-muted">
|
||||
Kein Name passt zu „{suche.trim()}“. {eintraege.length}{" "}
|
||||
{eintraege.length === 1 ? "Ereignis steht" : "Ereignisse stehen"} an.
|
||||
</p>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{/* Der Knopf steht nur da, wenn es etwas zu holen gibt. Ist die Liste
|
||||
aufgeklappt, führt er wieder zurück — sonst bliebe die Karte für den
|
||||
Rest der Sitzung lang, auch wenn jemand nur einmal nachsehen
|
||||
wollte. */}
|
||||
{(weitere > 0 || alle) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAlle((v) => !v)}
|
||||
aria-expanded={alle}
|
||||
className="mt-2 rounded text-xs font-semibold text-brand-700 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
{alle
|
||||
? "Weniger anzeigen"
|
||||
: `… und ${weitere} ${weitere === 1 ? "weiteres Ereignis" : "weitere Ereignisse"} anzeigen`}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Lock, Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
import { deleteHireDraft } from "@/actions/hireDrafts";
|
||||
import { useHireWizard } from "@/components/hire/HireWizardContext";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import type { Entwurf } from "@/lib/entwuerfe";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
|
||||
type Draft = { id: string; step: number; payload: Record<string, unknown>; updated_at: string };
|
||||
// Angefangene Neueinstellungen — die eigenen und, wer in der Glocke
|
||||
// hinzugewählt ist, auch dessen.
|
||||
//
|
||||
// Fortsetzen geht bei beiden: wer vertreten wird, soll die angefangene
|
||||
// Einstellung zu Ende bringen können. Aber immer nur eine Person zugleich —
|
||||
// der Entwurf ist ein einziges JSONB-Feld, zwei gleichzeitig Schreibende
|
||||
// überschrieben einander vollständig, und die Zweite merkte nichts davon.
|
||||
// Die Sperre dazu steht in der Datenbank (20260910160000); hier steht nur,
|
||||
// was sie für die Zeile bedeutet.
|
||||
//
|
||||
// Löschen bleibt beim Eigentümer. Die Regel liesse mehr zu — sie muss, weil
|
||||
// der Assistent den Entwurf nach dem Anlegen selbst wegräumt —, aber eine
|
||||
// Schaltfläche, die fremde halbfertige Arbeit wegwirft, gehört nicht neben
|
||||
// eine, die sie fortsetzt.
|
||||
|
||||
export function DraftsCard({ drafts }: { drafts: Draft[] }) {
|
||||
export function DraftsCard({ drafts }: { drafts: Entwurf[] }) {
|
||||
const { openWizard } = useHireWizard();
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
@@ -39,24 +53,46 @@ export function DraftsCard({ drafts }: { drafts: Draft[] }) {
|
||||
const lastName = typeof d.payload.lastName === "string" ? d.payload.lastName : "";
|
||||
const name = [firstName, lastName].filter(Boolean).join(" ") || "Ohne Namen";
|
||||
return (
|
||||
<li key={d.id} className="flex items-center justify-between py-2 text-sm">
|
||||
<li key={d.id} className="flex items-center justify-between gap-3 py-2 text-sm">
|
||||
<div>
|
||||
<span className="font-semibold text-ink">{name}</span>
|
||||
<span className="ml-2 text-xs text-ink-muted">Gespeichert am {fmtDate(d.updated_at)}</span>
|
||||
{/* An jeder Zeile, wie an den Notizen in der Glocke: seit
|
||||
fremde Entwürfe dazwischenstehen, ist ohne die Angabe nicht
|
||||
zu sehen, welcher wessen ist. „von mir" statt des eigenen
|
||||
Namens — den kennt man. */}
|
||||
<span className="ml-2 text-xs text-ink-muted">{d.vonMir ? "von mir" : `von ${d.autor}`}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" onClick={() => openWizard({ draftId: d.id })} className="text-xs font-semibold text-brand-700 hover:underline">
|
||||
Fortsetzen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(d.id)}
|
||||
disabled={pending}
|
||||
aria-label="Entwurf löschen"
|
||||
className="text-ink-muted hover:text-danger-solid"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
{d.gesperrtVon ? (
|
||||
// Mit dem Grund daneben, nicht bloss abgeschaltet: eine
|
||||
// graue Schaltfläche ohne Erklärung liest sich wie ein
|
||||
// Fehler, und die nächste Handlung wäre, es gleich noch
|
||||
// einmal zu versuchen.
|
||||
<span className="flex shrink-0 items-center gap-1 text-xs text-ink-muted">
|
||||
<Lock className="h-3.5 w-3.5" />
|
||||
{d.gesperrtVon} bearbeitet gerade
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openWizard({ draftId: d.id })}
|
||||
className="text-xs font-semibold text-brand-700 hover:underline"
|
||||
>
|
||||
Fortsetzen
|
||||
</button>
|
||||
)}
|
||||
{d.vonMir && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(d.id)}
|
||||
disabled={pending || Boolean(d.gesperrtVon)}
|
||||
aria-label="Entwurf löschen"
|
||||
className="text-ink-muted hover:text-danger-solid disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { SelectField, TextField } from "@/components/ui/Field";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { todayIso } from "@/lib/format";
|
||||
import type { RelationshipType } from "@/lib/supabase/types";
|
||||
import type { RelationshipType } from "@/lib/types";
|
||||
|
||||
const RELATIONSHIPS: RelationshipType[] = ["Ehepartner:in", "Lebenspartner:in", "Kind", "Sonstige"];
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { deleteEmployeeDependent } from "@/actions/employees";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { fmtDate, todayIso } from "@/lib/format";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
import type { Database } from "@/lib/types";
|
||||
import { AddDependentModal } from "./AddDependentModal";
|
||||
|
||||
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
|
||||
@@ -52,7 +52,7 @@ export function AngehoerigeSection({ employeeId, dependents, effectiveDate }: {
|
||||
<div className="overflow-x-auto rounded border border-border">
|
||||
<table className="w-full min-w-[600px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-brand-50 text-left text-xs font-semibold uppercase tracking-wide text-ink-muted">
|
||||
<tr className="border-b border-border bg-accent-50 text-left text-xs font-semibold uppercase tracking-wide text-ink-muted">
|
||||
<th className="px-4 py-2.5">Name</th>
|
||||
<th className="px-4 py-2.5">Verhältnis</th>
|
||||
<th className="px-4 py-2.5">SVNR</th>
|
||||
|
||||
@@ -6,17 +6,23 @@ import { useState } from "react";
|
||||
import { Avatar } from "@/components/ui/Avatar";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { StatusChip } from "@/components/ui/StatusChip";
|
||||
import type { AufgabenStand } from "@/lib/checklist";
|
||||
import { fmtFullName, tenure } from "@/lib/format";
|
||||
import { fortschritt as fortschrittOffboarding, gehoertOffboarding } from "@/lib/offboarding";
|
||||
import { fortschritt as fortschrittOnboarding } from "@/lib/onboarding";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
import { deriveStatusAsOf } from "@/lib/reports";
|
||||
import type { Database } from "@/lib/types";
|
||||
import { DatenAendernPanel } from "./panels/DatenAendernPanel";
|
||||
import { KarenzPanel } from "./panels/KarenzPanel";
|
||||
import { PromotePanel } from "./panels/PromotePanel";
|
||||
import { RehirePanel } from "./panels/RehirePanel";
|
||||
import { RehireWizard } from "../hire/RehireWizard";
|
||||
import { TerminatePanel } from "./panels/TerminatePanel";
|
||||
import { TransferPanel } from "./panels/TransferPanel";
|
||||
import { HistorieTab } from "./tabs/HistorieTab";
|
||||
import { NotizenTab } from "./tabs/NotizenTab";
|
||||
import { OffboardingTab } from "./tabs/OffboardingTab";
|
||||
import { OnboardingTab } from "./tabs/OnboardingTab";
|
||||
import { OrganisationTab } from "./tabs/OrganisationTab";
|
||||
import { StammdatenTab } from "./tabs/StammdatenTab";
|
||||
import { VertragTab } from "./tabs/VertragTab";
|
||||
@@ -36,6 +42,10 @@ type EmployeeDetailProps = {
|
||||
breadcrumb: string;
|
||||
/** Die Kostenstelle der laufenden Planstellenbesetzung. */
|
||||
kostenstelle: { code: string; name: string } | null;
|
||||
/** Der Stand der Onboarding-Checkliste; leer heisst: es gibt keine. */
|
||||
onboarding: AufgabenStand[];
|
||||
/** Der Stand der Offboarding-Checkliste; leer heisst: es gibt keine. */
|
||||
offboarding: AufgabenStand[];
|
||||
manager: MiniEmployee | null;
|
||||
/** Nur gesetzt, wenn die zuständige Leitung abwesend ist und vertreten wird. */
|
||||
formalManager: MiniEmployee | null;
|
||||
@@ -45,20 +55,37 @@ type EmployeeDetailProps = {
|
||||
notes: NoteRow[];
|
||||
locations: Location[];
|
||||
openPositions: OpenPositionResolved[];
|
||||
/** Der Tag, zu dem die Akte gelesen wurde — derselbe wie in loadEmployeeDetail. */
|
||||
today: string;
|
||||
};
|
||||
|
||||
type PanelType = "transfer" | "promote" | "karenz" | "daten" | "terminate" | "rehire" | null;
|
||||
const TABS = ["Stammdaten", "Vertrag", "Organisation", "Historie", "HR-Notizen"] as const;
|
||||
const ALLE_TABS = ["Stammdaten", "Vertrag", "Organisation", "Onboarding", "Offboarding", "Historie", "HR-Notizen"] as const;
|
||||
type Tab = (typeof ALLE_TABS)[number];
|
||||
|
||||
export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
const { employee, placement, breadcrumb, kostenstelle, manager, formalManager, directReports, history, dependents, notes, locations, openPositions } = props;
|
||||
const [tab, setTab] = useState<(typeof TABS)[number]>("Stammdaten");
|
||||
const { employee, placement, breadcrumb, kostenstelle, onboarding, offboarding, manager, formalManager, directReports, history, dependents, notes, locations, openPositions, today } = props;
|
||||
const [tab, setTab] = useState<Tab>("Stammdaten");
|
||||
const [panel, setPanel] = useState<PanelType>(null);
|
||||
|
||||
const location = locations.find((l) => l.id === employee.location_id);
|
||||
// Am Reiter steht, was noch aussteht — sonst müsste man hineinsehen, um zu
|
||||
// erfahren, dass nichts zu tun ist.
|
||||
const offeneOnboarding = onboarding.length > 0 ? fortschrittOnboarding(onboarding).gesamt - fortschrittOnboarding(onboarding).erledigt : 0;
|
||||
const offeneOffboarding = offboarding.length > 0 ? fortschrittOffboarding(offboarding).gesamt - fortschrittOffboarding(offboarding).erledigt : 0;
|
||||
|
||||
const isActive = employee.status === "Aktiv" || employee.status === "Karenz";
|
||||
const canEditData = employee.status !== "Ausgetreten";
|
||||
const zeigtOffboarding = gehoertOffboarding(employee);
|
||||
const tabs = ALLE_TABS.filter((t) => t !== "Offboarding" || zeigtOffboarding);
|
||||
|
||||
// Abgeleitet, nicht aus `employee.status` gelesen. Die Spalte hängt nach,
|
||||
// wenn ein Austritt mit einem damals künftigen Datum erfasst wurde — es gibt
|
||||
// keinen Lauf, der sie später nachzieht (Migration 20260814100000). Hier
|
||||
// entschied sie nicht nur über eine Beschriftung, sondern über die Knöpfe:
|
||||
// an einer Person, die seit zwei Wochen ausgetreten ist, stand „Austritt"
|
||||
// weiter zur Verfügung, und „Daten ändern" ebenfalls.
|
||||
const status = deriveStatusAsOf(employee, today);
|
||||
const isActive = status === "Aktiv" || status === "Karenz";
|
||||
const canEditData = status !== "Ausgetreten";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -75,7 +102,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
<h2 className="text-xl font-extrabold text-ink">
|
||||
{fmtFullName(employee.first_name, employee.last_name, employee.title_prefix, employee.title_suffix)}
|
||||
</h2>
|
||||
<StatusChip status={employee.status} entryDate={employee.entry_date} absenceType={employee.absence_type} />
|
||||
<StatusChip employee={employee} asOf={today} />
|
||||
</div>
|
||||
<p className="text-sm text-ink-body">{placement?.jobTitle ?? employee.job_title}</p>
|
||||
<p className="text-xs text-ink-muted">
|
||||
@@ -90,7 +117,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-ink-muted">
|
||||
Pers.-Nr. {employee.personnel_number}
|
||||
{employee.status !== "Geplant" && <> · Zugehörigkeit: {tenure(employee.entry_date, employee.exit_date)}</>}
|
||||
{status !== "Geplant" && <> · Zugehörigkeit: {tenure(employee.entry_date, employee.exit_date)}</>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -102,7 +129,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
<ActionButton icon={TrendingUp} label="Befördern" onClick={() => setPanel("promote")} />
|
||||
<ActionButton
|
||||
icon={Clock}
|
||||
label={employee.status === "Karenz" ? "Abwesenheit verwalten" : "Langzeitabwesenheit"}
|
||||
label={status === "Karenz" ? "Abwesenheit verwalten" : "Langzeitabwesenheit"}
|
||||
onClick={() => setPanel("karenz")}
|
||||
/>
|
||||
</>
|
||||
@@ -113,7 +140,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
noch keinen Tag gearbeitet, und genau dafür gibt es den Grund
|
||||
„No Show" — ohne diesen Knopf bliebe sie auf Dauer als
|
||||
geplanter Eintritt stehen. */}
|
||||
{(isActive || employee.status === "Geplant") && (
|
||||
{(isActive || status === "Geplant") && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@@ -121,12 +148,22 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
className="!border-danger-solid !text-danger-solid hover:!bg-danger-bg"
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
{employee.status === "Geplant" ? "Nicht angetreten" : "Austritt"}
|
||||
{status === "Geplant" ? "Nicht angetreten" : "Austritt"}
|
||||
</Button>
|
||||
)}
|
||||
{employee.status === "Ausgetreten" && (
|
||||
{/* Der Knopf hing an `employee.status` — der Spalte, die
|
||||
nachhängt. An einer Person, deren Austritt erfasst und
|
||||
inzwischen vollzogen war, stand dort weiter „Aktiv", und der
|
||||
Wiedereintritt war schlicht nicht erreichbar. Der Kunde hat
|
||||
ihn deshalb für verschwunden gehalten („ich dachte, das ist
|
||||
schon implementiert, das war mal drin").
|
||||
*
|
||||
Beim Statusfix waren nur `isActive` und `canEditData`
|
||||
umgestellt worden — die fünf einzelnen Abfragen in dieser
|
||||
Datei blieben stehen. Jetzt liest keine mehr die Spalte. */}
|
||||
{status === "Ausgetreten" && (
|
||||
<Button size="sm" onClick={() => setPanel("rehire")}>
|
||||
<RotateCcw className="h-4 w-4" /> Wiedereinstellen
|
||||
<RotateCcw className="h-4 w-4" /> Wiedereintritt
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -136,7 +173,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
{/* Tabs, so the list gets the tablist role and each button says
|
||||
whether it is the selected one. */}
|
||||
<div role="tablist" aria-label="Mitarbeiterdetails" className="flex gap-1 overflow-x-auto border-b border-border">
|
||||
{TABS.map((t) => (
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
role="tab"
|
||||
@@ -146,7 +183,13 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
tab === t ? "border-brand-500 text-brand-700" : "border-transparent text-ink-muted hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{t === "HR-Notizen" ? `HR-Notizen ${notes.length}` : t}
|
||||
{t === "HR-Notizen"
|
||||
? `HR-Notizen ${notes.length}`
|
||||
: t === "Onboarding" && offeneOnboarding > 0
|
||||
? `Onboarding ${offeneOnboarding}`
|
||||
: t === "Offboarding" && offeneOffboarding > 0
|
||||
? `Offboarding ${offeneOffboarding}`
|
||||
: t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -164,6 +207,12 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
kostenstelle={kostenstelle}
|
||||
/>
|
||||
)}
|
||||
{tab === "Onboarding" && (
|
||||
<OnboardingTab employeeId={employee.id} staende={onboarding} vorhanden={onboarding.length > 0} />
|
||||
)}
|
||||
{tab === "Offboarding" && zeigtOffboarding && (
|
||||
<OffboardingTab employeeId={employee.id} staende={offboarding} vorhanden={offboarding.length > 0} />
|
||||
)}
|
||||
{tab === "Historie" && <HistorieTab history={history} employeeId={employee.id} />}
|
||||
{tab === "HR-Notizen" && <NotizenTab employeeId={employee.id} notes={notes} />}
|
||||
</div>
|
||||
@@ -175,7 +224,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
openPositions={openPositions}
|
||||
/>
|
||||
<PromotePanel open={panel === "promote"} onClose={() => setPanel(null)} employee={employee} />
|
||||
<KarenzPanel open={panel === "karenz"} onClose={() => setPanel(null)} employee={employee} />
|
||||
<KarenzPanel open={panel === "karenz"} onClose={() => setPanel(null)} employee={employee} status={status} />
|
||||
<DatenAendernPanel
|
||||
open={panel === "daten"}
|
||||
onClose={() => setPanel(null)}
|
||||
@@ -183,13 +232,27 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
dependents={dependents}
|
||||
locationCountry={location?.country}
|
||||
/>
|
||||
<TerminatePanel open={panel === "terminate"} onClose={() => setPanel(null)} employee={employee} directReportCount={directReports.length} />
|
||||
<RehirePanel
|
||||
open={panel === "rehire"}
|
||||
<TerminatePanel
|
||||
open={panel === "terminate"}
|
||||
onClose={() => setPanel(null)}
|
||||
employee={employee}
|
||||
openPositions={openPositions}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,11 @@ type EmployeeFiltersProps = {
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "Aktiv", label: "Aktiv" },
|
||||
{ value: "Karenz", label: "Langzeitabwesenheit" },
|
||||
{ value: "Aktiv,Karenz", label: "Aktiv + Langzeitabwesenheit (beschäftigt)" },
|
||||
// Der Name der Kachel zuerst, die Bestandteile in Klammern dahinter: die
|
||||
// Kachel „Aktives Dienstverhältnis" verlinkt genau auf diese Auswahl, und
|
||||
// wer ihr folgt, soll oben dieselbe Bezeichnung wiederfinden statt eine
|
||||
// zweite für dieselbe Sache.
|
||||
{ value: "Aktiv,Karenz", label: "Aktives Dienstverhältnis (Aktiv + Langzeitabwesenheit)" },
|
||||
{ value: "Geplant", label: "Geplant" },
|
||||
{ value: "Ausgetreten", label: "Ausgetreten" },
|
||||
] as const;
|
||||
@@ -104,6 +108,9 @@ export function EmployeeFilters({ units, depthOf, locations }: EmployeeFiltersPr
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* Sortiert wird nicht hier, sondern an den Spaltenköpfen der Liste.
|
||||
Diese Leiste schränkt ein, *was* zu sehen ist; die Reihenfolge
|
||||
gehört an die Spalte, die sie bestimmt. */}
|
||||
{/* Der Dienstwagen stand hier einmal als eigenes Auswahlfeld. Er ist
|
||||
jetzt eines von rund zwanzig Kriterien unter Berichte, zusammen mit
|
||||
Vertragsart, Kollektivvertrag, Eintrittszeitraum und dem Rest —
|
||||
|
||||
@@ -9,7 +9,7 @@ import { TextField } from "@/components/ui/Field";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import type { AuditChange } from "@/lib/supabase/types";
|
||||
import type { AuditChange } from "@/lib/types";
|
||||
|
||||
// Berichtigen, nicht neu erfassen.
|
||||
//
|
||||
|
||||
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,12 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { SelectField, TextField } from "@/components/ui/Field";
|
||||
import type { CollectiveAgreement, DienstwagenArt, Weekday, WorkerType } from "@/lib/supabase/types";
|
||||
import {
|
||||
BEGUENSTIGUNG_AB_GRAD,
|
||||
GRUND_BEGUENSTIGT_BEHINDERT,
|
||||
KUENDIGUNGSSCHUTZ_GRUENDE,
|
||||
} from "@/lib/kuendigungsschutz";
|
||||
import { MITARBEITERARTEN } from "@/lib/mitarbeiterart";
|
||||
import { sortiereWochentage, WOCHENTAGE } from "@/lib/wochentage";
|
||||
import type { CollectiveAgreement, DienstwagenArt, Mitarbeiterart, SourceType, Weekday, WorkerType } from "@/lib/types";
|
||||
|
||||
const WEEKDAYS: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||
|
||||
export type RoleEmploymentValue = {
|
||||
workerType: WorkerType;
|
||||
/**
|
||||
* Form der Beschäftigung — zweite Achse neben der Gruppe. Ein Praktikant
|
||||
* kann Arbeiter oder Angestellter sein; in einem Feld liesse sich nur eines
|
||||
* von beidem sagen.
|
||||
*/
|
||||
mitarbeiterart: Mitarbeiterart;
|
||||
/**
|
||||
* Extern oder intern besetzt.
|
||||
*
|
||||
* Wurde bisher nur bei der Neueinstellung gesetzt und war danach
|
||||
* unerreichbar — eine Angabe, die mit dem ersten Tag erstarrt, obwohl
|
||||
* gerade ihr Wechsel der Vorgang ist, um den es geht: von extern auf
|
||||
* intern ist eine **Übernahme** und wird als eigenes Ereignis protokolliert
|
||||
* (Migration 20260917120000).
|
||||
*
|
||||
* Nicht gesetzt heisst: dieses Formular fragt die Besetzungsart nicht. So
|
||||
* ist es im Einstellungsassistenten — dort steht sie im Schritt „Position",
|
||||
* wo sie hingehört, und zweimal danach zu fragen wäre eine Einladung, zwei
|
||||
* verschiedene Antworten zu geben.
|
||||
*/
|
||||
source?: SourceType;
|
||||
collectiveAgreement: CollectiveAgreement;
|
||||
workDays: Weekday[];
|
||||
isBetriebsrat: boolean;
|
||||
@@ -30,28 +57,91 @@ export type RoleEmploymentValue = {
|
||||
* einer erfundenen Zahl. Leer heisst „bis auf Weiteres".
|
||||
*/
|
||||
kuendigungsschutzBis: string;
|
||||
/**
|
||||
* Der Personenkreis — freiwillig, aus demselben Grund wie das Enddatum:
|
||||
* der Bestand trägt das Kennzeichen seit 20260814120000 ohne Grund, und ein
|
||||
* Pflichtfeld hätte jede Änderung an diesen Zeilen blockiert.
|
||||
*/
|
||||
kuendigungsschutzGrund: string;
|
||||
/** Beginn des Schutzes — ebenfalls freiwillig. */
|
||||
kuendigungsschutzAb: string;
|
||||
/**
|
||||
* Begünstigte Behinderung. Eigenes Kennzeichen und nicht aus dem
|
||||
* Personenkreis abgeleitet: die Begünstigung besteht unabhängig davon, ob
|
||||
* sie als Kündigungsschutz geführt wird. Die Oberfläche stellt den
|
||||
* Zusammenhang her, die Datenbank koppelt die beiden nicht.
|
||||
*/
|
||||
istBeguenstigtBehindert: boolean;
|
||||
/** Grad in Prozent laut Bescheid. Als Text, weil ein leeres Zahlenfeld sonst 0 wäre. */
|
||||
behinderungGrad: string;
|
||||
behinderungAb: string;
|
||||
behinderungBis: string;
|
||||
};
|
||||
|
||||
// Shared by the hire wizard (StepVertrag) and DatenAendernPanel — both edit
|
||||
// the same set of employees columns, just against different local state.
|
||||
export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymentValue; onChange: (patch: Partial<RoleEmploymentValue>) => void }) {
|
||||
function toggleWorkDay(day: Weekday) {
|
||||
onChange({ workDays: value.workDays.includes(day) ? value.workDays.filter((d) => d !== day) : [...value.workDays, day] });
|
||||
// Sortiert und nicht angehängt: sonst hinge die gespeicherte Reihenfolge
|
||||
// davon ab, in welcher die Knöpfe angeklickt wurden. „Mo, Di" und „Di,
|
||||
// Mo" waren damit zwei Werte für dieselbe Aussage, und wer die Tage nur
|
||||
// nachsah und wieder herstellte, erzeugte eine Vertragsänderung in der
|
||||
// Akte über nichts. Siehe lib/wochentage.ts.
|
||||
const neu = value.workDays.includes(day) ? value.workDays.filter((d) => d !== day) : [...value.workDays, day];
|
||||
onChange({ workDays: sortiereWochentage(neu) });
|
||||
}
|
||||
|
||||
// Der Personenkreis zieht das Kennzeichen mit: wer „Begünstigte behinderte
|
||||
// ArbeitnehmerInnen" als Grund wählt, hat die Frage damit schon beantwortet.
|
||||
// Eine Hilfe der Oberfläche, keine Bedingung der Datenbank — abwählen lässt
|
||||
// es sich weiterhin.
|
||||
const istBehindert = value.istBeguenstigtBehindert || value.kuendigungsschutzGrund === GRUND_BEGUENSTIGT_BEHINDERT;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{/* „Beschäftigtengruppe" statt „Angestellte:r / Arbeiter:in": ein
|
||||
Feld, das nach seinen Werten heisst, wird falsch, sobald ein
|
||||
dritter dazukommt. Den Namen führt der Import schon länger. */}
|
||||
<SelectField
|
||||
label="Angestellte:r / Arbeiter:in"
|
||||
label="Beschäftigtengruppe"
|
||||
dense
|
||||
value={value.workerType}
|
||||
onChange={(v) => onChange({ workerType: v as WorkerType })}
|
||||
options={[
|
||||
{ value: "Angestellte:r", label: "Angestellte:r" },
|
||||
{ value: "Arbeiter:in", label: "Arbeiter:in" },
|
||||
{ value: "Lehrling", label: "Lehrling" },
|
||||
]}
|
||||
/>
|
||||
{/* Zweites Feld und nicht dieselbe Liste erweitert: die Gruppe sagt,
|
||||
*als was* jemand beschäftigt ist, die Art *in welcher Form*. Beides
|
||||
gilt gleichzeitig — ein Praktikant ist Arbeiter oder Angestellter,
|
||||
nicht statt dessen. */}
|
||||
<SelectField
|
||||
label="Mitarbeiterart"
|
||||
dense
|
||||
value={value.mitarbeiterart}
|
||||
onChange={(v) => onChange({ mitarbeiterart: v as Mitarbeiterart })}
|
||||
options={MITARBEITERARTEN.map((a) => ({ value: a, label: a }))}
|
||||
/>
|
||||
{/* Der Wechsel von extern auf intern ist eine Übernahme und wird als
|
||||
eigenes Ereignis protokolliert — der Hinweis steht am Feld, damit
|
||||
niemand ihn erst im Protokoll entdeckt. Der umgekehrte Weg
|
||||
erzeugt nichts; das ist Absicht und keine Lücke. */}
|
||||
{value.source !== undefined && (
|
||||
<SelectField
|
||||
label="Besetzungsart"
|
||||
dense
|
||||
value={value.source}
|
||||
onChange={(v) => onChange({ source: v as SourceType })}
|
||||
options={[
|
||||
{ value: "Extern", label: "Extern" },
|
||||
{ value: "Intern", label: "Intern" },
|
||||
]}
|
||||
hint={value.source === "Extern" ? "Ein Wechsel auf „Intern“ wird als Übernahme protokolliert." : undefined}
|
||||
/>
|
||||
)}
|
||||
<SelectField
|
||||
label="Kollektivvertrag"
|
||||
dense
|
||||
@@ -70,7 +160,7 @@ export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymen
|
||||
<fieldset>
|
||||
<legend className="mb-1 block text-xs font-semibold text-ink-muted">Arbeitstage</legend>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{WEEKDAYS.map((day) => (
|
||||
{WOCHENTAGE.map((day) => (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
@@ -130,14 +220,85 @@ export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymen
|
||||
gibt. Anders als dort aber freiwillig — leer heisst „bis auf
|
||||
Weiteres", nicht „vergessen". */}
|
||||
{value.hasKuendigungsschutz && (
|
||||
<TextField
|
||||
label="Geschützt bis"
|
||||
dense
|
||||
type="date"
|
||||
value={value.kuendigungsschutzBis}
|
||||
onChange={(v) => onChange({ kuendigungsschutzBis: v })}
|
||||
hint="Optional. Leer lassen, solange das Ende nicht feststeht."
|
||||
/>
|
||||
<>
|
||||
<SelectField
|
||||
label="Personenkreis"
|
||||
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 })}
|
||||
hint={`In Prozent laut Bescheid. Die Begünstigung beginnt bei ${BEGUENSTIGUNG_AB_GRAD}%.`}
|
||||
/>
|
||||
<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>
|
||||
|
||||
@@ -14,9 +14,11 @@ import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { brauchtAufenthaltstitel, UN_COUNTRIES } from "@/lib/countries";
|
||||
import { STUNDEN_GRUENDE } from "@/lib/absence";
|
||||
import { GRUND_BEGUENSTIGT_BEHINDERT } from "@/lib/kuendigungsschutz";
|
||||
import { MITARBEITERART_STANDARD } from "@/lib/mitarbeiterart";
|
||||
import { fmtFullName, todayIso } from "@/lib/format";
|
||||
import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr";
|
||||
import { EMERGENCY_RELATIONS, type ContractType, type Database, type EmploymentType, type GenderType } from "@/lib/supabase/types";
|
||||
import { EMERGENCY_RELATIONS, type ContractType, type Database, type EmploymentType, type GenderType } from "@/lib/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
|
||||
@@ -80,12 +82,25 @@ export function DatenAendernPanel({
|
||||
|
||||
const [role, setRole] = useState<RoleEmploymentValue>({
|
||||
workerType: employee.worker_type ?? "Angestellte:r",
|
||||
mitarbeiterart: employee.mitarbeiterart ?? MITARBEITERART_STANDARD,
|
||||
// Hier gesetzt und im Assistenten nicht: dort steht die Besetzungsart im
|
||||
// Schritt „Position". Ihr Wechsel von extern auf intern ist eine
|
||||
// Übernahme und wird als eigenes Ereignis protokolliert.
|
||||
source: employee.source ?? "Extern",
|
||||
collectiveAgreement: employee.collective_agreement ?? "Handel",
|
||||
workDays: employee.work_days ?? ["Mo", "Di", "Mi", "Do", "Fr"],
|
||||
isBetriebsrat: employee.is_betriebsrat ?? false,
|
||||
hasDienstwagen: employee.has_dienstwagen ?? false,
|
||||
hasKuendigungsschutz: employee.has_kuendigungsschutz ?? false,
|
||||
kuendigungsschutzBis: employee.kuendigungsschutz_bis ?? "",
|
||||
kuendigungsschutzGrund: employee.kuendigungsschutz_grund ?? "",
|
||||
kuendigungsschutzAb: employee.kuendigungsschutz_ab ?? "",
|
||||
istBeguenstigtBehindert: employee.ist_beguenstigt_behindert ?? false,
|
||||
// Als Text und nicht als Zahl: ein leeres Zahlenfeld wäre 0, und 0
|
||||
// bedeutet hier „kein Bescheid", nicht „Grad null".
|
||||
behinderungGrad: employee.behinderung_grad === null ? "" : String(employee.behinderung_grad),
|
||||
behinderungAb: employee.behinderung_ab ?? "",
|
||||
behinderungBis: employee.behinderung_bis ?? "",
|
||||
dienstwagenArt: employee.dienstwagen_art ?? "Verbrenner",
|
||||
isLateraleFuehrung: employee.is_laterale_fuehrung ?? false,
|
||||
isCLevel: employee.is_c_level ?? false,
|
||||
@@ -93,6 +108,10 @@ export function DatenAendernPanel({
|
||||
function updateRole(patch: Partial<RoleEmploymentValue>) {
|
||||
setRole((prev) => ({ ...prev, ...patch }));
|
||||
}
|
||||
// Dieselbe Regel wie in RoleEmploymentFields: der Personenkreis zieht das
|
||||
// Kennzeichen mit. Hier noch einmal, weil nicht das Formular speichert,
|
||||
// sondern diese Seite — und was gezeigt wird, muss auch abgeschickt werden.
|
||||
const istBehindert = role.istBeguenstigtBehindert || role.kuendigungsschutzGrund === GRUND_BEGUENSTIGT_BEHINDERT;
|
||||
|
||||
function handleEmploymentTypeChange(value: EmploymentType) {
|
||||
setEmploymentType(value);
|
||||
@@ -159,13 +178,24 @@ export function DatenAendernPanel({
|
||||
},
|
||||
role: {
|
||||
worker_type: role.workerType,
|
||||
mitarbeiterart: role.mitarbeiterart,
|
||||
source: role.source,
|
||||
collective_agreement: role.collectiveAgreement,
|
||||
work_days: role.workDays,
|
||||
is_betriebsrat: role.isBetriebsrat,
|
||||
has_dienstwagen: role.hasDienstwagen,
|
||||
dienstwagen_art: role.hasDienstwagen ? role.dienstwagenArt : "",
|
||||
has_kuendigungsschutz: role.hasKuendigungsschutz,
|
||||
// Leer statt des Wertes, wenn das Kennzeichen weg ist: die Bedingung
|
||||
// in der Datenbank duldet keinen Rest ohne Bezug, und die Funktion
|
||||
// räumt ihn auf demselben Weg weg wie beim Dienstwagen.
|
||||
kuendigungsschutz_bis: role.hasKuendigungsschutz ? role.kuendigungsschutzBis : "",
|
||||
kuendigungsschutz_grund: role.hasKuendigungsschutz ? role.kuendigungsschutzGrund : "",
|
||||
kuendigungsschutz_ab: role.hasKuendigungsschutz ? role.kuendigungsschutzAb : "",
|
||||
ist_beguenstigt_behindert: istBehindert,
|
||||
behinderung_grad: istBehindert ? role.behinderungGrad : "",
|
||||
behinderung_ab: istBehindert ? role.behinderungAb : "",
|
||||
behinderung_bis: istBehindert ? role.behinderungBis : "",
|
||||
// Die Teilzeitvariante wird nur mitgeschickt, wenn sich die Stunden
|
||||
// tatsächlich ändern. Sonst schriebe jede Adressänderung den Wert
|
||||
// erneut — und setzte ihn bei „Vertragliche Stundenänderung" sogar
|
||||
|
||||
@@ -10,16 +10,27 @@ import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { ABSENCE_TYPES, absenceLabel, RUECKKEHR_GRUENDE } from "@/lib/absence";
|
||||
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 Mode = "adjust" | "return";
|
||||
type EmploymentMode = "unverändert" | "Vollzeit" | "Teilzeit";
|
||||
|
||||
export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClose: () => void; employee: EmployeeRow }) {
|
||||
export function KarenzPanel({
|
||||
open,
|
||||
onClose,
|
||||
employee,
|
||||
status,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
employee: EmployeeRow;
|
||||
/** Der zum heutigen Tag abgeleitete Status — nicht `employee.status`, die Spalte hängt nach. */
|
||||
status: EmploymentStatus;
|
||||
}) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const isOnKarenz = employee.status === "Karenz";
|
||||
const isOnKarenz = status === "Karenz";
|
||||
const [mode, setMode] = useState<Mode>("adjust");
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
@@ -188,7 +199,7 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
|
||||
{mode === "adjust" && (
|
||||
<>
|
||||
<p className="rounded bg-surface px-3 py-2 text-sm text-ink-body">
|
||||
<span className="font-semibold">{absenceLabel(employee.status, employee.absence_type)}</span>
|
||||
<span className="font-semibold">{absenceLabel(status, employee.absence_type)}</span>
|
||||
{" · Rückkehr "}
|
||||
{fmtDate(employee.karenz_return_date)}
|
||||
</p>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Button } from "@/components/ui/Button";
|
||||
import { SelectField, TextField } from "@/components/ui/Field";
|
||||
import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import type { Database, PaygradeType } from "@/lib/supabase/types";
|
||||
import type { Database, PaygradeType } from "@/lib/types";
|
||||
import { fmtName } from "@/lib/format";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { rehireEmployee } from "@/actions/employees";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { SelectField, TextField } from "@/components/ui/Field";
|
||||
import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { fmtDate, fmtName } from "@/lib/format";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
|
||||
// Eine Wiedereinstellung braucht eine Planstelle — genau wie eine
|
||||
// Neueinstellung.
|
||||
//
|
||||
// Die alte Stelle ist dafür kein Ersatz: sie kann inzwischen besetzt,
|
||||
// ausgelaufen oder ganz entfallen sein. `rehire_employee` verlangte die
|
||||
// Angabe deshalb schon immer und wies den Aufruf sonst ab; nur schickte das
|
||||
// Formular sie nie mit, sodass jede Wiedereinstellung an einer Meldung
|
||||
// scheiterte, die sich im Dialog gar nicht beheben liess.
|
||||
export function RehirePanel({
|
||||
open,
|
||||
onClose,
|
||||
employee,
|
||||
openPositions,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
employee: EmployeeRow;
|
||||
openPositions: OpenPositionResolved[];
|
||||
}) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [rehireDate, setRehireDate] = useState("");
|
||||
const [positionId, setPositionId] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
openPositions
|
||||
.slice()
|
||||
.sort((a, b) => a.orgLabel.localeCompare(b.orgLabel, "de") || a.title.localeCompare(b.title, "de"))
|
||||
.map((p) => ({ value: p.id, label: `${p.orgLabel} · ${p.title} (${p.position_number})` })),
|
||||
[openPositions]
|
||||
);
|
||||
|
||||
const selected = openPositions.find((p) => p.id === positionId);
|
||||
|
||||
// Die Datenbank weist eine Besetzung ausserhalb der Gültigkeit ab. Das hier
|
||||
// nimmt die Meldung vorweg, solange sie noch etwas nützt — im Dialog, mit
|
||||
// beiden Daten sichtbar.
|
||||
const ausserhalb =
|
||||
selected && rehireDate
|
||||
? rehireDate < selected.valid_from
|
||||
? `Diese Planstelle gilt erst ab ${fmtDate(selected.valid_from)}.`
|
||||
: selected.valid_to && rehireDate >= selected.valid_to
|
||||
? `Diese Planstelle gilt nur bis ${fmtDate(selected.valid_to)}.`
|
||||
: null
|
||||
: null;
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!rehireDate || !positionId) {
|
||||
showToast("Wiedereintrittsdatum und Planstelle sind Pflicht.", "error");
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
const result = await rehireEmployee({
|
||||
employee_id: employee.id,
|
||||
rehire_date: rehireDate,
|
||||
position_id: positionId,
|
||||
});
|
||||
setPending(false);
|
||||
if (result.success) {
|
||||
showToast(`${employee.first_name} ${employee.last_name} wurde wiedereingestellt.`);
|
||||
router.refresh();
|
||||
onClose();
|
||||
} else {
|
||||
showToast(result.error ?? "Fehler beim Speichern.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SlideOver
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Wiedereinstellung"
|
||||
subtitle={fmtName(employee.first_name, employee.last_name)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} pending={pending} disabled={Boolean(ausserhalb)}>
|
||||
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} />
|
||||
|
||||
{options.length === 0 ? (
|
||||
<p className="rounded border border-danger-text/20 bg-danger-bg px-3 py-2 text-sm text-danger-text">
|
||||
Es ist derzeit keine Planstelle frei. Ohne eine solche ist keine Wiedereinstellung möglich — zuerst eine
|
||||
Planstelle anlegen oder eine bestehende freimachen.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<SelectField
|
||||
label="Planstelle"
|
||||
required
|
||||
value={positionId}
|
||||
onChange={setPositionId}
|
||||
placeholder="Bitte wählen…"
|
||||
options={options}
|
||||
/>
|
||||
{selected && (
|
||||
<div className="rounded border border-border bg-surface p-3 text-sm text-ink-body">
|
||||
<div className="font-semibold text-ink">{selected.title}</div>
|
||||
<div className="text-xs text-ink-muted">{selected.orgLabel}</div>
|
||||
<div className="mt-1 text-xs text-ink-muted">
|
||||
{selected.is_chief ? "Leitungsplanstelle" : "Mitarbeiterplanstelle"}
|
||||
{selected.future ? ` · gültig ab ${fmtDate(selected.valid_from)}` : ""}
|
||||
{selected.valid_to ? ` · endet am ${fmtDate(selected.valid_to)}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ausserhalb && (
|
||||
<p role="alert" className="rounded border border-danger-text/20 bg-danger-bg px-3 py-2 text-sm text-danger-text">
|
||||
{ausserhalb} Wiedereintritt und Gültigkeit müssen zusammenpassen.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SlideOver>
|
||||
);
|
||||
}
|
||||
@@ -7,42 +7,37 @@ import { Button } from "@/components/ui/Button";
|
||||
import { SelectField, TextField, TextareaField } from "@/components/ui/Field";
|
||||
import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
import { AUSTRITTSART_LABELS, AUSTRITTSARTEN, BEENDIGUNG_NO_SHOW, BEENDIGUNGSART_WERTE } from "@/lib/beendigung";
|
||||
import type { Austrittsart, Database, EmploymentStatus } from "@/lib/types";
|
||||
import { fmtDate, fmtName } from "@/lib/format";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
|
||||
// „No Show" steht am Ende und getrennt: es ist kein Austritt im gewohnten
|
||||
// Sinn, sondern der Fall, dass jemand nie angetreten ist.
|
||||
const NO_SHOW = "No Show";
|
||||
const EXIT_REASONS = [
|
||||
"Einvernehmliche Auflösung",
|
||||
"Kündigung AN",
|
||||
"Kündigung AG",
|
||||
"Befristungsablauf",
|
||||
"Pensionierung",
|
||||
"Entlassung",
|
||||
NO_SHOW,
|
||||
];
|
||||
const CHECKLIST_ITEMS = ["IT-Zugänge deaktivieren", "Hardware retournieren", "ÖGK-Abmeldung", "Endabrechnung & Dienstzeugnis"];
|
||||
// Die Liste steht in lib/beendigung.ts, nicht mehr hier: der Berichtemanager
|
||||
// braucht sie ebenso, und zwei Listen liefen auseinander.
|
||||
const NO_SHOW = BEENDIGUNG_NO_SHOW;
|
||||
|
||||
type TerminatePanelProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
employee: EmployeeRow;
|
||||
/** Der zum heutigen Tag abgeleitete Status — nicht `employee.status`, die Spalte hängt nach. */
|
||||
status: EmploymentStatus;
|
||||
directReportCount: number;
|
||||
};
|
||||
|
||||
export function TerminatePanel({ open, onClose, employee, directReportCount }: TerminatePanelProps) {
|
||||
export function TerminatePanel({ open, onClose, employee, status, directReportCount }: TerminatePanelProps) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [exitDate, setExitDate] = useState("");
|
||||
// Wer noch gar nicht angefangen hat, tritt fast nie aus einem anderen Grund
|
||||
// aus. Die Vorbelegung nimmt den wahrscheinlichen Fall vorweg, ohne die
|
||||
// übrigen zu verstellen.
|
||||
const [reason, setReason] = useState(employee.status === "Geplant" ? NO_SHOW : EXIT_REASONS[0]);
|
||||
const [reason, setReason] = useState(status === "Geplant" ? NO_SHOW : BEENDIGUNGSART_WERTE[0]);
|
||||
// Leer als Vorbelegung: die Angabe ist freiwillig, und eine vorausgewählte
|
||||
// Seite wäre eine Behauptung, die niemand getroffen hat.
|
||||
const [austrittsart, setAustrittsart] = useState<Austrittsart | "">("");
|
||||
const [note, setNote] = useState("");
|
||||
const [checked, setChecked] = useState<boolean[]>(CHECKLIST_ITEMS.map(() => false));
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
// Bei einem Nichtantritt ist das Datum nicht frei wählbar: es ist der Tag,
|
||||
@@ -62,6 +57,9 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
|
||||
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);
|
||||
@@ -116,11 +114,38 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
|
||||
{directReportCount} direkte Berichte werden automatisch der nächsthöheren Führungskraft zugeordnet.
|
||||
</div>
|
||||
)}
|
||||
{/* ── Zwei Angaben, die einander nicht bestimmen ──────────────
|
||||
Erst die Beendigungsart, darunter der Anstoss. Beide Listen sind
|
||||
vollständig und keine schränkt die andere ein.
|
||||
|
||||
Der erste Entwurf hatte es umgekehrt: oben die Austrittsart, und
|
||||
sie filterte die Beendigungsarten darunter, weil der Anstoss aus
|
||||
der Art ableitbar schien. In Österreich ist er das nicht — die
|
||||
einvernehmliche Auflösung ist der Regelfall und kann von beiden
|
||||
Seiten ausgehen. Die Begründung steht in lib/beendigung.ts.
|
||||
|
||||
Unter der Beendigungsart stand ausserdem der abgeleitete Anstoss
|
||||
als Hinweis. Er erschien von selbst und sah aus wie ein Fehler des
|
||||
Formulars; er ist weg. */}
|
||||
<SelectField
|
||||
label="Beendigungsart"
|
||||
value={reason}
|
||||
onChange={setReason}
|
||||
options={EXIT_REASONS.map((r) => ({ value: r, label: r === NO_SHOW ? "No Show (nicht angetreten)" : 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"}
|
||||
@@ -142,22 +167,16 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
|
||||
Stichtag als beschäftigt.
|
||||
</p>
|
||||
)}
|
||||
{/* Bei einem Nichtantritt wurde nichts ausgegeben, was zurückkäme. */}
|
||||
<fieldset hidden={istNoShow}>
|
||||
<legend className="mb-2 text-sm font-semibold text-ink">Offboarding-Checkliste</legend>
|
||||
<div className="flex flex-col gap-2">
|
||||
{CHECKLIST_ITEMS.map((item, i) => (
|
||||
<label key={item} className="flex items-center gap-2 text-sm text-ink-body">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked[i]}
|
||||
onChange={(e) => setChecked((prev) => prev.map((c, idx) => (idx === i ? e.target.checked : c)))}
|
||||
/>
|
||||
{item}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
{/* Bei einem Nichtantritt wurde nichts ausgegeben, was zurückkäme —
|
||||
deshalb entsteht dort auch keine Offboarding-Checkliste. Für einen
|
||||
echten Austritt legt terminateEmployee sie in derselben
|
||||
Transaktion an; hier steht nur die Ankündigung, damit niemand sie
|
||||
hier sucht und nichts findet. */}
|
||||
{!istNoShow && (
|
||||
<p className="rounded bg-surface px-3 py-2 text-sm text-ink-body">
|
||||
Nach der Bestätigung steht im Reiter „Offboarding“ eine Checkliste zur Verfügung.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SlideOver>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { SelectField, TextField } from "@/components/ui/Field";
|
||||
import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
import type { Database } from "@/lib/types";
|
||||
import { fmtName } from "@/lib/format";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -6,10 +6,10 @@ import { HistorieLoeschen } from "@/components/employees/HistorieLoeschen";
|
||||
import { AenderungsTabelle } from "@/components/ui/AenderungsTabelle";
|
||||
import { TextField } from "@/components/ui/Field";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
import { actionBadgeStyle } from "@/lib/colors";
|
||||
import { eventBadgeStyle } from "@/lib/colors";
|
||||
import { fmtDate, todayIso } from "@/lib/format";
|
||||
import { darfBearbeitetWerden, darfKorrigiertWerden, loeschVorschau } from "@/lib/history";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
import type { Database } from "@/lib/types";
|
||||
|
||||
type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"];
|
||||
|
||||
@@ -108,7 +108,7 @@ export function HistorieTab({ history, employeeId }: { history: HistoryRow[]; em
|
||||
onClick={() => typUmschalten(typ)}
|
||||
aria-pressed={typen.has(typ)}
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
|
||||
typen.has(typ) ? actionBadgeStyle(typ) : "bg-white text-ink-muted hover:text-ink"
|
||||
typen.has(typ) ? eventBadgeStyle(typ) : "bg-white text-ink-muted hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{typ}
|
||||
@@ -145,7 +145,7 @@ export function HistorieTab({ history, employeeId }: { history: HistoryRow[]; em
|
||||
return (
|
||||
<li key={h.id} className="py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${actionBadgeStyle(h.event_type)}`}>
|
||||
<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>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { SelectField, TextField, TextareaField } from "@/components/ui/Field";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { NOTE_CATEGORY_STYLES } from "@/lib/colors";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import type { Database, NoteCategory } from "@/lib/supabase/types";
|
||||
import type { Database, NoteCategory } from "@/lib/types";
|
||||
|
||||
type Note = Database["public"]["Tables"]["employee_notes"]["Row"];
|
||||
|
||||
@@ -103,7 +103,7 @@ export function NotizenTab({ employeeId, notes }: { employeeId: string; notes: N
|
||||
{n.done ? (
|
||||
<span className="rounded-full bg-success-bg px-2 py-0.5 text-xs font-semibold text-success-text">Erledigt</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-brand-100 px-2 py-0.5 text-xs font-semibold text-brand-700">Offen</span>
|
||||
<span className="rounded-full bg-accent-200 px-2 py-0.5 text-xs font-semibold text-brand-700">Offen</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-ink">{n.note_text}</p>
|
||||
|
||||
33
components/employees/tabs/OffboardingTab.tsx
Normal file
33
components/employees/tabs/OffboardingTab.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { setOffboardingTask, startOffboarding } from "@/actions/employees";
|
||||
import { OFFBOARDING_GRUPPEN } from "@/lib/offboarding";
|
||||
import type { AufgabenStand } from "@/lib/checklist";
|
||||
import { ChecklistPanel } from "./ChecklistPanel";
|
||||
|
||||
// Dünne Bindung — siehe OnboardingTab.tsx, dieselbe Mechanik für den anderen
|
||||
// Weg.
|
||||
|
||||
export function OffboardingTab({
|
||||
employeeId,
|
||||
staende,
|
||||
vorhanden,
|
||||
}: {
|
||||
employeeId: string;
|
||||
staende: AufgabenStand[];
|
||||
/** Ob überhaupt eine Liste existiert — sie entsteht mit dem Austritt. */
|
||||
vorhanden: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ChecklistPanel
|
||||
gruppen={OFFBOARDING_GRUPPEN}
|
||||
staende={staende}
|
||||
vorhanden={vorhanden}
|
||||
leerText="Für diese Person gibt es keine Offboarding-Checkliste. Für einen No Show wird keine angelegt — wer nie angetreten ist, hat nichts offzuboarden."
|
||||
anlegenLabel="Checkliste anlegen"
|
||||
druckHref={`/employees/${employeeId}/checkliste?art=offboarding`}
|
||||
onSpeichern={(itemKey, teil) => setOffboardingTask({ employee_id: employeeId, item_key: itemKey, ...teil })}
|
||||
onAnlegen={() => startOffboarding(employeeId)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
34
components/employees/tabs/OnboardingTab.tsx
Normal file
34
components/employees/tabs/OnboardingTab.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { setOnboardingTask, startOnboarding } from "@/actions/employees";
|
||||
import { ONBOARDING_GRUPPEN } from "@/lib/onboarding";
|
||||
import type { AufgabenStand } from "@/lib/checklist";
|
||||
import { ChecklistPanel } from "./ChecklistPanel";
|
||||
|
||||
// Dünne Bindung: die Mechanik der Checkliste steht in ChecklistPanel, geteilt
|
||||
// mit OffboardingTab. Hier steht nur, welches Verzeichnis gilt und welche
|
||||
// Server-Aktionen es speichern.
|
||||
|
||||
export function OnboardingTab({
|
||||
employeeId,
|
||||
staende,
|
||||
vorhanden,
|
||||
}: {
|
||||
employeeId: string;
|
||||
staende: AufgabenStand[];
|
||||
/** Ob überhaupt eine Liste existiert — sie entsteht mit dem Eintritt. */
|
||||
vorhanden: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ChecklistPanel
|
||||
gruppen={ONBOARDING_GRUPPEN}
|
||||
staende={staende}
|
||||
vorhanden={vorhanden}
|
||||
leerText="Für diese Person gibt es keine Onboarding-Checkliste. Sie entsteht mit einer Einstellung oder Wiedereinstellung — wer davor eingetreten ist, hat keine."
|
||||
anlegenLabel="Checkliste anlegen"
|
||||
druckHref={`/employees/${employeeId}/checkliste?art=onboarding`}
|
||||
onSpeichern={(itemKey, teil) => setOnboardingTask({ employee_id: employeeId, item_key: itemKey, ...teil })}
|
||||
onAnlegen={() => startOnboarding(employeeId)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,15 @@ type OrganisationTabProps = {
|
||||
kostenstelle: { code: string; name: string } | null;
|
||||
};
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AngehoerigeSection } from "@/components/employees/AngehoerigeSection";
|
||||
import { brauchtAufenthaltstitel } from "@/lib/countries";
|
||||
import { fmtAge, fmtDate } from "@/lib/format";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
import type { Database } from "@/lib/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
type Location = Database["public"]["Tables"]["locations"]["Row"];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { dienstwagenLabel } from "@/lib/dienstwagen";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
import type { Database } from "@/lib/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
|
||||
@@ -27,7 +27,7 @@ export function VertragTab({ employee }: { employee: EmployeeRow }) {
|
||||
["Wochenstunden", `${employee.weekly_hours} h`],
|
||||
["Urlaubsanspruch", "25 Tage"],
|
||||
["Paygrade", PAYGRADE_LABELS[employee.paygrade] ?? employee.paygrade],
|
||||
["Angestellte:r / Arbeiter:in", employee.worker_type ?? "–"],
|
||||
["Beschäftigtengruppe", employee.worker_type ?? "–"],
|
||||
["Arbeitstage", employee.work_days?.join(", ") || "–"],
|
||||
// Beim Dienstwagen steht die Antriebsart statt eines blossen „Ja" — das
|
||||
// war die Frage dahinter, seit E-Fahrzeuge getrennt zu führen sind.
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { addEmployeeDependent, hireEmployee } from "@/actions/employees";
|
||||
import { deleteHireDraft, saveHireDraft } from "@/actions/hireDrafts";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { GRUND_BEGUENSTIGT_BEHINDERT } from "@/lib/kuendigungsschutz";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr";
|
||||
import { StepAngehoerige } from "./StepAngehoerige";
|
||||
@@ -17,7 +18,12 @@ import { StepSummary } from "./StepSummary";
|
||||
import { StepVertrag } from "./StepVertrag";
|
||||
import { EMPTY_HIRE_DRAFT, type HireDraftData } from "./types";
|
||||
|
||||
const STEP_LABELS = ["Person", "Position", "Angehörige", "Vertrag", "Notfallkontakt", "Zusammenfassung"];
|
||||
// Vertrag vor Angehörige: was den Vertrag ausmacht — Eintritt, Arbeitstage,
|
||||
// Befristung — steht auf dem Papier, das vor dem Gespräch da ist. Angehörige
|
||||
// bringt die Person mit, oft erst am ersten Tag. Bis September 2026 stand es
|
||||
// andersherum, und der freiwillige Schritt lag vor dem, der die Einstellung
|
||||
// überhaupt trägt.
|
||||
const STEP_LABELS = ["Person", "Position", "Vertrag", "Angehörige", "Notfallkontakt", "Zusammenfassung"] as const;
|
||||
|
||||
type HireWizardProps = {
|
||||
open: boolean;
|
||||
@@ -54,36 +60,62 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
||||
|
||||
// Blocks step 1 rather than letting the hire fail at the RPC: the SVNR
|
||||
// trigger rejects a bad number, and by then the user is three steps on.
|
||||
// Dieselbe Überlegung wie bei der SV-Nummer, nur dass die Antwort aus der
|
||||
// Datenbank kommt: eine vergebene Personalnummer wies bisher erst
|
||||
// hire_employee ab — nach sechs Schritten Eingabe, und die Nummer steht im
|
||||
// ersten Feld des ersten Schritts.
|
||||
//
|
||||
// Gemerkt wird die *Nummer*, die als vergeben zurückkam, nicht ein Ja/Nein.
|
||||
// So ist die Sperre eine Ableitung aus dem, was im Feld steht, und muss
|
||||
// beim Weitertippen nicht zurückgesetzt werden — ein Zurücksetzen, das man
|
||||
// vergessen kann, sperrt sonst ein Formular ohne sichtbaren Grund.
|
||||
const [vergebeneNummer, setVergebeneNummer] = useState<number | null>(null);
|
||||
const nummerFrei = Number(draft.personnelNumber) !== vergebeneNummer;
|
||||
const merkeBefund = useCallback(
|
||||
(befund: { nummer: number; vergeben: boolean }) => setVergebeneNummer(befund.vergeben ? befund.nummer : null),
|
||||
[]
|
||||
);
|
||||
|
||||
const svNummerOk =
|
||||
!draft.svNummer.trim() ||
|
||||
!requiresAustrianSvnr(locations.find((l) => l.id === draft.locationId)?.country) ||
|
||||
isValidSvnr(draft.svNummer, draft.birthDate || null);
|
||||
|
||||
const stepValid = [
|
||||
// Am Namen des Schritts, nicht an seiner Nummer.
|
||||
//
|
||||
// Vorher war das eine Liste in derselben Reihenfolge wie STEP_LABELS, und
|
||||
// die beiden mussten stumm zusammenpassen. Beim Vertauschen von Vertrag und
|
||||
// Angehörige wäre die Prüfung stehengeblieben, wo sie war: „Weiter" im
|
||||
// Vertrag hätte die Angehörigen geprüft und ein leeres Eintrittsdatum
|
||||
// durchgelassen — bis die Datenbank es am Ende abweist.
|
||||
const pruefung: Record<(typeof STEP_LABELS)[number], boolean> = {
|
||||
// E-Mail gehört zu den Pflichtfeldern, weil die Spalte NOT NULL ist. Ohne
|
||||
// die Prüfung hier bricht erst die Datenbank ab — am Ende des vierten
|
||||
// Schritts, nach allen Eingaben.
|
||||
// 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.
|
||||
Boolean(draft.personnelNumber.trim() && draft.firstName && draft.lastName && draft.birthDate && draft.locationId) &&
|
||||
svNummerOk,
|
||||
Boolean(draft.positionId && draft.besetzung),
|
||||
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.
|
||||
draft.angehoerige.every(
|
||||
Angehörige: draft.angehoerige.every(
|
||||
(a) =>
|
||||
a.firstName.trim() &&
|
||||
a.lastName.trim() &&
|
||||
a.birthDate &&
|
||||
(!a.svNummer.trim() || isValidSvnr(a.svNummer, a.birthDate || null))
|
||||
),
|
||||
Boolean(draft.entryDate && draft.workDays.length > 0),
|
||||
// Notfallkontakt: freiwillig, aber Name und Nummer nur gemeinsam — die
|
||||
// Datenbank weist eines ohne das andere ab (chk_emergency_contact).
|
||||
Boolean(draft.emergencyContactName.trim()) === Boolean(draft.emergencyContactPhone.trim()),
|
||||
true,
|
||||
][step];
|
||||
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;
|
||||
@@ -101,6 +133,10 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!selectedPosition || !draft.besetzung) return;
|
||||
// Dieselbe Regel wie im Formular (RoleEmploymentFields): der
|
||||
// Personenkreis zieht das Kennzeichen mit. Was gezeigt wurde, muss auch
|
||||
// abgeschickt werden.
|
||||
const istBehindert = draft.istBeguenstigtBehindert || draft.kuendigungsschutzGrund === GRUND_BEGUENSTIGT_BEHINDERT;
|
||||
setSubmitting(true);
|
||||
const result = await hireEmployee({
|
||||
personnel_number: Number(draft.personnelNumber),
|
||||
@@ -130,9 +166,17 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
||||
// 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 — der CHECK lässt es nicht anders zu.
|
||||
// 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,
|
||||
@@ -240,10 +284,10 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
||||
))}
|
||||
</div>
|
||||
|
||||
{step === 0 && <StepPerson draft={draft} update={update} locations={locations} />}
|
||||
{step === 0 && <StepPerson draft={draft} update={update} locations={locations} onNummerBefund={merkeBefund} />}
|
||||
{step === 1 && <StepPosition draft={draft} update={update} openPositions={openPositions} />}
|
||||
{step === 2 && <StepAngehoerige draft={draft} update={update} />}
|
||||
{step === 3 && <StepVertrag draft={draft} update={update} />}
|
||||
{step === 2 && <StepVertrag draft={draft} update={update} />}
|
||||
{step === 3 && <StepAngehoerige draft={draft} update={update} />}
|
||||
{step === 4 && <StepNotfallkontakt draft={draft} update={update} />}
|
||||
{step === 5 && <StepSummary draft={draft} selectedPosition={selectedPosition} locations={locations} />}
|
||||
</Modal>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { entwurfFreigeben, entwurfSperren } from "@/actions/hireDrafts";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import type { Entwurf } from "@/lib/entwuerfe";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import { HireWizard } from "./HireWizard";
|
||||
|
||||
type Location = { id: string; name: string; country: string };
|
||||
type HireDraft = { id: string; step: number; payload: Record<string, unknown> };
|
||||
|
||||
type OpenWizardOptions = { draftId?: string; positionId?: string };
|
||||
|
||||
@@ -15,6 +18,16 @@ type HireWizardContextValue = {
|
||||
|
||||
const HireWizardContext = createContext<HireWizardContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Wie oft die Sperre aufgefrischt wird, solange der Assistent offen ist.
|
||||
*
|
||||
* Deutlich kürzer als die Frist in der Datenbank (15 Minuten,
|
||||
* app_entwurf_sperrfrist): zwischen zwei Takten darf eine Antwort ausfallen,
|
||||
* ohne dass die Sperre wegläuft. Wer eine halbe Stunde an einem Entwurf
|
||||
* sitzt, soll ihn nicht auf halbem Weg an eine Kollegin verlieren.
|
||||
*/
|
||||
const TAKT_MS = 5 * 60 * 1000;
|
||||
|
||||
export function HireWizardProvider({
|
||||
children,
|
||||
openPositions,
|
||||
@@ -24,10 +37,12 @@ export function HireWizardProvider({
|
||||
children: ReactNode;
|
||||
openPositions: OpenPositionResolved[];
|
||||
locations: Location[];
|
||||
drafts: HireDraft[];
|
||||
drafts: Entwurf[];
|
||||
}) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [resumeDraft, setResumeDraft] = useState<HireDraft | null>(null);
|
||||
const [resumeDraft, setResumeDraft] = useState<Entwurf | null>(null);
|
||||
const [initialPositionId, setInitialPositionId] = useState<string | undefined>(undefined);
|
||||
// Forces HireWizard to remount fresh each time it's opened, so its
|
||||
// internal draft/step state is (re-)initialized directly from the current
|
||||
@@ -35,20 +50,73 @@ export function HireWizardProvider({
|
||||
// effect needed inside HireWizard itself.
|
||||
const [openKey, setOpenKey] = useState(0);
|
||||
|
||||
function openWizard(options?: OpenWizardOptions) {
|
||||
// Die Kennung des Entwurfs, dessen Sperre wir halten. Als Ref, weil sie im
|
||||
// Aufräumen des Effekts gebraucht wird — über den Zustand gelesen wäre es
|
||||
// dort der Stand von vorhin.
|
||||
const gesperrt = useRef<string | null>(null);
|
||||
|
||||
async function openWizard(options?: OpenWizardOptions) {
|
||||
// Ohne Entwurf gibt es nichts zu sperren: eine neue Einstellung entsteht
|
||||
// erst beim Speichern.
|
||||
if (options?.draftId) {
|
||||
const result = await entwurfSperren(options.draftId);
|
||||
if (!result.success) {
|
||||
showToast(result.error ?? "Der Entwurf lässt sich gerade nicht öffnen.", "error");
|
||||
// Die Liste holt sich den Stand: wer die Sperre hält, steht danach
|
||||
// an der Zeile, statt dass nur eine Meldung aufblitzt.
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
gesperrt.current = options.draftId;
|
||||
}
|
||||
setResumeDraft(options?.draftId ? (drafts.find((d) => d.id === options.draftId) ?? null) : null);
|
||||
setInitialPositionId(options?.positionId);
|
||||
setOpen(true);
|
||||
setOpenKey((k) => k + 1);
|
||||
}
|
||||
|
||||
function closeWizard() {
|
||||
setOpen(false);
|
||||
freigeben();
|
||||
}
|
||||
|
||||
function freigeben() {
|
||||
const id = gesperrt.current;
|
||||
if (!id) return;
|
||||
gesperrt.current = null;
|
||||
void entwurfFreigeben(id);
|
||||
}
|
||||
|
||||
// Auffrischen, solange der Assistent offen ist. Ohne das liefe die Frist
|
||||
// einem langen Ausfüllen davon, und das Speichern am Ende fände seine
|
||||
// eigene Sperre abgelaufen.
|
||||
//
|
||||
// `!open` ist doppelt gemoppelt: freigeben() räumt beim Zumachen auch
|
||||
// gesperrt.current weg, und daran scheitert der Takt ohnehin. Es steht
|
||||
// trotzdem da, weil es die Bedingung ausspricht, um die es geht — dass
|
||||
// nach dem Zumachen nichts mehr aufgefrischt wird, hängt sonst an einer
|
||||
// Zuweisung drei Funktionen weiter oben.
|
||||
useEffect(() => {
|
||||
if (!open || !gesperrt.current) return;
|
||||
const timer = setInterval(() => {
|
||||
if (gesperrt.current) void entwurfSperren(gesperrt.current);
|
||||
}, TAKT_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [open, openKey]);
|
||||
|
||||
// Beim Verlassen der Seite: dieselbe Freigabe wie beim Schliessen. Sie
|
||||
// erreicht den Server nicht immer — ein zugeklappter Laptop schickt nichts
|
||||
// mehr. Deshalb ist sie die Höflichkeit und nicht die Absicherung; die
|
||||
// Absicherung ist die Frist in der Datenbank.
|
||||
useEffect(() => () => freigeben(), []);
|
||||
|
||||
return (
|
||||
<HireWizardContext.Provider value={{ openWizard }}>
|
||||
{children}
|
||||
<HireWizard
|
||||
key={openKey}
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
onClose={closeWizard}
|
||||
openPositions={openPositions}
|
||||
locations={locations}
|
||||
resumeDraft={resumeDraft}
|
||||
|
||||
99
components/hire/PersonalnummerField.tsx
Normal file
99
components/hire/PersonalnummerField.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { personalnummerVergeben } from "@/actions/employees";
|
||||
import { TextField } from "@/components/ui/Field";
|
||||
|
||||
// Die Personalnummer wird eingegeben, nicht vergeben — sie muss mit Loga und
|
||||
// Interflex übereinstimmen. Genau deshalb kommt sie von aussen, und genau
|
||||
// deshalb kann sie schon vergeben sein: an dieselbe Person, die jemand ein
|
||||
// zweites Mal anlegt, oder an eine ganz andere nach einem Zahlendreher.
|
||||
//
|
||||
// Die Datenbank weist das ab (hire_employee prüft es eigens), aber erst beim
|
||||
// Anlegen — nach sechs Schritten Eingabe. Bis dahin ist die falsche Nummer
|
||||
// schon durch alle Formulare gereist.
|
||||
//
|
||||
// ── Wann gefragt wird ───────────────────────────────────────────────
|
||||
//
|
||||
// Nicht bei jedem Tastendruck: „3", „34", „347" wären drei Fragen an die
|
||||
// Datenbank für eine Eingabe, und die ersten beiden hätten eine Antwort auf
|
||||
// eine Nummer, die niemand meint. Stattdessen eine halbe Sekunde Ruhe
|
||||
// abwarten — lange genug, dass eine vierstellige Nummer am Stück eingetippt
|
||||
// eine einzige Frage ergibt, kurz genug, dass die Antwort da ist, bevor der
|
||||
// Blick zum nächsten Feld wandert.
|
||||
//
|
||||
// Die Antwort kann veralten, während sie unterwegs ist. Deshalb zählt jede
|
||||
// Anfrage mit und nur die jüngste darf schreiben: sonst überschriebe die
|
||||
// langsamere Antwort auf „347" die schnellere auf „3471".
|
||||
//
|
||||
// ── Warum der Befund an der Nummer hängt und nicht an einem Ja/Nein ──
|
||||
//
|
||||
// Gespeichert wird **die geprüfte Nummer** mitsamt ihrem Ergebnis, nicht
|
||||
// bloss „frei" oder „vergeben". Damit ist jede Anzeige eine Ableitung aus
|
||||
// dem, was gerade im Feld steht — es gibt keinen Zustand, der zurückgesetzt
|
||||
// werden müsste, wenn die Eingabe sich ändert, und deshalb auch keine Stelle,
|
||||
// an der das Zurücksetzen vergessen werden kann.
|
||||
|
||||
const WARTEZEIT_MS = 500;
|
||||
|
||||
type Befund = { nummer: number; vergeben: boolean; name?: string };
|
||||
|
||||
export function PersonalnummerField({
|
||||
wert,
|
||||
onChange,
|
||||
onBefund,
|
||||
}: {
|
||||
wert: string;
|
||||
onChange: (wert: string) => void;
|
||||
/** Meldet dem Assistenten die geprüfte Nummer und ihr Ergebnis. */
|
||||
onBefund: (befund: Befund) => void;
|
||||
}) {
|
||||
const [befund, setBefund] = useState<Befund | null>(null);
|
||||
const laufendeNr = useRef(0);
|
||||
|
||||
const nummer = Number(wert);
|
||||
const gueltig = Boolean(wert.trim()) && Number.isInteger(nummer) && nummer > 0;
|
||||
// Nur ein Befund zu *dieser* Nummer zählt. Steht im Feld inzwischen etwas
|
||||
// anderes, ist die alte Auskunft gegenstandslos.
|
||||
const passend = gueltig && befund?.nummer === nummer ? befund : null;
|
||||
const laeuft = gueltig && !passend;
|
||||
|
||||
useEffect(() => {
|
||||
if (!gueltig) return;
|
||||
|
||||
const meine = ++laufendeNr.current;
|
||||
const zeit = setTimeout(async () => {
|
||||
let antwort: { vergeben: boolean; name?: string };
|
||||
try {
|
||||
antwort = await personalnummerVergeben(nummer);
|
||||
} catch {
|
||||
// Keine Auskunft ist kein Hindernis: die Datenbank entscheidet beim
|
||||
// Anlegen ohnehin. Eine Meldung über eine gescheiterte *Vorab*-Prüfung
|
||||
// wäre für die Eingebende nur Lärm.
|
||||
antwort = { vergeben: false };
|
||||
}
|
||||
if (meine !== laufendeNr.current) return;
|
||||
const neu: Befund = { nummer, ...antwort };
|
||||
setBefund(neu);
|
||||
onBefund(neu);
|
||||
}, WARTEZEIT_MS);
|
||||
|
||||
return () => clearTimeout(zeit);
|
||||
}, [gueltig, nummer, onBefund]);
|
||||
|
||||
return (
|
||||
<TextField
|
||||
label="Personalnummer"
|
||||
required
|
||||
inputMode="numeric"
|
||||
value={wert}
|
||||
onChange={(v) => onChange(v.replace(/\D/g, ""))}
|
||||
error={
|
||||
passend?.vergeben
|
||||
? `Diese Personalnummer ist bereits vergeben${passend.name ? ` — an ${passend.name}` : ""}.`
|
||||
: undefined
|
||||
}
|
||||
hint={laeuft ? "Wird geprüft…" : "Muss mit Loga und Interflex übereinstimmen. Wird nicht automatisch vergeben."}
|
||||
/>
|
||||
);
|
||||
}
|
||||
239
components/hire/RehireWizard.tsx
Normal file
239
components/hire/RehireWizard.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
"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(),
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Button } from "@/components/ui/Button";
|
||||
import { SelectField, TextField } from "@/components/ui/Field";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import { formatSvnr, svnrErrorMessage, validateSvnr } from "@/lib/svnr";
|
||||
import type { RelationshipType } from "@/lib/supabase/types";
|
||||
import type { RelationshipType } from "@/lib/types";
|
||||
import type { HireDraftAngehoerige, HireDraftData } from "./types";
|
||||
|
||||
const VERHAELTNIS: RelationshipType[] = ["Ehepartner:in", "Lebenspartner:in", "Kind", "Sonstige"];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SelectField, TextField } from "@/components/ui/Field";
|
||||
import { EMERGENCY_RELATIONS } from "@/lib/supabase/types";
|
||||
import { EMERGENCY_RELATIONS } from "@/lib/types";
|
||||
import type { HireDraftData } from "./types";
|
||||
|
||||
// Eigener Schritt, kurz vor der Zusammenfassung.
|
||||
|
||||
@@ -1,25 +1,44 @@
|
||||
import { SvNummerField } from "@/components/employees/SvNummerField";
|
||||
import { TitleFields } from "@/components/employees/TitleFields";
|
||||
import { SelectField, TextField } from "@/components/ui/Field";
|
||||
import { PersonalnummerField } from "./PersonalnummerField";
|
||||
import type { HireDraftData } from "./types";
|
||||
|
||||
type StepPersonProps = {
|
||||
draft: HireDraftData;
|
||||
update: (patch: Partial<HireDraftData>) => void;
|
||||
locations: { id: string; name: string; country: string }[];
|
||||
/** Meldet die geprüfte Personalnummer und ihr Ergebnis — steuert „Weiter". */
|
||||
onNummerBefund?: (befund: { nummer: number; vergeben: boolean }) => void;
|
||||
/**
|
||||
* Beim Wiedereintritt: die Nummer steht fest und wird nur gezeigt.
|
||||
*
|
||||
* Zwei Gründe. Es ist dieselbe Person und sie behält ihre Nummer — und die
|
||||
* Prüfung auf Dubletten fände hier zwangsläufig einen Treffer, nämlich sie
|
||||
* selbst, und sperrte das Formular mit einer Meldung, die stimmt und
|
||||
* trotzdem in die Irre führt.
|
||||
*/
|
||||
nummerGesperrt?: boolean;
|
||||
};
|
||||
|
||||
export function StepPerson({ draft, update, locations }: StepPersonProps) {
|
||||
export function StepPerson({ draft, update, locations, onNummerBefund, nummerGesperrt }: StepPersonProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<TextField
|
||||
label="Personalnummer"
|
||||
required
|
||||
inputMode="numeric"
|
||||
value={draft.personnelNumber}
|
||||
onChange={(personnelNumber) => update({ personnelNumber: personnelNumber.replace(/\D/g, "") })}
|
||||
hint="Muss mit Loga und Interflex übereinstimmen. Wird nicht automatisch vergeben."
|
||||
/>
|
||||
{nummerGesperrt ? (
|
||||
<TextField
|
||||
label="Personalnummer"
|
||||
value={draft.personnelNumber}
|
||||
onChange={() => {}}
|
||||
disabled
|
||||
hint="Bleibt bei einem Wiedereintritt dieselbe."
|
||||
/>
|
||||
) : (
|
||||
<PersonalnummerField
|
||||
wert={draft.personnelNumber}
|
||||
onChange={(personnelNumber) => update({ personnelNumber })}
|
||||
onBefund={onNummerBefund ?? (() => {})}
|
||||
/>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<TextField label="Vorname" required value={draft.firstName} onChange={(firstName) => update({ firstName })} />
|
||||
<TextField label="Nachname" required value={draft.lastName} onChange={(lastName) => update({ lastName })} />
|
||||
|
||||
@@ -50,7 +50,7 @@ export function StepSummary({ draft, selectedPosition, locations }: StepSummaryP
|
||||
["Vertragsart", draft.contractType === "befristet" ? `befristet bis ${fmtDate(draft.contractEndDate)}` : "unbefristet"],
|
||||
["Beschäftigungsausmaß", `${draft.employmentType} (${draft.weeklyHours} h)`],
|
||||
["Paygrade", PAYGRADE_LABELS[draft.paygrade]],
|
||||
["Angestellte:r / Arbeiter:in", draft.workerType],
|
||||
["Beschäftigtengruppe", draft.workerType],
|
||||
["Kollektivvertrag", draft.collectiveAgreement],
|
||||
["Arbeitstage", draft.workDays.join(", ") || "–"],
|
||||
["Merkmale", flags.length > 0 ? flags.join(", ") : "–"],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { RoleEmploymentFields } from "@/components/employees/RoleEmploymentFields";
|
||||
import { SelectField, TextField } from "@/components/ui/Field";
|
||||
import type { PaygradeType } from "@/lib/supabase/types";
|
||||
import type { PaygradeType } from "@/lib/types";
|
||||
import type { HireDraftData } from "./types";
|
||||
|
||||
const PAYGRADES: { value: PaygradeType; label: string; description: string }[] = [
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CollectiveAgreement, ContractType, DienstwagenArt, EmploymentType, GenderType, PaygradeType, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types";
|
||||
import { MITARBEITERART_STANDARD } from "@/lib/mitarbeiterart";
|
||||
import type { CollectiveAgreement, ContractType, DienstwagenArt, EmploymentType, GenderType, Mitarbeiterart, PaygradeType, RelationshipType, Weekday, WorkerType } from "@/lib/types";
|
||||
|
||||
// The spec's hire wizard field list (§4.4) omits Geschlecht and Standort even
|
||||
// though both are NOT NULL on employees — added here (defaults keep them
|
||||
@@ -56,9 +57,19 @@ export type HireDraftData = {
|
||||
angehoerige: HireDraftAngehoerige[];
|
||||
isLateraleFuehrung: boolean;
|
||||
isCLevel: boolean;
|
||||
/** Form der Beschäftigung — zweite Achse neben der Beschäftigtengruppe. */
|
||||
mitarbeiterart: Mitarbeiterart;
|
||||
hasKuendigungsschutz: boolean;
|
||||
/** Freiwillig — leer heisst „bis auf Weiteres". */
|
||||
kuendigungsschutzBis: string;
|
||||
/** Personenkreis nach dem besonderen Kündigungsschutz; leer heisst „nicht erfasst". */
|
||||
kuendigungsschutzGrund: string;
|
||||
kuendigungsschutzAb: string;
|
||||
istBeguenstigtBehindert: boolean;
|
||||
/** Grad in Prozent als Text — ein leeres Zahlenfeld wäre sonst 0. */
|
||||
behinderungGrad: string;
|
||||
behinderungAb: string;
|
||||
behinderungBis: string;
|
||||
};
|
||||
|
||||
export const EMPTY_HIRE_DRAFT: HireDraftData = {
|
||||
@@ -93,6 +104,13 @@ export const EMPTY_HIRE_DRAFT: HireDraftData = {
|
||||
angehoerige: [],
|
||||
isLateraleFuehrung: false,
|
||||
isCLevel: false,
|
||||
mitarbeiterart: MITARBEITERART_STANDARD,
|
||||
hasKuendigungsschutz: false,
|
||||
kuendigungsschutzBis: "",
|
||||
kuendigungsschutzGrund: "",
|
||||
kuendigungsschutzAb: "",
|
||||
istBeguenstigtBehindert: false,
|
||||
behinderungGrad: "",
|
||||
behinderungAb: "",
|
||||
behinderungBis: "",
|
||||
};
|
||||
|
||||
@@ -171,7 +171,7 @@ export function ImportWorkbench() {
|
||||
function BefundListe({ titel, befunde, art }: { titel: string; befunde: Befund[]; art: "fehler" | "hinweis" }) {
|
||||
const sichtbar = befunde.slice(0, MAX_ANZEIGE);
|
||||
return (
|
||||
<section className={`overflow-x-auto ${CARD_CLASS}`}>
|
||||
<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>
|
||||
|
||||
@@ -156,7 +156,7 @@ export function EmployeeTree({ employees, focusId = null }: { employees: OrgEmpl
|
||||
<div key={e.id}>
|
||||
<div
|
||||
ref={e.id === focusId ? focusRef : undefined}
|
||||
className={`flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface ${isMatch ? "bg-brand-100" : ""}`}
|
||||
className={`flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface ${isMatch ? "bg-accent-200" : ""}`}
|
||||
style={{ paddingLeft: depth * 24 + 8 }}
|
||||
>
|
||||
{hasChildren ? (
|
||||
|
||||
@@ -23,11 +23,15 @@ import type { ChartNode, ChartNodeKind } from "./types";
|
||||
// object as a change and remounts every custom node otherwise.
|
||||
const NODE_TYPES = { orgNode: OrgChartNode };
|
||||
|
||||
// React Flow nimmt echte Farbwerte, keine Klassen — deshalb stehen die
|
||||
// Token hier als Literal. Sie folgen KIND_ACCENT in OrgChartNode; laeuft das
|
||||
// eine dem anderen davon, zeigt die Uebersichtskarte andere Farben als die
|
||||
// Karten, ueber die sie liegt.
|
||||
const MINIMAP_COLORS: Record<ChartNodeKind, string> = {
|
||||
person: "#d6046e",
|
||||
role: "#5c2e91",
|
||||
group: "#c9b3c0",
|
||||
vacancy: "#f6cfe2",
|
||||
person: "#164194", // brand-500, Manner Blau
|
||||
role: "#54277f", // purple-text
|
||||
group: "#756c6e", // ink-muted
|
||||
vacancy: "#f69686", // accent-500, Manner Rosa
|
||||
};
|
||||
|
||||
export type GraphOrgChartProps = {
|
||||
@@ -118,7 +122,7 @@ function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds }: GraphOrg
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={22} size={1.4} color="#eedde6" />
|
||||
<Background variant={BackgroundVariant.Dots} gap={22} size={1.4} color="#f0dbd4" />
|
||||
<Controls showInteractive={false} />
|
||||
{/* Hidden under lg via CSS: on a phone it would cover a real
|
||||
fraction of the canvas for little navigational benefit. */}
|
||||
@@ -126,8 +130,8 @@ function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds }: GraphOrg
|
||||
pannable
|
||||
zoomable
|
||||
ariaLabel="Übersichtskarte"
|
||||
maskColor="rgba(249, 241, 245, 0.75)"
|
||||
nodeColor={(n) => MINIMAP_COLORS[(n.data as OrgChartNodeDataLike).chartNode.kind] ?? "#d6046e"}
|
||||
maskColor="rgba(253, 246, 244, 0.75)"
|
||||
nodeColor={(n) => MINIMAP_COLORS[(n.data as OrgChartNodeDataLike).chartNode.kind] ?? "#164194"}
|
||||
nodeStrokeWidth={0}
|
||||
nodeBorderRadius={3}
|
||||
/>
|
||||
|
||||
@@ -25,14 +25,18 @@ const KIND_ACCENT: Record<ChartNodeKind, string> = {
|
||||
person: "bg-brand-500",
|
||||
role: "bg-purple-text",
|
||||
group: "bg-ink-muted",
|
||||
vacancy: "bg-brand-200",
|
||||
// Rosa statt einer blassen Blaustufe: eine offene Stelle ist kein
|
||||
// schwaecheres Abbild einer Person, sondern etwas anderes. Gegen das
|
||||
// Blau der Person ist der Unterschied auch dann noch zu sehen, wenn
|
||||
// herausgezoomt nur noch die Streifen uebrig sind.
|
||||
vacancy: "bg-accent-500",
|
||||
};
|
||||
|
||||
const KIND_SHELL: Record<ChartNodeKind, string> = {
|
||||
person: "border-border bg-white",
|
||||
role: "border-border bg-white",
|
||||
group: "border-border-subtle bg-surface",
|
||||
vacancy: "border-dashed border-brand-200 bg-brand-50",
|
||||
vacancy: "border-dashed border-accent-500 bg-accent-50",
|
||||
};
|
||||
|
||||
// React Flow re-renders node components on every pan/zoom frame — memo is
|
||||
@@ -48,7 +52,7 @@ export const OrgChartNode = memo(function OrgChartNode({ id, data }: NodeProps<O
|
||||
{kind === "person" && avatar ? (
|
||||
<Avatar firstName={avatar.firstName} lastName={avatar.lastName} size="sm" />
|
||||
) : kind === "vacancy" ? (
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-dashed border-brand-200 text-brand-500">
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-dashed border-accent-500 text-accent-700">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
) : kind === "role" ? (
|
||||
|
||||
@@ -538,10 +538,12 @@ function Stapel({ units, ebenen, mitPersonen }: { units: PrintUnit[]; ebenen: nu
|
||||
const KOPFFARBE: Record<PrintUnit["unitType"], string> = {
|
||||
// Nach unten hin blasser: so bleibt die Rangfolge auch auf dem
|
||||
// Schwarzweissdrucker sichtbar, auf dem die meisten Ausdrucke landen.
|
||||
// Vier Stufen einer Farbe statt drei plus der Seitenflaeche: so ist es
|
||||
// eine Reihe und nicht eine Reihe mit einem Fremdkoerper am Ende.
|
||||
Gesellschaft: "bg-brand-500 text-white",
|
||||
Bereich: "bg-brand-100 text-ink",
|
||||
Abteilung: "bg-brand-50 text-ink",
|
||||
Team: "bg-surface text-ink",
|
||||
Bereich: "bg-brand-200 text-ink",
|
||||
Abteilung: "bg-brand-100 text-ink",
|
||||
Team: "bg-brand-50 text-ink",
|
||||
};
|
||||
|
||||
function Kasten({ unit, mitPersonen }: { unit: PrintUnit; mitPersonen: boolean }) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { Copy, Plus, Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { deletePosition } from "@/actions/positions";
|
||||
import { clonePosition, deletePosition } from "@/actions/positions";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { fmtDate, todayIso } from "@/lib/format";
|
||||
@@ -25,6 +25,7 @@ export function PositionsPageClient({ openPositions, units, kostenstellen }: Pos
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<OpenPositionWithDays | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [cloningId, setCloningId] = useState<string | null>(null);
|
||||
|
||||
// Getrennt, weil es zwei verschiedene Aussagen sind: „hier fehlt jemand"
|
||||
// und „das entsteht erst". In einer Liste vermischt liest sich eine
|
||||
@@ -32,6 +33,18 @@ export function PositionsPageClient({ openPositions, units, kostenstellen }: Pos
|
||||
const offen = openPositions.filter((p) => !p.future);
|
||||
const kuenftig = openPositions.filter((p) => p.future);
|
||||
|
||||
async function handleClone(p: OpenPositionWithDays) {
|
||||
setCloningId(p.id);
|
||||
const result = await clonePosition({ position_id: p.id });
|
||||
setCloningId(null);
|
||||
if (result.success) {
|
||||
showToast(`Planstelle ${p.position_number} kopiert.`);
|
||||
router.refresh();
|
||||
} else {
|
||||
showToast(result.error ?? "Fehler beim Klonen.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
setDeletingId(id);
|
||||
const result = await deletePosition(id);
|
||||
@@ -77,15 +90,33 @@ export function PositionsPageClient({ openPositions, units, kostenstellen }: Pos
|
||||
)}
|
||||
</button>
|
||||
|
||||
<Button
|
||||
variant="icon"
|
||||
onClick={() => handleDelete(p.id)}
|
||||
pending={deletingId === p.id}
|
||||
aria-label={`Planstelle ${p.position_number} (${p.title}) entfernen`}
|
||||
className="absolute right-2 top-2 hover:!text-danger-solid"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<span className="absolute right-2 top-2 flex items-center gap-0.5">
|
||||
{/* Nicht bei einer Leitung: je Einheit gibt es genau eine, ein Klon
|
||||
wäre also entweder ein Fehler aus dem Maschinenraum oder eine
|
||||
Planstelle, die stillschweigend anders ist als ihre Vorlage. Die
|
||||
SQL-Funktion weist es ohnehin ab — der fehlende Knopf erspart
|
||||
den Weg dorthin. */}
|
||||
{!p.is_chief && (
|
||||
<Button
|
||||
variant="icon"
|
||||
onClick={() => handleClone(p)}
|
||||
pending={cloningId === p.id}
|
||||
aria-label={`Planstelle ${p.position_number} (${p.title}) klonen`}
|
||||
title="Klonen — gleiche Einheit, Tätigkeit und Kostenstelle, neue Nummer"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="icon"
|
||||
onClick={() => handleDelete(p.id)}
|
||||
pending={deletingId === p.id}
|
||||
aria-label={`Planstelle ${p.position_number} (${p.title}) entfernen`}
|
||||
className="hover:!text-danger-solid"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
AVERAGE_MEASURES,
|
||||
EVENT_DATE_OPEN,
|
||||
EVENT_GROUP_LABELS,
|
||||
EREIGNIS_AUSWAHL,
|
||||
EREIGNIS_UNTERTYP,
|
||||
EVENT_REPORT_PRESETS,
|
||||
EVENT_TYPE_LABELS,
|
||||
GROUP_LABELS,
|
||||
@@ -31,16 +33,20 @@ import {
|
||||
sortKeysForDimension,
|
||||
STATUS_OPTIONS,
|
||||
todayIso,
|
||||
untertypOptionen,
|
||||
type EventGroupDimension,
|
||||
type GroupDimension,
|
||||
type Measure,
|
||||
type ReportRow,
|
||||
type UnitOption,
|
||||
} from "@/lib/reports";
|
||||
import type { HistoryEventType } from "@/lib/supabase/types";
|
||||
import type { HistoryEventType } from "@/lib/types";
|
||||
|
||||
const SPLIT_COLORS = ["bg-brand-500", "bg-info-text", "bg-purple-text", "bg-warning-text", "bg-success-text", "bg-danger-solid"];
|
||||
const EVENT_TYPES = Object.keys(EVENT_TYPE_LABELS) as HistoryEventType[];
|
||||
const EVENT_TYPES = EREIGNIS_AUSWAHL;
|
||||
|
||||
/** Wie viele Namen eine aufgeklappte Zeile zeigt, bevor der Rest auf Klick kommt. */
|
||||
const VORSCHAU_NAMEN = 12;
|
||||
|
||||
type SavedReport = { id: string; name: string; config: Record<string, unknown> };
|
||||
type OrgOption = { id: string; name: string };
|
||||
@@ -69,6 +75,8 @@ type EventsProps = CommonProps & {
|
||||
eventGroup: EventGroupDimension;
|
||||
eventSplit: EventGroupDimension | "";
|
||||
eventType: string;
|
||||
/** Beendigungsart bzw. Art der Langzeitabwesenheit; leer heisst „alle". */
|
||||
eventSubtype: string;
|
||||
eventFilters: { division: string; location: string; from: string; to: string };
|
||||
};
|
||||
|
||||
@@ -227,6 +235,10 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const { showToast } = useToast();
|
||||
const [expandedKey, setExpandedKey] = useState<string | null>(null);
|
||||
// Welche aufgeklappte Zeile ihre Namen **vollständig** zeigt. Vorher stand
|
||||
// dort „+794 weitere" als blosser Text: die Auskunft, dass es mehr gibt,
|
||||
// ohne einen Weg dorthin. Wer die Liste brauchte, musste sie exportieren.
|
||||
const [volleListeKey, setVolleListeKey] = useState<string | null>(null);
|
||||
const [saveModalOpen, setSaveModalOpen] = useState(false);
|
||||
const [newReportName, setNewReportName] = useState("");
|
||||
const [savingReport, setSavingReport] = useState(false);
|
||||
@@ -439,12 +451,35 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
|
||||
label="Ereignistyp"
|
||||
dense
|
||||
value={props.eventType}
|
||||
onChange={(v) => updateParams({ eventType: v || undefined })}
|
||||
// Der Untertyp gehört zum Ereignistyp und fällt mit ihm weg.
|
||||
// Bliebe er in der Adresse stehen, filterte eine Auswahl
|
||||
// weiter, die auf dem Bildschirm nicht mehr zu sehen ist.
|
||||
onChange={(v) => updateParams({ eventType: v || undefined, eventSubtype: undefined })}
|
||||
options={[
|
||||
{ value: "", label: "Alle Ereignistypen" },
|
||||
...EVENT_TYPES.map((t) => ({ value: t, label: EVENT_TYPE_LABELS[t] })),
|
||||
]}
|
||||
/>
|
||||
{/* Nur dort, wo es einen gibt: eine Versetzung hat keine
|
||||
Beendigungsart, und ein leeres zweites Feld wäre eine Frage
|
||||
ohne Antwortmöglichkeit. */}
|
||||
{(() => {
|
||||
const typ = props.eventType ? (props.eventType as HistoryEventType) : null;
|
||||
const untertyp = typ ? EREIGNIS_UNTERTYP[typ] : undefined;
|
||||
if (!untertyp) return null;
|
||||
return (
|
||||
<SelectField
|
||||
label={untertyp.label}
|
||||
dense
|
||||
value={props.eventSubtype}
|
||||
onChange={(v) => updateParams({ eventSubtype: v || undefined })}
|
||||
options={[
|
||||
{ value: "", label: untertyp.alle },
|
||||
...untertypOptionen(typ).map((o) => ({ value: o, label: o })),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
<SelectField
|
||||
label="Gruppieren nach"
|
||||
dense
|
||||
@@ -677,7 +712,16 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
|
||||
const isExpanded = expandedKey === row.key;
|
||||
return (
|
||||
<div key={row.key}>
|
||||
<button type="button" onClick={() => setExpandedKey(isExpanded ? null : row.key)} className="w-full text-left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setExpandedKey(isExpanded ? null : row.key);
|
||||
// Zuklappen setzt auch die volle Liste zurück: sonst
|
||||
// stünde sie beim nächsten Aufklappen unerwartet offen.
|
||||
setVolleListeKey(null);
|
||||
}}
|
||||
className="w-full text-left"
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between text-sm">
|
||||
<span className="font-semibold text-ink">{row.key}</span>
|
||||
<span className="text-ink-body">
|
||||
@@ -695,23 +739,41 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="mt-2 rounded border border-border bg-surface p-3">
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{row.people.slice(0, 12).map((p, i) => (
|
||||
<li key={`${p.id}-${i}`} className="flex items-center justify-between py-1.5 text-sm">
|
||||
<Link href={`/employees/${p.id}`} className="font-semibold text-ink hover:text-brand-700 hover:underline">
|
||||
{p.name}
|
||||
</Link>
|
||||
<span className="text-xs text-ink-muted">
|
||||
{p.title} · {p.team} · {fmtDate(p.entry_date)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{row.people.length > 12 && <p className="mt-2 text-xs text-ink-muted">+{row.people.length - 12} weitere</p>}
|
||||
</div>
|
||||
)}
|
||||
{isExpanded &&
|
||||
(() => {
|
||||
const alle = volleListeKey === row.key;
|
||||
const sichtbar = alle ? row.people : row.people.slice(0, VORSCHAU_NAMEN);
|
||||
const rest = row.people.length - VORSCHAU_NAMEN;
|
||||
return (
|
||||
<div className="mt-2 rounded border border-border bg-surface p-3">
|
||||
{/* Bei achthundert Namen scrollt die Liste in sich,
|
||||
statt die Auswertung darüber aus dem Bild zu
|
||||
schieben. */}
|
||||
<ul className={`flex flex-col divide-y divide-border ${alle ? "max-h-[28rem] overflow-y-auto" : ""}`}>
|
||||
{sichtbar.map((p, i) => (
|
||||
<li key={`${p.id}-${i}`} className="flex items-center justify-between gap-3 py-1.5 text-sm">
|
||||
<Link href={`/employees/${p.id}`} className="font-semibold text-ink hover:text-brand-700 hover:underline">
|
||||
{p.name}
|
||||
</Link>
|
||||
<span className="text-right text-xs text-ink-muted">
|
||||
{p.title} · {p.team} · {fmtDate(p.entry_date)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{rest > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVolleListeKey(alle ? null : row.key)}
|
||||
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 ? `Nur die ersten ${VORSCHAU_NAMEN} zeigen` : `+${rest} weitere anzeigen`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -8,17 +8,39 @@ import { Topbar } from "./Topbar";
|
||||
// Owns the one piece of state the shell needs (is the mobile drawer open),
|
||||
// so app/(app)/layout.tsx can stay a Server Component and keep doing its
|
||||
// auth check and data loading on the server.
|
||||
export function AppShell({ userLabel, openNotes, children }: { userLabel: string; openNotes: OpenNote[]; children: ReactNode }) {
|
||||
export function AppShell({
|
||||
userLabel,
|
||||
openNotes,
|
||||
kollegen,
|
||||
children,
|
||||
}: {
|
||||
userLabel: string;
|
||||
openNotes: OpenNote[];
|
||||
kollegen: { id: string; name: string; sichtbar: boolean }[];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const closeNav = useCallback(() => setNavOpen(false), []);
|
||||
|
||||
// Auf Papier gehört von der Hülle nichts hin: Navigation, Glocke und
|
||||
// Abmelde-Knopf sind Bedienelemente, und gedruckt sind sie bestenfalls
|
||||
// Zierrat — auf einer Checkliste, die in eine Personalakte geht, sogar
|
||||
// irreführend. Die Regel steht hier und nicht auf der einzelnen Druckseite,
|
||||
// damit jede weitere sie mitbekommt, statt sie zu wiederholen.
|
||||
//
|
||||
// Auch die Ränder fallen weg: die Satzfläche bestimmt @page auf der
|
||||
// Druckseite selbst, und die Polsterung der Hülle käme obendrauf.
|
||||
return (
|
||||
<div className="min-h-dvh">
|
||||
<Sidebar open={navOpen} onClose={closeNav} />
|
||||
<div className="flex min-h-dvh flex-col lg:ml-[236px]">
|
||||
<Topbar userLabel={userLabel} openNotes={openNotes} onOpenNav={() => setNavOpen(true)} />
|
||||
<main className="flex-1 px-4 py-4 pb-[max(1rem,env(safe-area-inset-bottom))] sm:px-6 sm:py-6">
|
||||
<div className="mx-auto w-full max-w-[1280px]">{children}</div>
|
||||
<div className="min-h-dvh print:min-h-0">
|
||||
<div className="print:hidden">
|
||||
<Sidebar open={navOpen} onClose={closeNav} />
|
||||
</div>
|
||||
<div className="flex min-h-dvh flex-col lg:ml-[236px] print:ml-0 print:block print:min-h-0">
|
||||
<div className="print:hidden">
|
||||
<Topbar userLabel={userLabel} openNotes={openNotes} kollegen={kollegen} onOpenNav={() => setNavOpen(true)} />
|
||||
</div>
|
||||
<main className="flex-1 px-4 py-4 pb-[max(1rem,env(safe-area-inset-bottom))] sm:px-6 sm:py-6 print:p-0">
|
||||
<div className="mx-auto w-full max-w-[1280px] print:max-w-none">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { Bell } from "lucide-react";
|
||||
import { Bell, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { completeEmployeeNote } from "@/actions/employees";
|
||||
import { setNotizSichtbarkeit } from "@/actions/notes";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { NOTE_CATEGORY_STYLES } from "@/lib/colors";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import type { OpenNote } from "@/lib/notes";
|
||||
|
||||
export type Kollege = { id: string; name: string; sichtbar: boolean };
|
||||
|
||||
// Click-outside mechanics borrowed from CountryPicker (useRef + mousedown
|
||||
// listener) — without its draft-text reset, which has no equivalent here.
|
||||
export function NotesBell({ notes }: { notes: OpenNote[] }) {
|
||||
export function NotesBell({ notes, kollegen }: { notes: OpenNote[]; kollegen: Kollege[] }) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [completingId, setCompletingId] = useState<string | null>(null);
|
||||
const [zeigeEinstellung, setZeigeEinstellung] = useState(false);
|
||||
// Der Haken springt sofort um und wird zurückgedreht, wenn das Speichern
|
||||
// scheitert. Ohne das fühlt sich jeder Klick wie eine halbe Sekunde
|
||||
// Bedenkzeit an — die Liste wird beim Durchgehen mehrfach angetippt.
|
||||
const [eigen, setEigen] = useState<Record<string, boolean>>({});
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const sichtbarkeit = (k: Kollege) => eigen[k.id] ?? k.sichtbar;
|
||||
const dazugewaehlt = kollegen.filter((k) => sichtbarkeit(k)).length;
|
||||
|
||||
async function schalte(k: Kollege) {
|
||||
const neu = !sichtbarkeit(k);
|
||||
setEigen((v) => ({ ...v, [k.id]: neu }));
|
||||
const result = await setNotizSichtbarkeit({ kollegeId: k.id, sichtbar: neu });
|
||||
if (result.success) {
|
||||
router.refresh();
|
||||
} else {
|
||||
setEigen((v) => ({ ...v, [k.id]: !neu }));
|
||||
showToast(result.error ?? "Fehler beim Speichern.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
@@ -43,10 +66,17 @@ export function NotesBell({ notes }: { notes: OpenNote[] }) {
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<button type="button" onClick={() => setOpen((o) => !o)} aria-label="Meine Notizen" className="relative rounded p-1.5 text-ink-muted hover:bg-surface">
|
||||
{/* brand-700 statt ink-muted: die Glocke sitzt im rosa Markenband der
|
||||
Kopfzeile, und ink-muted kommt darauf nur auf 2.33:1. */}
|
||||
<button type="button" onClick={() => setOpen((o) => !o)} aria-label="Meine Notizen" className="relative rounded p-1.5 text-brand-700 hover:bg-accent-100">
|
||||
<Bell className="h-4 w-4" />
|
||||
{notes.length > 0 && (
|
||||
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-danger-solid px-1 text-[10px] font-bold text-white">
|
||||
// danger-text statt danger-solid: als Flaeche auf dem rosa Band kam
|
||||
// das hellere Rot nur auf 2.78:1 und blieb damit unter den 3:1, die
|
||||
// WCAG 1.4.11 fuer ein bedeutungstragendes Element verlangt. Der
|
||||
// dunklere Ton derselben Familie erreicht 3.26:1, und die Ziffer
|
||||
// darin steht mit 7.12:1 besser als vorher.
|
||||
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-danger-text px-1 text-[10px] font-bold text-white">
|
||||
{notes.length}
|
||||
</span>
|
||||
)}
|
||||
@@ -54,7 +84,58 @@ export function NotesBell({ notes }: { notes: OpenNote[] }) {
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 z-20 mt-2 max-h-[28rem] w-96 overflow-y-auto rounded border border-border bg-white shadow-[var(--shadow-card-hover)]">
|
||||
<div className="border-b border-border px-4 py-2.5 text-xs font-bold uppercase tracking-wide text-ink-muted">Meine Notizen ({notes.length})</div>
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border px-4 py-2.5">
|
||||
<span className="text-xs font-bold uppercase tracking-wide text-ink-muted">
|
||||
Meine Notizen ({notes.length})
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZeigeEinstellung((v) => !v)}
|
||||
aria-expanded={zeigeEinstellung}
|
||||
className={`flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-semibold hover:bg-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
|
||||
zeigeEinstellung ? "text-brand-700" : "text-ink-muted"
|
||||
}`}
|
||||
>
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
Kolleg:innen
|
||||
{/* Nur wenn jemand dazugewählt ist: eine „0" bei jedem
|
||||
Aufschlagen zu lesen, sagt nichts. */}
|
||||
{dazugewaehlt > 0 && <span className="text-brand-700">+{dazugewaehlt}</span>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{zeigeEinstellung && (
|
||||
<div className="border-b border-border bg-surface px-4 py-3">
|
||||
{/* Dass die Auswahl auch die Entwürfe auf der Übersicht
|
||||
steuert, steht hier und nicht nur dort: sie wird hier
|
||||
getroffen, und eine Wirkung, die zwei Klicks entfernt
|
||||
sichtbar wird, sucht sonst niemand. */}
|
||||
<p className="mb-2 text-xs text-ink-muted">
|
||||
Wessen Notizen hier und wessen Entwürfe auf der Übersicht zusätzlich erscheinen. Die
|
||||
eigenen sind immer dabei.
|
||||
</p>
|
||||
{kollegen.length === 0 ? (
|
||||
<p className="py-2 text-sm text-ink-muted">Keine weiteren HR-Kolleg:innen freigeschaltet.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{kollegen.map((k) => (
|
||||
<li key={k.id}>
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded px-1 py-1 text-sm hover:bg-white">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sichtbarkeit(k)}
|
||||
onChange={() => schalte(k)}
|
||||
className="h-4 w-4 rounded border-border text-brand-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
/>
|
||||
<span className={sichtbarkeit(k) ? "text-ink" : "text-ink-muted line-through"}>{k.name}</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notes.length === 0 ? (
|
||||
<p className="px-4 py-6 text-center text-sm text-ink-muted">Keine offenen Notizen.</p>
|
||||
) : (
|
||||
@@ -69,6 +150,13 @@ export function NotesBell({ notes }: { notes: OpenNote[] }) {
|
||||
<span className="text-xs text-ink-muted">{fmtDate(n.created_at)}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-ink-body">{n.note_text}</p>
|
||||
{/* Von wem die Notiz stammt. Sichtbar sind die eigenen und
|
||||
die hinzugewählten — ohne diese Zeile wäre nicht zu
|
||||
unterscheiden, welche davon man gerade liest. „Ich"
|
||||
statt des eigenen Namens: den kennt man. */}
|
||||
<p className="mt-1 text-xs text-ink-muted">
|
||||
{n.vonMir ? "von mir" : `von ${n.author_name}`}
|
||||
</p>
|
||||
{n.due_date && <p className="mt-1 text-xs text-warning-text">🔔 fällig {fmtDate(n.due_date)}</p>}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BarChart3, Building2, History, LayoutGrid, Network, Upload, Users, X }
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { AppWortmarke } from "@/components/brand/Logo";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: "/", label: "Übersicht", icon: LayoutGrid },
|
||||
@@ -55,13 +56,20 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void
|
||||
open ? "translate-x-0" : "-translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border pl-5 pr-3">
|
||||
<span className="text-base font-extrabold text-brand-700">Alpenwerk HR</span>
|
||||
{/* pl-2 statt pl-5: der Freiraum des Logos bringt seinen eigenen
|
||||
Abstand mit (siehe components/brand/Logo.tsx), und beides
|
||||
uebereinander schoebe die Wortmarke sichtbar aus der Flucht der
|
||||
Navigationspunkte darunter. */}
|
||||
{/* Rosa wie die Kopfzeile daneben: beide zusammen sind ein
|
||||
durchgehendes Band ueber der Anwendung, in dem das Logo mit seinem
|
||||
eigenen rosa Feld aufgeht statt als Rechteck darauf zu liegen. */}
|
||||
<div className="flex h-14 shrink-0 items-center justify-between border-b border-accent-600/40 bg-accent-500 pl-2 pr-3">
|
||||
<AppWortmarke hoehe={24} aufRosa textKlasse="text-brand-700" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Navigation schließen"
|
||||
className="-mr-1 rounded p-2 text-ink-muted hover:bg-surface lg:hidden"
|
||||
className="-mr-1 rounded p-2 text-brand-700 hover:bg-accent-100 lg:hidden"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
@@ -25,36 +25,50 @@ function titleFor(pathname: string): string {
|
||||
type TopbarProps = {
|
||||
userLabel: string;
|
||||
openNotes: OpenNote[];
|
||||
kollegen: { id: string; name: string; sichtbar: boolean }[];
|
||||
onOpenNav: () => void;
|
||||
};
|
||||
|
||||
export function Topbar({ userLabel, openNotes, onOpenNav }: TopbarProps) {
|
||||
export function Topbar({ userLabel, openNotes, kollegen, onOpenNav }: TopbarProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
// Sticky, with the notch inset added to its top padding: on an iPhone in
|
||||
// landscape the bar would otherwise sit under the rounded corner.
|
||||
<header className="sticky top-0 z-20 flex h-14 shrink-0 items-center justify-between border-b border-border bg-white px-3 pt-[env(safe-area-inset-top)] sm:px-6">
|
||||
//
|
||||
// Rosa und nicht Weiss: zusammen mit dem Kopf der Seitenleiste bildet
|
||||
// dieser Streifen das durchgehende Markenband ueber der Anwendung. Auf
|
||||
// Weiss stand das Logo daneben als ausgeschnittenes Rechteck, weil es
|
||||
// sein rosa Feld mitbringt; in einem rosa Band geht es darin auf.
|
||||
//
|
||||
// Geschrieben wird darauf in brand-700 und nicht in ink: Schwarz auf Rosa
|
||||
// traegt zwar (7.47:1), sieht aber aus wie Text, der aus Versehen auf der
|
||||
// Marke gelandet ist. Dunkelblau auf Rosa ist dieselbe Paarung, aus der
|
||||
// der Schriftzug selbst besteht, und kommt auf 6.60:1.
|
||||
//
|
||||
// Gedaempftes gibt es hier nicht mehr: ink-muted liegt auf Rosa bei
|
||||
// 2.33:1. Gestuft wird ueber Groesse und Gewicht.
|
||||
<header className="sticky top-0 z-20 flex h-14 shrink-0 items-center justify-between border-b border-accent-600/40 bg-accent-500 px-3 pt-[env(safe-area-inset-top)] sm:px-6">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenNav}
|
||||
aria-label="Navigation öffnen"
|
||||
className="-ml-1 rounded p-2 text-ink-body hover:bg-surface lg:hidden"
|
||||
className="-ml-1 rounded p-2 text-brand-700 hover:bg-accent-100 lg:hidden"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
<h1 className="truncate text-base font-bold text-ink">{titleFor(pathname)}</h1>
|
||||
<h1 className="truncate text-base font-bold text-brand-700">{titleFor(pathname)}</h1>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1 sm:gap-4">
|
||||
<NotesBell notes={openNotes} />
|
||||
<NotesBell notes={openNotes} kollegen={kollegen} />
|
||||
<NewHireButton />
|
||||
<div className="flex items-center gap-2 text-sm sm:border-l sm:border-border sm:pl-4">
|
||||
<div className="flex items-center gap-2 text-sm sm:border-l sm:border-brand-700/25 sm:pl-4">
|
||||
{/* The name is the first thing worth dropping on a narrow screen —
|
||||
the account is still reachable via the sign-out control. */}
|
||||
<span className="hidden font-semibold text-ink md:inline">{userLabel}</span>
|
||||
<span className="hidden font-semibold text-brand-700 md:inline">{userLabel}</span>
|
||||
<form action={logout}>
|
||||
<button type="submit" aria-label="Abmelden" className="rounded p-2 text-ink-muted hover:bg-surface">
|
||||
<button type="submit" aria-label="Abmelden" className="rounded p-2 text-brand-700 hover:bg-accent-100">
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AuditChange } from "@/lib/supabase/types";
|
||||
import type { AuditChange } from "@/lib/types";
|
||||
|
||||
// Was sich geändert hat, feldweise — im Protokoll und in der Historie einer
|
||||
// Person dieselbe Darstellung. Zwei Ansichten derselben Sache verschieden zu
|
||||
|
||||
@@ -14,7 +14,10 @@ const BASE =
|
||||
|
||||
const VARIANTS = {
|
||||
primary: "bg-brand-500 text-white hover:bg-brand-600",
|
||||
secondary: "border border-border bg-white text-ink-body hover:bg-surface",
|
||||
// border-strong wie bei den Eingabefeldern: die Umrandung ist hier das
|
||||
// Einzige, was den Knopf vom Hintergrund trennt, und muss deshalb die
|
||||
// 3:1 aus WCAG 1.4.11 erreichen.
|
||||
secondary: "border border-border-strong bg-white text-ink-body hover:bg-surface",
|
||||
ghost: "text-ink-body hover:bg-surface",
|
||||
danger: "bg-danger-solid text-white hover:brightness-110",
|
||||
// Square icon-only button; pair with an aria-label.
|
||||
|
||||
@@ -12,16 +12,21 @@ import { useId, type ReactNode, type SelectHTMLAttributes, type InputHTMLAttribu
|
||||
// impossible to get wrong, and gives every field one place to fix focus
|
||||
// styling, error display and sizing.
|
||||
|
||||
// `border-strong` und nicht `border`: der blasse Kartenrahmen kommt gegen
|
||||
// Weiss auf 1.3:1 und ist als Schmuck in Ordnung, die Umrandung eines
|
||||
// Bedienelements aber nicht — WCAG 1.4.11 verlangt dafuer 3:1, und ohne sie
|
||||
// ist auf einem hellen Bildschirm nicht zu sehen, wo ein Feld anfaengt.
|
||||
// border-strong liegt bei 3.56:1.
|
||||
export const CONTROL_CLASS =
|
||||
"w-full rounded border border-border bg-white px-3 py-2 text-sm text-ink " +
|
||||
"w-full rounded border border-border-strong bg-white px-3 py-2 text-sm text-ink " +
|
||||
"focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500 " +
|
||||
"disabled:cursor-not-allowed disabled:bg-surface disabled:text-ink-muted";
|
||||
"disabled:cursor-not-allowed disabled:border-border disabled:bg-surface disabled:text-ink-muted";
|
||||
|
||||
const INVALID_CLASS = "border-danger-solid";
|
||||
|
||||
/** Auto-width select for filter bars, where the label is an aria-label. */
|
||||
export const FILTER_SELECT_CLASS =
|
||||
"rounded border border-border bg-white px-3 py-2 text-sm text-ink " +
|
||||
"rounded border border-border-strong bg-white px-3 py-2 text-sm text-ink " +
|
||||
"focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500";
|
||||
|
||||
type FieldChildProps = {
|
||||
|
||||
@@ -126,7 +126,7 @@ export function Lookup<T>({
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<div className="flex items-center gap-2 rounded border border-border bg-white px-3 py-2 focus-within:outline-2 focus-within:outline-offset-1 focus-within:outline-brand-500">
|
||||
<div className="flex items-center gap-2 rounded border border-border-strong bg-white px-3 py-2 focus-within:outline-2 focus-within:outline-offset-1 focus-within:outline-brand-500">
|
||||
<Search className="h-4 w-4 shrink-0 text-ink-muted" aria-hidden />
|
||||
<input
|
||||
id={id}
|
||||
|
||||
@@ -25,7 +25,7 @@ export function SearchInput({
|
||||
const id = useId();
|
||||
return (
|
||||
<div
|
||||
className={`flex min-w-[240px] flex-1 items-center gap-2 rounded border border-border bg-white px-3 py-2 focus-within:outline-2 focus-within:outline-offset-1 focus-within:outline-brand-500 ${className}`}
|
||||
className={`flex min-w-[240px] flex-1 items-center gap-2 rounded border border-border-strong bg-white px-3 py-2 focus-within:outline-2 focus-within:outline-offset-1 focus-within:outline-brand-500 ${className}`}
|
||||
>
|
||||
<Search className="h-4 w-4 shrink-0 text-ink-muted" aria-hidden />
|
||||
<label htmlFor={id} className="sr-only">
|
||||
|
||||
@@ -36,8 +36,27 @@ export function SlideOver({ open, onClose, title, subtitle, children, footer }:
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
tabIndex={-1}
|
||||
className={`absolute right-0 top-0 flex h-dvh w-full max-w-md flex-col bg-white shadow-[var(--shadow-overlay)] outline-none transition-transform duration-200 ${
|
||||
open ? "translate-x-0" : "translate-x-full"
|
||||
// ── Warum der Schatten am geschlossenen Panel wegfällt ──────────
|
||||
//
|
||||
// Geschlossen steht das Panel per `translate-x-full` genau hinter dem
|
||||
// rechten Bildschirmrand: die eigene linke Kante liegt auf dem Rand.
|
||||
// Der Schatten ist das Einzige, was von dort noch ins Bild reicht —
|
||||
// 32 px Weichzeichnung nach aussen, und „aussen" heisst hier: nach
|
||||
// links, in die Seite hinein.
|
||||
//
|
||||
// Bei einem Panel fällt das nicht auf. Die Protokollseite rendert
|
||||
// jedoch eines **je Zeile**, also fünfzig übereinander an derselben
|
||||
// Stelle. Bei 18 % Deckkraft bleiben nach fünfzig Lagen rechnerisch
|
||||
// 0,01 % Durchsicht — ein fast schwarzer Streifen neben der
|
||||
// Bildlaufleiste, mit sichtbaren Stufen dort, wo sich die
|
||||
// Weichzeichnungen der einzelnen Lagen überlagern. Genau das hat der
|
||||
// Kunde gemeldet.
|
||||
//
|
||||
// Die Tiefe gehört zum geöffneten Panel, nicht zum weggeschobenen.
|
||||
// Sie erst beim Öffnen zu setzen kostet nichts und nimmt dem Stapel
|
||||
// seine Wirkung.
|
||||
className={`absolute right-0 top-0 flex h-dvh w-full max-w-md flex-col bg-white outline-none transition-transform duration-200 ${
|
||||
open ? "translate-x-0 shadow-[var(--shadow-overlay)]" : "translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<div className="flex shrink-0 items-start justify-between border-b border-border px-4 py-3 pt-[max(0.75rem,env(safe-area-inset-top))] sm:px-6 sm:py-4">
|
||||
|
||||
@@ -1,19 +1,46 @@
|
||||
import { absenceLabel } from "@/lib/absence";
|
||||
import { STATUS_STYLES } from "@/lib/colors";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import type { EmploymentStatus } from "@/lib/supabase/types";
|
||||
import { deriveStatusAsOf } from "@/lib/reports";
|
||||
|
||||
type StatusChipProps = {
|
||||
status: EmploymentStatus;
|
||||
entryDate?: string | null; // shown as "Eintritt {date}" when status is Geplant
|
||||
// ── Warum der Status hier abgeleitet und nicht übergeben wird ────────
|
||||
//
|
||||
// Vorher nahm dieses Bauteil einen fertigen Status entgegen, und beide
|
||||
// Aufrufer reichten `employees.status` durch — die gespeicherte Spalte. Die
|
||||
// Liste daneben filtert aber über die Datumsspalten (lib/employee-status-
|
||||
// filter.ts), und sobald die Spalte nachhängt, widersprechen sich Filter und
|
||||
// Beschriftung: der Filter „Ausgetreten" fand 48 Personen, von denen mehrere
|
||||
// als „Aktiv" beschriftet waren.
|
||||
//
|
||||
// Nachhängen kann sie regelmässig. terminate_employee (Migration
|
||||
// 20260814100000) setzt die Spalte nur, wenn das Austrittsdatum nicht in der
|
||||
// Zukunft liegt — „es gibt keinen Lauf, der das später nachzieht" steht dort
|
||||
// als Kommentar. Ein Austritt, der zum Zeitpunkt der Erfassung noch bevorstand,
|
||||
// lässt die Spalte also für immer auf „Aktiv" stehen.
|
||||
//
|
||||
// Deshalb nimmt das Bauteil jetzt die Zeile und den Stichtag und leitet
|
||||
// selbst ab. Die Spalte lässt sich nicht mehr hineinreichen: die zweite
|
||||
// Quelle ist nicht bloss ungenutzt, es gibt sie hier nicht mehr.
|
||||
export type StatusChipEmployee = {
|
||||
entry_date: string;
|
||||
exit_date: string | null;
|
||||
karenz_start_date: string | null;
|
||||
karenz_return_date: string | null;
|
||||
/** Shown instead of the generic label when the kind of absence is known. */
|
||||
absenceType?: string | null;
|
||||
absence_type?: string | null;
|
||||
};
|
||||
|
||||
export function StatusChip({ status, entryDate, absenceType }: StatusChipProps) {
|
||||
// The stored status is still 'Karenz'; absenceLabel maps it to
|
||||
// "Langzeitabwesenheit", or to the specific kind when one is recorded.
|
||||
const label = status === "Geplant" && entryDate ? `Eintritt ${fmtDate(entryDate)}` : absenceLabel(status, absenceType);
|
||||
type StatusChipProps = {
|
||||
employee: StatusChipEmployee;
|
||||
/** Der Tag, zu dem der Status gilt — derselbe, nach dem die Seite filtert. */
|
||||
asOf: string;
|
||||
};
|
||||
|
||||
export function StatusChip({ employee, asOf }: StatusChipProps) {
|
||||
const status = deriveStatusAsOf(employee, asOf);
|
||||
// Der abgeleitete Status ist 'Karenz'; absenceLabel macht daraus
|
||||
// "Langzeitabwesenheit", oder die konkrete Art, wenn eine erfasst ist.
|
||||
const label = status === "Geplant" ? `Eintritt ${fmtDate(employee.entry_date)}` : absenceLabel(status, employee.absence_type ?? null);
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center whitespace-nowrap rounded-full px-2.5 py-0.5 text-xs font-semibold ${STATUS_STYLES[status]}`}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user