Compare commits
41 Commits
0d8af7cdf0
...
e30923e781
| Author | SHA1 | Date | |
|---|---|---|---|
| e30923e781 | |||
| 3a4f44318c | |||
| 10f8f1f2d5 | |||
| 91b2b3406b | |||
| 6957b95a97 | |||
| 16216c5537 | |||
| 861c47b757 | |||
| d2f4a7aab7 | |||
| 384bdb4fb3 | |||
| f5ace8af2e | |||
| e6554e7982 | |||
| 6681dcda77 | |||
| 0144267a59 | |||
| 8a76b3688f | |||
| a3fac47f47 | |||
| 08d2740690 | |||
| 9308096754 | |||
| 33d053ce75 | |||
| 272e8b1acf | |||
| 6297288c13 | |||
| 5f50cb97f3 | |||
| 00df973824 | |||
| 578ce696f0 | |||
| 917911457e | |||
| 64eb155dda | |||
| 0b8f874fa5 | |||
| f14f1cb8df | |||
| 3151f32404 | |||
| c23df08648 | |||
| 4eba557121 | |||
| 9d754359e0 | |||
| 53d5d41784 | |||
| f28fd2da60 | |||
| 27e0e8a8ce | |||
| 92685f0ac0 | |||
| d8a1fdf43b | |||
| 780f8fe2b7 | |||
| 56662c0775 | |||
| 61ccce5456 | |||
| ecbda3f3a5 | |||
| 27cfd6431f |
159
DEPLOYMENT.md
159
DEPLOYMENT.md
@@ -1,17 +1,83 @@
|
||||
# Deployment mit Docker
|
||||
# Deployment
|
||||
|
||||
Dieser Guide beschreibt, wie die App (bisher auf Vercel deployed, siehe
|
||||
`vercel.json`) stattdessen als Docker-Container auf einem beliebigen Server
|
||||
läuft.
|
||||
Zwei Wege, beide unterstützt. Das Abbild ist umgebungsneutral — es gibt keine
|
||||
Werte mehr, die beim Bauen eingebacken werden —, ein Wechsel ist also
|
||||
jederzeit möglich.
|
||||
|
||||
| | passt, wenn |
|
||||
|---|---|
|
||||
| [Vercel](#vercel) | ihr nichts betreiben wollt; schnellster Weg |
|
||||
| [Docker](#deployment-mit-docker) | es in eure eigene Infrastruktur soll |
|
||||
|
||||
**In beiden Fällen gleich:** die Umgebungsvariablen aus [Abschnitt 1](#1-env-anlegen),
|
||||
die Umleitungs-URI in der Entra-Registrierung, und dass eine neue Person nach
|
||||
ihrer ersten Anmeldung eine `profiles`-Zeile braucht (siehe
|
||||
[docs/entra-sso.md](docs/entra-sso.md)).
|
||||
|
||||
## Vercel
|
||||
|
||||
Das Repository liegt auf `git.elycon.solutions` — einem selbst betriebenen
|
||||
Git. **Vercels Git-Anbindung kann nur GitHub, GitLab und Bitbucket**, dieses
|
||||
Repository lässt sich dort also nicht verknüpfen. Zwei Möglichkeiten:
|
||||
|
||||
### a) Von der Arbeitsstation ausrollen (ohne GitHub)
|
||||
|
||||
```bash
|
||||
npx vercel login
|
||||
npx vercel link
|
||||
npx vercel --prod
|
||||
```
|
||||
|
||||
Funktioniert mit jedem Repository. Der Preis: kein automatisches Ausrollen
|
||||
bei einem Push — jede Veröffentlichung ist ein bewusster Befehl. Für zwei
|
||||
Personen ist das eher Vorteil als Nachteil.
|
||||
|
||||
### b) Zusätzlich nach GitHub spiegeln
|
||||
|
||||
```bash
|
||||
git remote add github git@github.com:<konto>/alpenwerk-hr.git
|
||||
git push github feat/sap-om-org-model
|
||||
```
|
||||
|
||||
Danach das GitHub-Repository in Vercel verbinden. Ab dann rollt jeder Push
|
||||
aus. Zwei Fernziele bedeuten aber auch: beide müssen gepflegt werden.
|
||||
|
||||
### Danach
|
||||
|
||||
1. **Umgebungsvariablen** im Vercel-Projekt setzen (Settings → Environment
|
||||
Variables), dieselben wie in [Abschnitt 1](#1-env-anlegen). `AUTH_URL` ist
|
||||
nicht nötig, Vercel setzt den Host selbst.
|
||||
2. **Umleitungs-URI** in der Entra-Registrierung ergänzen:
|
||||
`https://<projekt>.vercel.app/api/auth/callback/microsoft-entra-id`
|
||||
3. Der nächtliche Lauf ist über `vercel.json` bereits eingerichtet.
|
||||
|
||||
Zwei Eigenheiten der Plattform, die im Code berücksichtigt sind:
|
||||
`output: "standalone"` entfällt dort automatisch (Vercel baut selbst), und
|
||||
`/api/import` ist auf 60 Sekunden begrenzt — die Obergrenze des kostenlosen
|
||||
Tarifs. Im Pro-Tarif liessen sich 300 setzen, falls eine Importdatei mit
|
||||
vielen tausend Zeilen ansteht.
|
||||
|
||||
Der Verbindungspool passt zu serverlosen Aufrufen, **weil** `DATABASE_URL`
|
||||
auf den Transaktions-Modus zeigt (Port 6543). Mit dem Sitzungs-Modus wären
|
||||
die 15 Verbindungen des Tarifs nach wenigen gleichzeitigen Aufrufen
|
||||
verbraucht.
|
||||
|
||||
## Deployment mit Docker
|
||||
|
||||
Dieser Guide beschreibt, wie die App stattdessen als Docker-Container auf
|
||||
einem eigenen Linux-Server läuft.
|
||||
|
||||
## Was wird containerisiert – und was nicht
|
||||
|
||||
- **Containerisiert:** nur die Next.js-App selbst (`Dockerfile`).
|
||||
- **Nicht containerisiert:** Supabase (Datenbank + Auth). Die App verbindet
|
||||
sich per URL/Key zu einem bestehenden Supabase-Projekt (Cloud oder
|
||||
selbst gehostet) – das bleibt unverändert. `supabase/` in diesem Repo ist
|
||||
nur die lokale Dev-/Migrations-Umgebung (`supabase start`), kein Teil des
|
||||
Deployments.
|
||||
- **Nicht containerisiert:** die Datenbank. Die App verbindet sich über
|
||||
`DATABASE_URL` zu einem beliebigen PostgreSQL ab 15 — heute ein
|
||||
Supabase-Projekt, genauso möglich sind Azure Flexible Server, RDS,
|
||||
Cloud SQL oder eigenes Blech. `supabase/` in diesem Repo ist die
|
||||
Migrations- und Entwicklungsumgebung, kein Teil des Deployments.
|
||||
- **Ebenfalls nicht containerisiert:** die Anmeldung. Sie läuft über
|
||||
Microsoft Entra ID; die App hält nur das Sitzungscookie (Auth.js). Es gibt
|
||||
keinen Anmeldedienst, der mit ausgerollt werden müsste.
|
||||
- **Ersetzt:** der Vercel-Cron-Job aus `vercel.json` (täglich 03:00 Uhr,
|
||||
ruft `/api/cron/apply-pending-changes` auf, um fällige Versetzungen/
|
||||
Beförderungen/Karenz/Reorg-Änderungen zu übernehmen). Da es außerhalb von
|
||||
@@ -19,6 +85,39 @@ läuft.
|
||||
enthaltene `cron`-Sidecar-Container diese Aufgabe mit demselben Schema
|
||||
und demselben Bearer-Secret, das die Route bereits erwartet.
|
||||
|
||||
## Betrieb im Firmennetz — zwei Dinge vorab
|
||||
|
||||
Die App läuft intern, aber sie ist **nicht** von der Aussenwelt unabhängig.
|
||||
Beides vor der Installation klären, sonst scheitert es am Ende an der
|
||||
Firewall:
|
||||
|
||||
**1. Der Server braucht ausgehenden Zugang.** Eingehend aus dem Internet
|
||||
nichts, ausgehend zwingend:
|
||||
|
||||
| Ziel | Wofür | Ohne das |
|
||||
|---|---|---|
|
||||
| `login.microsoftonline.com` (443) | Auth.js tauscht den Anmeldecode **serverseitig** gegen ein Token und lädt die Konfiguration des Ausstellers | keine Anmeldung möglich |
|
||||
| Die Datenbank (Supabase: `*.pooler.supabase.com`, 6543) | jede Abfrage | die App startet, zeigt aber nichts |
|
||||
|
||||
Dass die Anmeldung im Browser der Person stattfindet, genügt **nicht** — der
|
||||
Tausch von Code gegen Token läuft vom Server aus. Liegt die VM in einem
|
||||
abgeschotteten Netz, ist entweder ein Proxy nötig oder eine PostgreSQL-
|
||||
Instanz im selben Netz statt Supabase.
|
||||
|
||||
**2. HTTPS ist Pflicht, auch intern.** Entra ID akzeptiert `http` nur für
|
||||
`localhost`. Der praktikable Weg ohne öffentliche Erreichbarkeit: ein
|
||||
**öffentlicher DNS-Name, der auf die private Adresse zeigt** (z. B.
|
||||
`hr.elycon.solutions` → `10.x.x.x`) und ein Zertifikat über die
|
||||
DNS-Challenge. Das ist zulässig, verbreitet, und liefert ein regulär
|
||||
vertrauenswürdiges Zertifikat, ohne dass der Server je aus dem Internet
|
||||
erreichbar ist.
|
||||
|
||||
Beispielkonfiguration: [`deploy/Caddyfile`](deploy/Caddyfile).
|
||||
|
||||
Die Alternative — selbst signiertes Zertifikat — bedeutet, es auf jedem
|
||||
Arbeitsplatz als vertrauenswürdig zu hinterlegen. Bei zwei Personen machbar,
|
||||
bei zwanzig nicht.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- Docker + Docker Compose (v2, das im Docker Desktop/Docker Engine
|
||||
@@ -83,30 +182,42 @@ docker compose up -d --build
|
||||
```
|
||||
|
||||
Alternative für CI/CD (Image einmal bauen, überall pullen): Image in einer
|
||||
Registry (GHCR, Docker Hub, …) bauen und pushen, auf dem Server nur
|
||||
Registry bauen und pushen, auf dem Server nur
|
||||
`docker compose pull && docker compose up -d` ausführen. Dafür in
|
||||
`docker-compose.yml` zusätzlich `image: <registry>/<name>:<tag>` setzen und
|
||||
den Build in der CI-Pipeline mit den `--build-arg`-Werten für
|
||||
`NEXT_PUBLIC_*` laufen lassen.
|
||||
`docker-compose.yml` zusätzlich `image: <registry>/<name>:<tag>` setzen.
|
||||
|
||||
Build-Argumente braucht es dabei **keine**: das Abbild enthält keine
|
||||
umgebungsabhängigen Werte mehr, alles kommt zur Laufzeit aus `.env`.
|
||||
Dasselbe Abbild läuft damit in Test und Produktion.
|
||||
|
||||
## 4. Reverse Proxy + HTTPS
|
||||
|
||||
Next.js selbst sollte laut den offiziellen Docs **nicht** direkt exponiert
|
||||
werden – ein Reverse Proxy übernimmt TLS, Rate-Limiting und Request-
|
||||
Validierung. Beispiel mit [Caddy](https://caddyserver.com/) (automatisches
|
||||
HTTPS via Let's Encrypt):
|
||||
Validierung.
|
||||
|
||||
```caddyfile
|
||||
# /etc/caddy/Caddyfile
|
||||
hr.example.com {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
Fertige Konfiguration: [`deploy/Caddyfile`](deploy/Caddyfile) — mit
|
||||
DNS-Challenge, weil der Server aus dem Internet nicht erreichbar ist (siehe
|
||||
[oben](#betrieb-im-firmennetz--zwei-dinge-vorab)).
|
||||
|
||||
```bash
|
||||
sudo cp deploy/Caddyfile /etc/caddy/Caddyfile
|
||||
sudo systemctl reload caddy
|
||||
```
|
||||
|
||||
`docker-compose.yml` published Port 3000 aktuell auf den Host – bei
|
||||
Verwendung eines Reverse Proxys auf demselben Host kann das Publishing auf
|
||||
`127.0.0.1:3000:3000` eingeschränkt werden, damit der Container-Port nicht
|
||||
direkt von außen erreichbar ist.
|
||||
`docker-compose.yml` veröffentlicht Port 3000 bewusst nur auf
|
||||
`127.0.0.1` — die App ist also ausschliesslich über den Proxy erreichbar,
|
||||
nicht daneben unverschlüsselt.
|
||||
|
||||
Zusätzlich in die `.env`:
|
||||
|
||||
```
|
||||
AUTH_URL=https://hr.elycon.solutions
|
||||
```
|
||||
|
||||
Ohne diesen Wert baut Auth.js seine Rückruf-Adresse aus dem, was der
|
||||
Container sieht — und das ist hinter dem Proxy nicht der Name, den der
|
||||
Browser benutzt hat.
|
||||
|
||||
## 5. Updates ausrollen
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ selbst spricht. Ein Docker-Abbild ist damit umgebungsneutral: einmal gebaut,
|
||||
Ersatzkontrolle.
|
||||
- **Ein Rollenmodell:** `profiles.role = 'hr'` + `profiles.is_active = true`,
|
||||
geprüft über die SQL-Funktion `is_hr_user()`. Kein Sub-Rollensystem —
|
||||
siehe [`docs/data-model.md`](docs/data-model.md#zugriffsmodell).
|
||||
siehe [`docs/datenkatalog.md`](docs/datenkatalog.md#zugriffsschutz).
|
||||
- **Es gibt keinen privilegierten Zugang mehr.** Der Dienstschlüssel, der RLS
|
||||
aushebelte, ist ersatzlos entfallen; auch der nächtliche Lauf benutzt
|
||||
dieselbe Rolle ohne `BYPASSRLS`. Was ohne angemeldete Person laufen muss,
|
||||
@@ -122,7 +122,8 @@ selbst spricht. Ein Docker-Abbild ist damit umgebungsneutral: einmal gebaut,
|
||||
## Supabase-Hinweise
|
||||
|
||||
- Schema-Quelle der Wahrheit: `supabase/migrations/`. Menschlich lesbare
|
||||
Zusammenfassung: [`docs/data-model.md`](docs/data-model.md).
|
||||
Fassung, aus der laufenden Datenbank erzeugt:
|
||||
[`docs/datenkatalog.md`](docs/datenkatalog.md).
|
||||
- Migrationen einspielen: `supabase db push` (gegen das verlinkte Projekt)
|
||||
bzw. `supabase start` + automatische Anwendung für lokale Entwicklung.
|
||||
- `supabase/seed.ts` und `.env.test.local` sind nur für lokale
|
||||
|
||||
@@ -4,7 +4,7 @@ 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, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types";
|
||||
import type { CollectiveAgreement, DienstwagenArt, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types";
|
||||
|
||||
async function callRpc(fn: MutationFn, payload: Record<string, unknown>, revalidate: string[]): Promise<ActionResult> {
|
||||
const result = await runMutation(await currentUserId(), fn, payload);
|
||||
@@ -14,6 +14,14 @@ async function callRpc(fn: MutationFn, payload: Record<string, unknown>, revalid
|
||||
}
|
||||
|
||||
export async function hireEmployee(payload: {
|
||||
/**
|
||||
* Wird eingegeben, nicht vergeben.
|
||||
*
|
||||
* Sie muss mit Loga und Interflex übereinstimmen; eine hier selbst gezogene
|
||||
* Nummer wäre dort unbekannt und die Person hätte in drei Systemen zwei
|
||||
* Nummern. Die Datenbank weist eine bereits vergebene Nummer ab.
|
||||
*/
|
||||
personnel_number: number;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
title_prefix?: string[];
|
||||
@@ -22,14 +30,13 @@ export async function hireEmployee(payload: {
|
||||
birth_date: string;
|
||||
sv_nummer?: string;
|
||||
/**
|
||||
* Pflicht, weil employees.email NOT NULL ist.
|
||||
* Die **private** Adresse, freiwillig.
|
||||
*
|
||||
* Der Assistent hat die Adresse immer erhoben und in der Zusammenfassung
|
||||
* angezeigt — sie fehlte nur in dieser Signatur und wurde deshalb
|
||||
* stillschweigend verworfen. Jede Einstellung scheiterte danach an der
|
||||
* Spaltenbedingung.
|
||||
* Sie war einmal Pflicht, weil die Spalte NOT NULL war — für eine private
|
||||
* Angabe die falsche Vorgabe: wer keine hat, musste eine erfinden. Bleibt
|
||||
* eindeutig, wenn angegeben.
|
||||
*/
|
||||
email: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
position_id?: string;
|
||||
team_id?: string;
|
||||
@@ -47,8 +54,15 @@ export async function hireEmployee(payload: {
|
||||
work_days?: Weekday[];
|
||||
is_betriebsrat?: boolean;
|
||||
has_dienstwagen?: boolean;
|
||||
dienstwagen_art?: DienstwagenArt | null;
|
||||
emergency_contact_name?: string;
|
||||
emergency_contact_phone?: string;
|
||||
emergency_contact_relation?: string;
|
||||
is_laterale_fuehrung?: boolean;
|
||||
is_c_level?: boolean;
|
||||
has_kuendigungsschutz?: boolean;
|
||||
/** Nur mit dem Kennzeichen zusammen — so verlangt es chk_kuendigungsschutz_bis. */
|
||||
kuendigungsschutz_bis?: string | null;
|
||||
}): Promise<ActionResult & { employeeId?: string }> {
|
||||
// Einzige Mutation, deren Rückgabewert gebraucht wird: die neue
|
||||
// Personen-Kennung, damit die Oberfläche direkt auf die Akte springen kann.
|
||||
@@ -115,6 +129,10 @@ export async function recordKarenzReturn(payload: {
|
||||
return_date: string;
|
||||
employment_mode: "unverändert" | "Vollzeit" | "Teilzeit";
|
||||
weekly_hours?: number;
|
||||
/** Wiedereingliederungs- oder Elternteilzeit, wenn reduziert zurückgekehrt wird. */
|
||||
reduction_reason?: string;
|
||||
/** Ende der Teilzeit, falls bekannt — nur mit einem Grund zusammen. */
|
||||
teilzeit_bis?: string;
|
||||
}): Promise<ActionResult> {
|
||||
return callRpc("record_karenz_return", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]);
|
||||
}
|
||||
@@ -129,7 +147,19 @@ export async function changeEmployeeData(payload: {
|
||||
return callRpc("change_employee_data", payload, [`/employees/${payload.employee_id}`, "/employees"]);
|
||||
}
|
||||
|
||||
export async function rehireEmployee(payload: { employee_id: string; rehire_date: string }): Promise<ActionResult> {
|
||||
/**
|
||||
* `position_id` ist Pflicht — die Datenbankfunktion verlangt sie seit jeher.
|
||||
*
|
||||
* Die alte Stelle taugt nicht als stille Vorgabe: sie kann inzwischen besetzt
|
||||
* oder ausgelaufen sein. Sie fehlte hier nur in der Signatur, weshalb jede
|
||||
* Wiedereinstellung an einer Meldung scheiterte, die im Dialog nicht zu
|
||||
* beheben war.
|
||||
*/
|
||||
export async function rehireEmployee(payload: {
|
||||
employee_id: string;
|
||||
rehire_date: string;
|
||||
position_id: string;
|
||||
}): Promise<ActionResult> {
|
||||
return callRpc("rehire_employee", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]);
|
||||
}
|
||||
|
||||
@@ -153,6 +183,41 @@ export async function deleteEmployeeDependent(payload: {
|
||||
return callRpc("delete_employee_dependent", payload, [`/employees/${payload.employee_id}`]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt eine irrtümlich erfasste Stammdaten- oder Vertragsänderung zurück.
|
||||
*
|
||||
* Was zurückgesetzt wird und was stehen bleibt, entscheidet die Datenbank —
|
||||
* sie prüft dabei erneut, ob der Eintrag überhaupt gelöscht werden darf. Die
|
||||
* Oberfläche zeigt den Knopf nur dort, wo es geht (siehe lib/history.ts);
|
||||
* kommt trotzdem eine Ablehnung zurück, wird deren Begründung angezeigt.
|
||||
*/
|
||||
export async function deleteHistoryEntry(payload: {
|
||||
history_id: string;
|
||||
employee_id: string;
|
||||
}): Promise<ActionResult> {
|
||||
return callRpc("delete_history_entry", { history_id: payload.history_id }, [`/employees/${payload.employee_id}`, "/audit"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Berichtigt Wert und/oder Datum eines Historieneintrags.
|
||||
*
|
||||
* Geändert wird nur das „Nachher" — was vor der Änderung galt, ist nicht
|
||||
* nachträglich beschliessbar. Welcher Wert danach in den Stammdaten steht,
|
||||
* leitet die Datenbank je Feld aus dem jüngsten Eintrag ab, der es trägt.
|
||||
*/
|
||||
export async function updateHistoryEntry(payload: {
|
||||
history_id: string;
|
||||
employee_id: string;
|
||||
event_date?: string;
|
||||
werte: { feld: string; nachher: string | null }[];
|
||||
}): Promise<ActionResult> {
|
||||
return callRpc(
|
||||
"update_history_entry",
|
||||
{ history_id: payload.history_id, event_date: payload.event_date, werte: payload.werte },
|
||||
[`/employees/${payload.employee_id}`, "/audit"]
|
||||
);
|
||||
}
|
||||
|
||||
export async function addEmployeeNote(payload: {
|
||||
employee_id: string;
|
||||
category: NoteCategory;
|
||||
|
||||
@@ -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",
|
||||
fn: "create_position" | "update_position" | "delete_position" | "set_position_cost_center",
|
||||
payload: Record<string, unknown>,
|
||||
revalidate: string[]
|
||||
): Promise<ActionResult> {
|
||||
@@ -49,3 +49,19 @@ export async function deletePosition(positionId: string): Promise<ActionResult>
|
||||
return callRpc("delete_position", { position_id: positionId }, POSITION_PATHS);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Kontiert eine Planstelle auf eine andere Kostenstelle — zum Stichtag.
|
||||
*
|
||||
* Kein Feld im Änderungsdialog, sondern ein eigener Vorgang, weil es einer
|
||||
* ist: die laufende Kontierung wird beendet, die neue beginnt. Die alte
|
||||
* einfach zu überschreiben hiesse, die Kosten der Vergangenheit nachträglich
|
||||
* anderswohin zu buchen.
|
||||
*/
|
||||
export async function setPositionCostCenter(payload: {
|
||||
position_id: string;
|
||||
cost_center_id: string;
|
||||
valid_from: string;
|
||||
}): Promise<ActionResult> {
|
||||
return callRpc("set_position_cost_center", payload, POSITION_PATHS);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,8 @@ import { EmployeeDetail } from "@/components/employees/EmployeeDetail";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
import { todayIso } from "@/lib/format";
|
||||
import { breadcrumbLabel, loadOrgMaps } from "@/lib/org";
|
||||
import { loadPlacements, loadReportingLines } from "@/lib/placement";
|
||||
import { loadOpenPositions } from "@/lib/positions";
|
||||
import { breadcrumbLabel } from "@/lib/org";
|
||||
import { loadEmployeeDetail } from "@/lib/employee-detail-data";
|
||||
|
||||
type PageProps = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -13,66 +12,12 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const today = todayIso();
|
||||
|
||||
const data = await withUser(await currentUserId(), async (tx) => {
|
||||
// Vorgesetzte und direkte Berichte stehen nirgends als Spalte — sie
|
||||
// kommen aus om_reporting_lines(). Beide Abfragen schränken *in* der
|
||||
// Funktion ein, es wandern also neun Zeilen über die Leitung und nicht
|
||||
// achthundert.
|
||||
const [employee, ownLines, reports, history, dependents, notes, orgMaps, placements, openPositions] =
|
||||
await Promise.all([
|
||||
tx.selectFrom("employees").selectAll().where("id", "=", id).executeTakeFirst(),
|
||||
loadReportingLines(tx, today, { employeeId: id }),
|
||||
loadReportingLines(tx, today, { actingManagerId: id }),
|
||||
tx
|
||||
.selectFrom("employee_history")
|
||||
.selectAll()
|
||||
.where("employee_id", "=", id)
|
||||
.orderBy("event_date", "desc")
|
||||
.orderBy("created_at", "desc")
|
||||
.execute(),
|
||||
tx.selectFrom("employee_dependents").selectAll().where("employee_id", "=", id).orderBy("created_at").execute(),
|
||||
tx.selectFrom("employee_notes").selectAll().where("employee_id", "=", id).orderBy("created_at", "desc").execute(),
|
||||
loadOrgMaps(tx),
|
||||
loadPlacements(tx, { asOf: today, employeeIds: [id] }),
|
||||
loadOpenPositions(tx),
|
||||
]);
|
||||
|
||||
if (!employee) return null;
|
||||
const line = ownLines[0] ?? null;
|
||||
|
||||
// Namen für die beteiligten Personen in einem Zug: die Vertretung, die
|
||||
// formal zuständige Leitung und die direkten Berichte.
|
||||
const relatedIds = Array.from(
|
||||
new Set(
|
||||
[line?.acting_manager_id, line?.formal_manager_id, ...reports.map((r) => r.employee_id)].filter(
|
||||
(x): x is string => Boolean(x)
|
||||
)
|
||||
)
|
||||
);
|
||||
const relatedRows = relatedIds.length
|
||||
? await tx
|
||||
.selectFrom("employees")
|
||||
.select(["id", "first_name", "last_name", "job_title", "status"])
|
||||
.where("id", "in", relatedIds)
|
||||
.execute()
|
||||
: [];
|
||||
|
||||
return {
|
||||
employee,
|
||||
line,
|
||||
reports,
|
||||
history,
|
||||
dependents,
|
||||
notes,
|
||||
orgMaps,
|
||||
placement: placements.get(id) ?? null,
|
||||
openPositions,
|
||||
byId: new Map(relatedRows.map((e) => [e.id, e])),
|
||||
};
|
||||
});
|
||||
// Was die Akte liest und in wie vielen Rundreisen, steht in
|
||||
// lib/employee-detail-data.ts.
|
||||
const data = await withUser(await currentUserId(), (tx) => loadEmployeeDetail(tx, id, today));
|
||||
|
||||
if (!data) notFound();
|
||||
const { employee, line, reports, history, dependents, notes, orgMaps, placement, openPositions, byId } = data;
|
||||
const { employee, line, reports, history, dependents, notes, orgMaps, placement, kostenstelle, openPositions, byId } = data;
|
||||
|
||||
return (
|
||||
<EmployeeDetail
|
||||
@@ -86,6 +31,7 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
|
||||
}
|
||||
}
|
||||
breadcrumb={breadcrumbLabel(orgMaps, placement?.orgUnitId)}
|
||||
kostenstelle={kostenstelle}
|
||||
manager={(line?.acting_manager_id ? byId.get(line.acting_manager_id) : null) ?? null}
|
||||
// Nur wenn eine Vertretung im Spiel ist — sonst stünde dieselbe Person
|
||||
// zweimal da.
|
||||
|
||||
@@ -6,16 +6,24 @@ import { CARD_CLASS } from "@/components/ui/Card";
|
||||
import { Pagination } from "@/components/ui/Pagination";
|
||||
import { StatusChip } from "@/components/ui/StatusChip";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
import { sql, withUser } from "@/lib/db";
|
||||
import { jsonArrayFrom, jsonObjectFrom } from "@/lib/db/json";
|
||||
import { istPersonalnummer, suchMuster } from "@/lib/employee-search";
|
||||
import { derivedStatusFilter } from "@/lib/employee-status-filter";
|
||||
import { fmtDate, todayIso } from "@/lib/format";
|
||||
import { breadcrumbLabel, divisionOf, loadOrgMaps, subtreeOf, unitOf } from "@/lib/org";
|
||||
import { fmtDate, fmtName, todayIso } from "@/lib/format";
|
||||
import { breadcrumbLabel, divisionOf, loadOrgMaps, subtreeOf, unitOf, type OrgEb } from "@/lib/org";
|
||||
import { loadPlacements } from "@/lib/placement";
|
||||
import type { EmploymentStatus } from "@/lib/supabase/types";
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
type SearchParams = { q?: string; division?: string; status?: string; location?: string; page?: string };
|
||||
type SearchParams = {
|
||||
q?: string;
|
||||
division?: string;
|
||||
status?: string;
|
||||
location?: string;
|
||||
page?: string;
|
||||
};
|
||||
|
||||
type EmployeesPageProps = {
|
||||
searchParams: Promise<SearchParams>;
|
||||
@@ -43,7 +51,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
||||
.map((s) => s.trim())
|
||||
.filter((s): s is EmploymentStatus => (["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const).includes(s as EmploymentStatus));
|
||||
|
||||
const { orgMaps, employees, count, placements } = await withUser(await currentUserId(), async (tx) => {
|
||||
const { orgMaps, employees, count, placements, ueberPosition } = await withUser(await currentUserId(), async (tx) => {
|
||||
// Die Referenzdaten zuerst: der Bereichsfilter braucht den Teilbaum.
|
||||
// „Produktion" meint die Abteilungen und Teams darunter — in der Einheit
|
||||
// selbst sitzt nur die Bereichsleitung.
|
||||
@@ -55,8 +63,11 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
||||
// vorher brauchte es zwei getrennte Select-Formen, weil der Typparser der
|
||||
// API-Schicht einen bedingt zusammengesetzten Select-String nicht
|
||||
// auflösen konnte.
|
||||
const base = () => {
|
||||
let q = tx.selectFrom("employees");
|
||||
// Der Ausdrucksbauer wird durchgereicht, damit dieselbe Filterkette
|
||||
// einmal für die Seite und einmal für die Zählung in *einer* Abfrage
|
||||
// stehen kann.
|
||||
const base = (nurNamen: boolean, eb: OrgEb = tx as never) => {
|
||||
let q = eb.selectFrom("employees");
|
||||
|
||||
if (unitFilter) {
|
||||
// Nach Organisationseinheit gefiltert wird über die *laufende*
|
||||
@@ -78,15 +89,48 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
||||
|
||||
if (params.q) {
|
||||
const term = params.q.trim();
|
||||
if (/^d+$/.test(term)) {
|
||||
// `\d`, nicht `d`: der fehlende Backslash liess die Ziffernerkennung
|
||||
// nie greifen — „1590" wurde als Name gesucht und fand nichts,
|
||||
// während das Muster auf „ddd" ansprang.
|
||||
if (istPersonalnummer(term)) {
|
||||
q = q.where("personnel_number", "=", Number(term));
|
||||
} else {
|
||||
// Als Parameter gebunden statt in die Abfrage geschrieben: die
|
||||
// Zeichen, die in der alten Filtersyntax ausbrechen konnten, sind
|
||||
// hier bedeutungslos.
|
||||
const like = `%${term}%`;
|
||||
// ── Wie hier gesucht wird ──────────────────────────────────
|
||||
//
|
||||
// **Wortweise, Reihenfolge egal.** Jedes Wort muss treffen, aber
|
||||
// nicht in einer bestimmten Ordnung: „Winkler micha" und „micha
|
||||
// Winkler" führen beide zu Michaela Winkler. Am Stück gesucht stand
|
||||
// „Michael Winkler" in keinem einzelnen Feld und ergab null Treffer,
|
||||
// obwohl es vierzehn Winkler gibt.
|
||||
//
|
||||
// **Am Wortanfang, nicht mittendrin.** Als Teilzeichenkette traf ein
|
||||
// „H" auf T-h-omas und Kat-h-arina — bei „Winkler H" kamen alle
|
||||
// sieben Winkler zurück. Trennzeichen zählen als Wortgrenze, damit
|
||||
// „dreher" auch „CNC-Dreher:in" findet.
|
||||
//
|
||||
// **Namen vor Positionen.** Die Position mitzudurchsuchen ist
|
||||
// nützlich („dreher"), darf aber eine Namenssuche nicht verwässern:
|
||||
// bei „Winkler M" tauchten sonst Karin Winkler (Montagemitarbeiterin)
|
||||
// und Katharina Winkler (Maschinenbedienerin) auf, weil ihre
|
||||
// Position mit M beginnt. Deshalb wird zuerst nur über die Namen
|
||||
// gesucht; nur wenn das *nichts* findet, kommt die Position dazu.
|
||||
// Eine feste Mindestlänge fürs Wort wäre die einfachere Regel, aber
|
||||
// jede Grenze wäre geraten — diese hier ergibt sich aus den Daten.
|
||||
//
|
||||
// Der Trigramm-Index auf dem zusammengesetzten Namen greift bei
|
||||
// diesem Ausdruck nicht mehr. Bei knapp neunhundert Zeilen liest
|
||||
// Postgres die Tabelle in wenigen Millisekunden; ein Index auf
|
||||
// demselben Ausdruck holt das zurück, sobald das nicht mehr stimmt.
|
||||
const heuhaufen = nurNamen
|
||||
? sql<string>`translate(lower(first_name || ' ' || last_name), '-/:.,', ' ')`
|
||||
: sql<string>`translate(lower(first_name || ' ' || last_name || ' ' || job_title), '-/:.,', ' ')`;
|
||||
q = q.where((eb) =>
|
||||
eb.or([eb("first_name", "ilike", like), eb("last_name", "ilike", like), eb("job_title", "ilike", like)])
|
||||
eb.and(
|
||||
// Als Parameter gebunden, nicht in die Abfrage geschrieben.
|
||||
suchMuster(term).map(([amAnfang, nachLeerzeichen]) =>
|
||||
eb.or([eb(heuhaufen, "like", amAnfang), eb(heuhaufen, "like", nachLeerzeichen)])
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -101,8 +145,22 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
||||
return q;
|
||||
};
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
base()
|
||||
// Erst nachsehen, ob die Namen allein etwas hergeben. Nur wenn nicht,
|
||||
// wird die Position mitgesucht — eine zusätzliche, sehr kleine Abfrage,
|
||||
// und nur bei einer Textsuche.
|
||||
const sucheNachNamen = Boolean(params.q) && !istPersonalnummer(params.q!.trim());
|
||||
const namensTreffer = sucheNachNamen
|
||||
? Number((await base(true).select(({ fn }) => fn.countAll<string>().as("anzahl")).executeTakeFirst())?.anzahl ?? 0)
|
||||
: 0;
|
||||
const nurNamen = sucheNachNamen && namensTreffer > 0;
|
||||
|
||||
// Seite und Gesamtzahl in *einer* Rundreise. Als Promise.all sah das nach
|
||||
// Gleichzeitigkeit aus und war keine: eine Transaktion hängt an einer
|
||||
// Verbindung, und darüber laufen Abfragen nacheinander (lib/db/json.ts).
|
||||
const { rows, total } = await tx
|
||||
.selectNoFrom((eb) => [
|
||||
jsonArrayFrom(
|
||||
base(nurNamen, eb)
|
||||
.select([
|
||||
"id",
|
||||
"first_name",
|
||||
@@ -123,17 +181,24 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
||||
.orderBy("id")
|
||||
.limit(PAGE_SIZE)
|
||||
.offset((page - 1) * PAGE_SIZE)
|
||||
.execute(),
|
||||
base()
|
||||
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
|
||||
.executeTakeFirst(),
|
||||
]);
|
||||
).as("rows"),
|
||||
jsonObjectFrom(base(nurNamen, eb).select(({ fn }) => fn.countAll<string>().as("anzahl"))).as("total"),
|
||||
])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
// Die Einordnung kommt über die Planstelle — nur für die 15 Zeilen dieser
|
||||
// Seite, nicht für den ganzen Bestand.
|
||||
const placements = await loadPlacements(tx, { asOf: today, employeeIds: rows.map((e) => e.id) });
|
||||
|
||||
return { orgMaps, employees: rows, count: Number(total?.anzahl ?? 0), placements };
|
||||
return {
|
||||
orgMaps,
|
||||
employees: rows,
|
||||
count: Number(total?.anzahl ?? 0),
|
||||
placements,
|
||||
// Für den Hinweis über der Liste: wurde nach Namen gesucht, und hat es
|
||||
// gereicht?
|
||||
ueberPosition: sucheNachNamen && !nurNamen,
|
||||
};
|
||||
});
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(count / PAGE_SIZE));
|
||||
@@ -143,7 +208,14 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
||||
<Suspense>
|
||||
<EmployeeFilters units={orgMaps.unitList} depthOf={orgMaps.depthOf} locations={orgMaps.locationList} />
|
||||
</Suspense>
|
||||
<p className="text-sm text-ink-muted">{count ?? 0} Mitarbeiter:innen gefunden</p>
|
||||
<p className="text-sm text-ink-muted">
|
||||
{count ?? 0} Mitarbeiter:innen gefunden
|
||||
{/* Wenn kein Name passte, wurde nach der Position gesucht. Ohne diesen
|
||||
Hinweis wirkt das Ergebnis, als hätte die Suche etwas erfunden. */}
|
||||
{ueberPosition && (count ?? 0) > 0 && (
|
||||
<span className="text-ink-muted"> · kein Namenstreffer, gesucht nach Position</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className={`overflow-x-auto ${CARD_CLASS}`}>
|
||||
<table className="w-full min-w-[800px] text-sm">
|
||||
@@ -176,7 +248,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
||||
<Avatar firstName={e.first_name} lastName={e.last_name} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-semibold text-ink">
|
||||
{e.first_name} {e.last_name}
|
||||
{fmtName(e.first_name, e.last_name)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-ink-muted">{placement?.jobTitle ?? e.job_title}</div>
|
||||
</div>
|
||||
|
||||
@@ -4,46 +4,23 @@ import { HireWizardProvider } from "@/components/hire/HireWizardContext";
|
||||
import { AppShell } from "@/components/shell/AppShell";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
import { loadOpenNotes } from "@/lib/notes";
|
||||
import { loadOpenPositions } from "@/lib/positions";
|
||||
import { loadShellData } from "@/lib/shell-data";
|
||||
|
||||
export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
const userId = await currentUserId();
|
||||
if (!userId) redirect("/login");
|
||||
|
||||
// Alles in *einer* Transaktion, weil nur dort der Sitzungskontext gilt —
|
||||
// und damit nebenbei auf einem einheitlichen Lesestand.
|
||||
const data = await withUser(userId, async (tx) => {
|
||||
// Hier — und nicht im Proxy — fällt die Entscheidung über den Zugang.
|
||||
// Der Proxy prüft nur, ob überhaupt jemand angemeldet ist; er hat keine
|
||||
// Datenbankverbindung. Diese Abfrage läuft bei jedem Aufbau frisch, eine
|
||||
// entzogene Freischaltung wirkt also sofort statt erst mit dem nächsten
|
||||
// Sitzungstoken. Die eigentliche Grenze bleibt darunter RLS.
|
||||
const profile = await tx
|
||||
.selectFrom("profiles")
|
||||
.select(["full_name", "email", "role", "is_active"])
|
||||
.where("id", "=", userId)
|
||||
.executeTakeFirst();
|
||||
if (profile?.role !== "hr" || profile?.is_active !== true) return null;
|
||||
|
||||
const [openPositions, locations, drafts, openNotes] = await Promise.all([
|
||||
loadOpenPositions(tx),
|
||||
tx.selectFrom("locations").select(["id", "name", "country"]).orderBy("name").execute(),
|
||||
tx
|
||||
.selectFrom("hire_drafts")
|
||||
.select(["id", "step", "payload", "updated_at"])
|
||||
.where("created_by", "=", userId)
|
||||
.orderBy("updated_at", "desc")
|
||||
.execute(),
|
||||
loadOpenNotes(tx),
|
||||
]);
|
||||
|
||||
return { profile, openPositions, locations, drafts, openNotes };
|
||||
});
|
||||
// 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));
|
||||
|
||||
// `data` ist null, wenn die Person angemeldet, aber nicht freigeschaltet
|
||||
// ist. Ohne den Grund in der Adresse stünde sie vor einer wortlosen
|
||||
// Anmeldeseite und versuchte es endlos erneut.
|
||||
// ist. Hier — und nicht im Proxy — fällt diese Entscheidung: der Proxy hat
|
||||
// keine Datenbankverbindung. Sie wird bei jedem Aufbau frisch gestellt, eine
|
||||
// entzogene Freischaltung wirkt also sofort statt erst mit dem nächsten
|
||||
// Sitzungstoken. Ohne den Grund in der Adresse stünde die Person vor einer
|
||||
// wortlosen Anmeldeseite und versuchte es endlos erneut.
|
||||
if (!data) redirect("/login?error=no_hr_access");
|
||||
|
||||
const userLabel = data.profile.full_name || data.profile.email || "";
|
||||
|
||||
43
app/(app)/orgchart/print/page.tsx
Normal file
43
app/(app)/orgchart/print/page.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { PrintChart } from "@/components/orgchart/PrintChart";
|
||||
import type { OrgUnitNode } from "@/components/orgchart/types";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
import { todayIso } from "@/lib/format";
|
||||
import { loadOrgAsOf } from "@/lib/orgchart-data";
|
||||
import { buildPrintModel } from "@/lib/orgchart-print";
|
||||
import { parseIsoDateParam } from "@/lib/reports";
|
||||
|
||||
export const metadata = { title: "Organigramm drucken" };
|
||||
|
||||
type SearchParams = { asOf?: string };
|
||||
|
||||
// Eigene Route statt eines Druckstils auf der Organigramm-Seite.
|
||||
//
|
||||
// Die interaktive Ansicht ist eine Leinwand mit Zoom und Verschiebung; was
|
||||
// davon auf Papier landet, hängt vom Zufallsstand der Ansicht ab. Hier wird
|
||||
// stattdessen aus denselben Daten eine Seitenfolge gebaut, und der Stichtag
|
||||
// wird aus der Adresse übernommen — wer im Organigramm einen Stichtag
|
||||
// eingestellt hat, druckt genau den.
|
||||
export default async function OrgChartPrintPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||
const params = await searchParams;
|
||||
const today = todayIso();
|
||||
const asOf = parseIsoDateParam(params.asOf) ?? today;
|
||||
|
||||
const { org, units } = await withUser(await currentUserId(), async (tx) => {
|
||||
const [org, units] = await Promise.all([
|
||||
loadOrgAsOf(tx, asOf),
|
||||
tx
|
||||
.selectFrom("org_units")
|
||||
.select(["id", "org_number", "name", "parent_id", "unit_type"])
|
||||
.where((eb) => eb.or([eb("valid_to", "is", null), eb("valid_to", ">", asOf)]))
|
||||
.where("valid_from", "<=", asOf)
|
||||
.orderBy("org_number")
|
||||
.execute(),
|
||||
]);
|
||||
return { org, units };
|
||||
});
|
||||
|
||||
const model = buildPrintModel(units as OrgUnitNode[], org.employees, org.vacancies);
|
||||
|
||||
return <PrintChart model={model} asOf={asOf} today={today} />;
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { AnstehendFilter } from "@/components/dashboard/AnstehendFilter";
|
||||
import { DraftsCard } from "@/components/dashboard/DraftsCard";
|
||||
import { Card, CARD_CLASS, CardTitle } from "@/components/ui/Card";
|
||||
import { actionBadgeStyle } from "@/lib/colors";
|
||||
import { addDaysIso, fmtDate, todayIso } from "@/lib/format";
|
||||
import { divisionOf, loadOrgMaps } from "@/lib/org";
|
||||
import { loadPlacements } from "@/lib/placement";
|
||||
import { loadOpenPositions } from "@/lib/positions";
|
||||
import { istEingeschraenkt, parseArten, parseZeitraum } from "@/lib/dashboard-filter";
|
||||
import { loadDashboardData } from "@/lib/dashboard-data";
|
||||
import { addDaysIso, fmtDate, fmtName, todayIso } from "@/lib/format";
|
||||
import { divisionOf } from "@/lib/org";
|
||||
import { deriveStatusAsOf } from "@/lib/reports";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
import type { HistoryEventType } from "@/lib/supabase/types";
|
||||
|
||||
// Each KPI carries a colour already; the accent bar repeats it in a second
|
||||
// channel so the tiles are scannable as a row rather than six identical
|
||||
@@ -37,9 +38,18 @@ const DOT_STYLES: Record<string, string> = {
|
||||
Gehaltsanpassung: "bg-warning-text",
|
||||
};
|
||||
|
||||
const KIND_LABEL = { hire: "Eintritt", exit: "Austritt", return: "Rückkehr aus Abwesenheit" } as const;
|
||||
const KIND_LABEL = {
|
||||
hire: "Eintritt",
|
||||
exit: "Austritt",
|
||||
return: "Rückkehr aus Abwesenheit",
|
||||
note: "Wiedervorlage",
|
||||
} as const;
|
||||
|
||||
export default async function DashboardPage() {
|
||||
export default async function DashboardPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ tage?: string; arten?: string }>;
|
||||
}) {
|
||||
// Built as strings, not by round-tripping a local Date through
|
||||
// toISOString(): in any positive-offset zone new Date(year, 0, 1) is still
|
||||
// the previous year in UTC, which shifted the whole YTD window a day early
|
||||
@@ -48,7 +58,14 @@ export default async function DashboardPage() {
|
||||
const year = today.slice(0, 4);
|
||||
const yearStart = `${year}-01-01`;
|
||||
const yearEnd = `${year}-12-31`;
|
||||
const in60Iso = addDaysIso(today, 60);
|
||||
|
||||
// Der Vorschauzeitraum ist einstellbar, und mit ihm, was überhaupt geladen
|
||||
// wird. Deshalb steht die Auswahl in der Adresse und nicht im Browser: 90
|
||||
// statt 60 Tage bringt Zeilen ins Spiel, die sonst nirgends lägen.
|
||||
const params = await searchParams;
|
||||
const zeitraum = parseZeitraum(params.tage);
|
||||
const arten = parseArten(params.arten);
|
||||
const bisIso = addDaysIso(today, zeitraum);
|
||||
|
||||
const userId = await currentUserId();
|
||||
|
||||
@@ -71,106 +88,9 @@ export default async function DashboardPage() {
|
||||
upcomingHires,
|
||||
upcomingExits,
|
||||
upcomingReturns,
|
||||
upcomingNotes,
|
||||
history,
|
||||
} = await withUser(userId, async (tx) => {
|
||||
const countIn = (types: readonly HistoryEventType[]) =>
|
||||
tx
|
||||
.selectFrom("employee_history")
|
||||
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
|
||||
.where("event_type", "in", [...types])
|
||||
.where("event_date", ">=", yearStart)
|
||||
.where("event_date", "<=", yearEnd)
|
||||
.executeTakeFirst();
|
||||
|
||||
const [
|
||||
drafts,
|
||||
staffRows,
|
||||
hiresYtd,
|
||||
exitsYtd,
|
||||
openPositions,
|
||||
orgMaps,
|
||||
placements,
|
||||
upcomingHires,
|
||||
upcomingExits,
|
||||
upcomingReturns,
|
||||
history,
|
||||
] = await Promise.all([
|
||||
userId
|
||||
? tx
|
||||
.selectFrom("hire_drafts")
|
||||
.select(["id", "step", "payload", "updated_at"])
|
||||
.where("created_by", "=", userId)
|
||||
.orderBy("updated_at", "desc")
|
||||
.execute()
|
||||
: Promise.resolve([]),
|
||||
|
||||
tx
|
||||
.selectFrom("employees")
|
||||
.select(["id", "weekly_hours", "entry_date", "exit_date", "karenz_start_date", "karenz_return_date"])
|
||||
.orderBy("id")
|
||||
.execute(),
|
||||
|
||||
// Entries/exits count history events, which is what the linked report
|
||||
// counts too. `entry_date` would also sweep up rehires, whose event is
|
||||
// logged as 'Wiedereintritt' — the tile and its destination then showed
|
||||
// different numbers for the same year.
|
||||
countIn(["Eintritt", "Wiedereintritt"]),
|
||||
countIn(["Austritt"]),
|
||||
|
||||
loadOpenPositions(tx),
|
||||
loadOrgMaps(tx),
|
||||
loadPlacements(tx, { asOf: today }),
|
||||
|
||||
tx
|
||||
.selectFrom("employees")
|
||||
.select(["id", "first_name", "last_name", "entry_date"])
|
||||
.where("status", "=", "Geplant")
|
||||
.where("entry_date", ">=", today)
|
||||
.where("entry_date", "<=", in60Iso)
|
||||
.execute(),
|
||||
|
||||
tx
|
||||
.selectFrom("employees")
|
||||
.select(["id", "first_name", "last_name", "exit_date"])
|
||||
.where("exit_date", "is not", null)
|
||||
.where("exit_date", ">=", today)
|
||||
.where("exit_date", "<=", in60Iso)
|
||||
.execute(),
|
||||
|
||||
tx
|
||||
.selectFrom("employees")
|
||||
.select(["id", "first_name", "last_name", "karenz_return_date"])
|
||||
.where("status", "=", "Karenz")
|
||||
.where("karenz_return_date", "is not", null)
|
||||
.where("karenz_return_date", ">=", today)
|
||||
.where("karenz_return_date", "<=", in60Iso)
|
||||
.execute(),
|
||||
|
||||
tx
|
||||
.selectFrom("employee_history as h")
|
||||
.leftJoin("employees as e", "e.id", "h.employee_id")
|
||||
.select(["h.id", "h.employee_id", "h.event_date", "h.event_type", "h.description", "e.first_name", "e.last_name"])
|
||||
.orderBy("h.event_date", "desc")
|
||||
.orderBy("h.created_at", "desc")
|
||||
.limit(10)
|
||||
.execute(),
|
||||
]);
|
||||
|
||||
return {
|
||||
drafts,
|
||||
staffRows,
|
||||
hiresYtd: Number(hiresYtd?.anzahl ?? 0),
|
||||
exitsYtd: Number(exitsYtd?.anzahl ?? 0),
|
||||
openPositions,
|
||||
orgMaps,
|
||||
placements,
|
||||
upcomingHires,
|
||||
upcomingExits,
|
||||
upcomingReturns,
|
||||
history,
|
||||
};
|
||||
});
|
||||
|
||||
} = await withUser(userId, (tx) => loadDashboardData(tx, { userId, today, yearStart, yearEnd, bisIso, arten }));
|
||||
// "Aktiv" means status Aktiv — somebody on Karenz is employed but not
|
||||
// active, and is counted by its own tile instead. FTE follows the same
|
||||
// set: Karenz contributes no capacity, so including it would overstate
|
||||
@@ -201,29 +121,53 @@ 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; label: string; date: string; kind: keyof typeof KIND_LABEL };
|
||||
const upcoming: UpcomingItem[] = [
|
||||
type UpcomingItem = {
|
||||
id: string;
|
||||
/** Ziel des Klicks — bei einer Wiedervorlage die Akte, nicht die Notiz. */
|
||||
employeeId: string;
|
||||
label: string;
|
||||
/** Zweite Zeile: bei einer Wiedervorlage der Notiztext statt der Art. */
|
||||
hinweis?: string;
|
||||
date: string;
|
||||
kind: keyof typeof KIND_LABEL;
|
||||
};
|
||||
const upcomingAlle: UpcomingItem[] = [
|
||||
...(upcomingHires).map((e) => ({
|
||||
id: e.id,
|
||||
label: `${e.first_name} ${e.last_name}`,
|
||||
employeeId: e.id,
|
||||
label: fmtName(e.first_name, e.last_name),
|
||||
date: e.entry_date,
|
||||
kind: "hire" as const,
|
||||
})),
|
||||
...(upcomingExits).map((e) => ({
|
||||
id: e.id,
|
||||
label: `${e.first_name} ${e.last_name}`,
|
||||
employeeId: e.id,
|
||||
label: fmtName(e.first_name, e.last_name),
|
||||
date: e.exit_date!,
|
||||
kind: "exit" as const,
|
||||
})),
|
||||
...(upcomingReturns).map((e) => ({
|
||||
id: e.id,
|
||||
label: `${e.first_name} ${e.last_name}`,
|
||||
employeeId: e.id,
|
||||
label: fmtName(e.first_name, e.last_name),
|
||||
date: e.karenz_return_date!,
|
||||
kind: "return" as const,
|
||||
})),
|
||||
]
|
||||
.sort((a, b) => a.date.localeCompare(b.date))
|
||||
.slice(0, 8);
|
||||
...(upcomingNotes).map((n) => ({
|
||||
id: n.id,
|
||||
employeeId: n.employee_id!,
|
||||
label: fmtName(n.first_name, n.last_name),
|
||||
hinweis: n.note_text,
|
||||
date: n.due_date!,
|
||||
kind: "note" as const,
|
||||
})),
|
||||
].sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
// 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.
|
||||
@@ -308,24 +252,55 @@ export default async function DashboardPage() {
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle className="mb-1">Anstehend (60 Tage)</CardTitle>
|
||||
<CardTitle className="mb-2">Anstehend ({zeitraum} Tage)</CardTitle>
|
||||
{/* useSearchParams braucht eine Suspense-Grenze; ohne sie fällt beim
|
||||
Bauen die ganze Seite auf Rendern zur Laufzeit zurück. */}
|
||||
<Suspense fallback={<div className="mb-2 h-9" />}>
|
||||
<AnstehendFilter zeitraum={zeitraum} arten={arten} />
|
||||
</Suspense>
|
||||
<ul className="flex flex-col divide-y divide-border-subtle">
|
||||
{upcoming.map((item) => (
|
||||
{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.id}`}
|
||||
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="text-xs text-ink-muted">{KIND_LABEL[item.kind]}</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>
|
||||
<span className="shrink-0 text-xs font-semibold tabular-nums text-ink-muted">{fmtDate(item.date)}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
{upcoming.length === 0 && <p className="py-2 text-sm text-ink-muted">Keine anstehenden Ereignisse.</p>}
|
||||
);
|
||||
})}
|
||||
{upcoming.length === 0 && (
|
||||
<p className="py-2 text-sm text-ink-muted">
|
||||
{istEingeschraenkt(zeitraum, arten)
|
||||
? "Zu dieser Auswahl steht nichts an."
|
||||
: "Keine anstehenden Ereignisse."}
|
||||
</p>
|
||||
)}
|
||||
</ul>
|
||||
{weitere > 0 && (
|
||||
<p className="mt-2 text-xs text-ink-muted">
|
||||
… und {weitere} {weitere === 1 ? "weiteres Ereignis" : "weitere Ereignisse"} in diesem Zeitraum.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
@@ -338,7 +313,7 @@ export default async function DashboardPage() {
|
||||
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${DOT_STYLES[h.event_type] ?? "bg-ink-muted"}`} aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="text-sm font-semibold text-ink">{h.first_name && h.last_name ? `${h.first_name} ${h.last_name}` : "Unbekannt"}</span>
|
||||
<span className="text-sm font-semibold text-ink">{h.first_name && h.last_name ? fmtName(h.first_name, h.last_name) : "Unbekannt"}</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${actionBadgeStyle(h.event_type)}`}>
|
||||
{h.event_type}
|
||||
</span>
|
||||
|
||||
@@ -1,22 +1,39 @@
|
||||
import type { UnitOption } from "@/components/positions/CreatePositionModal";
|
||||
import { PositionsPageClient } from "@/components/positions/PositionsPageClient";
|
||||
import { daysBetweenIso } from "@/lib/format";
|
||||
import { loadOrgMaps } from "@/lib/org";
|
||||
import { loadOpenPositions } from "@/lib/positions";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { kostenstellenAbfrage } from "@/lib/cost-centers";
|
||||
import { withUser } from "@/lib/db";
|
||||
import { jsonArrayFrom } from "@/lib/db/json";
|
||||
import { daysBetweenIso, todayIso } from "@/lib/format";
|
||||
import { buildOrgMaps, orgMapsAbfragen } from "@/lib/org";
|
||||
import { offeneStellenAbfrage, resolveOpenPositions, type OffeneStelle } from "@/lib/positions";
|
||||
|
||||
export default async function PositionsPage() {
|
||||
const { openPositions, orgMaps, chiefRows } = await withUser(await currentUserId(), async (tx) => {
|
||||
const [openPositions, orgMaps, chiefRows] = await Promise.all([
|
||||
loadOpenPositions(tx),
|
||||
loadOrgMaps(tx),
|
||||
const today = todayIso();
|
||||
|
||||
const { openPositions, orgMaps, chiefRows, kostenstellen } = await withUser(await currentUserId(), async (tx) => {
|
||||
// Alles, was ohne Vorwissen geht, in einer Rundreise (lib/db/json.ts).
|
||||
const g = await tx
|
||||
.selectNoFrom((eb) => [
|
||||
...orgMapsAbfragen(eb),
|
||||
jsonArrayFrom(offeneStellenAbfrage(eb, today)).as("open"),
|
||||
jsonArrayFrom(kostenstellenAbfrage(eb, today)).as("kostenstellen"),
|
||||
// Wo es schon eine gültige Leitungsplanstelle gibt, lässt der
|
||||
// Unique-Index keine zweite zu — das gehört in den Dialog, nicht in eine
|
||||
// Fehlermeldung nach dem Absenden.
|
||||
tx.selectFrom("om_positions").select("org_unit_id").where("is_chief", "=", true).where("valid_to", "is", null).execute(),
|
||||
]);
|
||||
return { openPositions, orgMaps, chiefRows };
|
||||
jsonArrayFrom(
|
||||
eb.selectFrom("om_positions").select("org_unit_id").where("is_chief", "=", true).where("valid_to", "is", null)
|
||||
).as("chiefRows"),
|
||||
])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const orgMaps = buildOrgMaps(g.units as never, g.locations as never);
|
||||
return {
|
||||
orgMaps,
|
||||
chiefRows: g.chiefRows,
|
||||
kostenstellen: g.kostenstellen,
|
||||
openPositions: await resolveOpenPositions(tx, orgMaps, g.open as OffeneStelle[], today),
|
||||
};
|
||||
});
|
||||
|
||||
const withChief = new Set(chiefRows.map((r) => r.org_unit_id));
|
||||
@@ -30,5 +47,5 @@ export default async function PositionsPage() {
|
||||
|
||||
const openPositionsWithDays = openPositions.map((p) => ({ ...p, daysOpen: daysBetweenIso(p.vacantSince) }));
|
||||
|
||||
return <PositionsPageClient openPositions={openPositionsWithDays} units={units} />;
|
||||
return <PositionsPageClient openPositions={openPositionsWithDays} units={units} kostenstellen={kostenstellen} />;
|
||||
}
|
||||
|
||||
@@ -15,11 +15,15 @@ import {
|
||||
sumValues,
|
||||
totalForRows,
|
||||
} from "@/lib/reports";
|
||||
import { parseCriteria } from "@/lib/report-criteria";
|
||||
import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
|
||||
type SearchParams = {
|
||||
// Nur die Parameter, die diese Seite selbst auswertet. Die Auswahlkriterien
|
||||
// stehen ebenfalls in der Adresszeile, werden aber geschlossen von
|
||||
// parseCriteria gelesen — siehe lib/report-criteria.ts.
|
||||
type SearchParams = Record<string, string | string[] | undefined> & {
|
||||
mode?: string;
|
||||
measure?: string;
|
||||
group?: string;
|
||||
@@ -27,7 +31,6 @@ type SearchParams = {
|
||||
division?: string;
|
||||
location?: string;
|
||||
status?: string;
|
||||
employment?: string;
|
||||
asOf?: string;
|
||||
eventType?: string;
|
||||
from?: string;
|
||||
@@ -50,6 +53,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
||||
const measure = parseMeasure(params.measure);
|
||||
const group = parseGroupDimension(params.group);
|
||||
const split = parseSplitDimension(params.split);
|
||||
const criteria = parseCriteria((k) => (typeof params[k] === "string" ? (params[k] as string) : undefined));
|
||||
|
||||
// The report data depends on neither the org lookups nor on who is signed
|
||||
// in, so all three go out together. Against a hosted database a round trip
|
||||
@@ -61,8 +65,8 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
||||
// Lesestand ist über alle Abfragen hinweg derselbe. Vorher waren es drei
|
||||
// Wellen nacheinander, was gegen eine entfernte Datenbank der teuerste
|
||||
// Teil dieser Seite war.
|
||||
const { lookups, divisions, locations, events, employees, savedReports } = await withUser(userId, async (tx) => {
|
||||
const [{ lookups, divisions, locations }, events, employees, savedReports] = await Promise.all([
|
||||
const { lookups, units, locations, events, employees, savedReports } = await withUser(userId, async (tx) => {
|
||||
const [{ lookups, units, locations }, events, employees, savedReports] = await Promise.all([
|
||||
loadOrgLookups(tx),
|
||||
mode === "events"
|
||||
? loadEventHistory(tx, {
|
||||
@@ -78,7 +82,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
||||
division: params.division,
|
||||
location: params.location,
|
||||
status: params.status,
|
||||
employment: params.employment,
|
||||
criteria,
|
||||
asOf,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
@@ -91,7 +95,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
||||
.execute()
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
return { lookups, divisions, locations, events, employees, savedReports };
|
||||
return { lookups, units, locations, events, employees, savedReports };
|
||||
});
|
||||
|
||||
if (mode === "events") {
|
||||
@@ -108,7 +112,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
||||
rows={rows}
|
||||
total={sumValues(rows)}
|
||||
recordCount={events.length}
|
||||
divisions={divisions}
|
||||
units={units}
|
||||
locations={locations}
|
||||
savedReports={savedReports}
|
||||
/>
|
||||
@@ -130,12 +134,12 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
||||
division: params.division ?? "",
|
||||
location: params.location ?? "",
|
||||
status: params.status ?? "",
|
||||
employment: params.employment ?? "",
|
||||
}}
|
||||
criteria={criteria}
|
||||
rows={rows}
|
||||
total={totalForRows(rows, measure)}
|
||||
recordCount={employees.length}
|
||||
divisions={divisions}
|
||||
units={units}
|
||||
locations={locations}
|
||||
savedReports={savedReports}
|
||||
/>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { statusLabel } from "@/lib/absence";
|
||||
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
|
||||
import { todayIso } from "@/lib/format";
|
||||
import { fmtName, todayIso } from "@/lib/format";
|
||||
import { subtreeOf } from "@/lib/org";
|
||||
import { loadPlacements, loadReportingLineMap } from "@/lib/placement";
|
||||
import { LEERE_CRITERIA, parseCriteria, passtImSpeicher } from "@/lib/report-criteria";
|
||||
import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "@/lib/reports";
|
||||
import { loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data";
|
||||
import { applyCriteria, loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data";
|
||||
import { requireHrUser } from "@/lib/auth/require-hr";
|
||||
import { withUser } from "@/lib/db";
|
||||
import type { Database, EmploymentType, Weekday } from "@/lib/supabase/types";
|
||||
import type { Database, Weekday } from "@/lib/supabase/types";
|
||||
|
||||
// Die Rohzeile plus die Einordnung, die nicht mehr auf ihr steht: sie kommt
|
||||
// über die Planstelle und die abgeleitete Berichtslinie.
|
||||
@@ -35,7 +36,7 @@ export async function GET(request: NextRequest) {
|
||||
division: params.get("division") ?? undefined,
|
||||
location: params.get("location") ?? undefined,
|
||||
status: params.get("status") ?? undefined,
|
||||
employment: params.get("employment") ?? undefined,
|
||||
criteria: parseCriteria((k) => params.get(k)),
|
||||
};
|
||||
|
||||
const statuses = parseStatuses(filters.status);
|
||||
@@ -48,9 +49,10 @@ 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 (filters.employment) q = q.where("employment_type", "=", filters.employment as EmploymentType);
|
||||
if (!asOf) q = q.where("status", "in", statuses);
|
||||
return q;
|
||||
// Dieselben Bedingungen wie im Bericht daneben — sonst stimmt die
|
||||
// Zahl auf dem Bildschirm nicht mit der Zeilenzahl im Export überein.
|
||||
return applyCriteria(q, filters.criteria ?? LEERE_CRITERIA);
|
||||
}
|
||||
|
||||
const [employees, lookupResult, allEmployees, dependentsCounts, placements, lines] = await Promise.all([
|
||||
@@ -74,15 +76,17 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
);
|
||||
|
||||
const managerName = new Map(allEmployees.map((e) => [e.id, `${e.first_name} ${e.last_name}`]));
|
||||
const managerName = new Map(allEmployees.map((e) => [e.id, fmtName(e.first_name, e.last_name)]));
|
||||
// Der Einheitenfilter meint den ganzen Teilbaum — sonst enthielte ein
|
||||
// Export für "Produktion" nur die Bereichsleitung.
|
||||
const allowedUnits = filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null;
|
||||
|
||||
const criteria = filters.criteria ?? LEERE_CRITERIA;
|
||||
const enriched: EmployeeRow[] = employees.flatMap((e) => {
|
||||
const placement = placements.get(e.id);
|
||||
const orgUnitId = placement?.current ? placement.orgUnitId : null;
|
||||
if (allowedUnits && (!orgUnitId || !allowedUnits.has(orgUnitId))) return [];
|
||||
if (!passtImSpeicher({ work_days: e.work_days, dependentsCount: dependentsCounts.get(e.id) ?? 0 }, criteria)) return [];
|
||||
return [{
|
||||
...e,
|
||||
org_unit_id: orgUnitId,
|
||||
@@ -117,12 +121,14 @@ function employeeExportColumns(
|
||||
{ header: "Geburtsdatum", get: (e) => e.birth_date, kind: "date" },
|
||||
{ header: "SV-Nummer", get: (e) => e.sv_nummer },
|
||||
{ header: "Staatsbürgerschaft", get: (e) => e.nationality },
|
||||
{ header: "Aufenthaltstitel", get: (e) => e.hat_aufenthaltstitel },
|
||||
{ header: "Aufenthaltstitel bis", get: (e) => e.aufenthaltstitel_bis, kind: "date" },
|
||||
{ header: "Adresse", get: (e) => e.address },
|
||||
{ header: "Postleitzahl", get: (e) => e.postal_code },
|
||||
{ header: "Ort", get: (e) => e.city },
|
||||
{ header: "Wohnsitzland", get: (e) => e.address_country },
|
||||
{ header: "E-Mail", get: (e) => e.email },
|
||||
{ header: "Telefon", get: (e) => e.phone },
|
||||
{ header: "Private E-Mail", get: (e) => e.email },
|
||||
{ header: "Private Telefonnummer", get: (e) => e.phone },
|
||||
{ header: "Bereich", get: (e) => (e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "") : "") },
|
||||
{ header: "Abteilung", get: (e) => (e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "") : "") },
|
||||
{ header: "Team", get: (e) => (e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "") : "") },
|
||||
@@ -142,6 +148,14 @@ function employeeExportColumns(
|
||||
{ header: "Kollektivvertrag", get: (e) => e.collective_agreement },
|
||||
{ header: "Betriebsrat", get: (e) => e.is_betriebsrat },
|
||||
{ header: "Dienstwagen", get: (e) => e.has_dienstwagen },
|
||||
{ header: "Antriebsart", get: (e) => e.dienstwagen_art ?? "" },
|
||||
{ header: "Besonderer Kündigungsschutz", get: (e) => e.has_kuendigungsschutz },
|
||||
{ header: "Kündigungsschutz bis", get: (e) => e.kuendigungsschutz_bis, kind: "date" },
|
||||
{ header: "Teilzeitvariante", get: (e) => e.teilzeit_art ?? "" },
|
||||
{ header: "Teilzeit bis", get: (e) => e.teilzeit_bis, kind: "date" },
|
||||
{ header: "Notfallkontakt", get: (e) => e.emergency_contact_name ?? "" },
|
||||
{ header: "Notfallkontakt Telefon", get: (e) => e.emergency_contact_phone ?? "" },
|
||||
{ header: "Notfallkontakt Verhältnis", get: (e) => e.emergency_contact_relation ?? "" },
|
||||
{ header: "Laterale Führung", get: (e) => e.is_laterale_fuehrung },
|
||||
{ header: "C-Level", get: (e) => e.is_c_level },
|
||||
{ header: "Paygrade", get: (e) => e.paygrade },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
|
||||
import { parseCriteria } from "@/lib/report-criteria";
|
||||
import {
|
||||
aggregateEvents,
|
||||
aggregateReport,
|
||||
@@ -69,7 +70,7 @@ export async function GET(request: NextRequest) {
|
||||
division: params.get("division") ?? undefined,
|
||||
location: params.get("location") ?? undefined,
|
||||
status: params.get("status") ?? undefined,
|
||||
employment: params.get("employment") ?? undefined,
|
||||
criteria: parseCriteria((k) => params.get(k)),
|
||||
asOf,
|
||||
});
|
||||
rows = aggregateReport(employees, measure, group, split, lookups, asOf);
|
||||
|
||||
@@ -25,7 +25,11 @@ class Rueckabwicklung extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export const maxDuration = 120;
|
||||
// 60 Sekunden, weil das die Obergrenze im kostenlosen Vercel-Tarif ist —
|
||||
// ein höherer Wert lässt sich dort nicht ausrollen. Auf einem eigenen Server
|
||||
// gilt die Angabe ohnehin nicht, und im Pro-Tarif liesse sie sich auf 300
|
||||
// heben, falls eine Datei mit vielen tausend Zeilen ansteht.
|
||||
export const maxDuration = 60;
|
||||
|
||||
type Antwort = {
|
||||
ok: boolean;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { AenderungsTabelle } from "@/components/ui/AenderungsTabelle";
|
||||
import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { actionBadgeStyle } from "@/lib/colors";
|
||||
import type { AuditChange } from "@/lib/supabase/types";
|
||||
@@ -33,14 +34,6 @@ const zeitFormat = new Intl.DateTimeFormat("de-AT", {
|
||||
timeZone: "Europe/Vienna",
|
||||
});
|
||||
|
||||
/** Leerer Wert heisst „war nicht gesetzt“ — und das ist eine Aussage. */
|
||||
function Wert({ text, art }: { text: string | null; art: "vorher" | "nachher" }) {
|
||||
if (text === null || text === "") {
|
||||
return <span className="text-ink-muted italic">leer</span>;
|
||||
}
|
||||
return <span className={art === "vorher" ? "text-ink-muted line-through decoration-ink-muted/40" : "text-ink"}>{text}</span>;
|
||||
}
|
||||
|
||||
export function AuditDetail({ eintrag }: { eintrag: AuditEintrag }) {
|
||||
const [offen, setOffen] = useState(false);
|
||||
const anzahl = eintrag.changes?.length ?? 0;
|
||||
@@ -101,29 +94,8 @@ export function AuditDetail({ eintrag }: { eintrag: AuditEintrag }) {
|
||||
|
||||
<h3 className="mt-6 text-sm font-bold text-ink">Geänderte Felder</h3>
|
||||
{anzahl > 0 ? (
|
||||
<div className="mt-2 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-[11px] font-bold uppercase tracking-wider text-ink-muted">
|
||||
<th className="py-2 pr-4">Feld</th>
|
||||
<th className="py-2 pr-4">Vorher</th>
|
||||
<th className="py-2">Nachher</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{eintrag.changes!.map((c, i) => (
|
||||
<tr key={i} className="border-b border-border-subtle align-top last:border-0">
|
||||
<td className="py-2 pr-4 font-semibold text-ink-body">{c.feld}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<Wert text={c.vorher} art="vorher" />
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<Wert text={c.nachher} art="nachher" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-2">
|
||||
<AenderungsTabelle changes={eintrag.changes!} />
|
||||
</div>
|
||||
) : (
|
||||
// Kein Aufzählungszeichen für „nichts da“: der Grund ist wichtig,
|
||||
|
||||
70
components/dashboard/AnstehendFilter.tsx
Normal file
70
components/dashboard/AnstehendFilter.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
import { actionBadgeStyle } from "@/lib/colors";
|
||||
import {
|
||||
ANSTEHEND_ARTEN,
|
||||
STANDARD_ZEITRAUM,
|
||||
ZEITRAEUME,
|
||||
type AnstehendArt,
|
||||
type Zeitraum,
|
||||
} from "@/lib/dashboard-filter";
|
||||
|
||||
// Die Auswahl wandert in die Adresse; die Seite baut sich damit neu. Das ist
|
||||
// hier nötig und nicht bloss ordentlich: ein längerer Zeitraum bringt Zeilen
|
||||
// ins Spiel, die vorher gar nicht geladen waren.
|
||||
//
|
||||
// router.replace statt push, damit der Zurück-Knopf nicht durch jede einzelne
|
||||
// Filterstellung zurückläuft, und ohne Sprung nach oben — die Karte steht in
|
||||
// der unteren Hälfte, und dorthin sieht gerade, wer hier klickt.
|
||||
|
||||
export function AnstehendFilter({ zeitraum, arten }: { zeitraum: Zeitraum; arten: AnstehendArt[] }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
function setzen(key: string, wert: string | null) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (wert) params.set(key, wert);
|
||||
else params.delete(key);
|
||||
const query = params.toString();
|
||||
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
|
||||
}
|
||||
|
||||
function artUmschalten(art: AnstehendArt) {
|
||||
const alle = ANSTEHEND_ARTEN.map((a) => a.value);
|
||||
const naechste = arten.includes(art) ? arten.filter((a) => a !== art) : [...arten, art];
|
||||
// Nichts ausgewählt heisst wieder alles: eine leere Karte ist keine
|
||||
// Antwort, und der Weg dorthin wäre ein Klick zu weit.
|
||||
setzen("arten", naechste.length === 0 || naechste.length === alle.length ? null : naechste.join(","));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<SegmentedControl<string>
|
||||
value={String(zeitraum)}
|
||||
onChange={(v) => setzen("tage", v === String(STANDARD_ZEITRAUM) ? null : v)}
|
||||
options={ZEITRAEUME.map((t) => ({ value: String(t), label: `${t} Tage` }))}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{ANSTEHEND_ARTEN.map((a) => {
|
||||
const aktiv = arten.includes(a.value);
|
||||
return (
|
||||
<button
|
||||
key={a.value}
|
||||
type="button"
|
||||
onClick={() => artUmschalten(a.value)}
|
||||
aria-pressed={aktiv}
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
|
||||
aktiv ? actionBadgeStyle(a.label) : "bg-surface text-ink-muted hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{a.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,8 @@ type EmployeeDetailProps = {
|
||||
employee: EmployeeRow;
|
||||
placement: PlacementInfo | null;
|
||||
breadcrumb: string;
|
||||
/** Die Kostenstelle der laufenden Planstellenbesetzung. */
|
||||
kostenstelle: { code: string; name: string } | null;
|
||||
manager: MiniEmployee | null;
|
||||
/** Nur gesetzt, wenn die zuständige Leitung abwesend ist und vertreten wird. */
|
||||
formalManager: MiniEmployee | null;
|
||||
@@ -49,7 +51,7 @@ type PanelType = "transfer" | "promote" | "karenz" | "daten" | "terminate" | "re
|
||||
const TABS = ["Stammdaten", "Vertrag", "Organisation", "Historie", "HR-Notizen"] as const;
|
||||
|
||||
export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
const { employee, placement, breadcrumb, manager, formalManager, directReports, history, dependents, notes, locations, openPositions } = props;
|
||||
const { employee, placement, breadcrumb, kostenstelle, manager, formalManager, directReports, history, dependents, notes, locations, openPositions } = props;
|
||||
const [tab, setTab] = useState<(typeof TABS)[number]>("Stammdaten");
|
||||
const [panel, setPanel] = useState<PanelType>(null);
|
||||
|
||||
@@ -106,14 +108,20 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
</>
|
||||
)}
|
||||
{canEditData && <ActionButton icon={Pencil} label="Daten ändern" onClick={() => setPanel("daten")} />}
|
||||
{isActive && (
|
||||
{/* Auch bei einem geplanten Eintritt, und dort heisst es anders:
|
||||
wer nie angetreten ist, „tritt" nicht „aus". Die Person hat
|
||||
noch keinen Tag gearbeitet, und genau dafür gibt es den Grund
|
||||
„No Show" — ohne diesen Knopf bliebe sie auf Dauer als
|
||||
geplanter Eintritt stehen. */}
|
||||
{(isActive || employee.status === "Geplant") && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setPanel("terminate")}
|
||||
className="!border-danger-solid !text-danger-solid hover:!bg-danger-bg"
|
||||
>
|
||||
<XCircle className="h-4 w-4" /> Austritt
|
||||
<XCircle className="h-4 w-4" />
|
||||
{employee.status === "Geplant" ? "Nicht angetreten" : "Austritt"}
|
||||
</Button>
|
||||
)}
|
||||
{employee.status === "Ausgetreten" && (
|
||||
@@ -153,9 +161,10 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
formalManager={formalManager}
|
||||
directReports={directReports}
|
||||
breadcrumb={breadcrumb}
|
||||
kostenstelle={kostenstelle}
|
||||
/>
|
||||
)}
|
||||
{tab === "Historie" && <HistorieTab history={history} />}
|
||||
{tab === "Historie" && <HistorieTab history={history} employeeId={employee.id} />}
|
||||
{tab === "HR-Notizen" && <NotizenTab employeeId={employee.id} notes={notes} />}
|
||||
</div>
|
||||
|
||||
@@ -175,7 +184,12 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
locationCountry={location?.country}
|
||||
/>
|
||||
<TerminatePanel open={panel === "terminate"} onClose={() => setPanel(null)} employee={employee} directReportCount={directReports.length} />
|
||||
<RehirePanel open={panel === "rehire"} onClose={() => setPanel(null)} employee={employee} />
|
||||
<RehirePanel
|
||||
open={panel === "rehire"}
|
||||
onClose={() => setPanel(null)}
|
||||
employee={employee}
|
||||
openPositions={openPositions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -104,6 +104,12 @@ export function EmployeeFilters({ units, depthOf, locations }: EmployeeFiltersPr
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* Der Dienstwagen stand hier einmal als eigenes Auswahlfeld. Er ist
|
||||
jetzt eines von rund zwanzig Kriterien unter Berichte, zusammen mit
|
||||
Vertragsart, Kollektivvertrag, Eintrittszeitraum und dem Rest —
|
||||
dort lässt sich die Auswahl auch exportieren, was der eigentliche
|
||||
Zweck der Frage war. In dieser Leiste, die vor allem zum Suchen da
|
||||
ist, wäre er ein Sonderfall unter vielen gleichrangigen. */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
210
components/employees/HistorieBearbeiten.tsx
Normal file
210
components/employees/HistorieBearbeiten.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { Pencil } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { updateHistoryEntry } from "@/actions/employees";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { TextField } from "@/components/ui/Field";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import type { AuditChange } from "@/lib/supabase/types";
|
||||
|
||||
// Berichtigen, nicht neu erfassen.
|
||||
//
|
||||
// „Daten ändern" schreibt eine *neue* Änderung — richtig, wenn sich etwas
|
||||
// wirklich geändert hat. Hier geht es um den anderen Fall: der Vorgang
|
||||
// stimmt, aber der erfasste Wert oder das Datum nicht. Ohne diesen Weg
|
||||
// stünden in der Akte zwei Einträge für eine Änderung, von denen der erste
|
||||
// nie stattgefunden hat.
|
||||
//
|
||||
// Bearbeitet wird nur das **Nachher**. Das Vorher steht daneben, unveränder-
|
||||
// lich: es beschreibt, was vor der Änderung galt, und das lässt sich
|
||||
// nachträglich nicht anders beschliessen.
|
||||
|
||||
export function HistorieBearbeiten({
|
||||
historyId,
|
||||
employeeId,
|
||||
bezeichnung,
|
||||
datum,
|
||||
changes,
|
||||
istZukunft,
|
||||
heute,
|
||||
nurDatum = false,
|
||||
}: {
|
||||
historyId: string;
|
||||
employeeId: string;
|
||||
bezeichnung: string;
|
||||
datum: string;
|
||||
changes: AuditChange[];
|
||||
/** Noch nicht wirksam — dann wird der geplante Vorgang berichtigt, nicht der Stand. */
|
||||
istZukunft: boolean;
|
||||
/** Vom Server, nicht aus new Date(): sonst rechnet der Browser mit seiner
|
||||
* eigenen Zeitzone, und in einer Renderfunktion hat die Uhr ohnehin nichts
|
||||
* verloren. */
|
||||
heute: string;
|
||||
/**
|
||||
* Der Eintritt hat keine Felder, die sich zurücknehmen liessen — nur ein
|
||||
* Datum, das falsch erfasst sein kann. Daran hängt trotzdem einiges: die
|
||||
* erste Planstellenbesetzung, der frühestmögliche Zeitpunkt jedes weiteren
|
||||
* Ereignisses, die Zugehörigkeit. Die Datenbank prüft das und weist
|
||||
* verständlich ab.
|
||||
*/
|
||||
nurDatum?: boolean;
|
||||
}) {
|
||||
const [offen, setOffen] = useState(false);
|
||||
const [laeuft, setLaeuft] = useState(false);
|
||||
const [neuesDatum, setNeuesDatum] = useState(datum);
|
||||
const [werte, setWerte] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(changes.map((c) => [c.feld, c.nachher ?? ""]))
|
||||
);
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
// Morgen aus dem Serverdatum, nicht aus der Uhr des Browsers.
|
||||
const morgen = new Date(Date.parse(heute + "T00:00:00Z") + 86400000).toISOString().slice(0, 10);
|
||||
const etwasGeaendert =
|
||||
neuesDatum !== datum || changes.some((c) => (werte[c.feld] ?? "") !== (c.nachher ?? ""));
|
||||
|
||||
function abbrechen() {
|
||||
// Beim Schliessen zurück auf den gespeicherten Stand, damit ein zweites
|
||||
// Öffnen nicht die verworfenen Eingaben zeigt.
|
||||
setNeuesDatum(datum);
|
||||
setWerte(Object.fromEntries(changes.map((c) => [c.feld, c.nachher ?? ""])));
|
||||
setOffen(false);
|
||||
}
|
||||
|
||||
async function speichern() {
|
||||
// Ein Eintrag bleibt auf seiner Seite der Gegenwart. Eine gelaufene
|
||||
// Änderung in eine geplante zu verwandeln (oder umgekehrt) hiesse,
|
||||
// Stammdaten und Vorgang gegenläufig anzupassen — dafür gibt es die
|
||||
// fachlichen Vorgänge. Die Datenbank weist es ohnehin ab; hier steht es
|
||||
// nur früher und freundlicher.
|
||||
// Beim Eintritt gilt das nicht: er darf in der Vergangenheit *und* in der
|
||||
// Zukunft liegen — ein geplanter Eintritt ist ein gewöhnlicher Fall. Was
|
||||
// dort zusammenpassen muss, prüft die Datenbank und sagt es verständlich.
|
||||
if (!nurDatum && istZukunft && neuesDatum <= heute) {
|
||||
showToast("Eine geplante Änderung lässt sich hier nicht vorziehen.", "error");
|
||||
return;
|
||||
}
|
||||
if (!nurDatum && !istZukunft && neuesDatum > heute) {
|
||||
showToast("Eine bereits wirksame Änderung lässt sich nicht in die Zukunft verschieben.", "error");
|
||||
return;
|
||||
}
|
||||
setLaeuft(true);
|
||||
const ergebnis = await updateHistoryEntry({
|
||||
history_id: historyId,
|
||||
employee_id: employeeId,
|
||||
event_date: neuesDatum,
|
||||
werte: changes.map((c) => ({ feld: c.feld, nachher: werte[c.feld]?.trim() || null })),
|
||||
});
|
||||
setLaeuft(false);
|
||||
if (ergebnis.success) {
|
||||
showToast("Eintrag berichtigt.");
|
||||
setOffen(false);
|
||||
router.refresh();
|
||||
} else {
|
||||
showToast(ergebnis.error ?? "Berichtigen fehlgeschlagen.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOffen(true)}
|
||||
aria-label={`${bezeichnung} vom ${fmtDate(datum)} bearbeiten`}
|
||||
className="rounded p-1 text-ink-muted hover:bg-brand-50 hover:text-brand-700
|
||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
<Modal
|
||||
open={offen}
|
||||
onClose={abbrechen}
|
||||
title="Eintrag berichtigen"
|
||||
widthClassName="max-w-2xl"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={abbrechen}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={speichern} pending={laeuft} disabled={!etwasGeaendert}>
|
||||
Berichtigen
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-ink-body">
|
||||
<strong className="text-ink">{bezeichnung}</strong>
|
||||
{nurDatum
|
||||
? " — der Eintritt selbst bleibt; berichtigt wird nur sein Datum."
|
||||
: istZukunft
|
||||
? " — diese Änderung ist noch nicht wirksam. Berichtigt wird, was am Stichtag passieren soll."
|
||||
: " — was hier stand, war falsch erfasst. Für eine tatsächliche Änderung ist „Daten ändern“ der richtige Weg."}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 max-w-xs">
|
||||
<TextField
|
||||
label={nurDatum ? "Eintrittsdatum" : "Wirksam ab"}
|
||||
type="date"
|
||||
min={!nurDatum && istZukunft ? morgen : undefined}
|
||||
max={!nurDatum && !istZukunft ? heute : undefined}
|
||||
value={neuesDatum}
|
||||
onChange={setNeuesDatum}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{nurDatum && (
|
||||
<p className="mt-4 rounded bg-surface px-3 py-2 text-xs text-ink-muted">
|
||||
Daran hängt mehr als eine Zahl: die erste Planstellenbesetzung wandert mit, und kein anderes Ereignis darf
|
||||
vor dem Eintritt liegen. Passt das neue Datum nicht dazu, wird die Änderung mit dem Grund abgewiesen.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={`mt-5 overflow-x-auto ${nurDatum ? "hidden" : ""}`}>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-[11px] font-bold uppercase tracking-wider text-ink-muted">
|
||||
<th className="py-2 pr-4">Feld</th>
|
||||
<th className="py-2 pr-4">Vorher</th>
|
||||
<th className="py-2">Nachher</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{changes.map((c) => (
|
||||
<tr key={c.feld} className="border-b border-border-subtle align-middle last:border-0">
|
||||
<td className="py-2 pr-4 font-semibold text-ink-body">{c.feld}</td>
|
||||
<td className="py-2 pr-4 text-ink-muted">
|
||||
{c.vorher === null || c.vorher === "" ? <span className="italic">leer</span> : c.vorher}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<input
|
||||
type="text"
|
||||
aria-label={`${c.feld} — neuer Wert`}
|
||||
value={werte[c.feld] ?? ""}
|
||||
onChange={(e) => setWerte((v) => ({ ...v, [c.feld]: e.target.value }))}
|
||||
className="w-full rounded border border-border px-2 py-1 text-sm text-ink
|
||||
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 rounded bg-surface px-3 py-2 text-xs text-ink-muted">
|
||||
{nurDatum
|
||||
? "Stammdaten, Historie und Planstellenbesetzung werden gemeinsam nachgezogen."
|
||||
: istZukunft
|
||||
? "An den Stammdaten ändert sich jetzt nichts — die Änderung greift erst am Stichtag. Berichtigt wird der geplante Vorgang selbst."
|
||||
: "Die Stammdaten werden nachgezogen — je Feld gilt dann der jüngste Eintrag, der es trägt. Hat eine spätere Änderung dasselbe Feld erneut gesetzt, bleibt deren Wert stehen."}{" "}
|
||||
Die Berichtigung selbst steht im Protokoll.
|
||||
</p>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
162
components/employees/HistorieLoeschen.tsx
Normal file
162
components/employees/HistorieLoeschen.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { deleteHistoryEntry } from "@/actions/employees";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import type { Vorschau } from "@/lib/history";
|
||||
|
||||
// Löschen mit Ansage.
|
||||
//
|
||||
// Bestätigen heisst hier nicht „Wirklich?" — das beantwortet jede Person nach
|
||||
// dem dritten Mal blind mit Ja. Der Dialog sagt stattdessen, **was danach
|
||||
// anders ist**: welches Feld auf welchen Wert zurückgeht, und welches nicht,
|
||||
// weil eine spätere Änderung es erneut angefasst hat. Wer das liest, merkt
|
||||
// selbst, ob er den richtigen Eintrag erwischt hat.
|
||||
|
||||
export function HistorieLoeschen({
|
||||
historyId,
|
||||
employeeId,
|
||||
bezeichnung,
|
||||
datum,
|
||||
vorschau,
|
||||
istZukunft,
|
||||
}: {
|
||||
historyId: string;
|
||||
employeeId: string;
|
||||
bezeichnung: string;
|
||||
datum: string;
|
||||
vorschau: Vorschau[];
|
||||
/** Noch nicht wirksam — dann wird der geplante Vorgang entschärft. */
|
||||
istZukunft: boolean;
|
||||
}) {
|
||||
const [offen, setOffen] = useState(false);
|
||||
const [laeuft, setLaeuft] = useState(false);
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const zurueck = vorschau.filter((v) => !v.bleibt);
|
||||
const bleibt = vorschau.filter((v) => v.bleibt);
|
||||
// Eine geplante Abwesenheit oder Rückkehr hat keine einzelnen Felder, die
|
||||
// sich herausnehmen liessen — sie fällt als Ganzes. Dann ist „Löschen und
|
||||
// zurücksetzen" das falsche Wort für das, was der Knopf tut.
|
||||
const ganzerVorgang = istZukunft && vorschau.length === 0;
|
||||
|
||||
async function loeschen() {
|
||||
setLaeuft(true);
|
||||
const ergebnis = await deleteHistoryEntry({ history_id: historyId, employee_id: employeeId });
|
||||
setLaeuft(false);
|
||||
if (ergebnis.success) {
|
||||
showToast("Eintrag gelöscht.");
|
||||
setOffen(false);
|
||||
router.refresh();
|
||||
} else {
|
||||
showToast(ergebnis.error ?? "Löschen fehlgeschlagen.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOffen(true)}
|
||||
aria-label={`${bezeichnung} vom ${fmtDate(datum)} löschen`}
|
||||
className="rounded p-1 text-ink-muted hover:bg-danger-bg hover:text-danger-text
|
||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
<Modal
|
||||
open={offen}
|
||||
onClose={() => setOffen(false)}
|
||||
title={ganzerVorgang ? "Geplanten Vorgang abbrechen" : "Eintrag löschen"}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setOffen(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={loeschen} pending={laeuft} className="!bg-danger-solid text-white hover:brightness-110">
|
||||
{ganzerVorgang ? "Vorgang abbrechen" : "Löschen und zurücksetzen"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-ink-body">
|
||||
<strong className="text-ink">{bezeichnung}</strong> {istZukunft ? "zum" : "vom"} {fmtDate(datum)} wird aus der
|
||||
Historie entfernt.
|
||||
</p>
|
||||
|
||||
{ganzerVorgang && (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wide text-ink-muted">Wird nicht mehr passieren</h3>
|
||||
<p className="mt-1.5 text-sm text-ink-body">
|
||||
Der Vorgang entfällt ganz — die {bezeichnung} zum {fmtDate(datum)} findet nicht statt. Am Stammsatz wird
|
||||
das vorgemerkte Datum mit entfernt.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-ink-muted">
|
||||
An den heutigen Stammdaten ändert sich nichts: der Vorgang war noch nicht wirksam.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{istZukunft && vorschau.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wide text-ink-muted">Wird nicht mehr passieren</h3>
|
||||
<ul className="mt-1.5 flex flex-col gap-1">
|
||||
{vorschau.map((v) => (
|
||||
<li key={v.feld} className="text-sm text-ink-body">
|
||||
<span className="font-semibold text-ink">{v.feld}</span>{" "}
|
||||
<span className="text-ink-muted">sollte auf</span> <span className="text-ink">{v.von || "leer"}</span>{" "}
|
||||
<span className="text-ink-muted">gesetzt werden</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-2 text-xs text-ink-muted">
|
||||
An den Stammdaten ändert sich nichts — die Änderung war noch nicht wirksam. Betrifft der geplante Vorgang
|
||||
noch weitere Felder, läuft er mit diesen weiter.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!istZukunft && zurueck.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wide text-ink-muted">Wird zurückgesetzt</h3>
|
||||
<ul className="mt-1.5 flex flex-col gap-1">
|
||||
{zurueck.map((v) => (
|
||||
<li key={v.feld} className="text-sm text-ink-body">
|
||||
<span className="font-semibold text-ink">{v.feld}</span>{" "}
|
||||
<span className="text-ink-muted line-through decoration-ink-muted/40">{v.von || "leer"}</span>{" "}
|
||||
<span aria-hidden="true">→</span> <span className="text-ink">{v.auf || "leer"}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!istZukunft && bleibt.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wide text-ink-muted">Bleibt unverändert</h3>
|
||||
<ul className="mt-1.5 flex flex-col gap-1">
|
||||
{bleibt.map((v) => (
|
||||
<li key={v.feld} className="text-sm text-ink-muted">
|
||||
<span className="font-semibold">{v.feld}</span> — eine spätere Änderung hat dieses Feld erneut
|
||||
gesetzt, und die gilt weiter.
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-4 rounded bg-surface px-3 py-2 text-xs text-ink-muted">
|
||||
Der Vorgang wird im Protokoll festgehalten — mit Zeitpunkt, Person und den Werten der gelöschten Zeile. Die
|
||||
Zeile selbst lässt sich nicht wiederherstellen.
|
||||
</p>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { SelectField } from "@/components/ui/Field";
|
||||
import type { CollectiveAgreement, Weekday, WorkerType } from "@/lib/supabase/types";
|
||||
import { SelectField, TextField } from "@/components/ui/Field";
|
||||
import type { CollectiveAgreement, DienstwagenArt, Weekday, WorkerType } from "@/lib/supabase/types";
|
||||
|
||||
const WEEKDAYS: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||
|
||||
@@ -11,8 +11,25 @@ export type RoleEmploymentValue = {
|
||||
workDays: Weekday[];
|
||||
isBetriebsrat: boolean;
|
||||
hasDienstwagen: boolean;
|
||||
/**
|
||||
* Nur bedeutsam, solange hasDienstwagen gesetzt ist.
|
||||
*
|
||||
* Der Wert bleibt beim Abwählen stehen, statt zurückgesetzt zu werden —
|
||||
* wer versehentlich klickt und zurückklickt, findet seine Angabe wieder.
|
||||
* Beim Speichern setzen die Aufrufer ihn auf null, wie es der CHECK
|
||||
* verlangt.
|
||||
*/
|
||||
dienstwagenArt: DienstwagenArt;
|
||||
isLateraleFuehrung: boolean;
|
||||
isCLevel: boolean;
|
||||
/** Betriebsrat, Mutterschutz, Karenz, begünstigte Behinderung, Lehre. */
|
||||
hasKuendigungsschutz: boolean;
|
||||
/**
|
||||
* Ende des Schutzes — freiwillig. Bei einem Betriebsratsmandat steht es
|
||||
* fest, bei einer Schwangerschaft nicht; ein Pflichtfeld zwänge dort zu
|
||||
* einer erfundenen Zahl. Leer heisst „bis auf Weiteres".
|
||||
*/
|
||||
kuendigungsschutzBis: string;
|
||||
};
|
||||
|
||||
// Shared by the hire wizard (StepVertrag) and DatenAendernPanel — both edit
|
||||
@@ -78,6 +95,21 @@ export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymen
|
||||
<input type="checkbox" checked={value.hasDienstwagen} onChange={(e) => onChange({ hasDienstwagen: e.target.checked })} />
|
||||
Dienstwagen
|
||||
</label>
|
||||
{/* Nur sichtbar, wenn es einen gibt: eine Antriebsart ohne Fahrzeug
|
||||
ist keine Angabe, sondern eine Frage ohne Gegenstand — und die
|
||||
Datenbank weist sie ab. */}
|
||||
{value.hasDienstwagen && (
|
||||
<SelectField
|
||||
label="Antriebsart"
|
||||
dense
|
||||
value={value.dienstwagenArt}
|
||||
onChange={(v) => onChange({ dienstwagenArt: v as DienstwagenArt })}
|
||||
options={[
|
||||
{ value: "Verbrenner", label: "Verbrenner" },
|
||||
{ value: "Elektro", label: "Elektro (E-KFZ)" },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-sm text-ink-body">
|
||||
<input type="checkbox" checked={value.isLateraleFuehrung} onChange={(e) => onChange({ isLateraleFuehrung: e.target.checked })} />
|
||||
Laterale Führung
|
||||
@@ -86,6 +118,27 @@ export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymen
|
||||
<input type="checkbox" checked={value.isCLevel} onChange={(e) => onChange({ isCLevel: e.target.checked })} />
|
||||
C-Level
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-ink-body">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value.hasKuendigungsschutz}
|
||||
onChange={(e) => onChange({ hasKuendigungsschutz: e.target.checked })}
|
||||
/>
|
||||
Besonderer Kündigungsschutz
|
||||
</label>
|
||||
{/* Wie bei der Antriebsart: erst sichtbar, wenn es einen Gegenstand
|
||||
gibt. Anders als dort aber freiwillig — leer heisst „bis auf
|
||||
Weiteres", nicht „vergessen". */}
|
||||
{value.hasKuendigungsschutz && (
|
||||
<TextField
|
||||
label="Geschützt bis"
|
||||
dense
|
||||
type="date"
|
||||
value={value.kuendigungsschutzBis}
|
||||
onChange={(v) => onChange({ kuendigungsschutzBis: v })}
|
||||
hint="Optional. Leer lassen, solange das Ende nicht feststeht."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,10 +12,11 @@ import { CountryPicker } from "@/components/ui/CountryPicker";
|
||||
import { Field, SelectField, TextField } from "@/components/ui/Field";
|
||||
import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { UN_COUNTRIES } from "@/lib/countries";
|
||||
import { brauchtAufenthaltstitel, UN_COUNTRIES } from "@/lib/countries";
|
||||
import { STUNDEN_GRUENDE } from "@/lib/absence";
|
||||
import { fmtFullName, todayIso } from "@/lib/format";
|
||||
import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr";
|
||||
import type { ContractType, Database, EmploymentType, GenderType } from "@/lib/supabase/types";
|
||||
import { EMERGENCY_RELATIONS, type ContractType, type Database, type EmploymentType, type GenderType } from "@/lib/supabase/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
|
||||
@@ -52,15 +53,28 @@ export function DatenAendernPanel({
|
||||
const svNummerOk =
|
||||
!svNummer.trim() || !requiresAustrianSvnr(locationCountry) || isValidSvnr(svNummer, birthDate || null);
|
||||
const [nationality, setNationality] = useState(employee.nationality);
|
||||
const [hatTitel, setHatTitel] = useState(employee.hat_aufenthaltstitel ?? false);
|
||||
const [titelBis, setTitelBis] = useState(employee.aufenthaltstitel_bis ?? "");
|
||||
// Die Staatsbürgerschaft entscheidet, ob die Frage überhaupt gestellt wird.
|
||||
const titelNoetig = brauchtAufenthaltstitel(nationality);
|
||||
const [address, setAddress] = useState(employee.address ?? "");
|
||||
const [postalCode, setPostalCode] = useState(employee.postal_code ?? "");
|
||||
const [city, setCity] = useState(employee.city ?? "");
|
||||
const [addressCountry, setAddressCountry] = useState(employee.address_country ?? "Österreich");
|
||||
const [email, setEmail] = useState(employee.email);
|
||||
const [email, setEmail] = useState(employee.email ?? "");
|
||||
const [phone, setPhone] = useState(employee.phone ?? "");
|
||||
const [notfallName, setNotfallName] = useState(employee.emergency_contact_name ?? "");
|
||||
const [notfallTelefon, setNotfallTelefon] = useState(employee.emergency_contact_phone ?? "");
|
||||
const [notfallVerhaeltnis, setNotfallVerhaeltnis] = useState(employee.emergency_contact_relation ?? "");
|
||||
|
||||
const [employmentType, setEmploymentType] = useState<EmploymentType>(employee.employment_type);
|
||||
const [weeklyHours, setWeeklyHours] = useState(String(employee.weekly_hours));
|
||||
// Der Grund wird erst gefragt, wenn sich die Stunden tatsächlich ändern —
|
||||
// sonst stünde bei jeder Adressänderung eine Frage im Weg, die niemand
|
||||
// gestellt hat.
|
||||
const [stundenGrund, setStundenGrund] = useState<string>(STUNDEN_GRUENDE[0]);
|
||||
const stundenGeaendert = Number(weeklyHours) !== Number(employee.weekly_hours);
|
||||
const [teilzeitBis, setTeilzeitBis] = useState(employee.teilzeit_bis ?? "");
|
||||
const [contractType, setContractType] = useState<ContractType>(employee.contract_type);
|
||||
const [contractEndDate, setContractEndDate] = useState(employee.contract_end_date ?? "");
|
||||
|
||||
@@ -70,6 +84,9 @@ export function DatenAendernPanel({
|
||||
workDays: employee.work_days ?? ["Mo", "Di", "Mi", "Do", "Fr"],
|
||||
isBetriebsrat: employee.is_betriebsrat ?? false,
|
||||
hasDienstwagen: employee.has_dienstwagen ?? false,
|
||||
hasKuendigungsschutz: employee.has_kuendigungsschutz ?? false,
|
||||
kuendigungsschutzBis: employee.kuendigungsschutz_bis ?? "",
|
||||
dienstwagenArt: employee.dienstwagen_art ?? "Verbrenner",
|
||||
isLateraleFuehrung: employee.is_laterale_fuehrung ?? false,
|
||||
isCLevel: employee.is_c_level ?? false,
|
||||
});
|
||||
@@ -99,6 +116,12 @@ export function DatenAendernPanel({
|
||||
showToast("Bitte ein Wirksam-ab-Datum angeben.", "error");
|
||||
return;
|
||||
}
|
||||
// Name und Nummer gehören zusammen — die Datenbank weist eines ohne das
|
||||
// andere ab, und die Meldung dort erklärt es nicht.
|
||||
if (Boolean(notfallName.trim()) !== Boolean(notfallTelefon.trim())) {
|
||||
showToast("Beim Notfallkontakt braucht es Name und Telefonnummer — oder beides leer.", "error");
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
const result = await changeEmployeeData({
|
||||
employee_id: employee.id,
|
||||
@@ -112,12 +135,21 @@ export function DatenAendernPanel({
|
||||
birth_date: birthDate,
|
||||
sv_nummer: svNummer,
|
||||
nationality,
|
||||
// Wechselt die Staatsbürgerschaft in den Freizügigkeitsraum, fällt
|
||||
// der Titel weg — sonst bliebe er als Rest an einer Person hängen,
|
||||
// die ihn nicht mehr braucht. Die Datenbank prüft diese Kopplung
|
||||
// bewusst nicht (siehe Migration), also gehört sie hierher.
|
||||
hat_aufenthaltstitel: titelNoetig ? hatTitel : false,
|
||||
aufenthaltstitel_bis: titelNoetig && hatTitel ? titelBis : "",
|
||||
address,
|
||||
postal_code: postalCode,
|
||||
city,
|
||||
address_country: addressCountry,
|
||||
email,
|
||||
phone,
|
||||
emergency_contact_name: notfallName.trim(),
|
||||
emergency_contact_phone: notfallTelefon.trim(),
|
||||
emergency_contact_relation: notfallVerhaeltnis.trim(),
|
||||
},
|
||||
contract: {
|
||||
employment_type: employmentType,
|
||||
@@ -131,6 +163,19 @@ export function DatenAendernPanel({
|
||||
work_days: role.workDays,
|
||||
is_betriebsrat: role.isBetriebsrat,
|
||||
has_dienstwagen: role.hasDienstwagen,
|
||||
dienstwagen_art: role.hasDienstwagen ? role.dienstwagenArt : "",
|
||||
has_kuendigungsschutz: role.hasKuendigungsschutz,
|
||||
kuendigungsschutz_bis: role.hasKuendigungsschutz ? role.kuendigungsschutzBis : "",
|
||||
// Die Teilzeitvariante wird nur mitgeschickt, wenn sich die Stunden
|
||||
// tatsächlich ändern. Sonst schriebe jede Adressänderung den Wert
|
||||
// erneut — und setzte ihn bei „Vertragliche Stundenänderung" sogar
|
||||
// zurück, obwohl niemand die Stunden angefasst hat.
|
||||
...(stundenGeaendert
|
||||
? {
|
||||
teilzeit_art: stundenGrund === "Vertragliche Stundenänderung" ? "" : stundenGrund,
|
||||
teilzeit_bis: stundenGrund === "Vertragliche Stundenänderung" ? "" : teilzeitBis,
|
||||
}
|
||||
: {}),
|
||||
is_laterale_fuehrung: role.isLateraleFuehrung,
|
||||
is_c_level: role.isCLevel,
|
||||
},
|
||||
@@ -197,6 +242,34 @@ export function DatenAendernPanel({
|
||||
<CountryPicker {...p} value={nationality} onChange={setNationality} countries={UN_COUNTRIES} placeholder="Staatsbürgerschaft suchen…" />
|
||||
)}
|
||||
</Field>
|
||||
{/* Nur wo er verlangt ist. Bei Freizügigkeit — EU, EWR, Schweiz —
|
||||
wäre die Frage gegenstandslos, und ein „Nein" im Formular
|
||||
sähe aus wie eine Auskunft. */}
|
||||
{titelNoetig && (
|
||||
<>
|
||||
<SelectField
|
||||
label="Aufenthaltstitel"
|
||||
dense
|
||||
value={hatTitel ? "ja" : "nein"}
|
||||
onChange={(v) => setHatTitel(v === "ja")}
|
||||
options={[
|
||||
{ value: "nein", label: "Nein" },
|
||||
{ value: "ja", label: "Ja" },
|
||||
]}
|
||||
hint="Bei Staatsbürgerschaften ausserhalb von EU, EWR und Schweiz."
|
||||
/>
|
||||
{hatTitel && (
|
||||
<TextField
|
||||
label="Aufenthaltstitel gültig bis"
|
||||
dense
|
||||
type="date"
|
||||
value={titelBis}
|
||||
onChange={setTitelBis}
|
||||
hint="Optional. Leer lassen, wenn unbefristet."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<TextField label="Adresse (Straße und Hausnummer)" dense value={address} onChange={setAddress} />
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] gap-3">
|
||||
<TextField label="Postleitzahl" dense inputMode="numeric" value={postalCode} onChange={setPostalCode} />
|
||||
@@ -208,6 +281,24 @@ export function DatenAendernPanel({
|
||||
<TextField label="E-Mail" dense type="email" value={email} onChange={setEmail} />
|
||||
<TextField label="Telefon" dense type="tel" value={phone} onChange={setPhone} />
|
||||
</div>
|
||||
|
||||
{/* Eigener Block: im Ernstfall greift jemand danach, und dann darf
|
||||
er nicht zwischen den Adressfeldern der Person untergehen. */}
|
||||
<fieldset className="mt-4 rounded-md border border-border-subtle p-3">
|
||||
<legend className="px-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Notfallkontakt</legend>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<TextField label="Name" dense value={notfallName} onChange={setNotfallName} />
|
||||
<TextField label="Telefon" dense type="tel" value={notfallTelefon} onChange={setNotfallTelefon} />
|
||||
<SelectField
|
||||
label="Verhältnis"
|
||||
dense
|
||||
value={notfallVerhaeltnis}
|
||||
onChange={setNotfallVerhaeltnis}
|
||||
placeholder="Bitte wählen…"
|
||||
options={EMERGENCY_RELATIONS.map((r) => ({ value: r, label: r }))}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -232,6 +323,26 @@ export function DatenAendernPanel({
|
||||
disabled={employmentType === "Vollzeit"}
|
||||
onChange={setWeeklyHours}
|
||||
/>
|
||||
{stundenGeaendert && (
|
||||
<SelectField
|
||||
label="Grund der Stundenänderung"
|
||||
dense
|
||||
value={stundenGrund}
|
||||
onChange={setStundenGrund}
|
||||
options={STUNDEN_GRUENDE.map((g) => ({ value: g, label: g }))}
|
||||
hint="Steht danach am Profil und lässt sich auswerten."
|
||||
/>
|
||||
)}
|
||||
{stundenGeaendert && stundenGrund !== "Vertragliche Stundenänderung" && (
|
||||
<TextField
|
||||
label="Teilzeit bis"
|
||||
dense
|
||||
type="date"
|
||||
value={teilzeitBis}
|
||||
onChange={setTeilzeitBis}
|
||||
hint="Optional. Leer lassen, solange das Ende nicht feststeht."
|
||||
/>
|
||||
)}
|
||||
<SelectField
|
||||
label="Vertragsart"
|
||||
dense
|
||||
|
||||
@@ -8,8 +8,8 @@ import { SelectField, TextField, TextareaField } from "@/components/ui/Field";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { ABSENCE_TYPES, absenceLabel } from "@/lib/absence";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import { ABSENCE_TYPES, absenceLabel, RUECKKEHR_GRUENDE } from "@/lib/absence";
|
||||
import { fmtDate, fmtName } from "@/lib/format";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
@@ -33,7 +33,18 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
|
||||
|
||||
const [returnDate, setReturnDate] = useState("");
|
||||
const [employmentMode, setEmploymentMode] = useState<EmploymentMode>("unverändert");
|
||||
const [weeklyHours, setWeeklyHours] = useState("20");
|
||||
// Warum weniger Stunden: Wiedereingliederungs- oder Elternteilzeit. Beide
|
||||
// beginnen typischerweise genau dann, wenn die Abwesenheit endet — deshalb
|
||||
// steht die Frage hier und nicht in einem zweiten Vorgang danach.
|
||||
const [reduktionsgrund, setReduktionsgrund] = useState<string>("");
|
||||
const [teilzeitBis, setTeilzeitBis] = useState("");
|
||||
// Die Stunden, die vor der Abwesenheit galten — mit Komma, wie man sie
|
||||
// hierzulande schreibt.
|
||||
const stundenText = String(employee.weekly_hours).replace(".", ",");
|
||||
// Absichtlich leer statt vorbelegt: eine Zahl, die schon dasteht, wird
|
||||
// bestätigt statt erfasst. Die reduzierten Stunden stehen in einer
|
||||
// Vereinbarung, und die muss jemand ablesen.
|
||||
const [weeklyHours, setWeeklyHours] = useState("");
|
||||
|
||||
const diffDays =
|
||||
isOnKarenz && employee.karenz_return_date && newReturnDate
|
||||
@@ -89,16 +100,24 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
|
||||
showToast("Bitte Rückkehrdatum angeben.", "error");
|
||||
return;
|
||||
}
|
||||
if (employmentMode === "Teilzeit" && (Number(weeklyHours) <= 0 || Number(weeklyHours) >= 38.5)) {
|
||||
if (employmentMode === "Teilzeit") {
|
||||
if (!weeklyHours.trim()) {
|
||||
showToast("Bitte die reduzierten Wochenstunden erfassen.", "error");
|
||||
return;
|
||||
}
|
||||
if (Number(weeklyHours) <= 0 || Number(weeklyHours) >= 38.5) {
|
||||
showToast("Wochenstunden müssen zwischen 0 und 38,5 liegen.", "error");
|
||||
return;
|
||||
}
|
||||
}
|
||||
setPending(true);
|
||||
const result = await recordKarenzReturn({
|
||||
employee_id: employee.id,
|
||||
return_date: returnDate,
|
||||
employment_mode: employmentMode,
|
||||
weekly_hours: employmentMode === "Teilzeit" ? Number(weeklyHours) : undefined,
|
||||
reduction_reason: employmentMode === "Teilzeit" ? reduktionsgrund || undefined : undefined,
|
||||
teilzeit_bis: employmentMode === "Teilzeit" && reduktionsgrund ? teilzeitBis || undefined : undefined,
|
||||
});
|
||||
setPending(false);
|
||||
if (result.success) {
|
||||
@@ -115,7 +134,7 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={isOnKarenz ? "Langzeitabwesenheit verwalten" : "Langzeitabwesenheit erfassen"}
|
||||
subtitle={`${employee.first_name} ${employee.last_name} · ${employee.job_title}`}
|
||||
subtitle={`${fmtName(employee.first_name, employee.last_name)} · ${employee.job_title}`}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
@@ -191,22 +210,46 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
|
||||
value={employmentMode}
|
||||
onChange={(v) => setEmploymentMode(v as EmploymentMode)}
|
||||
options={[
|
||||
{ value: "unverändert", label: "unverändert" },
|
||||
{ value: "Vollzeit", label: "Vollzeit (38,5h)" },
|
||||
{ value: "Teilzeit", label: "Teilzeit-Elternteilzeit" },
|
||||
// Die Stunden, die zuletzt gearbeitet wurden, stehen in der
|
||||
// Beschriftung. „unverändert" allein zwang dazu, in der
|
||||
// Akte nachzusehen, worauf man sich da einlässt.
|
||||
{ value: "unverändert", label: `Wie vor Abwesenheit (${stundenText} h)` },
|
||||
{ value: "Teilzeit", label: "Reduziert" },
|
||||
]}
|
||||
/>
|
||||
{employmentMode === "Teilzeit" && (
|
||||
<>
|
||||
<TextField
|
||||
label="Wochenstunden"
|
||||
label="Reduzierte Wochenstunden"
|
||||
required
|
||||
type="number"
|
||||
step="0.5"
|
||||
max="38"
|
||||
value={weeklyHours}
|
||||
onChange={setWeeklyHours}
|
||||
placeholder={`weniger als ${stundenText}`}
|
||||
hint="Muss unter 38,5 liegen."
|
||||
/>
|
||||
<SelectField
|
||||
label="Grund der Reduktion"
|
||||
value={reduktionsgrund}
|
||||
onChange={setReduktionsgrund}
|
||||
options={[
|
||||
{ value: "", label: "Ohne besonderen Grund" },
|
||||
...RUECKKEHR_GRUENDE.map((g) => ({ value: g, label: g })),
|
||||
]}
|
||||
hint="Steht danach am Profil und lässt sich auswerten."
|
||||
/>
|
||||
{reduktionsgrund && (
|
||||
<TextField
|
||||
label="Teilzeit bis"
|
||||
type="date"
|
||||
value={teilzeitBis}
|
||||
onChange={setTeilzeitBis}
|
||||
hint="Optional. Leer lassen, solange das Ende nicht feststeht."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { SelectField, TextField } from "@/components/ui/Field";
|
||||
import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import type { Database, PaygradeType } from "@/lib/supabase/types";
|
||||
import { fmtName } from "@/lib/format";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
|
||||
@@ -55,7 +56,7 @@ export function PromotePanel({ open, onClose, employee }: { open: boolean; onClo
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Beförderung"
|
||||
subtitle={`${employee.first_name} ${employee.last_name} · ${employee.job_title}`}
|
||||
subtitle={`${fmtName(employee.first_name, employee.last_name)} · ${employee.job_title}`}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
|
||||
@@ -1,30 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { rehireEmployee } from "@/actions/employees";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { TextField } from "@/components/ui/Field";
|
||||
import { SelectField, TextField } from "@/components/ui/Field";
|
||||
import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import { fmtDate, fmtName } from "@/lib/format";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
|
||||
export function RehirePanel({ open, onClose, employee }: { open: boolean; onClose: () => void; employee: EmployeeRow }) {
|
||||
// Eine Wiedereinstellung braucht eine Planstelle — genau wie eine
|
||||
// Neueinstellung.
|
||||
//
|
||||
// Die alte Stelle ist dafür kein Ersatz: sie kann inzwischen besetzt,
|
||||
// ausgelaufen oder ganz entfallen sein. `rehire_employee` verlangte die
|
||||
// Angabe deshalb schon immer und wies den Aufruf sonst ab; nur schickte das
|
||||
// Formular sie nie mit, sodass jede Wiedereinstellung an einer Meldung
|
||||
// scheiterte, die sich im Dialog gar nicht beheben liess.
|
||||
export function RehirePanel({
|
||||
open,
|
||||
onClose,
|
||||
employee,
|
||||
openPositions,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
employee: EmployeeRow;
|
||||
openPositions: OpenPositionResolved[];
|
||||
}) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [rehireDate, setRehireDate] = useState("");
|
||||
const [positionId, setPositionId] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
openPositions
|
||||
.slice()
|
||||
.sort((a, b) => a.orgLabel.localeCompare(b.orgLabel, "de") || a.title.localeCompare(b.title, "de"))
|
||||
.map((p) => ({ value: p.id, label: `${p.orgLabel} · ${p.title} (${p.position_number})` })),
|
||||
[openPositions]
|
||||
);
|
||||
|
||||
const selected = openPositions.find((p) => p.id === positionId);
|
||||
|
||||
// Die Datenbank weist eine Besetzung ausserhalb der Gültigkeit ab. Das hier
|
||||
// nimmt die Meldung vorweg, solange sie noch etwas nützt — im Dialog, mit
|
||||
// beiden Daten sichtbar.
|
||||
const ausserhalb =
|
||||
selected && rehireDate
|
||||
? rehireDate < selected.valid_from
|
||||
? `Diese Planstelle gilt erst ab ${fmtDate(selected.valid_from)}.`
|
||||
: selected.valid_to && rehireDate >= selected.valid_to
|
||||
? `Diese Planstelle gilt nur bis ${fmtDate(selected.valid_to)}.`
|
||||
: null
|
||||
: null;
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!rehireDate) {
|
||||
showToast("Bitte ein Wiedereintrittsdatum angeben.", "error");
|
||||
if (!rehireDate || !positionId) {
|
||||
showToast("Wiedereintrittsdatum und Planstelle sind Pflicht.", "error");
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
const result = await rehireEmployee({ employee_id: employee.id, rehire_date: rehireDate });
|
||||
const result = await rehireEmployee({
|
||||
employee_id: employee.id,
|
||||
rehire_date: rehireDate,
|
||||
position_id: positionId,
|
||||
});
|
||||
setPending(false);
|
||||
if (result.success) {
|
||||
showToast(`${employee.first_name} ${employee.last_name} wurde wiedereingestellt.`);
|
||||
@@ -40,13 +87,13 @@ export function RehirePanel({ open, onClose, employee }: { open: boolean; onClos
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Wiedereinstellung"
|
||||
subtitle={`${employee.first_name} ${employee.last_name}`}
|
||||
subtitle={fmtName(employee.first_name, employee.last_name)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} pending={pending}>
|
||||
<Button onClick={handleSubmit} pending={pending} disabled={Boolean(ausserhalb)}>
|
||||
Wiedereinstellen
|
||||
</Button>
|
||||
</>
|
||||
@@ -58,7 +105,42 @@ export function RehirePanel({ open, onClose, employee }: { open: boolean; onClos
|
||||
<p className="mt-1 text-ink">{employee.job_title}</p>
|
||||
<p className="text-xs text-ink-muted">Ausgetreten am {fmtDate(employee.exit_date)}</p>
|
||||
</div>
|
||||
|
||||
<TextField label="Wiedereintritt am" required type="date" value={rehireDate} onChange={setRehireDate} />
|
||||
|
||||
{options.length === 0 ? (
|
||||
<p className="rounded border border-danger-text/20 bg-danger-bg px-3 py-2 text-sm text-danger-text">
|
||||
Es ist derzeit keine Planstelle frei. Ohne eine solche ist keine Wiedereinstellung möglich — zuerst eine
|
||||
Planstelle anlegen oder eine bestehende freimachen.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<SelectField
|
||||
label="Planstelle"
|
||||
required
|
||||
value={positionId}
|
||||
onChange={setPositionId}
|
||||
placeholder="Bitte wählen…"
|
||||
options={options}
|
||||
/>
|
||||
{selected && (
|
||||
<div className="rounded border border-border bg-surface p-3 text-sm text-ink-body">
|
||||
<div className="font-semibold text-ink">{selected.title}</div>
|
||||
<div className="text-xs text-ink-muted">{selected.orgLabel}</div>
|
||||
<div className="mt-1 text-xs text-ink-muted">
|
||||
{selected.is_chief ? "Leitungsplanstelle" : "Mitarbeiterplanstelle"}
|
||||
{selected.future ? ` · gültig ab ${fmtDate(selected.valid_from)}` : ""}
|
||||
{selected.valid_to ? ` · endet am ${fmtDate(selected.valid_to)}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ausserhalb && (
|
||||
<p role="alert" className="rounded border border-danger-text/20 bg-danger-bg px-3 py-2 text-sm text-danger-text">
|
||||
{ausserhalb} Wiedereintritt und Gültigkeit müssen zusammenpassen.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SlideOver>
|
||||
);
|
||||
|
||||
@@ -8,10 +8,22 @@ import { SelectField, TextField, TextareaField } from "@/components/ui/Field";
|
||||
import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
import { fmtDate, fmtName } from "@/lib/format";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
|
||||
const EXIT_REASONS = ["Einvernehmliche Auflösung", "Kündigung AN", "Kündigung AG", "Befristungsablauf", "Pensionierung", "Entlassung"];
|
||||
// „No Show" steht am Ende und getrennt: es ist kein Austritt im gewohnten
|
||||
// Sinn, sondern der Fall, dass jemand nie angetreten ist.
|
||||
const NO_SHOW = "No Show";
|
||||
const EXIT_REASONS = [
|
||||
"Einvernehmliche Auflösung",
|
||||
"Kündigung AN",
|
||||
"Kündigung AG",
|
||||
"Befristungsablauf",
|
||||
"Pensionierung",
|
||||
"Entlassung",
|
||||
NO_SHOW,
|
||||
];
|
||||
const CHECKLIST_ITEMS = ["IT-Zugänge deaktivieren", "Hardware retournieren", "ÖGK-Abmeldung", "Endabrechnung & Dienstzeugnis"];
|
||||
|
||||
type TerminatePanelProps = {
|
||||
@@ -25,21 +37,40 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [exitDate, setExitDate] = useState("");
|
||||
const [reason, setReason] = useState(EXIT_REASONS[0]);
|
||||
// Wer noch gar nicht angefangen hat, tritt fast nie aus einem anderen Grund
|
||||
// aus. Die Vorbelegung nimmt den wahrscheinlichen Fall vorweg, ohne die
|
||||
// übrigen zu verstellen.
|
||||
const [reason, setReason] = useState(employee.status === "Geplant" ? NO_SHOW : EXIT_REASONS[0]);
|
||||
const [note, setNote] = useState("");
|
||||
const [checked, setChecked] = useState<boolean[]>(CHECKLIST_ITEMS.map(() => false));
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
// Bei einem Nichtantritt ist das Datum nicht frei wählbar: es ist der Tag,
|
||||
// an dem die Person hätte anfangen sollen. Die Datenbank setzt es ohnehin
|
||||
// so; hier steht es sichtbar, damit niemand ein Datum eintippt, das dann
|
||||
// stillschweigend übergangen wird.
|
||||
const istNoShow = reason === NO_SHOW;
|
||||
const wirksamesDatum = istNoShow ? employee.entry_date : exitDate;
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!exitDate) {
|
||||
if (!wirksamesDatum) {
|
||||
showToast("Bitte ein Austrittsdatum angeben.", "error");
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
const result = await terminateEmployee({ employee_id: employee.id, exit_date: exitDate, exit_reason: reason, note });
|
||||
const result = await terminateEmployee({
|
||||
employee_id: employee.id,
|
||||
exit_date: wirksamesDatum,
|
||||
exit_reason: reason,
|
||||
note,
|
||||
});
|
||||
setPending(false);
|
||||
if (result.success) {
|
||||
showToast(`Austritt für ${employee.first_name} ${employee.last_name} erfasst.`);
|
||||
showToast(
|
||||
istNoShow
|
||||
? `${employee.first_name} ${employee.last_name} ist nicht angetreten.`
|
||||
: `Austritt für ${employee.first_name} ${employee.last_name} erfasst.`
|
||||
);
|
||||
router.refresh();
|
||||
onClose();
|
||||
} else {
|
||||
@@ -51,8 +82,8 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
|
||||
<SlideOver
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Austritt"
|
||||
subtitle={`${employee.first_name} ${employee.last_name} · ${employee.job_title}`}
|
||||
title={istNoShow ? "Nicht angetreten" : "Austritt"}
|
||||
subtitle={`${fmtName(employee.first_name, employee.last_name)} · ${employee.job_title}`}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
@@ -65,20 +96,54 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Zuerst, und nicht zu übersehen: bei besonderem Kündigungsschutz
|
||||
gelten eigene Regeln, bevor beendet werden darf. Die Anwendung
|
||||
entscheidet das nicht — sie darf es aber auch nicht verschweigen,
|
||||
und im Vertragsblatt nachzusehen ist genau der Schritt, den man
|
||||
unter Zeitdruck auslässt. */}
|
||||
{employee.has_kuendigungsschutz && (
|
||||
<div className="rounded border border-danger-solid bg-danger-bg px-3 py-2 text-sm font-semibold text-danger-text">
|
||||
Achtung: besonderer Kündigungsschutz
|
||||
{employee.kuendigungsschutz_bis ? ` bis ${fmtDate(employee.kuendigungsschutz_bis)}` : " (Ende nicht erfasst)"}.
|
||||
<span className="block font-normal">
|
||||
Vor einer Beendigung ist zu prüfen, ob sie zulässig ist — je nach Grund braucht es eine Zustimmung des
|
||||
Betriebsrats oder des Gerichts.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{directReportCount > 0 && (
|
||||
<div className="rounded bg-warning-bg px-3 py-2 text-sm text-warning-text">
|
||||
{directReportCount} direkte Berichte werden automatisch der nächsthöheren Führungskraft zugeordnet.
|
||||
</div>
|
||||
)}
|
||||
<TextField label="Austrittsdatum" required type="date" value={exitDate} onChange={setExitDate} />
|
||||
<SelectField
|
||||
label="Beendigungsart"
|
||||
value={reason}
|
||||
onChange={setReason}
|
||||
options={EXIT_REASONS.map((r) => ({ value: r, label: r }))}
|
||||
options={EXIT_REASONS.map((r) => ({ value: r, label: r === NO_SHOW ? "No Show (nicht angetreten)" : r }))}
|
||||
/>
|
||||
<TextField
|
||||
label={istNoShow ? "Wirksam am (Eintrittstag)" : "Austrittsdatum"}
|
||||
required
|
||||
type="date"
|
||||
value={wirksamesDatum}
|
||||
disabled={istNoShow}
|
||||
onChange={setExitDate}
|
||||
hint={
|
||||
istNoShow
|
||||
? "Wer nie angetreten ist, scheidet am Tag seines Eintritts aus. Damit gibt es keinen Tag, an dem die Person als beschäftigt zählt."
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<TextareaField label="Anmerkung" rows={3} value={note} onChange={setNote} />
|
||||
<fieldset>
|
||||
{istNoShow && (
|
||||
<p className="rounded bg-surface px-3 py-2 text-sm text-ink-body">
|
||||
Die Planstelle wird wieder frei und gilt als nie besetzt. In allen Auswertungen zählt die Person an keinem
|
||||
Stichtag als beschäftigt.
|
||||
</p>
|
||||
)}
|
||||
{/* Bei einem Nichtantritt wurde nichts ausgegeben, was zurückkäme. */}
|
||||
<fieldset hidden={istNoShow}>
|
||||
<legend className="mb-2 text-sm font-semibold text-ink">Offboarding-Checkliste</legend>
|
||||
<div className="flex flex-col gap-2">
|
||||
{CHECKLIST_ITEMS.map((item, i) => (
|
||||
|
||||
@@ -9,6 +9,7 @@ import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
import { fmtName } from "@/lib/format";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
|
||||
@@ -68,7 +69,7 @@ export function TransferPanel({ open, onClose, employee, openPositions }: Transf
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Versetzung"
|
||||
subtitle={`${employee.first_name} ${employee.last_name} · ${employee.job_title}`}
|
||||
subtitle={`${fmtName(employee.first_name, employee.last_name)} · ${employee.job_title}`}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
|
||||
@@ -1,35 +1,208 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { HistorieBearbeiten } from "@/components/employees/HistorieBearbeiten";
|
||||
import { HistorieLoeschen } from "@/components/employees/HistorieLoeschen";
|
||||
import { AenderungsTabelle } from "@/components/ui/AenderungsTabelle";
|
||||
import { TextField } from "@/components/ui/Field";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
import { actionBadgeStyle } from "@/lib/colors";
|
||||
import { fmtDate, todayIso } from "@/lib/format";
|
||||
import { darfBearbeitetWerden, darfKorrigiertWerden, loeschVorschau } from "@/lib/history";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"];
|
||||
|
||||
export function HistorieTab({ history }: { history: HistoryRow[] }) {
|
||||
// Die Geschichte einer Person — aufklappbar bis auf die Werte, filterbar, und
|
||||
// dort, wo ein Eintrag irrtümlich entstanden ist, auch zurücknehmbar.
|
||||
//
|
||||
// Vorher stand hier nur „Geänderte Felder: Adresse, Ort". Damit liess sich
|
||||
// zwar sehen, *dass* jemand die Anschrift angefasst hat, aber nicht, was
|
||||
// vorher dort stand. Die alte Adresse lag allein im Protokoll, und das ist
|
||||
// eine andere Seite, nach Zeitpunkt sortiert statt nach Person — man hätte
|
||||
// also erst wissen müssen, wonach man sucht.
|
||||
//
|
||||
// Aufgeklappt wird mit <details>, nicht mit einem Zustand im Browser: die
|
||||
// Werte stehen dann schon in der Seite, sind durchsuchbar (Strg+F) und im
|
||||
// Ausdruck sichtbar, und es braucht kein Skript dafür.
|
||||
//
|
||||
// Die Knöpfe erscheinen nur an Einträgen, wo sie etwas bewirken können. An
|
||||
// allen anderen steht stattdessen der Grund — leise, aber lesbar. Ein Knopf,
|
||||
// der erst nach dem Klick sagt „geht nicht", wäre eine Falle; ein fehlender
|
||||
// Knopf ohne Erklärung wäre ein Rätsel.
|
||||
|
||||
type Sicht = "alle" | "anstehend" | "erledigt";
|
||||
|
||||
const SICHTEN: { value: Sicht; label: string }[] = [
|
||||
{ value: "alle", label: "Alle" },
|
||||
{ value: "anstehend", label: "Anstehend" },
|
||||
{ value: "erledigt", label: "Gelaufen" },
|
||||
];
|
||||
|
||||
export function HistorieTab({ history, employeeId }: { history: HistoryRow[]; employeeId: string }) {
|
||||
const today = todayIso();
|
||||
const [sicht, setSicht] = useState<Sicht>("alle");
|
||||
const [von, setVon] = useState("");
|
||||
const [bis, setBis] = useState("");
|
||||
// Ereignistypen, die in dieser Akte überhaupt vorkommen — eine Auswahl aus
|
||||
// elf Typen, von denen zehn nie auftauchen, wäre nur Suchaufwand.
|
||||
const vorhandeneTypen = [...new Set(history.map((h) => h.event_type))];
|
||||
const [typen, setTypen] = useState<Set<string>>(new Set());
|
||||
|
||||
const gefiltert = history.filter((h) => {
|
||||
if (sicht === "anstehend" && h.event_date <= today) return false;
|
||||
if (sicht === "erledigt" && h.event_date > today) return false;
|
||||
if (von && h.event_date < von) return false;
|
||||
if (bis && h.event_date > bis) return false;
|
||||
if (typen.size > 0 && !typen.has(h.event_type)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const anstehend = history.filter((h) => h.event_date > today).length;
|
||||
const eingeschraenkt = sicht !== "alle" || Boolean(von) || Boolean(bis) || typen.size > 0;
|
||||
|
||||
function typUmschalten(typ: string) {
|
||||
setTypen((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(typ)) next.delete(typ);
|
||||
else next.add(typ);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function zuruecksetzen() {
|
||||
setSicht("alle");
|
||||
setVon("");
|
||||
setBis("");
|
||||
setTypen(new Set());
|
||||
}
|
||||
|
||||
if (history.length === 0) {
|
||||
return <p className="text-sm text-ink-muted">Keine Historieneinträge vorhanden.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 rounded border border-border bg-surface px-3 py-2.5">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<SegmentedControl<Sicht> value={sicht} onChange={setSicht} options={SICHTEN} />
|
||||
{/* Was noch kommt, ist der häufigste Grund, hier hereinzusehen —
|
||||
deshalb steht die Zahl da, auch ohne dass jemand filtert. */}
|
||||
{anstehend > 0 && (
|
||||
<span className="rounded-full bg-warning-bg px-2 py-0.5 text-xs font-semibold text-warning-text">
|
||||
{anstehend} anstehend
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-end gap-2">
|
||||
<TextField label="Von" dense type="date" value={von} onChange={setVon} className="w-40" />
|
||||
<TextField label="Bis" dense type="date" value={bis} onChange={setBis} className="w-40" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{vorhandeneTypen.length > 1 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{vorhandeneTypen.map((typ) => (
|
||||
<button
|
||||
key={typ}
|
||||
type="button"
|
||||
onClick={() => typUmschalten(typ)}
|
||||
aria-pressed={typen.has(typ)}
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
|
||||
typen.has(typ) ? actionBadgeStyle(typ) : "bg-white text-ink-muted hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{typ}
|
||||
</button>
|
||||
))}
|
||||
{eingeschraenkt && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={zuruecksetzen}
|
||||
className="ml-auto rounded text-xs text-ink-muted hover:text-ink hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
Filter zurücksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{gefiltert.length === 0 ? (
|
||||
<p className="text-sm text-ink-muted">
|
||||
Kein Eintrag passt zu dieser Auswahl. {history.length} {history.length === 1 ? "Eintrag" : "Einträge"} sind
|
||||
vorhanden.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{history.map((h) => {
|
||||
{gefiltert.map((h) => {
|
||||
const isFuture = h.event_date > today;
|
||||
const changes = h.changes ?? [];
|
||||
// Die ganze Historie mitgeben, nicht die gefilterte: ob eine
|
||||
// Abwesenheit gelöscht werden darf, hängt an einer späteren
|
||||
// Rückkehr — auch wenn die gerade ausgeblendet ist.
|
||||
const loeschbar = darfKorrigiertWerden(h, today, history);
|
||||
const bearbeitbar = darfBearbeitetWerden(h, today, history);
|
||||
return (
|
||||
<li key={h.id} className="py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${actionBadgeStyle(h.event_type)}`}>{h.event_type}</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${actionBadgeStyle(h.event_type)}`}>
|
||||
{h.event_type}
|
||||
</span>
|
||||
<span className="text-sm text-ink-muted">{fmtDate(h.event_date)}</span>
|
||||
{isFuture && (
|
||||
<span className="rounded-full bg-warning-bg px-2 py-0.5 text-xs font-semibold text-warning-text">
|
||||
⏱ zukünftig – wirksam ab {fmtDate(h.event_date)}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto flex items-center gap-0.5">
|
||||
{bearbeitbar.erlaubt && (
|
||||
<HistorieBearbeiten
|
||||
historyId={h.id}
|
||||
employeeId={employeeId}
|
||||
bezeichnung={h.event_type}
|
||||
datum={h.event_date}
|
||||
changes={changes}
|
||||
istZukunft={isFuture}
|
||||
heute={today}
|
||||
nurDatum={h.event_type === "Eintritt"}
|
||||
/>
|
||||
)}
|
||||
{loeschbar.erlaubt && (
|
||||
<HistorieLoeschen
|
||||
historyId={h.id}
|
||||
employeeId={employeeId}
|
||||
bezeichnung={h.event_type}
|
||||
datum={h.event_date}
|
||||
vorschau={loeschVorschau(h, history)}
|
||||
istZukunft={isFuture}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-ink">{h.description}</p>
|
||||
|
||||
{changes.length > 0 && (
|
||||
<details className="group mt-1.5">
|
||||
<summary
|
||||
className="inline-flex cursor-pointer list-none items-center gap-1 rounded text-xs font-semibold text-brand-700
|
||||
hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
<span className="transition-transform group-open:rotate-90" aria-hidden="true">
|
||||
›
|
||||
</span>
|
||||
{changes.length} {changes.length === 1 ? "Feld" : "Felder"} im Detail
|
||||
</summary>
|
||||
<div className="mt-2 rounded border border-border bg-surface px-3 py-2">
|
||||
<AenderungsTabelle changes={changes} />
|
||||
</div>
|
||||
{!loeschbar.erlaubt && <p className="mt-1.5 text-xs text-ink-muted">{loeschbar.grund}</p>}
|
||||
</details>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Network } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Avatar } from "@/components/ui/Avatar";
|
||||
import { LINK_BUTTON_CLASS } from "@/components/ui/Button";
|
||||
import { fmtName } from "@/lib/format";
|
||||
|
||||
type MiniEmployee = { id: string; first_name: string; last_name: string; job_title: string; status?: string };
|
||||
|
||||
@@ -12,15 +13,39 @@ type OrganisationTabProps = {
|
||||
formalManager: MiniEmployee | null;
|
||||
directReports: MiniEmployee[];
|
||||
breadcrumb: string;
|
||||
/**
|
||||
* Die Kostenstelle der Planstelle, auf der die Person heute sitzt — nicht
|
||||
* ihre eigene: sie kontiert dorthin, wo ihr Sitz kontiert ist. Fehlt sie,
|
||||
* hat die Person keine laufende Besetzung (geplanter Eintritt, Austritt).
|
||||
*/
|
||||
kostenstelle: { code: string; name: string } | null;
|
||||
};
|
||||
|
||||
export function OrganisationTab({ employeeId, manager, formalManager, directReports, breadcrumb }: OrganisationTabProps) {
|
||||
export function OrganisationTab({
|
||||
employeeId,
|
||||
manager,
|
||||
formalManager,
|
||||
directReports,
|
||||
breadcrumb,
|
||||
kostenstelle,
|
||||
}: OrganisationTabProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-ink-muted">Organisationseinheit</h3>
|
||||
<p className="mt-1 text-sm text-ink">{breadcrumb}</p>
|
||||
<h3 className="mt-4 text-xs font-semibold uppercase tracking-wide text-ink-muted">Kostenstelle</h3>
|
||||
<p className="mt-1 text-sm text-ink">
|
||||
{kostenstelle ? (
|
||||
<>
|
||||
<span className="font-semibold tabular-nums">{kostenstelle.code}</span>
|
||||
<span className="text-ink-body"> · {kostenstelle.name}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-ink-muted">Keine laufende Planstellenbesetzung</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{/* ?focus= drives the same highlight/auto-expand path the org chart
|
||||
search already uses, so the person is unfolded and centred on
|
||||
@@ -37,7 +62,7 @@ export function OrganisationTab({ employeeId, manager, formalManager, directRepo
|
||||
</h3>
|
||||
{formalManager && (
|
||||
<p className="mb-2 text-xs text-ink-muted">
|
||||
Zuständig ist {formalManager.first_name} {formalManager.last_name}; während der Abwesenheit übernimmt die nächste
|
||||
Zuständig ist {fmtName(formalManager.first_name, formalManager.last_name)}; während der Abwesenheit übernimmt die nächste
|
||||
besetzte Ebene.
|
||||
</p>
|
||||
)}
|
||||
@@ -46,7 +71,7 @@ export function OrganisationTab({ employeeId, manager, formalManager, directRepo
|
||||
<Avatar firstName={manager.first_name} lastName={manager.last_name} />
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-ink">
|
||||
{manager.first_name} {manager.last_name}
|
||||
{fmtName(manager.first_name, manager.last_name)}
|
||||
</div>
|
||||
<div className="text-xs text-ink-muted">{manager.job_title}</div>
|
||||
</div>
|
||||
@@ -65,7 +90,7 @@ export function OrganisationTab({ employeeId, manager, formalManager, directRepo
|
||||
<Avatar firstName={r.first_name} lastName={r.last_name} />
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-ink">
|
||||
{r.first_name} {r.last_name}
|
||||
{fmtName(r.first_name, r.last_name)}
|
||||
</div>
|
||||
<div className="text-xs text-ink-muted">{r.job_title}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AngehoerigeSection } from "@/components/employees/AngehoerigeSection";
|
||||
import { brauchtAufenthaltstitel } from "@/lib/countries";
|
||||
import { fmtAge, fmtDate } from "@/lib/format";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
@@ -6,27 +7,56 @@ type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
type Location = Database["public"]["Tables"]["locations"]["Row"];
|
||||
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
|
||||
|
||||
function formatAddress(employee: EmployeeRow): string {
|
||||
const cityLine = [employee.postal_code, employee.city].filter(Boolean).join(" ");
|
||||
return [employee.address, cityLine].filter(Boolean).join(", ") || "–";
|
||||
}
|
||||
|
||||
export function StammdatenTab({ employee, location, dependents }: { employee: EmployeeRow; location?: Location; dependents: Dependent[] }) {
|
||||
// Defensive against a DB that hasn't received the title_prefix/title_suffix
|
||||
// migration yet — select("*") simply omits unknown columns, so these can
|
||||
// be undefined rather than the empty array the column default implies.
|
||||
const titles = [...(employee.title_prefix ?? []), ...(employee.title_suffix ?? [])];
|
||||
const prefixe = employee.title_prefix ?? [];
|
||||
const suffixe = employee.title_suffix ?? [];
|
||||
|
||||
// Feld für Feld dieselbe Liste wie im Abschnitt „Person" von „Daten
|
||||
// ändern", in derselben Reihenfolge.
|
||||
//
|
||||
// Vorher fasste die Anzeige zusammen: Titel in einer Zeile, Adresse mit
|
||||
// Postleitzahl und Ort verschmolzen, Vor- und Nachname gar nicht — die
|
||||
// standen nur in der Kopfzeile. Wer eine Angabe prüfen wollte, musste den
|
||||
// Änderungsdialog öffnen, um sie überhaupt zu sehen, und stand dann schon
|
||||
// in einem Formular. Was sich ändern lässt, soll sich auch ansehen lassen.
|
||||
const rows: [string, string][] = [
|
||||
["Titel", titles.length > 0 ? titles.join(", ") : "–"],
|
||||
["Personalnummer", String(employee.personnel_number)],
|
||||
["Vorname", employee.first_name],
|
||||
["Nachname", employee.last_name],
|
||||
["Titel (vorangestellt)", prefixe.length > 0 ? prefixe.join(", ") : "–"],
|
||||
["Titel (nachgestellt)", suffixe.length > 0 ? suffixe.join(", ") : "–"],
|
||||
["Geschlecht", employee.gender === "m" ? "männlich" : "weiblich"],
|
||||
["Geburtsdatum", `${fmtDate(employee.birth_date)} (${fmtAge(employee.birth_date)} Jahre)`],
|
||||
["SV-Nummer", employee.sv_nummer ?? "–"],
|
||||
["Staatsbürgerschaft", employee.nationality],
|
||||
["E-Mail", employee.email],
|
||||
["Telefon", employee.phone ?? "–"],
|
||||
["Standort", location ? `${location.name} (${location.country})` : "–"],
|
||||
["Adresse", formatAddress(employee)],
|
||||
// Nur, wo er verlangt ist. Bei einer österreichischen Staatsbürger-
|
||||
// schaft wäre die Zeile „Aufenthaltstitel: Nein" keine Auskunft,
|
||||
// sondern eine Frage, die sich nicht stellt.
|
||||
...(brauchtAufenthaltstitel(employee.nationality)
|
||||
? ([
|
||||
[
|
||||
"Aufenthaltstitel",
|
||||
employee.hat_aufenthaltstitel
|
||||
? employee.aufenthaltstitel_bis
|
||||
? `Ja, bis ${fmtDate(employee.aufenthaltstitel_bis)}`
|
||||
: "Ja (unbefristet oder nicht erfasst)"
|
||||
: "Nein",
|
||||
],
|
||||
] as [string, string][])
|
||||
: []),
|
||||
["Adresse", employee.address ?? "–"],
|
||||
["Postleitzahl", employee.postal_code ?? "–"],
|
||||
["Ort", employee.city ?? "–"],
|
||||
["Land", employee.address_country ?? "–"],
|
||||
["Geschlecht", employee.gender === "m" ? "männlich" : "weiblich"],
|
||||
["Private E-Mail", employee.email ?? "–"],
|
||||
["Private Telefonnummer", employee.phone ?? "–"],
|
||||
// Der Standort ist keine Angabe zur Person, sondern die Betriebsstätte —
|
||||
// er steht deshalb am Ende und nicht zwischen Adresse und Land, wo man
|
||||
// ihn für den Wohnort halten könnte.
|
||||
["Standort", location ? `${location.name} (${location.country})` : "–"],
|
||||
];
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -38,7 +68,46 @@ export function StammdatenTab({ employee, location, dependents }: { employee: Em
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<AngehoerigeSection employeeId={employee.id} dependents={dependents} />
|
||||
|
||||
{/* Eigener Abschnitt statt einer Zelle im Raster, und unterhalb der
|
||||
Angehörigen: beides sind Personen im Umfeld, und der Notfallkontakt
|
||||
ist die Ausnahme davon — deshalb steht er zuletzt, nicht dazwischen.
|
||||
Im Ernstfall greift jemand in Eile danach; dann muss die Nummer
|
||||
sofort zu finden sein und wählbar. */}
|
||||
<div className="border-t border-border pt-6">
|
||||
<h3 className="mb-3 text-xs font-bold uppercase tracking-wide text-brand-700">Notfallkontakt</h3>
|
||||
{employee.emergency_contact_name ? (
|
||||
<dl className="grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<dt className="text-xs font-semibold uppercase tracking-wide text-ink-muted">Name</dt>
|
||||
<dd className="mt-1 text-sm text-ink">{employee.emergency_contact_name}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs font-semibold uppercase tracking-wide text-ink-muted">Telefon</dt>
|
||||
<dd className="mt-1 text-sm font-semibold text-ink">
|
||||
{employee.emergency_contact_phone ? (
|
||||
<a
|
||||
href={`tel:${employee.emergency_contact_phone.replace(/\s/g, "")}`}
|
||||
className="rounded hover:text-brand-700 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
{employee.emergency_contact_phone}
|
||||
</a>
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs font-semibold uppercase tracking-wide text-ink-muted">Verhältnis</dt>
|
||||
<dd className="mt-1 text-sm text-ink">{employee.emergency_contact_relation || "–"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : (
|
||||
<p className="text-sm text-ink-muted">Kein Notfallkontakt hinterlegt.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { dienstwagenLabel } from "@/lib/dienstwagen";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
@@ -13,12 +14,11 @@ const PAYGRADE_LABELS: Record<string, string> = {
|
||||
};
|
||||
|
||||
export function VertragTab({ employee }: { employee: EmployeeRow }) {
|
||||
const flags = [
|
||||
employee.is_betriebsrat && "Betriebsrat",
|
||||
employee.has_dienstwagen && "Dienstwagen",
|
||||
employee.is_laterale_fuehrung && "Laterale Führung",
|
||||
employee.is_c_level && "C-Level",
|
||||
].filter(Boolean);
|
||||
// Früher stand hier eine einzige Zeile „Merkmale" mit allem, was zutraf,
|
||||
// durch Kommas getrennt — und ein Gedankenstrich, wenn nichts zutraf. Damit
|
||||
// liess sich nicht ablesen, ob jemand *keinen* Dienstwagen hat oder ob
|
||||
// niemand die Frage je beantwortet hat. Jedes Merkmal steht jetzt für sich,
|
||||
// mit Ja oder Nein, wie jede andere Zeile auf diesem Blatt auch.
|
||||
const rows: [string, string][] = [
|
||||
["Eintrittsdatum", fmtDate(employee.entry_date)],
|
||||
["Vertragsart", employee.contract_type === "befristet" ? `befristet bis ${fmtDate(employee.contract_end_date)}` : "unbefristet"],
|
||||
@@ -29,7 +29,32 @@ export function VertragTab({ employee }: { employee: EmployeeRow }) {
|
||||
["Paygrade", PAYGRADE_LABELS[employee.paygrade] ?? employee.paygrade],
|
||||
["Angestellte:r / Arbeiter:in", employee.worker_type ?? "–"],
|
||||
["Arbeitstage", employee.work_days?.join(", ") || "–"],
|
||||
["Merkmale", flags.length > 0 ? flags.join(", ") : "–"],
|
||||
// Beim Dienstwagen steht die Antriebsart statt eines blossen „Ja" — das
|
||||
// war die Frage dahinter, seit E-Fahrzeuge getrennt zu führen sind.
|
||||
["Dienstwagen", employee.has_dienstwagen ? dienstwagenLabel(employee.dienstwagen_art) : "Nein"],
|
||||
["Betriebsrat", employee.is_betriebsrat ? "Ja" : "Nein"],
|
||||
["Laterale Führung", employee.is_laterale_fuehrung ? "Ja" : "Nein"],
|
||||
["C-Level", employee.is_c_level ? "Ja" : "Nein"],
|
||||
// Das Enddatum steht gleich dabei: „Ja" allein liesse offen, ob der
|
||||
// Schutz noch läuft, und genau danach fragt man.
|
||||
// Die Teilzeitvariante gehört neben die Stunden: sie erklärt, warum sie
|
||||
// sind, wie sie sind.
|
||||
[
|
||||
"Teilzeitvariante",
|
||||
employee.teilzeit_art
|
||||
? employee.teilzeit_bis
|
||||
? `${employee.teilzeit_art} bis ${fmtDate(employee.teilzeit_bis)}`
|
||||
: `${employee.teilzeit_art} (Ende offen)`
|
||||
: "–",
|
||||
],
|
||||
[
|
||||
"Besonderer Kündigungsschutz",
|
||||
employee.has_kuendigungsschutz
|
||||
? employee.kuendigungsschutz_bis
|
||||
? `bis ${fmtDate(employee.kuendigungsschutz_bis)}`
|
||||
: "Ja (Ende offen)"
|
||||
: "Nein",
|
||||
],
|
||||
];
|
||||
if (employee.exit_date) rows.push(["Austrittsdatum", fmtDate(employee.exit_date)]);
|
||||
|
||||
|
||||
@@ -2,20 +2,22 @@
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { hireEmployee } from "@/actions/employees";
|
||||
import { addEmployeeDependent, hireEmployee } from "@/actions/employees";
|
||||
import { deleteHireDraft, saveHireDraft } from "@/actions/hireDrafts";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr";
|
||||
import { StepAngehoerige } from "./StepAngehoerige";
|
||||
import { StepNotfallkontakt } from "./StepNotfallkontakt";
|
||||
import { StepPerson } from "./StepPerson";
|
||||
import { StepPosition } from "./StepPosition";
|
||||
import { StepSummary } from "./StepSummary";
|
||||
import { StepVertrag } from "./StepVertrag";
|
||||
import { EMPTY_HIRE_DRAFT, type HireDraftData } from "./types";
|
||||
|
||||
const STEP_LABELS = ["Person", "Position", "Vertrag", "Zusammenfassung"];
|
||||
const STEP_LABELS = ["Person", "Position", "Angehörige", "Vertrag", "Notfallkontakt", "Zusammenfassung"];
|
||||
|
||||
type HireWizardProps = {
|
||||
open: boolean;
|
||||
@@ -61,13 +63,31 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
||||
// 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.
|
||||
Boolean(draft.firstName && draft.lastName && draft.birthDate && draft.locationId && draft.email.trim()) &&
|
||||
// Die private E-Mail-Adresse steht bewusst nicht mehr darunter: sie ist
|
||||
// freiwillig, seit die Spalte NULL zulässt.
|
||||
Boolean(draft.personnelNumber.trim() && draft.firstName && draft.lastName && draft.birthDate && draft.locationId) &&
|
||||
svNummerOk,
|
||||
Boolean(draft.positionId && draft.besetzung),
|
||||
// Angehörige: freiwillig — aber eine begonnene Zeile muss vollständig
|
||||
// sein, sonst scheitert sie erst nach dem Anlegen der Person, und die
|
||||
// steht dann schon in der Datenbank.
|
||||
draft.angehoerige.every(
|
||||
(a) =>
|
||||
a.firstName.trim() &&
|
||||
a.lastName.trim() &&
|
||||
a.birthDate &&
|
||||
(!a.svNummer.trim() || isValidSvnr(a.svNummer, a.birthDate || null))
|
||||
),
|
||||
Boolean(draft.entryDate && draft.workDays.length > 0),
|
||||
// Notfallkontakt: freiwillig, aber Name und Nummer nur gemeinsam — die
|
||||
// Datenbank weist eines ohne das andere ab (chk_emergency_contact).
|
||||
Boolean(draft.emergencyContactName.trim()) === Boolean(draft.emergencyContactPhone.trim()),
|
||||
true,
|
||||
][step];
|
||||
|
||||
/** Der letzte Schritt; von hier wird angelegt statt weitergeblättert. */
|
||||
const letzterSchritt = STEP_LABELS.length - 1;
|
||||
|
||||
async function handleSaveDraft() {
|
||||
const result = await saveHireDraft({ id: draftId, step, data: draft });
|
||||
if (result.success) {
|
||||
@@ -83,6 +103,7 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
||||
if (!selectedPosition || !draft.besetzung) return;
|
||||
setSubmitting(true);
|
||||
const result = await hireEmployee({
|
||||
personnel_number: Number(draft.personnelNumber),
|
||||
first_name: draft.firstName,
|
||||
last_name: draft.lastName,
|
||||
title_prefix: draft.titlePrefix,
|
||||
@@ -90,7 +111,7 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
||||
gender: draft.gender,
|
||||
birth_date: draft.birthDate,
|
||||
sv_nummer: draft.svNummer || undefined,
|
||||
email: draft.email.trim(),
|
||||
email: draft.email.trim() || undefined,
|
||||
phone: draft.phone || undefined,
|
||||
position_id: draft.positionId,
|
||||
location_id: draft.locationId,
|
||||
@@ -106,17 +127,57 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
||||
work_days: draft.workDays,
|
||||
is_betriebsrat: draft.isBetriebsrat,
|
||||
has_dienstwagen: draft.hasDienstwagen,
|
||||
// Null, sobald kein Dienstwagen da ist — der CHECK lässt die Angabe
|
||||
// sonst nicht zu.
|
||||
dienstwagen_art: draft.hasDienstwagen ? draft.dienstwagenArt : null,
|
||||
has_kuendigungsschutz: draft.hasKuendigungsschutz,
|
||||
// Ohne Schutz kein Enddatum — der CHECK lässt es nicht anders zu.
|
||||
kuendigungsschutz_bis: draft.hasKuendigungsschutz ? draft.kuendigungsschutzBis || null : null,
|
||||
emergency_contact_name: draft.emergencyContactName.trim() || undefined,
|
||||
emergency_contact_phone: draft.emergencyContactPhone.trim() || undefined,
|
||||
emergency_contact_relation: draft.emergencyContactRelation.trim() || undefined,
|
||||
is_laterale_fuehrung: draft.isLateraleFuehrung,
|
||||
is_c_level: draft.isCLevel,
|
||||
});
|
||||
if (!result.success || !result.employeeId) {
|
||||
setSubmitting(false);
|
||||
showToast(result.error ?? "Fehler beim Anlegen.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Angehörige erst jetzt: add_employee_dependent braucht die Kennung, und
|
||||
// die entsteht mit der Einstellung.
|
||||
//
|
||||
// Damit hängen sie ausserhalb der Transaktion, in der die Person
|
||||
// entsteht. Scheitert eine, ist die Person trotzdem angelegt — deshalb
|
||||
// wird nicht stillschweigend weitergemacht, sondern genau gesagt, wer
|
||||
// fehlt. Nachtragen geht in der Personalakte.
|
||||
const gescheitert: string[] = [];
|
||||
for (const a of draft.angehoerige) {
|
||||
const r = await addEmployeeDependent({
|
||||
employee_id: result.employeeId,
|
||||
first_name: a.firstName.trim(),
|
||||
last_name: a.lastName.trim(),
|
||||
relationship: a.relationship,
|
||||
birth_date: a.birthDate,
|
||||
sv_nummer: a.svNummer.trim() || undefined,
|
||||
effective_date: draft.entryDate,
|
||||
});
|
||||
if (!r.success) gescheitert.push(`${a.firstName} ${a.lastName}`.trim());
|
||||
}
|
||||
|
||||
setSubmitting(false);
|
||||
if (result.success) {
|
||||
showToast(`${draft.firstName} ${draft.lastName} wurde eingestellt.`);
|
||||
if (draftId) await deleteHireDraft(draftId);
|
||||
router.refresh();
|
||||
onClose();
|
||||
|
||||
if (gescheitert.length > 0) {
|
||||
showToast(
|
||||
`${draft.firstName} ${draft.lastName} wurde eingestellt, aber ${gescheitert.join(", ")} konnte nicht als Angehörige:r angelegt werden — bitte in der Personalakte nachtragen.`,
|
||||
"error"
|
||||
);
|
||||
} else {
|
||||
showToast(result.error ?? "Fehler beim Anlegen.", "error");
|
||||
showToast(`${draft.firstName} ${draft.lastName} wurde eingestellt.`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,12 +203,12 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
||||
Zurück
|
||||
</Button>
|
||||
)}
|
||||
{step < 3 && (
|
||||
{step < letzterSchritt && (
|
||||
<Button onClick={() => setStep((s) => s + 1)} disabled={!stepValid}>
|
||||
Weiter
|
||||
</Button>
|
||||
)}
|
||||
{step === 3 && (
|
||||
{step === letzterSchritt && (
|
||||
<Button onClick={handleSubmit} pending={submitting}>
|
||||
Anlegen
|
||||
</Button>
|
||||
@@ -181,8 +242,10 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
||||
|
||||
{step === 0 && <StepPerson draft={draft} update={update} locations={locations} />}
|
||||
{step === 1 && <StepPosition draft={draft} update={update} openPositions={openPositions} />}
|
||||
{step === 2 && <StepVertrag draft={draft} update={update} />}
|
||||
{step === 3 && <StepSummary draft={draft} selectedPosition={selectedPosition} locations={locations} />}
|
||||
{step === 2 && <StepAngehoerige draft={draft} update={update} />}
|
||||
{step === 3 && <StepVertrag draft={draft} update={update} />}
|
||||
{step === 4 && <StepNotfallkontakt draft={draft} update={update} />}
|
||||
{step === 5 && <StepSummary draft={draft} selectedPosition={selectedPosition} locations={locations} />}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
113
components/hire/StepAngehoerige.tsx
Normal file
113
components/hire/StepAngehoerige.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { SelectField, TextField } from "@/components/ui/Field";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import { formatSvnr, svnrErrorMessage, validateSvnr } from "@/lib/svnr";
|
||||
import type { RelationshipType } from "@/lib/supabase/types";
|
||||
import type { HireDraftAngehoerige, HireDraftData } from "./types";
|
||||
|
||||
const VERHAELTNIS: RelationshipType[] = ["Ehepartner:in", "Lebenspartner:in", "Kind", "Sonstige"];
|
||||
|
||||
// Angehörige im Assistenten, obwohl es die Person noch nicht gibt.
|
||||
//
|
||||
// Sie werden hier gesammelt und erst nach dem Anlegen angehängt — die
|
||||
// Datenbankfunktion braucht eine Kennung, und die entsteht mit der
|
||||
// Einstellung. Der Preis dafür steht in HireWizard: schlägt eine der
|
||||
// Ergänzungen fehl, ist die Person trotzdem angelegt, und die Meldung sagt
|
||||
// das dann auch.
|
||||
//
|
||||
// Freiwillig: die meisten Einstellungen kommen ohne aus, und wer später
|
||||
// etwas nachträgt, findet denselben Dialog in der Personalakte.
|
||||
export function StepAngehoerige({
|
||||
draft,
|
||||
update,
|
||||
}: {
|
||||
draft: HireDraftData;
|
||||
update: (patch: Partial<HireDraftData>) => void;
|
||||
}) {
|
||||
const liste = draft.angehoerige;
|
||||
|
||||
function setze(index: number, patch: Partial<HireDraftAngehoerige>) {
|
||||
update({ angehoerige: liste.map((a, i) => (i === index ? { ...a, ...patch } : a)) });
|
||||
}
|
||||
|
||||
function hinzufuegen() {
|
||||
update({
|
||||
angehoerige: [...liste, { firstName: "", lastName: draft.lastName, relationship: "Kind", birthDate: "", svNummer: "" }],
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="max-w-prose text-sm text-ink-muted">
|
||||
Angehörige sind freiwillig und lassen sich jederzeit in der Personalakte nachtragen. Der Nachname ist mit dem
|
||||
der einzustellenden Person vorbelegt — überschreibbar.
|
||||
</p>
|
||||
|
||||
{liste.length === 0 ? (
|
||||
<p className="text-sm text-ink-muted">Keine Angehörigen erfasst.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{liste.map((a, i) => {
|
||||
// Dieselbe Prüfung wie bei der Person selbst: Prüfziffer und
|
||||
// Geburtsdatum müssen zusammenpassen. Hier schon, damit der
|
||||
// Fehler nicht erst nach dem Anlegen auftaucht — dann existiert
|
||||
// die Person bereits und die Angehörige fehlt.
|
||||
const svFehler = a.svNummer.trim() ? validateSvnr(a.svNummer, a.birthDate || null) : null;
|
||||
return (
|
||||
<fieldset key={i} className="rounded-md border border-border p-3">
|
||||
<legend className="flex items-center gap-2 px-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">
|
||||
{a.firstName || a.lastName ? `${a.firstName} ${a.lastName}`.trim() : `Angehörige:r ${i + 1}`}
|
||||
{a.birthDate && <span className="font-normal normal-case">· {fmtDate(a.birthDate)}</span>}
|
||||
</legend>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<TextField label="Vorname" dense required value={a.firstName} onChange={(v) => setze(i, { firstName: v })} />
|
||||
<TextField label="Nachname" dense required value={a.lastName} onChange={(v) => setze(i, { lastName: v })} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<SelectField
|
||||
label="Verhältnis"
|
||||
dense
|
||||
required
|
||||
value={a.relationship}
|
||||
onChange={(v) => setze(i, { relationship: v as RelationshipType })}
|
||||
options={VERHAELTNIS.map((r) => ({ value: r, label: r }))}
|
||||
/>
|
||||
<TextField label="Geburtsdatum" dense required type="date" value={a.birthDate} onChange={(v) => setze(i, { birthDate: v })} />
|
||||
</div>
|
||||
<TextField
|
||||
label="SV-Nummer"
|
||||
dense
|
||||
value={a.svNummer}
|
||||
onChange={(v) => setze(i, { svNummer: v })}
|
||||
error={svFehler ? svnrErrorMessage(svFehler) : undefined}
|
||||
hint={!svFehler && a.svNummer.trim() ? formatSvnr(a.svNummer) : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => update({ angehoerige: liste.filter((_, j) => j !== i) })}
|
||||
className="!px-1 text-danger-text hover:!bg-transparent hover:underline"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" /> Entfernen
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Button variant="secondary" size="sm" onClick={hinzufuegen}>
|
||||
<Plus className="h-4 w-4" /> Angehörige:n hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
components/hire/StepNotfallkontakt.tsx
Normal file
60
components/hire/StepNotfallkontakt.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { SelectField, TextField } from "@/components/ui/Field";
|
||||
import { EMERGENCY_RELATIONS } from "@/lib/supabase/types";
|
||||
import type { HireDraftData } from "./types";
|
||||
|
||||
// Eigener Schritt, kurz vor der Zusammenfassung.
|
||||
//
|
||||
// Zuerst stand das zwischen den Stammdaten — dort ging es unter, obwohl es
|
||||
// die einzige Angabe im ganzen Assistenten ist, die eine dritte Person
|
||||
// betrifft und im Ernstfall gebraucht wird.
|
||||
//
|
||||
// Die Angabe bleibt freiwillig. Wer sie macht, braucht Name und Nummer
|
||||
// zusammen; das prüft der Assistent, bevor er weiterlässt, und die Datenbank
|
||||
// noch einmal (chk_emergency_contact).
|
||||
export function StepNotfallkontakt({
|
||||
draft,
|
||||
update,
|
||||
}: {
|
||||
draft: HireDraftData;
|
||||
update: (patch: Partial<HireDraftData>) => void;
|
||||
}) {
|
||||
const angefangen = Boolean(draft.emergencyContactName.trim() || draft.emergencyContactPhone.trim());
|
||||
const unvollstaendig = angefangen && !(draft.emergencyContactName.trim() && draft.emergencyContactPhone.trim());
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="max-w-prose text-sm text-ink-muted">
|
||||
Wen sollen wir verständigen, wenn etwas passiert? Die Angabe ist freiwillig — Name und Telefonnummer gehören
|
||||
aber zusammen, eines allein hilft im Ernstfall nicht.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<TextField
|
||||
label="Name"
|
||||
value={draft.emergencyContactName}
|
||||
onChange={(emergencyContactName) => update({ emergencyContactName })}
|
||||
/>
|
||||
<TextField
|
||||
label="Telefon"
|
||||
type="tel"
|
||||
value={draft.emergencyContactPhone}
|
||||
onChange={(emergencyContactPhone) => update({ emergencyContactPhone })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SelectField
|
||||
label="Verhältnis"
|
||||
value={draft.emergencyContactRelation}
|
||||
onChange={(emergencyContactRelation) => update({ emergencyContactRelation })}
|
||||
placeholder="Bitte wählen…"
|
||||
options={EMERGENCY_RELATIONS.map((r) => ({ value: r, label: r }))}
|
||||
/>
|
||||
|
||||
{unvollstaendig && (
|
||||
<p role="alert" className="rounded-md border border-danger-text/20 bg-danger-bg px-3 py-2 text-sm text-danger-text">
|
||||
Name und Telefonnummer werden beide gebraucht — oder beide leer lassen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,14 @@ type StepPersonProps = {
|
||||
export function StepPerson({ draft, update, locations }: StepPersonProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<TextField
|
||||
label="Personalnummer"
|
||||
required
|
||||
inputMode="numeric"
|
||||
value={draft.personnelNumber}
|
||||
onChange={(personnelNumber) => update({ personnelNumber: personnelNumber.replace(/\D/g, "") })}
|
||||
hint="Muss mit Loga und Interflex übereinstimmen. Wird nicht automatisch vergeben."
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<TextField label="Vorname" required value={draft.firstName} onChange={(firstName) => update({ firstName })} />
|
||||
<TextField label="Nachname" required value={draft.lastName} onChange={(lastName) => update({ lastName })} />
|
||||
@@ -37,8 +45,11 @@ export function StepPerson({ draft, update, locations }: StepPersonProps) {
|
||||
birthDate={draft.birthDate || null}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<TextField label="E-Mail" required type="email" value={draft.email} onChange={(email) => update({ email })} />
|
||||
<TextField label="Telefon" type="tel" value={draft.phone} onChange={(phone) => update({ phone })} />
|
||||
{/* Die private Adresse, nicht die Firmenadresse — die entsteht erst
|
||||
mit dem Eintritt. Freiwillig: wer keine hat oder keine angeben
|
||||
will, soll nicht gezwungen sein, eine zu erfinden. */}
|
||||
<TextField label="Private E-Mail" type="email" value={draft.email} onChange={(email) => update({ email })} />
|
||||
<TextField label="Private Telefonnummer" type="tel" value={draft.phone} onChange={(phone) => update({ phone })} />
|
||||
</div>
|
||||
<SelectField
|
||||
label="Standort"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { dienstwagenLabel } from "@/lib/dienstwagen";
|
||||
import { fmtDate, fmtFullName } from "@/lib/format";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import type { HireDraftData } from "./types";
|
||||
@@ -21,17 +22,25 @@ export function StepSummary({ draft, selectedPosition, locations }: StepSummaryP
|
||||
const location = locations.find((l) => l.id === draft.locationId);
|
||||
const flags = [
|
||||
draft.isBetriebsrat && "Betriebsrat",
|
||||
draft.hasDienstwagen && "Dienstwagen",
|
||||
draft.hasDienstwagen && `Dienstwagen (${dienstwagenLabel(draft.dienstwagenArt)})`,
|
||||
draft.isLateraleFuehrung && "Laterale Führung",
|
||||
draft.isCLevel && "C-Level",
|
||||
draft.hasKuendigungsschutz &&
|
||||
`Besonderer Kündigungsschutz${draft.kuendigungsschutzBis ? ` bis ${fmtDate(draft.kuendigungsschutzBis)}` : ""}`,
|
||||
].filter(Boolean);
|
||||
const notfall = [draft.emergencyContactName, draft.emergencyContactRelation && `(${draft.emergencyContactRelation})`, draft.emergencyContactPhone]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const rows: [string, string][] = [
|
||||
["Personalnummer", draft.personnelNumber || "–"],
|
||||
["Name", fmtFullName(draft.firstName, draft.lastName, draft.titlePrefix, draft.titleSuffix)],
|
||||
["Geschlecht", draft.gender === "m" ? "männlich" : "weiblich"],
|
||||
["Geburtsdatum", fmtDate(draft.birthDate)],
|
||||
["SV-Nummer", draft.svNummer || "–"],
|
||||
["E-Mail (privat)", draft.email || "–"],
|
||||
["Telefon", draft.phone || "–"],
|
||||
["Notfallkontakt", notfall || "–"],
|
||||
["Standort", location ? `${location.name} (${location.country})` : "–"],
|
||||
["Position", selectedPosition ? `${selectedPosition.title} (${selectedPosition.position_number})` : "–"],
|
||||
["Organisationseinheit", selectedPosition?.orgLabel ?? "–"],
|
||||
@@ -57,9 +66,6 @@ export function StepSummary({ draft, selectedPosition, locations }: StepSummaryP
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<p className="rounded bg-info-bg px-3 py-2 text-sm text-info-text">
|
||||
Personalnummer und Firmen-E-Mail-Adresse werden automatisch vergeben.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
import type { CollectiveAgreement, ContractType, EmploymentType, GenderType, PaygradeType, Weekday, WorkerType } from "@/lib/supabase/types";
|
||||
import type { CollectiveAgreement, ContractType, DienstwagenArt, EmploymentType, GenderType, PaygradeType, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types";
|
||||
|
||||
// The spec's hire wizard field list (§4.4) omits Geschlecht and Standort even
|
||||
// though both are NOT NULL on employees — added here (defaults keep them
|
||||
// effectively "free" for the user, same treatment as the karenz-start gap).
|
||||
/**
|
||||
* Angehörige:r, wie sie im Assistenten gesammelt wird.
|
||||
*
|
||||
* Eigener Typ statt der Zeile aus der Datenbank: es gibt weder eine Kennung
|
||||
* noch eine Person, an der sie hängt — beides entsteht erst mit dem Anlegen.
|
||||
*/
|
||||
export type HireDraftAngehoerige = {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
relationship: RelationshipType;
|
||||
birthDate: string;
|
||||
svNummer: string;
|
||||
};
|
||||
|
||||
export type HireDraftData = {
|
||||
/**
|
||||
* Eingabe, nicht Vergabe.
|
||||
*
|
||||
* Muss mit Loga und Interflex übereinstimmen — als Zeichenkette geführt,
|
||||
* weil ein leeres Zahlenfeld sonst als 0 im Entwurf landet.
|
||||
*/
|
||||
personnelNumber: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
titlePrefix: string[];
|
||||
@@ -27,11 +48,21 @@ export type HireDraftData = {
|
||||
workDays: Weekday[];
|
||||
isBetriebsrat: boolean;
|
||||
hasDienstwagen: boolean;
|
||||
/** Nur ausgewertet, wenn hasDienstwagen gesetzt ist — so will es der CHECK. */
|
||||
dienstwagenArt: DienstwagenArt;
|
||||
emergencyContactName: string;
|
||||
emergencyContactPhone: string;
|
||||
emergencyContactRelation: string;
|
||||
angehoerige: HireDraftAngehoerige[];
|
||||
isLateraleFuehrung: boolean;
|
||||
isCLevel: boolean;
|
||||
hasKuendigungsschutz: boolean;
|
||||
/** Freiwillig — leer heisst „bis auf Weiteres". */
|
||||
kuendigungsschutzBis: string;
|
||||
};
|
||||
|
||||
export const EMPTY_HIRE_DRAFT: HireDraftData = {
|
||||
personnelNumber: "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
titlePrefix: [],
|
||||
@@ -55,6 +86,13 @@ export const EMPTY_HIRE_DRAFT: HireDraftData = {
|
||||
workDays: ["Mo", "Di", "Mi", "Do", "Fr"],
|
||||
isBetriebsrat: false,
|
||||
hasDienstwagen: false,
|
||||
dienstwagenArt: "Verbrenner",
|
||||
emergencyContactName: "",
|
||||
emergencyContactPhone: "",
|
||||
emergencyContactRelation: "",
|
||||
angehoerige: [],
|
||||
isLateraleFuehrung: false,
|
||||
isCLevel: false,
|
||||
hasKuendigungsschutz: false,
|
||||
kuendigungsschutzBis: "",
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import { SearchInput } from "@/components/ui/SearchInput";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
import { LazyGraphOrgChart } from "./LazyGraphOrgChart";
|
||||
import type { ChartNode, OrgEmployee } from "./types";
|
||||
import { fmtName } from "@/lib/format";
|
||||
|
||||
type ViewMode = "list" | "graph";
|
||||
|
||||
@@ -53,7 +54,7 @@ export function EmployeeTree({ employees, focusId = null }: { employees: OrgEmpl
|
||||
|
||||
// Both badges name a person by id, so the lookup is shared rather than
|
||||
// rebuilt per node.
|
||||
const nameById = useMemo(() => new Map(employees.map((e) => [e.id, `${e.first_name} ${e.last_name}`])), [employees]);
|
||||
const nameById = useMemo(() => new Map(employees.map((e) => [e.id, fmtName(e.first_name, e.last_name)])), [employees]);
|
||||
const nameOf = useCallback((id: string | null) => (id ? nameById.get(id) : undefined), [nameById]);
|
||||
|
||||
const matchIds = useMemo(() => {
|
||||
@@ -64,8 +65,17 @@ export function EmployeeTree({ employees, focusId = null }: { employees: OrgEmpl
|
||||
const q = query.trim().toLowerCase();
|
||||
const matches = new Set<string>();
|
||||
for (const e of employees) {
|
||||
// Beide Reihenfolgen: angezeigt wird „Winkler, Hannah", im Kopf hat man
|
||||
// aber je nach Anlass das eine oder das andere zuerst. Wer abtippt, was
|
||||
// er sieht, soll ebenso fündig werden wie jemand, der „hannah winkler"
|
||||
// eingibt.
|
||||
const vorNach = `${e.first_name} ${e.last_name}`.toLowerCase();
|
||||
const nachVor = fmtName(e.first_name, e.last_name).toLowerCase();
|
||||
if (
|
||||
`${e.first_name} ${e.last_name}`.toLowerCase().includes(q) ||
|
||||
vorNach.includes(q) ||
|
||||
nachVor.includes(q) ||
|
||||
// Ohne den Beistrich, falls jemand ihn beim Abtippen weglässt.
|
||||
nachVor.replace(",", "").includes(q) ||
|
||||
e.job_title.toLowerCase().includes(q) ||
|
||||
String(e.personnel_number).includes(q)
|
||||
) {
|
||||
@@ -118,7 +128,7 @@ export function EmployeeTree({ employees, focusId = null }: { employees: OrgEmpl
|
||||
return {
|
||||
id: e.id,
|
||||
kind: "person",
|
||||
label: `${e.first_name} ${e.last_name}`,
|
||||
label: fmtName(e.first_name, e.last_name),
|
||||
sublabel: e.job_title,
|
||||
href: `/employees/${e.id}`,
|
||||
avatar: { firstName: e.first_name, lastName: e.last_name },
|
||||
@@ -159,7 +169,7 @@ export function EmployeeTree({ employees, focusId = null }: { employees: OrgEmpl
|
||||
<Avatar firstName={e.first_name} lastName={e.last_name} size="sm" />
|
||||
<Link href={`/employees/${e.id}`} className="min-w-0 flex-1 hover:underline">
|
||||
<span className="text-sm font-semibold text-ink">
|
||||
{e.first_name} {e.last_name}
|
||||
{fmtName(e.first_name, e.last_name)}
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-ink-muted">{e.job_title}</span>
|
||||
</Link>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Printer } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
import { AsOfPicker } from "./AsOfPicker";
|
||||
import { EmployeeTree } from "./EmployeeTree";
|
||||
@@ -35,6 +37,7 @@ export function OrgChartClient({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<SegmentedControl<View>
|
||||
value={view}
|
||||
onChange={setView}
|
||||
@@ -43,6 +46,16 @@ export function OrgChartClient({
|
||||
{ value: "pos", label: "Organisation" },
|
||||
]}
|
||||
/>
|
||||
{/* Der Stichtag wandert mit: wer eine vergangene Struktur ansieht,
|
||||
druckt sie auch. */}
|
||||
<Link
|
||||
href={asOf === today ? "/orgchart/print" : `/orgchart/print?asOf=${asOf}`}
|
||||
className="inline-flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-semibold text-ink hover:bg-brand-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
<Printer className="h-4 w-4" />
|
||||
Als PDF
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<AsOfPicker asOf={asOf} today={today} projectedCount={projectedCount} historyStartsAt={historyStartsAt} />
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useCallback, useMemo, useState } from "react";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
import { LazyGraphOrgChart } from "./LazyGraphOrgChart";
|
||||
import type { ChartNode, OrgEmployee, OrgUnitNode, OrgVacancy } from "./types";
|
||||
import { fmtName } from "@/lib/format";
|
||||
|
||||
// Die Struktursicht. Sie folgt jetzt org_units.parent_id statt einer fest
|
||||
// verdrahteten Abfolge Bereich → Abteilung → Team: eine fünfte Ebene ist
|
||||
@@ -178,7 +179,7 @@ export function buildUnitTree(units: OrgUnitNode[], employees: OrgEmployee[], va
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
kind: "person" as const,
|
||||
label: `${p.first_name} ${p.last_name}`,
|
||||
label: fmtName(p.first_name, p.last_name),
|
||||
href: `/employees/${p.id}`,
|
||||
avatar: { firstName: p.first_name, lastName: p.last_name },
|
||||
absent: p.absent,
|
||||
@@ -204,7 +205,7 @@ export function buildUnitTree(units: OrgUnitNode[], employees: OrgEmployee[], va
|
||||
kind: "role",
|
||||
label: `${unit.org_number} · ${unit.name}`,
|
||||
sublabel: chief
|
||||
? `Leitung: ${chief.first_name} ${chief.last_name}${chief.absent ? " (abwesend)" : ""}`
|
||||
? `Leitung: ${fmtName(chief.first_name, chief.last_name)}${chief.absent ? " (abwesend)" : ""}`
|
||||
: "Leitung vakant",
|
||||
vacant: !chief,
|
||||
children: [...subUnits, ...titleGroups, ...vacancyNodes],
|
||||
|
||||
609
components/orgchart/PrintChart.tsx
Normal file
609
components/orgchart/PrintChart.tsx
Normal file
@@ -0,0 +1,609 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronRight, Printer } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import type { PrintModel, PrintPerson, PrintUnit } from "@/lib/orgchart-print";
|
||||
|
||||
// Auswahl, dann Vorschau, dann Papier.
|
||||
//
|
||||
// Drei Entscheidungen prägen das hier:
|
||||
//
|
||||
// **Erstens wird gefragt, bevor gedruckt wird.** Ein Organigramm mit 810
|
||||
// Personen auf Papier ist fast nie das, was jemand braucht — meist geht es um
|
||||
// einen Bereich, oder um die oberen zwei Ebenen für eine Besprechung. Wer
|
||||
// alles bekommt, druckt vierzig Blätter und wirft achtunddreissig weg.
|
||||
//
|
||||
// **Zweitens wird hierarchisch gezeichnet, nicht aufgelistet.** Kästen mit
|
||||
// Verbindungslinien zeigen die Über- und Unterordnung; eine Spaltenliste
|
||||
// zeigt sie nicht, egal wie sauber sie gesetzt ist. Die Linien entstehen aus
|
||||
// Rahmen von Pseudo-Elementen — damit sind sie im PDF Vektoren und bleiben
|
||||
// beim Hineinzoomen scharf.
|
||||
//
|
||||
// **Drittens breitet sich nur eine Ebene waagrecht aus.** Ein Baum, der auf
|
||||
// jeder Stufe in die Breite geht, wächst exponentiell: sechs Bereiche mit je
|
||||
// drei Abteilungen sind achtzehn Kästen nebeneinander, und quer über ein A4
|
||||
// bleiben davon zwei Millimeter je Kasten — die erste Fassung lief genau
|
||||
// deshalb über den Blattrand hinaus. Jetzt stehen die Bereiche in einer
|
||||
// Reihe, und alles darunter hängt längs an einer Linie: die Breite ist die
|
||||
// Zahl der Bereiche, unabhängig von der Tiefe. Was dann immer noch übersteht,
|
||||
// wird als Ganzes so weit verkleinert, bis es aufs Blatt passt.
|
||||
|
||||
type Tiefe = "bereich" | "abteilung" | "team" | "personen";
|
||||
|
||||
const TIEFEN: { wert: Tiefe; label: string; hinweis: string }[] = [
|
||||
{ wert: "bereich", label: "Bereiche", hinweis: "Gesellschaft und Bereiche — ein Blatt" },
|
||||
{ wert: "abteilung", label: "bis Abteilung", hinweis: "eine Ebene tiefer, noch immer ein Blatt" },
|
||||
{ wert: "team", label: "bis Team", hinweis: "je Bereich ein Blatt, ohne Namen" },
|
||||
{ wert: "personen", label: "mit Personen", hinweis: "je Abteilung ein Blatt, mit allen Namen" },
|
||||
];
|
||||
|
||||
type Format = "a4" | "a3";
|
||||
// Die Satzfläche ist die Blattgrösse abzüglich der Ränder aus @page, ein
|
||||
// wenig knapper gerechnet. Die Vorschau bekommt genau diese Masse fest
|
||||
// zugewiesen — dadurch ist das, was am Bildschirm zu sehen ist, dasselbe, was
|
||||
// aufs Papier passt, und nicht bloss ungefähr.
|
||||
const FORMATE: Record<Format, { label: string; css: string; blatt: string; satzBreite: string; satzHoehe: string }> = {
|
||||
a4: { label: "A4 quer", css: "A4 landscape", blatt: "297mm", satzBreite: "268mm", satzHoehe: "182mm" },
|
||||
a3: { label: "A3 quer", css: "A3 landscape", blatt: "420mm", satzBreite: "390mm", satzHoehe: "269mm" },
|
||||
};
|
||||
|
||||
/** Unter diesem Massstab wird es auf A4 mühsam — dann lieber A3. */
|
||||
const LESBAR = 0.55;
|
||||
|
||||
type Seite = {
|
||||
key: string;
|
||||
titel: string;
|
||||
unterzeile: string;
|
||||
wurzel: PrintUnit;
|
||||
/** Nur auf der Übersicht: die gewählten Bereiche statt aller Kinder. */
|
||||
kinder?: PrintUnit[];
|
||||
/** Wie viele Ebenen unter der Wurzel gezeichnet werden. */
|
||||
ebenen: number;
|
||||
mitPersonen: boolean;
|
||||
};
|
||||
|
||||
function seitenBauen(model: PrintModel, bereiche: PrintUnit[], tiefe: Tiefe, asOf: string): Seite[] {
|
||||
const stand = `Stand ${fmtDate(asOf)}`;
|
||||
const seiten: Seite[] = [];
|
||||
|
||||
if (model.root) {
|
||||
seiten.push({
|
||||
key: "uebersicht",
|
||||
titel: model.root.name,
|
||||
unterzeile: `Organigramm · ${stand} · ${model.totals.people} Personen`,
|
||||
wurzel: model.root,
|
||||
kinder: bereiche,
|
||||
// Bei „bis Abteilung" ist die Übersicht das ganze Ergebnis. Sonst folgen
|
||||
// Detailblätter, und dann bleibt sie eine reine Bereichsübersicht.
|
||||
ebenen: tiefe === "abteilung" ? 2 : 1,
|
||||
mitPersonen: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (tiefe === "team") {
|
||||
for (const b of bereiche) {
|
||||
seiten.push({
|
||||
key: b.id,
|
||||
titel: b.name,
|
||||
unterzeile: `${b.totalPeople} Personen · ${stand}`,
|
||||
wurzel: b,
|
||||
ebenen: 2,
|
||||
mitPersonen: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (tiefe === "personen") {
|
||||
for (const b of bereiche) {
|
||||
// Wer unmittelbar im Bereich sitzt, hätte sonst kein Blatt — die
|
||||
// Leitung steht auf der Übersicht, die übrigen nirgends.
|
||||
if (b.members.length > 0) {
|
||||
seiten.push({
|
||||
key: `${b.id}-direkt`,
|
||||
titel: b.name,
|
||||
unterzeile: `Bereichsleitung und Stab · ${stand}`,
|
||||
wurzel: b,
|
||||
ebenen: 0,
|
||||
mitPersonen: true,
|
||||
});
|
||||
}
|
||||
for (const a of b.children) {
|
||||
seiten.push({
|
||||
key: a.id,
|
||||
titel: `${b.name} · ${a.name}`,
|
||||
unterzeile: `${a.totalPeople} Personen · ${stand}`,
|
||||
wurzel: a,
|
||||
ebenen: 1,
|
||||
mitPersonen: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return seiten;
|
||||
}
|
||||
|
||||
export function PrintChart({ model, asOf, today }: { model: PrintModel; asOf: string; today: string }) {
|
||||
const [format, setFormat] = useState<Format>("a4");
|
||||
const [tiefe, setTiefe] = useState<Tiefe>("team");
|
||||
const [gewaehlt, setGewaehlt] = useState<Set<string>>(() => new Set(model.divisions.map((d) => d.id)));
|
||||
const [schritt, setSchritt] = useState<"auswahl" | "vorschau">("auswahl");
|
||||
|
||||
const bereiche = useMemo(() => model.divisions.filter((d) => gewaehlt.has(d.id)), [model.divisions, gewaehlt]);
|
||||
const seiten = useMemo(() => seitenBauen(model, bereiche, tiefe, asOf), [model, bereiche, tiefe, asOf]);
|
||||
|
||||
// Der kleinste Massstab aller Blätter — daran hängt der Hinweis auf A3.
|
||||
//
|
||||
// Die gemeldeten Werte tragen die Auswahl mit, für die sie gemessen wurden:
|
||||
// ändert sie sich, sind die alten Werte nicht mehr gültig und werden
|
||||
// verworfen, sobald das erste Blatt der neuen Auswahl meldet. Das erspart
|
||||
// einen Effekt, der beim Wechsel aufräumt — und damit eine Renderrunde,
|
||||
// in der noch der Hinweis der vorherigen Auswahl stünde.
|
||||
const signatur = `${format}|${tiefe}|${[...gewaehlt].sort().join(",")}`;
|
||||
const [gemessen, setGemessen] = useState<{ signatur: string; werte: Record<string, number> }>({ signatur, werte: {} });
|
||||
const meldeMassstab = useCallback(
|
||||
(key: string, faktor: number) => {
|
||||
setGemessen((prev) => {
|
||||
// Unverändert heisst unverändert: denselben Zustand zurückgeben, nicht
|
||||
// ein gleich aussehendes neues Objekt. Ein neues Objekt zählt für React
|
||||
// als Änderung und löst ein Rendern aus — und da das Rendern die
|
||||
// Messung anstösst, die wieder hier landet, dreht sich das ohne Ende.
|
||||
// Genau daran ist die Vorschau eingefroren.
|
||||
if (prev.signatur === signatur && prev.werte[key] === faktor) return prev;
|
||||
return prev.signatur === signatur
|
||||
? { signatur, werte: { ...prev.werte, [key]: faktor } }
|
||||
: { signatur, werte: { [key]: faktor } };
|
||||
});
|
||||
},
|
||||
[signatur]
|
||||
);
|
||||
const engste = Math.min(1, ...Object.values(gemessen.signatur === signatur ? gemessen.werte : {}));
|
||||
|
||||
function umschalten(id: string) {
|
||||
setGewaehlt((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<style>{`
|
||||
@page { size: ${FORMATE[format].css}; margin: 12mm 14mm 14mm; }
|
||||
@media print {
|
||||
html, body { background: #fff; }
|
||||
.nur-bildschirm { display: none !important; }
|
||||
.blatt {
|
||||
box-shadow: none !important; border: 0 !important; border-radius: 0 !important;
|
||||
margin: 0 !important; width: auto !important; padding: 0 !important;
|
||||
break-after: page;
|
||||
}
|
||||
.blatt:last-child { break-after: auto; }
|
||||
/* Die Satzfläche behält ihre Masse — der Massstab wurde für genau
|
||||
diese berechnet. Nur der Rand fällt weg, den setzt @page. */
|
||||
.satz { margin: 0 !important; }
|
||||
}
|
||||
|
||||
/* Eine Reihe: waagrechte Geschwister mit Winkeln nach oben. Das erste
|
||||
und das letzte Kind lassen ihre Aussenseite weg — zusammen ergibt
|
||||
das eine durchgehende Querlinie ohne ein einziges Hilfselement. */
|
||||
ul.reihe {
|
||||
position: relative; display: flex; justify-content: center;
|
||||
list-style: none; margin: 0; padding: 18px 0 0;
|
||||
}
|
||||
ul.reihe::before {
|
||||
content: ""; position: absolute; top: 0; left: 50%;
|
||||
height: 18px; border-left: 1px solid var(--color-border);
|
||||
}
|
||||
ul.reihe > li { position: relative; width: var(--spalte); padding: 18px 6px 0; }
|
||||
ul.reihe > li::before, ul.reihe > li::after {
|
||||
content: ""; position: absolute; top: 0; width: 50%; height: 18px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
ul.reihe > li::before { right: 50%; }
|
||||
ul.reihe > li::after { left: 50%; border-left: 1px solid var(--color-border); }
|
||||
ul.reihe > li:first-child::before { border-top: 0; }
|
||||
ul.reihe > li:last-child::after { border-top: 0; }
|
||||
|
||||
/* Ein Stapel: längs an einer Linie, mit Winkel in jeden Kasten. Kostet
|
||||
Höhe statt Breite — und Höhe ist auf einem Querformat übrig. */
|
||||
ul.stapel { list-style: none; margin: 0; padding: 0; text-align: left; }
|
||||
ul.stapel > li { position: relative; padding: 8px 0 0 20px; }
|
||||
ul.stapel > li::before {
|
||||
content: ""; position: absolute; left: 9px; top: 0; bottom: 0;
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
ul.stapel > li:last-child::before { bottom: auto; height: 22px; }
|
||||
ul.stapel > li::after {
|
||||
content: ""; position: absolute; left: 9px; top: 22px; width: 11px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* ── Auswahl ─────────────────────────────────────────── */}
|
||||
<section className="nur-bildschirm rounded border border-border bg-white p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-ink">Was soll aufs Papier?</h2>
|
||||
<p className="mt-0.5 max-w-prose text-sm text-ink-muted">
|
||||
Das vollständige Organigramm sind {model.totals.people} Personen. Gedruckt wird selten alles — hier steht,
|
||||
wie tief und welche Bereiche.
|
||||
</p>
|
||||
</div>
|
||||
<p className="whitespace-nowrap text-sm text-ink-muted">
|
||||
Stand {fmtDate(asOf)}
|
||||
{asOf !== today && " · abweichender Stichtag"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-ink-muted">Tiefe</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TIEFEN.map((t) => (
|
||||
<button
|
||||
key={t.wert}
|
||||
type="button"
|
||||
onClick={() => setTiefe(t.wert)}
|
||||
aria-pressed={tiefe === t.wert}
|
||||
className={`rounded-md border px-3 py-2 text-left focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
|
||||
tiefe === t.wert ? "border-brand-500 bg-brand-50" : "border-border hover:bg-surface"
|
||||
}`}
|
||||
>
|
||||
<span className="block text-sm font-semibold text-ink">{t.label}</span>
|
||||
<span className="block text-xs text-ink-muted">{t.hinweis}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-ink-muted">
|
||||
Bereiche ({gewaehlt.size} von {model.divisions.length})
|
||||
</p>
|
||||
<div className="flex gap-3 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGewaehlt(new Set(model.divisions.map((d) => d.id)))}
|
||||
className="rounded font-semibold text-brand-700 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
Alle
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGewaehlt(new Set())}
|
||||
className="rounded font-semibold text-brand-700 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
Keine
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{model.divisions.map((d) => (
|
||||
<label
|
||||
key={d.id}
|
||||
className={`flex cursor-pointer items-start gap-2 rounded-md border px-3 py-2 ${
|
||||
gewaehlt.has(d.id) ? "border-brand-500 bg-brand-50" : "border-border"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={gewaehlt.has(d.id)}
|
||||
onChange={() => umschalten(d.id)}
|
||||
className="mt-0.5 h-4 w-4 rounded border-border text-brand-600"
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-sm font-semibold text-ink">{d.name}</span>
|
||||
<span className="block text-xs text-ink-muted">
|
||||
{d.totalPeople} Personen · {d.children.length} Abteilungen
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-center justify-between gap-3 border-t border-border-subtle pt-4">
|
||||
<p className="text-sm text-ink-muted">
|
||||
{gewaehlt.size === 0 ? (
|
||||
<span className="text-danger-text">Kein Bereich gewählt — es bliebe nur die Übersicht.</span>
|
||||
) : (
|
||||
<>
|
||||
Ergibt <strong className="text-ink">{seiten.length}</strong> {seiten.length === 1 ? "Seite" : "Seiten"}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/orgchart"
|
||||
className="rounded-md border border-border px-3 py-2 text-sm font-semibold text-ink hover:bg-brand-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
Zurück
|
||||
</Link>
|
||||
<Button onClick={() => setSchritt("vorschau")}>
|
||||
Vorschau <ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{schritt === "vorschau" && (
|
||||
<>
|
||||
<div className="nur-bildschirm flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm text-ink-muted">
|
||||
{seiten.length} {seiten.length === 1 ? "Seite" : "Seiten"} · so kommt es aus dem Drucker
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex rounded-md border border-border p-0.5">
|
||||
{(Object.keys(FORMATE) as Format[]).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
onClick={() => setFormat(f)}
|
||||
aria-pressed={format === f}
|
||||
className={`rounded px-3 py-1.5 text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
|
||||
format === f ? "bg-brand-500 text-white" : "text-ink-muted hover:bg-surface"
|
||||
}`}
|
||||
>
|
||||
{FORMATE[f].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button onClick={() => window.print()}>
|
||||
<Printer className="h-4 w-4" />
|
||||
Als PDF speichern
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{format === "a4" && engste < LESBAR && (
|
||||
<p className="nur-bildschirm rounded border border-warning-text/30 bg-warning-bg px-3 py-2 text-sm text-warning-text">
|
||||
Ein Blatt musste auf {Math.round(engste * 100)} % verkleinert werden, damit es passt — auf A4 kaum noch
|
||||
lesbar. A3 wählen oder weniger Bereiche.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="nur-bildschirm max-w-prose text-xs leading-relaxed text-ink-muted">
|
||||
Im Druckdialog „Als PDF speichern“ wählen. Hintergrundgrafiken müssen dort eingeschaltet sein,
|
||||
sonst fehlen die farbigen Kopfzeilen — in Chrome unter „Weitere Einstellungen“.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col items-center gap-8">
|
||||
{seiten.map((seite, i) => (
|
||||
<Blatt key={seite.key} format={format}>
|
||||
<Kopf
|
||||
titel={seite.titel}
|
||||
unterzeile={seite.unterzeile}
|
||||
seite={i === 0 ? "Übersicht" : `Seite ${i + 1} von ${seiten.length}`}
|
||||
/>
|
||||
<Skaliert kennung={`${seite.key}|${format}|${tiefe}`} onFaktor={(f) => meldeMassstab(seite.key, f)}>
|
||||
<Chart seite={seite} />
|
||||
</Skaliert>
|
||||
</Blatt>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Blatt({ children, format }: { children: React.ReactNode; format: Format }) {
|
||||
const { blatt, satzBreite, satzHoehe } = FORMATE[format];
|
||||
return (
|
||||
<div className="blatt rounded border border-border bg-white shadow-[var(--shadow-card)]" style={{ width: blatt }}>
|
||||
<div className="satz flex flex-col" style={{ width: satzBreite, height: satzHoehe, margin: "12mm 14mm 14mm" }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Kopf({ titel, unterzeile, seite }: { titel: string; unterzeile: string; seite: string }) {
|
||||
return (
|
||||
<header className="mb-5 flex shrink-0 items-end justify-between border-b-2 border-brand-500 pb-2">
|
||||
<div>
|
||||
<h1 className="text-lg font-extrabold tracking-tight text-ink">{titel}</h1>
|
||||
<p className="mt-0.5 text-xs text-ink-muted">{unterzeile}</p>
|
||||
</div>
|
||||
<p className="whitespace-nowrap text-xs font-semibold text-ink-muted">{seite}</p>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verkleinert den Inhalt so weit, dass er in die Satzfläche passt.
|
||||
*
|
||||
* Ein Organigramm hat keine Grösse, die man vorher kennt — sie hängt daran,
|
||||
* wie viele Bereiche jemand angehakt hat und wie tief. Statt zu raten wird
|
||||
* gemessen: die natürliche Grösse steht im Layout, die verfügbare auch, und
|
||||
* `transform: scale` ändert nur die Darstellung, nicht das Layout. Damit ist
|
||||
* die Messung stabil und läuft nicht gegen sich selbst.
|
||||
*/
|
||||
function Skaliert({
|
||||
kennung,
|
||||
onFaktor,
|
||||
children,
|
||||
}: {
|
||||
kennung: string;
|
||||
onFaktor: (faktor: number) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const rahmen = useRef<HTMLDivElement>(null);
|
||||
const inhalt = useRef<HTMLDivElement>(null);
|
||||
const [faktor, setFaktor] = useState(1);
|
||||
const [versatz, setVersatz] = useState(0);
|
||||
|
||||
// Die Meldefunktion wird beim Aufruf frisch erzeugt und ist daher bei jedem
|
||||
// Rendern eine andere. Stünde sie in den Abhängigkeiten, liefe der Effekt
|
||||
// nach jedem Rendern erneut — samt Messung, die das nächste Rendern
|
||||
// auslöst. Über eine Referenz bleibt der aktuelle Stand erreichbar, ohne
|
||||
// dass der Effekt daran hängt.
|
||||
const melden = useRef(onFaktor);
|
||||
useEffect(() => {
|
||||
melden.current = onFaktor;
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const aussen = rahmen.current;
|
||||
const innen = inhalt.current;
|
||||
if (!aussen || !innen) return;
|
||||
|
||||
function messen() {
|
||||
if (!aussen || !innen) return;
|
||||
const breite = aussen.clientWidth;
|
||||
const hoehe = aussen.clientHeight;
|
||||
const noetigeBreite = innen.offsetWidth;
|
||||
const noetigeHoehe = innen.offsetHeight;
|
||||
if (!breite || !hoehe || !noetigeBreite || !noetigeHoehe) return;
|
||||
const f = Math.min(1, breite / noetigeBreite, hoehe / noetigeHoehe);
|
||||
setFaktor(f);
|
||||
// Waagrecht mittig: die Verkleinerung geht von links oben aus, sonst
|
||||
// klebt ein schmales Diagramm am linken Blattrand.
|
||||
setVersatz(Math.max(0, (breite - noetigeBreite * f) / 2));
|
||||
melden.current(f);
|
||||
}
|
||||
|
||||
messen();
|
||||
// Schriften kommen nach dem ersten Layout — bis dahin sind alle Kästen in
|
||||
// der Ersatzschrift gesetzt und damit anders breit.
|
||||
void document.fonts?.ready.then(messen);
|
||||
const beobachter = new ResizeObserver(messen);
|
||||
beobachter.observe(aussen);
|
||||
return () => beobachter.disconnect();
|
||||
}, [kennung]);
|
||||
|
||||
return (
|
||||
<div ref={rahmen} className="min-h-0 flex-1 overflow-hidden">
|
||||
<div
|
||||
ref={inhalt}
|
||||
style={{
|
||||
width: "max-content",
|
||||
transform: `scale(${faktor})`,
|
||||
transformOrigin: "top left",
|
||||
marginLeft: versatz,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Chart({ seite }: { seite: Seite }) {
|
||||
const kinder = seite.kinder ?? seite.wurzel.children;
|
||||
const zeigeKinder = seite.ebenen > 0 && kinder.length > 0;
|
||||
|
||||
return (
|
||||
// Feste Spaltenbreite: dadurch sitzt jeder Kasten mittig unter seiner
|
||||
// Verbindungslinie, und die Gesamtbreite ist die Zahl der Spalten mal
|
||||
// dieser Breite — berechenbar, statt vom längsten Namen abzuhängen.
|
||||
<div className="inline-block text-center" style={{ ["--spalte" as string]: "13rem" }}>
|
||||
<div className="mx-auto w-[15rem]">
|
||||
<Kasten unit={seite.wurzel} mitPersonen={seite.mitPersonen} />
|
||||
</div>
|
||||
{zeigeKinder && (
|
||||
<ul className="reihe">
|
||||
{kinder.map((k) => (
|
||||
<li key={k.id}>
|
||||
<Kasten unit={k} mitPersonen={seite.mitPersonen} />
|
||||
<Stapel units={k.children} ebenen={seite.ebenen - 1} mitPersonen={seite.mitPersonen} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stapel({ units, ebenen, mitPersonen }: { units: PrintUnit[]; ebenen: number; mitPersonen: boolean }) {
|
||||
if (ebenen <= 0 || units.length === 0) return null;
|
||||
return (
|
||||
<ul className="stapel">
|
||||
{units.map((u) => (
|
||||
<li key={u.id}>
|
||||
<Kasten unit={u} mitPersonen={mitPersonen} />
|
||||
<Stapel units={u.children} ebenen={ebenen - 1} mitPersonen={mitPersonen} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
const KOPFFARBE: Record<PrintUnit["unitType"], string> = {
|
||||
// Nach unten hin blasser: so bleibt die Rangfolge auch auf dem
|
||||
// Schwarzweissdrucker sichtbar, auf dem die meisten Ausdrucke landen.
|
||||
Gesellschaft: "bg-brand-500 text-white",
|
||||
Bereich: "bg-brand-100 text-ink",
|
||||
Abteilung: "bg-brand-50 text-ink",
|
||||
Team: "bg-surface text-ink",
|
||||
};
|
||||
|
||||
function Kasten({ unit, mitPersonen }: { unit: PrintUnit; mitPersonen: boolean }) {
|
||||
const offen = unit.vacancies.filter((v) => !v.isChief);
|
||||
// Lange Namenslisten in zwei Spalten: sonst wächst der Kasten in die Höhe,
|
||||
// und die Höhe ist es, die den Massstab drückt.
|
||||
const zweispaltig = mitPersonen && unit.members.length > 8;
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-hidden rounded border border-border text-left">
|
||||
<div className={`px-2 py-1 ${KOPFFARBE[unit.unitType]}`}>
|
||||
<p className="text-[11px] font-bold leading-tight">{unit.name}</p>
|
||||
<p
|
||||
className={`text-[9px] uppercase tracking-wide ${
|
||||
unit.unitType === "Gesellschaft" ? "text-white/75" : "text-ink-muted"
|
||||
}`}
|
||||
>
|
||||
{unit.unitType} · {unit.totalPeople}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="px-2 py-1">
|
||||
{unit.chief ? (
|
||||
<PersonZeile p={unit.chief} leitung />
|
||||
) : (
|
||||
<p className="text-[10px] italic text-ink-muted">Leitung unbesetzt</p>
|
||||
)}
|
||||
|
||||
{mitPersonen && unit.members.length > 0 && (
|
||||
<div className={`mt-1 border-t border-border-subtle pt-1 ${zweispaltig ? "columns-2 gap-2" : ""}`}>
|
||||
{unit.members.map((p) => (
|
||||
<div key={p.id} className="break-inside-avoid">
|
||||
<PersonZeile p={p} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mitPersonen &&
|
||||
offen.map((v) => (
|
||||
<p key={v.positionNumber} className="text-[10px] italic text-ink-muted">
|
||||
offen · {v.jobTitle}
|
||||
</p>
|
||||
))}
|
||||
|
||||
{!mitPersonen && unit.members.length > 0 && (
|
||||
<p className="text-[10px] text-ink-muted">
|
||||
{unit.members.length} {unit.members.length === 1 ? "Person" : "Personen"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonZeile({ p, leitung }: { p: PrintPerson; leitung?: boolean }) {
|
||||
return (
|
||||
<p className="text-[10px] leading-tight">
|
||||
<span className={leitung ? "font-bold text-ink" : "text-ink"}>{p.name}</span>
|
||||
{p.absent && <span className="text-ink-muted"> · abwesend</span>}
|
||||
<br />
|
||||
<span className="text-ink-muted">{p.jobTitle}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { updatePosition } from "@/actions/positions";
|
||||
import { setPositionCostCenter, updatePosition } from "@/actions/positions";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { SelectField, TextField } from "@/components/ui/Field";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
@@ -29,10 +29,15 @@ import type { UnitOption } from "./CreatePositionModal";
|
||||
export function EditPositionModal({
|
||||
position,
|
||||
units,
|
||||
kostenstellen,
|
||||
heute,
|
||||
onClose,
|
||||
}: {
|
||||
position: OpenPositionResolved;
|
||||
units: UnitOption[];
|
||||
kostenstellen: { id: string; code: string; name: string }[];
|
||||
/** Vom Server, nicht aus new Date(): sonst rechnet der Browser mit seiner Zone. */
|
||||
heute: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { showToast } = useToast();
|
||||
@@ -42,8 +47,18 @@ export function EditPositionModal({
|
||||
const [isChief, setIsChief] = useState(position.is_chief);
|
||||
const [validFrom, setValidFrom] = useState(position.valid_from);
|
||||
const [validTo, setValidTo] = useState(position.valid_to ?? "");
|
||||
// Die Kontierung ist ein eigener Vorgang mit eigenem Stichtag, kein Feld
|
||||
// unter anderen: die laufende wird beendet, die neue beginnt. Deshalb steht
|
||||
// hier ein Datum daneben und kein stilles Überschreiben.
|
||||
const [kostenstelleId, setKostenstelleId] = useState(
|
||||
kostenstellen.find((k) => k.code === position.kostenstelle?.code)?.id ?? ""
|
||||
);
|
||||
const [kostenstelleAb, setKostenstelleAb] = useState(heute);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const kostenstelleGeaendert =
|
||||
kostenstelleId !== "" && kostenstellen.find((k) => k.id === kostenstelleId)?.code !== position.kostenstelle?.code;
|
||||
|
||||
const unit = units.find((u) => u.id === orgUnitId);
|
||||
// Die eigene Leitung zählt nicht als Hindernis für sich selbst.
|
||||
const chiefTaken = Boolean(unit?.hasChief) && !(position.is_chief && orgUnitId === position.org_unit_id);
|
||||
@@ -62,9 +77,26 @@ export function EditPositionModal({
|
||||
valid_from: validFrom,
|
||||
valid_to: validTo || null,
|
||||
});
|
||||
if (result.success && kostenstelleGeaendert) {
|
||||
// Nacheinander, nicht gemeinsam: die Umkontierung ist ein eigener
|
||||
// Vorgang mit eigenem Stichtag und eigenem Protokolleintrag. Schlägt sie
|
||||
// fehl, steht die übrige Änderung trotzdem — und die Meldung sagt, was
|
||||
// offen blieb, statt beides stillschweigend zu verwerfen.
|
||||
const kosten = await setPositionCostCenter({
|
||||
position_id: position.id,
|
||||
cost_center_id: kostenstelleId,
|
||||
valid_from: kostenstelleAb,
|
||||
});
|
||||
if (!kosten.success) {
|
||||
setPending(false);
|
||||
showToast(kosten.error ?? "Die Kostenstelle konnte nicht geändert werden.", "error");
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
}
|
||||
setPending(false);
|
||||
if (result.success) {
|
||||
showToast("Planstelle geändert.");
|
||||
showToast(kostenstelleGeaendert ? "Planstelle geändert und umkontiert." : "Planstelle geändert.");
|
||||
router.refresh();
|
||||
onClose();
|
||||
} else {
|
||||
@@ -110,6 +142,30 @@ export function EditPositionModal({
|
||||
<TextField label="Gültig bis" type="date" value={validTo} onChange={setValidTo} />
|
||||
</div>
|
||||
|
||||
<SelectField
|
||||
label="Kostenstelle"
|
||||
value={kostenstelleId}
|
||||
onChange={setKostenstelleId}
|
||||
placeholder="Keine"
|
||||
options={kostenstellen.map((k) => ({ value: k.id, label: `${k.code} · ${k.name}` }))}
|
||||
/>
|
||||
{kostenstelleGeaendert && (
|
||||
<div className="rounded border border-border bg-surface px-3 py-2">
|
||||
<TextField
|
||||
label="Umkontieren ab"
|
||||
required
|
||||
type="date"
|
||||
value={kostenstelleAb}
|
||||
onChange={setKostenstelleAb}
|
||||
min={position.valid_from}
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-ink-muted">
|
||||
Die bisherige Kontierung endet an diesem Tag, die neue beginnt. Was vorher gebucht wurde, bleibt, wo es
|
||||
gebucht wurde.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="flex items-start gap-2 text-sm text-ink-body">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useState } from "react";
|
||||
import { deletePosition } from "@/actions/positions";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import { fmtDate, todayIso } from "@/lib/format";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import { CreatePositionModal, type UnitOption } from "./CreatePositionModal";
|
||||
import { EditPositionModal } from "./EditPositionModal";
|
||||
@@ -16,9 +16,10 @@ type OpenPositionWithDays = OpenPositionResolved & { daysOpen: number };
|
||||
type PositionsPageClientProps = {
|
||||
openPositions: OpenPositionWithDays[];
|
||||
units: UnitOption[];
|
||||
kostenstellen: { id: string; code: string; name: string }[];
|
||||
};
|
||||
|
||||
export function PositionsPageClient({ openPositions, units }: PositionsPageClientProps) {
|
||||
export function PositionsPageClient({ openPositions, units, kostenstellen }: PositionsPageClientProps) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
@@ -66,6 +67,14 @@ export function PositionsPageClient({ openPositions, units }: PositionsPageClien
|
||||
</span>
|
||||
{p.valid_to && <span className="text-xs font-semibold text-warning-text">endet am {fmtDate(p.valid_to)}</span>}
|
||||
{p.managerName && <span className="text-xs text-ink-muted">berichtet an {p.managerName}</span>}
|
||||
{/* Was die Stelle kostet und wen es trifft — die Frage, die bei einer
|
||||
Vakanz zuerst kommt. */}
|
||||
{p.kostenstelle && (
|
||||
<span className="text-xs text-ink-muted">
|
||||
Kostenstelle <span className="font-semibold tabular-nums text-ink-body">{p.kostenstelle.code}</span> ·{" "}
|
||||
{p.kostenstelle.name}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<Button
|
||||
@@ -121,7 +130,14 @@ export function PositionsPageClient({ openPositions, units }: PositionsPageClien
|
||||
baut — sonst blieben beim zweiten Öffnen die Werte des ersten
|
||||
stehen. */}
|
||||
{editing && (
|
||||
<EditPositionModal key={editing.id} position={editing} units={units} onClose={() => setEditing(null)} />
|
||||
<EditPositionModal
|
||||
key={editing.id}
|
||||
position={editing}
|
||||
units={units}
|
||||
kostenstellen={kostenstellen}
|
||||
heute={todayIso()}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { FileSpreadsheet, FileText, Save, Trash2 } from "lucide-react";
|
||||
import { ChevronDown, FileSpreadsheet, FileText, Save, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
@@ -10,6 +10,14 @@ import { CONTROL_CLASS, SelectField, TextField } from "@/components/ui/Field";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import {
|
||||
anzahlKriterien,
|
||||
AUSWAHL_KRITERIEN,
|
||||
JANEIN_KRITERIEN,
|
||||
kriterienParameter,
|
||||
ZEITRAUM_KRITERIEN,
|
||||
type Criteria,
|
||||
} from "@/lib/report-criteria";
|
||||
import {
|
||||
AVERAGE_MEASURES,
|
||||
EVENT_DATE_OPEN,
|
||||
@@ -27,6 +35,7 @@ import {
|
||||
type GroupDimension,
|
||||
type Measure,
|
||||
type ReportRow,
|
||||
type UnitOption,
|
||||
} from "@/lib/reports";
|
||||
import type { HistoryEventType } from "@/lib/supabase/types";
|
||||
|
||||
@@ -40,7 +49,7 @@ type CommonProps = {
|
||||
rows: ReportRow[];
|
||||
total: number;
|
||||
recordCount: number;
|
||||
divisions: OrgOption[];
|
||||
units: UnitOption[];
|
||||
locations: OrgOption[];
|
||||
savedReports: SavedReport[];
|
||||
};
|
||||
@@ -51,7 +60,8 @@ type SnapshotProps = CommonProps & {
|
||||
group: GroupDimension;
|
||||
split: GroupDimension | "";
|
||||
asOf: string;
|
||||
filters: { division: string; location: string; status: string; employment: string };
|
||||
filters: { division: string; location: string; status: string };
|
||||
criteria: Criteria;
|
||||
};
|
||||
|
||||
type EventsProps = CommonProps & {
|
||||
@@ -64,6 +74,144 @@ type EventsProps = CommonProps & {
|
||||
|
||||
type ReportsPageClientProps = SnapshotProps | EventsProps;
|
||||
|
||||
/**
|
||||
* Alle übrigen Auswahlkriterien, eingeklappt.
|
||||
*
|
||||
* Aufgeklappt wäre die Leiste dreimal so lang wie der Bericht daneben, und
|
||||
* die vier Filter, die fast immer gemeint sind, würden darin untergehen.
|
||||
* Eingeklappt steht nur die Zahl der gesetzten Kriterien da — und die ist
|
||||
* das, was man wissen muss: ob gerade etwas einschränkt, das man vergessen
|
||||
* hat.
|
||||
*/
|
||||
function KriterienBlock({
|
||||
criteria,
|
||||
onChange,
|
||||
onReset,
|
||||
}: {
|
||||
criteria: Criteria;
|
||||
onChange: (patch: Record<string, string | undefined>) => void;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
const [offen, setOffen] = useState(false);
|
||||
const anzahl = anzahlKriterien(criteria);
|
||||
|
||||
return (
|
||||
<div className="mt-2 border-t border-border-subtle pt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOffen(!offen)}
|
||||
aria-expanded={offen}
|
||||
className="flex items-center gap-1 rounded text-xs font-semibold text-brand-700 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${offen ? "rotate-180" : ""}`} />
|
||||
Weitere Kriterien
|
||||
{anzahl > 0 && <span className="rounded-full bg-brand-500 px-1.5 text-[10px] text-white">{anzahl}</span>}
|
||||
</button>
|
||||
{anzahl > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="rounded text-xs text-ink-muted hover:text-ink hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{offen && (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{AUSWAHL_KRITERIEN.map((k) => (
|
||||
<select
|
||||
key={k.key}
|
||||
aria-label={`Nach ${k.label} filtern`}
|
||||
value={criteria.auswahl[k.key] ?? ""}
|
||||
onChange={(e) => onChange({ [k.key]: e.target.value || undefined })}
|
||||
className={CONTROL_CLASS}
|
||||
>
|
||||
<option value="">{k.alle}</option>
|
||||
{k.optionen.map((o) => (
|
||||
<option key={o.wert} value={o.wert}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
))}
|
||||
|
||||
<fieldset className="rounded border border-border px-3 py-2">
|
||||
<legend className="mb-1.5 text-xs font-semibold text-ink-muted">Merkmale</legend>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{JANEIN_KRITERIEN.map((k) => (
|
||||
<div key={k.key} className="flex items-center justify-between gap-2 text-sm text-ink-body">
|
||||
<span>{k.label}</span>
|
||||
<select
|
||||
aria-label={`${k.label}: ja oder nein`}
|
||||
value={criteria.jaNein[k.key] ?? ""}
|
||||
onChange={(e) => onChange({ [k.key]: e.target.value || undefined })}
|
||||
className="rounded border border-border px-2 py-1 text-xs focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
|
||||
>
|
||||
<option value="">egal</option>
|
||||
<option value="ja">ja</option>
|
||||
<option value="nein">nein</option>
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{ZEITRAUM_KRITERIEN.map((k) => (
|
||||
<fieldset key={k.key} className="rounded border border-border px-3 py-2">
|
||||
<legend className="mb-1 text-xs font-semibold text-ink-muted">{k.label}</legend>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<TextField
|
||||
label="Von"
|
||||
dense
|
||||
type="date"
|
||||
value={criteria.zeitraum[k.key]?.von ?? ""}
|
||||
onChange={(v) => onChange({ [`${k.key}From`]: v || undefined })}
|
||||
/>
|
||||
<TextField
|
||||
label="Bis"
|
||||
dense
|
||||
type="date"
|
||||
value={criteria.zeitraum[k.key]?.bis ?? ""}
|
||||
onChange={(v) => onChange({ [`${k.key}To`]: v || undefined })}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
))}
|
||||
|
||||
<fieldset className="rounded border border-border px-3 py-2">
|
||||
<legend className="mb-1 text-xs font-semibold text-ink-muted">Wochenstunden</legend>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<TextField
|
||||
label="Ab"
|
||||
dense
|
||||
type="number"
|
||||
step="0.5"
|
||||
value={criteria.stundenVon?.toString() ?? ""}
|
||||
onChange={(v) => onChange({ hoursFrom: v || undefined })}
|
||||
/>
|
||||
<TextField
|
||||
label="Bis"
|
||||
dense
|
||||
type="number"
|
||||
step="0.5"
|
||||
value={criteria.stundenBis?.toString() ?? ""}
|
||||
onChange={(v) => onChange({ hoursTo: v || undefined })}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<p className="text-xs text-ink-muted">
|
||||
Alle Kriterien gelten für den Bericht <em>und</em> für beide Exporte.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatValue(measure: Measure, value: number): string {
|
||||
if (measure === "headcount") return String(Math.round(value));
|
||||
if (measure === "fte") return value.toFixed(1);
|
||||
@@ -73,7 +221,7 @@ function formatValue(measure: Measure, value: number): string {
|
||||
}
|
||||
|
||||
export function ReportsPageClient(props: ReportsPageClientProps) {
|
||||
const { mode, rows, total, recordCount, divisions, locations, savedReports } = props;
|
||||
const { mode, rows, total, recordCount, units, locations, savedReports } = props;
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -103,6 +251,12 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
|
||||
updateParams({ status: next.length > 0 ? next.join(",") : undefined });
|
||||
}
|
||||
|
||||
function resetKriterien() {
|
||||
const patch: Record<string, undefined> = {};
|
||||
for (const p of kriterienParameter()) patch[p] = undefined;
|
||||
updateParams(patch);
|
||||
}
|
||||
|
||||
function applyPreset(preset: { group: string; split?: string; eventType?: string; measure?: string }) {
|
||||
const sp = new URLSearchParams({ mode });
|
||||
if (preset.measure) sp.set("measure", preset.measure);
|
||||
@@ -126,10 +280,13 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
|
||||
showToast("Bitte einen Namen angeben.", "error");
|
||||
return;
|
||||
}
|
||||
const config =
|
||||
mode === "snapshot"
|
||||
? { mode, measure: props.measure, group: props.group, split: props.split, asOf: props.asOf, ...props.filters }
|
||||
: { mode, group: props.eventGroup, split: props.eventSplit, eventType: props.eventType, ...props.eventFilters };
|
||||
// Auch hier die ganze Adresszeile: ein gespeicherter Bericht, der die
|
||||
// Hälfte der Kriterien vergisst, führt beim nächsten Öffnen still zu
|
||||
// anderen Zahlen.
|
||||
const config: Record<string, string> = Object.fromEntries(searchParams.entries());
|
||||
config.mode = mode;
|
||||
config.group = mode === "snapshot" ? props.group : props.eventGroup;
|
||||
if (mode === "snapshot") config.measure = props.measure;
|
||||
setSavingReport(true);
|
||||
const result = await saveReport({ name: newReportName.trim(), config });
|
||||
setSavingReport(false);
|
||||
@@ -153,41 +310,30 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Beide Ausgänge übernehmen die Adresszeile unverändert, statt die
|
||||
// Parameter einzeln aufzuzählen. Genau diese Aufzählung war der Grund,
|
||||
// warum ein neues Kriterium im Bericht wirkte und im Export fehlte — man
|
||||
// musste daran denken, sie an drei Stellen zu ergänzen. Jetzt sieht der
|
||||
// Export das, was der Bericht gesehen hat, weil es dieselbe Auswahl ist.
|
||||
function exportHref(ziel: string, format: "csv" | "xlsx"): string {
|
||||
const sp = new URLSearchParams(searchParams.toString());
|
||||
sp.set("format", format);
|
||||
return `${ziel}?${sp.toString()}`;
|
||||
}
|
||||
|
||||
function reportExportHref(format: "csv" | "xlsx"): string {
|
||||
const sp = new URLSearchParams();
|
||||
const sp = new URLSearchParams(searchParams.toString());
|
||||
sp.set("format", format);
|
||||
sp.set("mode", mode);
|
||||
if (mode === "snapshot") {
|
||||
sp.set("measure", props.measure);
|
||||
sp.set("group", props.group);
|
||||
if (props.split) sp.set("split", props.split);
|
||||
if (props.asOf) sp.set("asOf", props.asOf);
|
||||
for (const [k, v] of Object.entries(props.filters)) if (v) sp.set(k, v);
|
||||
} else {
|
||||
sp.set("group", props.eventGroup);
|
||||
if (props.eventSplit) sp.set("split", props.eventSplit);
|
||||
if (props.eventType) sp.set("eventType", props.eventType);
|
||||
for (const [k, v] of Object.entries(props.eventFilters)) if (v) sp.set(k, v);
|
||||
}
|
||||
// Die Vorgaben stehen nicht zwingend in der Adresszeile; die Route
|
||||
// braucht sie aber, um dieselbe Auswertung zu bauen.
|
||||
sp.set("group", mode === "snapshot" ? props.group : props.eventGroup);
|
||||
if (mode === "snapshot") sp.set("measure", props.measure);
|
||||
return `/api/export/report?${sp.toString()}`;
|
||||
}
|
||||
|
||||
function fullExportHref(format: "csv" | "xlsx"): string {
|
||||
if (mode === "snapshot") {
|
||||
const sp = new URLSearchParams();
|
||||
sp.set("format", format);
|
||||
if (props.asOf) sp.set("asOf", props.asOf);
|
||||
if (props.filters.division) sp.set("division", props.filters.division);
|
||||
if (props.filters.location) sp.set("location", props.filters.location);
|
||||
if (props.filters.status) sp.set("status", props.filters.status);
|
||||
if (props.filters.employment) sp.set("employment", props.filters.employment);
|
||||
return `/api/export/employees?${sp.toString()}`;
|
||||
}
|
||||
const sp = new URLSearchParams();
|
||||
sp.set("format", format);
|
||||
if (props.eventType) sp.set("eventType", props.eventType);
|
||||
for (const [k, v] of Object.entries(props.eventFilters)) if (v) sp.set(k, v);
|
||||
return `/api/export/events?${sp.toString()}`;
|
||||
return exportHref(mode === "snapshot" ? "/api/export/employees" : "/api/export/events", format);
|
||||
}
|
||||
|
||||
const isAverage = mode === "snapshot" && AVERAGE_MEASURES.includes(props.measure);
|
||||
@@ -367,15 +513,19 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
|
||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Filter</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<select
|
||||
aria-label="Nach Bereich filtern"
|
||||
aria-label="Nach Organisationseinheit filtern"
|
||||
value={mode === "snapshot" ? props.filters.division : props.eventFilters.division}
|
||||
onChange={(e) => updateParams({ division: e.target.value })}
|
||||
className={CONTROL_CLASS}
|
||||
>
|
||||
<option value="">Alle Bereiche</option>
|
||||
{divisions.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
<option value="">Alle Organisationseinheiten</option>
|
||||
{units.map((u) => (
|
||||
// Einrückung mit geschützten Leerzeichen: ein <optgroup> je
|
||||
// Ebene ginge nicht, weil Einheiten dort auswählbar bleiben
|
||||
// müssen — eine Abteilung ist selbst ein Filterwert.
|
||||
<option key={u.id} value={u.id}>
|
||||
{" ".repeat(u.depth)}
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -410,19 +560,13 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<select
|
||||
aria-label="Nach Beschäftigungsart filtern"
|
||||
value={props.filters.employment}
|
||||
onChange={(e) => updateParams({ employment: e.target.value })}
|
||||
className={CONTROL_CLASS}
|
||||
>
|
||||
<option value="">Alle Beschäftigungsarten</option>
|
||||
<option value="Vollzeit">Vollzeit</option>
|
||||
<option value="Teilzeit">Teilzeit</option>
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mode === "snapshot" && (
|
||||
<KriterienBlock criteria={props.criteria} onChange={updateParams} onReset={resetKriterien} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-border bg-white p-4">
|
||||
|
||||
42
components/ui/AenderungsTabelle.tsx
Normal file
42
components/ui/AenderungsTabelle.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { AuditChange } from "@/lib/supabase/types";
|
||||
|
||||
// Was sich geändert hat, feldweise — im Protokoll und in der Historie einer
|
||||
// Person dieselbe Darstellung. Zwei Ansichten derselben Sache verschieden zu
|
||||
// setzen, hiesse dass jemand sie zweimal lesen lernen muss.
|
||||
|
||||
/** Leerer Wert heisst „war nicht gesetzt“ — und das ist eine Aussage. */
|
||||
function Wert({ text, art }: { text: string | null; art: "vorher" | "nachher" }) {
|
||||
if (text === null || text === "") {
|
||||
return <span className="text-ink-muted italic">leer</span>;
|
||||
}
|
||||
return <span className={art === "vorher" ? "text-ink-muted line-through decoration-ink-muted/40" : "text-ink"}>{text}</span>;
|
||||
}
|
||||
|
||||
export function AenderungsTabelle({ changes }: { changes: AuditChange[] }) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-[11px] font-bold uppercase tracking-wider text-ink-muted">
|
||||
<th className="py-2 pr-4">Feld</th>
|
||||
<th className="py-2 pr-4">Vorher</th>
|
||||
<th className="py-2">Nachher</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{changes.map((c, i) => (
|
||||
<tr key={`${c.feld}-${i}`} className="border-b border-border-subtle align-top last:border-0">
|
||||
<td className="py-2 pr-4 font-semibold text-ink-body">{c.feld}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<Wert text={c.vorher} art="vorher" />
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<Wert text={c.nachher} art="nachher" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,18 @@ function focusableWithin(container: HTMLElement): HTMLElement[] {
|
||||
export function useDialogFocus(open: boolean, onClose: () => void, containerRef: RefObject<HTMLElement | null>) {
|
||||
const restoreToRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// onClose wird von den meisten Aufrufern bei jedem Rendern neu erzeugt.
|
||||
// Stünde es in den Abhängigkeiten, liefe dieser Effekt nach jedem Rendern
|
||||
// erneut — und sein Aufräumen holt den Fokus dorthin zurück, wo er vor dem
|
||||
// Öffnen war. In einem Dialog mit Eingabefeldern hiesse das: nach dem
|
||||
// ersten Zeichen springt der Fokus auf den Knopf, der ihn geöffnet hat, und
|
||||
// der Rest der Eingabe geht ins Leere. Über eine Referenz bleibt der
|
||||
// aktuelle Rückruf erreichbar, ohne dass der Effekt daran hängt.
|
||||
const schliessen = useRef(onClose);
|
||||
useEffect(() => {
|
||||
schliessen.current = onClose;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const container = containerRef.current;
|
||||
@@ -39,7 +51,7 @@ export function useDialogFocus(open: boolean, onClose: () => void, containerRef:
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
schliessen.current();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab" || !container) return;
|
||||
@@ -74,5 +86,5 @@ export function useDialogFocus(open: boolean, onClose: () => void, containerRef:
|
||||
// there instead of restarting at the top of the page.
|
||||
restoreToRef.current?.focus();
|
||||
};
|
||||
}, [open, onClose, containerRef]);
|
||||
}, [open, containerRef]);
|
||||
}
|
||||
|
||||
45
deploy/Caddyfile
Normal file
45
deploy/Caddyfile
Normal file
@@ -0,0 +1,45 @@
|
||||
# Reverse Proxy für den Betrieb im Firmennetz.
|
||||
#
|
||||
# Der Name ist öffentlich (hr.elycon.solutions), die Adresse dahinter privat
|
||||
# (10.x). Das ist erlaubt und der übliche Weg: der DNS-Eintrag ist von aussen
|
||||
# auflösbar, der Server nicht erreichbar.
|
||||
#
|
||||
# Warum das der Aufwand wert ist: Entra ID akzeptiert `http` nur für
|
||||
# localhost. Ohne HTTPS gibt es keine Anmeldung — und ein selbst signiertes
|
||||
# Zertifikat müsste auf jedem Arbeitsplatz als vertrauenswürdig hinterlegt
|
||||
# werden. Mit der DNS-Challenge kommt ein regulär vertrauenswürdiges
|
||||
# Zertifikat zustande, ohne dass der Server je aus dem Internet erreichbar
|
||||
# sein muss.
|
||||
#
|
||||
# Voraussetzung: ein Caddy-Build mit dem DNS-Modul des eigenen Anbieters,
|
||||
# etwa
|
||||
# xcaddy build --with github.com/caddy-dns/cloudflare
|
||||
# Andere Anbieter siehe https://github.com/caddy-dns
|
||||
#
|
||||
# Der API-Schlüssel gehört in eine Umgebungsvariable des Dienstes, nicht in
|
||||
# diese Datei.
|
||||
|
||||
hr.elycon.solutions {
|
||||
tls {
|
||||
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
||||
}
|
||||
|
||||
# Die Anwendung lauscht nur auf der Loopback-Adresse (siehe
|
||||
# docker-compose.yml), erreichbar ist sie also ausschliesslich über
|
||||
# diesen Proxy.
|
||||
reverse_proxy 127.0.0.1:3000
|
||||
|
||||
# Ohne diese Weitergabe baut Auth.js seine Rückruf-Adresse aus dem
|
||||
# Container-Hostnamen statt aus dem echten Namen — die Anmeldung landet
|
||||
# dann auf einer Adresse, die niemand kennt. `trustHost` in
|
||||
# lib/auth/config.ts wertet genau diese Header aus.
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
header_up X-Forwarded-Host {host}
|
||||
|
||||
encode gzip zstd
|
||||
|
||||
log {
|
||||
output file /var/log/caddy/hr.log
|
||||
format json
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,12 @@ services:
|
||||
build:
|
||||
context: .
|
||||
restart: unless-stopped
|
||||
# Nur auf der Loopback-Adresse, nicht auf allen Schnittstellen. Erreichbar
|
||||
# ist die App damit ausschliesslich über den Reverse Proxy, der TLS
|
||||
# beendet — sonst stünde daneben derselbe Dienst unverschlüsselt offen,
|
||||
# und ein Fehler in der Firewall genügte.
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "127.0.0.1:3000:3000"
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ Supabase als Abhängigkeit entfernt.
|
||||
|
||||
| | Anzahl |
|
||||
|---|---|
|
||||
| RLS-Policies | 58 |
|
||||
| RLS-Policies | 21 |
|
||||
| `auth.uid()` / `auth.users` in Migrationen | 79 |
|
||||
| Fremdschlüssel auf `auth.users` | 9 |
|
||||
| Datenzugriffe in der App (`.from()`, `.rpc()`) | 50 |
|
||||
@@ -35,7 +35,7 @@ language sql security definer stable as $$
|
||||
$$;
|
||||
```
|
||||
|
||||
Alle 58 Policies rufen `is_hr_user()` auf. Wird hier die Herkunft der
|
||||
Alle 21 Policies rufen `is_hr_user()` auf. Wird hier die Herkunft der
|
||||
Benutzerkennung ausgetauscht, **bleiben alle Policies unverändert gültig**.
|
||||
Die Sicherheitsarchitektur wandert also *nicht* in den Anwendungscode — das
|
||||
war meine Sorge bei Variante B, und sie ist ausgeräumt.
|
||||
@@ -122,7 +122,7 @@ Typen aus dem Schema, womit auch der Schema-Drift-Prüfer überflüssig wird.
|
||||
|
||||
## Was bewusst gleich bleibt
|
||||
|
||||
- **Alle 58 RLS-Policies**, unverändert
|
||||
- **Alle 21 RLS-Policies**, unverändert
|
||||
- Das gesamte Schema samt Enums, Arrays, `jsonb`, PL/pgSQL, partiellen Indizes
|
||||
- `pgcrypto` und `pg_trgm` (beide auf Azure freigegeben)
|
||||
- Die Geschäftslogik in den RPCs
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# Datenmodell
|
||||
|
||||
> **Veraltet — siehe `docs/datenkatalog.md`.**
|
||||
>
|
||||
> Dieses Dokument beschreibt den Stand vor zwei Umbauten und stimmt in
|
||||
> wesentlichen Teilen nicht mehr:
|
||||
>
|
||||
> - Die Org-Tabellen `divisions` / `departments` / `teams` und die Tabelle
|
||||
> `positions` gibt es nicht mehr. An ihrer Stelle steht das SAP-OM-Modell
|
||||
> (`org_units`, `jobs`, `om_positions`, `position_assignments`) mit
|
||||
> zeitabhängigen Zuordnungen.
|
||||
> - Supabase Auth, der anon key und die Service-Role sind weg. Angemeldet
|
||||
> wird über Auth.js gegen Entra ID, die Konten stehen in `app_users`, und
|
||||
> der Zugriff läuft über eine Rolle ohne `BYPASSRLS`.
|
||||
> - Die Zahl der RLS-Policies ist 21, nicht 58.
|
||||
>
|
||||
> Der Datenkatalog wurde aus der laufenden Datenbank erzeugt und gilt. Was
|
||||
> hier noch stimmt — die Grundprinzipien und der Abschnitt zu den
|
||||
> effective-dated changes — steht dort ebenfalls.
|
||||
|
||||
Beschreibt das tatsächliche Supabase-Schema (siehe `supabase/migrations/`),
|
||||
nicht ein generisches HR-Schema. Quelle der Wahrheit sind immer die
|
||||
Migrationen; dieses Dokument ist eine lesbare Zusammenfassung und wird bei
|
||||
|
||||
421
docs/datenkatalog.md
Normal file
421
docs/datenkatalog.md
Normal file
@@ -0,0 +1,421 @@
|
||||
# Datenkatalog
|
||||
|
||||
Jede Tabelle, jede Spalte, jede Regel — ausgelesen aus der laufenden
|
||||
Datenbank am **13.08.2026**.
|
||||
|
||||
Die Wahrheit steht in `supabase/migrations/` (48 Dateien). Dieses Dokument
|
||||
ist eine lesbare Fassung davon und wurde nicht abgetippt, sondern aus dem
|
||||
Systemkatalog erzeugt: Spaltentypen, Vorgabewerte, Schlüssel und
|
||||
Prüfbedingungen stammen aus `information_schema` und `pg_catalog`. Wo unten
|
||||
eine Regel in Worten steht, steht daneben, aus welcher `CHECK`-Bedingung sie
|
||||
kommt.
|
||||
|
||||
> **Nicht verwechseln mit `docs/data-model.md`.** Das Dokument beschreibt den
|
||||
> Stand vor der Umstellung auf das SAP-OM-Modell und auf Auth.js — es nennt
|
||||
> Tabellen (`divisions`, `departments`, `teams`, `positions`), die es nicht
|
||||
> mehr gibt. Bei Widerspruch gilt dieser Katalog.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Tabellen | 15 |
|
||||
| Spalten | 142 |
|
||||
| Aufzählungstypen | 10 |
|
||||
| Sichten (Views) | 0 |
|
||||
| Eigene SQL-Funktionen | 38 (plus 31 aus der Erweiterung `pg_trgm`) |
|
||||
| RLS-Policies | 21, auf jeder Tabelle mindestens eine |
|
||||
|
||||
---
|
||||
|
||||
## Wie das Modell gedacht ist
|
||||
|
||||
Drei Entscheidungen erklären fast jede Eigenheit weiter unten.
|
||||
|
||||
**Person und Planstelle sind zwei Dinge.** Eine Person (`employees`) hat
|
||||
keine Spalte „Abteilung". Sie sitzt auf einer Planstelle (`om_positions`),
|
||||
und die Planstelle hängt in einer Organisationseinheit (`org_units`). Wo
|
||||
jemand arbeitet, ist damit nicht ein Feld, sondern ein Weg über zwei
|
||||
Tabellen. Der Preis ist ein Join; dafür lässt sich eine Planstelle
|
||||
ausschreiben, bevor jemand darauf sitzt, und eine Person wechseln, ohne dass
|
||||
die Stelle verschwindet.
|
||||
|
||||
**Zuordnungen haben ein Von und ein Bis.** `position_assignments`,
|
||||
`om_positions` und `org_units` tragen alle `valid_from` / `valid_to` als
|
||||
halboffenes Intervall: `valid_from` gehört dazu, `valid_to` nicht mehr. Eine
|
||||
Zuordnung, die heute endet, hat `valid_to = heute` und gilt heute schon nicht
|
||||
mehr. Deshalb kann jede Auswertung einen Stichtag haben, auch einen in der
|
||||
Vergangenheit, und deshalb ist eine Versetzung zum Ersten des nächsten Monats
|
||||
kein Termin im Kalender, sondern eine Zeile, die erst dann greift.
|
||||
|
||||
**Geschichte wird geschrieben, nicht überschrieben.** `employee_history` und
|
||||
`audit_log` haben keine Update- und keine Delete-Policy — RLS lässt nur
|
||||
`select` und `insert` zu. Eine Korrektur ist ein neuer Eintrag, nie eine
|
||||
geänderte Zeile.
|
||||
|
||||
Zwei Ausnahmen gibt es, und sie sind eng: `delete_history_entry` nimmt eine
|
||||
irrtümlich erfasste Stammdaten- oder Vertragsänderung samt ihrer Wirkung
|
||||
zurück, `update_history_entry` berichtigt Wert und Datum einer solchen. Die
|
||||
Policies bleiben dabei unangetastet — beide laufen als `SECURITY DEFINER` an
|
||||
ihnen vorbei und prüfen die Berechtigung selbst. Das Protokoll behält den
|
||||
Vorgang, dort verschwindet nichts.
|
||||
|
||||
Die Namen der OM-Tabellen sind nicht zufällig gewählt: `org_units` ist der
|
||||
SAP-Objekttyp O, `jobs` ist C, `om_positions` ist S, `employees` ist P, und
|
||||
`position_assignments` ist die Verknüpfung A008 („Inhaber ist"). Das Flag
|
||||
`is_chief` entspricht A012 („ist Leiter von"). Wer das Modell aus SAP kennt,
|
||||
findet sich wieder; wer nicht, verliert nichts.
|
||||
|
||||
```
|
||||
org_units ──parent_id──┐ (rekursiv: Gesellschaft › Bereich › Abteilung › Team)
|
||||
│ │
|
||||
└──────────────────┘
|
||||
▲
|
||||
│ org_unit_id
|
||||
om_positions ──job_id──► jobs
|
||||
▲
|
||||
│ position_id
|
||||
position_assignments ──employee_id──► employees ──location_id──► locations
|
||||
▲
|
||||
├── employee_history (Ereignisse)
|
||||
├── employee_dependents (Angehörige)
|
||||
├── employee_notes (HR-Notizen)
|
||||
├── pending_org_changes (wirkt später)
|
||||
└── audit_log (wer/wann/was)
|
||||
|
||||
app_users ──1:1──► profiles (Anmeldung ist nicht Berechtigung)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Personen
|
||||
|
||||
### `employees` — 46 Spalten
|
||||
|
||||
Die Person selbst: Stammdaten, Vertrag, Status. **Nicht** die Organisation —
|
||||
die kommt über die Planstelle.
|
||||
|
||||
| Spalte | Typ | Null | Vorgabe | Bedeutung |
|
||||
|---|---|---|---|---|
|
||||
| `id` | uuid | – | `gen_random_uuid()` | Schlüssel |
|
||||
| `personnel_number` | int4 | – | – | **Eindeutig.** Wird eingegeben, nicht vergeben — sie muss mit Loga/Interflex übereinstimmen |
|
||||
| `first_name`, `last_name` | text | – | – | |
|
||||
| `gender` | `gender_type` | – | – | `m` / `w` |
|
||||
| `birth_date` | date | – | – | |
|
||||
| `sv_nummer` | text | ja | – | Österreichische SV-Nummer; gegen Prüfziffer *und* Geburtsdatum geprüft (Trigger `fn_validate_employee_svnr`) |
|
||||
| `nationality` | text | – | `'Österreich'` | |
|
||||
| `address`, `postal_code`, `city`, `address_country` | text | ja | – | Wohnanschrift |
|
||||
| `email` | text | ja | – | **Private** E-Mail. Freiwillig, aber eindeutig, wenn angegeben |
|
||||
| `phone` | text | ja | – | **Private** Telefonnummer. Freiwillig |
|
||||
| `job_title` | text | – | – | Anzeigetitel. Der verbindliche Titel steht am Job der Planstelle |
|
||||
| `location_id` | uuid | – | – | → `locations` |
|
||||
| `employment_type` | `employment_type` | – | `Vollzeit` | |
|
||||
| `weekly_hours` | numeric | – | `38.5` | An `employment_type` gekoppelt, siehe Regeln |
|
||||
| `contract_type` | `contract_type` | – | `unbefristet` | |
|
||||
| `contract_end_date` | date | ja | – | Pflicht bei `befristet` |
|
||||
| `worker_type` | `worker_type` | – | `Angestellte:r` | |
|
||||
| `collective_agreement` | `collective_agreement` | – | `Handel` | |
|
||||
| `paygrade` | `paygrade_type` | – | `B` | A–F |
|
||||
| `source` | `source_type` | – | `Extern` | Intern besetzt oder extern geholt |
|
||||
| `work_days` | text[] | – | `{Mo,Di,Mi,Do,Fr}` | In Klickreihenfolge gespeichert, nicht sortiert |
|
||||
| `status` | `employment_status` | – | `Aktiv` | Gilt für **heute**; für einen Stichtag wird er zurückgerechnet |
|
||||
| `entry_date` | date | – | – | |
|
||||
| `exit_date`, `exit_reason` | date/text | ja | – | |
|
||||
| `karenz_start_date`, `karenz_return_date` | date | ja | – | Laufende Langzeitabwesenheit |
|
||||
| `absence_type` | text | ja | – | Art der Abwesenheit; wird bei der Rückkehr geleert. 13 erlaubte Werte |
|
||||
| `is_betriebsrat`, `is_laterale_fuehrung`, `is_c_level` | bool | – | `false` | |
|
||||
| `has_dienstwagen` | bool | – | `false` | |
|
||||
| `dienstwagen_art` | text | ja | – | `Verbrenner` / `Elektro`; nur zusammen mit `has_dienstwagen` |
|
||||
| `emergency_contact_name`, `_phone`, `_relation` | text | ja | – | Daten einer dritten Person, nur für den Notfall erhoben |
|
||||
| `title_prefix`, `title_suffix` | text[] | – | `{}` | Akademische Grade, gegen feste Listen geprüft |
|
||||
| `avatar_color` | text | ja | – | Darstellung |
|
||||
| `monthly_salary_gross` | numeric | ja | – | **Stillgelegt.** Gehalt ist ausserhalb des Funktionsumfangs; keine Funktion liest oder schreibt die Spalte mehr. Steht nur noch da, falls Altdaten drin sind |
|
||||
| `created_at`, `updated_at` | timestamptz | – | `now()` | `updated_at` per Trigger |
|
||||
|
||||
**Regeln, die die Datenbank durchsetzt:**
|
||||
|
||||
- *Vollzeit heisst 38,5 Stunden; Teilzeit heisst mehr als 0 und weniger als
|
||||
38,5.* Ein Vollzeitvertrag mit 30 Stunden lässt sich nicht speichern
|
||||
(`chk_weekly_hours`).
|
||||
- *Befristet ohne Enddatum gibt es nicht* (`chk_befristet_end`).
|
||||
- *Austritt nicht vor Eintritt*, *Rückkehr nicht vor Eintritt*
|
||||
(`chk_exit_after_entry`, `chk_karenz_return_after_entry`).
|
||||
- *Dienstwagen und Antriebsart gehören zusammen* — beides oder keins
|
||||
(`chk_dienstwagen_art`). Die Bedingung nennt den Fall ohne Wagen
|
||||
ausdrücklich, weil `art in (…)` bei `null` weder wahr noch falsch ergibt
|
||||
und die Regel sonst genau das durchgelassen hätte, was sie verhindern soll.
|
||||
- *Notfallkontakt: Name und Telefon gemeinsam oder gar nicht*, und keiner der
|
||||
beiden leer (`chk_emergency_contact`).
|
||||
- *Arbeitstage nur aus Mo–So und mindestens einer* (`chk_work_days_valid`).
|
||||
- *Titel nur aus den bekannten Listen* — 10 vorangestellte, 12 nachgestellte
|
||||
(`chk_title_prefix_valid`, `chk_title_suffix_valid`).
|
||||
- *Abwesenheitsart nur aus den 13 bekannten* (`chk_absence_type`).
|
||||
|
||||
### `employee_history` — die Zeitleiste
|
||||
|
||||
Eine Zeile je Ereignis: `employee_id`, `event_date`, `event_type`
|
||||
(Aufzählung, 11 Werte), `description`. Ein Trigger verhindert Ereignisse vor
|
||||
dem Eintrittsdatum (`fn_check_history_not_before_entry`). Nur einfügen und
|
||||
lesen — kein Ändern, kein Löschen.
|
||||
|
||||
### `employee_dependents` — Angehörige
|
||||
|
||||
`first_name`, `last_name`, `relationship` (Ehepartner:in / Lebenspartner:in /
|
||||
Kind / Sonstige), `birth_date`, optional `sv_nummer`. Ändern heisst löschen
|
||||
und neu anlegen; ein In-place-Update gibt es nicht. Beim Löschen der Person
|
||||
verschwinden sie mit (`on delete cascade`).
|
||||
|
||||
### `employee_notes` — HR-Notizen
|
||||
|
||||
`category` (Allgemein / Vertraulich / Personalgespräch / Wiedervorlage /
|
||||
Lob / Anerkennung), `note_text`, optional `due_date` für die Wiedervorlage,
|
||||
dazu `done` / `done_at` / `done_by`.
|
||||
|
||||
Bewusst **nicht** auf die verfassende Person eingeschränkt: jede aktive
|
||||
HR-Person sieht jede offene Notiz. „Meine Notizen" ist ein gemeinsames
|
||||
Postfach, kein privates.
|
||||
|
||||
---
|
||||
|
||||
## Organisation
|
||||
|
||||
### `org_units` — Einheiten (SAP-Objekttyp O)
|
||||
|
||||
`org_number` (eindeutig), `name`, `parent_id` (rekursiv), `unit_type`
|
||||
(Gesellschaft / Bereich / Abteilung / Team), `valid_from` / `valid_to`.
|
||||
|
||||
`unit_type` ist ein Etikett für die Anzeige, keine Struktur — die Struktur
|
||||
ist `parent_id`. Eine Abteilung unter einer Abteilung wäre erlaubt. Was die
|
||||
Datenbank verhindert, ist nur, dass eine Einheit ihr eigenes Elternteil wird
|
||||
(`chk_org_unit_not_own_parent`); tiefere Zyklen fängt sie nicht ab.
|
||||
|
||||
### `jobs` — Tätigkeiten (Objekttyp C)
|
||||
|
||||
`code` und `title`, beide eindeutig. Ein schlanker Katalog: die Planstelle
|
||||
verweist darauf, statt den Titel abzuschreiben.
|
||||
|
||||
### `om_positions` — Planstellen (Objekttyp S)
|
||||
|
||||
`position_number` (eindeutig), `org_unit_id`, `job_id`, `is_chief`,
|
||||
`valid_from` / `valid_to`.
|
||||
|
||||
Die Nummern haben die Form `6` + sieben Ziffern, weil
|
||||
`next_position_number()` sie so erzeugt — erzwungen wird das Format aber
|
||||
nicht: an der Spalte hängt nur Eindeutigkeit. Wer von aussen eine Nummer
|
||||
einträgt, kann eine andere Form wählen, und die Zählfunktion übergeht sie
|
||||
dann (sie sucht ihr Maximum nur unter `^6[0-9]{7}$`).
|
||||
|
||||
`is_chief` markiert die Leitungsstelle einer Einheit — daraus entsteht die
|
||||
Führungslinie, nicht aus einem Feld „Vorgesetzte:r" an der Person. Eine
|
||||
Planstelle lässt sich nur besetzen, solange sie gültig ist.
|
||||
|
||||
### `position_assignments` — Besetzungen (Verknüpfung A008)
|
||||
|
||||
`position_id`, `employee_id`, `valid_from` / `valid_to`. Diese Tabelle
|
||||
beantwortet „wer sass wann wo" — die einzige Stelle, an der das steht.
|
||||
|
||||
### `locations` — Standorte
|
||||
|
||||
`name` (eindeutig) und `country`, beschränkt auf Österreich, Deutschland,
|
||||
Tschechien und Slowenien.
|
||||
|
||||
---
|
||||
|
||||
## Ablauf und Nachweis
|
||||
|
||||
### `pending_org_changes` — was später wirkt
|
||||
|
||||
`change_type` (transfer / promotion / karenz_start / karenz_return /
|
||||
contract_change / reorg / dependent_add / dependent_remove),
|
||||
`effective_date`, `payload` (JSONB), `status` (pending / applied /
|
||||
cancelled).
|
||||
|
||||
Der Weg ist `pending` → `applied`. Die Auswahl filtert immer auf `pending`,
|
||||
ein zweiter Lauf wirkt also nicht doppelt. Verarbeitet wird täglich von
|
||||
`apply_due_pending_changes()`.
|
||||
|
||||
### `audit_log` — wer, wann, was, an wem
|
||||
|
||||
`occurred_at`, `actor_user_id` + `actor_name`, `action`, `target_label`,
|
||||
`target_employee_id`, `details`, und `changes` als JSONB in der Form
|
||||
`[{feld, vorher, nachher}]` — daher die aufklappbare Detailansicht in der
|
||||
Oberfläche. Bei Einträgen von vor der entsprechenden Migration ist `changes`
|
||||
null.
|
||||
|
||||
Geschrieben wird ausschliesslich aus den SQL-Funktionen heraus, in derselben
|
||||
Transaktion wie die Änderung selbst. Das ist der Punkt: ein fehlgeschlagener
|
||||
Log-Eintrag lässt die ganze Änderung scheitern, statt still eine Änderung
|
||||
ohne Nachweis zu hinterlassen. Einen Helfer im Anwendungscode gibt es nicht
|
||||
und sollte es nicht geben — das wäre eine zweite, nicht atomare Quelle.
|
||||
|
||||
Dass `actor_name` als Text mitgeschrieben wird und nicht nur die Kennung: der
|
||||
Nachweis soll lesbar bleiben, auch wenn das Benutzerkonto später verschwindet.
|
||||
|
||||
---
|
||||
|
||||
## Zugang
|
||||
|
||||
### `app_users`
|
||||
|
||||
Ersetzt `auth.users` aus der Supabase-Zeit. `external_id` ist die `oid` aus
|
||||
Entra ID — **nicht** die E-Mail-Adresse, die kann sich ändern. Angelegt wird
|
||||
die Zeile bei der ersten Anmeldung durch `app_upsert_user()`.
|
||||
|
||||
### `profiles`
|
||||
|
||||
Eine Zeile je Konto, gleicher Schlüssel wie `app_users`. `role` ist per
|
||||
Prüfbedingung auf den einen Wert `'hr'` festgenagelt, `is_active` steht
|
||||
anfangs auf `false`.
|
||||
|
||||
**Anmelden können heisst nichts.** Wer sich mit dem Firmenkonto anmeldet,
|
||||
bekommt eine `app_users`-Zeile und kommt trotzdem an keine einzige
|
||||
Personalzeile, solange `profiles.is_active` nicht gesetzt ist. Die Freigabe
|
||||
ist ein bewusster zweiter Schritt.
|
||||
|
||||
### `hire_drafts`, `saved_reports`
|
||||
|
||||
Zwischenstand des Einstellungsassistenten (`payload` JSONB, `step`) und
|
||||
gespeicherte Berichtskonfigurationen. Beide sind auf die anlegende Person
|
||||
eingeschränkt.
|
||||
|
||||
---
|
||||
|
||||
## Aufzählungstypen
|
||||
|
||||
| Typ | Werte |
|
||||
|---|---|
|
||||
| `employment_status` | Aktiv, Karenz, Geplant, Ausgetreten |
|
||||
| `employment_type` | Vollzeit, Teilzeit |
|
||||
| `contract_type` | unbefristet, befristet |
|
||||
| `worker_type` | Angestellte:r, Arbeiter:in |
|
||||
| `collective_agreement` | Handel, Süßwaren |
|
||||
| `paygrade_type` | A, B, C, D, E, F |
|
||||
| `source_type` | Intern, Extern |
|
||||
| `gender_type` | m, w |
|
||||
| `org_unit_type` | Gesellschaft, Bereich, Abteilung, Team |
|
||||
| `history_event_type` | Eintritt, Beförderung, Versetzung, Karenz, Vertragsänderung, Stammdatenänderung, Austritt, Wiedereintritt, Reorganisation, Gehaltsanpassung, Rückkehr |
|
||||
|
||||
`Karenz` heisst in der Oberfläche „Langzeitabwesenheit" — der gespeicherte
|
||||
Wert wurde beim Umbenennen bewusst nicht angefasst, die Beschriftung folgt
|
||||
dem neuen Begriff.
|
||||
|
||||
Nicht als Aufzählungstyp, sondern als Prüfbedingung auf einer Textspalte
|
||||
gelöst: Abwesenheitsart, Antriebsart, Verhältnis von Angehörigen,
|
||||
Notizkategorie, Änderungsart, Land. Der praktische Unterschied: eine
|
||||
Prüfbedingung lässt sich in einer Migration ändern, ein Aufzählungstyp nur
|
||||
erweitern.
|
||||
|
||||
---
|
||||
|
||||
## Die SQL-Schnittstelle
|
||||
|
||||
Änderungen laufen nicht über `insert`/`update` aus der Anwendung, sondern
|
||||
über Funktionen. Jede schreibt ihren Nachweis und ihre Historie in derselben
|
||||
Transaktion mit.
|
||||
|
||||
**Personal:** `hire_employee`, `rehire_employee`, `terminate_employee`,
|
||||
`transfer_employee`, `promote_employee`, `change_employee_data`,
|
||||
`start_karenz`, `adjust_karenz_return`, `record_karenz_return`
|
||||
|
||||
**Planstellen:** `create_position`, `update_position`, `delete_position`,
|
||||
`next_position_number`
|
||||
|
||||
**Historie:** `delete_history_entry` nimmt eine irrtümliche Stammdaten- oder
|
||||
Vertragsänderung zurück: setzt je Feld auf den Wert davor, sofern kein
|
||||
späterer Eintrag dasselbe Feld angefasst hat, und entfernt die Zeile.
|
||||
`update_history_entry` berichtigt stattdessen Wert und Datum — das „vorher"
|
||||
bleibt unangetastet, und der heutige Stand wird je Feld aus dem jüngsten
|
||||
Eintrag abgeleitet, der es trägt. Beide sind der einzige Weg an den fehlenden
|
||||
`update`- und `delete`-Policies vorbei, deshalb `SECURITY DEFINER` und mit
|
||||
`require_hr_admin()` davor. `app_feld_karte` liefert beiden die Zuordnung
|
||||
Beschriftung → Spalte und Typ.
|
||||
|
||||
**Umfeld:** `add_employee_dependent`, `delete_employee_dependent`,
|
||||
`add_employee_note`, `complete_employee_note`
|
||||
|
||||
**Auswertung:** `om_reporting_lines(p_as_of)` — löst zum Stichtag auf, wer an
|
||||
wen berichtet, samt Vertretung bei Abwesenheit (`acting_manager_id` neben
|
||||
`formal_manager_id`)
|
||||
|
||||
**Zugang und Nachweis:** `is_hr_user`, `app_current_user_id`,
|
||||
`app_upsert_user`, `current_actor_name`, `app_aenderung`,
|
||||
`app_aenderungsfelder`
|
||||
|
||||
**Automatik:** `apply_due_pending_changes` (täglich), `rls_auto_enable`
|
||||
(hängt am Ereignis-Trigger `ensure_rls`: neue Tabellen bekommen sofort RLS),
|
||||
die vier `fn_*`-Trigger, `is_valid_svnr`
|
||||
|
||||
Sechs Funktionen laufen als `SECURITY DEFINER`, also mit den Rechten ihrer
|
||||
Eigentümerin statt der aufrufenden Person: `is_hr_user`,
|
||||
`app_current_user_id`, `app_upsert_user`, `apply_due_pending_changes`,
|
||||
`delete_history_entry` und `update_history_entry`. Die ersten drei müssen es
|
||||
sein, weil sie sonst gegen dieselben Policies liefen, die sie gerade auswerten
|
||||
sollen — eine Rekursion. Die vierte läuft ohne angemeldete Person, es gibt ja
|
||||
nur den Zeitplan. Die letzten beiden müssen an `employee_history` schreiben,
|
||||
wo es absichtlich weder eine `update`- noch eine `delete`-Policy gibt; beide
|
||||
prüfen die Berechtigung deshalb selbst, in ihrer ersten Zeile.
|
||||
|
||||
**Der Türsteher:** `require_hr_admin()` steht am Anfang von **16**
|
||||
Funktionen — jeder ändernden. Es wirft, wenn `is_hr_user()` falsch ist, und
|
||||
liefert damit eine lesbare Meldung statt einer nackten RLS-Verletzung. Der
|
||||
Name täuscht: ein Admin-Rollenmodell gibt es nicht, `is_hr_admin()` ruft
|
||||
schlicht `is_hr_user()` auf. Die Schranke selbst bleiben die Policies.
|
||||
|
||||
**Übrig geblieben:** `generate_company_email` steht noch in der Datenbank,
|
||||
wird aber von nichts mehr gerufen — sie stammt aus der Zeit, als eine
|
||||
Firmenadresse automatisch vergeben wurde; heute ist `employees.email` die
|
||||
private Adresse und freiwillig.
|
||||
|
||||
---
|
||||
|
||||
## Zugriffsschutz
|
||||
|
||||
Auf **jeder** der 15 Tabellen ist Row Level Security aktiv, zusammen 21
|
||||
Policies. Fast alle prüfen dasselbe: `is_hr_user()` — also `profiles.role =
|
||||
'hr'` **und** `is_active`. Nachgereicht wird das nicht: der Ereignis-Trigger
|
||||
`ensure_rls` schaltet RLS bei jeder neu angelegten Tabelle sofort ein.
|
||||
|
||||
Die Prüfung hängt an einer Sitzungsvariablen (`app.user_id`), die
|
||||
`withUser()` als erste Anweisung jeder Transaktion setzt — transaktionslokal,
|
||||
damit sie nicht an der Verbindung kleben bleibt und die nächste Anfrage aus
|
||||
dem Pool mit fremder Kennung läuft.
|
||||
|
||||
Wie wirksam das ist, zeigt sich beim Erzeugen dieses Katalogs: die Verbindung
|
||||
lief ohne Sitzungskontext, und **jede** Tabelle lieferte null Zeilen — bei
|
||||
vollständig vorhandenen Strukturdaten. Nicht „alles", nicht ein Fehler,
|
||||
sondern nichts. Die Anwendung verbindet sich ausserdem als `alpenwerk_app` —
|
||||
eine Rolle ohne `BYPASSRLS` und ohne Superuser-Recht; es gibt also keinen Weg
|
||||
daran vorbei, auch nicht versehentlich.
|
||||
|
||||
Wo das Muster abweicht:
|
||||
|
||||
- `audit_log` und `employee_history` haben je zwei Policies — lesen und
|
||||
einfügen, getrennt, und kein Ändern oder Löschen. Das ist die
|
||||
Unveränderlichkeit, technisch durchgesetzt.
|
||||
- `profiles` hat vier, weil dort auch die Freischaltung anderer Konten
|
||||
passiert — und eine davon lässt jede Person die *eigene* Zeile lesen, auch
|
||||
ohne Freischaltung. Sonst könnte niemand erfahren, warum er nicht
|
||||
hineinkommt.
|
||||
- `app_users` ebenso: die eigene Zeile oder HR.
|
||||
- `hire_drafts` und `saved_reports` verlangen zusätzlich, dass die Zeile der
|
||||
anfragenden Person gehört.
|
||||
- `locations` trennt Lesen und Schreiben in zwei Policies, prüft aber beide
|
||||
Male dasselbe.
|
||||
|
||||
---
|
||||
|
||||
## Was hier nicht steht
|
||||
|
||||
**Gehalt.** `monthly_salary_gross` ist stillgelegt und wird von keiner
|
||||
Funktion mehr angefasst. Gehaltsdaten leben in Loga.
|
||||
|
||||
**Zeitwirtschaft.** Kommen und Gehen, Urlaubskonten, Krankenstände als
|
||||
Einzelfälle — das ist Interflex. Hier steht nur die dauerhafte
|
||||
Langzeitabwesenheit, weil sie die Führungslinie verschiebt.
|
||||
|
||||
**Bewerbungen.** Eine offene Planstelle ist hier eine Planstelle ohne
|
||||
Besetzung, mehr nicht.
|
||||
|
||||
---
|
||||
|
||||
*Erzeugt aus dem Systemkatalog der laufenden Datenbank. Nach strukturellen
|
||||
Änderungen gehört dieses Dokument nachgezogen — am ehrlichsten, indem es neu
|
||||
aus der Datenbank erzeugt wird, statt es von Hand zu pflegen.*
|
||||
@@ -6,7 +6,7 @@ keinen Anmeldedienst eines Anbieters mehr dazwischen.
|
||||
|
||||
**Warum das trotzdem eine kleine Änderung ist:** die Anmeldung liefert nach wie
|
||||
vor nur eine UUID. `profiles.id` trägt weiterhin `role` und `is_active`, und
|
||||
damit bleiben `is_hr_user()` und alle 58 RLS-Policies unverändert gültig. Die
|
||||
damit bleiben `is_hr_user()` und alle 21 RLS-Policies unverändert gültig. Die
|
||||
Sicherheitsgrenze wandert nicht in den Anwendungscode.
|
||||
|
||||
## Einrichtung im Entra-Mandanten
|
||||
|
||||
@@ -7,18 +7,24 @@ import type { EmploymentStatus } from "./supabase/types";
|
||||
// a label change (see supabase/migrations/*_absence_type.sql). So the value
|
||||
// is mapped to its display name here, in the one place the UI reads it from.
|
||||
|
||||
// Nur echte Abwesenheiten. Eine Teilzeit ist keine: wer in Bildungs-,
|
||||
// Pflege-, Eltern- oder Wiedereingliederungsteilzeit ist, arbeitet — nur
|
||||
// weniger. Als „Langzeitabwesenheit" geführt, verschwand die Person aus dem
|
||||
// Bestand, verlor ihre Berichtslinie an eine Vertretung und zählte in
|
||||
// Auswertungen nicht mehr mit, obwohl sie jede Woche im Haus war.
|
||||
//
|
||||
// Die vier sind deshalb hierher gewandert, wo sie hingehören: zur
|
||||
// Stundenänderung (siehe STUNDEN_GRUENDE) und zur Rückkehr aus einer
|
||||
// Abwesenheit (RUECKKEHR_GRUENDE).
|
||||
export const ABSENCE_TYPES = [
|
||||
"Wochenhilfe (Mutterschutz)",
|
||||
"Elternkarenz (inkl. Väterkarenz)",
|
||||
"Papamonat",
|
||||
"Bildungskarenz",
|
||||
"Bildungsteilzeit",
|
||||
"Präsenzdienst",
|
||||
"Zivildienst",
|
||||
"Langer Krankenstand",
|
||||
"Wiedereingliederungsteilzeit",
|
||||
"Pflegekarenz",
|
||||
"Pflegeteilzeit",
|
||||
"Familienhospizkarenz",
|
||||
"Sabbatical",
|
||||
] as const;
|
||||
@@ -29,6 +35,25 @@ export function isAbsenceType(value: string | null | undefined): value is Absenc
|
||||
return ABSENCE_TYPES.includes(value as AbsenceType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Warum jemand nach einer Abwesenheit mit weniger Stunden zurückkommt.
|
||||
*
|
||||
* Beides sind Ansprüche, keine freien Vereinbarungen — und beide beginnen
|
||||
* typischerweise genau dann, wenn die Abwesenheit endet.
|
||||
*/
|
||||
export const RUECKKEHR_GRUENDE = ["Wiedereingliederungsteilzeit", "Elternteilzeit"] as const;
|
||||
|
||||
/**
|
||||
* Warum sich die Wochenstunden ändern.
|
||||
*
|
||||
* Der erste Wert ist der Normalfall; die beiden anderen sind gesetzlich
|
||||
* geregelte Teilzeiten, die man später wiederfinden können muss — im
|
||||
* Zweifel Jahre danach, wenn jemand fragt, warum die Stunden damals sanken.
|
||||
*/
|
||||
export const STUNDEN_GRUENDE = ["Vertragliche Stundenänderung", "Bildungsteilzeit", "Pflegeteilzeit"] as const;
|
||||
|
||||
export type StundenGrund = (typeof STUNDEN_GRUENDE)[number];
|
||||
|
||||
/** Display names for the stored status values. */
|
||||
const STATUS_LABELS: Record<EmploymentStatus, string> = {
|
||||
Aktiv: "Aktiv",
|
||||
|
||||
@@ -11,7 +11,7 @@ import { auth } from "@/auth";
|
||||
// zwischen beiden macht app_upsert_user() bei der Anmeldung, und sie
|
||||
// übernimmt für eine bereits bekannte Adresse die vorhandene profiles.id.
|
||||
// Deshalb passt die Kennung weiterhin auf das, was app_current_user_id() in
|
||||
// der Datenbank erwartet, und die 58 RLS-Policies merken vom Wechsel nichts.
|
||||
// der Datenbank erwartet, und die 21 RLS-Policies merken vom Wechsel nichts.
|
||||
|
||||
export async function currentUserId(): Promise<string | null> {
|
||||
const session = await auth();
|
||||
|
||||
107
lib/cost-centers.ts
Normal file
107
lib/cost-centers.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { Tx } from "./db";
|
||||
import { jsonArrayFrom } from "./db/json";
|
||||
import { todayIso } from "./format";
|
||||
import type { OrgEb } from "./org";
|
||||
|
||||
// Die Kostenstelle einer Planstelle — zum Stichtag, nicht „aktuell".
|
||||
//
|
||||
// Sie hängt an der Planstelle, nicht an der Person: der Sitz kostet Geld, auch
|
||||
// wenn niemand darauf sitzt. Genau das ist die Frage bei einer Vakanz.
|
||||
//
|
||||
// Und sie hat einen Zeitraum, weil eine Umkontierung ein Ereignis mit Stichtag
|
||||
// ist. Wer nach den Kosten des Vorjahres fragt, muss die Kostenstelle von
|
||||
// damals bekommen — sonst verändert jede Umkontierung rückwirkend jede alte
|
||||
// Auswertung, und das merkt niemand.
|
||||
|
||||
export type Kostenstelle = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type KontierungsZeile = {
|
||||
position_id: string;
|
||||
cost_center_id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
valid_from: string;
|
||||
valid_to: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Die Kontierungen als *Teilabfrage* — zum Einhängen in die eine Abfrage, die
|
||||
* eine Seite ohnehin stellt (lib/db/json.ts).
|
||||
*
|
||||
* Ohne `positionIds` alle. Gefiltert wird nicht nach Stichtag: die Auswahl
|
||||
* trifft `kontierungZum` im Speicher, damit dieselben Zeilen für mehrere
|
||||
* Stichtage reichen und die Abfrage eine bleibt.
|
||||
*/
|
||||
export function kontierungenAbfrage(eb: OrgEb, positionIds?: string[]) {
|
||||
const q = eb
|
||||
.selectFrom("position_cost_centers as z")
|
||||
.innerJoin("cost_centers as k", "k.id", "z.cost_center_id")
|
||||
.select(["z.position_id", "z.cost_center_id", "k.code", "k.name", "z.valid_from", "z.valid_to"])
|
||||
.orderBy("z.position_id")
|
||||
.orderBy("z.valid_from");
|
||||
|
||||
return positionIds ? q.where("z.position_id", "in", positionIds) : q;
|
||||
}
|
||||
|
||||
/** Alle Kostenstellen zur Auswahl — abgelaufene bleiben draussen. */
|
||||
export function kostenstellenAbfrage(eb: OrgEb, asOf: string = todayIso()) {
|
||||
return eb
|
||||
.selectFrom("cost_centers")
|
||||
.select(["id", "code", "name"])
|
||||
.where((e) => e.or([e("valid_to", "is", null), e("valid_to", ">", asOf)]))
|
||||
.orderBy("code");
|
||||
}
|
||||
|
||||
/**
|
||||
* Welche Kostenstelle je Planstelle am Stichtag galt.
|
||||
*
|
||||
* Halboffen [valid_from, valid_to): der letzte Tag gehört schon zur nächsten
|
||||
* Kontierung. Dieselbe Regel wie bei den Besetzungen — eine zweite Auslegung
|
||||
* desselben Zeitraummodells wäre der sichere Weg zu zwei Antworten auf
|
||||
* dieselbe Frage.
|
||||
*/
|
||||
export function kontierungZum(rows: KontierungsZeile[], asOf: string): Map<string, Kostenstelle> {
|
||||
const out = new Map<string, Kostenstelle>();
|
||||
for (const r of rows) {
|
||||
if (r.valid_from > asOf) continue;
|
||||
if (r.valid_to !== null && r.valid_to <= asOf) continue;
|
||||
out.set(r.position_id, { id: r.cost_center_id, code: r.code, name: r.name });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie kontierungZum, aber für eine einzelne Planstelle und einen Stichtag,
|
||||
* der nicht heute sein muss.
|
||||
*
|
||||
* Nötig für Planstellen, die erst entstehen: ihre Kontierung beginnt mit
|
||||
* ihnen. Zu heute gefragt gäbe es keine — und die Vakanzliste, die künftige
|
||||
* Stellen bewusst zeigt, stünde für genau diese ohne Kostenstelle da. Die
|
||||
* Frage lautet dort nicht „wer zahlt heute", sondern „wer zahlt, wenn es
|
||||
* losgeht".
|
||||
*/
|
||||
export function kontierungAm(rows: KontierungsZeile[], positionId: string, asOf: string): Kostenstelle | null {
|
||||
for (const r of rows) {
|
||||
if (r.position_id !== positionId) continue;
|
||||
if (r.valid_from > asOf) continue;
|
||||
if (r.valid_to !== null && r.valid_to <= asOf) continue;
|
||||
return { id: r.cost_center_id, code: r.code, name: r.name };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Der bequeme Weg für Aufrufer ohne eigene Abfrage. */
|
||||
export async function loadKontierungen(
|
||||
tx: Tx,
|
||||
{ asOf, positionIds }: { asOf: string; positionIds?: string[] }
|
||||
): Promise<Map<string, Kostenstelle>> {
|
||||
if (positionIds?.length === 0) return new Map();
|
||||
const { rows } = await tx
|
||||
.selectNoFrom((eb) => [jsonArrayFrom(kontierungenAbfrage(eb, positionIds)).as("rows")])
|
||||
.executeTakeFirstOrThrow();
|
||||
return kontierungZum(rows as KontierungsZeile[], asOf);
|
||||
}
|
||||
@@ -197,3 +197,63 @@ export const UN_COUNTRIES: string[] = [
|
||||
"Zentralafrikanische Republik",
|
||||
"Zypern",
|
||||
].sort((a, b) => a.localeCompare(b, "de"));
|
||||
|
||||
// Wer keinen Aufenthaltstitel braucht.
|
||||
//
|
||||
// EU-27, dazu Island, Liechtenstein und Norwegen (EWR) sowie die Schweiz.
|
||||
// Für diese Staatsangehörigen gilt Freizügigkeit; ein Aufenthaltstitel ist
|
||||
// weder nötig noch zu erfassen. Für alle übrigen ist er es, und dann will HR
|
||||
// wissen, bis wann er läuft.
|
||||
//
|
||||
// Die Liste steht hier und nicht in der Datenbank: eine Prüfbedingung, die
|
||||
// Staatsbürgerschaft und Titel koppelt, würde bei jeder Korrektur der
|
||||
// Staatsbürgerschaft zuschlagen — und Beitritte müssten als Migration
|
||||
// nachgezogen werden, statt als Zeile in einer Liste.
|
||||
export const FREIZUEGIGKEIT: readonly string[] = [
|
||||
// EU
|
||||
"Belgien",
|
||||
"Bulgarien",
|
||||
"Dänemark",
|
||||
"Deutschland",
|
||||
"Estland",
|
||||
"Finnland",
|
||||
"Frankreich",
|
||||
"Griechenland",
|
||||
"Irland",
|
||||
"Italien",
|
||||
"Kroatien",
|
||||
"Lettland",
|
||||
"Litauen",
|
||||
"Luxemburg",
|
||||
"Malta",
|
||||
"Niederlande",
|
||||
"Österreich",
|
||||
"Polen",
|
||||
"Portugal",
|
||||
"Rumänien",
|
||||
"Schweden",
|
||||
"Slowakei",
|
||||
"Slowenien",
|
||||
"Spanien",
|
||||
"Tschechien",
|
||||
"Ungarn",
|
||||
"Zypern",
|
||||
// EWR
|
||||
"Island",
|
||||
"Liechtenstein",
|
||||
"Norwegen",
|
||||
// Bilaterale Freizügigkeit
|
||||
"Schweiz",
|
||||
];
|
||||
|
||||
/**
|
||||
* Braucht jemand mit dieser Staatsbürgerschaft einen Aufenthaltstitel?
|
||||
*
|
||||
* Bei leerer Angabe: nein. Eine fehlende Staatsbürgerschaft ist kein Anlass,
|
||||
* jemandem einen Titel abzuverlangen — sie ist ein Anlass, sie zu erfassen.
|
||||
*/
|
||||
export function brauchtAufenthaltstitel(nationality: string | null | undefined): boolean {
|
||||
const n = nationality?.trim();
|
||||
if (!n) return false;
|
||||
return !FREIZUEGIGKEIT.includes(n);
|
||||
}
|
||||
|
||||
159
lib/dashboard-data.ts
Normal file
159
lib/dashboard-data.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import type { Tx } from "./db";
|
||||
import { jsonArrayFrom, jsonObjectFrom, zeitstempel } from "./db/json";
|
||||
import { besetzungenAbfrage, pickPlacements } from "./placement";
|
||||
import { buildOrgMaps, orgMapsAbfragen, type OrgEb } from "./org";
|
||||
import { offeneStellenAbfrage, resolveOpenPositions, type OffeneStelle } from "./positions";
|
||||
import type { HistoryEventType } from "./supabase/types";
|
||||
import type { AnstehendArt } from "./dashboard-filter";
|
||||
|
||||
// Was die Übersichtsseite liest — in zwei Rundreisen statt in dreizehn.
|
||||
//
|
||||
// Hier und nicht in der Seite, damit sich die Zahl der Rundreisen messen und
|
||||
// das Ergebnis gegen den alten Weg halten lässt, ohne eine React-Komponente
|
||||
// aufzubauen.
|
||||
|
||||
export type DashboardParams = {
|
||||
userId: string | null;
|
||||
today: string;
|
||||
yearStart: string;
|
||||
yearEnd: string;
|
||||
bisIso: string;
|
||||
arten: AnstehendArt[];
|
||||
};
|
||||
|
||||
export async function loadDashboardData(tx: Tx, p: DashboardParams) {
|
||||
const { userId, today, yearStart, yearEnd, bisIso, arten } = p;
|
||||
const zeigt = (art: AnstehendArt) => arten.includes(art);
|
||||
// Elf Abfragen standen hier in einem Promise.all, das keine
|
||||
// Gleichzeitigkeit war: eine Transaktion hängt an einer Verbindung, und
|
||||
// über eine Verbindung laufen Abfragen nacheinander. Bei rund 36 ms
|
||||
// Umlaufzeit war das eine knappe Sekunde Warten für Daten, die zusammen
|
||||
// keine 200 kB wiegen. Jetzt: eine Rundreise, und eine zweite für die
|
||||
// offenen Stellen, deren zweite Hälfte vom Ergebnis der ersten abhängt.
|
||||
// Der Weg dahin steht in lib/db/json.ts.
|
||||
const countIn = (eb: OrgEb, types: readonly HistoryEventType[]) =>
|
||||
eb
|
||||
.selectFrom("employee_history")
|
||||
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
|
||||
.where("event_type", "in", [...types])
|
||||
.where("event_date", ">=", yearStart)
|
||||
.where("event_date", "<=", yearEnd);
|
||||
|
||||
const g = await tx
|
||||
.selectNoFrom((eb) => [
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("hire_drafts")
|
||||
.select(["id", "step", "payload"])
|
||||
.select((x) => zeitstempel(x.ref("updated_at")).as("updated_at"))
|
||||
.where("created_by", "=", userId ?? "")
|
||||
.orderBy("updated_at", "desc")
|
||||
).as("drafts"),
|
||||
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("employees")
|
||||
.select(["id", "weekly_hours", "entry_date", "exit_date", "karenz_start_date", "karenz_return_date"])
|
||||
.orderBy("id")
|
||||
).as("staffRows"),
|
||||
|
||||
// Entries/exits count history events, which is what the linked report
|
||||
// counts too. `entry_date` would also sweep up rehires, whose event is
|
||||
// logged as 'Wiedereintritt' — the tile and its destination then showed
|
||||
// different numbers for the same year.
|
||||
jsonObjectFrom(countIn(eb, ["Eintritt", "Wiedereintritt"])).as("hiresYtd"),
|
||||
jsonObjectFrom(countIn(eb, ["Austritt"])).as("exitsYtd"),
|
||||
|
||||
...orgMapsAbfragen(eb),
|
||||
jsonArrayFrom(offeneStellenAbfrage(eb, today)).as("open"),
|
||||
jsonArrayFrom(besetzungenAbfrage(eb)).as("placementRows"),
|
||||
|
||||
// Abgewählte Arten werden gar nicht erst gelesen — die Karte zeigt sie
|
||||
// ohnehin nicht, und eine Teilabfrage, deren Ergebnis niemand ansieht,
|
||||
// ist eine Teilabfrage zu viel.
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("employees")
|
||||
.select(["id", "first_name", "last_name", "entry_date"])
|
||||
.where("status", "=", "Geplant")
|
||||
.where("entry_date", ">=", today)
|
||||
.where("entry_date", "<=", bisIso)
|
||||
.where((e) => e.lit(zeigt("hire")))
|
||||
).as("upcomingHires"),
|
||||
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("employees")
|
||||
.select(["id", "first_name", "last_name", "exit_date"])
|
||||
.where("exit_date", "is not", null)
|
||||
.where("exit_date", ">=", today)
|
||||
.where("exit_date", "<=", bisIso)
|
||||
.where((e) => e.lit(zeigt("exit")))
|
||||
).as("upcomingExits"),
|
||||
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("employees")
|
||||
.select(["id", "first_name", "last_name", "karenz_return_date"])
|
||||
.where("status", "=", "Karenz")
|
||||
.where("karenz_return_date", "is not", null)
|
||||
.where("karenz_return_date", ">=", today)
|
||||
.where("karenz_return_date", "<=", bisIso)
|
||||
.where((e) => e.lit(zeigt("return")))
|
||||
).as("upcomingReturns"),
|
||||
|
||||
// Wiedervorlagen. Sie verhalten sich anders als der Rest dieser Karte:
|
||||
// ein Eintritt am Montag ist am Dienstag vorbei, eine Wiedervorlage
|
||||
// nicht. Deshalb gibt es hier **keine untere Grenze** — was fällig war
|
||||
// und nicht abgehakt wurde, bleibt stehen, bis jemand „Erledigt" klickt.
|
||||
// Eine Aufgabe, die von selbst aus der Liste rutscht, ist eine
|
||||
// vergessene Aufgabe.
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("employee_notes as n")
|
||||
.innerJoin("employees as e", "e.id", "n.employee_id")
|
||||
.select(["n.id", "n.employee_id", "n.due_date", "n.category", "n.note_text", "e.first_name", "e.last_name"])
|
||||
.where("n.done", "=", false)
|
||||
.where("n.due_date", "is not", null)
|
||||
.where("n.due_date", "<=", bisIso)
|
||||
.where((x) => x.lit(zeigt("note")))
|
||||
.orderBy("n.due_date")
|
||||
).as("upcomingNotes"),
|
||||
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("employee_history as h")
|
||||
.leftJoin("employees as e", "e.id", "h.employee_id")
|
||||
.select([
|
||||
"h.id",
|
||||
"h.employee_id",
|
||||
"h.event_date",
|
||||
"h.event_type",
|
||||
"h.description",
|
||||
"e.first_name",
|
||||
"e.last_name",
|
||||
])
|
||||
.orderBy("h.event_date", "desc")
|
||||
.orderBy("h.created_at", "desc")
|
||||
.limit(10)
|
||||
).as("history"),
|
||||
])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const orgMaps = buildOrgMaps(g.units as never, g.locations as never);
|
||||
|
||||
return {
|
||||
drafts: userId ? g.drafts : [],
|
||||
staffRows: g.staffRows,
|
||||
hiresYtd: Number(g.hiresYtd?.anzahl ?? 0),
|
||||
exitsYtd: Number(g.exitsYtd?.anzahl ?? 0),
|
||||
openPositions: await resolveOpenPositions(tx, orgMaps, g.open as OffeneStelle[], today),
|
||||
orgMaps,
|
||||
placements: pickPlacements(g.placementRows as never, today),
|
||||
upcomingHires: g.upcomingHires,
|
||||
upcomingExits: g.upcomingExits,
|
||||
upcomingReturns: g.upcomingReturns,
|
||||
upcomingNotes: g.upcomingNotes,
|
||||
history: g.history,
|
||||
};
|
||||
}
|
||||
47
lib/dashboard-filter.ts
Normal file
47
lib/dashboard-filter.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// Was auf der Übersicht als „anstehend" gilt — Zeitraum und Art.
|
||||
//
|
||||
// Die Auswahl steht in der Adresse, nicht im Browser: die Übersicht wird auf
|
||||
// dem Server gebaut, und ein Zeitraum von 90 statt 60 Tagen bringt Zeilen ins
|
||||
// Spiel, die im Browser gar nicht liegen. Nebenbei lässt sich eine so
|
||||
// eingestellte Seite verschicken und wiederfinden.
|
||||
|
||||
export const ZEITRAEUME = [30, 60, 90, 180] as const;
|
||||
export type Zeitraum = (typeof ZEITRAEUME)[number];
|
||||
|
||||
export const STANDARD_ZEITRAUM: Zeitraum = 60;
|
||||
|
||||
export const ANSTEHEND_ARTEN = [
|
||||
{ value: "hire", label: "Eintritt" },
|
||||
{ value: "exit", label: "Austritt" },
|
||||
{ value: "return", label: "Rückkehr" },
|
||||
{ value: "note", label: "Wiedervorlage" },
|
||||
] as const;
|
||||
|
||||
export type AnstehendArt = (typeof ANSTEHEND_ARTEN)[number]["value"];
|
||||
|
||||
const ALLE_ARTEN: AnstehendArt[] = ANSTEHEND_ARTEN.map((a) => a.value);
|
||||
|
||||
/** Alles, was nicht in der Liste steht, führt auf den Standard zurück. */
|
||||
export function parseZeitraum(wert: string | undefined): Zeitraum {
|
||||
const zahl = Number(wert);
|
||||
return (ZEITRAEUME as readonly number[]).includes(zahl) ? (zahl as Zeitraum) : STANDARD_ZEITRAUM;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keine Angabe heisst „alle" — nicht „keine". Wer die Übersicht aufruft, ohne
|
||||
* etwas auszuwählen, will alles sehen; eine leere Karte wäre die falsche
|
||||
* Antwort auf eine nicht gestellte Frage. Aus demselben Grund führt auch eine
|
||||
* Auswahl, in der nur Unbekanntes steht, zurück auf alle.
|
||||
*/
|
||||
export function parseArten(wert: string | undefined): AnstehendArt[] {
|
||||
const gewaehlt = (wert ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s): s is AnstehendArt => (ALLE_ARTEN as string[]).includes(s));
|
||||
return gewaehlt.length > 0 ? gewaehlt : ALLE_ARTEN;
|
||||
}
|
||||
|
||||
/** Steht in der Adresse etwas anderes als der Standard? */
|
||||
export function istEingeschraenkt(zeitraum: Zeitraum, arten: AnstehendArt[]): boolean {
|
||||
return zeitraum !== STANDARD_ZEITRAUM || arten.length !== ALLE_ARTEN.length;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import "server-only";
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { Kysely, PostgresDialect, sql, type Transaction } from "kysely";
|
||||
import { getPool } from "./pool";
|
||||
import type { Schema } from "./schema";
|
||||
@@ -7,7 +8,7 @@ import type { Schema } from "./schema";
|
||||
//
|
||||
// ═══ Warum das keine gewöhnliche Datenbankschicht ist ═══
|
||||
//
|
||||
// Die Zugriffsrechte liegen in der Datenbank: 58 RLS-Policies rufen
|
||||
// Die Zugriffsrechte liegen in der Datenbank: 21 RLS-Policies rufen
|
||||
// is_hr_user() auf, und das fragt seit der Umstellung nicht mehr Supabase,
|
||||
// sondern `current_setting('app.user_id')` — eine Sitzungsvariable.
|
||||
//
|
||||
@@ -36,8 +37,33 @@ import type { Schema } from "./schema";
|
||||
// Der Pool wird als Funktion übergeben, nicht als fertige Instanz: Kysely
|
||||
// ruft sie erst bei der ersten Abfrage auf. So verlangt der Import dieses
|
||||
// Moduls noch keine Zugangsdaten — siehe getPool().
|
||||
// ═══ Wie viele Rundreisen eine Anfrage kostet ═══
|
||||
//
|
||||
// Eine Transaktion hängt an einer Verbindung, und über eine Verbindung laufen
|
||||
// Abfragen nacheinander — auch die in einem Promise.all. Bei rund 36 ms
|
||||
// Umlaufzeit zur Datenbank ist die Zahl der Abfragen deshalb *die* Kennzahl
|
||||
// für die Ladezeit einer Seite, und zwar eine, die man nicht schätzen muss.
|
||||
//
|
||||
// Sie wird darum mitgezählt und im Entwicklungsbetrieb gemeldet, sobald eine
|
||||
// Transaktion viele davon braucht. Ohne diese Meldung wächst so etwas
|
||||
// unbemerkt: jede neue Kachel bringt ihre eigene Abfrage mit, und dass die
|
||||
// Seite langsamer wird, merkt man erst, wenn es alle merken.
|
||||
const zaehler = new AsyncLocalStorage<{ abfragen: number }>();
|
||||
|
||||
export function zaehleAbfragen(): { abfragen: number } | undefined {
|
||||
return zaehler.getStore();
|
||||
}
|
||||
|
||||
/** Ab wann eine Transaktion im Entwicklungsbetrieb gemeldet wird. */
|
||||
const MELDESCHWELLE = Number(process.env.DB_QUERY_WARN ?? 6);
|
||||
|
||||
const db = new Kysely<Schema>({
|
||||
dialect: new PostgresDialect({ pool: async () => getPool() }),
|
||||
log: (event) => {
|
||||
const store = zaehler.getStore();
|
||||
if (store) store.abfragen++;
|
||||
if (event.level === "error") console.error("Abfrage fehlgeschlagen:", event.error);
|
||||
},
|
||||
});
|
||||
|
||||
export type Tx = Transaction<Schema>;
|
||||
@@ -49,11 +75,26 @@ export type Tx = Transaction<Schema>;
|
||||
* greift keine Policy und es kommt nichts zurück, was auch richtig ist.
|
||||
*/
|
||||
export async function withUser<T>(userId: string | null, fn: (tx: Tx) => Promise<T>): Promise<T> {
|
||||
return db.transaction().execute(async (tx) => {
|
||||
const stand = { abfragen: 0 };
|
||||
const start = performance.now();
|
||||
try {
|
||||
return await zaehler.run(stand, () =>
|
||||
db.transaction().execute(async (tx) => {
|
||||
// Erste Anweisung der Transaktion, vor allem anderen.
|
||||
await sql`select set_config('app.user_id', ${userId ?? ""}, true)`.execute(tx);
|
||||
return fn(tx);
|
||||
});
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
// Nur im Entwicklungsbetrieb: in der Produktion gehörte das in die
|
||||
// Ablaufverfolgung, nicht auf die Konsole.
|
||||
if (process.env.NODE_ENV !== "production" && stand.abfragen > MELDESCHWELLE) {
|
||||
console.warn(
|
||||
`[db] ${stand.abfragen} Abfragen in einer Transaktion, ${Math.round(performance.now() - start)} ms — ` +
|
||||
`sie laufen nacheinander über eine Verbindung. Bündeln: siehe lib/db/json.ts.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
69
lib/db/json.ts
Normal file
69
lib/db/json.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
// Kein `server-only` hier, anders als in ./index.ts und ./pool.ts: diese Datei
|
||||
// baut nur Abfragen zusammen und hält weder Verbindung noch Zugangsdaten. Mit
|
||||
// der Sperre wären die reinen Tests von lib/org.ts nicht mehr ladbar, obwohl
|
||||
// dort nur ein Baum aus Zeilen gebaut wird.
|
||||
import { sql, type Expression, type RawBuilder } from "kysely";
|
||||
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/postgres";
|
||||
|
||||
// Mehrere unabhängige Lesevorgänge in **einer** Rundreise.
|
||||
//
|
||||
// ═══ Warum das nötig ist ═══
|
||||
//
|
||||
// Eine Transaktion hängt an genau einer Verbindung, und über eine Verbindung
|
||||
// laufen Abfragen nacheinander — auch die, die in einem Promise.all stehen.
|
||||
// Der Treiber stellt sie in eine Schlange. `Promise.all` sieht nach
|
||||
// Gleichzeitigkeit aus und ist hier keine.
|
||||
//
|
||||
// Gemessen an der echten Datenbank: die Umlaufzeit beträgt rund 36 ms, zehn
|
||||
// belanglose `select 1` über eine Verbindung brauchen 343 ms, über zehn
|
||||
// Verbindungen 39 ms. Der Aufbau der Übersichtsseite — zehn Abfragen, die
|
||||
// zusammen keine 200 kB liefern — kostete so knapp eine Sekunde, fast
|
||||
// ausschliesslich Warten.
|
||||
//
|
||||
// Mehr Verbindungen sind trotzdem nicht die Antwort: der Sitzungskontext für
|
||||
// RLS gilt je Transaktion (siehe ./index.ts), und mehrere Transaktionen je
|
||||
// Anfrage vervielfachen die Verbindungen, die die Datenbank zulässt. Also
|
||||
// weniger Rundreisen statt mehr Leitungen: Postgres kann jede Teilabfrage als
|
||||
// JSON-Spalte in *einem* Ergebnis liefern.
|
||||
//
|
||||
// const { einheiten, standorte } = await tx
|
||||
// .selectNoFrom((eb) => [
|
||||
// jsonArrayFrom(eb.selectFrom("org_units").select([...])).as("einheiten"),
|
||||
// jsonArrayFrom(eb.selectFrom("locations").selectAll()).as("standorte"),
|
||||
// ])
|
||||
// .executeTakeFirstOrThrow();
|
||||
//
|
||||
// Typisiert wie jede andere Kysely-Abfrage, mit Parametern, ohne Handarbeit
|
||||
// an der Zeichenkette.
|
||||
//
|
||||
// ═══ Die eine Falle ═══
|
||||
//
|
||||
// Innerhalb von json_agg formatiert Postgres die Werte selbst, und der
|
||||
// Treiber kommt nicht mehr daran (lib/db/pool.ts stellt ihn dort auf die
|
||||
// Formen um, die die Typen beschreiben). Für die meisten Typen macht das
|
||||
// nichts — im Gegenteil:
|
||||
//
|
||||
// date → "2022-03-30" wie ausserhalb
|
||||
// numeric → 38.5 wie ausserhalb
|
||||
// uuid → Zeichenkette wie ausserhalb
|
||||
// timestamptz → "2026-08-03T12:08:06.272938+00:00"
|
||||
// ← **anders**: ausserhalb "…272Z"
|
||||
//
|
||||
// Der Unterschied ist nicht kosmetisch. Zeitstempel werden im Projekt als
|
||||
// Zeichenketten verglichen (lib/history.ts entscheidet daran, was später
|
||||
// passiert ist), und "+00:00" sortiert gegen "Z" falsch herum. Deshalb geht
|
||||
// **jede** timestamptz-Spalte in einer gebündelten Abfrage durch zeitstempel().
|
||||
|
||||
export { jsonArrayFrom, jsonObjectFrom };
|
||||
|
||||
/**
|
||||
* Eine timestamptz-Spalte in der Form, die der Treiber ausserhalb von JSON
|
||||
* liefert — ISO-8601 in UTC, auf Millisekunden gekürzt.
|
||||
*
|
||||
* Ohne das käme aus einer gebündelten Abfrage eine andere Zeichenkette als
|
||||
* aus derselben Abfrage einzeln gestellt.
|
||||
*/
|
||||
export function zeitstempel(spalte: Expression<unknown> | string): RawBuilder<string> {
|
||||
const ref = typeof spalte === "string" ? sql.ref(spalte) : spalte;
|
||||
return sql<string>`to_char(${ref} at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`;
|
||||
}
|
||||
17
lib/dienstwagen.ts
Normal file
17
lib/dienstwagen.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { DienstwagenArt } from "./supabase/types";
|
||||
|
||||
// Wie ein Dienstwagen benannt wird.
|
||||
//
|
||||
// Gespeichert steht „Elektro"; gesprochen wird von einem E-KFZ, und danach
|
||||
// wird auch gefragt. Die Beschriftung stand an drei Stellen einzeln — im
|
||||
// Auswahlfeld, in der Zusammenfassung des Einstellungsassistenten und auf dem
|
||||
// Vertragsblatt. Drei Kopien einer Beschriftung laufen auseinander, und dann
|
||||
// heisst dasselbe Auto je nach Bildschirm anders.
|
||||
|
||||
export function dienstwagenLabel(art: DienstwagenArt | null | undefined): string {
|
||||
if (art === "Elektro") return "E-KFZ";
|
||||
if (art === "Verbrenner") return "Verbrenner";
|
||||
// Der CHECK erlaubt es nicht, aber gelesen wird auch, was jemand über den
|
||||
// Import hineingelegt hat.
|
||||
return art ?? "–";
|
||||
}
|
||||
137
lib/employee-detail-data.ts
Normal file
137
lib/employee-detail-data.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { Tx } from "./db";
|
||||
import { kontierungZum, kontierungenAbfrage, type KontierungsZeile } from "./cost-centers";
|
||||
import { jsonArrayFrom, zeitstempel } from "./db/json";
|
||||
import { todayIso } from "./format";
|
||||
import { buildOrgMaps, orgMapsAbfragen } from "./org";
|
||||
import { berichtslinienAbfrage, besetzungenAbfrage, orgAsOf, pickPlacements } from "./placement";
|
||||
import { offeneStellenAbfrage, resolveOpenPositions, type OffeneStelle } from "./positions";
|
||||
|
||||
// Was die Mitarbeiterakte liest — in vier Rundreisen statt in zwölf.
|
||||
//
|
||||
// Vorher: die Person, dann acht Dinge in einem Promise.all, dann die Namen der
|
||||
// Beteiligten. Das Promise.all war keine Gleichzeitigkeit — eine Transaktion
|
||||
// hängt an einer Verbindung (lib/db/json.ts). Bei rund 36 ms Umlaufzeit war
|
||||
// diese Seite damit die teuerste der Anwendung.
|
||||
//
|
||||
// Die drei Tabellen mit `selectAll()` stehen hier ausgeschrieben. Das ist
|
||||
// keine Umständlichkeit: innerhalb von JSON formatiert Postgres timestamptz
|
||||
// anders als der Treiber sonst, und `created_at` entscheidet in lib/history.ts
|
||||
// darüber, was später passiert ist. Ausgeschrieben ist sichtbar, welche Spalte
|
||||
// durch zeitstempel() geht — bei selectAll() wäre es unsichtbar falsch.
|
||||
|
||||
export async function loadEmployeeDetail(tx: Tx, id: string, today: string = todayIso()) {
|
||||
// Die Person zuerst, allein: erst aus Eintritt und Austritt ergibt sich der
|
||||
// Stichtag, zu dem ihre Organisation überhaupt eine Antwort hat. Eine
|
||||
// Person, die am 01.09. anfängt, hat heute keine laufende Besetzung — zu
|
||||
// heute gefragt lieferte om_reporting_lines() nichts, und die Akte
|
||||
// behauptete „Keine Führungskraft", obwohl das Team eine hat.
|
||||
const employee = await tx.selectFrom("employees").selectAll().where("id", "=", id).executeTakeFirst();
|
||||
if (!employee) return null;
|
||||
|
||||
const asOf = orgAsOf(employee, today);
|
||||
|
||||
const g = await tx
|
||||
.selectNoFrom((eb) => [
|
||||
// Vorgesetzte und direkte Berichte stehen nirgends als Spalte — sie
|
||||
// kommen aus om_reporting_lines(). Beide schränken *in* der Funktion
|
||||
// ein, es wandern also neun Zeilen über die Leitung und nicht achthundert.
|
||||
jsonArrayFrom(berichtslinienAbfrage(eb, asOf, { employeeId: id })).as("ownLines"),
|
||||
jsonArrayFrom(berichtslinienAbfrage(eb, asOf, { actingManagerId: id })).as("reports"),
|
||||
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("employee_history")
|
||||
.select(["id", "employee_id", "event_date", "event_type", "description", "changes", "pending_id"])
|
||||
.select((x) => zeitstempel(x.ref("created_at")).as("created_at"))
|
||||
.where("employee_id", "=", id)
|
||||
.orderBy("event_date", "desc")
|
||||
.orderBy("created_at", "desc")
|
||||
).as("history"),
|
||||
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("employee_dependents")
|
||||
.select(["id", "employee_id", "first_name", "last_name", "relationship", "sv_nummer", "birth_date"])
|
||||
.select((x) => zeitstempel(x.ref("created_at")).as("created_at"))
|
||||
.where("employee_id", "=", id)
|
||||
.orderBy("created_at")
|
||||
).as("dependents"),
|
||||
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("employee_notes")
|
||||
.select([
|
||||
"id",
|
||||
"employee_id",
|
||||
"author_user_id",
|
||||
"author_name",
|
||||
"category",
|
||||
"note_text",
|
||||
"due_date",
|
||||
"done",
|
||||
"done_by",
|
||||
])
|
||||
.select((x) => [
|
||||
zeitstempel(x.ref("created_at")).as("created_at"),
|
||||
zeitstempel(x.ref("done_at")).as("done_at"),
|
||||
])
|
||||
.where("employee_id", "=", id)
|
||||
.orderBy("created_at", "desc")
|
||||
).as("notes"),
|
||||
|
||||
...orgMapsAbfragen(eb),
|
||||
jsonArrayFrom(besetzungenAbfrage(eb, [id])).as("placementRows"),
|
||||
// Die Kontierungen der Planstellen dieser Person. Welche gilt, hängt am
|
||||
// Stichtag — und der ergibt sich erst aus Eintritt und Austritt, steht
|
||||
// hier also schon fest.
|
||||
jsonArrayFrom(
|
||||
kontierungenAbfrage(eb).where("z.position_id", "in", (e) =>
|
||||
e.selectFrom("position_assignments as pa").select("pa.position_id").where("pa.employee_id", "=", id)
|
||||
)
|
||||
).as("costRows"),
|
||||
// Die offenen Planstellen bleiben bei heute: sie gehören zur
|
||||
// Organisation, nicht zu dieser Person.
|
||||
jsonArrayFrom(offeneStellenAbfrage(eb, today)).as("open"),
|
||||
])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const orgMaps = buildOrgMaps(g.units as never, g.locations as never);
|
||||
const line = g.ownLines[0] ?? null;
|
||||
const placement = pickPlacements(g.placementRows as never, asOf).get(id) ?? null;
|
||||
|
||||
// Namen für die beteiligten Personen in einem Zug: die Vertretung, die
|
||||
// formal zuständige Leitung und die direkten Berichte.
|
||||
const relatedIds = Array.from(
|
||||
new Set(
|
||||
[line?.acting_manager_id, line?.formal_manager_id, ...g.reports.map((r) => r.employee_id)].filter(
|
||||
(x): x is string => Boolean(x)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const [relatedRows, openPositions] = await Promise.all([
|
||||
relatedIds.length
|
||||
? tx
|
||||
.selectFrom("employees")
|
||||
.select(["id", "first_name", "last_name", "job_title", "status"])
|
||||
.where("id", "in", relatedIds)
|
||||
.execute()
|
||||
: Promise.resolve([]),
|
||||
resolveOpenPositions(tx, orgMaps, g.open as OffeneStelle[], today),
|
||||
]);
|
||||
|
||||
return {
|
||||
employee,
|
||||
line,
|
||||
reports: g.reports,
|
||||
history: g.history,
|
||||
dependents: g.dependents,
|
||||
notes: g.notes,
|
||||
orgMaps,
|
||||
placement,
|
||||
// Die Kostenstelle des Sitzes, auf dem die Person am Stichtag sitzt.
|
||||
kostenstelle: placement ? (kontierungZum(g.costRows as KontierungsZeile[], asOf).get(placement.positionId) ?? null) : null,
|
||||
openPositions,
|
||||
byId: new Map(relatedRows.map((e) => [e.id, e])),
|
||||
};
|
||||
}
|
||||
35
lib/employee-search.ts
Normal file
35
lib/employee-search.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// Wie aus einer Eingabe Suchmuster werden.
|
||||
//
|
||||
// Getrennt von der Seite, weil hier die Entscheidungen stecken, die man
|
||||
// prüfen können muss: was als Wort zählt, was am Wortanfang treffen muss und
|
||||
// was von LIKE als Text und nicht als Platzhalter gelesen wird.
|
||||
//
|
||||
// Die Bedeutung selbst — „trifft am Wortanfang" — liegt im SQL der Seite:
|
||||
// verglichen wird gegen Vorname, Nachname und Position zusammengesetzt, mit
|
||||
// Trennzeichen als Wortgrenze.
|
||||
|
||||
/** Nur Ziffern? Dann ist es eine Personalnummer und kein Name. */
|
||||
export function istPersonalnummer(term: string): boolean {
|
||||
return /^\d+$/.test(term.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Je Suchwort zwei LIKE-Muster: „am Anfang" und „nach einem Leerzeichen".
|
||||
* Zusammen ergeben sie „am Anfang eines Wortes".
|
||||
*
|
||||
* Ein Wort als Teilzeichenkette zu suchen wäre einfacher und war die erste
|
||||
* Fassung — bei „Winkler H" traf das „H" dann auf Thomas, Katharina und
|
||||
* CNC-Dreher:in, also auf alle. Je kürzer die Eingabe, desto unbrauchbarer
|
||||
* das Ergebnis, und ein Anfangsbuchstabe ist die kürzeste sinnvolle Eingabe.
|
||||
*/
|
||||
export function suchMuster(term: string): string[][] {
|
||||
return term
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((wort) => {
|
||||
// Die Platzhalter von LIKE entschärfen: wer „50 %" tippt, sucht Text.
|
||||
const klein = wort.toLowerCase().replace(/[\\%_]/g, (z) => `\\${z}`);
|
||||
return [`${klein}%`, `% ${klein}%`];
|
||||
});
|
||||
}
|
||||
@@ -56,8 +56,27 @@ export function initials(firstName: string, lastName: string): string {
|
||||
return `${a}${b}`;
|
||||
}
|
||||
|
||||
// "Dr. Max Mustermann, MSc MBA" — prefix titles precede the name, suffix
|
||||
// titles follow after a comma, both space-joined in the stored order.
|
||||
// „Mustermann, Max" — der Nachname zuerst.
|
||||
//
|
||||
// So steht es in jeder Personalliste, und danach wird gesucht: wer jemanden
|
||||
// sucht, hat den Nachnamen im Kopf. Nebenbei stimmt die Anzeige damit auch
|
||||
// mit der Sortierung überein, die schon immer nach Nachnamen ging — vorher
|
||||
// las sich eine alphabetische Liste, als wäre sie ungeordnet.
|
||||
//
|
||||
// Eine Stelle für alle: der Name wurde an gut zwanzig Orten einzeln
|
||||
// zusammengesetzt, und eine Umstellung, die die Hälfte davon vergisst, ist
|
||||
// schlimmer als gar keine.
|
||||
export function fmtName(firstName: string, lastName: string): string {
|
||||
const nach = lastName?.trim() ?? "";
|
||||
const vor = firstName?.trim() ?? "";
|
||||
if (!nach) return vor;
|
||||
if (!vor) return nach;
|
||||
return `${nach}, ${vor}`;
|
||||
}
|
||||
|
||||
// "Mustermann, Dr. Max, MSc MBA" — vorangestellte Titel stehen beim
|
||||
// Vornamen, nachgestellte am Ende. Der Nachname bleibt vorne, damit auch die
|
||||
// Überschrift einer Personalakte so beginnt wie ihr Eintrag in der Liste.
|
||||
export function fmtFullName(
|
||||
firstName: string,
|
||||
lastName: string,
|
||||
@@ -66,7 +85,7 @@ export function fmtFullName(
|
||||
): string {
|
||||
const prefix = titlePrefix && titlePrefix.length > 0 ? `${titlePrefix.join(" ")} ` : "";
|
||||
const suffix = titleSuffix && titleSuffix.length > 0 ? `, ${titleSuffix.join(" ")}` : "";
|
||||
return `${prefix}${firstName} ${lastName}${suffix}`;
|
||||
return `${fmtName(`${prefix}${firstName}`, lastName)}${suffix}`;
|
||||
}
|
||||
|
||||
// Whole years between two ISO dates. Compares the "MM-DD" tails as strings,
|
||||
|
||||
136
lib/history.ts
Normal file
136
lib/history.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { AuditChange, HistoryEventType } from "./supabase/types";
|
||||
|
||||
// Welche Historieneinträge sich zurücknehmen lassen — und warum die übrigen
|
||||
// nicht.
|
||||
//
|
||||
// Dieselbe Regel steht in der Datenbank (delete_history_entry). Das ist eine
|
||||
// Doppelung, und zwar mit Absicht: die Datenbank ist die verbindliche Stelle,
|
||||
// weil sie die einzige ist, an der niemand vorbeikommt. Hier steht sie
|
||||
// nochmal, damit die Oberfläche einen Knopf nur dort zeigt, wo er auch
|
||||
// funktioniert, und daneben schreiben kann, woran es sonst liegt. Ein Knopf,
|
||||
// der erst nach dem Klick sagt „geht nicht", ist eine Falle.
|
||||
//
|
||||
// Läuft eine Seite der anderen davon, gewinnt die Datenbank: sie weist ab,
|
||||
// und die Oberfläche zeigt ihre Begründung.
|
||||
|
||||
export type KorrekturUrteil = { erlaubt: true } | { erlaubt: false; grund: string };
|
||||
|
||||
type Eintrag = {
|
||||
event_type: HistoryEventType;
|
||||
event_date: string;
|
||||
changes: AuditChange[] | null;
|
||||
/** Der geplante Vorgang, falls die Änderung noch nicht wirksam ist. */
|
||||
pending_id?: string | null;
|
||||
/** Für die Reihenfolge bei gleichem Datum. */
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
/** Die Vorgänge, die sich zurücknehmen und berichtigen lassen. */
|
||||
const KORRIGIERBAR: HistoryEventType[] = ["Stammdatenänderung", "Vertragsänderung", "Karenz", "Rückkehr"];
|
||||
|
||||
/**
|
||||
* Abwesenheit und Rückkehr werden anders zurückgenommen als der Rest: aus
|
||||
* einer geplanten Vertragsänderung nimmt man einzelne Felder heraus, eine
|
||||
* geplante Abwesenheit fällt ganz. Beides steht in delete_history_entry;
|
||||
* hier zählt nur, dass für diese beiden keine Feldwerte nötig sind.
|
||||
*/
|
||||
const OHNE_FELDWERTE: HistoryEventType[] = ["Karenz", "Rückkehr"];
|
||||
|
||||
export function darfKorrigiertWerden(eintrag: Eintrag, heute: string, alle: Eintrag[] = []): KorrekturUrteil {
|
||||
if (eintrag.event_type === "Eintritt") {
|
||||
return { erlaubt: false, grund: "Der Eintritt ist der Anfang der Zeitleiste und bleibt." };
|
||||
}
|
||||
|
||||
if (!KORRIGIERBAR.includes(eintrag.event_type)) {
|
||||
return {
|
||||
erlaubt: false,
|
||||
grund:
|
||||
"Dieser Vorgang hat Planstellen oder den Status bewegt. Zurücknehmen lässt er sich nur über den passenden " +
|
||||
"Vorgang, nicht durch Löschen der Zeile.",
|
||||
};
|
||||
}
|
||||
|
||||
// Die Reihenfolge zählt: eine Rückkehr setzt eine Abwesenheit voraus.
|
||||
// Bliebe sie stehen, während die Abwesenheit verschwindet, stünde in der
|
||||
// Akte eine Rückkehr aus dem Nichts — und der Status ergäbe sich aus einem
|
||||
// Eintrag, dessen Ausgangslage gelöscht ist.
|
||||
if (eintrag.event_type === "Karenz" && alle.some((h) => h.event_type === "Rückkehr" && spaeter(h, eintrag))) {
|
||||
return {
|
||||
erlaubt: false,
|
||||
grund: "Zu dieser Abwesenheit gibt es eine Rückkehr. Sie muss zuerst gelöscht werden.",
|
||||
};
|
||||
}
|
||||
|
||||
// Noch nicht wirksam: das geht, aber nur mit Bezug auf den geplanten
|
||||
// Vorgang. Zeilen aus der Zeit vor dieser Verknüpfung haben keinen — sie
|
||||
// liessen sich nur über Person und Datum zuordnen, und das ist nicht
|
||||
// eindeutig genug, um daraufhin eine geplante Änderung abzubrechen.
|
||||
if (eintrag.event_date > heute && !eintrag.pending_id) {
|
||||
return {
|
||||
erlaubt: false,
|
||||
grund:
|
||||
"Zu dieser geplanten Änderung ist kein Vorgang hinterlegt — sie stammt aus der Zeit vor dieser Verknüpfung. " +
|
||||
"Zurücknehmen lässt sie sich nur, indem der Vorgang selbst abgebrochen wird.",
|
||||
};
|
||||
}
|
||||
|
||||
// Eine geplante Abwesenheit hat keine Feldwerte — sie fällt als Ganzes.
|
||||
if (eintrag.event_date > heute && OHNE_FELDWERTE.includes(eintrag.event_type)) {
|
||||
return { erlaubt: true };
|
||||
}
|
||||
|
||||
if (!eintrag.changes || eintrag.changes.length === 0) {
|
||||
return {
|
||||
erlaubt: false,
|
||||
grund: "Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts, worauf zurückgesetzt werden könnte.",
|
||||
};
|
||||
}
|
||||
|
||||
return { erlaubt: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearbeiten ist weiter gefasst als Löschen.
|
||||
*
|
||||
* Der Eintritt lässt sich nicht löschen — er ist der Anfang der Zeitleiste,
|
||||
* und ohne ihn hätte die Person keinen. Sein **Datum** kann aber falsch
|
||||
* erfasst sein, und dann hängt daran mehr als eine Zahl: die erste
|
||||
* Planstellenbesetzung, der frühestmögliche Zeitpunkt jedes weiteren
|
||||
* Ereignisses, die Zugehörigkeit. Die Datenbank prüft das alles beim Ändern;
|
||||
* hier geht es nur darum, den Knopf überhaupt anzubieten.
|
||||
*/
|
||||
export function darfBearbeitetWerden(eintrag: Eintrag, heute: string, alle: Eintrag[] = []): KorrekturUrteil {
|
||||
if (eintrag.event_type === "Eintritt") return { erlaubt: true };
|
||||
return darfKorrigiertWerden(eintrag, heute, alle);
|
||||
}
|
||||
|
||||
/** Später im Sinne der Anzeige: erst das Datum, dann die Erfassungszeit. */
|
||||
function spaeter(a: Eintrag, b: Eintrag): boolean {
|
||||
if (a.event_date !== b.event_date) return a.event_date > b.event_date;
|
||||
return (a.created_at ?? "") > (b.created_at ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Was das Löschen bewirken würde: je Feld entweder Zurücksetzen oder nicht,
|
||||
* weil ein späterer Eintrag dasselbe Feld angefasst hat.
|
||||
*
|
||||
* Dient allein der Ankündigung im Bestätigungsdialog — entschieden wird es
|
||||
* in der Datenbank, an denselben Daten, im selben Augenblick.
|
||||
*/
|
||||
export type Vorschau = { feld: string; von: string | null; auf: string | null; bleibt: boolean };
|
||||
|
||||
export function loeschVorschau(
|
||||
eintrag: Eintrag & { id: string; created_at: string },
|
||||
alle: (Eintrag & { id: string; created_at: string })[]
|
||||
): Vorschau[] {
|
||||
return (eintrag.changes ?? []).map((c) => {
|
||||
const spaeter = alle.some(
|
||||
(h) =>
|
||||
h.id !== eintrag.id &&
|
||||
(h.changes ?? []).some((a) => a.feld === c.feld) &&
|
||||
(h.event_date > eintrag.event_date ||
|
||||
(h.event_date === eintrag.event_date && h.created_at > eintrag.created_at))
|
||||
);
|
||||
return { feld: c.feld, von: c.nachher, auf: c.vorher, bleibt: spaeter };
|
||||
});
|
||||
}
|
||||
@@ -68,7 +68,9 @@ export async function bestandLaden(tx: Tx): Promise<Bestand> {
|
||||
jobCodes: new Map(jobs.map((j) => [j.code, j.id])),
|
||||
planstellen: new Map(stellen.map((p) => [p.position_number, { id: p.id, besetzt: besetzt.has(p.id) }])),
|
||||
personalnummern: new Map(personen.map((e) => [e.personnel_number, e.id])),
|
||||
emails: new Set(personen.map((e) => e.email.toLowerCase())),
|
||||
// Ohne Adresse gibt es nichts zu vergleichen — seit sie freiwillig ist,
|
||||
// kann sie fehlen.
|
||||
emails: new Set(personen.filter((e) => e.email).map((e) => e.email!.toLowerCase())),
|
||||
svNummern: new Set(personen.filter((e) => e.sv_nummer).map((e) => normalizeSvnr(e.sv_nummer!))),
|
||||
};
|
||||
}
|
||||
@@ -226,8 +228,19 @@ export async function laden(
|
||||
source: (txt(w.source) ?? "Extern") as never,
|
||||
is_betriebsrat: bool(w.is_betriebsrat, false),
|
||||
has_dienstwagen: bool(w.has_dienstwagen, false),
|
||||
// Ohne Dienstwagen zwingend null, mit Dienstwagen zwingend gesetzt —
|
||||
// so verlangt es chk_dienstwagen_art. Fehlt die Angabe in der Datei,
|
||||
// gilt Verbrenner, wie im übernommenen Bestand.
|
||||
dienstwagen_art: bool(w.has_dienstwagen, false) ? (txt(w.dienstwagen_art) ?? "Verbrenner") : null,
|
||||
emergency_contact_name: txt(w.emergency_contact_name),
|
||||
emergency_contact_phone: txt(w.emergency_contact_phone),
|
||||
emergency_contact_relation: txt(w.emergency_contact_relation),
|
||||
is_laterale_fuehrung: bool(w.is_laterale_fuehrung, false),
|
||||
is_c_level: bool(w.is_c_level, false),
|
||||
has_kuendigungsschutz: bool(w.has_kuendigungsschutz, false),
|
||||
// Ohne Schutz kein Enddatum — chk_kuendigungsschutz_bis weist es sonst
|
||||
// ab, und die ganze Datei scheiterte an einer einzelnen Zeile.
|
||||
kuendigungsschutz_bis: bool(w.has_kuendigungsschutz, false) ? (txt(w.kuendigungsschutz_bis) ?? null) : null,
|
||||
entry_date: eintritt,
|
||||
exit_date: austritt,
|
||||
exit_reason: txt(w.exit_reason),
|
||||
@@ -244,18 +257,15 @@ export async function laden(
|
||||
),
|
||||
};
|
||||
|
||||
// Von Hand geschrieben statt über den Abfragebauer, wegen genau eines
|
||||
// Wortes: OVERRIDING SYSTEM VALUE.
|
||||
//
|
||||
// personnel_number ist GENERATED ALWAYS AS IDENTITY — die Datenbank
|
||||
// vergibt sie und weist einen eigenen Wert sonst ab. Für eine Übernahme
|
||||
// aus einem Altsystem ist das die falsche Richtung: die Nummer steht auf
|
||||
// Lohnzetteln, in Akten und auf Ausweisen. Ein Import, der sie neu
|
||||
// würfelt, ist keine Übernahme.
|
||||
// Weiterhin von Hand geschrieben, aber ohne OVERRIDING SYSTEM VALUE:
|
||||
// personnel_number ist seit 20260811100000 keine Identitätsspalte mehr,
|
||||
// sondern eine gewöhnliche Pflichtangabe — sie muss mit Loga und
|
||||
// Interflex übereinstimmen und wird deshalb überall eingegeben, nicht
|
||||
// vergeben. Für eine Identitätsspalte war das Schlüsselwort nötig; für
|
||||
// eine gewöhnliche wäre es ein Fehler.
|
||||
const spalten = Object.keys(werte);
|
||||
const r = await sql<{ id: string; personnel_number: number }>`
|
||||
insert into employees (${sql.raw(spalten.map((s) => `"${s}"`).join(", "))})
|
||||
overriding system value
|
||||
values (${sql.join(Object.values(werte).map((v) => sql.val(v)))})
|
||||
returning id, personnel_number
|
||||
`.execute(tx);
|
||||
@@ -276,18 +286,9 @@ export async function laden(
|
||||
}
|
||||
bericht.Personen = personenZeilen.length;
|
||||
|
||||
// Den Zähler nachziehen. Ohne das vergibt die Datenbank für die nächste
|
||||
// Neueinstellung eine Nummer, die der Import bereits verbraucht hat — und
|
||||
// der eindeutige Index weist sie ab. Der Fehler träte erst Wochen später
|
||||
// auf, beim ersten Eintritt nach der Übernahme.
|
||||
if (personenZeilen.length > 0) {
|
||||
await sql`
|
||||
select setval(
|
||||
pg_get_serial_sequence('employees', 'personnel_number'),
|
||||
(select max(personnel_number) from employees)
|
||||
)
|
||||
`.execute(tx);
|
||||
}
|
||||
// Kein Fortschreiben eines Zählers mehr: es gibt keinen. Die Nummer wird
|
||||
// bei jeder Einstellung eingegeben, und die Eindeutigkeit sichert der
|
||||
// Index — beim Import wie im Assistenten.
|
||||
|
||||
await einfuegen(tx, "position_assignments", besetzungen);
|
||||
|
||||
|
||||
@@ -224,8 +224,15 @@ export const BLATT_PERSONEN: BlattSchema = {
|
||||
hinweis: "Leer = Österreich.",
|
||||
beispiel: "Österreich",
|
||||
},
|
||||
{ name: "E-Mail", ziel: "email", pflicht: true, typ: { art: "text" }, hinweis: "Eindeutig.", beispiel: "s.aigner@example.at" },
|
||||
{ name: "Telefon", ziel: "phone", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "+43 660 1234567" },
|
||||
{
|
||||
name: "Private E-Mail",
|
||||
ziel: "email",
|
||||
pflicht: false,
|
||||
typ: { art: "text" },
|
||||
hinweis: "Freiwillig. Eindeutig, wenn angegeben.",
|
||||
beispiel: "s.aigner@example.at",
|
||||
},
|
||||
{ name: "Private Telefonnummer", ziel: "phone", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "+43 660 1234567" },
|
||||
{ name: "Adresse", ziel: "address", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "Hauptstraße 1" },
|
||||
{ name: "PLZ", ziel: "postal_code", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "1010" },
|
||||
{ name: "Ort", ziel: "city", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "Wien" },
|
||||
@@ -350,8 +357,56 @@ export const BLATT_PERSONEN: BlattSchema = {
|
||||
},
|
||||
{ name: "Betriebsrat", ziel: "is_betriebsrat", pflicht: false, typ: { art: "janein" }, hinweis: "Leer = nein.", beispiel: "nein" },
|
||||
{ name: "Dienstwagen", ziel: "has_dienstwagen", pflicht: false, typ: { art: "janein" }, hinweis: "", beispiel: "nein" },
|
||||
{
|
||||
name: "Antriebsart",
|
||||
ziel: "dienstwagen_art",
|
||||
pflicht: false,
|
||||
typ: { art: "auswahl", werte: ["Verbrenner", "Elektro"] },
|
||||
hinweis: "Pflicht, wenn ein Dienstwagen eingetragen ist; sonst leer lassen.",
|
||||
beispiel: "",
|
||||
},
|
||||
{
|
||||
name: "Notfallkontakt",
|
||||
ziel: "emergency_contact_name",
|
||||
pflicht: false,
|
||||
typ: { art: "text" },
|
||||
hinweis: "Name. Nur zusammen mit der Telefonnummer.",
|
||||
beispiel: "",
|
||||
},
|
||||
{
|
||||
name: "Notfallkontakt Telefon",
|
||||
ziel: "emergency_contact_phone",
|
||||
pflicht: false,
|
||||
typ: { art: "text" },
|
||||
hinweis: "Nur zusammen mit dem Namen.",
|
||||
beispiel: "",
|
||||
},
|
||||
{
|
||||
name: "Notfallkontakt Verhältnis",
|
||||
ziel: "emergency_contact_relation",
|
||||
pflicht: false,
|
||||
typ: { art: "text" },
|
||||
hinweis: "Zum Beispiel Gattin, Bruder, Freundin.",
|
||||
beispiel: "",
|
||||
},
|
||||
{ name: "Laterale Führung", ziel: "is_laterale_fuehrung", pflicht: false, typ: { art: "janein" }, hinweis: "", beispiel: "nein" },
|
||||
{ name: "C-Level", ziel: "is_c_level", pflicht: false, typ: { art: "janein" }, hinweis: "", beispiel: "nein" },
|
||||
{
|
||||
name: "Besonderer Kündigungsschutz",
|
||||
ziel: "has_kuendigungsschutz",
|
||||
pflicht: false,
|
||||
typ: { art: "janein" },
|
||||
hinweis: "Leer = nein.",
|
||||
beispiel: "nein",
|
||||
},
|
||||
{
|
||||
name: "Kündigungsschutz bis",
|
||||
ziel: "kuendigungsschutz_bis",
|
||||
pflicht: false,
|
||||
typ: { art: "datum" },
|
||||
hinweis: "Nur mit gesetztem Schutz; freiwillig.",
|
||||
beispiel: "",
|
||||
},
|
||||
{
|
||||
name: "Abwesenheit ab",
|
||||
ziel: "karenz_start_date",
|
||||
|
||||
@@ -277,9 +277,9 @@ export function pruefe(blaetter: ImportSheet[], bestand: Bestand = LEERER_BESTAN
|
||||
|
||||
const email = s(w.email)?.toLowerCase();
|
||||
if (email) {
|
||||
if (emails.has(email)) melde("Personen", z.zeile, "E-Mail", "Kommt bereits vor.", email);
|
||||
if (emails.has(email)) melde("Personen", z.zeile, "Private E-Mail", "Kommt bereits vor.", email);
|
||||
emails.add(email);
|
||||
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) melde("Personen", z.zeile, "E-Mail", "Sieht nicht wie eine Adresse aus.", email);
|
||||
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) melde("Personen", z.zeile, "Private E-Mail", "Sieht nicht wie eine Adresse aus.", email);
|
||||
}
|
||||
|
||||
const geburt = s(w.birth_date);
|
||||
|
||||
55
lib/notes.ts
55
lib/notes.ts
@@ -1,10 +1,21 @@
|
||||
import type { Tx } from "./db";
|
||||
import { jsonArrayFrom, zeitstempel } from "./db/json";
|
||||
import { fmtName } from "./format";
|
||||
import type { OrgEb } from "./org";
|
||||
import type { Database } from "./supabase/types";
|
||||
|
||||
export type OpenNote = Database["public"]["Tables"]["employee_notes"]["Row"] & {
|
||||
employeeName: string;
|
||||
};
|
||||
|
||||
/** Was die Teilabfrage liefert: die Notiz plus den Namen aus dem Join. */
|
||||
export type NotizZeile = Omit<Database["public"]["Tables"]["employee_notes"]["Row"], "created_at" | "done_at"> & {
|
||||
created_at: string;
|
||||
done_at: string | null;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
};
|
||||
|
||||
// „Meine Notizen" (Topbar-Glocke): das geteilte, mitarbeiterübergreifende
|
||||
// Postfach aller noch nicht erledigten HR-Notizen — unabhängig davon, wer sie
|
||||
// verfasst hat oder zu wem sie gehören (mit Nutzer abgestimmt).
|
||||
@@ -12,21 +23,51 @@ export type OpenNote = Database["public"]["Tables"]["employee_notes"]["Row"] & {
|
||||
// Früher zwei Abfragen, in JavaScript zusammengeführt, weil die API-Schicht
|
||||
// für eine einzelne verschachtelte Abfrage keine Verknüpfung anbot. Am
|
||||
// direkten Zugang ist es schlicht ein Join.
|
||||
export async function loadOpenNotes(tx: Tx): Promise<OpenNote[]> {
|
||||
const rows = await tx
|
||||
|
||||
/**
|
||||
* Die offenen Notizen als *Teilabfrage* — zum Einhängen in die eine Abfrage,
|
||||
* die eine Seite ohnehin stellt (lib/db/json.ts).
|
||||
*
|
||||
* Die beiden Zeitstempel gehen durch zeitstempel(): innerhalb von JSON
|
||||
* formatiert Postgres sie anders als der Treiber es sonst täte, und der
|
||||
* Unterschied fällt erst beim Vergleichen auf.
|
||||
*/
|
||||
export function offeneNotizenAbfrage(eb: OrgEb) {
|
||||
return eb
|
||||
.selectFrom("employee_notes as n")
|
||||
.leftJoin("employees as e", "e.id", "n.employee_id")
|
||||
.selectAll("n")
|
||||
.select(["e.first_name", "e.last_name"])
|
||||
.select([
|
||||
"n.id",
|
||||
"n.employee_id",
|
||||
"n.author_user_id",
|
||||
"n.author_name",
|
||||
"n.category",
|
||||
"n.note_text",
|
||||
"n.due_date",
|
||||
"n.done",
|
||||
"n.done_by",
|
||||
"e.first_name",
|
||||
"e.last_name",
|
||||
])
|
||||
.select((x) => [zeitstempel(x.ref("n.created_at")).as("created_at"), zeitstempel(x.ref("n.done_at")).as("done_at")])
|
||||
.where("n.done", "=", false)
|
||||
.orderBy("n.created_at", "desc")
|
||||
.execute();
|
||||
.orderBy("n.created_at", "desc");
|
||||
}
|
||||
|
||||
/** Der reine Teil: aus den Zeilen die Notizen mit lesbarem Namen. */
|
||||
export function baueOffeneNotizen(rows: NotizZeile[]): OpenNote[] {
|
||||
return rows.map((row) => {
|
||||
const { first_name, last_name, ...note } = row;
|
||||
return {
|
||||
...(note as Database["public"]["Tables"]["employee_notes"]["Row"]),
|
||||
employeeName: first_name && last_name ? `${first_name} ${last_name}` : "Unbekannt",
|
||||
employeeName: first_name && last_name ? fmtName(first_name, last_name) : "Unbekannt",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadOpenNotes(tx: Tx): Promise<OpenNote[]> {
|
||||
const { notes } = await tx
|
||||
.selectNoFrom((eb) => [jsonArrayFrom(offeneNotizenAbfrage(eb)).as("notes")])
|
||||
.executeTakeFirstOrThrow();
|
||||
return baueOffeneNotizen(notes as NotizZeile[]);
|
||||
}
|
||||
|
||||
42
lib/org.ts
42
lib/org.ts
@@ -1,6 +1,15 @@
|
||||
import type { ExpressionBuilder } from "kysely";
|
||||
import type { Tx } from "./db";
|
||||
import { jsonArrayFrom } from "./db/json";
|
||||
import type { Schema } from "./db/schema";
|
||||
import type { Database } from "./supabase/types";
|
||||
|
||||
/**
|
||||
* Der Ausdrucksbauer einer Abfrage ohne eigene Tabelle (selectNoFrom) — das
|
||||
* ist der Ort, an dem Teilabfragen zusammengehängt werden.
|
||||
*/
|
||||
export type OrgEb = ExpressionBuilder<Schema, never>;
|
||||
|
||||
// Die Organisation ist ein Baum, keine drei Tabellen mehr. Alles, was früher
|
||||
// aus divisions/departments/teams zusammengesteckt wurde, ergibt sich jetzt
|
||||
// aus org_units.parent_id — und damit funktioniert es auch für eine fünfte
|
||||
@@ -16,7 +25,7 @@ export type OrgUnit = {
|
||||
unit_type: OrgUnitType;
|
||||
};
|
||||
|
||||
type Location = Database["public"]["Tables"]["locations"]["Row"];
|
||||
export type Location = Database["public"]["Tables"]["locations"]["Row"];
|
||||
|
||||
export type OrgMaps = {
|
||||
units: Map<string, OrgUnit>;
|
||||
@@ -31,15 +40,30 @@ export type OrgMaps = {
|
||||
|
||||
// Die Referenzdaten sind winzig (60 Einheiten, 5 Standorte) — sie werden
|
||||
// ganz geladen und im Speicher verknüpft, statt je Zeile nachzuschlagen.
|
||||
/**
|
||||
* Einheiten und Standorte als *Teilabfragen* — zum Einhängen in die eine
|
||||
* Abfrage, die eine Seite ohnehin stellt.
|
||||
*
|
||||
* Fast jede Seite braucht den Baum. Als eigene Abfrage wäre das je Seitenauf-
|
||||
* bau eine zusätzliche Rundreise für 65 Zeilen, die sich selten ändern; als
|
||||
* Teilabfrage kostet sie nichts (lib/db/json.ts).
|
||||
*/
|
||||
export function orgMapsAbfragen(eb: OrgEb) {
|
||||
return [
|
||||
jsonArrayFrom(
|
||||
eb.selectFrom("org_units").select(["id", "org_number", "name", "parent_id", "unit_type"]).orderBy("org_number")
|
||||
).as("units"),
|
||||
jsonArrayFrom(eb.selectFrom("locations").selectAll().orderBy("name")).as("locations"),
|
||||
] as const;
|
||||
}
|
||||
|
||||
export async function loadOrgMaps(tx: Tx): Promise<OrgMaps> {
|
||||
const [units, locations] = await Promise.all([
|
||||
tx
|
||||
.selectFrom("org_units")
|
||||
.select(["id", "org_number", "name", "parent_id", "unit_type"])
|
||||
.orderBy("org_number")
|
||||
.execute(),
|
||||
tx.selectFrom("locations").selectAll().orderBy("name").execute(),
|
||||
]);
|
||||
// Beides in einer Rundreise. Ein Promise.all wäre hier keine
|
||||
// Gleichzeitigkeit gewesen, sondern eine Schlange — der Grund steht in
|
||||
// lib/db/json.ts.
|
||||
const { units, locations } = await tx
|
||||
.selectNoFrom((eb) => [...orgMapsAbfragen(eb)])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return buildOrgMaps(units as OrgUnit[], locations as Location[]);
|
||||
}
|
||||
|
||||
152
lib/orgchart-print.ts
Normal file
152
lib/orgchart-print.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import type { OrgEmployee, OrgUnitNode, OrgVacancy } from "@/components/orgchart/types";
|
||||
import { fmtName } from "@/lib/format";
|
||||
|
||||
// Das Modell für den Druck.
|
||||
//
|
||||
// Der Bildschirm zeigt das Organigramm als Fläche, in die man hineinzoomt.
|
||||
// Papier kann das nicht: es hat eine feste Grösse, und was darauf nicht
|
||||
// lesbar ist, ist verloren. Ein Ausdruck der Leinwand — 850 Personen auf eine
|
||||
// Seite skaliert — ergibt Kästchen von zwei Millimetern.
|
||||
//
|
||||
// Deshalb wird für den Druck **umgebaut statt verkleinert**: eine Übersicht
|
||||
// über die obersten Ebenen, und danach ein Bereich je Seite als eigener,
|
||||
// vollständiger Baum. Jede Seite ist für sich verständlich und trägt ihre
|
||||
// Überschrift — auch wenn jemand nur Blatt sieben aus dem Drucker nimmt.
|
||||
//
|
||||
// Der Baum bleibt hier ein Baum: `children` und `totalPeople` sind das, woraus
|
||||
// die Auswahl („welche Bereiche, wie tief") und die gezeichnete Hierarchie
|
||||
// gleichermassen entstehen.
|
||||
|
||||
export type PrintPerson = {
|
||||
id: string;
|
||||
/** Getrennt mitgeführt, weil danach sortiert wird — siehe nachName. */
|
||||
lastName: string;
|
||||
name: string;
|
||||
jobTitle: string;
|
||||
personnelNumber: number;
|
||||
isChief: boolean;
|
||||
absent: boolean;
|
||||
absenceType: string | null;
|
||||
};
|
||||
|
||||
export type PrintUnit = {
|
||||
id: string;
|
||||
name: string;
|
||||
unitType: OrgUnitNode["unit_type"];
|
||||
orgNumber: string;
|
||||
/** Die Leitung dieser Einheit, falls besetzt. */
|
||||
chief: PrintPerson | null;
|
||||
/** Alle übrigen Personen, die unmittelbar in dieser Einheit sitzen. */
|
||||
members: PrintPerson[];
|
||||
/** Unbesetzte Planstellen dieser Einheit. */
|
||||
vacancies: { positionNumber: string; jobTitle: string; isChief: boolean }[];
|
||||
children: PrintUnit[];
|
||||
/** Personen in dieser Einheit und allem darunter — für die Übersicht. */
|
||||
totalPeople: number;
|
||||
};
|
||||
|
||||
export type PrintModel = {
|
||||
root: PrintUnit | null;
|
||||
/** Je Bereich eine Seite. Bei nur einer Ebene bleibt es bei der Übersicht. */
|
||||
divisions: PrintUnit[];
|
||||
totals: { people: number; vacancies: number; units: number };
|
||||
};
|
||||
|
||||
function personFrom(e: OrgEmployee): PrintPerson {
|
||||
return {
|
||||
id: e.id,
|
||||
lastName: e.last_name,
|
||||
name: fmtName(e.first_name, e.last_name),
|
||||
jobTitle: e.job_title,
|
||||
personnelNumber: e.personnel_number,
|
||||
isChief: e.is_chief,
|
||||
absent: e.absent,
|
||||
absenceType: e.absence_type,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Nachname, dann Vorname — wie in jeder Liste, die jemand durchsucht.
|
||||
*
|
||||
* Der Nachname kommt aus dem eigenen Feld, nicht aus dem zusammengesetzten
|
||||
* Namen. Vorher wurde das letzte Wort genommen, was bei „Hannah Winkler"
|
||||
* zufällig stimmte; seit die Anzeige „Winkler, Hannah" lautet, wäre das
|
||||
* letzte Wort der Vorname, und die Liste stünde nach Vornamen sortiert da.
|
||||
*/
|
||||
function nachName(a: PrintPerson, b: PrintPerson): number {
|
||||
return a.lastName.localeCompare(b.lastName, "de") || a.name.localeCompare(b.name, "de");
|
||||
}
|
||||
|
||||
export function buildPrintModel(
|
||||
units: OrgUnitNode[],
|
||||
employees: OrgEmployee[],
|
||||
vacancies: OrgVacancy[]
|
||||
): PrintModel {
|
||||
const byUnit = new Map<string, PrintUnit>();
|
||||
for (const u of units) {
|
||||
byUnit.set(u.id, {
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
unitType: u.unit_type,
|
||||
orgNumber: u.org_number,
|
||||
chief: null,
|
||||
members: [],
|
||||
vacancies: [],
|
||||
children: [],
|
||||
totalPeople: 0,
|
||||
});
|
||||
}
|
||||
|
||||
for (const e of employees) {
|
||||
const unit = byUnit.get(e.org_unit_id);
|
||||
if (!unit) continue;
|
||||
const p = personFrom(e);
|
||||
// Die Leitung steht oben und nicht in der Namensliste — sie ist die
|
||||
// Antwort auf „wer führt das hier", und die soll man nicht suchen.
|
||||
if (p.isChief && !unit.chief) unit.chief = p;
|
||||
else unit.members.push(p);
|
||||
}
|
||||
|
||||
for (const v of vacancies) {
|
||||
const unit = byUnit.get(v.org_unit_id);
|
||||
if (!unit) continue;
|
||||
unit.vacancies.push({ positionNumber: v.position_number, jobTitle: v.job_title, isChief: v.is_chief });
|
||||
}
|
||||
|
||||
// Bäume verknüpfen, in der Reihenfolge der Orgnummern — dieselbe wie
|
||||
// überall sonst in der Anwendung.
|
||||
const sortiert = [...units].sort((a, b) => a.org_number.localeCompare(b.org_number, "de"));
|
||||
let root: PrintUnit | null = null;
|
||||
for (const u of sortiert) {
|
||||
const node = byUnit.get(u.id)!;
|
||||
if (u.parent_id && byUnit.has(u.parent_id)) byUnit.get(u.parent_id)!.children.push(node);
|
||||
else if (u.unit_type === "Gesellschaft") root = node;
|
||||
}
|
||||
|
||||
for (const unit of byUnit.values()) {
|
||||
unit.members.sort(nachName);
|
||||
unit.vacancies.sort((a, b) => a.jobTitle.localeCompare(b.jobTitle, "de"));
|
||||
}
|
||||
|
||||
// Summen von unten nach oben. Rekursiv über höchstens vier Ebenen — die
|
||||
// Tiefe ist durch das Modell begrenzt (Gesellschaft/Bereich/Abteilung/Team).
|
||||
function zaehle(unit: PrintUnit): number {
|
||||
const eigene = (unit.chief ? 1 : 0) + unit.members.length;
|
||||
unit.totalPeople = eigene + unit.children.reduce((s, c) => s + zaehle(c), 0);
|
||||
return unit.totalPeople;
|
||||
}
|
||||
if (root) zaehle(root);
|
||||
else for (const u of byUnit.values()) if (!units.find((x) => x.id === u.id)?.parent_id) zaehle(u);
|
||||
|
||||
const divisions = root ? root.children : [...byUnit.values()].filter((u) => u.unitType === "Bereich");
|
||||
|
||||
return {
|
||||
root,
|
||||
divisions,
|
||||
totals: {
|
||||
people: employees.length,
|
||||
vacancies: vacancies.length,
|
||||
units: units.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,34 @@
|
||||
import { sql, type Tx } from "./db";
|
||||
import { addDaysIso } from "./format";
|
||||
import type { OrgEb } from "./org";
|
||||
|
||||
/**
|
||||
* Der Stichtag, zu dem die Organisation *dieser* Person betrachtet wird.
|
||||
*
|
||||
* „Heute" ist für zwei Gruppen die falsche Frage. Wer am 01.09. anfängt, hat
|
||||
* heute keine laufende Besetzung — om_reporting_lines() liefert dann gar
|
||||
* keine Zeile, und die Akte behauptet „Keine Führungskraft", obwohl das Team
|
||||
* eine hat. Wer ausgetreten ist, ebenso.
|
||||
*
|
||||
* Die Stammdaten zeigten die Planstelle trotzdem an, weil pickPlacements()
|
||||
* ersatzweise auf die nächstbeste Zuordnung zurückfällt. Zwei Vorstellungen
|
||||
* davon, wo jemand sitzt — eine nachsichtige und eine strenge — und in der
|
||||
* Akte standen sie nebeneinander.
|
||||
*
|
||||
* Deshalb: der Stichtag wird in das Beschäftigungsverhältnis hineingezogen.
|
||||
* Für künftige Eintritte auf den ersten Tag, für Ausgetretene auf den
|
||||
* letzten. Sonst bleibt es heute.
|
||||
*/
|
||||
export function orgAsOf(
|
||||
employee: { entry_date: string; exit_date: string | null },
|
||||
today: string
|
||||
): string {
|
||||
if (employee.entry_date > today) return employee.entry_date;
|
||||
// exit_date ist ausschliessend wie überall im Modell: der letzte Arbeitstag
|
||||
// ist der Tag davor.
|
||||
if (employee.exit_date && employee.exit_date <= today) return addDaysIso(employee.exit_date, -1);
|
||||
return today;
|
||||
}
|
||||
|
||||
// Wo jemand in der Organisation steht, steht nicht mehr auf der Person. Es
|
||||
// ergibt sich aus der Planstelle, die sie zum Stichtag innehat:
|
||||
@@ -68,6 +98,34 @@ export function pickPlacements(rows: Row[], asOf: string): Map<string, Placement
|
||||
return byEmployee;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Besetzungen als *Teilabfrage* — zum Einhängen in die eine Abfrage, die
|
||||
* eine Seite ohnehin stellt (lib/db/json.ts).
|
||||
*
|
||||
* Ein Join statt einer eingebetteten Ressource. Und ohne die
|
||||
* 1000-Zeilen-Grenze von PostgREST fällt das seitenweise Nachladen weg, das
|
||||
* es dafür brauchte.
|
||||
*/
|
||||
export function besetzungenAbfrage(eb: OrgEb, employeeIds?: string[]) {
|
||||
const q = eb
|
||||
.selectFrom("position_assignments as pa")
|
||||
.innerJoin("om_positions as p", "p.id", "pa.position_id")
|
||||
.innerJoin("jobs as j", "j.id", "p.job_id")
|
||||
.select([
|
||||
"pa.employee_id",
|
||||
"pa.valid_from",
|
||||
"pa.valid_to",
|
||||
"p.id as position_id",
|
||||
"p.position_number",
|
||||
"p.org_unit_id",
|
||||
"p.is_chief",
|
||||
"j.title as job_title",
|
||||
])
|
||||
.orderBy("pa.employee_id");
|
||||
|
||||
return employeeIds ? q.where("pa.employee_id", "in", employeeIds) : q;
|
||||
}
|
||||
|
||||
export async function loadPlacements(
|
||||
tx: Tx,
|
||||
{ asOf, employeeIds }: { asOf: string; employeeIds?: string[] }
|
||||
@@ -119,6 +177,25 @@ export type ReportingLine = {
|
||||
* bei der Detailseite wandern damit neun Zeilen über die Leitung statt
|
||||
* achthundert.
|
||||
*/
|
||||
/**
|
||||
* Die Berichtslinien als *Teilabfrage* — zum Einhängen in die eine Abfrage,
|
||||
* die eine Seite ohnehin stellt (lib/db/json.ts).
|
||||
*
|
||||
* `filter` schränkt die Funktion selbst ein, nicht das Ergebnis im Speicher.
|
||||
*/
|
||||
export function berichtslinienAbfrage(
|
||||
eb: OrgEb,
|
||||
asOf: string,
|
||||
filter?: { employeeId?: string; actingManagerId?: string }
|
||||
) {
|
||||
let q = eb
|
||||
.selectFrom(sql<ReportingLine>`om_reporting_lines(${asOf}::date)`.as("l"))
|
||||
.select(["l.employee_id", "l.position_id", "l.org_unit_id", "l.is_chief", "l.formal_manager_id", "l.acting_manager_id"]);
|
||||
if (filter?.employeeId) q = q.where("l.employee_id", "=", filter.employeeId);
|
||||
if (filter?.actingManagerId) q = q.where("l.acting_manager_id", "=", filter.actingManagerId);
|
||||
return q;
|
||||
}
|
||||
|
||||
export async function loadReportingLines(
|
||||
tx: Tx,
|
||||
asOf: string,
|
||||
|
||||
169
lib/positions.ts
169
lib/positions.ts
@@ -1,6 +1,8 @@
|
||||
import { kontierungAm, kontierungenAbfrage, type KontierungsZeile } from "./cost-centers";
|
||||
import type { Tx } from "./db";
|
||||
import { todayIso } from "./format";
|
||||
import { breadcrumbLabel, loadOrgMaps, type OrgMaps } from "./org";
|
||||
import { jsonArrayFrom } from "./db/json";
|
||||
import { fmtName, todayIso } from "./format";
|
||||
import { breadcrumbLabel, buildOrgMaps, orgMapsAbfragen, type OrgEb, type OrgMaps } from "./org";
|
||||
|
||||
// Eine offene Stelle ist keine eigene Sache mehr. Sie ist eine Planstelle
|
||||
// ohne laufende Besetzung — Vakanz ist eine Eigenschaft der Planstelle, kein
|
||||
@@ -30,6 +32,14 @@ export type OpenPositionResolved = {
|
||||
orgLabel: string;
|
||||
/** Seit wann die Stelle unbesetzt ist: Ende der letzten Besetzung, sonst ihr Beginn. */
|
||||
vacantSince: string;
|
||||
/**
|
||||
* Wessen Budget die Stelle belastet.
|
||||
*
|
||||
* Der Grund, warum die Kostenstelle an der Planstelle hängt und nicht an der
|
||||
* Person: hier gibt es keine Person, und trotzdem — gerade deshalb — ist die
|
||||
* Frage offen, was diese Stelle kostet und wer sie bezahlt.
|
||||
*/
|
||||
kostenstelle: { code: string; name: string } | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -42,41 +52,53 @@ function managerUnitFor(maps: OrgMaps, orgUnitId: string, isChief: boolean): str
|
||||
return maps.units.get(orgUnitId)?.parent_id ?? null;
|
||||
}
|
||||
|
||||
export async function loadOpenPositions(tx: Tx): Promise<OpenPositionResolved[]> {
|
||||
const asOf = todayIso();
|
||||
export type OffeneStelle = {
|
||||
id: string;
|
||||
position_number: string;
|
||||
org_unit_id: string;
|
||||
is_chief: boolean;
|
||||
valid_from: string;
|
||||
valid_to: string | null;
|
||||
title: string;
|
||||
};
|
||||
|
||||
const [orgMaps, open] = await Promise.all([
|
||||
loadOrgMaps(tx),
|
||||
// Unbesetzt heisst: keine Zuordnung, die noch gilt — **auch keine, die
|
||||
// erst beginnt.**
|
||||
//
|
||||
// Der Unterschied ist kein Feinschliff. Wer unterschrieben hat und am
|
||||
// 24.09. anfängt, belegt die Planstelle heute schon; sie steht nur noch
|
||||
// nicht besetzt da. Die frühere Fassung fragte „sitzt heute jemand
|
||||
// darauf?" und listete solche Stellen als offen — mit „seit 2 Tagen
|
||||
// unbesetzt" daneben. Aus dieser Liste speist sich auch die Auswahl im
|
||||
// Einstellungsassistenten, also lud sie dazu ein, dieselbe Stelle ein
|
||||
// zweites Mal zu besetzen. Aufgefallen wäre das erst am Teilindex der
|
||||
// Datenbank, nach dem Gespräch mit der zweiten Person.
|
||||
//
|
||||
// Eine beendete Zuordnung (valid_to in der Vergangenheit) gibt die Stelle
|
||||
// dagegen wieder frei — deshalb bleibt die Bedingung auf valid_to.
|
||||
//
|
||||
// Als NOT EXISTS in der Datenbank statt als Filter über alle Planstellen
|
||||
// im Speicher.
|
||||
tx
|
||||
/**
|
||||
* Die unbesetzten Planstellen als *Teilabfrage*.
|
||||
*
|
||||
* Als eigene Funktion, damit eine Seite sie zusammen mit ihren übrigen
|
||||
* Lesevorgängen in einer Rundreise holen kann statt in einer eigenen — siehe
|
||||
* lib/db/json.ts. Wer nichts weiter zu holen hat, nimmt loadOpenPositions().
|
||||
*
|
||||
* Unbesetzt heisst: keine Zuordnung, die noch gilt — **auch keine, die erst
|
||||
* beginnt.**
|
||||
*
|
||||
* Der Unterschied ist kein Feinschliff. Wer unterschrieben hat und am 24.09.
|
||||
* anfängt, belegt die Planstelle heute schon; sie steht nur noch nicht besetzt
|
||||
* da. Die frühere Fassung fragte „sitzt heute jemand darauf?" und listete
|
||||
* solche Stellen als offen — mit „seit 2 Tagen unbesetzt" daneben. Aus dieser
|
||||
* Liste speist sich auch die Auswahl im Einstellungsassistenten, also lud sie
|
||||
* dazu ein, dieselbe Stelle ein zweites Mal zu besetzen. Aufgefallen wäre das
|
||||
* erst am Teilindex der Datenbank, nach dem Gespräch mit der zweiten Person.
|
||||
*
|
||||
* Eine beendete Zuordnung (valid_to in der Vergangenheit) gibt die Stelle
|
||||
* dagegen wieder frei — deshalb bleibt die Bedingung auf valid_to.
|
||||
*
|
||||
* Künftige Planstellen bleiben drin. Sie sind der Grund, warum diese Ansicht
|
||||
* existiert: eine Stelle, die zum Quartalswechsel entsteht, muss vorher
|
||||
* sichtbar und planbar sein.
|
||||
*/
|
||||
export function offeneStellenAbfrage(eb: OrgEb, asOf: string) {
|
||||
return eb
|
||||
.selectFrom("om_positions as p")
|
||||
.innerJoin("jobs as j", "j.id", "p.job_id")
|
||||
.select(["p.id", "p.position_number", "p.org_unit_id", "p.is_chief", "p.valid_from", "p.valid_to", "j.title"])
|
||||
// Künftige Planstellen bleiben drin. Sie sind der Grund, warum diese
|
||||
// Ansicht existiert: eine Stelle, die zum Quartalswechsel entsteht,
|
||||
// muss vorher sichtbar und planbar sein. Vorher fielen sie durch das
|
||||
// Raster und tauchten am Stichtag unangekündigt auf.
|
||||
.where((eb) => eb.or([eb("p.valid_to", "is", null), eb("p.valid_to", ">", asOf)]))
|
||||
.where((eb) =>
|
||||
eb.not(
|
||||
eb.exists(
|
||||
eb
|
||||
.where((e) => e.or([e("p.valid_to", "is", null), e("p.valid_to", ">", asOf)]))
|
||||
// Als NOT EXISTS in der Datenbank statt als Filter über alle Planstellen
|
||||
// im Speicher.
|
||||
.where((e) =>
|
||||
e.not(
|
||||
e.exists(
|
||||
e
|
||||
.selectFrom("position_assignments as a")
|
||||
.select("a.id")
|
||||
.whereRef("a.position_id", "=", "p.id")
|
||||
@@ -84,33 +106,41 @@ export async function loadOpenPositions(tx: Tx): Promise<OpenPositionResolved[]>
|
||||
)
|
||||
)
|
||||
)
|
||||
.orderBy("p.position_number")
|
||||
.execute(),
|
||||
]);
|
||||
.orderBy("p.position_number");
|
||||
}
|
||||
|
||||
/**
|
||||
* Der zweite Teil: die beiden Nachschläge, die erst gestellt werden können,
|
||||
* wenn feststeht, welche Stellen offen sind — seit wann sie leer stehen, und
|
||||
* wer sie führen würde. Beide in einer Rundreise.
|
||||
*/
|
||||
export async function resolveOpenPositions(
|
||||
tx: Tx,
|
||||
orgMaps: OrgMaps,
|
||||
open: OffeneStelle[],
|
||||
asOf: string = todayIso()
|
||||
): Promise<OpenPositionResolved[]> {
|
||||
if (open.length === 0) return [];
|
||||
|
||||
const positionIds = open.map((p) => p.id);
|
||||
const chiefUnitIds = Array.from(
|
||||
new Set(
|
||||
open.map((p) => managerUnitFor(orgMaps, p.org_unit_id, p.is_chief)).filter((id): id is string => Boolean(id))
|
||||
)
|
||||
);
|
||||
|
||||
// Zwei Nachschläge: seit wann die Stelle leer steht, und wer sie führen
|
||||
// würde.
|
||||
const [ended, chiefs] = await Promise.all([
|
||||
tx
|
||||
const { ended, chiefs, costRows } = await tx
|
||||
.selectNoFrom((eb) => [
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("position_assignments")
|
||||
.select(["position_id", "valid_to"])
|
||||
.where("position_id", "in", positionIds)
|
||||
.where("valid_to", "is not", null)
|
||||
.execute(),
|
||||
(async () => {
|
||||
const chiefUnitIds = Array.from(
|
||||
new Set(
|
||||
open
|
||||
.map((p) => managerUnitFor(orgMaps, p.org_unit_id, p.is_chief))
|
||||
.filter((id): id is string => Boolean(id))
|
||||
)
|
||||
);
|
||||
if (chiefUnitIds.length === 0) return [];
|
||||
return tx
|
||||
).as("ended"),
|
||||
jsonArrayFrom(kontierungenAbfrage(eb, positionIds)).as("costRows"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("om_positions as p")
|
||||
.innerJoin("position_assignments as a", "a.position_id", "p.id")
|
||||
.innerJoin("employees as e", "e.id", "a.employee_id")
|
||||
@@ -118,17 +148,20 @@ export async function loadOpenPositions(tx: Tx): Promise<OpenPositionResolved[]>
|
||||
.where("p.is_chief", "=", true)
|
||||
.where("p.valid_to", "is", null)
|
||||
.where("a.valid_to", "is", null)
|
||||
.where("p.org_unit_id", "in", chiefUnitIds)
|
||||
.execute();
|
||||
})(),
|
||||
]);
|
||||
// Ohne Einheiten darf hier keine Liste stehen: `in ()` ist ein
|
||||
// Syntaxfehler, und `in ('')` bricht an der uuid-Umwandlung ab
|
||||
// statt nichts zu liefern. Dann also eine Bedingung, die nie zutrifft.
|
||||
.where((e) => (chiefUnitIds.length > 0 ? e("p.org_unit_id", "in", chiefUnitIds) : e.lit(false)))
|
||||
).as("chiefs"),
|
||||
])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const lastEndByPosition = new Map<string, string>();
|
||||
for (const e of ended) {
|
||||
const prev = lastEndByPosition.get(e.position_id);
|
||||
if (e.valid_to && (!prev || e.valid_to > prev)) lastEndByPosition.set(e.position_id, e.valid_to);
|
||||
}
|
||||
const chiefNameByUnit = new Map(chiefs.map((c) => [c.org_unit_id, `${c.first_name} ${c.last_name}`]));
|
||||
const chiefNameByUnit = new Map(chiefs.map((c) => [c.org_unit_id, fmtName(c.first_name, c.last_name)]));
|
||||
|
||||
return open.map((p) => {
|
||||
const managerUnit = managerUnitFor(orgMaps, p.org_unit_id, p.is_chief);
|
||||
@@ -145,6 +178,30 @@ export async function loadOpenPositions(tx: Tx): Promise<OpenPositionResolved[]>
|
||||
managerName: managerUnit ? (chiefNameByUnit.get(managerUnit) ?? null) : null,
|
||||
orgLabel: breadcrumbLabel(orgMaps, p.org_unit_id),
|
||||
vacantSince: lastEndByPosition.get(p.id) ?? p.valid_from,
|
||||
// Nicht zum heutigen Tag, sondern zu dem, an dem die Stelle besteht:
|
||||
// eine künftige Planstelle wird erst mit ihrem Beginn kontiert, hätte
|
||||
// heute also keine Kostenstelle — obwohl gerade sie geplant wird.
|
||||
kostenstelle: kontierungAm(costRows as KontierungsZeile[], p.id, p.valid_from > asOf ? p.valid_from : asOf),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Der bequeme Weg: Organisationsbaum und offene Stellen in einer Rundreise,
|
||||
* die beiden Nachschläge in einer zweiten.
|
||||
*
|
||||
* Zwei sind das Minimum — was in der zweiten steht, hängt vom Ergebnis der
|
||||
* ersten ab.
|
||||
*/
|
||||
export async function loadOpenPositions(tx: Tx): Promise<OpenPositionResolved[]> {
|
||||
const asOf = todayIso();
|
||||
|
||||
const { units, locations, open } = await tx
|
||||
.selectNoFrom((eb) => [
|
||||
...orgMapsAbfragen(eb),
|
||||
jsonArrayFrom(offeneStellenAbfrage(eb, asOf)).as("open"),
|
||||
])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return resolveOpenPositions(tx, buildOrgMaps(units as never, locations as never), open as OffeneStelle[], asOf);
|
||||
}
|
||||
|
||||
181
lib/report-criteria.ts
Normal file
181
lib/report-criteria.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { ABSENCE_TYPES } from "./absence";
|
||||
import { parseIsoDateParam } from "./reports";
|
||||
import type { Weekday } from "./supabase/types";
|
||||
|
||||
// Ein Verzeichnis aller Auswahlkriterien — für die Oberfläche, die Abfrage
|
||||
// und den Export dasselbe.
|
||||
//
|
||||
// Ein Export, der weniger filtern kann als der Bericht daneben, ist der
|
||||
// Grund, warum Leute Daten nach Excel kippen und dort weiterarbeiten: was
|
||||
// die Anwendung nicht hergibt, wird eben von Hand nachgebaut, und ab da
|
||||
// stimmt es mit nichts mehr überein. Deshalb steht hier jedes Kriterium
|
||||
// einmal, mit seinen erlaubten Werten, und alle drei Seiten lesen aus
|
||||
// derselben Liste:
|
||||
//
|
||||
// * die Oberfläche baut ihre Felder daraus,
|
||||
// * `parseCriteria` prüft die Adresszeile dagegen,
|
||||
// * `applyCriteria` (serverseitig) macht Bedingungen daraus.
|
||||
//
|
||||
// Ein neues Kriterium ist damit ein Eintrag in dieser Datei und sonst
|
||||
// nichts — und es kann nicht passieren, dass es im Bericht wirkt, im Export
|
||||
// aber stillschweigend ignoriert wird.
|
||||
//
|
||||
// Die Prüfung ist nicht bloss Ordnungsliebe: die Werte landen in
|
||||
// SQL-Vergleichen und im Dateinamen des Downloads, also in einem
|
||||
// Content-Disposition-Header. Was nicht in der Liste steht, kommt nicht
|
||||
// durch.
|
||||
|
||||
export type JaNein = "ja" | "nein";
|
||||
|
||||
/** Ein Kriterium mit fester Werteliste. */
|
||||
export type AuswahlKriterium = {
|
||||
key: string;
|
||||
label: string;
|
||||
/** Beschriftung für „keine Einschränkung". */
|
||||
alle: string;
|
||||
optionen: readonly { wert: string; label: string }[];
|
||||
};
|
||||
|
||||
function werte(...w: string[]): { wert: string; label: string }[] {
|
||||
return w.map((x) => ({ wert: x, label: x }));
|
||||
}
|
||||
|
||||
export const AUSWAHL_KRITERIEN: readonly AuswahlKriterium[] = [
|
||||
{ key: "employment", label: "Beschäftigungsart", alle: "Alle Beschäftigungsarten", optionen: werte("Vollzeit", "Teilzeit") },
|
||||
{ key: "contract", label: "Vertragsart", alle: "Alle Vertragsarten", optionen: werte("unbefristet", "befristet") },
|
||||
{ key: "worker", label: "Angestellte:r / Arbeiter:in", alle: "Alle", optionen: werte("Angestellte:r", "Arbeiter:in") },
|
||||
{ key: "kv", label: "Kollektivvertrag", alle: "Alle Kollektivverträge", optionen: werte("Handel", "Süßwaren") },
|
||||
{ key: "paygrade", label: "Paygrade", alle: "Alle Paygrades", optionen: werte("A", "B", "C", "D", "E", "F") },
|
||||
{ key: "source", label: "Intern/Extern", alle: "Alle", optionen: werte("Intern", "Extern") },
|
||||
{
|
||||
key: "gender",
|
||||
label: "Geschlecht",
|
||||
alle: "Alle",
|
||||
optionen: [
|
||||
{ wert: "m", label: "männlich" },
|
||||
{ wert: "w", label: "weiblich" },
|
||||
],
|
||||
},
|
||||
{ key: "dienstwagenArt", label: "Antriebsart Dienstwagen", alle: "Alle Antriebsarten", optionen: werte("Verbrenner", "Elektro") },
|
||||
{ key: "absence", label: "Art der Langzeitabwesenheit", alle: "Alle Arten", optionen: werte(...ABSENCE_TYPES) },
|
||||
{
|
||||
key: "teilzeitArt",
|
||||
label: "Teilzeitvariante",
|
||||
alle: "Alle Teilzeitvarianten",
|
||||
optionen: werte("Bildungsteilzeit", "Elternteilzeit", "Pflegeteilzeit", "Wiedereingliederungsteilzeit"),
|
||||
},
|
||||
{ key: "weekday", label: "Arbeitet am", alle: "Beliebiger Wochentag", optionen: werte("Mo", "Di", "Mi", "Do", "Fr", "Sa", "So") },
|
||||
] as const;
|
||||
|
||||
/** Kriterien über ein Ja/Nein-Feld. */
|
||||
export type JaNeinKriterium = { key: string; label: string };
|
||||
|
||||
export const JANEIN_KRITERIEN: readonly JaNeinKriterium[] = [
|
||||
{ key: "dienstwagen", label: "Dienstwagen" },
|
||||
{ key: "betriebsrat", label: "Betriebsrat" },
|
||||
{ key: "lateral", label: "Laterale Führung" },
|
||||
{ key: "clevel", label: "C-Level" },
|
||||
{ key: "kuendigungsschutz", label: "Besonderer Kündigungsschutz" },
|
||||
{ key: "teilzeit", label: "In einer Teilzeitvariante" },
|
||||
{ key: "aufenthaltstitel", label: "Aufenthaltstitel" },
|
||||
{ key: "dependents", label: "Angehörige erfasst" },
|
||||
] as const;
|
||||
|
||||
/** Zeiträume: je ein Parameter `<key>From` und `<key>To`. */
|
||||
export type ZeitraumKriterium = { key: string; label: string };
|
||||
|
||||
export const ZEITRAUM_KRITERIEN: readonly ZeitraumKriterium[] = [
|
||||
{ key: "entry", label: "Eintritt" },
|
||||
{ key: "exit", label: "Austritt" },
|
||||
{ key: "birth", label: "Geburtsdatum" },
|
||||
{ key: "schutz", label: "Kündigungsschutz bis" },
|
||||
{ key: "teilzeitEnde", label: "Teilzeit endet" },
|
||||
{ key: "titelEnde", label: "Aufenthaltstitel läuft ab" },
|
||||
] as const;
|
||||
|
||||
export type Criteria = {
|
||||
/** Werteliste je Kriteriumsschlüssel aus AUSWAHL_KRITERIEN. */
|
||||
auswahl: Record<string, string>;
|
||||
/** Ja/Nein je Schlüssel aus JANEIN_KRITERIEN. */
|
||||
jaNein: Record<string, JaNein>;
|
||||
/** ISO-Daten je Schlüssel aus ZEITRAUM_KRITERIEN, jeweils von/bis. */
|
||||
zeitraum: Record<string, { von?: string; bis?: string }>;
|
||||
/** Wochenstunden von/bis. */
|
||||
stundenVon?: number;
|
||||
stundenBis?: number;
|
||||
};
|
||||
|
||||
export const LEERE_CRITERIA: Criteria = { auswahl: {}, jaNein: {}, zeitraum: {} };
|
||||
|
||||
function parseZahl(value: string | null | undefined): number | undefined {
|
||||
if (!value) return undefined;
|
||||
const n = Number(value.replace(",", "."));
|
||||
// Negative Stunden oder eine Woche mit 200 Stunden sind keine Eingabe,
|
||||
// sondern ein Tippfehler oder ein Versuch.
|
||||
return Number.isFinite(n) && n >= 0 && n <= 168 ? n : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest die Kriterien aus der Adresszeile und verwirft alles, was nicht in
|
||||
* den Listen oben steht.
|
||||
*/
|
||||
export function parseCriteria(get: (key: string) => string | null | undefined): Criteria {
|
||||
const auswahl: Record<string, string> = {};
|
||||
for (const k of AUSWAHL_KRITERIEN) {
|
||||
const roh = get(k.key);
|
||||
if (roh && k.optionen.some((o) => o.wert === roh)) auswahl[k.key] = roh;
|
||||
}
|
||||
|
||||
const jaNein: Record<string, JaNein> = {};
|
||||
for (const k of JANEIN_KRITERIEN) {
|
||||
const roh = get(k.key);
|
||||
if (roh === "ja" || roh === "nein") jaNein[k.key] = roh;
|
||||
}
|
||||
|
||||
const zeitraum: Record<string, { von?: string; bis?: string }> = {};
|
||||
for (const k of ZEITRAUM_KRITERIEN) {
|
||||
const von = parseIsoDateParam(get(`${k.key}From`));
|
||||
const bis = parseIsoDateParam(get(`${k.key}To`));
|
||||
if (von || bis) zeitraum[k.key] = { von, bis };
|
||||
}
|
||||
|
||||
return { auswahl, jaNein, zeitraum, stundenVon: parseZahl(get("hoursFrom")), stundenBis: parseZahl(get("hoursTo")) };
|
||||
}
|
||||
|
||||
/** Wie viele Kriterien gesetzt sind — für die Anzeige „(3)" am Aufklapper. */
|
||||
export function anzahlKriterien(c: Criteria): number {
|
||||
let n = Object.keys(c.auswahl).length + Object.keys(c.jaNein).length;
|
||||
for (const z of Object.values(c.zeitraum)) n += (z.von ? 1 : 0) + (z.bis ? 1 : 0);
|
||||
if (c.stundenVon !== undefined) n += 1;
|
||||
if (c.stundenBis !== undefined) n += 1;
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Alle Parameternamen, die zu den Kriterien gehören — zum Zurücksetzen. */
|
||||
export function kriterienParameter(): string[] {
|
||||
return [
|
||||
...AUSWAHL_KRITERIEN.map((k) => k.key),
|
||||
...JANEIN_KRITERIEN.map((k) => k.key),
|
||||
...ZEITRAUM_KRITERIEN.flatMap((k) => [`${k.key}From`, `${k.key}To`]),
|
||||
"hoursFrom",
|
||||
"hoursTo",
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Die zwei Kriterien, die keine Spalte sind: der Wochentag steckt in einem
|
||||
* Array, die Angehörigen in einer anderen Tabelle. Beide werden an den
|
||||
* geladenen Zeilen geprüft, statt den Enum-Vergleich bzw. einen Join in die
|
||||
* Abfrage zu zwingen.
|
||||
*/
|
||||
export function passtImSpeicher(
|
||||
zeile: { work_days: Weekday[] | string[]; dependentsCount: number },
|
||||
c: Criteria
|
||||
): boolean {
|
||||
const tag = c.auswahl.weekday;
|
||||
if (tag && !zeile.work_days.includes(tag as Weekday)) return false;
|
||||
const angehoerige = c.jaNein.dependents;
|
||||
if (angehoerige === "ja" && zeile.dependentsCount === 0) return false;
|
||||
if (angehoerige === "nein" && zeile.dependentsCount > 0) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -1,8 +1,31 @@
|
||||
import type { SelectQueryBuilder } from "kysely";
|
||||
import type { Schema } from "./db/schema";
|
||||
import type { Tx } from "./db";
|
||||
import { ancestorsOf, loadOrgMaps, subtreeOf, type OrgMaps } from "./org";
|
||||
import { loadPlacements } from "./placement";
|
||||
import { deriveStatusAsOf, EVENT_DATE_OPEN, parseStatuses, todayIso, type OrgLookups, type ReportEmployee, type ReportEvent } from "./reports";
|
||||
import type { EmploymentType, HistoryEventType } from "./supabase/types";
|
||||
import { LEERE_CRITERIA, passtImSpeicher, type Criteria } from "./report-criteria";
|
||||
import {
|
||||
deriveStatusAsOf,
|
||||
EVENT_DATE_OPEN,
|
||||
parseStatuses,
|
||||
todayIso,
|
||||
type OrgLookups,
|
||||
type ReportEmployee,
|
||||
type ReportEvent,
|
||||
type UnitOption,
|
||||
} from "./reports";
|
||||
import type {
|
||||
CollectiveAgreement,
|
||||
ContractType,
|
||||
DienstwagenArt,
|
||||
EmploymentType,
|
||||
GenderType,
|
||||
HistoryEventType,
|
||||
PaygradeType,
|
||||
SourceType,
|
||||
TeilzeitArt,
|
||||
WorkerType,
|
||||
} from "./supabase/types";
|
||||
|
||||
// Shared by the Berichte page and /api/export/* so they can never drift on
|
||||
// what "the current view" means — same filters, same stichtag/event-window
|
||||
@@ -12,9 +35,72 @@ export type ReportFilters = {
|
||||
division?: string;
|
||||
location?: string;
|
||||
status?: string;
|
||||
employment?: string;
|
||||
/** Alles Weitere — siehe lib/report-criteria.ts. */
|
||||
criteria?: Criteria;
|
||||
};
|
||||
|
||||
/**
|
||||
* Macht aus den Kriterien Bedingungen auf `employees`.
|
||||
*
|
||||
* Bewusst Zeile für Zeile statt über eine Tabelle Schlüssel→Spalte: so prüft
|
||||
* der Compiler jeden Wert gegen den Spaltentyp. Die Umwandlungen sind keine
|
||||
* blinden Zusicherungen — `parseCriteria` hat den Wert vorher gegen dieselbe
|
||||
* Werteliste geprüft, aus der auch der Aufzählungstyp besteht.
|
||||
*
|
||||
* Nicht hier: Wochentag und Angehörige (siehe passtImSpeicher), Einheit
|
||||
* (Teilbaum) und Status (zum Stichtag abgeleitet) — die drei kann keine
|
||||
* Spaltenbedingung ausdrücken.
|
||||
*/
|
||||
export function applyCriteria<O>(
|
||||
q: SelectQueryBuilder<Schema, "employees", O>,
|
||||
c: Criteria
|
||||
): SelectQueryBuilder<Schema, "employees", O> {
|
||||
const a = c.auswahl;
|
||||
if (a.employment) q = q.where("employment_type", "=", a.employment as EmploymentType);
|
||||
if (a.contract) q = q.where("contract_type", "=", a.contract as ContractType);
|
||||
if (a.worker) q = q.where("worker_type", "=", a.worker as WorkerType);
|
||||
if (a.kv) q = q.where("collective_agreement", "=", a.kv as CollectiveAgreement);
|
||||
if (a.paygrade) q = q.where("paygrade", "=", a.paygrade as PaygradeType);
|
||||
if (a.source) q = q.where("source", "=", a.source as SourceType);
|
||||
if (a.gender) q = q.where("gender", "=", a.gender as GenderType);
|
||||
if (a.dienstwagenArt) q = q.where("dienstwagen_art", "=", a.dienstwagenArt as DienstwagenArt);
|
||||
if (a.teilzeitArt) q = q.where("teilzeit_art", "=", a.teilzeitArt as TeilzeitArt);
|
||||
if (a.absence) q = q.where("absence_type", "=", a.absence);
|
||||
|
||||
const j = c.jaNein;
|
||||
if (j.dienstwagen) q = q.where("has_dienstwagen", "=", j.dienstwagen === "ja");
|
||||
if (j.betriebsrat) q = q.where("is_betriebsrat", "=", j.betriebsrat === "ja");
|
||||
if (j.lateral) q = q.where("is_laterale_fuehrung", "=", j.lateral === "ja");
|
||||
if (j.clevel) q = q.where("is_c_level", "=", j.clevel === "ja");
|
||||
if (j.kuendigungsschutz) q = q.where("has_kuendigungsschutz", "=", j.kuendigungsschutz === "ja");
|
||||
// „In einer Teilzeitvariante" heisst: es steht eine drin, gleich welche.
|
||||
if (j.teilzeit === "ja") q = q.where("teilzeit_art", "is not", null);
|
||||
if (j.teilzeit === "nein") q = q.where("teilzeit_art", "is", null);
|
||||
if (j.aufenthaltstitel) q = q.where("hat_aufenthaltstitel", "=", j.aufenthaltstitel === "ja");
|
||||
|
||||
// Ein Austrittszeitraum schliesst alle ohne Austritt aus — ein Vergleich
|
||||
// mit NULL ist nicht wahr, und das ist hier genau die richtige Bedeutung.
|
||||
if (c.zeitraum.entry?.von) q = q.where("entry_date", ">=", c.zeitraum.entry.von);
|
||||
if (c.zeitraum.entry?.bis) q = q.where("entry_date", "<=", c.zeitraum.entry.bis);
|
||||
if (c.zeitraum.exit?.von) q = q.where("exit_date", ">=", c.zeitraum.exit.von);
|
||||
if (c.zeitraum.exit?.bis) q = q.where("exit_date", "<=", c.zeitraum.exit.bis);
|
||||
if (c.zeitraum.birth?.von) q = q.where("birth_date", ">=", c.zeitraum.birth.von);
|
||||
if (c.zeitraum.birth?.bis) q = q.where("birth_date", "<=", c.zeitraum.birth.bis);
|
||||
if (c.zeitraum.schutz?.von) q = q.where("kuendigungsschutz_bis", ">=", c.zeitraum.schutz.von);
|
||||
if (c.zeitraum.schutz?.bis) q = q.where("kuendigungsschutz_bis", "<=", c.zeitraum.schutz.bis);
|
||||
if (c.zeitraum.teilzeitEnde?.von) q = q.where("teilzeit_bis", ">=", c.zeitraum.teilzeitEnde.von);
|
||||
if (c.zeitraum.teilzeitEnde?.bis) q = q.where("teilzeit_bis", "<=", c.zeitraum.teilzeitEnde.bis);
|
||||
// Wer bis wann einen Titel hat — die Frage hinter „wessen Titel läuft im
|
||||
// nächsten Quartal aus".
|
||||
if (c.zeitraum.titelEnde?.von) q = q.where("aufenthaltstitel_bis", ">=", c.zeitraum.titelEnde.von);
|
||||
if (c.zeitraum.titelEnde?.bis) q = q.where("aufenthaltstitel_bis", "<=", c.zeitraum.titelEnde.bis);
|
||||
|
||||
if (c.stundenVon !== undefined) q = q.where("weekly_hours", ">=", c.stundenVon);
|
||||
if (c.stundenBis !== undefined) q = q.where("weekly_hours", "<=", c.stundenBis);
|
||||
|
||||
return q;
|
||||
}
|
||||
|
||||
export type SnapshotFilters = ReportFilters & { asOf?: string };
|
||||
export type EventFilters = { eventType?: HistoryEventType; division?: string; location?: string; from?: string; to?: string };
|
||||
|
||||
@@ -42,7 +128,7 @@ export function lookupsFromOrgMaps(orgMaps: OrgMaps, locations: { id: string; na
|
||||
export async function loadOrgLookups(tx: Tx): Promise<{
|
||||
lookups: OrgLookups;
|
||||
orgMaps: OrgMaps;
|
||||
divisions: { id: string; name: string }[];
|
||||
units: UnitOption[];
|
||||
locations: { id: string; name: string }[];
|
||||
}> {
|
||||
const orgMaps = await loadOrgMaps(tx);
|
||||
@@ -51,10 +137,15 @@ export async function loadOrgLookups(tx: Tx): Promise<{
|
||||
return {
|
||||
lookups: lookupsFromOrgMaps(orgMaps, locations),
|
||||
orgMaps,
|
||||
// Als Filter angeboten wird die oberste Ebene unter der Gesellschaft —
|
||||
// das, was im Altmodell „Bereich" hiess. Der Filter greift auf den
|
||||
// ganzen Teilbaum.
|
||||
divisions: orgMaps.unitList.filter((u) => u.unit_type === "Bereich").map((u) => ({ id: u.id, name: u.name })),
|
||||
// Jede Ebene, nicht nur die Bereiche: wer eine einzelne Abteilung
|
||||
// auswerten will, soll sie nicht über einen Umweg zusammensuchen müssen.
|
||||
// Der Filter greift immer auf den ganzen Teilbaum darunter.
|
||||
units: orgMaps.unitList.map((u) => ({
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
depth: orgMaps.depthOf.get(u.id) ?? 0,
|
||||
unitType: u.unit_type,
|
||||
})),
|
||||
locations,
|
||||
};
|
||||
}
|
||||
@@ -83,6 +174,7 @@ const SNAPSHOT_EMPLOYEE_COLUMNS = [
|
||||
"has_dienstwagen",
|
||||
"is_laterale_fuehrung",
|
||||
"is_c_level",
|
||||
"teilzeit_art",
|
||||
] as const;
|
||||
|
||||
// Anzahl der Angehörigen je Person. Nur der Fremdschlüssel wird gelesen —
|
||||
@@ -105,8 +197,7 @@ export async function loadSnapshotEmployees(tx: Tx, filters: SnapshotFilters): P
|
||||
function snapshotQuery() {
|
||||
let q = tx.selectFrom("employees").select([...SNAPSHOT_EMPLOYEE_COLUMNS]).orderBy("id");
|
||||
if (filters.location) q = q.where("location_id", "=", filters.location);
|
||||
if (filters.employment) q = q.where("employment_type", "=", filters.employment as EmploymentType);
|
||||
return q;
|
||||
return applyCriteria(q, filters.criteria ?? LEERE_CRITERIA);
|
||||
}
|
||||
|
||||
const [data, dependentsCounts, placements, orgMaps] = await Promise.all([
|
||||
@@ -121,6 +212,7 @@ export async function loadSnapshotEmployees(tx: Tx, filters: SnapshotFilters): P
|
||||
// Bereich selbst nur die Bereichsleitung sitzt.
|
||||
const allowedUnits = orgMaps && filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null;
|
||||
|
||||
const criteria = filters.criteria ?? LEERE_CRITERIA;
|
||||
const withDerivedStatus: ReportEmployee[] = [];
|
||||
for (const e of data) {
|
||||
const placement = placements.get(e.id);
|
||||
@@ -128,6 +220,7 @@ export async function loadSnapshotEmployees(tx: Tx, filters: SnapshotFilters): P
|
||||
// sitzt aber auf keiner Planstelle mehr.
|
||||
const orgUnitId = placement?.current ? placement.orgUnitId : null;
|
||||
if (allowedUnits && (!orgUnitId || !allowedUnits.has(orgUnitId))) continue;
|
||||
if (!passtImSpeicher({ work_days: e.work_days, dependentsCount: dependentsCounts.get(e.id) ?? 0 }, criteria)) continue;
|
||||
|
||||
withDerivedStatus.push({
|
||||
id: e.id,
|
||||
@@ -153,6 +246,7 @@ export async function loadSnapshotEmployees(tx: Tx, filters: SnapshotFilters): P
|
||||
has_dienstwagen: e.has_dienstwagen,
|
||||
is_laterale_fuehrung: e.is_laterale_fuehrung,
|
||||
is_c_level: e.is_c_level,
|
||||
teilzeit_art: e.teilzeit_art,
|
||||
dependents_count: dependentsCounts.get(e.id) ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { todayIso, yearsBetweenIso } from "./format";
|
||||
import { fmtName, todayIso, yearsBetweenIso } from "./format";
|
||||
import type { EmploymentStatus, HistoryEventType, Weekday } from "./supabase/types";
|
||||
|
||||
export { todayIso };
|
||||
@@ -25,6 +25,7 @@ export type GroupDimension =
|
||||
| "laterale_fuehrung"
|
||||
| "c_level"
|
||||
| "has_dependents"
|
||||
| "teilzeit_art"
|
||||
| "weekday";
|
||||
|
||||
export const MEASURE_LABELS: Record<Measure, string> = {
|
||||
@@ -55,6 +56,7 @@ export const GROUP_LABELS: Record<GroupDimension, string> = {
|
||||
laterale_fuehrung: "Laterale Führung",
|
||||
c_level: "C-Level",
|
||||
has_dependents: "Hat Angehörige",
|
||||
teilzeit_art: "Teilzeitvariante",
|
||||
weekday: "Wochentag",
|
||||
};
|
||||
|
||||
@@ -101,6 +103,7 @@ export type ReportEmployee = {
|
||||
has_dienstwagen: boolean;
|
||||
is_laterale_fuehrung: boolean;
|
||||
is_c_level: boolean;
|
||||
teilzeit_art: string | null;
|
||||
dependents_count: number;
|
||||
};
|
||||
|
||||
@@ -108,6 +111,12 @@ export type ReportEmployee = {
|
||||
// drei verschiedene Fremdschlüssel: welcher Bereich, welche Abteilung und
|
||||
// welches Team zu einer Einheit gehören, ergibt sich aus ihrer Vorfahrenkette
|
||||
// und wird einmal vorberechnet.
|
||||
/**
|
||||
* Eine Organisationseinheit als Filterwert — jede Ebene, nicht nur die
|
||||
* Bereiche. `depth` dient der Einrückung in der Auswahlliste.
|
||||
*/
|
||||
export type UnitOption = { id: string; name: string; depth: number; unitType: string };
|
||||
|
||||
export type OrgLookups = {
|
||||
divisionName: Map<string, string>;
|
||||
departmentName: Map<string, string>;
|
||||
@@ -176,6 +185,9 @@ export function groupKeyFor(e: ReportEmployee, dim: GroupDimension, lookups: Org
|
||||
return e.is_c_level ? "Ja" : "Nein";
|
||||
case "has_dependents":
|
||||
return e.dependents_count > 0 ? "Ja" : "Nein";
|
||||
case "teilzeit_art":
|
||||
// „Keine" statt „–": die Antwort ist hier eine Aussage, kein Fehlen.
|
||||
return e.teilzeit_art ?? "Keine";
|
||||
case "weekday":
|
||||
// Not a strict partition — see groupKeysFor, which aggregateReport
|
||||
// actually uses. This single-key fallback only covers a direct
|
||||
@@ -260,7 +272,7 @@ export function aggregateReport(
|
||||
const value = measureValue(rowsForGroup, measure, asOf);
|
||||
const people: ReportPerson[] = rowsForGroup.map((e) => ({
|
||||
id: e.id,
|
||||
name: `${e.first_name} ${e.last_name}`,
|
||||
name: fmtName(e.first_name, e.last_name),
|
||||
title: e.job_title,
|
||||
team: e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–",
|
||||
entry_date: e.entry_date,
|
||||
@@ -398,7 +410,7 @@ export function aggregateEvents(
|
||||
// code working unchanged for both report modes.
|
||||
const people: ReportPerson[] = rowsForGroup.map((e) => ({
|
||||
id: e.employee_id,
|
||||
name: `${e.first_name} ${e.last_name}`,
|
||||
name: fmtName(e.first_name, e.last_name),
|
||||
title: e.description,
|
||||
team: e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–",
|
||||
entry_date: e.event_date,
|
||||
|
||||
76
lib/shell-data.ts
Normal file
76
lib/shell-data.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { Tx } from "./db";
|
||||
import { jsonArrayFrom, jsonObjectFrom, zeitstempel } from "./db/json";
|
||||
import { todayIso } from "./format";
|
||||
import { baueOffeneNotizen, offeneNotizenAbfrage, type NotizZeile, type OpenNote } from "./notes";
|
||||
import { buildOrgMaps, orgMapsAbfragen, type Location } from "./org";
|
||||
import {
|
||||
offeneStellenAbfrage,
|
||||
resolveOpenPositions,
|
||||
type OffeneStelle,
|
||||
type OpenPositionResolved,
|
||||
} from "./positions";
|
||||
|
||||
// Was die Hülle jeder Seite braucht — in zwei Rundreisen statt in sechs.
|
||||
//
|
||||
// Vorher stand das im Layout selbst, als Promise.all, das wie Gleichzeitigkeit
|
||||
// aussah und keine war: eine Transaktion hängt an einer Verbindung, und über
|
||||
// eine Verbindung laufen Abfragen nacheinander. Bei rund 36 ms Umlaufzeit
|
||||
// kostete diese Hülle — die **jede** Seite mitlädt — eine halbe Sekunde
|
||||
// Warten für ein paar Kilobyte. Der Weg dahin steht in lib/db/json.ts.
|
||||
//
|
||||
// Hier und nicht im Layout, damit sich die Zahl der Rundreisen messen lässt,
|
||||
// ohne eine React-Komponente aufzubauen.
|
||||
|
||||
export type ShellData = {
|
||||
profile: { full_name: string | null; email: string | null; role: string | null; is_active: boolean | null };
|
||||
openPositions: OpenPositionResolved[];
|
||||
locations: Location[];
|
||||
drafts: { id: string; step: number; payload: Record<string, unknown>; updated_at: string }[];
|
||||
openNotes: OpenNote[];
|
||||
};
|
||||
|
||||
/**
|
||||
* `null` heisst: angemeldet, aber nicht als HR freigeschaltet.
|
||||
*
|
||||
* Die Zugangsprüfung fragt gleichzeitig mit dem Rest statt davor. Das liest
|
||||
* ein paar Zeilen mehr, als eine gesperrte Person sehen dürfte, wirft sie aber
|
||||
* weg, ohne sie je auszuliefern — und die eigentliche Grenze ist ohnehin RLS,
|
||||
* nicht die Reihenfolge hier.
|
||||
*/
|
||||
export async function loadShellData(tx: Tx, userId: string): Promise<ShellData | null> {
|
||||
const asOf = todayIso();
|
||||
|
||||
const gelesen = await tx
|
||||
.selectNoFrom((eb) => [
|
||||
jsonObjectFrom(
|
||||
eb.selectFrom("profiles").select(["full_name", "email", "role", "is_active"]).where("id", "=", userId)
|
||||
).as("profile"),
|
||||
...orgMapsAbfragen(eb),
|
||||
jsonArrayFrom(offeneStellenAbfrage(eb, asOf)).as("open"),
|
||||
jsonArrayFrom(offeneNotizenAbfrage(eb)).as("notes"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("hire_drafts")
|
||||
.select(["id", "step", "payload"])
|
||||
.select((x) => zeitstempel(x.ref("updated_at")).as("updated_at"))
|
||||
.where("created_by", "=", userId)
|
||||
.orderBy("updated_at", "desc")
|
||||
).as("drafts"),
|
||||
])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const profile = gelesen.profile;
|
||||
if (profile?.role !== "hr" || profile?.is_active !== true) return null;
|
||||
|
||||
const orgMaps = buildOrgMaps(gelesen.units as never, gelesen.locations as never);
|
||||
|
||||
return {
|
||||
profile,
|
||||
// Die zweite Rundreise: was sie fragt, hängt davon ab, welche Stellen
|
||||
// offen sind — das lässt sich nicht in die erste ziehen.
|
||||
openPositions: await resolveOpenPositions(tx, orgMaps, gelesen.open as OffeneStelle[], asOf),
|
||||
locations: gelesen.locations as Location[],
|
||||
drafts: gelesen.drafts as ShellData["drafts"],
|
||||
openNotes: baueOffeneNotizen(gelesen.notes as NotizZeile[]),
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,49 @@ export type WorkerType = "Angestellte:r" | "Arbeiter:in";
|
||||
export type CollectiveAgreement = "Handel" | "Süßwaren";
|
||||
export type Weekday = "Mo" | "Di" | "Mi" | "Do" | "Fr" | "Sa" | "So";
|
||||
|
||||
/**
|
||||
* Antriebsart des Dienstwagens.
|
||||
*
|
||||
* Bewusst keine dritte Möglichkeit „unbekannt": die Angabe hängt per CHECK
|
||||
* an has_dienstwagen, und wer einen Dienstwagen hat, weiss auch, ob er lädt
|
||||
* oder tankt.
|
||||
*/
|
||||
export type DienstwagenArt = "Verbrenner" | "Elektro";
|
||||
|
||||
/**
|
||||
* Gesetzlich geregelte Teilzeiten.
|
||||
*
|
||||
* Keine Abwesenheiten: die Person arbeitet, nur kürzer. Als Zustand geführt,
|
||||
* damit sich auswerten lässt, wer gerade in einer ist — mit einem optionalen
|
||||
* Enddatum, weil nicht jede ein bekanntes Ende hat.
|
||||
*/
|
||||
export type TeilzeitArt = "Bildungsteilzeit" | "Elternteilzeit" | "Pflegeteilzeit" | "Wiedereingliederungsteilzeit";
|
||||
|
||||
/**
|
||||
* Verhältnis zum Notfallkontakt.
|
||||
*
|
||||
* Auswahlliste statt Freitext, damit sich danach auswerten lässt und nicht
|
||||
* „Gattin", „Ehefrau" und „Frau" nebeneinander stehen. „Sonstige" ist der
|
||||
* Ausweg für alles, was hier fehlt — ohne ihn wäre die Liste eine Anmassung.
|
||||
*
|
||||
* In der Datenbank bleibt die Spalte `text`: eine Aufzählung dort würde jede
|
||||
* Ergänzung zu einer Migration machen, und diese Liste wird sich ändern.
|
||||
*/
|
||||
export const EMERGENCY_RELATIONS = [
|
||||
"Gattin/Gatte",
|
||||
"Lebensgefährtin/Lebensgefährte",
|
||||
"Mutter",
|
||||
"Vater",
|
||||
"Tochter",
|
||||
"Sohn",
|
||||
"Schwester",
|
||||
"Bruder",
|
||||
"Freundin/Freund",
|
||||
"Sonstige",
|
||||
] as const;
|
||||
|
||||
export type EmergencyRelation = (typeof EMERGENCY_RELATIONS)[number];
|
||||
|
||||
/**
|
||||
* Eine einzelne Feldänderung im Protokoll.
|
||||
*
|
||||
@@ -106,11 +149,16 @@ export type Database = {
|
||||
birth_date: string;
|
||||
sv_nummer: string | null;
|
||||
nationality: string;
|
||||
/** Aufenthaltstitel — nur ausserhalb von EU, EWR und Schweiz erhoben. */
|
||||
hat_aufenthaltstitel: boolean;
|
||||
/** Gültig bis, falls befristet. Nur mit dem Kennzeichen zusammen erlaubt. */
|
||||
aufenthaltstitel_bis: string | null;
|
||||
address: string | null;
|
||||
postal_code: string | null;
|
||||
city: string | null;
|
||||
address_country: string | null;
|
||||
email: string;
|
||||
/** Private Adresse, freiwillig — eindeutig, wenn angegeben. */
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
job_title: string;
|
||||
location_id: string;
|
||||
@@ -135,6 +183,20 @@ export type Database = {
|
||||
work_days: Weekday[];
|
||||
is_betriebsrat: boolean;
|
||||
has_dienstwagen: boolean;
|
||||
/** Null genau dann, wenn kein Dienstwagen vorhanden ist — chk_dienstwagen_art. */
|
||||
dienstwagen_art: DienstwagenArt | null;
|
||||
/** Besonderer Kündigungsschutz — löst beim Austritt eine Warnung aus. */
|
||||
has_kuendigungsschutz: boolean;
|
||||
/** Ende des Schutzes, falls bekannt. Nur mit dem Kennzeichen zusammen erlaubt. */
|
||||
kuendigungsschutz_bis: string | null;
|
||||
/** Bildungs-, Eltern-, Pflege- oder Wiedereingliederungsteilzeit; null bei einer gewöhnlichen Regelung. */
|
||||
teilzeit_art: TeilzeitArt | null;
|
||||
/** Ende der Teilzeit, falls bekannt. Nur mit einer Variante zusammen erlaubt. */
|
||||
teilzeit_bis: string | null;
|
||||
/** Nur gemeinsam gesetzt oder gemeinsam leer — chk_emergency_contact. */
|
||||
emergency_contact_name: string | null;
|
||||
emergency_contact_phone: string | null;
|
||||
emergency_contact_relation: string | null;
|
||||
is_laterale_fuehrung: boolean;
|
||||
is_c_level: boolean;
|
||||
title_prefix: string[];
|
||||
@@ -150,11 +212,13 @@ export type Database = {
|
||||
birth_date: string;
|
||||
sv_nummer?: string | null;
|
||||
nationality?: string;
|
||||
hat_aufenthaltstitel?: boolean;
|
||||
aufenthaltstitel_bis?: string | null;
|
||||
address?: string | null;
|
||||
postal_code?: string | null;
|
||||
city?: string | null;
|
||||
address_country?: string | null;
|
||||
email: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
job_title: string;
|
||||
location_id: string;
|
||||
@@ -177,6 +241,14 @@ export type Database = {
|
||||
work_days?: Weekday[];
|
||||
is_betriebsrat?: boolean;
|
||||
has_dienstwagen?: boolean;
|
||||
dienstwagen_art?: DienstwagenArt | null;
|
||||
has_kuendigungsschutz?: boolean;
|
||||
kuendigungsschutz_bis?: string | null;
|
||||
teilzeit_art?: TeilzeitArt | null;
|
||||
teilzeit_bis?: string | null;
|
||||
emergency_contact_name?: string | null;
|
||||
emergency_contact_phone?: string | null;
|
||||
emergency_contact_relation?: string | null;
|
||||
is_laterale_fuehrung?: boolean;
|
||||
is_c_level?: boolean;
|
||||
title_prefix?: string[];
|
||||
@@ -193,6 +265,10 @@ export type Database = {
|
||||
event_date: string;
|
||||
event_type: HistoryEventType;
|
||||
description: string;
|
||||
/** Feldweise Änderungen — dieselbe Form wie audit_log.changes. */
|
||||
changes: AuditChange[] | null;
|
||||
/** Der geplante Vorgang, solange die Änderung noch nicht wirksam ist. */
|
||||
pending_id: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
Insert: {
|
||||
@@ -201,6 +277,8 @@ export type Database = {
|
||||
event_date: string;
|
||||
event_type: HistoryEventType;
|
||||
description: string;
|
||||
changes?: AuditChange[] | null;
|
||||
pending_id?: string | null;
|
||||
created_at?: string;
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["employee_history"]["Insert"]>;
|
||||
@@ -401,6 +479,49 @@ export type Database = {
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["om_positions"]["Insert"]>;
|
||||
};
|
||||
// Kostenstellen. Die Zuordnung hängt an der Planstelle, nicht an der
|
||||
// Person: der Sitz kostet Geld, auch wenn niemand darauf sitzt.
|
||||
cost_centers: NoRelationships & {
|
||||
Row: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
org_unit_id: string | null;
|
||||
valid_from: string;
|
||||
valid_to: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
code: string;
|
||||
name: string;
|
||||
org_unit_id?: string | null;
|
||||
valid_from?: string;
|
||||
valid_to?: string | null;
|
||||
created_at?: string;
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["cost_centers"]["Insert"]>;
|
||||
};
|
||||
// A011: Planstelle kontiert auf Kostenstelle, zeitabhängig.
|
||||
position_cost_centers: NoRelationships & {
|
||||
Row: {
|
||||
id: string;
|
||||
position_id: string;
|
||||
cost_center_id: string;
|
||||
valid_from: string;
|
||||
valid_to: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
position_id: string;
|
||||
cost_center_id: string;
|
||||
valid_from: string;
|
||||
valid_to?: string | null;
|
||||
created_at?: string;
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["position_cost_centers"]["Insert"]>;
|
||||
};
|
||||
// A008: Person besetzt Planstelle, zeitabhängig.
|
||||
position_assignments: {
|
||||
Row: {
|
||||
@@ -454,10 +575,16 @@ export type Database = {
|
||||
delete_employee_dependent: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
add_employee_note: { Args: { payload: Record<string, unknown> }; Returns: string };
|
||||
complete_employee_note: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
// Nimmt eine irrtümliche Stammdaten-/Vertragsänderung zurück. Der einzige
|
||||
// Weg an der fehlenden delete-Policy auf employee_history vorbei.
|
||||
delete_history_entry: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
// Berichtigt Wert und/oder Datum eines Historieneintrags.
|
||||
update_history_entry: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
// Planstelle anlegen bzw. schliessen — im OM-Modell Operationen auf
|
||||
// om_positions, nicht mehr auf einer eigenen Ausschreibungstabelle.
|
||||
create_position: { Args: { payload: Record<string, unknown> }; Returns: string };
|
||||
update_position: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
set_position_cost_center: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
delete_position: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
is_valid_svnr: { Args: { p_svnr: string; p_birth_date?: string | null }; Returns: boolean };
|
||||
apply_due_pending_changes: { Args: Record<string, never>; Returns: number };
|
||||
|
||||
@@ -44,8 +44,12 @@ function contentSecurityPolicy(): string {
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// Emits a self-contained .next/standalone server (only the deps actually
|
||||
// used at runtime, no full node_modules) - what the Dockerfile copies in.
|
||||
output: "standalone",
|
||||
// used at runtime, no full node_modules) — what the Dockerfile copies in.
|
||||
//
|
||||
// Auf Vercel ist das falsch: dort baut die Plattform selbst und erwartet
|
||||
// die übliche Ausgabe. `VERCEL` setzt sie in jeder Baustrecke, die Angabe
|
||||
// entfällt dort also von selbst — und der Docker-Weg bleibt unberührt.
|
||||
output: process.env.VERCEL ? undefined : "standalone",
|
||||
// Baseline security headers (clickjacking, MIME-sniffing, referrer leakage,
|
||||
// browser feature access) plus the report-only CSP described above.
|
||||
async headers() {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
-- Die Fremdschlüssel von auth.users auf app_users umhängen.
|
||||
--
|
||||
-- Solange profiles.id auf auth.users zeigt, lässt sich **keine zweite Person
|
||||
-- freischalten**. Die Anmeldung läuft über Auth.js und legt dort nichts mehr
|
||||
-- an; wer sich neu anmeldet, bekommt eine app_users-Zeile, aber die dazu
|
||||
-- nötige profiles-Zeile scheitert am Fremdschlüssel. Das Ergebnis wäre
|
||||
-- „Kein HR-Zugriff" ohne Möglichkeit, es zu ändern.
|
||||
--
|
||||
-- Damit ist das hier keine Aufräumarbeit, sondern die Voraussetzung dafür,
|
||||
-- dass die Anwendung mehr als eine Person bedienen kann.
|
||||
--
|
||||
-- Vorher geprüft: jeder referenzierte Wert steht bereits in app_users. Die
|
||||
-- Umhängung ändert also keine Daten, nur die Zusicherung.
|
||||
|
||||
-- ═══ 1. Sicherheitsnetz ══════════════════════════════════════════
|
||||
-- Was in app_users fehlt, wird aus profiles ergänzt. Im geprüften Bestand
|
||||
-- ist das leer; die Migration soll aber auch auf einer Kopie laufen, in der
|
||||
-- jemand zwischenzeitlich etwas angelegt hat.
|
||||
insert into app_users (id, external_id, email, full_name)
|
||||
select p.id, 'legacy:' || p.id::text, p.email, p.full_name
|
||||
from profiles p
|
||||
where not exists (select 1 from app_users a where a.id = p.id)
|
||||
on conflict (id) do nothing;
|
||||
|
||||
-- ═══ 2. Umhängen ═════════════════════════════════════════════════
|
||||
-- Über den Katalog statt acht handgeschriebene Anweisungen: die Namen der
|
||||
-- Zwänge stammen aus verschiedenen Migrationen, und einer davon von Hand
|
||||
-- falsch abgeschrieben hiesse, dass er stehen bleibt.
|
||||
do $$
|
||||
declare
|
||||
r record;
|
||||
v_delete text;
|
||||
begin
|
||||
for r in
|
||||
select k.conname, c.relname as tabelle, a.attname as spalte, k.confdeltype
|
||||
from pg_constraint k
|
||||
join pg_class c on c.oid = k.conrelid
|
||||
join pg_namespace n on n.oid = c.relnamespace
|
||||
join pg_attribute a on a.attrelid = k.conrelid and a.attnum = any(k.conkey)
|
||||
where k.contype = 'f'
|
||||
and n.nspname = 'public'
|
||||
and k.confrelid = (
|
||||
select oid from pg_class
|
||||
where relname = 'users'
|
||||
and relnamespace = (select oid from pg_namespace where nspname = 'auth')
|
||||
)
|
||||
loop
|
||||
-- Das Löschverhalten bleibt, wie es war: profiles hängt kaskadierend am
|
||||
-- Konto, die Protokoll- und Notizfelder nicht — dort soll ein Eintrag
|
||||
-- gerade nicht verschwinden, weil ein Konto entfernt wird.
|
||||
v_delete := case r.confdeltype when 'c' then ' on delete cascade' else '' end;
|
||||
|
||||
execute format('alter table %I drop constraint %I', r.tabelle, r.conname);
|
||||
execute format('alter table %I add constraint %I foreign key (%I) references app_users(id)%s',
|
||||
r.tabelle, r.conname, r.spalte, v_delete);
|
||||
raise notice '%.% -> app_users%', r.tabelle, r.spalte, v_delete;
|
||||
end loop;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- ═══ 3. Gegenprobe ═══════════════════════════════════════════════
|
||||
do $$
|
||||
declare
|
||||
v_offen int;
|
||||
v_neu int;
|
||||
begin
|
||||
select count(*) into v_offen
|
||||
from pg_constraint k
|
||||
join pg_class c on c.oid = k.conrelid
|
||||
join pg_namespace n on n.oid = c.relnamespace
|
||||
where k.contype = 'f' and n.nspname = 'public'
|
||||
and k.confrelid = (
|
||||
select oid from pg_class
|
||||
where relname = 'users'
|
||||
and relnamespace = (select oid from pg_namespace where nspname = 'auth')
|
||||
);
|
||||
if v_offen > 0 then
|
||||
raise exception '% Fremdschlüssel zeigen weiterhin auf auth.users.', v_offen;
|
||||
end if;
|
||||
|
||||
select count(*) into v_neu
|
||||
from pg_constraint k
|
||||
join pg_class c on c.oid = k.conrelid
|
||||
join pg_namespace n on n.oid = c.relnamespace
|
||||
where k.contype = 'f' and n.nspname = 'public'
|
||||
and k.confrelid = 'app_users'::regclass;
|
||||
if v_neu < 8 then
|
||||
raise exception 'Nur % Fremdschlüssel zeigen auf app_users — erwartet mindestens 8.', v_neu;
|
||||
end if;
|
||||
|
||||
-- Und die eigentliche Frage: lässt sich jetzt eine zweite Person anlegen?
|
||||
-- Geprüft und wieder entfernt, damit die Migration keine Daten hinterlässt.
|
||||
declare
|
||||
v_id uuid := gen_random_uuid();
|
||||
begin
|
||||
insert into app_users (id, external_id, email, full_name)
|
||||
values (v_id, 'probe:' || v_id::text, 'probe@example.invalid', 'Probe');
|
||||
insert into profiles (id, email, full_name, role, is_active)
|
||||
values (v_id, 'probe@example.invalid', 'Probe', 'hr', false);
|
||||
delete from profiles where id = v_id;
|
||||
delete from app_users where id = v_id;
|
||||
end;
|
||||
end;
|
||||
$$;
|
||||
@@ -0,0 +1,119 @@
|
||||
-- Auf eine Planstelle darf nur besetzt werden, solange sie gilt.
|
||||
--
|
||||
-- Bisher prüften hire_employee und transfer_employee nur, ob die Stelle frei
|
||||
-- ist — nicht, ob es sie zum fraglichen Zeitpunkt überhaupt gibt. Damit liess
|
||||
-- sich heute jemand auf eine Planstelle einstellen, die erst im Oktober
|
||||
-- entsteht, oder auf eine, die im Frühjahr ausgelaufen ist. Die Besetzung
|
||||
-- stand dann in der Datenbank, die Stelle im Organigramm aber nicht, und die
|
||||
-- Person hing an einer Struktur, die es zu ihrem Eintrittsdatum nicht gab.
|
||||
--
|
||||
-- Seit die Oberfläche künftige Planstellen anzeigt, ist das kein
|
||||
-- theoretischer Fall mehr: sie stehen in derselben Auswahl.
|
||||
--
|
||||
-- Die Regel: das Datum der Besetzung — Eintritt bzw. Wirksamkeit der
|
||||
-- Versetzung — muss in [valid_from, valid_to) liegen. `valid_to` ist wie
|
||||
-- überall im Modell ausschliessend; eine Planstelle mit valid_to = heute gilt
|
||||
-- heute nicht mehr (siehe lib/positions.ts).
|
||||
--
|
||||
-- Zweite Korrektur im selben Zug: die Belegungsprüfung sah nur Zuordnungen
|
||||
-- mit offenem Ende. Eine, die erst später endet, blieb unsichtbar — dieselbe
|
||||
-- Lücke, die die Übersicht der unbesetzten Planstellen hatte.
|
||||
|
||||
create or replace function app_funktion_ersetzen(p_funktion text, p_muster text, p_neu text)
|
||||
returns void
|
||||
language plpgsql
|
||||
set search_path = public, pg_temp
|
||||
as $$
|
||||
declare
|
||||
v_def text;
|
||||
v_neu text;
|
||||
begin
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p
|
||||
join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = p_funktion
|
||||
limit 1;
|
||||
|
||||
if v_def is null then
|
||||
raise exception 'Funktion %() nicht gefunden.', p_funktion;
|
||||
end if;
|
||||
|
||||
-- Über ein Muster statt über festen Text, weil die Rümpfe je nach Herkunft
|
||||
-- CRLF oder LF enthalten. Ein wörtlicher Vergleich fände dann nichts und
|
||||
-- die Migration liefe erfolgreich durch, ohne etwas zu ändern.
|
||||
v_neu := regexp_replace(v_def, p_muster, p_neu, 'g');
|
||||
if v_neu = v_def then
|
||||
raise exception 'In %() passte das Muster auf nichts — nichts geändert.', p_funktion;
|
||||
end if;
|
||||
|
||||
execute v_neu;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- ═══ Eintritt ════════════════════════════════════════════════════
|
||||
select app_funktion_ersetzen(
|
||||
'hire_employee',
|
||||
'select\s+pa\.employee_id\s+into\s+v_besetzt\s+from\s+position_assignments\s+pa\s+where\s+pa\.position_id\s*=\s*v_position_id\s+and\s+pa\.valid_to\s+is\s+null;',
|
||||
$neu$declare
|
||||
v_ab date;
|
||||
v_bis date;
|
||||
begin
|
||||
select valid_from, valid_to into v_ab, v_bis from om_positions where id = v_position_id;
|
||||
if v_ab is null then
|
||||
raise exception 'Die Planstelle existiert nicht.';
|
||||
end if;
|
||||
if v_entry < v_ab then
|
||||
raise exception 'Die Planstelle gilt erst ab %. Ein Eintritt am % ist darauf nicht möglich.', v_ab, v_entry;
|
||||
end if;
|
||||
if v_bis is not null and v_entry >= v_bis then
|
||||
raise exception 'Die Planstelle gilt nur bis %. Ein Eintritt am % ist darauf nicht möglich.', v_bis, v_entry;
|
||||
end if;
|
||||
end;
|
||||
|
||||
select pa.employee_id into v_besetzt
|
||||
from position_assignments pa
|
||||
where pa.position_id = v_position_id
|
||||
and (pa.valid_to is null or pa.valid_to > v_entry);$neu$
|
||||
);
|
||||
|
||||
-- ═══ Versetzung ══════════════════════════════════════════════════
|
||||
select app_funktion_ersetzen(
|
||||
'transfer_employee',
|
||||
'select\s+pa\.employee_id\s+into\s+v_besetzt\s+from\s+position_assignments\s+pa\s+where\s+pa\.position_id\s*=\s*v_target_position\s+and\s+pa\.valid_to\s+is\s+null;',
|
||||
$neu$declare
|
||||
v_ab date;
|
||||
v_bis date;
|
||||
begin
|
||||
select valid_from, valid_to into v_ab, v_bis from om_positions where id = v_target_position;
|
||||
if v_ab is null then
|
||||
raise exception 'Die Zielplanstelle existiert nicht.';
|
||||
end if;
|
||||
if v_effective < v_ab then
|
||||
raise exception 'Die Zielplanstelle gilt erst ab %. Eine Versetzung zum % ist darauf nicht möglich.', v_ab, v_effective;
|
||||
end if;
|
||||
if v_bis is not null and v_effective >= v_bis then
|
||||
raise exception 'Die Zielplanstelle gilt nur bis %. Eine Versetzung zum % ist darauf nicht möglich.', v_bis, v_effective;
|
||||
end if;
|
||||
end;
|
||||
|
||||
select pa.employee_id into v_besetzt
|
||||
from position_assignments pa
|
||||
where pa.position_id = v_target_position
|
||||
and (pa.valid_to is null or pa.valid_to > v_effective);$neu$
|
||||
);
|
||||
|
||||
drop function app_funktion_ersetzen(text, text, text);
|
||||
|
||||
-- ═══ Gegenprobe ══════════════════════════════════════════════════
|
||||
do $$
|
||||
declare r text;
|
||||
begin
|
||||
foreach r in array array['hire_employee', 'transfer_employee'] loop
|
||||
if (select pg_get_functiondef(p.oid) from pg_proc p
|
||||
join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = r limit 1) not like '%gilt erst ab%' then
|
||||
raise exception '%() enthält die Gültigkeitsprüfung nicht.', r;
|
||||
end if;
|
||||
end loop;
|
||||
end;
|
||||
$$;
|
||||
@@ -0,0 +1,64 @@
|
||||
-- Der Vorgabewert für die Arbeitstage in hire_employee greift nie.
|
||||
--
|
||||
-- coalesce(array(select jsonb_array_elements_text(payload->'work_days'))::text[],
|
||||
-- '{Mo,Di,Mi,Do,Fr}')
|
||||
--
|
||||
-- Fehlt der Schlüssel, liefert die Unterabfrage keine Zeilen, und `array(…)`
|
||||
-- macht daraus ein **leeres** Array — nicht NULL. `coalesce` sieht also
|
||||
-- keinen fehlenden Wert und lässt `{}` stehen. Die Bedingung
|
||||
-- chk_work_days_valid verlangt aber mindestens einen Tag, und die
|
||||
-- Einstellung bricht ab.
|
||||
--
|
||||
-- Sichtbar ist das bisher nicht, weil der Assistent die Arbeitstage immer
|
||||
-- mitschickt und ohne sie gar nicht weiterlässt. Es ist eine Falle für jeden
|
||||
-- anderen Aufrufer — und ein Vorgabewert, der nichts vorgibt, ist schlimmer
|
||||
-- als keiner: er sieht aus, als wäre der Fall bedacht.
|
||||
--
|
||||
-- `nullif(…, '{}')` macht aus dem leeren Array wieder ein fehlendes.
|
||||
|
||||
do $$
|
||||
declare
|
||||
v_def text;
|
||||
v_neu text;
|
||||
begin
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p
|
||||
join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'hire_employee'
|
||||
limit 1;
|
||||
|
||||
v_neu := regexp_replace(
|
||||
v_def,
|
||||
'coalesce\(\s*array\(\s*select\s+jsonb_array_elements_text\(payload->''work_days''\)\s*\)\s*::text\[\]\s*,',
|
||||
'coalesce(nullif(array(select jsonb_array_elements_text(payload->''work_days''))::text[], ''{}''),',
|
||||
'g'
|
||||
);
|
||||
|
||||
if v_neu = v_def then
|
||||
raise exception 'Das Muster für die Arbeitstage passte nicht — hire_employee blieb unverändert.';
|
||||
end if;
|
||||
|
||||
execute v_neu;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- ═══ Gegenprobe ══════════════════════════════════════════════════
|
||||
-- Der Ausdruck in beiden Formen, damit die Regel festgehalten ist und nicht
|
||||
-- beim nächsten Mal neu entdeckt werden muss.
|
||||
do $$
|
||||
begin
|
||||
if array(select jsonb_array_elements_text('{}'::jsonb->'work_days')) is null then
|
||||
raise exception 'array() über einen fehlenden Schlüssel liefert null — die Annahme dieser Migration stimmt nicht mehr.';
|
||||
end if;
|
||||
|
||||
if coalesce(nullif(array(select jsonb_array_elements_text('{}'::jsonb->'work_days'))::text[], '{}'), '{Mo,Di,Mi,Do,Fr}')
|
||||
<> '{Mo,Di,Mi,Do,Fr}'::text[] then
|
||||
raise exception 'Der korrigierte Ausdruck liefert nicht die Vorgabe.';
|
||||
end if;
|
||||
|
||||
if coalesce(nullif(array(select jsonb_array_elements_text('{"work_days":["Mo","Di"]}'::jsonb->'work_days'))::text[], '{}'), '{Mo,Di,Mi,Do,Fr}')
|
||||
<> '{Mo,Di}'::text[] then
|
||||
raise exception 'Der korrigierte Ausdruck überschreibt einen mitgegebenen Wert.';
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
@@ -0,0 +1,82 @@
|
||||
-- Die Wiedereinstellung prüft die Zielplanstelle wie Eintritt und Versetzung.
|
||||
--
|
||||
-- rehire_employee verlangte zwar eine Planstelle, setzte die Besetzung dann
|
||||
-- aber ungeprüft: weder ob die Stelle zum Wiedereintritt gilt noch ob sie
|
||||
-- frei ist. Eine wiedereingestellte Person konnte damit auf einer bereits
|
||||
-- besetzten Stelle landen — abgefangen erst vom Teilindex, mit einer Meldung,
|
||||
-- die in der Oberfläche nichts erklärt — oder auf einer, die es zu dem Datum
|
||||
-- gar nicht gibt.
|
||||
--
|
||||
-- Dieselbe Regel wie in 20260810100000: das Datum muss in
|
||||
-- [valid_from, valid_to) liegen, und es darf keine Zuordnung geben, die zu
|
||||
-- diesem Zeitpunkt noch gilt.
|
||||
|
||||
do $$
|
||||
declare
|
||||
v_def text;
|
||||
v_neu text;
|
||||
begin
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p
|
||||
join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'rehire_employee'
|
||||
limit 1;
|
||||
|
||||
if v_def is null then
|
||||
raise exception 'rehire_employee() nicht gefunden.';
|
||||
end if;
|
||||
|
||||
-- Über ein Muster, weil der Rumpf CRLF enthalten kann; ein wörtlicher
|
||||
-- Vergleich fände nichts und die Migration meldete trotzdem Erfolg.
|
||||
v_neu := regexp_replace(
|
||||
v_def,
|
||||
'if\s+v_position_id\s+is\s+null\s+then\s+raise\s+exception\s+''Für die Wiedereinstellung muss eine Planstelle angegeben werden\.'';\s+end\s+if;',
|
||||
$neu$if v_position_id is null then
|
||||
raise exception 'Für die Wiedereinstellung muss eine Planstelle angegeben werden.';
|
||||
end if;
|
||||
|
||||
declare
|
||||
v_ab date;
|
||||
v_bis date;
|
||||
v_besetzt uuid;
|
||||
begin
|
||||
select valid_from, valid_to into v_ab, v_bis from om_positions where id = v_position_id;
|
||||
if v_ab is null then
|
||||
raise exception 'Die Planstelle existiert nicht.';
|
||||
end if;
|
||||
if v_date < v_ab then
|
||||
raise exception 'Die Planstelle gilt erst ab %. Ein Wiedereintritt am % ist darauf nicht möglich.', v_ab, v_date;
|
||||
end if;
|
||||
if v_bis is not null and v_date >= v_bis then
|
||||
raise exception 'Die Planstelle gilt nur bis %. Ein Wiedereintritt am % ist darauf nicht möglich.', v_bis, v_date;
|
||||
end if;
|
||||
|
||||
select pa.employee_id into v_besetzt
|
||||
from position_assignments pa
|
||||
where pa.position_id = v_position_id
|
||||
and (pa.valid_to is null or pa.valid_to > v_date);
|
||||
if v_besetzt is not null then
|
||||
raise exception 'Diese Planstelle ist bereits besetzt.';
|
||||
end if;
|
||||
end;$neu$,
|
||||
'g'
|
||||
);
|
||||
|
||||
if v_neu = v_def then
|
||||
raise exception 'Das Muster passte nicht — rehire_employee blieb unverändert.';
|
||||
end if;
|
||||
|
||||
execute v_neu;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- ═══ Gegenprobe ══════════════════════════════════════════════════
|
||||
do $$
|
||||
begin
|
||||
if (select pg_get_functiondef(p.oid) from pg_proc p
|
||||
join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'rehire_employee' limit 1) not like '%gilt erst ab%' then
|
||||
raise exception 'rehire_employee() enthält die Gültigkeitsprüfung nicht.';
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
@@ -0,0 +1,65 @@
|
||||
-- rehire_employee funktionierte nie.
|
||||
--
|
||||
-- status = case when v_date <= current_date then 'Aktiv' else 'Geplant' end
|
||||
--
|
||||
-- Der case-Ausdruck ist `text`, die Spalte ist `employment_status`. Postgres
|
||||
-- weist das ab:
|
||||
--
|
||||
-- column "status" is of type employment_status but expression is of type text
|
||||
--
|
||||
-- Sichtbar wurde es erst jetzt. Davor brach die Funktion eine Zeile früher ab,
|
||||
-- weil das Formular nie eine Planstelle mitschickte — ein Fehler verdeckte den
|
||||
-- anderen, wie schon bei hire_employee.
|
||||
--
|
||||
-- Bei den beiden anderen Zuweisungen im selben update (exit_date, exit_reason)
|
||||
-- stellt sich die Frage nicht: `null` ist typunabhängig.
|
||||
|
||||
do $$
|
||||
declare
|
||||
v_def text;
|
||||
v_neu text;
|
||||
begin
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p
|
||||
join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'rehire_employee'
|
||||
limit 1;
|
||||
|
||||
v_neu := regexp_replace(
|
||||
v_def,
|
||||
'(case\s+when\s+v_date\s*<=\s*current_date\s+then\s+''Aktiv''\s+else\s+''Geplant''\s+end)',
|
||||
'(\1)::employment_status',
|
||||
'g'
|
||||
);
|
||||
|
||||
if v_neu = v_def then
|
||||
raise exception 'Das Muster für den Status passte nicht — rehire_employee blieb unverändert.';
|
||||
end if;
|
||||
|
||||
execute v_neu;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- ═══ Gegenprobe ══════════════════════════════════════════════════
|
||||
-- Nachgestellt wird die **Spaltenzuweisung**, nicht die an eine Variable.
|
||||
-- Das ist der Unterschied, an dem mein erster Prüfausdruck vorbeiging:
|
||||
-- plpgsql wandelt bei einer Variablenzuweisung stillschweigend um, ein
|
||||
-- UPDATE auf eine Spalte nicht. Nur die zweite Form entspricht dem Fehler.
|
||||
do $$
|
||||
begin
|
||||
create temp table probe_status (s employment_status) on commit drop;
|
||||
insert into probe_status values ('Aktiv');
|
||||
|
||||
begin
|
||||
execute $probe$ update probe_status set s = (case when true then 'Aktiv' else 'Geplant' end)::text $probe$;
|
||||
raise exception 'Eine text-Zuweisung an eine employment_status-Spalte wirft nicht mehr — die Prüfung ist wertlos geworden.';
|
||||
exception
|
||||
when datatype_mismatch then null; -- erwartet
|
||||
end;
|
||||
|
||||
execute $probe$ update probe_status set s = (case when true then 'Aktiv' else 'Geplant' end)::employment_status $probe$;
|
||||
if (select s from probe_status) <> 'Aktiv'::employment_status then
|
||||
raise exception 'Die umgewandelte Form liefert nicht Aktiv.';
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
@@ -0,0 +1,120 @@
|
||||
-- Die Personalnummer wird eingegeben, nicht vergeben.
|
||||
--
|
||||
-- Sie muss mit Loga und Interflex übereinstimmen. Eine von dieser Anwendung
|
||||
-- selbst gezogene Nummer ist dort unbekannt, und die Person hätte in drei
|
||||
-- Systemen zwei Nummern.
|
||||
--
|
||||
-- Zwei Dinge ändern sich dadurch:
|
||||
--
|
||||
-- 1. Die Identität fällt weg. `GENERATED ALWAYS` weist eigene Werte
|
||||
-- ausdrücklich ab — deshalb brauchte der Import bisher OVERRIDING SYSTEM
|
||||
-- VALUE.
|
||||
-- 2. Die Eindeutigkeit muss ausdrücklich her. Bisher gab es **keine**: die
|
||||
-- Identität verhinderte Doppelte nur als Nebenwirkung. Sobald der Wert von
|
||||
-- aussen kommt, ist das die eigentliche Zusicherung — und sie fehlte.
|
||||
|
||||
-- Gegenprobe vor dem Umbau: was jetzt doppelt ist, liesse sich danach nicht
|
||||
-- mehr eindeutig machen.
|
||||
do $$
|
||||
declare v_doppelt int;
|
||||
begin
|
||||
select count(*) into v_doppelt from (
|
||||
select personnel_number from employees group by 1 having count(*) > 1
|
||||
) x;
|
||||
if v_doppelt > 0 then
|
||||
raise exception '% Personalnummern kommen mehrfach vor — vor der Umstellung bereinigen.', v_doppelt;
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
alter table employees alter column personnel_number drop identity if exists;
|
||||
|
||||
alter table employees add constraint employees_personnel_number_key unique (personnel_number);
|
||||
|
||||
comment on column employees.personnel_number is
|
||||
'Wird eingegeben und muss mit Loga/Interflex übereinstimmen. Nicht automatisch vergeben.';
|
||||
|
||||
-- ═══ hire_employee nimmt die Nummer entgegen ═════════════════════
|
||||
do $$
|
||||
declare
|
||||
v_def text;
|
||||
v_neu text;
|
||||
begin
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'hire_employee' limit 1;
|
||||
|
||||
-- Pflichtprüfung direkt nach der Planstellenprüfung einhängen.
|
||||
v_neu := regexp_replace(
|
||||
v_def,
|
||||
'(if\s+v_position_id\s+is\s+null\s+then\s+raise\s+exception\s+''Es muss eine Planstelle angegeben werden\.'';\s+end\s+if;)',
|
||||
$neu$\1
|
||||
|
||||
if payload->>'personnel_number' is null or btrim(payload->>'personnel_number') = '' then
|
||||
raise exception 'Es muss eine Personalnummer angegeben werden.';
|
||||
end if;
|
||||
if exists (select 1 from employees where personnel_number = (payload->>'personnel_number')::int) then
|
||||
raise exception 'Die Personalnummer % ist bereits vergeben.', payload->>'personnel_number';
|
||||
end if;$neu$,
|
||||
'g'
|
||||
);
|
||||
if v_neu = v_def then
|
||||
raise exception 'Die Pflichtprüfung liess sich nicht einhängen — hire_employee blieb unverändert.';
|
||||
end if;
|
||||
v_def := v_neu;
|
||||
|
||||
-- Und in die Einfügung aufnehmen. Die Spaltenliste beginnt mit
|
||||
-- first_name; davor kommt personnel_number, in beiden Listen an gleicher
|
||||
-- Stelle.
|
||||
v_neu := regexp_replace(v_def, 'insert\s+into\s+employees\s*\(\s*first_name,', 'insert into employees (' || chr(10) || ' personnel_number, first_name,', 'g');
|
||||
if v_neu = v_def then
|
||||
raise exception 'Die Spaltenliste liess sich nicht ergänzen.';
|
||||
end if;
|
||||
v_def := v_neu;
|
||||
|
||||
v_neu := regexp_replace(v_def, 'values\s*\(\s*payload->>''first_name'',', 'values (' || chr(10) || ' (payload->>''personnel_number'')::int, payload->>''first_name'',', 'g');
|
||||
if v_neu = v_def then
|
||||
raise exception 'Die Werteliste liess sich nicht ergänzen.';
|
||||
end if;
|
||||
|
||||
-- OVERRIDING SYSTEM VALUE gibt es nur für Identitätsspalten; nach dem
|
||||
-- Wegfall wäre es ein Fehler. Der Import setzt es selbst nicht mehr, hier
|
||||
-- steht es vorsorglich, falls eine ältere Fassung es doch trägt.
|
||||
v_neu := regexp_replace(v_neu, '\s+overriding\s+system\s+value', '', 'gi');
|
||||
|
||||
execute v_neu;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- ═══ Gegenprobe ══════════════════════════════════════════════════
|
||||
do $$
|
||||
declare
|
||||
v_ist_identitaet text;
|
||||
v_def text;
|
||||
begin
|
||||
select is_identity into v_ist_identitaet
|
||||
from information_schema.columns
|
||||
where table_name = 'employees' and column_name = 'personnel_number';
|
||||
if v_ist_identitaet <> 'NO' then
|
||||
raise exception 'personnel_number ist weiterhin eine Identitätsspalte.';
|
||||
end if;
|
||||
|
||||
if not exists (
|
||||
select 1 from pg_constraint
|
||||
where conrelid = 'employees'::regclass and contype = 'u'
|
||||
and pg_get_constraintdef(oid) = 'UNIQUE (personnel_number)'
|
||||
) then
|
||||
raise exception 'Die Eindeutigkeit auf personnel_number fehlt.';
|
||||
end if;
|
||||
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'hire_employee' limit 1;
|
||||
if v_def not like '%bereits vergeben%' then
|
||||
raise exception 'hire_employee() prüft die Personalnummer nicht.';
|
||||
end if;
|
||||
if v_def ilike '%overriding system value%' then
|
||||
raise exception 'hire_employee() enthält weiterhin OVERRIDING SYSTEM VALUE.';
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
@@ -0,0 +1,96 @@
|
||||
-- Notfallkontakt und Antriebsart des Dienstwagens.
|
||||
|
||||
-- ═══ Notfallkontakt ══════════════════════════════════════════════
|
||||
-- Als Spalten und nicht als eigene Tabelle: gefragt war *der* Notfallkontakt,
|
||||
-- einer je Person. Eine Tabelle wäre die richtige Antwort auf „mehrere", und
|
||||
-- die Frage stellt sich hier nicht.
|
||||
--
|
||||
-- Das Verhältnis bleibt Freitext. Die Beispiele — Gattin/Gatte,
|
||||
-- Schwester/Bruder, Freund — sind keine Aufzählung, die sich schliessen
|
||||
-- lässt, ohne jemandem die eigene Lebensform abzusprechen.
|
||||
alter table employees add column if not exists emergency_contact_name text;
|
||||
alter table employees add column if not exists emergency_contact_phone text;
|
||||
alter table employees add column if not exists emergency_contact_relation text;
|
||||
|
||||
comment on column employees.emergency_contact_name is
|
||||
'Notfallkontakt: Name. Daten einer dritten Person — nur für den Notfall erhoben.';
|
||||
|
||||
-- Alles oder nichts: ein Name ohne Nummer nützt im Notfall nichts, eine
|
||||
-- Nummer ohne Namen sagt nicht, wen man da anruft.
|
||||
alter table employees drop constraint if exists chk_emergency_contact;
|
||||
alter table employees add constraint chk_emergency_contact check (
|
||||
(emergency_contact_name is null and emergency_contact_phone is null)
|
||||
or (btrim(coalesce(emergency_contact_name, '')) <> '' and btrim(coalesce(emergency_contact_phone, '')) <> '')
|
||||
);
|
||||
|
||||
-- ═══ Antriebsart des Dienstwagens ════════════════════════════════
|
||||
-- Eine zweite Spalte statt eines Umbaus von has_dienstwagen: die bestehende
|
||||
-- Angabe bleibt gültig, und alle Auswertungen darauf ebenfalls.
|
||||
alter table employees add column if not exists dienstwagen_art text;
|
||||
|
||||
comment on column employees.dienstwagen_art is
|
||||
'Antriebsart des Dienstwagens: Verbrenner oder Elektro. Null, wenn keiner vorhanden.';
|
||||
|
||||
-- Der CHECK bindet die beiden Angaben aneinander. Ohne ihn stünde irgendwann
|
||||
-- „E-KFZ" bei jemandem ohne Dienstwagen, und niemand wüsste, welche der
|
||||
-- beiden Angaben stimmt.
|
||||
-- Zuerst den Bestand füllen, dann prüfen — andersherum weist die Bedingung
|
||||
-- jede vorhandene Zeile mit Dienstwagen ab. Bestehende gelten als
|
||||
-- Verbrenner, bis jemand es besser weiss; das ist eine Annahme, aber eine
|
||||
-- sichtbare: „Elektro" steht nirgends, wo es niemand bestätigt hat.
|
||||
update employees set dienstwagen_art = 'Verbrenner'
|
||||
where has_dienstwagen and dienstwagen_art is null;
|
||||
|
||||
-- `is not null and` steht bewusst davor: `dienstwagen_art in (…)` ergibt bei
|
||||
-- NULL nicht `false`, sondern NULL — und ein CHECK gilt als erfüllt, wenn er
|
||||
-- NULL liefert. Ohne die ausdrückliche Prüfung hätte diese Bedingung genau
|
||||
-- den Fall durchgelassen, gegen den sie geschrieben ist. Aufgefallen ist das
|
||||
-- der Gegenprobe am Ende dieser Datei, nicht mir.
|
||||
alter table employees drop constraint if exists chk_dienstwagen_art;
|
||||
alter table employees add constraint chk_dienstwagen_art check (
|
||||
(has_dienstwagen and dienstwagen_art is not null and dienstwagen_art in ('Verbrenner', 'Elektro'))
|
||||
or (not has_dienstwagen and dienstwagen_art is null)
|
||||
);
|
||||
|
||||
-- ═══ Gegenprobe ══════════════════════════════════════════════════
|
||||
do $$
|
||||
begin
|
||||
create temp table probe_emp (
|
||||
has_dienstwagen boolean not null default false,
|
||||
dienstwagen_art text,
|
||||
emergency_contact_name text,
|
||||
emergency_contact_phone text,
|
||||
constraint p_art check (
|
||||
(has_dienstwagen and dienstwagen_art is not null and dienstwagen_art in ('Verbrenner','Elektro'))
|
||||
or (not has_dienstwagen and dienstwagen_art is null)),
|
||||
constraint p_kontakt check (
|
||||
(emergency_contact_name is null and emergency_contact_phone is null)
|
||||
or (btrim(coalesce(emergency_contact_name,'')) <> '' and btrim(coalesce(emergency_contact_phone,'')) <> ''))
|
||||
) on commit drop;
|
||||
|
||||
-- E-KFZ ohne Dienstwagen muss abgewiesen werden.
|
||||
begin
|
||||
insert into probe_emp (has_dienstwagen, dienstwagen_art) values (false, 'Elektro');
|
||||
raise exception 'Antriebsart ohne Dienstwagen wird angenommen — der CHECK greift nicht.';
|
||||
exception when check_violation then null;
|
||||
end;
|
||||
|
||||
-- Dienstwagen ohne Antriebsart ebenso.
|
||||
begin
|
||||
insert into probe_emp (has_dienstwagen, dienstwagen_art) values (true, null);
|
||||
raise exception 'Dienstwagen ohne Antriebsart wird angenommen — der CHECK greift nicht.';
|
||||
exception when check_violation then null;
|
||||
end;
|
||||
|
||||
insert into probe_emp (has_dienstwagen, dienstwagen_art) values (true, 'Elektro');
|
||||
|
||||
-- Name ohne Nummer muss abgewiesen werden.
|
||||
begin
|
||||
insert into probe_emp (emergency_contact_name) values ('Maria Muster');
|
||||
raise exception 'Notfallkontakt ohne Nummer wird angenommen — der CHECK greift nicht.';
|
||||
exception when check_violation then null;
|
||||
end;
|
||||
|
||||
insert into probe_emp (emergency_contact_name, emergency_contact_phone) values ('Maria Muster', '+43 660 1234567');
|
||||
end;
|
||||
$$;
|
||||
@@ -0,0 +1,91 @@
|
||||
-- change_employee_data kennt Notfallkontakt und Antriebsart.
|
||||
--
|
||||
-- Ohne diesen Schritt liessen sich die neuen Felder anlegen, aber nie
|
||||
-- ändern: die Funktion vergleicht und schreibt namentlich aufgezählte
|
||||
-- Spalten, und was dort fehlt, wird stillschweigend übergangen. Das ist die
|
||||
-- unangenehme Sorte Lücke — die Oberfläche zeigt ein Eingabefeld, das
|
||||
-- Speichern meldet Erfolg, und der Wert bleibt stehen.
|
||||
--
|
||||
-- Beide Ergänzungen laufen über app_aenderung(), landen also mit Vorher und
|
||||
-- Nachher im Protokoll wie alles andere auch.
|
||||
|
||||
do $$
|
||||
declare
|
||||
v_def text;
|
||||
v_neu text;
|
||||
begin
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'change_employee_data' limit 1;
|
||||
|
||||
-- ── Vergleiche: Notfallkontakt hinter Telefon, Antriebsart hinter C-Level
|
||||
v_neu := regexp_replace(
|
||||
v_def,
|
||||
'(if\s+v_person\s+\?\s+''phone''\s+then\s+v_person_changes\s*:=\s*app_aenderung\(v_person_changes,\s*''Telefon'',\s*v_old\.phone,\s*v_person->>''phone''\);\s*end\s+if;)',
|
||||
$neu$\1
|
||||
if v_person ? 'emergency_contact_name' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt', v_old.emergency_contact_name, v_person->>'emergency_contact_name'); end if;
|
||||
if v_person ? 'emergency_contact_phone' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Telefon', v_old.emergency_contact_phone, v_person->>'emergency_contact_phone'); end if;
|
||||
if v_person ? 'emergency_contact_relation' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Verhältnis', v_old.emergency_contact_relation, v_person->>'emergency_contact_relation'); end if;$neu$,
|
||||
'g'
|
||||
);
|
||||
if v_neu = v_def then raise exception 'Der Vergleichsblock für den Notfallkontakt liess sich nicht einhängen.'; end if;
|
||||
v_def := v_neu;
|
||||
|
||||
v_neu := regexp_replace(
|
||||
v_def,
|
||||
'(if\s+v_role\s+\?\s+''is_c_level''\s+then\s+v_contract_changes\s*:=\s*app_aenderung\(v_contract_changes,\s*''C-Level'',\s*v_old\.is_c_level::text,\s*v_role->>''is_c_level''\);\s*end\s+if;)',
|
||||
$neu$\1
|
||||
if v_role ? 'dienstwagen_art' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen Antrieb', v_old.dienstwagen_art, nullif(v_role->>'dienstwagen_art', '')); end if;$neu$,
|
||||
'g'
|
||||
);
|
||||
if v_neu = v_def then raise exception 'Der Vergleich für die Antriebsart liess sich nicht einhängen.'; end if;
|
||||
v_def := v_neu;
|
||||
|
||||
-- ── Schreiben
|
||||
v_neu := regexp_replace(
|
||||
v_def,
|
||||
'(phone\s*=\s*coalesce\(v_person->>''phone'',\s*phone\),)',
|
||||
$neu$\1
|
||||
emergency_contact_name = case when v_person ? 'emergency_contact_name' then nullif(v_person->>'emergency_contact_name', '') else emergency_contact_name end,
|
||||
emergency_contact_phone = case when v_person ? 'emergency_contact_phone' then nullif(v_person->>'emergency_contact_phone', '') else emergency_contact_phone end,
|
||||
emergency_contact_relation = case when v_person ? 'emergency_contact_relation' then nullif(v_person->>'emergency_contact_relation', '') else emergency_contact_relation end,$neu$,
|
||||
'g'
|
||||
);
|
||||
if v_neu = v_def then raise exception 'Der Schreibblock für den Notfallkontakt liess sich nicht einhängen.'; end if;
|
||||
v_def := v_neu;
|
||||
|
||||
-- Die Antriebsart hängt an has_dienstwagen: wird der Dienstwagen
|
||||
-- abgemeldet, muss sie mit, sonst weist der CHECK die Zeile ab.
|
||||
v_neu := regexp_replace(
|
||||
v_def,
|
||||
'(is_c_level\s*=\s*coalesce\(\(v_role->>''is_c_level''\)::boolean,\s*is_c_level\))',
|
||||
$neu$\1,
|
||||
dienstwagen_art = case
|
||||
when coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen) then
|
||||
coalesce(nullif(v_role->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner')
|
||||
else null
|
||||
end$neu$,
|
||||
'g'
|
||||
);
|
||||
if v_neu = v_def then raise exception 'Der Schreibblock für die Antriebsart liess sich nicht einhängen.'; end if;
|
||||
|
||||
execute v_neu;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- ═══ Gegenprobe ══════════════════════════════════════════════════
|
||||
do $$
|
||||
declare v_def text;
|
||||
begin
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'change_employee_data' limit 1;
|
||||
|
||||
if v_def not like '%Notfallkontakt Verhältnis%' then
|
||||
raise exception 'change_employee_data() vergleicht den Notfallkontakt nicht.';
|
||||
end if;
|
||||
if v_def not like '%dienstwagen_art = case%' then
|
||||
raise exception 'change_employee_data() schreibt die Antriebsart nicht.';
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
62
supabase/migrations/20260811130000_hire_takes_new_fields.sql
Normal file
62
supabase/migrations/20260811130000_hire_takes_new_fields.sql
Normal file
@@ -0,0 +1,62 @@
|
||||
-- hire_employee schreibt Notfallkontakt und Antriebsart mit.
|
||||
--
|
||||
-- Die Spalten gibt es seit 20260811110000, aber die Einstellung zählt ihre
|
||||
-- Spalten namentlich auf — was dort fehlt, wird beim Anlegen verworfen. Der
|
||||
-- Assistent hätte die Felder erhoben und stillschweigend weggeworfen; genau
|
||||
-- der Fall, der heute schon einmal mit der E-Mail passiert ist.
|
||||
|
||||
do $$
|
||||
declare
|
||||
v_def text;
|
||||
v_neu text;
|
||||
begin
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'hire_employee' limit 1;
|
||||
|
||||
v_neu := regexp_replace(
|
||||
v_def,
|
||||
'(is_betriebsrat,\s*has_dienstwagen,\s*is_laterale_fuehrung,\s*is_c_level\s*\))',
|
||||
'is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level,'
|
||||
|| chr(10) || ' dienstwagen_art, emergency_contact_name, emergency_contact_phone, emergency_contact_relation'
|
||||
|| chr(10) || ' )',
|
||||
'g'
|
||||
);
|
||||
if v_neu = v_def then raise exception 'Die Spaltenliste liess sich nicht ergänzen.'; end if;
|
||||
v_def := v_neu;
|
||||
|
||||
-- Die Werteliste endet mit is_c_level; davor steht der Abschluss der
|
||||
-- values-Klammer.
|
||||
v_neu := regexp_replace(
|
||||
v_def,
|
||||
'(coalesce\(\(payload->>''is_c_level''\)::boolean,\s*false\))',
|
||||
E'\\1,\n'
|
||||
-- Antrieb nur, wenn es einen Dienstwagen gibt — sonst weist der CHECK
|
||||
-- die Zeile ab. Ohne Angabe gilt Verbrenner, wie im Bestand.
|
||||
|| ' case when coalesce((payload->>''has_dienstwagen'')::boolean, false)'
|
||||
|| ' then coalesce(nullif(payload->>''dienstwagen_art'', ''''), ''Verbrenner'') else null end,' || chr(10)
|
||||
|| ' nullif(payload->>''emergency_contact_name'', ''''),' || chr(10)
|
||||
|| ' nullif(payload->>''emergency_contact_phone'', ''''),' || chr(10)
|
||||
|| ' nullif(payload->>''emergency_contact_relation'', '''')',
|
||||
'g'
|
||||
);
|
||||
if v_neu = v_def then raise exception 'Die Werteliste liess sich nicht ergänzen.'; end if;
|
||||
|
||||
execute v_neu;
|
||||
end;
|
||||
$$;
|
||||
|
||||
do $$
|
||||
declare v_def text;
|
||||
begin
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'hire_employee' limit 1;
|
||||
if v_def not like '%emergency_contact_relation%' then
|
||||
raise exception 'hire_employee() schreibt den Notfallkontakt nicht.';
|
||||
end if;
|
||||
if v_def not like '%dienstwagen_art%' then
|
||||
raise exception 'hire_employee() schreibt die Antriebsart nicht.';
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
@@ -0,0 +1,37 @@
|
||||
-- Die E-Mail-Adresse ist privat und freiwillig.
|
||||
--
|
||||
-- Sie war NOT NULL, obwohl es sich um die *private* Adresse handelt — nicht
|
||||
-- um eine Firmenadresse, die mit dem Eintritt entsteht. Wer keine angeben
|
||||
-- will oder keine hat, muss trotzdem angelegt werden können. Bisher zwang
|
||||
-- die Spalte dazu, etwas zu erfinden, und erfundene Daten in einer
|
||||
-- Personalakte sind schlimmer als fehlende.
|
||||
--
|
||||
-- Die Eindeutigkeit bleibt: sie verhindert weiterhin, dass dieselbe Adresse
|
||||
-- zweimal vorkommt. Mehrere NULL-Werte stören sie nicht — in PostgreSQL
|
||||
-- gelten sie in einem UNIQUE-Index als voneinander verschieden, und genau
|
||||
-- das ist hier gewollt.
|
||||
|
||||
alter table employees alter column email drop not null;
|
||||
|
||||
comment on column employees.email is
|
||||
'Private E-Mail-Adresse. Freiwillig; eindeutig, wenn angegeben.';
|
||||
|
||||
comment on column employees.phone is
|
||||
'Private Telefonnummer. Freiwillig.';
|
||||
|
||||
-- ═══ Gegenprobe ══════════════════════════════════════════════════
|
||||
-- Zwei Personen ohne Adresse müssen nebeneinander bestehen können, zwei mit
|
||||
-- derselben nicht.
|
||||
do $$
|
||||
begin
|
||||
create temp table probe_mail (email text unique) on commit drop;
|
||||
insert into probe_mail (email) values (null), (null);
|
||||
|
||||
insert into probe_mail (email) values ('a@example.invalid');
|
||||
begin
|
||||
insert into probe_mail (email) values ('a@example.invalid');
|
||||
raise exception 'Doppelte Adressen werden angenommen — die Eindeutigkeit ist verloren.';
|
||||
exception when unique_violation then null;
|
||||
end;
|
||||
end;
|
||||
$$;
|
||||
179
supabase/migrations/20260813120000_history_carries_changes.sql
Normal file
179
supabase/migrations/20260813120000_history_carries_changes.sql
Normal file
@@ -0,0 +1,179 @@
|
||||
-- Die Historie trägt die Werte mit, nicht nur die Feldnamen.
|
||||
--
|
||||
-- Beim Testen fiel auf: ändert jemand eine Adresse, steht in der Historie der
|
||||
-- Person nur "Geänderte Felder: Adresse, Ort". Die alte Adresse ist nirgends
|
||||
-- zu sehen — sie steckt allein im Audit-Log, und das ist eine andere Seite,
|
||||
-- nach Zeitpunkt und handelnder Person sortiert statt nach Person. Wer wissen
|
||||
-- will, ob eine Anschrift je geändert wurde und wie sie vorher lautete, kommt
|
||||
-- also nicht hin, obwohl die Anwendung es weiß.
|
||||
--
|
||||
-- Die Feldliste wird beim Ändern ohnehin gebaut (app_aenderung) und ins
|
||||
-- Audit-Log geschrieben. Sie wandert jetzt zusätzlich in die Historienzeile.
|
||||
-- Das ist bewusst redundant: die Historie ist die Geschichte *einer Person*
|
||||
-- und soll für sich allein lesbar sein — auch dann noch, wenn das Protokoll
|
||||
-- irgendwann nach Aufbewahrungsfrist ausgedünnt wird.
|
||||
--
|
||||
-- Alte Zeilen bleiben ohne Werte. Nachliefern ließe sich das nur aus dem
|
||||
-- Audit-Log, und die Zuordnung dorthin ist nicht eindeutig (kein Schlüssel,
|
||||
-- nur Zeitpunkt und Person). Lieber ehrlich leer als falsch verknüpft.
|
||||
|
||||
alter table employee_history add column if not exists changes jsonb;
|
||||
|
||||
comment on column employee_history.changes is
|
||||
'Feldweise Änderungen als [{feld, vorher, nachher}] — dieselbe Form wie audit_log.changes. Null bei Ereignissen ohne Einzelfelder (Eintritt, Austritt, Import) und bei Zeilen von vor dieser Migration.';
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.change_employee_data(payload jsonb)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $function$
|
||||
declare
|
||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
|
||||
v_old employees%rowtype;
|
||||
v_name text;
|
||||
v_person_changes jsonb := '[]'::jsonb;
|
||||
v_contract_changes jsonb := '[]'::jsonb;
|
||||
v_person jsonb := payload->'person';
|
||||
v_contract jsonb := payload->'contract';
|
||||
v_role jsonb := payload->'role';
|
||||
v_immediate boolean;
|
||||
v_new_work_days text[];
|
||||
v_new_title_prefix text[];
|
||||
v_new_title_suffix text[];
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select * into v_old from employees where id = v_employee_id;
|
||||
v_name := v_old.first_name || ' ' || v_old.last_name;
|
||||
v_immediate := v_effective_date <= current_date;
|
||||
|
||||
-- Der `?`-Test bleibt: ein fehlender Schlüssel heisst „nicht übermittelt",
|
||||
-- nicht „geleert". Ohne ihn würde jedes nicht gesendete Feld als Änderung
|
||||
-- auf null gemeldet.
|
||||
if v_person ? 'first_name' then v_person_changes := app_aenderung(v_person_changes, 'Vorname', v_old.first_name, v_person->>'first_name'); end if;
|
||||
if v_person ? 'last_name' then v_person_changes := app_aenderung(v_person_changes, 'Nachname', v_old.last_name, v_person->>'last_name'); end if;
|
||||
if v_person ? 'gender' then v_person_changes := app_aenderung(v_person_changes, 'Geschlecht', v_old.gender::text, v_person->>'gender'); end if;
|
||||
-- Datumswerte über ::date::text vergleichen, damit „2026-8-3" und
|
||||
-- „2026-08-03" nicht als Änderung gelten.
|
||||
if v_person ? 'birth_date' then v_person_changes := app_aenderung(v_person_changes, 'Geburtsdatum', v_old.birth_date::text, (nullif(v_person->>'birth_date','')::date)::text); end if;
|
||||
if v_person ? 'sv_nummer' then v_person_changes := app_aenderung(v_person_changes, 'SV-Nummer', v_old.sv_nummer, v_person->>'sv_nummer'); end if;
|
||||
if v_person ? 'nationality' then v_person_changes := app_aenderung(v_person_changes, 'Staatsbürgerschaft', v_old.nationality, v_person->>'nationality'); end if;
|
||||
if v_person ? 'address' then v_person_changes := app_aenderung(v_person_changes, 'Adresse', v_old.address, v_person->>'address'); end if;
|
||||
if v_person ? 'postal_code' then v_person_changes := app_aenderung(v_person_changes, 'Postleitzahl', v_old.postal_code, v_person->>'postal_code'); end if;
|
||||
if v_person ? 'city' then v_person_changes := app_aenderung(v_person_changes, 'Ort', v_old.city, v_person->>'city'); end if;
|
||||
if v_person ? 'address_country' then v_person_changes := app_aenderung(v_person_changes, 'Land', v_old.address_country, v_person->>'address_country'); end if;
|
||||
if v_person ? 'email' then v_person_changes := app_aenderung(v_person_changes, 'E-Mail', v_old.email, v_person->>'email'); end if;
|
||||
if v_person ? 'phone' then v_person_changes := app_aenderung(v_person_changes, 'Telefon', v_old.phone, v_person->>'phone'); end if;
|
||||
if v_person ? 'emergency_contact_name' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt', v_old.emergency_contact_name, v_person->>'emergency_contact_name'); end if;
|
||||
if v_person ? 'emergency_contact_phone' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Telefon', v_old.emergency_contact_phone, v_person->>'emergency_contact_phone'); end if;
|
||||
if v_person ? 'emergency_contact_relation' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Verhältnis', v_old.emergency_contact_relation, v_person->>'emergency_contact_relation'); end if;
|
||||
|
||||
if v_person ? 'title_prefix' then
|
||||
v_new_title_prefix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_prefix') elem), '{}');
|
||||
v_person_changes := app_aenderung(v_person_changes, 'Titel (vorangestellt)',
|
||||
array_to_string(v_old.title_prefix, ', '), array_to_string(v_new_title_prefix, ', '));
|
||||
end if;
|
||||
if v_person ? 'title_suffix' then
|
||||
v_new_title_suffix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_suffix') elem), '{}');
|
||||
v_person_changes := app_aenderung(v_person_changes, 'Titel (nachgestellt)',
|
||||
array_to_string(v_old.title_suffix, ', '), array_to_string(v_new_title_suffix, ', '));
|
||||
end if;
|
||||
|
||||
if v_contract ? 'employment_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Beschäftigungsausmaß', v_old.employment_type::text, v_contract->>'employment_type'); end if;
|
||||
-- Über ::numeric::text, damit „38.50" und „38.5" gleich zählen.
|
||||
if v_contract ? 'weekly_hours' then v_contract_changes := app_aenderung(v_contract_changes, 'Wochenstunden', v_old.weekly_hours::text, (nullif(v_contract->>'weekly_hours','')::numeric)::text); end if;
|
||||
if v_contract ? 'contract_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Vertragsart', v_old.contract_type::text, v_contract->>'contract_type'); end if;
|
||||
if v_contract ? 'contract_end_date' then v_contract_changes := app_aenderung(v_contract_changes, 'Befristet bis', v_old.contract_end_date::text, (nullif(v_contract->>'contract_end_date','')::date)::text); end if;
|
||||
|
||||
if v_role ? 'worker_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Angestellte:r/Arbeiter:in', v_old.worker_type::text, v_role->>'worker_type'); end if;
|
||||
if v_role ? 'collective_agreement' then v_contract_changes := app_aenderung(v_contract_changes, 'Kollektivvertrag', v_old.collective_agreement::text, v_role->>'collective_agreement'); end if;
|
||||
if v_role ? 'work_days' then
|
||||
v_new_work_days := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_role->'work_days') elem), '{}');
|
||||
v_contract_changes := app_aenderung(v_contract_changes, 'Arbeitstage',
|
||||
array_to_string(v_old.work_days, ', '), array_to_string(v_new_work_days, ', '));
|
||||
end if;
|
||||
if v_role ? 'is_betriebsrat' then v_contract_changes := app_aenderung(v_contract_changes, 'Betriebsrat', v_old.is_betriebsrat::text, v_role->>'is_betriebsrat'); end if;
|
||||
if v_role ? 'has_dienstwagen' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen', v_old.has_dienstwagen::text, v_role->>'has_dienstwagen'); end if;
|
||||
if v_role ? 'is_laterale_fuehrung' then v_contract_changes := app_aenderung(v_contract_changes, 'Laterale Führung', v_old.is_laterale_fuehrung::text, v_role->>'is_laterale_fuehrung'); end if;
|
||||
if v_role ? 'is_c_level' then v_contract_changes := app_aenderung(v_contract_changes, 'C-Level', v_old.is_c_level::text, v_role->>'is_c_level'); end if;
|
||||
if v_role ? 'dienstwagen_art' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen Antrieb', v_old.dienstwagen_art, nullif(v_role->>'dienstwagen_art', '')); end if;
|
||||
|
||||
if v_immediate then
|
||||
update employees set
|
||||
first_name = coalesce(v_person->>'first_name', first_name),
|
||||
last_name = coalesce(v_person->>'last_name', last_name),
|
||||
gender = coalesce((v_person->>'gender')::gender_type, gender),
|
||||
birth_date = coalesce((v_person->>'birth_date')::date, birth_date),
|
||||
sv_nummer = coalesce(v_person->>'sv_nummer', sv_nummer),
|
||||
nationality = coalesce(v_person->>'nationality', nationality),
|
||||
address = coalesce(v_person->>'address', address),
|
||||
postal_code = coalesce(v_person->>'postal_code', postal_code),
|
||||
city = coalesce(v_person->>'city', city),
|
||||
address_country = coalesce(v_person->>'address_country', address_country),
|
||||
email = coalesce(v_person->>'email', email),
|
||||
phone = coalesce(v_person->>'phone', phone),
|
||||
emergency_contact_name = case when v_person ? 'emergency_contact_name' then nullif(v_person->>'emergency_contact_name', '') else emergency_contact_name end,
|
||||
emergency_contact_phone = case when v_person ? 'emergency_contact_phone' then nullif(v_person->>'emergency_contact_phone', '') else emergency_contact_phone end,
|
||||
emergency_contact_relation = case when v_person ? 'emergency_contact_relation' then nullif(v_person->>'emergency_contact_relation', '') else emergency_contact_relation end,
|
||||
title_prefix = case when v_person ? 'title_prefix' then v_new_title_prefix else title_prefix end,
|
||||
title_suffix = case when v_person ? 'title_suffix' then v_new_title_suffix else title_suffix end,
|
||||
employment_type = coalesce((v_contract->>'employment_type')::employment_type, employment_type),
|
||||
weekly_hours = coalesce((v_contract->>'weekly_hours')::numeric, weekly_hours),
|
||||
contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),
|
||||
contract_end_date = case when v_contract ? 'contract_end_date' then nullif(v_contract->>'contract_end_date','')::date else contract_end_date end,
|
||||
worker_type = coalesce((v_role->>'worker_type')::worker_type, worker_type),
|
||||
collective_agreement = coalesce((v_role->>'collective_agreement')::collective_agreement, collective_agreement),
|
||||
work_days = case when v_role ? 'work_days' then v_new_work_days else work_days end,
|
||||
is_betriebsrat = coalesce((v_role->>'is_betriebsrat')::boolean, is_betriebsrat),
|
||||
has_dienstwagen = coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen),
|
||||
is_laterale_fuehrung = coalesce((v_role->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
|
||||
is_c_level = coalesce((v_role->>'is_c_level')::boolean, is_c_level),
|
||||
dienstwagen_art = case
|
||||
when coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen) then
|
||||
coalesce(nullif(v_role->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner')
|
||||
else null
|
||||
end
|
||||
where id = v_employee_id;
|
||||
elsif jsonb_array_length(v_person_changes) > 0 or jsonb_array_length(v_contract_changes) > 0 then
|
||||
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
|
||||
values (v_employee_id, 'contract_change', v_effective_date, payload);
|
||||
end if;
|
||||
|
||||
if jsonb_array_length(v_person_changes) > 0 then
|
||||
insert into employee_history (employee_id, event_date, event_type, description, changes)
|
||||
values (v_employee_id, v_effective_date, 'Stammdatenänderung',
|
||||
'Geänderte Felder: ' || app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes);
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
|
||||
values (app_current_user_id(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id,
|
||||
app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes);
|
||||
end if;
|
||||
|
||||
if jsonb_array_length(v_contract_changes) > 0 then
|
||||
insert into employee_history (employee_id, event_date, event_type, description, changes)
|
||||
values (v_employee_id, v_effective_date, 'Vertragsänderung',
|
||||
'Geänderte Felder: ' || app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes);
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
|
||||
values (app_current_user_id(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id,
|
||||
app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes);
|
||||
end if;
|
||||
end;
|
||||
$function$;
|
||||
|
||||
|
||||
-- Selbstprüfung: schlägt die Migration fehl, statt still nichts zu tun.
|
||||
do $$
|
||||
declare
|
||||
v_def text := pg_get_functiondef('public.change_employee_data(jsonb)'::regprocedure);
|
||||
begin
|
||||
if not exists (
|
||||
select 1 from information_schema.columns
|
||||
where table_schema = 'public' and table_name = 'employee_history' and column_name = 'changes'
|
||||
) then
|
||||
raise exception 'employee_history.changes fehlt';
|
||||
end if;
|
||||
|
||||
if (length(v_def) - length(replace(v_def, 'description, changes)', ''))) / length('description, changes)') <> 2 then
|
||||
raise exception 'change_employee_data schreibt changes nicht in beide Historien-Einträge';
|
||||
end if;
|
||||
end
|
||||
$$;
|
||||
209
supabase/migrations/20260813140000_delete_history_entry.sql
Normal file
209
supabase/migrations/20260813140000_delete_history_entry.sql
Normal file
@@ -0,0 +1,209 @@
|
||||
-- Einen Historieneintrag zurücknehmen — samt seiner Wirkung.
|
||||
--
|
||||
-- Die Historie ist bewusst fortschreibend: employee_history hat nur Policies
|
||||
-- für select und insert, es gibt kein update und kein delete. Das bleibt so.
|
||||
-- Was hier entsteht, ist ein einzelner kontrollierter Weg daran vorbei, und
|
||||
-- er ist eng: nur eine irrtümlich erfasste Stammdaten- oder Vertragsänderung,
|
||||
-- nur mit Feldwerten, nur wenn sie bereits wirksam ist.
|
||||
--
|
||||
-- ═══ Warum überhaupt löschen ═══
|
||||
--
|
||||
-- Eine Adresse, die versehentlich geändert wurde, steht sonst für immer als
|
||||
-- Änderung in der Akte — und die Person wohnt an der falschen Anschrift, bis
|
||||
-- jemand sie von Hand zurücksetzt. Dieses Zurücksetzen erzeugt dann eine
|
||||
-- *zweite* Änderung, und in der Historie stehen zwei Einträge, von denen
|
||||
-- keiner je stattgefunden hat. Genau das soll der Vorgang hier ersparen.
|
||||
--
|
||||
-- ═══ „Die letztgültige Änderung ist die schlagende" ═══
|
||||
--
|
||||
-- Beim Zurücknehmen wird je Feld einzeln entschieden:
|
||||
--
|
||||
-- * Hat ein **späterer** Eintrag dasselbe Feld angefasst, bleibt der
|
||||
-- heutige Wert stehen — die spätere Änderung ist die gültige.
|
||||
-- * Sonst wird der Wert auf das „vorher" des gelöschten Eintrags gesetzt.
|
||||
--
|
||||
-- Deshalb lässt sich auch der mittlere von drei Einträgen entfernen, ohne
|
||||
-- dass ein alter Wert einen neueren überschreibt.
|
||||
--
|
||||
-- ═══ Was nicht geht, und warum ═══
|
||||
--
|
||||
-- * **Eintritt** — der Anker der Zeitleiste. Ohne ihn hat die Person keinen
|
||||
-- Anfang, und ein Trigger verbietet ohnehin Ereignisse davor.
|
||||
-- * **Zukünftiges** — dazu gehört eine Zeile in pending_org_changes, und
|
||||
-- die lässt sich einem Historieneintrag nicht zuverlässig zuordnen: es
|
||||
-- gibt keinen Schlüssel zwischen beiden, nur Person und Datum. In den
|
||||
-- Daten hängt bereits ein „Eintritt" und eine Vertragsänderung am selben
|
||||
-- Tag. Eine Zuordnung über das Datum träfe irgendwann die falsche Zeile,
|
||||
-- und dann verschwände eine geplante Änderung, die niemand gemeint hat.
|
||||
-- * **Versetzung, Beförderung, Karenz, Rückkehr, Austritt, Wiedereintritt,
|
||||
-- Reorganisation** — die haben Planstellen und Zuordnungen bewegt.
|
||||
-- Dafür gibt es die fachlichen Vorgänge, die das sauber fortschreiben,
|
||||
-- statt rückwärts zu raten.
|
||||
-- * **Einträge ohne Feldwerte** — alles vor der Erweiterung der Historie.
|
||||
-- Es gibt nichts, worauf zurückgesetzt werden könnte.
|
||||
--
|
||||
-- ═══ Der Nachweis bleibt ═══
|
||||
--
|
||||
-- Gelöscht wird die Historienzeile, nicht die Spur: das Audit-Log bekommt
|
||||
-- einen Eintrag mit den Werten der gelöschten Zeile. Das Protokoll ist selbst
|
||||
-- fortschreibend, dort kann nichts verschwinden.
|
||||
|
||||
create or replace function delete_history_entry(payload jsonb)
|
||||
returns void
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path to 'public', 'pg_temp'
|
||||
as $function$
|
||||
declare
|
||||
v_id uuid := (payload->>'history_id')::uuid;
|
||||
v_eintrag employee_history%rowtype;
|
||||
v_name text;
|
||||
v_aenderung jsonb;
|
||||
v_feld text;
|
||||
v_wert text;
|
||||
v_spalte text;
|
||||
v_typ text;
|
||||
v_spaeter boolean;
|
||||
v_zurueckgesetzt jsonb := '[]'::jsonb;
|
||||
-- Feldbeschriftung → Spalte und Typ. Geschlossene Liste: was
|
||||
-- change_employee_data schreiben kann, steht hier, sonst nichts. Der
|
||||
-- Spaltenname geht in dynamisches SQL, deshalb darf er nur von hier kommen.
|
||||
v_karte constant jsonb := jsonb_build_object(
|
||||
'Vorname', jsonb_build_array('first_name', 'text'),
|
||||
'Nachname', jsonb_build_array('last_name', 'text'),
|
||||
'Geschlecht', jsonb_build_array('gender', 'gender_type'),
|
||||
'Geburtsdatum', jsonb_build_array('birth_date', 'date'),
|
||||
'SV-Nummer', jsonb_build_array('sv_nummer', 'text'),
|
||||
'Staatsbürgerschaft', jsonb_build_array('nationality', 'text'),
|
||||
'Adresse', jsonb_build_array('address', 'text'),
|
||||
'Postleitzahl', jsonb_build_array('postal_code', 'text'),
|
||||
'Ort', jsonb_build_array('city', 'text'),
|
||||
'Land', jsonb_build_array('address_country', 'text'),
|
||||
'E-Mail', jsonb_build_array('email', 'text'),
|
||||
'Telefon', jsonb_build_array('phone', 'text'),
|
||||
'Notfallkontakt', jsonb_build_array('emergency_contact_name', 'text'),
|
||||
'Notfallkontakt Telefon', jsonb_build_array('emergency_contact_phone', 'text'),
|
||||
'Notfallkontakt Verhältnis', jsonb_build_array('emergency_contact_relation', 'text'),
|
||||
'Titel (vorangestellt)', jsonb_build_array('title_prefix', 'liste'),
|
||||
'Titel (nachgestellt)', jsonb_build_array('title_suffix', 'liste'),
|
||||
'Beschäftigungsausmaß', jsonb_build_array('employment_type', 'employment_type'),
|
||||
'Wochenstunden', jsonb_build_array('weekly_hours', 'numeric'),
|
||||
'Vertragsart', jsonb_build_array('contract_type', 'contract_type'),
|
||||
'Befristet bis', jsonb_build_array('contract_end_date', 'date'),
|
||||
'Angestellte:r/Arbeiter:in', jsonb_build_array('worker_type', 'worker_type'),
|
||||
'Kollektivvertrag', jsonb_build_array('collective_agreement', 'collective_agreement'),
|
||||
'Arbeitstage', jsonb_build_array('work_days', 'liste'),
|
||||
'Betriebsrat', jsonb_build_array('is_betriebsrat', 'boolean'),
|
||||
'Dienstwagen', jsonb_build_array('has_dienstwagen', 'boolean'),
|
||||
'Laterale Führung', jsonb_build_array('is_laterale_fuehrung', 'boolean'),
|
||||
'C-Level', jsonb_build_array('is_c_level', 'boolean'),
|
||||
'Dienstwagen Antrieb', jsonb_build_array('dienstwagen_art', 'text')
|
||||
);
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
|
||||
select * into v_eintrag from employee_history where id = v_id;
|
||||
if not found then
|
||||
raise exception 'Historieneintrag nicht gefunden.';
|
||||
end if;
|
||||
|
||||
if v_eintrag.event_type = 'Eintritt' then
|
||||
raise exception 'Der Eintritt lässt sich nicht löschen — er ist der Anfang der Zeitleiste.';
|
||||
end if;
|
||||
|
||||
if v_eintrag.event_type not in ('Stammdatenänderung', 'Vertragsänderung') then
|
||||
raise exception 'Nur Stammdaten- und Vertragsänderungen lassen sich hier zurücknehmen. Für % gibt es den passenden Vorgang.', v_eintrag.event_type;
|
||||
end if;
|
||||
|
||||
if v_eintrag.event_date > current_date then
|
||||
raise exception 'Diese Änderung ist noch nicht wirksam und hängt an einem geplanten Vorgang. Sie muss dort abgebrochen werden.';
|
||||
end if;
|
||||
|
||||
if v_eintrag.changes is null or jsonb_array_length(v_eintrag.changes) = 0 then
|
||||
raise exception 'Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts, worauf zurückgesetzt werden könnte.';
|
||||
end if;
|
||||
|
||||
select first_name || ' ' || last_name into v_name from employees where id = v_eintrag.employee_id;
|
||||
|
||||
-- Je Feld: nur zurücksetzen, wenn kein späterer Eintrag dasselbe Feld
|
||||
-- angefasst hat. Sonst gilt der spätere Wert weiter.
|
||||
for v_aenderung in select * from jsonb_array_elements(v_eintrag.changes) loop
|
||||
v_feld := v_aenderung->>'feld';
|
||||
|
||||
if not v_karte ? v_feld then
|
||||
continue; -- unbekannte Beschriftung: nichts anfassen
|
||||
end if;
|
||||
|
||||
select exists (
|
||||
select 1
|
||||
from employee_history h,
|
||||
lateral jsonb_array_elements(coalesce(h.changes, '[]'::jsonb)) a
|
||||
where h.employee_id = v_eintrag.employee_id
|
||||
and h.id <> v_eintrag.id
|
||||
and a->>'feld' = v_feld
|
||||
and (h.event_date, h.created_at) > (v_eintrag.event_date, v_eintrag.created_at)
|
||||
) into v_spaeter;
|
||||
|
||||
if v_spaeter then
|
||||
continue;
|
||||
end if;
|
||||
|
||||
v_spalte := v_karte->v_feld->>0;
|
||||
v_typ := v_karte->v_feld->>1;
|
||||
v_wert := v_aenderung->>'vorher';
|
||||
|
||||
if v_typ = 'liste' then
|
||||
execute format('update employees set %I = coalesce(string_to_array(%L, '', ''), ''{}'') where id = %L',
|
||||
v_spalte, nullif(v_wert, ''), v_eintrag.employee_id);
|
||||
else
|
||||
execute format('update employees set %I = %L::%s where id = %L',
|
||||
v_spalte, nullif(v_wert, ''), v_typ, v_eintrag.employee_id);
|
||||
end if;
|
||||
|
||||
v_zurueckgesetzt := v_zurueckgesetzt || jsonb_build_object(
|
||||
'feld', v_feld,
|
||||
'vorher', v_aenderung->>'nachher',
|
||||
'nachher', v_wert
|
||||
);
|
||||
end loop;
|
||||
|
||||
delete from employee_history where id = v_id;
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
|
||||
values (app_current_user_id(), current_actor_name(), 'Historieneintrag gelöscht', v_name, v_eintrag.employee_id,
|
||||
v_eintrag.event_type || ' vom ' || v_eintrag.event_date ||
|
||||
case when jsonb_array_length(v_zurueckgesetzt) = 0
|
||||
then ' gelöscht; keine Werte zurückgesetzt (spätere Änderungen gelten)'
|
||||
else ' gelöscht und zurückgesetzt: ' || app_aenderungsfelder(v_zurueckgesetzt) end,
|
||||
v_zurueckgesetzt);
|
||||
end;
|
||||
$function$;
|
||||
|
||||
comment on function delete_history_entry(jsonb) is
|
||||
'Nimmt eine irrtümliche Stammdaten- oder Vertragsänderung zurück: setzt je Feld auf den Wert davor, sofern kein späterer Eintrag dasselbe Feld geändert hat, und entfernt die Historienzeile. Der Vorgang selbst wird im Audit-Log festgehalten. SECURITY DEFINER, weil employee_history absichtlich keine delete-Policy hat.';
|
||||
|
||||
-- Selbstprüfung: lieber laut scheitern als still nichts tun.
|
||||
do $$
|
||||
declare
|
||||
v_def text;
|
||||
begin
|
||||
select pg_get_functiondef('public.delete_history_entry(jsonb)'::regprocedure) into v_def;
|
||||
|
||||
if not (select prosecdef from pg_proc where oid = 'public.delete_history_entry(jsonb)'::regprocedure) then
|
||||
raise exception 'delete_history_entry muss SECURITY DEFINER sein, sonst greift die fehlende delete-Policy';
|
||||
end if;
|
||||
|
||||
if v_def not like '%require_hr_admin%' then
|
||||
raise exception 'delete_history_entry prüft die Berechtigung nicht';
|
||||
end if;
|
||||
|
||||
-- Die delete-Policy darf es weiterhin nicht geben: der Weg hier ist der
|
||||
-- einzige, und er ist geprüft.
|
||||
if exists (
|
||||
select 1 from pg_policy p join pg_class c on c.oid = p.polrelid
|
||||
where c.relname = 'employee_history' and p.polcmd = 'd'
|
||||
) then
|
||||
raise exception 'employee_history hat eine delete-Policy bekommen — das war nicht beabsichtigt';
|
||||
end if;
|
||||
end
|
||||
$$;
|
||||
185
supabase/migrations/20260813160000_revert_in_one_statement.sql
Normal file
185
supabase/migrations/20260813160000_revert_in_one_statement.sql
Normal file
@@ -0,0 +1,185 @@
|
||||
-- Zurücksetzen in einem Zug, nicht Feld für Feld.
|
||||
--
|
||||
-- Die erste Fassung schrieb je Feld ein eigenes UPDATE. Das ist bei
|
||||
-- unabhängigen Feldern harmlos und bei gekoppelten falsch: chk_weekly_hours
|
||||
-- verlangt, dass Beschäftigungsausmaß und Wochenstunden zueinander passen
|
||||
-- (Vollzeit genau 38,5; Teilzeit dazwischen). Wird zuerst das Ausmaß auf
|
||||
-- „Vollzeit" zurückgesetzt, während noch 37 Stunden dastehen, verbietet die
|
||||
-- Bedingung genau diesen Zwischenstand — und das Löschen scheiterte mit
|
||||
-- „new row for relation employees violates check constraint".
|
||||
--
|
||||
-- Es traf jede Rücknahme einer Vertragsänderung, die beide Felder betraf,
|
||||
-- also praktisch jede: die Oberfläche ändert Ausmaß und Stunden zusammen.
|
||||
--
|
||||
-- Jetzt werden die Zuweisungen gesammelt und in einer einzigen Anweisung
|
||||
-- geschrieben. Damit entsteht der verbotene Zwischenstand gar nicht — der
|
||||
-- Zustand von davor war ja gültig, sonst stünde er nicht in der Historie.
|
||||
--
|
||||
-- Bleibt danach doch eine Verletzung, ist sie echt: dann hat eine spätere
|
||||
-- Änderung eines der gekoppelten Felder einzeln angefasst, und der alte Wert
|
||||
-- passt nicht mehr zum heutigen Stand. Dieser Fall wird abgefangen und als
|
||||
-- Satz gemeldet, statt als Datenbankfehler durchzuschlagen.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.delete_history_entry(payload jsonb)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $function$
|
||||
declare
|
||||
v_id uuid := (payload->>'history_id')::uuid;
|
||||
v_eintrag employee_history%rowtype;
|
||||
v_name text;
|
||||
v_aenderung jsonb;
|
||||
v_feld text;
|
||||
v_wert text;
|
||||
v_spalte text;
|
||||
v_typ text;
|
||||
v_spaeter boolean;
|
||||
v_zurueckgesetzt jsonb := '[]'::jsonb;
|
||||
v_setz text[] := '{}';
|
||||
-- Feldbeschriftung → Spalte und Typ. Geschlossene Liste: was
|
||||
-- change_employee_data schreiben kann, steht hier, sonst nichts. Der
|
||||
-- Spaltenname geht in dynamisches SQL, deshalb darf er nur von hier kommen.
|
||||
v_karte constant jsonb := jsonb_build_object(
|
||||
'Vorname', jsonb_build_array('first_name', 'text'),
|
||||
'Nachname', jsonb_build_array('last_name', 'text'),
|
||||
'Geschlecht', jsonb_build_array('gender', 'gender_type'),
|
||||
'Geburtsdatum', jsonb_build_array('birth_date', 'date'),
|
||||
'SV-Nummer', jsonb_build_array('sv_nummer', 'text'),
|
||||
'Staatsbürgerschaft', jsonb_build_array('nationality', 'text'),
|
||||
'Adresse', jsonb_build_array('address', 'text'),
|
||||
'Postleitzahl', jsonb_build_array('postal_code', 'text'),
|
||||
'Ort', jsonb_build_array('city', 'text'),
|
||||
'Land', jsonb_build_array('address_country', 'text'),
|
||||
'E-Mail', jsonb_build_array('email', 'text'),
|
||||
'Telefon', jsonb_build_array('phone', 'text'),
|
||||
'Notfallkontakt', jsonb_build_array('emergency_contact_name', 'text'),
|
||||
'Notfallkontakt Telefon', jsonb_build_array('emergency_contact_phone', 'text'),
|
||||
'Notfallkontakt Verhältnis', jsonb_build_array('emergency_contact_relation', 'text'),
|
||||
'Titel (vorangestellt)', jsonb_build_array('title_prefix', 'liste'),
|
||||
'Titel (nachgestellt)', jsonb_build_array('title_suffix', 'liste'),
|
||||
'Beschäftigungsausmaß', jsonb_build_array('employment_type', 'employment_type'),
|
||||
'Wochenstunden', jsonb_build_array('weekly_hours', 'numeric'),
|
||||
'Vertragsart', jsonb_build_array('contract_type', 'contract_type'),
|
||||
'Befristet bis', jsonb_build_array('contract_end_date', 'date'),
|
||||
'Angestellte:r/Arbeiter:in', jsonb_build_array('worker_type', 'worker_type'),
|
||||
'Kollektivvertrag', jsonb_build_array('collective_agreement', 'collective_agreement'),
|
||||
'Arbeitstage', jsonb_build_array('work_days', 'liste'),
|
||||
'Betriebsrat', jsonb_build_array('is_betriebsrat', 'boolean'),
|
||||
'Dienstwagen', jsonb_build_array('has_dienstwagen', 'boolean'),
|
||||
'Laterale Führung', jsonb_build_array('is_laterale_fuehrung', 'boolean'),
|
||||
'C-Level', jsonb_build_array('is_c_level', 'boolean'),
|
||||
'Dienstwagen Antrieb', jsonb_build_array('dienstwagen_art', 'text')
|
||||
);
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
|
||||
select * into v_eintrag from employee_history where id = v_id;
|
||||
if not found then
|
||||
raise exception 'Historieneintrag nicht gefunden.';
|
||||
end if;
|
||||
|
||||
if v_eintrag.event_type = 'Eintritt' then
|
||||
raise exception 'Der Eintritt lässt sich nicht löschen — er ist der Anfang der Zeitleiste.';
|
||||
end if;
|
||||
|
||||
if v_eintrag.event_type not in ('Stammdatenänderung', 'Vertragsänderung') then
|
||||
raise exception 'Nur Stammdaten- und Vertragsänderungen lassen sich hier zurücknehmen. Für % gibt es den passenden Vorgang.', v_eintrag.event_type;
|
||||
end if;
|
||||
|
||||
if v_eintrag.event_date > current_date then
|
||||
raise exception 'Diese Änderung ist noch nicht wirksam und hängt an einem geplanten Vorgang. Sie muss dort abgebrochen werden.';
|
||||
end if;
|
||||
|
||||
if v_eintrag.changes is null or jsonb_array_length(v_eintrag.changes) = 0 then
|
||||
raise exception 'Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts, worauf zurückgesetzt werden könnte.';
|
||||
end if;
|
||||
|
||||
select first_name || ' ' || last_name into v_name from employees where id = v_eintrag.employee_id;
|
||||
|
||||
-- Je Feld: nur zurücksetzen, wenn kein späterer Eintrag dasselbe Feld
|
||||
-- angefasst hat. Sonst gilt der spätere Wert weiter.
|
||||
for v_aenderung in select * from jsonb_array_elements(v_eintrag.changes) loop
|
||||
v_feld := v_aenderung->>'feld';
|
||||
|
||||
if not v_karte ? v_feld then
|
||||
continue; -- unbekannte Beschriftung: nichts anfassen
|
||||
end if;
|
||||
|
||||
select exists (
|
||||
select 1
|
||||
from employee_history h,
|
||||
lateral jsonb_array_elements(coalesce(h.changes, '[]'::jsonb)) a
|
||||
where h.employee_id = v_eintrag.employee_id
|
||||
and h.id <> v_eintrag.id
|
||||
and a->>'feld' = v_feld
|
||||
and (h.event_date, h.created_at) > (v_eintrag.event_date, v_eintrag.created_at)
|
||||
) into v_spaeter;
|
||||
|
||||
if v_spaeter then
|
||||
continue;
|
||||
end if;
|
||||
|
||||
v_spalte := v_karte->v_feld->>0;
|
||||
v_typ := v_karte->v_feld->>1;
|
||||
v_wert := v_aenderung->>'vorher';
|
||||
|
||||
if v_typ = 'liste' then
|
||||
v_setz := v_setz || format('%I = coalesce(string_to_array(%L, '', ''), ''{}'')', v_spalte, nullif(v_wert, ''));
|
||||
else
|
||||
v_setz := v_setz || format('%I = %L::%s', v_spalte, nullif(v_wert, ''), v_typ);
|
||||
end if;
|
||||
|
||||
v_zurueckgesetzt := v_zurueckgesetzt || jsonb_build_object(
|
||||
'feld', v_feld,
|
||||
'vorher', v_aenderung->>'nachher',
|
||||
'nachher', v_wert
|
||||
);
|
||||
end loop;
|
||||
|
||||
-- Alle Felder in *einem* UPDATE. Einzeln nacheinander zu schreiben war der
|
||||
-- Fehler der ersten Fassung: chk_weekly_hours verknüpft Beschäftigungsausmaß
|
||||
-- und Wochenstunden, und zwischen zwei getrennten Anweisungen steht
|
||||
-- zwangsläufig ein Zwischenstand, den die Bedingung verbietet — „Vollzeit
|
||||
-- mit 37 Stunden". Gemeinsam gesetzt gibt es diesen Zwischenstand nicht.
|
||||
if array_length(v_setz, 1) > 0 then
|
||||
begin
|
||||
execute format('update employees set %s where id = %L', array_to_string(v_setz, ', '), v_eintrag.employee_id);
|
||||
exception when check_violation then
|
||||
-- Bleibt trotzdem etwas übrig: dann wurde eines von zwei zusammen-
|
||||
-- gehörenden Feldern später einzeln geändert, und der alte Wert passt
|
||||
-- nicht mehr zum heutigen Stand. Lieber verständlich abweisen.
|
||||
raise exception 'Zurücksetzen nicht möglich: die Werte von damals passen nicht mehr zum heutigen Stand (%). Vermutlich wurde ein zusammengehörendes Feld später einzeln geändert.', sqlerrm;
|
||||
end;
|
||||
end if;
|
||||
|
||||
delete from employee_history where id = v_id;
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
|
||||
values (app_current_user_id(), current_actor_name(), 'Historieneintrag gelöscht', v_name, v_eintrag.employee_id,
|
||||
v_eintrag.event_type || ' vom ' || v_eintrag.event_date ||
|
||||
case when jsonb_array_length(v_zurueckgesetzt) = 0
|
||||
then ' gelöscht; keine Werte zurückgesetzt (spätere Änderungen gelten)'
|
||||
else ' gelöscht und zurückgesetzt: ' || app_aenderungsfelder(v_zurueckgesetzt) end,
|
||||
v_zurueckgesetzt);
|
||||
end;
|
||||
$function$;
|
||||
|
||||
|
||||
-- Selbstprüfung.
|
||||
do $$
|
||||
declare
|
||||
v_def text := pg_get_functiondef('public.delete_history_entry(jsonb)'::regprocedure);
|
||||
begin
|
||||
if v_def like '%update employees set %I%' then
|
||||
raise exception 'Es wird noch je Feld einzeln geschrieben';
|
||||
end if;
|
||||
if v_def not like '%array_to_string(v_setz%' then
|
||||
raise exception 'Das gesammelte UPDATE fehlt';
|
||||
end if;
|
||||
if v_def not like '%check_violation%' then
|
||||
raise exception 'Die Verletzung wird nicht abgefangen';
|
||||
end if;
|
||||
end
|
||||
$$;
|
||||
366
supabase/migrations/20260813180000_update_history_entry.sql
Normal file
366
supabase/migrations/20260813180000_update_history_entry.sql
Normal file
@@ -0,0 +1,366 @@
|
||||
-- Historieneinträge berichtigen — und die Feldtabelle nur noch einmal führen.
|
||||
--
|
||||
-- Drei Teile: die gemeinsame Feldtabelle, die bisher im Rumpf der
|
||||
-- Löschfunktion stand; dieselbe Löschfunktion, nun auf die gemeinsame
|
||||
-- Tabelle umgestellt; und das Berichtigen als neue Funktion.
|
||||
|
||||
-- Die Feldtabelle einmal, für alle, die sie brauchen.
|
||||
--
|
||||
-- Beschriftung → Spalte und Typ. Sie stand bisher im Rumpf von
|
||||
-- delete_history_entry; mit dem Bearbeiten kam eine zweite Stelle dazu, die
|
||||
-- sie genauso braucht. Zwei Kopien einer solchen Liste laufen auseinander,
|
||||
-- sobald ein Feld hinzukommt — und dann lässt sich ein Feld löschen, aber
|
||||
-- nicht korrigieren, ohne dass es jemandem auffällt.
|
||||
--
|
||||
-- Geschlossene Liste: was change_employee_data schreiben kann, steht hier,
|
||||
-- sonst nichts. Die Spaltennamen gehen in dynamisches SQL und dürfen nur
|
||||
-- von hier kommen.
|
||||
create or replace function app_feld_karte()
|
||||
returns jsonb
|
||||
language sql
|
||||
immutable
|
||||
set search_path to 'public', 'pg_temp'
|
||||
as $function$
|
||||
select jsonb_build_object(
|
||||
'Vorname', jsonb_build_array('first_name', 'text'),
|
||||
'Nachname', jsonb_build_array('last_name', 'text'),
|
||||
'Geschlecht', jsonb_build_array('gender', 'gender_type'),
|
||||
'Geburtsdatum', jsonb_build_array('birth_date', 'date'),
|
||||
'SV-Nummer', jsonb_build_array('sv_nummer', 'text'),
|
||||
'Staatsbürgerschaft', jsonb_build_array('nationality', 'text'),
|
||||
'Adresse', jsonb_build_array('address', 'text'),
|
||||
'Postleitzahl', jsonb_build_array('postal_code', 'text'),
|
||||
'Ort', jsonb_build_array('city', 'text'),
|
||||
'Land', jsonb_build_array('address_country', 'text'),
|
||||
'E-Mail', jsonb_build_array('email', 'text'),
|
||||
'Telefon', jsonb_build_array('phone', 'text'),
|
||||
'Notfallkontakt', jsonb_build_array('emergency_contact_name', 'text'),
|
||||
'Notfallkontakt Telefon', jsonb_build_array('emergency_contact_phone', 'text'),
|
||||
'Notfallkontakt Verhältnis', jsonb_build_array('emergency_contact_relation', 'text'),
|
||||
'Titel (vorangestellt)', jsonb_build_array('title_prefix', 'liste'),
|
||||
'Titel (nachgestellt)', jsonb_build_array('title_suffix', 'liste'),
|
||||
'Beschäftigungsausmaß', jsonb_build_array('employment_type', 'employment_type'),
|
||||
'Wochenstunden', jsonb_build_array('weekly_hours', 'numeric'),
|
||||
'Vertragsart', jsonb_build_array('contract_type', 'contract_type'),
|
||||
'Befristet bis', jsonb_build_array('contract_end_date', 'date'),
|
||||
'Angestellte:r/Arbeiter:in', jsonb_build_array('worker_type', 'worker_type'),
|
||||
'Kollektivvertrag', jsonb_build_array('collective_agreement', 'collective_agreement'),
|
||||
'Arbeitstage', jsonb_build_array('work_days', 'liste'),
|
||||
'Betriebsrat', jsonb_build_array('is_betriebsrat', 'boolean'),
|
||||
'Dienstwagen', jsonb_build_array('has_dienstwagen', 'boolean'),
|
||||
'Laterale Führung', jsonb_build_array('is_laterale_fuehrung', 'boolean'),
|
||||
'C-Level', jsonb_build_array('is_c_level', 'boolean'),
|
||||
'Dienstwagen Antrieb', jsonb_build_array('dienstwagen_art', 'text')
|
||||
);
|
||||
$function$;
|
||||
|
||||
comment on function app_feld_karte() is 'Beschriftung eines Feldes → [Spalte, Typ]. Quelle für das Zurücksetzen und Korrigieren von Historieneinträgen.';
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.delete_history_entry(payload jsonb)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $function$
|
||||
declare
|
||||
v_id uuid := (payload->>'history_id')::uuid;
|
||||
v_eintrag employee_history%rowtype;
|
||||
v_name text;
|
||||
v_aenderung jsonb;
|
||||
v_feld text;
|
||||
v_wert text;
|
||||
v_spalte text;
|
||||
v_typ text;
|
||||
v_spaeter boolean;
|
||||
v_zurueckgesetzt jsonb := '[]'::jsonb;
|
||||
v_setz text[] := '{}';
|
||||
-- Feldbeschriftung → Spalte und Typ. Geschlossene Liste: was
|
||||
-- change_employee_data schreiben kann, steht hier, sonst nichts. Der
|
||||
-- Spaltenname geht in dynamisches SQL, deshalb darf er nur von hier kommen.
|
||||
v_karte constant jsonb := app_feld_karte();
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
|
||||
select * into v_eintrag from employee_history where id = v_id;
|
||||
if not found then
|
||||
raise exception 'Historieneintrag nicht gefunden.';
|
||||
end if;
|
||||
|
||||
if v_eintrag.event_type = 'Eintritt' then
|
||||
raise exception 'Der Eintritt lässt sich nicht löschen — er ist der Anfang der Zeitleiste.';
|
||||
end if;
|
||||
|
||||
if v_eintrag.event_type not in ('Stammdatenänderung', 'Vertragsänderung') then
|
||||
raise exception 'Nur Stammdaten- und Vertragsänderungen lassen sich hier zurücknehmen. Für % gibt es den passenden Vorgang.', v_eintrag.event_type;
|
||||
end if;
|
||||
|
||||
if v_eintrag.event_date > current_date then
|
||||
raise exception 'Diese Änderung ist noch nicht wirksam und hängt an einem geplanten Vorgang. Sie muss dort abgebrochen werden.';
|
||||
end if;
|
||||
|
||||
if v_eintrag.changes is null or jsonb_array_length(v_eintrag.changes) = 0 then
|
||||
raise exception 'Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts, worauf zurückgesetzt werden könnte.';
|
||||
end if;
|
||||
|
||||
select first_name || ' ' || last_name into v_name from employees where id = v_eintrag.employee_id;
|
||||
|
||||
-- Je Feld: nur zurücksetzen, wenn kein späterer Eintrag dasselbe Feld
|
||||
-- angefasst hat. Sonst gilt der spätere Wert weiter.
|
||||
for v_aenderung in select * from jsonb_array_elements(v_eintrag.changes) loop
|
||||
v_feld := v_aenderung->>'feld';
|
||||
|
||||
if not v_karte ? v_feld then
|
||||
continue; -- unbekannte Beschriftung: nichts anfassen
|
||||
end if;
|
||||
|
||||
select exists (
|
||||
select 1
|
||||
from employee_history h,
|
||||
lateral jsonb_array_elements(coalesce(h.changes, '[]'::jsonb)) a
|
||||
where h.employee_id = v_eintrag.employee_id
|
||||
and h.id <> v_eintrag.id
|
||||
and a->>'feld' = v_feld
|
||||
and (h.event_date, h.created_at) > (v_eintrag.event_date, v_eintrag.created_at)
|
||||
) into v_spaeter;
|
||||
|
||||
if v_spaeter then
|
||||
continue;
|
||||
end if;
|
||||
|
||||
v_spalte := v_karte->v_feld->>0;
|
||||
v_typ := v_karte->v_feld->>1;
|
||||
v_wert := v_aenderung->>'vorher';
|
||||
|
||||
if v_typ = 'liste' then
|
||||
v_setz := v_setz || format('%I = coalesce(string_to_array(%L, '', ''), ''{}'')', v_spalte, nullif(v_wert, ''));
|
||||
else
|
||||
v_setz := v_setz || format('%I = %L::%s', v_spalte, nullif(v_wert, ''), v_typ);
|
||||
end if;
|
||||
|
||||
v_zurueckgesetzt := v_zurueckgesetzt || jsonb_build_object(
|
||||
'feld', v_feld,
|
||||
'vorher', v_aenderung->>'nachher',
|
||||
'nachher', v_wert
|
||||
);
|
||||
end loop;
|
||||
|
||||
-- Alle Felder in *einem* UPDATE. Einzeln nacheinander zu schreiben war der
|
||||
-- Fehler der ersten Fassung: chk_weekly_hours verknüpft Beschäftigungsausmaß
|
||||
-- und Wochenstunden, und zwischen zwei getrennten Anweisungen steht
|
||||
-- zwangsläufig ein Zwischenstand, den die Bedingung verbietet — „Vollzeit
|
||||
-- mit 37 Stunden". Gemeinsam gesetzt gibt es diesen Zwischenstand nicht.
|
||||
if array_length(v_setz, 1) > 0 then
|
||||
begin
|
||||
execute format('update employees set %s where id = %L', array_to_string(v_setz, ', '), v_eintrag.employee_id);
|
||||
exception when check_violation then
|
||||
-- Bleibt trotzdem etwas übrig: dann wurde eines von zwei zusammen-
|
||||
-- gehörenden Feldern später einzeln geändert, und der alte Wert passt
|
||||
-- nicht mehr zum heutigen Stand. Lieber verständlich abweisen.
|
||||
raise exception 'Zurücksetzen nicht möglich: die Werte von damals passen nicht mehr zum heutigen Stand (%). Vermutlich wurde ein zusammengehörendes Feld später einzeln geändert.', sqlerrm;
|
||||
end;
|
||||
end if;
|
||||
|
||||
delete from employee_history where id = v_id;
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
|
||||
values (app_current_user_id(), current_actor_name(), 'Historieneintrag gelöscht', v_name, v_eintrag.employee_id,
|
||||
v_eintrag.event_type || ' vom ' || v_eintrag.event_date ||
|
||||
case when jsonb_array_length(v_zurueckgesetzt) = 0
|
||||
then ' gelöscht; keine Werte zurückgesetzt (spätere Änderungen gelten)'
|
||||
else ' gelöscht und zurückgesetzt: ' || app_aenderungsfelder(v_zurueckgesetzt) end,
|
||||
v_zurueckgesetzt);
|
||||
end;
|
||||
$function$;
|
||||
|
||||
-- Einen Historieneintrag berichtigen.
|
||||
--
|
||||
-- Löschen nimmt einen Eintrag zurück, der nie hätte entstehen dürfen. Hier
|
||||
-- geht es um den anderen Fall: der Vorgang stimmt, aber der erfasste Wert
|
||||
-- oder das Datum nicht. Ohne diesen Weg bliebe nur „löschen und neu
|
||||
-- erfassen" — und dann stünden in der Akte zwei Einträge für eine Änderung,
|
||||
-- von denen der erste nie stattgefunden hat.
|
||||
--
|
||||
-- Geändert werden darf das **Nachher** und das **Datum**. Das Vorher bleibt:
|
||||
-- es beschreibt, was vor der Änderung galt, und das lässt sich nachträglich
|
||||
-- nicht anders beschliessen.
|
||||
--
|
||||
-- ═══ Wie der heutige Stand danach zustande kommt ═══
|
||||
--
|
||||
-- Nicht durch Zurückrechnen, sondern durch Nachsehen: für jedes betroffene
|
||||
-- Feld gewinnt der **jüngste** Historieneintrag, der es trägt. Das ist
|
||||
-- dieselbe Regel wie beim Löschen — „die letztgültige Änderung ist die
|
||||
-- schlagende" — nur von der anderen Seite gelesen, und sie trägt hier
|
||||
-- zusätzlich den Fall, dass sich durch ein neues Datum die Reihenfolge
|
||||
-- verschiebt.
|
||||
--
|
||||
-- ═══ Was nicht geht ═══
|
||||
--
|
||||
-- Dieselben Grenzen wie beim Löschen: kein Eintritt, nur Stammdaten- und
|
||||
-- Vertragsänderungen, nichts Zukünftiges, nichts ohne Feldwerte. Ein Datum
|
||||
-- in der Zukunft würde aus dem Eintrag eine geplante Änderung machen, und
|
||||
-- die lebt in pending_org_changes — dorthin führt kein verlässlicher Weg
|
||||
-- zurück (kein Schlüssel zwischen beiden Tabellen).
|
||||
|
||||
create or replace function update_history_entry(payload jsonb)
|
||||
returns void
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path to 'public', 'pg_temp'
|
||||
as $function$
|
||||
declare
|
||||
v_id uuid := (payload->>'history_id')::uuid;
|
||||
v_eintrag employee_history%rowtype;
|
||||
v_datum date;
|
||||
v_name text;
|
||||
v_karte constant jsonb := app_feld_karte();
|
||||
v_alt jsonb;
|
||||
v_feld text;
|
||||
v_neuer_wert text;
|
||||
v_neu jsonb := '[]'::jsonb;
|
||||
v_korrektur jsonb := '[]'::jsonb;
|
||||
v_setz text[] := '{}';
|
||||
v_spalte text;
|
||||
v_typ text;
|
||||
v_gueltig text;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
|
||||
select * into v_eintrag from employee_history where id = v_id;
|
||||
if not found then
|
||||
raise exception 'Historieneintrag nicht gefunden.';
|
||||
end if;
|
||||
|
||||
if v_eintrag.event_type = 'Eintritt' then
|
||||
raise exception 'Der Eintritt lässt sich hier nicht berichtigen.';
|
||||
end if;
|
||||
|
||||
if v_eintrag.event_type not in ('Stammdatenänderung', 'Vertragsänderung') then
|
||||
raise exception 'Nur Stammdaten- und Vertragsänderungen lassen sich hier berichtigen. Für % gibt es den passenden Vorgang.', v_eintrag.event_type;
|
||||
end if;
|
||||
|
||||
if v_eintrag.event_date > current_date then
|
||||
raise exception 'Diese Änderung ist noch nicht wirksam und hängt an einem geplanten Vorgang. Sie muss dort berichtigt werden.';
|
||||
end if;
|
||||
|
||||
if v_eintrag.changes is null or jsonb_array_length(v_eintrag.changes) = 0 then
|
||||
raise exception 'Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts zu berichtigen.';
|
||||
end if;
|
||||
|
||||
v_datum := coalesce(nullif(payload->>'event_date', '')::date, v_eintrag.event_date);
|
||||
if v_datum > current_date then
|
||||
raise exception 'Ein Datum in der Zukunft macht daraus eine geplante Änderung. Dafür ist dieser Weg nicht gedacht.';
|
||||
end if;
|
||||
|
||||
select first_name || ' ' || last_name into v_name from employees where id = v_eintrag.employee_id;
|
||||
|
||||
-- Neue Werteliste bauen: Vorher bleibt, Nachher darf ersetzt werden.
|
||||
for v_alt in select * from jsonb_array_elements(v_eintrag.changes) loop
|
||||
v_feld := v_alt->>'feld';
|
||||
select w->>'nachher' into v_neuer_wert
|
||||
from jsonb_array_elements(coalesce(payload->'werte', '[]'::jsonb)) w
|
||||
where w->>'feld' = v_feld;
|
||||
|
||||
if v_neuer_wert is null then
|
||||
v_neu := v_neu || v_alt;
|
||||
else
|
||||
v_neu := v_neu || jsonb_build_object('feld', v_feld, 'vorher', v_alt->>'vorher', 'nachher', nullif(v_neuer_wert, ''));
|
||||
if coalesce(v_alt->>'nachher', '') is distinct from coalesce(nullif(v_neuer_wert, ''), '') then
|
||||
v_korrektur := v_korrektur || jsonb_build_object('feld', v_feld, 'vorher', v_alt->>'nachher', 'nachher', nullif(v_neuer_wert, ''));
|
||||
end if;
|
||||
end if;
|
||||
end loop;
|
||||
|
||||
if jsonb_array_length(v_korrektur) = 0 and v_datum = v_eintrag.event_date then
|
||||
raise exception 'Nichts geändert.';
|
||||
end if;
|
||||
|
||||
update employee_history
|
||||
set changes = v_neu,
|
||||
event_date = v_datum,
|
||||
description = 'Geänderte Felder: ' || app_aenderungsfelder(v_neu) || ', wirksam ab ' || v_datum
|
||||
where id = v_id;
|
||||
|
||||
-- Für jedes betroffene Feld den jüngsten Eintrag suchen, der es trägt, und
|
||||
-- dessen Nachher setzen. Das schliesst den eben berichtigten Eintrag ein
|
||||
-- und berücksichtigt ein verschobenes Datum von selbst.
|
||||
for v_feld in select distinct e->>'feld' from jsonb_array_elements(v_neu) e loop
|
||||
if not v_karte ? v_feld then
|
||||
continue;
|
||||
end if;
|
||||
|
||||
select a->>'nachher' into v_gueltig
|
||||
from employee_history h,
|
||||
lateral jsonb_array_elements(coalesce(h.changes, '[]'::jsonb)) a
|
||||
where h.employee_id = v_eintrag.employee_id
|
||||
and a->>'feld' = v_feld
|
||||
order by h.event_date desc, h.created_at desc
|
||||
limit 1;
|
||||
|
||||
v_spalte := v_karte->v_feld->>0;
|
||||
v_typ := v_karte->v_feld->>1;
|
||||
|
||||
if v_typ = 'liste' then
|
||||
v_setz := v_setz || format('%I = coalesce(string_to_array(%L, '', ''), ''{}'')', v_spalte, nullif(v_gueltig, ''));
|
||||
else
|
||||
v_setz := v_setz || format('%I = %L::%s', v_spalte, nullif(v_gueltig, ''), v_typ);
|
||||
end if;
|
||||
end loop;
|
||||
|
||||
-- Alles in einem UPDATE: chk_weekly_hours koppelt Beschäftigungsausmaß und
|
||||
-- Wochenstunden, und zwischen zwei getrennten Anweisungen stünde ein
|
||||
-- Zwischenstand, den die Bedingung verbietet.
|
||||
if array_length(v_setz, 1) > 0 then
|
||||
begin
|
||||
execute format('update employees set %s where id = %L', array_to_string(v_setz, ', '), v_eintrag.employee_id);
|
||||
exception when check_violation then
|
||||
raise exception 'Der berichtigte Wert passt nicht zum übrigen Stand (%). Zusammengehörende Felder — etwa Beschäftigungsausmaß und Wochenstunden — müssen gemeinsam stimmen.', sqlerrm;
|
||||
end;
|
||||
end if;
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
|
||||
values (app_current_user_id(), current_actor_name(), 'Historieneintrag berichtigt', v_name, v_eintrag.employee_id,
|
||||
v_eintrag.event_type || ' vom ' || v_eintrag.event_date ||
|
||||
case when v_datum <> v_eintrag.event_date then ' auf ' || v_datum || ' umdatiert' else '' end ||
|
||||
case when jsonb_array_length(v_korrektur) > 0
|
||||
then '; berichtigt: ' || app_aenderungsfelder(v_korrektur) else '' end,
|
||||
v_korrektur);
|
||||
end;
|
||||
$function$;
|
||||
|
||||
comment on function update_history_entry(jsonb) is
|
||||
'Berichtigt Wert und/oder Datum einer Stammdaten- oder Vertragsänderung. Der heutige Stand wird je Feld aus dem jüngsten Eintrag abgeleitet, der es trägt. SECURITY DEFINER, weil employee_history absichtlich keine update-Policy hat.';
|
||||
|
||||
|
||||
-- Selbstprüfung.
|
||||
do $$
|
||||
declare
|
||||
v_del text := pg_get_functiondef('public.delete_history_entry(jsonb)'::regprocedure);
|
||||
v_upd text := pg_get_functiondef('public.update_history_entry(jsonb)'::regprocedure);
|
||||
begin
|
||||
if jsonb_typeof(app_feld_karte()) <> 'object' then
|
||||
raise exception 'app_feld_karte liefert kein Objekt';
|
||||
end if;
|
||||
if not (app_feld_karte() ? 'Adresse' and app_feld_karte() ? 'Wochenstunden') then
|
||||
raise exception 'Die Feldtabelle ist unvollständig';
|
||||
end if;
|
||||
if v_del not like '%app_feld_karte()%' then
|
||||
raise exception 'delete_history_entry nutzt die gemeinsame Feldtabelle nicht';
|
||||
end if;
|
||||
if v_del like '%jsonb_build_array(''first_name''%' then
|
||||
raise exception 'delete_history_entry trägt noch eine eigene Kopie der Feldtabelle';
|
||||
end if;
|
||||
if not (select prosecdef from pg_proc where oid = 'public.update_history_entry(jsonb)'::regprocedure) then
|
||||
raise exception 'update_history_entry muss SECURITY DEFINER sein';
|
||||
end if;
|
||||
if v_upd not like '%require_hr_admin%' then
|
||||
raise exception 'update_history_entry prüft die Berechtigung nicht';
|
||||
end if;
|
||||
-- Die Policies bleiben, wie sie sind.
|
||||
if exists (
|
||||
select 1 from pg_policy p join pg_class c on c.oid = p.polrelid
|
||||
where c.relname = 'employee_history' and p.polcmd in ('d', 'w')
|
||||
) then
|
||||
raise exception 'employee_history hat eine update- oder delete-Policy bekommen';
|
||||
end if;
|
||||
end
|
||||
$$;
|
||||
681
supabase/migrations/20260813200000_history_links_to_pending.sql
Normal file
681
supabase/migrations/20260813200000_history_links_to_pending.sql
Normal file
@@ -0,0 +1,681 @@
|
||||
-- Auch geplante Änderungen lassen sich zurücknehmen und berichtigen.
|
||||
--
|
||||
-- Bisher endete beides an der Gegenwart: was noch nicht wirksam war, blieb
|
||||
-- stehen. Der Grund war kein Prinzip, sondern eine fehlende Verbindung — eine
|
||||
-- noch nicht wirksame Änderung lebt als payload in pending_org_changes, und
|
||||
-- zwischen ihr und der Historienzeile gab es keinen Schlüssel, nur Person und
|
||||
-- Datum. Darüber zu raten hätte irgendwann die falsche Zeile getroffen: in
|
||||
-- den Daten liegen bereits ein Eintritt und eine Vertragsänderung am selben
|
||||
-- Tag.
|
||||
--
|
||||
-- employee_history bekommt deshalb pending_id. change_employee_data setzt es,
|
||||
-- wenn es eine geplante Änderung anlegt; für alles Wirksame bleibt es null.
|
||||
--
|
||||
-- Eine geplante Änderung kann **zwei** Historienzeilen tragen — Stammdaten
|
||||
-- und Vertrag werden getrennt geführt, hängen aber am selben Vorgang. Deshalb
|
||||
-- entfernt das Zurücknehmen nur die Felder der betroffenen Gruppe aus dem
|
||||
-- payload und bricht den Vorgang nur ab, wenn danach nichts übrig bleibt.
|
||||
--
|
||||
-- Bestehende Zeilen werden verknüpft, wo es eindeutig ist: genau ein
|
||||
-- laufender Vorgang der Person am selben Stichtag, den nicht schon eine
|
||||
-- andere Zeile beansprucht. Alles andere bleibt ohne Bezug — und damit
|
||||
-- weiterhin unantastbar, mit einer Meldung, die das sagt.
|
||||
|
||||
alter table employee_history
|
||||
add column if not exists pending_id uuid references pending_org_changes(id) on delete set null;
|
||||
|
||||
comment on column employee_history.pending_id is
|
||||
'Der geplante Vorgang, zu dem diese Zeile gehört; null, sobald die Änderung wirksam ist oder es nie einen Vorgang gab. Ohne diesen Bezug lässt sich eine geplante Änderung nicht zurücknehmen — Person und Datum allein sind nicht eindeutig.';
|
||||
|
||||
create index if not exists idx_employee_history_pending on employee_history (pending_id) where pending_id is not null;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.change_employee_data(payload jsonb)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $function$
|
||||
declare
|
||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
|
||||
v_old employees%rowtype;
|
||||
v_name text;
|
||||
v_person_changes jsonb := '[]'::jsonb;
|
||||
v_contract_changes jsonb := '[]'::jsonb;
|
||||
v_person jsonb := payload->'person';
|
||||
v_contract jsonb := payload->'contract';
|
||||
v_role jsonb := payload->'role';
|
||||
v_immediate boolean;
|
||||
v_new_work_days text[];
|
||||
v_new_title_prefix text[];
|
||||
v_new_title_suffix text[];
|
||||
v_pending_id uuid;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select * into v_old from employees where id = v_employee_id;
|
||||
v_name := v_old.first_name || ' ' || v_old.last_name;
|
||||
v_immediate := v_effective_date <= current_date;
|
||||
|
||||
-- Der `?`-Test bleibt: ein fehlender Schlüssel heisst „nicht übermittelt",
|
||||
-- nicht „geleert". Ohne ihn würde jedes nicht gesendete Feld als Änderung
|
||||
-- auf null gemeldet.
|
||||
if v_person ? 'first_name' then v_person_changes := app_aenderung(v_person_changes, 'Vorname', v_old.first_name, v_person->>'first_name'); end if;
|
||||
if v_person ? 'last_name' then v_person_changes := app_aenderung(v_person_changes, 'Nachname', v_old.last_name, v_person->>'last_name'); end if;
|
||||
if v_person ? 'gender' then v_person_changes := app_aenderung(v_person_changes, 'Geschlecht', v_old.gender::text, v_person->>'gender'); end if;
|
||||
-- Datumswerte über ::date::text vergleichen, damit „2026-8-3" und
|
||||
-- „2026-08-03" nicht als Änderung gelten.
|
||||
if v_person ? 'birth_date' then v_person_changes := app_aenderung(v_person_changes, 'Geburtsdatum', v_old.birth_date::text, (nullif(v_person->>'birth_date','')::date)::text); end if;
|
||||
if v_person ? 'sv_nummer' then v_person_changes := app_aenderung(v_person_changes, 'SV-Nummer', v_old.sv_nummer, v_person->>'sv_nummer'); end if;
|
||||
if v_person ? 'nationality' then v_person_changes := app_aenderung(v_person_changes, 'Staatsbürgerschaft', v_old.nationality, v_person->>'nationality'); end if;
|
||||
if v_person ? 'address' then v_person_changes := app_aenderung(v_person_changes, 'Adresse', v_old.address, v_person->>'address'); end if;
|
||||
if v_person ? 'postal_code' then v_person_changes := app_aenderung(v_person_changes, 'Postleitzahl', v_old.postal_code, v_person->>'postal_code'); end if;
|
||||
if v_person ? 'city' then v_person_changes := app_aenderung(v_person_changes, 'Ort', v_old.city, v_person->>'city'); end if;
|
||||
if v_person ? 'address_country' then v_person_changes := app_aenderung(v_person_changes, 'Land', v_old.address_country, v_person->>'address_country'); end if;
|
||||
if v_person ? 'email' then v_person_changes := app_aenderung(v_person_changes, 'E-Mail', v_old.email, v_person->>'email'); end if;
|
||||
if v_person ? 'phone' then v_person_changes := app_aenderung(v_person_changes, 'Telefon', v_old.phone, v_person->>'phone'); end if;
|
||||
if v_person ? 'emergency_contact_name' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt', v_old.emergency_contact_name, v_person->>'emergency_contact_name'); end if;
|
||||
if v_person ? 'emergency_contact_phone' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Telefon', v_old.emergency_contact_phone, v_person->>'emergency_contact_phone'); end if;
|
||||
if v_person ? 'emergency_contact_relation' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Verhältnis', v_old.emergency_contact_relation, v_person->>'emergency_contact_relation'); end if;
|
||||
|
||||
if v_person ? 'title_prefix' then
|
||||
v_new_title_prefix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_prefix') elem), '{}');
|
||||
v_person_changes := app_aenderung(v_person_changes, 'Titel (vorangestellt)',
|
||||
array_to_string(v_old.title_prefix, ', '), array_to_string(v_new_title_prefix, ', '));
|
||||
end if;
|
||||
if v_person ? 'title_suffix' then
|
||||
v_new_title_suffix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_suffix') elem), '{}');
|
||||
v_person_changes := app_aenderung(v_person_changes, 'Titel (nachgestellt)',
|
||||
array_to_string(v_old.title_suffix, ', '), array_to_string(v_new_title_suffix, ', '));
|
||||
end if;
|
||||
|
||||
if v_contract ? 'employment_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Beschäftigungsausmaß', v_old.employment_type::text, v_contract->>'employment_type'); end if;
|
||||
-- Über ::numeric::text, damit „38.50" und „38.5" gleich zählen.
|
||||
if v_contract ? 'weekly_hours' then v_contract_changes := app_aenderung(v_contract_changes, 'Wochenstunden', v_old.weekly_hours::text, (nullif(v_contract->>'weekly_hours','')::numeric)::text); end if;
|
||||
if v_contract ? 'contract_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Vertragsart', v_old.contract_type::text, v_contract->>'contract_type'); end if;
|
||||
if v_contract ? 'contract_end_date' then v_contract_changes := app_aenderung(v_contract_changes, 'Befristet bis', v_old.contract_end_date::text, (nullif(v_contract->>'contract_end_date','')::date)::text); end if;
|
||||
|
||||
if v_role ? 'worker_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Angestellte:r/Arbeiter:in', v_old.worker_type::text, v_role->>'worker_type'); end if;
|
||||
if v_role ? 'collective_agreement' then v_contract_changes := app_aenderung(v_contract_changes, 'Kollektivvertrag', v_old.collective_agreement::text, v_role->>'collective_agreement'); end if;
|
||||
if v_role ? 'work_days' then
|
||||
v_new_work_days := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_role->'work_days') elem), '{}');
|
||||
v_contract_changes := app_aenderung(v_contract_changes, 'Arbeitstage',
|
||||
array_to_string(v_old.work_days, ', '), array_to_string(v_new_work_days, ', '));
|
||||
end if;
|
||||
if v_role ? 'is_betriebsrat' then v_contract_changes := app_aenderung(v_contract_changes, 'Betriebsrat', v_old.is_betriebsrat::text, v_role->>'is_betriebsrat'); end if;
|
||||
if v_role ? 'has_dienstwagen' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen', v_old.has_dienstwagen::text, v_role->>'has_dienstwagen'); end if;
|
||||
if v_role ? 'is_laterale_fuehrung' then v_contract_changes := app_aenderung(v_contract_changes, 'Laterale Führung', v_old.is_laterale_fuehrung::text, v_role->>'is_laterale_fuehrung'); end if;
|
||||
if v_role ? 'is_c_level' then v_contract_changes := app_aenderung(v_contract_changes, 'C-Level', v_old.is_c_level::text, v_role->>'is_c_level'); end if;
|
||||
if v_role ? 'dienstwagen_art' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen Antrieb', v_old.dienstwagen_art, nullif(v_role->>'dienstwagen_art', '')); end if;
|
||||
|
||||
if v_immediate then
|
||||
update employees set
|
||||
first_name = coalesce(v_person->>'first_name', first_name),
|
||||
last_name = coalesce(v_person->>'last_name', last_name),
|
||||
gender = coalesce((v_person->>'gender')::gender_type, gender),
|
||||
birth_date = coalesce((v_person->>'birth_date')::date, birth_date),
|
||||
sv_nummer = coalesce(v_person->>'sv_nummer', sv_nummer),
|
||||
nationality = coalesce(v_person->>'nationality', nationality),
|
||||
address = coalesce(v_person->>'address', address),
|
||||
postal_code = coalesce(v_person->>'postal_code', postal_code),
|
||||
city = coalesce(v_person->>'city', city),
|
||||
address_country = coalesce(v_person->>'address_country', address_country),
|
||||
email = coalesce(v_person->>'email', email),
|
||||
phone = coalesce(v_person->>'phone', phone),
|
||||
emergency_contact_name = case when v_person ? 'emergency_contact_name' then nullif(v_person->>'emergency_contact_name', '') else emergency_contact_name end,
|
||||
emergency_contact_phone = case when v_person ? 'emergency_contact_phone' then nullif(v_person->>'emergency_contact_phone', '') else emergency_contact_phone end,
|
||||
emergency_contact_relation = case when v_person ? 'emergency_contact_relation' then nullif(v_person->>'emergency_contact_relation', '') else emergency_contact_relation end,
|
||||
title_prefix = case when v_person ? 'title_prefix' then v_new_title_prefix else title_prefix end,
|
||||
title_suffix = case when v_person ? 'title_suffix' then v_new_title_suffix else title_suffix end,
|
||||
employment_type = coalesce((v_contract->>'employment_type')::employment_type, employment_type),
|
||||
weekly_hours = coalesce((v_contract->>'weekly_hours')::numeric, weekly_hours),
|
||||
contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),
|
||||
contract_end_date = case when v_contract ? 'contract_end_date' then nullif(v_contract->>'contract_end_date','')::date else contract_end_date end,
|
||||
worker_type = coalesce((v_role->>'worker_type')::worker_type, worker_type),
|
||||
collective_agreement = coalesce((v_role->>'collective_agreement')::collective_agreement, collective_agreement),
|
||||
work_days = case when v_role ? 'work_days' then v_new_work_days else work_days end,
|
||||
is_betriebsrat = coalesce((v_role->>'is_betriebsrat')::boolean, is_betriebsrat),
|
||||
has_dienstwagen = coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen),
|
||||
is_laterale_fuehrung = coalesce((v_role->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
|
||||
is_c_level = coalesce((v_role->>'is_c_level')::boolean, is_c_level),
|
||||
dienstwagen_art = case
|
||||
when coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen) then
|
||||
coalesce(nullif(v_role->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner')
|
||||
else null
|
||||
end
|
||||
where id = v_employee_id;
|
||||
elsif jsonb_array_length(v_person_changes) > 0 or jsonb_array_length(v_contract_changes) > 0 then
|
||||
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
|
||||
values (v_employee_id, 'contract_change', v_effective_date, payload)
|
||||
returning id into v_pending_id;
|
||||
end if;
|
||||
|
||||
if jsonb_array_length(v_person_changes) > 0 then
|
||||
insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
|
||||
values (v_employee_id, v_effective_date, 'Stammdatenänderung',
|
||||
'Geänderte Felder: ' || app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes, v_pending_id);
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
|
||||
values (app_current_user_id(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id,
|
||||
app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes);
|
||||
end if;
|
||||
|
||||
if jsonb_array_length(v_contract_changes) > 0 then
|
||||
insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
|
||||
values (v_employee_id, v_effective_date, 'Vertragsänderung',
|
||||
'Geänderte Felder: ' || app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes, v_pending_id);
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
|
||||
values (app_current_user_id(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id,
|
||||
app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes);
|
||||
end if;
|
||||
end;
|
||||
$function$;
|
||||
|
||||
|
||||
-- Die Feldtabelle: Beschriftung → Spalte, Typ und Gruppe.
|
||||
--
|
||||
-- Die Gruppe ist neu. Eine noch nicht wirksame Änderung liegt als payload in
|
||||
-- pending_org_changes, und dort sind die Felder nach person / contract / role
|
||||
-- sortiert — so, wie change_employee_data sie entgegennimmt. Wer eine
|
||||
-- geplante Änderung berichtigen oder zurücknehmen will, muss wissen, unter
|
||||
-- welchem Schlüssel ein Feld dort steht.
|
||||
create or replace function app_feld_karte()
|
||||
returns jsonb
|
||||
language sql
|
||||
immutable
|
||||
set search_path to 'public', 'pg_temp'
|
||||
as $function$
|
||||
select jsonb_build_object(
|
||||
'Vorname', jsonb_build_array('first_name', 'text', 'person'),
|
||||
'Nachname', jsonb_build_array('last_name', 'text', 'person'),
|
||||
'Geschlecht', jsonb_build_array('gender', 'gender_type', 'person'),
|
||||
'Geburtsdatum', jsonb_build_array('birth_date', 'date', 'person'),
|
||||
'SV-Nummer', jsonb_build_array('sv_nummer', 'text', 'person'),
|
||||
'Staatsbürgerschaft', jsonb_build_array('nationality', 'text', 'person'),
|
||||
'Adresse', jsonb_build_array('address', 'text', 'person'),
|
||||
'Postleitzahl', jsonb_build_array('postal_code', 'text', 'person'),
|
||||
'Ort', jsonb_build_array('city', 'text', 'person'),
|
||||
'Land', jsonb_build_array('address_country', 'text', 'person'),
|
||||
'E-Mail', jsonb_build_array('email', 'text', 'person'),
|
||||
'Telefon', jsonb_build_array('phone', 'text', 'person'),
|
||||
'Notfallkontakt', jsonb_build_array('emergency_contact_name', 'text', 'person'),
|
||||
'Notfallkontakt Telefon', jsonb_build_array('emergency_contact_phone', 'text', 'person'),
|
||||
'Notfallkontakt Verhältnis', jsonb_build_array('emergency_contact_relation', 'text', 'person'),
|
||||
'Titel (vorangestellt)', jsonb_build_array('title_prefix', 'liste', 'person'),
|
||||
'Titel (nachgestellt)', jsonb_build_array('title_suffix', 'liste', 'person'),
|
||||
'Beschäftigungsausmaß', jsonb_build_array('employment_type', 'employment_type', 'contract'),
|
||||
'Wochenstunden', jsonb_build_array('weekly_hours', 'numeric', 'contract'),
|
||||
'Vertragsart', jsonb_build_array('contract_type', 'contract_type', 'contract'),
|
||||
'Befristet bis', jsonb_build_array('contract_end_date', 'date', 'contract'),
|
||||
'Angestellte:r/Arbeiter:in', jsonb_build_array('worker_type', 'worker_type', 'role'),
|
||||
'Kollektivvertrag', jsonb_build_array('collective_agreement', 'collective_agreement', 'role'),
|
||||
'Arbeitstage', jsonb_build_array('work_days', 'liste', 'role'),
|
||||
'Betriebsrat', jsonb_build_array('is_betriebsrat', 'boolean', 'role'),
|
||||
'Dienstwagen', jsonb_build_array('has_dienstwagen', 'boolean', 'role'),
|
||||
'Laterale Führung', jsonb_build_array('is_laterale_fuehrung', 'boolean', 'role'),
|
||||
'C-Level', jsonb_build_array('is_c_level', 'boolean', 'role'),
|
||||
'Dienstwagen Antrieb', jsonb_build_array('dienstwagen_art', 'text', 'role')
|
||||
);
|
||||
$function$;
|
||||
|
||||
comment on function app_feld_karte() is
|
||||
'Beschriftung eines Feldes → [Spalte, Typ, Gruppe im payload]. Quelle für das Zurücksetzen, Berichtigen und Abbrechen von Historieneinträgen.';
|
||||
|
||||
|
||||
-- Einen Historieneintrag zurücknehmen — samt seiner Wirkung.
|
||||
--
|
||||
-- Zwei Fälle, und sie sind grundverschieden:
|
||||
--
|
||||
-- **Bereits wirksam.** Die Änderung steht in den Stammdaten. Je Feld wird auf
|
||||
-- den Wert davor zurückgesetzt — aber nur, wenn kein späterer Eintrag
|
||||
-- dasselbe Feld angefasst hat; sonst gilt der spätere weiter. Das ist „die
|
||||
-- letztgültige Änderung ist die schlagende".
|
||||
--
|
||||
-- **Noch nicht wirksam.** Es gibt nichts zurückzusetzen; die Änderung wartet
|
||||
-- als payload in pending_org_changes. Zurückgenommen wird sie, indem ihre
|
||||
-- Felder aus dem payload verschwinden. Bleibt danach nichts übrig, wird die
|
||||
-- geplante Änderung abgebrochen — bleibt etwas, läuft sie mit dem Rest.
|
||||
--
|
||||
-- Der zweite Fall braucht einen verlässlichen Bezug zwischen Historienzeile
|
||||
-- und geplanter Änderung. Den gab es nicht, und über Person und Datum zu
|
||||
-- raten hätte irgendwann die falsche Zeile getroffen: in den Daten liegen
|
||||
-- bereits ein Eintritt und eine Vertragsänderung am selben Tag. Deshalb
|
||||
-- trägt employee_history jetzt pending_id.
|
||||
--
|
||||
-- Eine geplante Änderung kann **zwei** Historienzeilen haben — Stammdaten und
|
||||
-- Vertrag werden getrennt geführt. Deshalb wird immer nur die Gruppe des
|
||||
-- betroffenen Eintrags entfernt, nie der ganze payload.
|
||||
create or replace function delete_history_entry(payload jsonb)
|
||||
returns void
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path to 'public', 'pg_temp'
|
||||
as $function$
|
||||
declare
|
||||
v_id uuid := (payload->>'history_id')::uuid;
|
||||
v_eintrag employee_history%rowtype;
|
||||
v_name text;
|
||||
v_karte constant jsonb := app_feld_karte();
|
||||
v_aenderung jsonb;
|
||||
v_feld text;
|
||||
v_wert text;
|
||||
v_spalte text;
|
||||
v_typ text;
|
||||
v_gruppe text;
|
||||
v_spaeter boolean;
|
||||
v_zurueckgesetzt jsonb := '[]'::jsonb;
|
||||
v_setz text[] := '{}';
|
||||
v_plan pending_org_changes%rowtype;
|
||||
v_neuer_payload jsonb;
|
||||
v_leer boolean;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
|
||||
select * into v_eintrag from employee_history where id = v_id;
|
||||
if not found then
|
||||
raise exception 'Historieneintrag nicht gefunden.';
|
||||
end if;
|
||||
|
||||
if v_eintrag.event_type = 'Eintritt' then
|
||||
raise exception 'Der Eintritt lässt sich nicht löschen — er ist der Anfang der Zeitleiste.';
|
||||
end if;
|
||||
|
||||
if v_eintrag.event_type not in ('Stammdatenänderung', 'Vertragsänderung') then
|
||||
raise exception 'Nur Stammdaten- und Vertragsänderungen lassen sich hier zurücknehmen. Für % gibt es den passenden Vorgang.', v_eintrag.event_type;
|
||||
end if;
|
||||
|
||||
if v_eintrag.changes is null or jsonb_array_length(v_eintrag.changes) = 0 then
|
||||
raise exception 'Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts, worauf zurückgesetzt werden könnte.';
|
||||
end if;
|
||||
|
||||
select first_name || ' ' || last_name into v_name from employees where id = v_eintrag.employee_id;
|
||||
|
||||
-- ── Noch nicht wirksam: die geplante Änderung entschärfen ──────────
|
||||
if v_eintrag.event_date > current_date then
|
||||
if v_eintrag.pending_id is null then
|
||||
raise exception 'Zu dieser geplanten Änderung ist kein Vorgang hinterlegt. Sie stammt aus der Zeit vor dieser Verknüpfung und lässt sich hier nicht abbrechen.';
|
||||
end if;
|
||||
|
||||
select * into v_plan from pending_org_changes where id = v_eintrag.pending_id for update;
|
||||
if not found or v_plan.status <> 'pending' then
|
||||
raise exception 'Der geplante Vorgang läuft nicht mehr — er wurde bereits angewendet oder abgebrochen.';
|
||||
end if;
|
||||
|
||||
v_neuer_payload := v_plan.payload;
|
||||
for v_aenderung in select * from jsonb_array_elements(v_eintrag.changes) loop
|
||||
v_feld := v_aenderung->>'feld';
|
||||
if not v_karte ? v_feld then
|
||||
continue;
|
||||
end if;
|
||||
v_spalte := v_karte->v_feld->>0;
|
||||
v_gruppe := v_karte->v_feld->>2;
|
||||
if v_neuer_payload ? v_gruppe then
|
||||
v_neuer_payload := jsonb_set(v_neuer_payload, array[v_gruppe], (v_neuer_payload->v_gruppe) - v_spalte);
|
||||
end if;
|
||||
end loop;
|
||||
|
||||
v_leer := coalesce(jsonb_array_length(
|
||||
(select jsonb_agg(k) from jsonb_object_keys(coalesce(v_neuer_payload->'person', '{}'::jsonb)) k)), 0) = 0
|
||||
and coalesce(jsonb_array_length(
|
||||
(select jsonb_agg(k) from jsonb_object_keys(coalesce(v_neuer_payload->'contract', '{}'::jsonb)) k)), 0) = 0
|
||||
and coalesce(jsonb_array_length(
|
||||
(select jsonb_agg(k) from jsonb_object_keys(coalesce(v_neuer_payload->'role', '{}'::jsonb)) k)), 0) = 0;
|
||||
|
||||
if v_leer then
|
||||
update pending_org_changes set status = 'cancelled' where id = v_plan.id;
|
||||
else
|
||||
update pending_org_changes set payload = v_neuer_payload where id = v_plan.id;
|
||||
end if;
|
||||
|
||||
delete from employee_history where id = v_id;
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
|
||||
values (app_current_user_id(), current_actor_name(), 'Geplante Änderung abgebrochen', v_name, v_eintrag.employee_id,
|
||||
v_eintrag.event_type || ' zum ' || v_eintrag.event_date || ' abgebrochen: ' || app_aenderungsfelder(v_eintrag.changes) ||
|
||||
case when v_leer then ' (der Vorgang entfällt ganz)' else ' (der Vorgang läuft mit den übrigen Feldern weiter)' end,
|
||||
v_eintrag.changes);
|
||||
return;
|
||||
end if;
|
||||
|
||||
-- ── Bereits wirksam: Feld für Feld zurücksetzen ────────────────────
|
||||
for v_aenderung in select * from jsonb_array_elements(v_eintrag.changes) loop
|
||||
v_feld := v_aenderung->>'feld';
|
||||
|
||||
if not v_karte ? v_feld then
|
||||
continue;
|
||||
end if;
|
||||
|
||||
select exists (
|
||||
select 1
|
||||
from employee_history h,
|
||||
lateral jsonb_array_elements(coalesce(h.changes, '[]'::jsonb)) a
|
||||
where h.employee_id = v_eintrag.employee_id
|
||||
and h.id <> v_eintrag.id
|
||||
and a->>'feld' = v_feld
|
||||
and (h.event_date, h.created_at) > (v_eintrag.event_date, v_eintrag.created_at)
|
||||
) into v_spaeter;
|
||||
|
||||
if v_spaeter then
|
||||
continue;
|
||||
end if;
|
||||
|
||||
v_spalte := v_karte->v_feld->>0;
|
||||
v_typ := v_karte->v_feld->>1;
|
||||
v_wert := v_aenderung->>'vorher';
|
||||
|
||||
if v_typ = 'liste' then
|
||||
v_setz := v_setz || format('%I = coalesce(string_to_array(%L, '', ''), ''{}'')', v_spalte, nullif(v_wert, ''));
|
||||
else
|
||||
v_setz := v_setz || format('%I = %L::%s', v_spalte, nullif(v_wert, ''), v_typ);
|
||||
end if;
|
||||
|
||||
v_zurueckgesetzt := v_zurueckgesetzt || jsonb_build_object(
|
||||
'feld', v_feld,
|
||||
'vorher', v_aenderung->>'nachher',
|
||||
'nachher', v_wert
|
||||
);
|
||||
end loop;
|
||||
|
||||
-- Alles in einem UPDATE: chk_weekly_hours verknüpft Beschäftigungsausmaß
|
||||
-- und Wochenstunden, und zwischen zwei getrennten Anweisungen stünde
|
||||
-- zwangsläufig ein Zwischenstand, den die Bedingung verbietet.
|
||||
if array_length(v_setz, 1) > 0 then
|
||||
begin
|
||||
execute format('update employees set %s where id = %L', array_to_string(v_setz, ', '), v_eintrag.employee_id);
|
||||
exception when check_violation then
|
||||
raise exception 'Zurücksetzen nicht möglich: die Werte von damals passen nicht mehr zum heutigen Stand (%). Vermutlich wurde ein zusammengehörendes Feld später einzeln geändert.', sqlerrm;
|
||||
end;
|
||||
end if;
|
||||
|
||||
delete from employee_history where id = v_id;
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
|
||||
values (app_current_user_id(), current_actor_name(), 'Historieneintrag gelöscht', v_name, v_eintrag.employee_id,
|
||||
v_eintrag.event_type || ' vom ' || v_eintrag.event_date ||
|
||||
case when jsonb_array_length(v_zurueckgesetzt) = 0
|
||||
then ' gelöscht; keine Werte zurückgesetzt (spätere Änderungen gelten)'
|
||||
else ' gelöscht und zurückgesetzt: ' || app_aenderungsfelder(v_zurueckgesetzt) end,
|
||||
v_zurueckgesetzt);
|
||||
end;
|
||||
$function$;
|
||||
|
||||
comment on function delete_history_entry(jsonb) is
|
||||
'Nimmt eine Stammdaten- oder Vertragsänderung zurück. Bereits wirksam: setzt je Feld auf den Wert davor, sofern kein späterer Eintrag dasselbe Feld geändert hat. Noch nicht wirksam: entfernt die Felder aus dem geplanten Vorgang und bricht ihn ab, wenn nichts übrig bleibt. SECURITY DEFINER, weil employee_history absichtlich keine delete-Policy hat.';
|
||||
|
||||
|
||||
-- Einen Historieneintrag berichtigen.
|
||||
--
|
||||
-- Löschen nimmt einen Eintrag zurück, der nie hätte entstehen dürfen. Hier
|
||||
-- geht es um den anderen Fall: der Vorgang stimmt, aber der erfasste Wert
|
||||
-- oder das Datum nicht. Ohne diesen Weg bliebe nur „löschen und neu
|
||||
-- erfassen" — und dann stünden in der Akte zwei Einträge für eine Änderung,
|
||||
-- von denen der erste nie stattgefunden hat.
|
||||
--
|
||||
-- Geändert wird das **Nachher** und das **Datum**. Das Vorher bleibt: es
|
||||
-- beschreibt, was vor der Änderung galt, und das lässt sich nachträglich
|
||||
-- nicht anders beschliessen.
|
||||
--
|
||||
-- Bereits wirksam: die Stammdaten werden nachgezogen, wobei je Feld der
|
||||
-- jüngste Eintrag gewinnt, der es trägt — dieselbe Regel wie beim Löschen,
|
||||
-- von der anderen Seite gelesen, und sie trägt zusätzlich den Fall, dass ein
|
||||
-- neues Datum die Reihenfolge verschiebt.
|
||||
--
|
||||
-- Noch nicht wirksam: geändert wird der payload des geplanten Vorgangs und
|
||||
-- sein Stichtag. An den Stammdaten passiert nichts — dort steht die Änderung
|
||||
-- ja noch nicht.
|
||||
create or replace function update_history_entry(payload jsonb)
|
||||
returns void
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path to 'public', 'pg_temp'
|
||||
as $function$
|
||||
declare
|
||||
v_id uuid := (payload->>'history_id')::uuid;
|
||||
v_eintrag employee_history%rowtype;
|
||||
v_datum date;
|
||||
v_name text;
|
||||
v_karte constant jsonb := app_feld_karte();
|
||||
v_alt jsonb;
|
||||
v_feld text;
|
||||
v_neuer_wert text;
|
||||
v_neu jsonb := '[]'::jsonb;
|
||||
v_korrektur jsonb := '[]'::jsonb;
|
||||
v_setz text[] := '{}';
|
||||
v_spalte text;
|
||||
v_typ text;
|
||||
v_gruppe text;
|
||||
v_gueltig text;
|
||||
v_plan pending_org_changes%rowtype;
|
||||
v_neuer_payload jsonb;
|
||||
v_war_zukunft boolean;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
|
||||
select * into v_eintrag from employee_history where id = v_id;
|
||||
if not found then
|
||||
raise exception 'Historieneintrag nicht gefunden.';
|
||||
end if;
|
||||
|
||||
if v_eintrag.event_type = 'Eintritt' then
|
||||
raise exception 'Der Eintritt lässt sich hier nicht berichtigen.';
|
||||
end if;
|
||||
|
||||
if v_eintrag.event_type not in ('Stammdatenänderung', 'Vertragsänderung') then
|
||||
raise exception 'Nur Stammdaten- und Vertragsänderungen lassen sich hier berichtigen. Für % gibt es den passenden Vorgang.', v_eintrag.event_type;
|
||||
end if;
|
||||
|
||||
if v_eintrag.changes is null or jsonb_array_length(v_eintrag.changes) = 0 then
|
||||
raise exception 'Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts zu berichtigen.';
|
||||
end if;
|
||||
|
||||
v_war_zukunft := v_eintrag.event_date > current_date;
|
||||
v_datum := coalesce(nullif(payload->>'event_date', '')::date, v_eintrag.event_date);
|
||||
|
||||
-- Ein Eintrag bleibt auf seiner Seite der Gegenwart. Beides zu erlauben
|
||||
-- hiesse, eine gelaufene Änderung in eine geplante zu verwandeln (oder
|
||||
-- umgekehrt) — dann müssten Stammdaten und payload gegenläufig angepasst
|
||||
-- werden, und dafür gibt es die fachlichen Vorgänge.
|
||||
if v_war_zukunft and v_datum <= current_date then
|
||||
raise exception 'Eine geplante Änderung lässt sich hier nicht vorziehen. Dafür ist „Daten ändern" der richtige Weg.';
|
||||
end if;
|
||||
if not v_war_zukunft and v_datum > current_date then
|
||||
raise exception 'Eine bereits wirksame Änderung lässt sich nicht in die Zukunft verschieben.';
|
||||
end if;
|
||||
|
||||
select first_name || ' ' || last_name into v_name from employees where id = v_eintrag.employee_id;
|
||||
|
||||
-- Neue Werteliste bauen: Vorher bleibt, Nachher darf ersetzt werden.
|
||||
for v_alt in select * from jsonb_array_elements(v_eintrag.changes) loop
|
||||
v_feld := v_alt->>'feld';
|
||||
select w->>'nachher' into v_neuer_wert
|
||||
from jsonb_array_elements(coalesce(payload->'werte', '[]'::jsonb)) w
|
||||
where w->>'feld' = v_feld;
|
||||
|
||||
if v_neuer_wert is null then
|
||||
v_neu := v_neu || v_alt;
|
||||
else
|
||||
v_neu := v_neu || jsonb_build_object('feld', v_feld, 'vorher', v_alt->>'vorher', 'nachher', nullif(v_neuer_wert, ''));
|
||||
if coalesce(v_alt->>'nachher', '') is distinct from coalesce(nullif(v_neuer_wert, ''), '') then
|
||||
v_korrektur := v_korrektur || jsonb_build_object('feld', v_feld, 'vorher', v_alt->>'nachher', 'nachher', nullif(v_neuer_wert, ''));
|
||||
end if;
|
||||
end if;
|
||||
end loop;
|
||||
|
||||
if jsonb_array_length(v_korrektur) = 0 and v_datum = v_eintrag.event_date then
|
||||
raise exception 'Nichts geändert.';
|
||||
end if;
|
||||
|
||||
update employee_history
|
||||
set changes = v_neu,
|
||||
event_date = v_datum,
|
||||
description = 'Geänderte Felder: ' || app_aenderungsfelder(v_neu) || ', wirksam ab ' || v_datum
|
||||
where id = v_id;
|
||||
|
||||
-- ── Noch nicht wirksam: den geplanten Vorgang nachziehen ───────────
|
||||
if v_war_zukunft then
|
||||
if v_eintrag.pending_id is null then
|
||||
raise exception 'Zu dieser geplanten Änderung ist kein Vorgang hinterlegt. Sie stammt aus der Zeit vor dieser Verknüpfung und lässt sich hier nicht berichtigen.';
|
||||
end if;
|
||||
|
||||
select * into v_plan from pending_org_changes where id = v_eintrag.pending_id for update;
|
||||
if not found or v_plan.status <> 'pending' then
|
||||
raise exception 'Der geplante Vorgang läuft nicht mehr — er wurde bereits angewendet oder abgebrochen.';
|
||||
end if;
|
||||
|
||||
v_neuer_payload := jsonb_set(v_plan.payload, '{effective_date}', to_jsonb(v_datum::text));
|
||||
for v_alt in select * from jsonb_array_elements(v_neu) loop
|
||||
v_feld := v_alt->>'feld';
|
||||
if not v_karte ? v_feld then
|
||||
continue;
|
||||
end if;
|
||||
v_spalte := v_karte->v_feld->>0;
|
||||
v_typ := v_karte->v_feld->>1;
|
||||
v_gruppe := v_karte->v_feld->>2;
|
||||
if not v_neuer_payload ? v_gruppe then
|
||||
v_neuer_payload := jsonb_set(v_neuer_payload, array[v_gruppe], '{}'::jsonb);
|
||||
end if;
|
||||
v_neuer_payload := jsonb_set(
|
||||
v_neuer_payload,
|
||||
array[v_gruppe, v_spalte],
|
||||
case
|
||||
when v_alt->>'nachher' is null then 'null'::jsonb
|
||||
when v_typ = 'liste' then to_jsonb(string_to_array(v_alt->>'nachher', ', '))
|
||||
when v_typ = 'boolean' then to_jsonb((v_alt->>'nachher')::boolean)
|
||||
when v_typ = 'numeric' then to_jsonb((v_alt->>'nachher')::numeric)
|
||||
else to_jsonb(v_alt->>'nachher')
|
||||
end,
|
||||
true);
|
||||
end loop;
|
||||
|
||||
update pending_org_changes
|
||||
set payload = v_neuer_payload, effective_date = v_datum
|
||||
where id = v_plan.id;
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
|
||||
values (app_current_user_id(), current_actor_name(), 'Geplante Änderung berichtigt', v_name, v_eintrag.employee_id,
|
||||
v_eintrag.event_type || ' zum ' || v_eintrag.event_date ||
|
||||
case when v_datum <> v_eintrag.event_date then ' auf ' || v_datum || ' verschoben' else '' end ||
|
||||
case when jsonb_array_length(v_korrektur) > 0 then '; berichtigt: ' || app_aenderungsfelder(v_korrektur) else '' end,
|
||||
v_korrektur);
|
||||
return;
|
||||
end if;
|
||||
|
||||
-- ── Bereits wirksam: Stammdaten nachziehen ─────────────────────────
|
||||
for v_feld in select distinct e->>'feld' from jsonb_array_elements(v_neu) e loop
|
||||
if not v_karte ? v_feld then
|
||||
continue;
|
||||
end if;
|
||||
|
||||
select a->>'nachher' into v_gueltig
|
||||
from employee_history h,
|
||||
lateral jsonb_array_elements(coalesce(h.changes, '[]'::jsonb)) a
|
||||
where h.employee_id = v_eintrag.employee_id
|
||||
and a->>'feld' = v_feld
|
||||
and h.event_date <= current_date
|
||||
order by h.event_date desc, h.created_at desc
|
||||
limit 1;
|
||||
|
||||
v_spalte := v_karte->v_feld->>0;
|
||||
v_typ := v_karte->v_feld->>1;
|
||||
|
||||
if v_typ = 'liste' then
|
||||
v_setz := v_setz || format('%I = coalesce(string_to_array(%L, '', ''), ''{}'')', v_spalte, nullif(v_gueltig, ''));
|
||||
else
|
||||
v_setz := v_setz || format('%I = %L::%s', v_spalte, nullif(v_gueltig, ''), v_typ);
|
||||
end if;
|
||||
end loop;
|
||||
|
||||
if array_length(v_setz, 1) > 0 then
|
||||
begin
|
||||
execute format('update employees set %s where id = %L', array_to_string(v_setz, ', '), v_eintrag.employee_id);
|
||||
exception when check_violation then
|
||||
raise exception 'Der berichtigte Wert passt nicht zum übrigen Stand (%). Zusammengehörende Felder — etwa Beschäftigungsausmaß und Wochenstunden — müssen gemeinsam stimmen.', sqlerrm;
|
||||
end;
|
||||
end if;
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
|
||||
values (app_current_user_id(), current_actor_name(), 'Historieneintrag berichtigt', v_name, v_eintrag.employee_id,
|
||||
v_eintrag.event_type || ' vom ' || v_eintrag.event_date ||
|
||||
case when v_datum <> v_eintrag.event_date then ' auf ' || v_datum || ' umdatiert' else '' end ||
|
||||
case when jsonb_array_length(v_korrektur) > 0
|
||||
then '; berichtigt: ' || app_aenderungsfelder(v_korrektur) else '' end,
|
||||
v_korrektur);
|
||||
end;
|
||||
$function$;
|
||||
|
||||
comment on function update_history_entry(jsonb) is
|
||||
'Berichtigt Wert und/oder Datum einer Stammdaten- oder Vertragsänderung. Bereits wirksam: die Stammdaten werden je Feld aus dem jüngsten wirksamen Eintrag abgeleitet. Noch nicht wirksam: payload und Stichtag des geplanten Vorgangs werden nachgezogen. SECURITY DEFINER, weil employee_history absichtlich keine update-Policy hat.';
|
||||
|
||||
|
||||
-- Bestehende Zeilen verknüpfen, wo genau ein Vorgang in Frage kommt.
|
||||
with kandidat as (
|
||||
select h.id as history_id,
|
||||
(select p.id
|
||||
from pending_org_changes p
|
||||
where p.employee_id = h.employee_id
|
||||
and p.effective_date = h.event_date
|
||||
and p.status = 'pending'
|
||||
and p.change_type = 'contract_change'
|
||||
and not exists (
|
||||
select 1 from employee_history x
|
||||
where x.pending_id = p.id and x.event_type = h.event_type
|
||||
)
|
||||
limit 2) as plan_id,
|
||||
(select count(*)
|
||||
from pending_org_changes p
|
||||
where p.employee_id = h.employee_id
|
||||
and p.effective_date = h.event_date
|
||||
and p.status = 'pending'
|
||||
and p.change_type = 'contract_change') as anzahl
|
||||
from employee_history h
|
||||
where h.event_date > current_date
|
||||
and h.pending_id is null
|
||||
and h.event_type in ('Stammdatenänderung', 'Vertragsänderung')
|
||||
)
|
||||
update employee_history h
|
||||
set pending_id = k.plan_id
|
||||
from kandidat k
|
||||
where h.id = k.history_id
|
||||
and k.anzahl = 1
|
||||
and k.plan_id is not null;
|
||||
|
||||
|
||||
-- Selbstprüfung.
|
||||
do $$
|
||||
declare
|
||||
v_ced text := pg_get_functiondef('public.change_employee_data(jsonb)'::regprocedure);
|
||||
v_del text := pg_get_functiondef('public.delete_history_entry(jsonb)'::regprocedure);
|
||||
v_upd text := pg_get_functiondef('public.update_history_entry(jsonb)'::regprocedure);
|
||||
begin
|
||||
if not exists (
|
||||
select 1 from information_schema.columns
|
||||
where table_schema = 'public' and table_name = 'employee_history' and column_name = 'pending_id'
|
||||
) then
|
||||
raise exception 'employee_history.pending_id fehlt';
|
||||
end if;
|
||||
|
||||
if (length(v_ced) - length(replace(v_ced, 'v_pending_id)', ''))) / length('v_pending_id)') <> 2 then
|
||||
raise exception 'change_employee_data schreibt pending_id nicht in beide Historien-Einträge';
|
||||
end if;
|
||||
|
||||
if v_ced not like '%returning id into v_pending_id%' then
|
||||
raise exception 'change_employee_data merkt sich den angelegten Vorgang nicht';
|
||||
end if;
|
||||
|
||||
if jsonb_array_length(app_feld_karte()->'Adresse') <> 3 then
|
||||
raise exception 'Die Feldtabelle nennt die Gruppe nicht';
|
||||
end if;
|
||||
if app_feld_karte()->'Adresse'->>2 <> 'person' or app_feld_karte()->'Wochenstunden'->>2 <> 'contract' then
|
||||
raise exception 'Die Gruppen in der Feldtabelle stimmen nicht';
|
||||
end if;
|
||||
|
||||
if v_del not like '%pending_id is null%' or v_upd not like '%pending_id is null%' then
|
||||
raise exception 'Der Zukunftsfall wird nicht behandelt';
|
||||
end if;
|
||||
|
||||
-- Die Policies bleiben, wie sie sind.
|
||||
if exists (
|
||||
select 1 from pg_policy p join pg_class c on c.oid = p.polrelid
|
||||
where c.relname = 'employee_history' and p.polcmd in ('d', 'w')
|
||||
) then
|
||||
raise exception 'employee_history hat eine update- oder delete-Policy bekommen';
|
||||
end if;
|
||||
end
|
||||
$$;
|
||||
113
supabase/migrations/20260814100000_no_show.sql
Normal file
113
supabase/migrations/20260814100000_no_show.sql
Normal file
@@ -0,0 +1,113 @@
|
||||
-- Wer nie angetreten ist: Austrittsgrund „No Show".
|
||||
--
|
||||
-- Der Fall gibt es, und bisher liess er sich nicht erfassen. Ein Austritt am
|
||||
-- Eintrittstag scheiterte an chk_assignment_range: die Besetzung wurde auf
|
||||
-- valid_to = valid_from geschlossen, und ein leeres Intervall ist dort
|
||||
-- verboten. Ausweichen auf den Folgetag hätte bedeutet, einen Tag
|
||||
-- Beschäftigung zu behaupten, den es nie gab — mit allem, was daran hängt:
|
||||
-- Kopfzahl, Zugehörigkeit, Auswertungen zum Stichtag.
|
||||
--
|
||||
-- Drei Dinge macht dieser Grund deshalb anders:
|
||||
--
|
||||
-- * Das Austrittsdatum ist **immer** der Eintrittstag, unabhängig davon,
|
||||
-- was übergeben wurde. Daraus folgt „nie aktiv" von selbst: als
|
||||
-- beschäftigt gilt, wessen exit_date *nach* dem Stichtag liegt, und das
|
||||
-- ist hier an keinem Tag der Fall.
|
||||
-- * Die Planstellenzuordnung wird **entfernt**, nicht geschlossen. Die
|
||||
-- Stelle war nie besetzt und ist wieder frei.
|
||||
-- * Der Status springt sofort auf „Ausgetreten", auch bei einem Eintritt in
|
||||
-- der Zukunft. Sonst bliebe in der Spalte „Geplant" stehen — es gibt
|
||||
-- keinen Lauf, der das später nachzieht.
|
||||
--
|
||||
-- Die Bedingung unten hält das fest, egal auf welchem Weg jemand schreibt —
|
||||
-- auch über den Import.
|
||||
|
||||
alter table employees drop constraint if exists chk_no_show_am_eintritt;
|
||||
alter table employees add constraint chk_no_show_am_eintritt
|
||||
check (exit_reason is distinct from 'No Show' or exit_date = entry_date);
|
||||
|
||||
comment on constraint chk_no_show_am_eintritt on employees is
|
||||
'Ein Nichtantritt endet am Eintrittstag. Sonst gäbe es Tage, an denen die Person als beschäftigt zählte, obwohl sie nie da war. „is distinct from" statt „<>", damit ein leerer Grund nicht zu null auswertet und die Bedingung durchrutschen lässt.';
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.terminate_employee(payload jsonb)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $function$
|
||||
declare
|
||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||
v_exit date := (payload->>'exit_date')::date;
|
||||
v_name text;
|
||||
-- „No Show" ist kein gewöhnlicher Austritt: die Person hat nie
|
||||
-- angefangen. Deshalb hängt an diesem einen Grund anderes Verhalten.
|
||||
v_no_show boolean := coalesce(payload->>'exit_reason', '') = 'No Show';
|
||||
v_entry date;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select first_name || ' ' || last_name, entry_date into v_name, v_entry
|
||||
from employees where id = v_employee_id;
|
||||
|
||||
-- Wer nie angetreten ist, tritt am Tag seines Eintritts wieder aus.
|
||||
-- Damit gibt es keinen einzigen Tag, an dem die Person beschäftigt war:
|
||||
-- die Statusableitung verlangt exit_date > Stichtag, um jemanden als
|
||||
-- beschäftigt zu zählen, und das ist hier nie erfüllt. „Nie aktiv" ist
|
||||
-- damit keine zusätzliche Regel, sondern folgt aus dem Datum.
|
||||
if v_no_show then
|
||||
v_exit := v_entry;
|
||||
end if;
|
||||
|
||||
update employees set
|
||||
-- Bei einem Nichtantritt sofort, auch wenn der Eintritt noch in der
|
||||
-- Zukunft lag: sonst bliebe in der Spalte auf Dauer „Geplant" stehen,
|
||||
-- denn es gibt keinen Lauf, der sie später nachzieht.
|
||||
status = case when v_no_show or v_exit <= current_date then 'Ausgetreten' else status end,
|
||||
exit_date = v_exit,
|
||||
exit_reason = payload->>'exit_reason'
|
||||
where id = v_employee_id;
|
||||
|
||||
-- Die Planstelle wird frei. Direkte Berichte müssen nicht umgehängt
|
||||
-- werden: die Berichtslinie wird abgeleitet und rutscht von selbst auf
|
||||
-- die nächste besetzte Ebene.
|
||||
if v_no_show then
|
||||
-- Die Planstelle war nie besetzt. Sie auf [Eintritt, Eintritt) zu
|
||||
-- schliessen ginge nicht — chk_assignment_range verlangt ein echtes
|
||||
-- Intervall, und genau daran scheiterte ein Austritt am Eintrittstag
|
||||
-- bisher. Die Zuordnung wird deshalb entfernt: die Stelle ist wieder
|
||||
-- frei, und es steht nirgends, jemand hätte sie je innegehabt.
|
||||
delete from position_assignments
|
||||
where employee_id = v_employee_id and valid_to is null;
|
||||
else
|
||||
update position_assignments set valid_to = v_exit
|
||||
where employee_id = v_employee_id and valid_to is null;
|
||||
end if;
|
||||
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_employee_id, v_exit, 'Austritt',
|
||||
case when v_no_show
|
||||
then 'Kein Antritt am ' || v_entry || ' (No Show)'
|
||||
else 'Austritt (' || coalesce(payload->>'exit_reason', '-') || ')' end);
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (app_current_user_id(), current_actor_name(), 'Austritt', v_name, v_employee_id, case when v_no_show then 'Kein Antritt am ' || v_entry || ' (No Show)' else 'Austritt am ' || v_exit end);
|
||||
end;
|
||||
$function$;
|
||||
|
||||
|
||||
-- Selbstprüfung.
|
||||
do $$
|
||||
declare
|
||||
v_def text := pg_get_functiondef('public.terminate_employee(jsonb)'::regprocedure);
|
||||
begin
|
||||
if v_def not like '%v_no_show%' then
|
||||
raise exception 'terminate_employee kennt den Nichtantritt nicht';
|
||||
end if;
|
||||
if v_def not like '%delete from position_assignments%' then
|
||||
raise exception 'Die Zuordnung wird bei einem Nichtantritt nicht entfernt';
|
||||
end if;
|
||||
if not exists (
|
||||
select 1 from pg_constraint where conname = 'chk_no_show_am_eintritt'
|
||||
) then
|
||||
raise exception 'Die Bedingung fehlt';
|
||||
end if;
|
||||
end
|
||||
$$;
|
||||
357
supabase/migrations/20260814120000_kuendigungsschutz.sql
Normal file
357
supabase/migrations/20260814120000_kuendigungsschutz.sql
Normal file
@@ -0,0 +1,357 @@
|
||||
-- Besonderer Kündigungsschutz.
|
||||
--
|
||||
-- Betriebsratsmitglieder, Schwangere, Eltern in Karenz, begünstigte
|
||||
-- Behinderte, Lehrlinge, Präsenzdiener — für sie gelten eigene Regeln, bevor
|
||||
-- ein Dienstverhältnis beendet werden darf. Das Werkzeug entscheidet das
|
||||
-- nicht, aber es soll niemanden einen Austritt erfassen lassen, ohne es zu
|
||||
-- erwähnen.
|
||||
--
|
||||
-- Zwei Spalten, wie beim Dienstwagen: ein Kennzeichen und eine Angabe, die
|
||||
-- nur mit ihm zusammen Sinn ergibt. Das Datum ist **freiwillig** — bei einem
|
||||
-- Betriebsratsmandat steht das Ende oft fest, bei einer Schwangerschaft
|
||||
-- nicht, und ein Pflichtfeld zwänge dann zu einer erfundenen Zahl.
|
||||
--
|
||||
-- Die Bedingung sagt nur, was ohne das Kennzeichen nicht sein darf. Sie
|
||||
-- verlangt umgekehrt kein Datum.
|
||||
|
||||
alter table employees
|
||||
add column if not exists has_kuendigungsschutz boolean not null default false,
|
||||
add column if not exists kuendigungsschutz_bis date;
|
||||
|
||||
comment on column employees.has_kuendigungsschutz is
|
||||
'Besonderer Kündigungsschutz — Betriebsrat, Mutterschutz, Karenz, begünstigte Behinderung, Lehrverhältnis. Löst beim Austritt eine Warnung aus.';
|
||||
comment on column employees.kuendigungsschutz_bis is
|
||||
'Ende des Schutzes, falls bekannt. Freiwillig: bei einer Schwangerschaft steht es nicht fest, bei einem Mandat schon.';
|
||||
|
||||
alter table employees drop constraint if exists chk_kuendigungsschutz_bis;
|
||||
alter table employees add constraint chk_kuendigungsschutz_bis
|
||||
check (has_kuendigungsschutz or kuendigungsschutz_bis is null);
|
||||
|
||||
comment on constraint chk_kuendigungsschutz_bis on employees is
|
||||
'Ein Enddatum ohne Schutz wäre ein Rest, den niemand mehr deuten kann. has_kuendigungsschutz ist NOT NULL, deshalb genügt hier die einfache Oder-Form — anders als bei chk_dienstwagen_art, wo eine nullbare Spalte die Bedingung sonst durchrutschen liesse.';
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.app_feld_karte()
|
||||
RETURNS jsonb
|
||||
LANGUAGE sql
|
||||
IMMUTABLE
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $function$
|
||||
select jsonb_build_object(
|
||||
'Vorname', jsonb_build_array('first_name', 'text', 'person'),
|
||||
'Nachname', jsonb_build_array('last_name', 'text', 'person'),
|
||||
'Geschlecht', jsonb_build_array('gender', 'gender_type', 'person'),
|
||||
'Geburtsdatum', jsonb_build_array('birth_date', 'date', 'person'),
|
||||
'SV-Nummer', jsonb_build_array('sv_nummer', 'text', 'person'),
|
||||
'Staatsbürgerschaft', jsonb_build_array('nationality', 'text', 'person'),
|
||||
'Adresse', jsonb_build_array('address', 'text', 'person'),
|
||||
'Postleitzahl', jsonb_build_array('postal_code', 'text', 'person'),
|
||||
'Ort', jsonb_build_array('city', 'text', 'person'),
|
||||
'Land', jsonb_build_array('address_country', 'text', 'person'),
|
||||
'E-Mail', jsonb_build_array('email', 'text', 'person'),
|
||||
'Telefon', jsonb_build_array('phone', 'text', 'person'),
|
||||
'Notfallkontakt', jsonb_build_array('emergency_contact_name', 'text', 'person'),
|
||||
'Notfallkontakt Telefon', jsonb_build_array('emergency_contact_phone', 'text', 'person'),
|
||||
'Notfallkontakt Verhältnis', jsonb_build_array('emergency_contact_relation', 'text', 'person'),
|
||||
'Titel (vorangestellt)', jsonb_build_array('title_prefix', 'liste', 'person'),
|
||||
'Titel (nachgestellt)', jsonb_build_array('title_suffix', 'liste', 'person'),
|
||||
'Beschäftigungsausmaß', jsonb_build_array('employment_type', 'employment_type', 'contract'),
|
||||
'Wochenstunden', jsonb_build_array('weekly_hours', 'numeric', 'contract'),
|
||||
'Vertragsart', jsonb_build_array('contract_type', 'contract_type', 'contract'),
|
||||
'Befristet bis', jsonb_build_array('contract_end_date', 'date', 'contract'),
|
||||
'Angestellte:r/Arbeiter:in', jsonb_build_array('worker_type', 'worker_type', 'role'),
|
||||
'Kollektivvertrag', jsonb_build_array('collective_agreement', 'collective_agreement', 'role'),
|
||||
'Arbeitstage', jsonb_build_array('work_days', 'liste', 'role'),
|
||||
'Betriebsrat', jsonb_build_array('is_betriebsrat', 'boolean', 'role'),
|
||||
'Dienstwagen', jsonb_build_array('has_dienstwagen', 'boolean', 'role'),
|
||||
'Laterale Führung', jsonb_build_array('is_laterale_fuehrung', 'boolean', 'role'),
|
||||
'C-Level', jsonb_build_array('is_c_level', 'boolean', 'role'),
|
||||
'Dienstwagen Antrieb', jsonb_build_array('dienstwagen_art', 'text', 'role'),
|
||||
'Besonderer Kündigungsschutz', jsonb_build_array('has_kuendigungsschutz', 'boolean', 'role'),
|
||||
'Kündigungsschutz bis', jsonb_build_array('kuendigungsschutz_bis', 'date', 'role')
|
||||
);
|
||||
$function$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.hire_employee(payload jsonb)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $function$
|
||||
declare
|
||||
v_id uuid;
|
||||
v_position_id uuid := (payload->>'position_id')::uuid;
|
||||
v_entry date := (payload->>'entry_date')::date;
|
||||
v_besetzt uuid;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
|
||||
if v_position_id is null then
|
||||
raise exception 'Es muss eine Planstelle angegeben werden.';
|
||||
end if;
|
||||
|
||||
if payload->>'personnel_number' is null or btrim(payload->>'personnel_number') = '' then
|
||||
raise exception 'Es muss eine Personalnummer angegeben werden.';
|
||||
end if;
|
||||
if exists (select 1 from employees where personnel_number = (payload->>'personnel_number')::int) then
|
||||
raise exception 'Die Personalnummer % ist bereits vergeben.', payload->>'personnel_number';
|
||||
end if;
|
||||
|
||||
declare
|
||||
v_ab date;
|
||||
v_bis date;
|
||||
begin
|
||||
select valid_from, valid_to into v_ab, v_bis from om_positions where id = v_position_id;
|
||||
if v_ab is null then
|
||||
raise exception 'Die Planstelle existiert nicht.';
|
||||
end if;
|
||||
if v_entry < v_ab then
|
||||
raise exception 'Die Planstelle gilt erst ab %. Ein Eintritt am % ist darauf nicht möglich.', v_ab, v_entry;
|
||||
end if;
|
||||
if v_bis is not null and v_entry >= v_bis then
|
||||
raise exception 'Die Planstelle gilt nur bis %. Ein Eintritt am % ist darauf nicht möglich.', v_bis, v_entry;
|
||||
end if;
|
||||
end;
|
||||
|
||||
select pa.employee_id into v_besetzt
|
||||
from position_assignments pa
|
||||
where pa.position_id = v_position_id
|
||||
and (pa.valid_to is null or pa.valid_to > v_entry);
|
||||
if v_besetzt is not null then
|
||||
raise exception 'Diese Planstelle ist bereits besetzt.';
|
||||
end if;
|
||||
|
||||
insert into employees (
|
||||
personnel_number, first_name, last_name, gender, birth_date, sv_nummer, nationality, email, phone,
|
||||
address, postal_code, city, address_country, location_id, job_title,
|
||||
employment_type, weekly_hours, contract_type, contract_end_date, paygrade,
|
||||
source, status, entry_date, title_prefix, title_suffix,
|
||||
worker_type, collective_agreement, work_days,
|
||||
is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level,
|
||||
has_kuendigungsschutz, kuendigungsschutz_bis,
|
||||
dienstwagen_art, emergency_contact_name, emergency_contact_phone, emergency_contact_relation
|
||||
)
|
||||
values (
|
||||
(payload->>'personnel_number')::int, payload->>'first_name', payload->>'last_name', (payload->>'gender')::gender_type,
|
||||
(payload->>'birth_date')::date, payload->>'sv_nummer',
|
||||
coalesce(payload->>'nationality', 'Österreich'), payload->>'email', payload->>'phone',
|
||||
payload->>'address', payload->>'postal_code', payload->>'city',
|
||||
coalesce(payload->>'address_country', 'Österreich'),
|
||||
(payload->>'location_id')::uuid,
|
||||
(select j.title from om_positions p join jobs j on j.id = p.job_id where p.id = v_position_id),
|
||||
coalesce((payload->>'employment_type')::employment_type, 'Vollzeit'),
|
||||
coalesce((payload->>'weekly_hours')::numeric, 38.5),
|
||||
coalesce((payload->>'contract_type')::contract_type, 'unbefristet'),
|
||||
nullif(payload->>'contract_end_date', '')::date,
|
||||
coalesce((payload->>'paygrade')::paygrade_type, 'B'),
|
||||
coalesce((payload->>'source')::source_type, 'Extern'),
|
||||
case when v_entry > current_date then 'Geplant' else 'Aktiv' end::employment_status,
|
||||
v_entry,
|
||||
coalesce(array(select jsonb_array_elements_text(payload->'title_prefix')), '{}'),
|
||||
coalesce(array(select jsonb_array_elements_text(payload->'title_suffix')), '{}'),
|
||||
coalesce((payload->>'worker_type')::worker_type, 'Angestellte:r'),
|
||||
coalesce((payload->>'collective_agreement')::collective_agreement, 'Süßwaren'),
|
||||
coalesce(nullif(array(select jsonb_array_elements_text(payload->'work_days'))::text[], '{}'), '{Mo,Di,Mi,Do,Fr}'),
|
||||
coalesce((payload->>'is_betriebsrat')::boolean, false),
|
||||
coalesce((payload->>'has_dienstwagen')::boolean, false),
|
||||
coalesce((payload->>'is_laterale_fuehrung')::boolean, false),
|
||||
coalesce((payload->>'is_c_level')::boolean, false),
|
||||
coalesce((payload->>'has_kuendigungsschutz')::boolean, false),
|
||||
-- Das Datum nur, wenn der Schutz überhaupt gesetzt ist: sonst bliebe
|
||||
-- ein Enddatum ohne Schutz stehen, und chk_kuendigungsschutz_bis
|
||||
-- würde es zu Recht abweisen.
|
||||
case when coalesce((payload->>'has_kuendigungsschutz')::boolean, false)
|
||||
then nullif(payload->>'kuendigungsschutz_bis', '')::date else null end,
|
||||
case when coalesce((payload->>'has_dienstwagen')::boolean, false) then coalesce(nullif(payload->>'dienstwagen_art', ''), 'Verbrenner') else null end,
|
||||
nullif(payload->>'emergency_contact_name', ''),
|
||||
nullif(payload->>'emergency_contact_phone', ''),
|
||||
nullif(payload->>'emergency_contact_relation', '')
|
||||
)
|
||||
returning id into v_id;
|
||||
|
||||
insert into position_assignments (position_id, employee_id, valid_from)
|
||||
values (v_position_id, v_id, v_entry);
|
||||
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_id, v_entry, 'Eintritt', 'Eintritt auf Planstelle ' ||
|
||||
(select position_number from om_positions where id = v_position_id));
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (app_current_user_id(), current_actor_name(), 'Neueinstellung',
|
||||
(payload->>'first_name') || ' ' || (payload->>'last_name'), v_id, 'Eintritt am ' || v_entry);
|
||||
|
||||
return v_id;
|
||||
end;
|
||||
$function$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.change_employee_data(payload jsonb)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $function$
|
||||
declare
|
||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
|
||||
v_old employees%rowtype;
|
||||
v_name text;
|
||||
v_person_changes jsonb := '[]'::jsonb;
|
||||
v_contract_changes jsonb := '[]'::jsonb;
|
||||
v_person jsonb := payload->'person';
|
||||
v_contract jsonb := payload->'contract';
|
||||
v_role jsonb := payload->'role';
|
||||
v_immediate boolean;
|
||||
v_new_work_days text[];
|
||||
v_new_title_prefix text[];
|
||||
v_new_title_suffix text[];
|
||||
v_pending_id uuid;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select * into v_old from employees where id = v_employee_id;
|
||||
v_name := v_old.first_name || ' ' || v_old.last_name;
|
||||
v_immediate := v_effective_date <= current_date;
|
||||
|
||||
-- Der `?`-Test bleibt: ein fehlender Schlüssel heisst „nicht übermittelt",
|
||||
-- nicht „geleert". Ohne ihn würde jedes nicht gesendete Feld als Änderung
|
||||
-- auf null gemeldet.
|
||||
if v_person ? 'first_name' then v_person_changes := app_aenderung(v_person_changes, 'Vorname', v_old.first_name, v_person->>'first_name'); end if;
|
||||
if v_person ? 'last_name' then v_person_changes := app_aenderung(v_person_changes, 'Nachname', v_old.last_name, v_person->>'last_name'); end if;
|
||||
if v_person ? 'gender' then v_person_changes := app_aenderung(v_person_changes, 'Geschlecht', v_old.gender::text, v_person->>'gender'); end if;
|
||||
-- Datumswerte über ::date::text vergleichen, damit „2026-8-3" und
|
||||
-- „2026-08-03" nicht als Änderung gelten.
|
||||
if v_person ? 'birth_date' then v_person_changes := app_aenderung(v_person_changes, 'Geburtsdatum', v_old.birth_date::text, (nullif(v_person->>'birth_date','')::date)::text); end if;
|
||||
if v_person ? 'sv_nummer' then v_person_changes := app_aenderung(v_person_changes, 'SV-Nummer', v_old.sv_nummer, v_person->>'sv_nummer'); end if;
|
||||
if v_person ? 'nationality' then v_person_changes := app_aenderung(v_person_changes, 'Staatsbürgerschaft', v_old.nationality, v_person->>'nationality'); end if;
|
||||
if v_person ? 'address' then v_person_changes := app_aenderung(v_person_changes, 'Adresse', v_old.address, v_person->>'address'); end if;
|
||||
if v_person ? 'postal_code' then v_person_changes := app_aenderung(v_person_changes, 'Postleitzahl', v_old.postal_code, v_person->>'postal_code'); end if;
|
||||
if v_person ? 'city' then v_person_changes := app_aenderung(v_person_changes, 'Ort', v_old.city, v_person->>'city'); end if;
|
||||
if v_person ? 'address_country' then v_person_changes := app_aenderung(v_person_changes, 'Land', v_old.address_country, v_person->>'address_country'); end if;
|
||||
if v_person ? 'email' then v_person_changes := app_aenderung(v_person_changes, 'E-Mail', v_old.email, v_person->>'email'); end if;
|
||||
if v_person ? 'phone' then v_person_changes := app_aenderung(v_person_changes, 'Telefon', v_old.phone, v_person->>'phone'); end if;
|
||||
if v_person ? 'emergency_contact_name' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt', v_old.emergency_contact_name, v_person->>'emergency_contact_name'); end if;
|
||||
if v_person ? 'emergency_contact_phone' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Telefon', v_old.emergency_contact_phone, v_person->>'emergency_contact_phone'); end if;
|
||||
if v_person ? 'emergency_contact_relation' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Verhältnis', v_old.emergency_contact_relation, v_person->>'emergency_contact_relation'); end if;
|
||||
|
||||
if v_person ? 'title_prefix' then
|
||||
v_new_title_prefix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_prefix') elem), '{}');
|
||||
v_person_changes := app_aenderung(v_person_changes, 'Titel (vorangestellt)',
|
||||
array_to_string(v_old.title_prefix, ', '), array_to_string(v_new_title_prefix, ', '));
|
||||
end if;
|
||||
if v_person ? 'title_suffix' then
|
||||
v_new_title_suffix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_suffix') elem), '{}');
|
||||
v_person_changes := app_aenderung(v_person_changes, 'Titel (nachgestellt)',
|
||||
array_to_string(v_old.title_suffix, ', '), array_to_string(v_new_title_suffix, ', '));
|
||||
end if;
|
||||
|
||||
if v_contract ? 'employment_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Beschäftigungsausmaß', v_old.employment_type::text, v_contract->>'employment_type'); end if;
|
||||
-- Über ::numeric::text, damit „38.50" und „38.5" gleich zählen.
|
||||
if v_contract ? 'weekly_hours' then v_contract_changes := app_aenderung(v_contract_changes, 'Wochenstunden', v_old.weekly_hours::text, (nullif(v_contract->>'weekly_hours','')::numeric)::text); end if;
|
||||
if v_contract ? 'contract_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Vertragsart', v_old.contract_type::text, v_contract->>'contract_type'); end if;
|
||||
if v_contract ? 'contract_end_date' then v_contract_changes := app_aenderung(v_contract_changes, 'Befristet bis', v_old.contract_end_date::text, (nullif(v_contract->>'contract_end_date','')::date)::text); end if;
|
||||
|
||||
if v_role ? 'worker_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Angestellte:r/Arbeiter:in', v_old.worker_type::text, v_role->>'worker_type'); end if;
|
||||
if v_role ? 'collective_agreement' then v_contract_changes := app_aenderung(v_contract_changes, 'Kollektivvertrag', v_old.collective_agreement::text, v_role->>'collective_agreement'); end if;
|
||||
if v_role ? 'work_days' then
|
||||
v_new_work_days := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_role->'work_days') elem), '{}');
|
||||
v_contract_changes := app_aenderung(v_contract_changes, 'Arbeitstage',
|
||||
array_to_string(v_old.work_days, ', '), array_to_string(v_new_work_days, ', '));
|
||||
end if;
|
||||
if v_role ? 'is_betriebsrat' then v_contract_changes := app_aenderung(v_contract_changes, 'Betriebsrat', v_old.is_betriebsrat::text, v_role->>'is_betriebsrat'); end if;
|
||||
if v_role ? 'has_dienstwagen' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen', v_old.has_dienstwagen::text, v_role->>'has_dienstwagen'); end if;
|
||||
if v_role ? 'is_laterale_fuehrung' then v_contract_changes := app_aenderung(v_contract_changes, 'Laterale Führung', v_old.is_laterale_fuehrung::text, v_role->>'is_laterale_fuehrung'); end if;
|
||||
if v_role ? 'is_c_level' then v_contract_changes := app_aenderung(v_contract_changes, 'C-Level', v_old.is_c_level::text, v_role->>'is_c_level'); end if;
|
||||
if v_role ? 'has_kuendigungsschutz' then v_contract_changes := app_aenderung(v_contract_changes, 'Besonderer Kündigungsschutz', v_old.has_kuendigungsschutz::text, v_role->>'has_kuendigungsschutz'); end if;
|
||||
if v_role ? 'kuendigungsschutz_bis' then v_contract_changes := app_aenderung(v_contract_changes, 'Kündigungsschutz bis', v_old.kuendigungsschutz_bis::text, (nullif(v_role->>'kuendigungsschutz_bis','')::date)::text); end if;
|
||||
if v_role ? 'dienstwagen_art' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen Antrieb', v_old.dienstwagen_art, nullif(v_role->>'dienstwagen_art', '')); end if;
|
||||
|
||||
if v_immediate then
|
||||
update employees set
|
||||
first_name = coalesce(v_person->>'first_name', first_name),
|
||||
last_name = coalesce(v_person->>'last_name', last_name),
|
||||
gender = coalesce((v_person->>'gender')::gender_type, gender),
|
||||
birth_date = coalesce((v_person->>'birth_date')::date, birth_date),
|
||||
sv_nummer = coalesce(v_person->>'sv_nummer', sv_nummer),
|
||||
nationality = coalesce(v_person->>'nationality', nationality),
|
||||
address = coalesce(v_person->>'address', address),
|
||||
postal_code = coalesce(v_person->>'postal_code', postal_code),
|
||||
city = coalesce(v_person->>'city', city),
|
||||
address_country = coalesce(v_person->>'address_country', address_country),
|
||||
email = coalesce(v_person->>'email', email),
|
||||
phone = coalesce(v_person->>'phone', phone),
|
||||
emergency_contact_name = case when v_person ? 'emergency_contact_name' then nullif(v_person->>'emergency_contact_name', '') else emergency_contact_name end,
|
||||
emergency_contact_phone = case when v_person ? 'emergency_contact_phone' then nullif(v_person->>'emergency_contact_phone', '') else emergency_contact_phone end,
|
||||
emergency_contact_relation = case when v_person ? 'emergency_contact_relation' then nullif(v_person->>'emergency_contact_relation', '') else emergency_contact_relation end,
|
||||
title_prefix = case when v_person ? 'title_prefix' then v_new_title_prefix else title_prefix end,
|
||||
title_suffix = case when v_person ? 'title_suffix' then v_new_title_suffix else title_suffix end,
|
||||
employment_type = coalesce((v_contract->>'employment_type')::employment_type, employment_type),
|
||||
weekly_hours = coalesce((v_contract->>'weekly_hours')::numeric, weekly_hours),
|
||||
contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),
|
||||
contract_end_date = case when v_contract ? 'contract_end_date' then nullif(v_contract->>'contract_end_date','')::date else contract_end_date end,
|
||||
worker_type = coalesce((v_role->>'worker_type')::worker_type, worker_type),
|
||||
collective_agreement = coalesce((v_role->>'collective_agreement')::collective_agreement, collective_agreement),
|
||||
work_days = case when v_role ? 'work_days' then v_new_work_days else work_days end,
|
||||
is_betriebsrat = coalesce((v_role->>'is_betriebsrat')::boolean, is_betriebsrat),
|
||||
has_dienstwagen = coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen),
|
||||
is_laterale_fuehrung = coalesce((v_role->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
|
||||
is_c_level = coalesce((v_role->>'is_c_level')::boolean, is_c_level),
|
||||
has_kuendigungsschutz = coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz),
|
||||
-- Fällt der Schutz weg, fällt das Datum mit. Andernfalls bliebe ein
|
||||
-- Enddatum ohne Schutz stehen — die Bedingung verbietet das, und der
|
||||
-- Vorgang schlüge fehl, statt das Offensichtliche zu tun.
|
||||
kuendigungsschutz_bis = case
|
||||
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
|
||||
case when v_role ? 'kuendigungsschutz_bis'
|
||||
then nullif(v_role->>'kuendigungsschutz_bis','')::date
|
||||
else kuendigungsschutz_bis end
|
||||
else null
|
||||
end,
|
||||
dienstwagen_art = case
|
||||
when coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen) then
|
||||
coalesce(nullif(v_role->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner')
|
||||
else null
|
||||
end
|
||||
where id = v_employee_id;
|
||||
elsif jsonb_array_length(v_person_changes) > 0 or jsonb_array_length(v_contract_changes) > 0 then
|
||||
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
|
||||
values (v_employee_id, 'contract_change', v_effective_date, payload)
|
||||
returning id into v_pending_id;
|
||||
end if;
|
||||
|
||||
if jsonb_array_length(v_person_changes) > 0 then
|
||||
insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
|
||||
values (v_employee_id, v_effective_date, 'Stammdatenänderung',
|
||||
'Geänderte Felder: ' || app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes, v_pending_id);
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
|
||||
values (app_current_user_id(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id,
|
||||
app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes);
|
||||
end if;
|
||||
|
||||
if jsonb_array_length(v_contract_changes) > 0 then
|
||||
insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
|
||||
values (v_employee_id, v_effective_date, 'Vertragsänderung',
|
||||
'Geänderte Felder: ' || app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes, v_pending_id);
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
|
||||
values (app_current_user_id(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id,
|
||||
app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes);
|
||||
end if;
|
||||
end;
|
||||
$function$;
|
||||
|
||||
|
||||
-- Selbstprüfung.
|
||||
do $$
|
||||
declare
|
||||
v_hire text := pg_get_functiondef('public.hire_employee(jsonb)'::regprocedure);
|
||||
v_chg text := pg_get_functiondef('public.change_employee_data(jsonb)'::regprocedure);
|
||||
begin
|
||||
if v_hire not like '%has_kuendigungsschutz%' then
|
||||
raise exception 'hire_employee nimmt den Kündigungsschutz nicht entgegen';
|
||||
end if;
|
||||
if v_chg not like '%Besonderer Kündigungsschutz%' then
|
||||
raise exception 'change_employee_data protokolliert den Kündigungsschutz nicht';
|
||||
end if;
|
||||
if not (app_feld_karte() ? 'Besonderer Kündigungsschutz' and app_feld_karte() ? 'Kündigungsschutz bis') then
|
||||
raise exception 'Die Feldtabelle kennt den Kündigungsschutz nicht — dann liesse sich ein Eintrag dazu nicht berichtigen';
|
||||
end if;
|
||||
if app_feld_karte()->'Kündigungsschutz bis'->>2 <> 'role' then
|
||||
raise exception 'Die Gruppe im payload stimmt nicht';
|
||||
end if;
|
||||
end
|
||||
$$;
|
||||
268
supabase/migrations/20260814140000_teilzeit_gruende.sql
Normal file
268
supabase/migrations/20260814140000_teilzeit_gruende.sql
Normal file
@@ -0,0 +1,268 @@
|
||||
-- Teilzeiten sind keine Abwesenheiten.
|
||||
--
|
||||
-- Bildungsteilzeit, Elternteilzeit, Pflegeteilzeit und Wiedereingliederungs-
|
||||
-- teilzeit standen in der Liste der Langzeitabwesenheiten. Wer so erfasst
|
||||
-- wurde, galt als abwesend: die Person verschwand aus dem Bestand, ihre
|
||||
-- Berichtslinie fiel an eine Vertretung, und in Auswertungen zählte sie nicht
|
||||
-- mehr mit — obwohl sie jede Woche im Haus war, nur kürzer.
|
||||
--
|
||||
-- Sie wandern deshalb dorthin, wo sie hingehören:
|
||||
--
|
||||
-- * **Wiedereingliederungs- und Elternteilzeit** an die Rückkehr aus einer
|
||||
-- Abwesenheit. Beide beginnen typischerweise genau dann, wenn die
|
||||
-- Abwesenheit endet, und beide sind der Grund dafür, dass jemand mit
|
||||
-- weniger Stunden zurückkommt.
|
||||
-- * **Bildungs- und Pflegeteilzeit** an die Stundenänderung unter „Daten
|
||||
-- ändern", neben der gewöhnlichen vertraglichen Änderung.
|
||||
--
|
||||
-- Der Grund wird **mit der Änderung** festgehalten, nicht als Zustand an der
|
||||
-- Person. Ein Zustand müsste gepflegt werden — es gibt aber niemanden, der
|
||||
-- nachträgt, wann eine Bildungsteilzeit endet, und ein Feld, das schleichend
|
||||
-- veraltet, ist schlimmer als keines. In der Historie steht der Grund dort,
|
||||
-- wo auch der geänderte Wert steht, und bleibt dort dauerhaft lesbar.
|
||||
--
|
||||
-- **Die Prüfbedingung auf absence_type bleibt unverändert.** Drei Personen
|
||||
-- tragen die Werte gerade (Pflegeteilzeit, Wiedereingliederungsteilzeit); sie
|
||||
-- zu verbieten hiesse, bestehende Zeilen ungültig zu machen. Aus der Auswahl
|
||||
-- verschwinden sie, die Geschichte bleibt lesbar.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.record_karenz_return(payload jsonb)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $function$
|
||||
declare
|
||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||
v_return_date date := (payload->>'return_date')::date;
|
||||
v_name text;
|
||||
v_employment_type employment_type;
|
||||
v_weekly_hours numeric;
|
||||
v_karenz_start date;
|
||||
v_absence_type text;
|
||||
-- Warum jemand mit weniger Stunden zurückkommt: Wiedereingliederungs-
|
||||
-- oder Elternteilzeit. Nur bedeutsam, wenn überhaupt reduziert wird.
|
||||
v_grund text := nullif(payload->>'reduction_reason', '');
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select first_name || ' ' || last_name, karenz_start_date, absence_type
|
||||
into v_name, v_karenz_start, v_absence_type
|
||||
from employees where id = v_employee_id;
|
||||
|
||||
if v_karenz_start is not null and v_return_date <= v_karenz_start then
|
||||
raise exception 'Das Rückkehrdatum muss nach dem Beginn der Langzeitabwesenheit (%) liegen.', v_karenz_start;
|
||||
end if;
|
||||
|
||||
if payload->>'employment_mode' = 'Vollzeit' then
|
||||
v_employment_type := 'Vollzeit'; v_weekly_hours := 38.5;
|
||||
elsif payload->>'employment_mode' = 'Teilzeit' then
|
||||
v_employment_type := 'Teilzeit'; v_weekly_hours := (payload->>'weekly_hours')::numeric;
|
||||
end if;
|
||||
|
||||
if v_return_date <= current_date then
|
||||
-- Keine Manager-Nachführung mehr nötig: wer aus der Abwesenheit
|
||||
-- zurückkehrt, ist wieder anwesend, und die abgeleitete Berichtslinie
|
||||
-- fällt automatisch von der Vertretung auf ihn zurück.
|
||||
update employees set
|
||||
status = 'Aktiv',
|
||||
karenz_return_date = null,
|
||||
karenz_start_date = null,
|
||||
absence_type = null,
|
||||
employment_type = coalesce(v_employment_type, employment_type),
|
||||
weekly_hours = coalesce(v_weekly_hours, weekly_hours)
|
||||
where id = v_employee_id;
|
||||
else
|
||||
update employees set karenz_return_date = v_return_date where id = v_employee_id;
|
||||
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
|
||||
values (v_employee_id, 'karenz_return', v_return_date,
|
||||
jsonb_build_object('employment_type', v_employment_type, 'weekly_hours', v_weekly_hours));
|
||||
end if;
|
||||
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_employee_id, v_return_date, 'Rückkehr',
|
||||
'Rückkehr aus ' || coalesce(v_absence_type, 'Langzeitabwesenheit') || ' am ' || v_return_date
|
||||
|| case when payload->>'employment_mode' = 'Teilzeit'
|
||||
then ', reduziert auf ' || (payload->>'weekly_hours') || ' h'
|
||||
|| coalesce(' (' || v_grund || ')', '')
|
||||
else '' end);
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (app_current_user_id(), current_actor_name(), 'Rückkehr', v_name, v_employee_id, 'Rückkehr am ' || v_return_date || coalesce(' — ' || v_grund, ''));
|
||||
end;
|
||||
$function$;
|
||||
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.change_employee_data(payload jsonb)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $function$
|
||||
declare
|
||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
|
||||
v_old employees%rowtype;
|
||||
v_name text;
|
||||
v_person_changes jsonb := '[]'::jsonb;
|
||||
v_contract_changes jsonb := '[]'::jsonb;
|
||||
v_person jsonb := payload->'person';
|
||||
v_contract jsonb := payload->'contract';
|
||||
v_role jsonb := payload->'role';
|
||||
v_immediate boolean;
|
||||
v_new_work_days text[];
|
||||
v_new_title_prefix text[];
|
||||
v_new_title_suffix text[];
|
||||
-- Warum sich die Stunden ändern. Kein Feld an der Person, sondern eine
|
||||
-- Eigenschaft *dieser* Änderung — es gibt niemanden, der später
|
||||
-- nachträgt, wann eine Bildungsteilzeit endet. In der Historie steht
|
||||
-- der Grund damit dort, wo auch der Wert steht.
|
||||
v_stunden_grund text := nullif(payload->>'hours_reason', '');
|
||||
v_pending_id uuid;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select * into v_old from employees where id = v_employee_id;
|
||||
v_name := v_old.first_name || ' ' || v_old.last_name;
|
||||
v_immediate := v_effective_date <= current_date;
|
||||
|
||||
-- Der `?`-Test bleibt: ein fehlender Schlüssel heisst „nicht übermittelt",
|
||||
-- nicht „geleert". Ohne ihn würde jedes nicht gesendete Feld als Änderung
|
||||
-- auf null gemeldet.
|
||||
if v_person ? 'first_name' then v_person_changes := app_aenderung(v_person_changes, 'Vorname', v_old.first_name, v_person->>'first_name'); end if;
|
||||
if v_person ? 'last_name' then v_person_changes := app_aenderung(v_person_changes, 'Nachname', v_old.last_name, v_person->>'last_name'); end if;
|
||||
if v_person ? 'gender' then v_person_changes := app_aenderung(v_person_changes, 'Geschlecht', v_old.gender::text, v_person->>'gender'); end if;
|
||||
-- Datumswerte über ::date::text vergleichen, damit „2026-8-3" und
|
||||
-- „2026-08-03" nicht als Änderung gelten.
|
||||
if v_person ? 'birth_date' then v_person_changes := app_aenderung(v_person_changes, 'Geburtsdatum', v_old.birth_date::text, (nullif(v_person->>'birth_date','')::date)::text); end if;
|
||||
if v_person ? 'sv_nummer' then v_person_changes := app_aenderung(v_person_changes, 'SV-Nummer', v_old.sv_nummer, v_person->>'sv_nummer'); end if;
|
||||
if v_person ? 'nationality' then v_person_changes := app_aenderung(v_person_changes, 'Staatsbürgerschaft', v_old.nationality, v_person->>'nationality'); end if;
|
||||
if v_person ? 'address' then v_person_changes := app_aenderung(v_person_changes, 'Adresse', v_old.address, v_person->>'address'); end if;
|
||||
if v_person ? 'postal_code' then v_person_changes := app_aenderung(v_person_changes, 'Postleitzahl', v_old.postal_code, v_person->>'postal_code'); end if;
|
||||
if v_person ? 'city' then v_person_changes := app_aenderung(v_person_changes, 'Ort', v_old.city, v_person->>'city'); end if;
|
||||
if v_person ? 'address_country' then v_person_changes := app_aenderung(v_person_changes, 'Land', v_old.address_country, v_person->>'address_country'); end if;
|
||||
if v_person ? 'email' then v_person_changes := app_aenderung(v_person_changes, 'E-Mail', v_old.email, v_person->>'email'); end if;
|
||||
if v_person ? 'phone' then v_person_changes := app_aenderung(v_person_changes, 'Telefon', v_old.phone, v_person->>'phone'); end if;
|
||||
if v_person ? 'emergency_contact_name' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt', v_old.emergency_contact_name, v_person->>'emergency_contact_name'); end if;
|
||||
if v_person ? 'emergency_contact_phone' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Telefon', v_old.emergency_contact_phone, v_person->>'emergency_contact_phone'); end if;
|
||||
if v_person ? 'emergency_contact_relation' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Verhältnis', v_old.emergency_contact_relation, v_person->>'emergency_contact_relation'); end if;
|
||||
|
||||
if v_person ? 'title_prefix' then
|
||||
v_new_title_prefix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_prefix') elem), '{}');
|
||||
v_person_changes := app_aenderung(v_person_changes, 'Titel (vorangestellt)',
|
||||
array_to_string(v_old.title_prefix, ', '), array_to_string(v_new_title_prefix, ', '));
|
||||
end if;
|
||||
if v_person ? 'title_suffix' then
|
||||
v_new_title_suffix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_suffix') elem), '{}');
|
||||
v_person_changes := app_aenderung(v_person_changes, 'Titel (nachgestellt)',
|
||||
array_to_string(v_old.title_suffix, ', '), array_to_string(v_new_title_suffix, ', '));
|
||||
end if;
|
||||
|
||||
if v_contract ? 'employment_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Beschäftigungsausmaß', v_old.employment_type::text, v_contract->>'employment_type'); end if;
|
||||
-- Über ::numeric::text, damit „38.50" und „38.5" gleich zählen.
|
||||
if v_contract ? 'weekly_hours' then v_contract_changes := app_aenderung(v_contract_changes, 'Wochenstunden', v_old.weekly_hours::text, (nullif(v_contract->>'weekly_hours','')::numeric)::text); end if;
|
||||
if v_contract ? 'contract_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Vertragsart', v_old.contract_type::text, v_contract->>'contract_type'); end if;
|
||||
if v_contract ? 'contract_end_date' then v_contract_changes := app_aenderung(v_contract_changes, 'Befristet bis', v_old.contract_end_date::text, (nullif(v_contract->>'contract_end_date','')::date)::text); end if;
|
||||
|
||||
if v_role ? 'worker_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Angestellte:r/Arbeiter:in', v_old.worker_type::text, v_role->>'worker_type'); end if;
|
||||
if v_role ? 'collective_agreement' then v_contract_changes := app_aenderung(v_contract_changes, 'Kollektivvertrag', v_old.collective_agreement::text, v_role->>'collective_agreement'); end if;
|
||||
if v_role ? 'work_days' then
|
||||
v_new_work_days := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_role->'work_days') elem), '{}');
|
||||
v_contract_changes := app_aenderung(v_contract_changes, 'Arbeitstage',
|
||||
array_to_string(v_old.work_days, ', '), array_to_string(v_new_work_days, ', '));
|
||||
end if;
|
||||
if v_role ? 'is_betriebsrat' then v_contract_changes := app_aenderung(v_contract_changes, 'Betriebsrat', v_old.is_betriebsrat::text, v_role->>'is_betriebsrat'); end if;
|
||||
if v_role ? 'has_dienstwagen' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen', v_old.has_dienstwagen::text, v_role->>'has_dienstwagen'); end if;
|
||||
if v_role ? 'is_laterale_fuehrung' then v_contract_changes := app_aenderung(v_contract_changes, 'Laterale Führung', v_old.is_laterale_fuehrung::text, v_role->>'is_laterale_fuehrung'); end if;
|
||||
if v_role ? 'is_c_level' then v_contract_changes := app_aenderung(v_contract_changes, 'C-Level', v_old.is_c_level::text, v_role->>'is_c_level'); end if;
|
||||
if v_role ? 'has_kuendigungsschutz' then v_contract_changes := app_aenderung(v_contract_changes, 'Besonderer Kündigungsschutz', v_old.has_kuendigungsschutz::text, v_role->>'has_kuendigungsschutz'); end if;
|
||||
if v_role ? 'kuendigungsschutz_bis' then v_contract_changes := app_aenderung(v_contract_changes, 'Kündigungsschutz bis', v_old.kuendigungsschutz_bis::text, (nullif(v_role->>'kuendigungsschutz_bis','')::date)::text); end if;
|
||||
if v_role ? 'dienstwagen_art' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen Antrieb', v_old.dienstwagen_art, nullif(v_role->>'dienstwagen_art', '')); end if;
|
||||
|
||||
if v_immediate then
|
||||
update employees set
|
||||
first_name = coalesce(v_person->>'first_name', first_name),
|
||||
last_name = coalesce(v_person->>'last_name', last_name),
|
||||
gender = coalesce((v_person->>'gender')::gender_type, gender),
|
||||
birth_date = coalesce((v_person->>'birth_date')::date, birth_date),
|
||||
sv_nummer = coalesce(v_person->>'sv_nummer', sv_nummer),
|
||||
nationality = coalesce(v_person->>'nationality', nationality),
|
||||
address = coalesce(v_person->>'address', address),
|
||||
postal_code = coalesce(v_person->>'postal_code', postal_code),
|
||||
city = coalesce(v_person->>'city', city),
|
||||
address_country = coalesce(v_person->>'address_country', address_country),
|
||||
email = coalesce(v_person->>'email', email),
|
||||
phone = coalesce(v_person->>'phone', phone),
|
||||
emergency_contact_name = case when v_person ? 'emergency_contact_name' then nullif(v_person->>'emergency_contact_name', '') else emergency_contact_name end,
|
||||
emergency_contact_phone = case when v_person ? 'emergency_contact_phone' then nullif(v_person->>'emergency_contact_phone', '') else emergency_contact_phone end,
|
||||
emergency_contact_relation = case when v_person ? 'emergency_contact_relation' then nullif(v_person->>'emergency_contact_relation', '') else emergency_contact_relation end,
|
||||
title_prefix = case when v_person ? 'title_prefix' then v_new_title_prefix else title_prefix end,
|
||||
title_suffix = case when v_person ? 'title_suffix' then v_new_title_suffix else title_suffix end,
|
||||
employment_type = coalesce((v_contract->>'employment_type')::employment_type, employment_type),
|
||||
weekly_hours = coalesce((v_contract->>'weekly_hours')::numeric, weekly_hours),
|
||||
contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),
|
||||
contract_end_date = case when v_contract ? 'contract_end_date' then nullif(v_contract->>'contract_end_date','')::date else contract_end_date end,
|
||||
worker_type = coalesce((v_role->>'worker_type')::worker_type, worker_type),
|
||||
collective_agreement = coalesce((v_role->>'collective_agreement')::collective_agreement, collective_agreement),
|
||||
work_days = case when v_role ? 'work_days' then v_new_work_days else work_days end,
|
||||
is_betriebsrat = coalesce((v_role->>'is_betriebsrat')::boolean, is_betriebsrat),
|
||||
has_dienstwagen = coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen),
|
||||
is_laterale_fuehrung = coalesce((v_role->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
|
||||
is_c_level = coalesce((v_role->>'is_c_level')::boolean, is_c_level),
|
||||
has_kuendigungsschutz = coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz),
|
||||
-- Fällt der Schutz weg, fällt das Datum mit. Andernfalls bliebe ein
|
||||
-- Enddatum ohne Schutz stehen — die Bedingung verbietet das, und der
|
||||
-- Vorgang schlüge fehl, statt das Offensichtliche zu tun.
|
||||
kuendigungsschutz_bis = case
|
||||
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
|
||||
case when v_role ? 'kuendigungsschutz_bis'
|
||||
then nullif(v_role->>'kuendigungsschutz_bis','')::date
|
||||
else kuendigungsschutz_bis end
|
||||
else null
|
||||
end,
|
||||
dienstwagen_art = case
|
||||
when coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen) then
|
||||
coalesce(nullif(v_role->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner')
|
||||
else null
|
||||
end
|
||||
where id = v_employee_id;
|
||||
elsif jsonb_array_length(v_person_changes) > 0 or jsonb_array_length(v_contract_changes) > 0 then
|
||||
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
|
||||
values (v_employee_id, 'contract_change', v_effective_date, payload)
|
||||
returning id into v_pending_id;
|
||||
end if;
|
||||
|
||||
if jsonb_array_length(v_person_changes) > 0 then
|
||||
insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
|
||||
values (v_employee_id, v_effective_date, 'Stammdatenänderung',
|
||||
'Geänderte Felder: ' || app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes, v_pending_id);
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
|
||||
values (app_current_user_id(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id,
|
||||
app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes);
|
||||
end if;
|
||||
|
||||
if jsonb_array_length(v_contract_changes) > 0 then
|
||||
insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
|
||||
values (v_employee_id, v_effective_date, 'Vertragsänderung',
|
||||
'Geänderte Felder: ' || app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date
|
||||
|| case when v_stunden_grund is not null and v_contract ? 'weekly_hours'
|
||||
then ' — ' || v_stunden_grund else '' end,
|
||||
v_contract_changes, v_pending_id);
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
|
||||
values (app_current_user_id(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id,
|
||||
app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes);
|
||||
end if;
|
||||
end;
|
||||
$function$;
|
||||
|
||||
|
||||
-- Selbstprüfung.
|
||||
do $$
|
||||
declare
|
||||
v_ret text := pg_get_functiondef('public.record_karenz_return(jsonb)'::regprocedure);
|
||||
v_chg text := pg_get_functiondef('public.change_employee_data(jsonb)'::regprocedure);
|
||||
begin
|
||||
if v_ret not like '%reduction_reason%' then
|
||||
raise exception 'record_karenz_return nimmt den Grund nicht entgegen';
|
||||
end if;
|
||||
if v_chg not like '%hours_reason%' then
|
||||
raise exception 'change_employee_data nimmt den Grund der Stundenänderung nicht entgegen';
|
||||
end if;
|
||||
end
|
||||
$$;
|
||||
350
supabase/migrations/20260814160000_teilzeit_art.sql
Normal file
350
supabase/migrations/20260814160000_teilzeit_art.sql
Normal file
@@ -0,0 +1,350 @@
|
||||
-- Die Teilzeitvariante als Zustand, nicht nur als Notiz.
|
||||
--
|
||||
-- Beim letzten Schritt sind die vier Teilzeiten aus der Abwesenheitsliste
|
||||
-- gewandert und wurden mit der Änderung festgehalten — im Beschreibungstext
|
||||
-- der Historie. Damit liess sich nachlesen, *dass* jemand in Bildungsteilzeit
|
||||
-- ging, aber nicht auswerten, wer gerade in einer ist, und am Profil stand es
|
||||
-- nirgends.
|
||||
--
|
||||
-- Also ein richtiges Feld: teilzeit_art, dazu ein freiwilliges Enddatum.
|
||||
--
|
||||
-- Der Einwand von damals — ein Zustand veraltet, weil niemand nachträgt, wann
|
||||
-- eine Bildungsteilzeit endet — bleibt richtig und ist der Grund für
|
||||
-- teilzeit_bis. Mit einem Enddatum kann eine Auswertung selbst entscheiden,
|
||||
-- was noch läuft, statt sich auf gepflegte Daten zu verlassen. Bleibt das
|
||||
-- Datum leer, heisst das „Ende offen", und das ist eine ehrliche Aussage.
|
||||
--
|
||||
-- Geführt wird das Feld über den gewöhnlichen Weg der Änderungen: es steht in
|
||||
-- app_feld_karte, taucht in der Historie als Feld mit Vorher/Nachher auf und
|
||||
-- lässt sich dort berichtigen wie jedes andere. Der Anhang am
|
||||
-- Beschreibungstext aus dem letzten Schritt entfällt dafür — zweimal
|
||||
-- dasselbe zu schreiben lädt nur dazu ein, dass die zwei Fassungen
|
||||
-- auseinanderlaufen.
|
||||
|
||||
alter table employees
|
||||
add column if not exists teilzeit_art text,
|
||||
add column if not exists teilzeit_bis date;
|
||||
|
||||
comment on column employees.teilzeit_art is
|
||||
'Bildungs-, Eltern-, Pflege- oder Wiedereingliederungsteilzeit; null bei einer gewöhnlichen vertraglichen Stundenregelung. Keine Abwesenheit — die Person arbeitet, nur kürzer.';
|
||||
comment on column employees.teilzeit_bis is
|
||||
'Ende der Teilzeit, falls bekannt. Freiwillig; leer heisst „Ende offen". Erlaubt Auswertungen darüber, was noch läuft, ohne auf nachgepflegte Daten angewiesen zu sein.';
|
||||
|
||||
alter table employees drop constraint if exists chk_teilzeit_art;
|
||||
alter table employees add constraint chk_teilzeit_art
|
||||
check (teilzeit_art is null or teilzeit_art in
|
||||
('Bildungsteilzeit', 'Elternteilzeit', 'Pflegeteilzeit', 'Wiedereingliederungsteilzeit'));
|
||||
|
||||
alter table employees drop constraint if exists chk_teilzeit_bis;
|
||||
alter table employees add constraint chk_teilzeit_bis
|
||||
check (teilzeit_bis is null or teilzeit_art is not null);
|
||||
|
||||
comment on constraint chk_teilzeit_bis on employees is
|
||||
'Ein Enddatum ohne Variante wäre ein Rest ohne Bezug. Umgekehrt ist eine Variante ohne Enddatum ausdrücklich erlaubt — nicht jede Teilzeit hat ein bekanntes Ende.';
|
||||
|
||||
create index if not exists idx_employees_teilzeit on employees (teilzeit_art) where teilzeit_art is not null;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.app_feld_karte()
|
||||
RETURNS jsonb
|
||||
LANGUAGE sql
|
||||
IMMUTABLE
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $function$
|
||||
select jsonb_build_object(
|
||||
'Vorname', jsonb_build_array('first_name', 'text', 'person'),
|
||||
'Nachname', jsonb_build_array('last_name', 'text', 'person'),
|
||||
'Geschlecht', jsonb_build_array('gender', 'gender_type', 'person'),
|
||||
'Geburtsdatum', jsonb_build_array('birth_date', 'date', 'person'),
|
||||
'SV-Nummer', jsonb_build_array('sv_nummer', 'text', 'person'),
|
||||
'Staatsbürgerschaft', jsonb_build_array('nationality', 'text', 'person'),
|
||||
'Adresse', jsonb_build_array('address', 'text', 'person'),
|
||||
'Postleitzahl', jsonb_build_array('postal_code', 'text', 'person'),
|
||||
'Ort', jsonb_build_array('city', 'text', 'person'),
|
||||
'Land', jsonb_build_array('address_country', 'text', 'person'),
|
||||
'E-Mail', jsonb_build_array('email', 'text', 'person'),
|
||||
'Telefon', jsonb_build_array('phone', 'text', 'person'),
|
||||
'Notfallkontakt', jsonb_build_array('emergency_contact_name', 'text', 'person'),
|
||||
'Notfallkontakt Telefon', jsonb_build_array('emergency_contact_phone', 'text', 'person'),
|
||||
'Notfallkontakt Verhältnis', jsonb_build_array('emergency_contact_relation', 'text', 'person'),
|
||||
'Titel (vorangestellt)', jsonb_build_array('title_prefix', 'liste', 'person'),
|
||||
'Titel (nachgestellt)', jsonb_build_array('title_suffix', 'liste', 'person'),
|
||||
'Beschäftigungsausmaß', jsonb_build_array('employment_type', 'employment_type', 'contract'),
|
||||
'Wochenstunden', jsonb_build_array('weekly_hours', 'numeric', 'contract'),
|
||||
'Vertragsart', jsonb_build_array('contract_type', 'contract_type', 'contract'),
|
||||
'Befristet bis', jsonb_build_array('contract_end_date', 'date', 'contract'),
|
||||
'Angestellte:r/Arbeiter:in', jsonb_build_array('worker_type', 'worker_type', 'role'),
|
||||
'Kollektivvertrag', jsonb_build_array('collective_agreement', 'collective_agreement', 'role'),
|
||||
'Arbeitstage', jsonb_build_array('work_days', 'liste', 'role'),
|
||||
'Betriebsrat', jsonb_build_array('is_betriebsrat', 'boolean', 'role'),
|
||||
'Dienstwagen', jsonb_build_array('has_dienstwagen', 'boolean', 'role'),
|
||||
'Laterale Führung', jsonb_build_array('is_laterale_fuehrung', 'boolean', 'role'),
|
||||
'C-Level', jsonb_build_array('is_c_level', 'boolean', 'role'),
|
||||
'Dienstwagen Antrieb', jsonb_build_array('dienstwagen_art', 'text', 'role'),
|
||||
'Besonderer Kündigungsschutz', jsonb_build_array('has_kuendigungsschutz', 'boolean', 'role'),
|
||||
'Kündigungsschutz bis', jsonb_build_array('kuendigungsschutz_bis', 'date', 'role'),
|
||||
'Teilzeitvariante', jsonb_build_array('teilzeit_art', 'text', 'role'),
|
||||
'Teilzeit bis', jsonb_build_array('teilzeit_bis', 'date', 'role')
|
||||
);
|
||||
$function$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.change_employee_data(payload jsonb)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $function$
|
||||
declare
|
||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
|
||||
v_old employees%rowtype;
|
||||
v_name text;
|
||||
v_person_changes jsonb := '[]'::jsonb;
|
||||
v_contract_changes jsonb := '[]'::jsonb;
|
||||
v_person jsonb := payload->'person';
|
||||
v_contract jsonb := payload->'contract';
|
||||
v_role jsonb := payload->'role';
|
||||
v_immediate boolean;
|
||||
v_new_work_days text[];
|
||||
v_new_title_prefix text[];
|
||||
v_new_title_suffix text[];
|
||||
-- Die Teilzeitvariante ist jetzt ein Feld an der Person (teilzeit_art)
|
||||
-- und läuft über die gewöhnliche Änderungsliste. Der frühere Anhang am
|
||||
-- Beschreibungstext ist damit weg — zweimal dasselbe zu schreiben lädt
|
||||
-- nur dazu ein, dass die zwei Fassungen auseinanderlaufen.
|
||||
v_pending_id uuid;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select * into v_old from employees where id = v_employee_id;
|
||||
v_name := v_old.first_name || ' ' || v_old.last_name;
|
||||
v_immediate := v_effective_date <= current_date;
|
||||
|
||||
-- Der `?`-Test bleibt: ein fehlender Schlüssel heisst „nicht übermittelt",
|
||||
-- nicht „geleert". Ohne ihn würde jedes nicht gesendete Feld als Änderung
|
||||
-- auf null gemeldet.
|
||||
if v_person ? 'first_name' then v_person_changes := app_aenderung(v_person_changes, 'Vorname', v_old.first_name, v_person->>'first_name'); end if;
|
||||
if v_person ? 'last_name' then v_person_changes := app_aenderung(v_person_changes, 'Nachname', v_old.last_name, v_person->>'last_name'); end if;
|
||||
if v_person ? 'gender' then v_person_changes := app_aenderung(v_person_changes, 'Geschlecht', v_old.gender::text, v_person->>'gender'); end if;
|
||||
-- Datumswerte über ::date::text vergleichen, damit „2026-8-3" und
|
||||
-- „2026-08-03" nicht als Änderung gelten.
|
||||
if v_person ? 'birth_date' then v_person_changes := app_aenderung(v_person_changes, 'Geburtsdatum', v_old.birth_date::text, (nullif(v_person->>'birth_date','')::date)::text); end if;
|
||||
if v_person ? 'sv_nummer' then v_person_changes := app_aenderung(v_person_changes, 'SV-Nummer', v_old.sv_nummer, v_person->>'sv_nummer'); end if;
|
||||
if v_person ? 'nationality' then v_person_changes := app_aenderung(v_person_changes, 'Staatsbürgerschaft', v_old.nationality, v_person->>'nationality'); end if;
|
||||
if v_person ? 'address' then v_person_changes := app_aenderung(v_person_changes, 'Adresse', v_old.address, v_person->>'address'); end if;
|
||||
if v_person ? 'postal_code' then v_person_changes := app_aenderung(v_person_changes, 'Postleitzahl', v_old.postal_code, v_person->>'postal_code'); end if;
|
||||
if v_person ? 'city' then v_person_changes := app_aenderung(v_person_changes, 'Ort', v_old.city, v_person->>'city'); end if;
|
||||
if v_person ? 'address_country' then v_person_changes := app_aenderung(v_person_changes, 'Land', v_old.address_country, v_person->>'address_country'); end if;
|
||||
if v_person ? 'email' then v_person_changes := app_aenderung(v_person_changes, 'E-Mail', v_old.email, v_person->>'email'); end if;
|
||||
if v_person ? 'phone' then v_person_changes := app_aenderung(v_person_changes, 'Telefon', v_old.phone, v_person->>'phone'); end if;
|
||||
if v_person ? 'emergency_contact_name' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt', v_old.emergency_contact_name, v_person->>'emergency_contact_name'); end if;
|
||||
if v_person ? 'emergency_contact_phone' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Telefon', v_old.emergency_contact_phone, v_person->>'emergency_contact_phone'); end if;
|
||||
if v_person ? 'emergency_contact_relation' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Verhältnis', v_old.emergency_contact_relation, v_person->>'emergency_contact_relation'); end if;
|
||||
|
||||
if v_person ? 'title_prefix' then
|
||||
v_new_title_prefix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_prefix') elem), '{}');
|
||||
v_person_changes := app_aenderung(v_person_changes, 'Titel (vorangestellt)',
|
||||
array_to_string(v_old.title_prefix, ', '), array_to_string(v_new_title_prefix, ', '));
|
||||
end if;
|
||||
if v_person ? 'title_suffix' then
|
||||
v_new_title_suffix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_suffix') elem), '{}');
|
||||
v_person_changes := app_aenderung(v_person_changes, 'Titel (nachgestellt)',
|
||||
array_to_string(v_old.title_suffix, ', '), array_to_string(v_new_title_suffix, ', '));
|
||||
end if;
|
||||
|
||||
if v_contract ? 'employment_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Beschäftigungsausmaß', v_old.employment_type::text, v_contract->>'employment_type'); end if;
|
||||
-- Über ::numeric::text, damit „38.50" und „38.5" gleich zählen.
|
||||
if v_contract ? 'weekly_hours' then v_contract_changes := app_aenderung(v_contract_changes, 'Wochenstunden', v_old.weekly_hours::text, (nullif(v_contract->>'weekly_hours','')::numeric)::text); end if;
|
||||
if v_contract ? 'contract_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Vertragsart', v_old.contract_type::text, v_contract->>'contract_type'); end if;
|
||||
if v_contract ? 'contract_end_date' then v_contract_changes := app_aenderung(v_contract_changes, 'Befristet bis', v_old.contract_end_date::text, (nullif(v_contract->>'contract_end_date','')::date)::text); end if;
|
||||
|
||||
if v_role ? 'worker_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Angestellte:r/Arbeiter:in', v_old.worker_type::text, v_role->>'worker_type'); end if;
|
||||
if v_role ? 'collective_agreement' then v_contract_changes := app_aenderung(v_contract_changes, 'Kollektivvertrag', v_old.collective_agreement::text, v_role->>'collective_agreement'); end if;
|
||||
if v_role ? 'work_days' then
|
||||
v_new_work_days := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_role->'work_days') elem), '{}');
|
||||
v_contract_changes := app_aenderung(v_contract_changes, 'Arbeitstage',
|
||||
array_to_string(v_old.work_days, ', '), array_to_string(v_new_work_days, ', '));
|
||||
end if;
|
||||
if v_role ? 'is_betriebsrat' then v_contract_changes := app_aenderung(v_contract_changes, 'Betriebsrat', v_old.is_betriebsrat::text, v_role->>'is_betriebsrat'); end if;
|
||||
if v_role ? 'has_dienstwagen' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen', v_old.has_dienstwagen::text, v_role->>'has_dienstwagen'); end if;
|
||||
if v_role ? 'is_laterale_fuehrung' then v_contract_changes := app_aenderung(v_contract_changes, 'Laterale Führung', v_old.is_laterale_fuehrung::text, v_role->>'is_laterale_fuehrung'); end if;
|
||||
if v_role ? 'is_c_level' then v_contract_changes := app_aenderung(v_contract_changes, 'C-Level', v_old.is_c_level::text, v_role->>'is_c_level'); end if;
|
||||
if v_role ? 'has_kuendigungsschutz' then v_contract_changes := app_aenderung(v_contract_changes, 'Besonderer Kündigungsschutz', v_old.has_kuendigungsschutz::text, v_role->>'has_kuendigungsschutz'); end if;
|
||||
if v_role ? 'kuendigungsschutz_bis' then v_contract_changes := app_aenderung(v_contract_changes, 'Kündigungsschutz bis', v_old.kuendigungsschutz_bis::text, (nullif(v_role->>'kuendigungsschutz_bis','')::date)::text); end if;
|
||||
if v_role ? 'teilzeit_art' then v_contract_changes := app_aenderung(v_contract_changes, 'Teilzeitvariante', v_old.teilzeit_art, nullif(v_role->>'teilzeit_art', '')); end if;
|
||||
if v_role ? 'teilzeit_bis' then v_contract_changes := app_aenderung(v_contract_changes, 'Teilzeit bis', v_old.teilzeit_bis::text, (nullif(v_role->>'teilzeit_bis','')::date)::text); end if;
|
||||
if v_role ? 'dienstwagen_art' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen Antrieb', v_old.dienstwagen_art, nullif(v_role->>'dienstwagen_art', '')); end if;
|
||||
|
||||
if v_immediate then
|
||||
update employees set
|
||||
first_name = coalesce(v_person->>'first_name', first_name),
|
||||
last_name = coalesce(v_person->>'last_name', last_name),
|
||||
gender = coalesce((v_person->>'gender')::gender_type, gender),
|
||||
birth_date = coalesce((v_person->>'birth_date')::date, birth_date),
|
||||
sv_nummer = coalesce(v_person->>'sv_nummer', sv_nummer),
|
||||
nationality = coalesce(v_person->>'nationality', nationality),
|
||||
address = coalesce(v_person->>'address', address),
|
||||
postal_code = coalesce(v_person->>'postal_code', postal_code),
|
||||
city = coalesce(v_person->>'city', city),
|
||||
address_country = coalesce(v_person->>'address_country', address_country),
|
||||
email = coalesce(v_person->>'email', email),
|
||||
phone = coalesce(v_person->>'phone', phone),
|
||||
emergency_contact_name = case when v_person ? 'emergency_contact_name' then nullif(v_person->>'emergency_contact_name', '') else emergency_contact_name end,
|
||||
emergency_contact_phone = case when v_person ? 'emergency_contact_phone' then nullif(v_person->>'emergency_contact_phone', '') else emergency_contact_phone end,
|
||||
emergency_contact_relation = case when v_person ? 'emergency_contact_relation' then nullif(v_person->>'emergency_contact_relation', '') else emergency_contact_relation end,
|
||||
title_prefix = case when v_person ? 'title_prefix' then v_new_title_prefix else title_prefix end,
|
||||
title_suffix = case when v_person ? 'title_suffix' then v_new_title_suffix else title_suffix end,
|
||||
employment_type = coalesce((v_contract->>'employment_type')::employment_type, employment_type),
|
||||
weekly_hours = coalesce((v_contract->>'weekly_hours')::numeric, weekly_hours),
|
||||
contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),
|
||||
contract_end_date = case when v_contract ? 'contract_end_date' then nullif(v_contract->>'contract_end_date','')::date else contract_end_date end,
|
||||
worker_type = coalesce((v_role->>'worker_type')::worker_type, worker_type),
|
||||
collective_agreement = coalesce((v_role->>'collective_agreement')::collective_agreement, collective_agreement),
|
||||
work_days = case when v_role ? 'work_days' then v_new_work_days else work_days end,
|
||||
is_betriebsrat = coalesce((v_role->>'is_betriebsrat')::boolean, is_betriebsrat),
|
||||
has_dienstwagen = coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen),
|
||||
is_laterale_fuehrung = coalesce((v_role->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
|
||||
is_c_level = coalesce((v_role->>'is_c_level')::boolean, is_c_level),
|
||||
has_kuendigungsschutz = coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz),
|
||||
-- Fällt der Schutz weg, fällt das Datum mit. Andernfalls bliebe ein
|
||||
-- Enddatum ohne Schutz stehen — die Bedingung verbietet das, und der
|
||||
-- Vorgang schlüge fehl, statt das Offensichtliche zu tun.
|
||||
teilzeit_art = case when v_role ? 'teilzeit_art' then nullif(v_role->>'teilzeit_art', '') else teilzeit_art end,
|
||||
-- Ohne Variante kein Enddatum: chk_teilzeit_bis verlangt es so, und
|
||||
-- ein Datum ohne Sache wäre ein Rest, den niemand mehr deutet.
|
||||
teilzeit_bis = case
|
||||
when coalesce(nullif(v_role->>'teilzeit_art', ''), case when v_role ? 'teilzeit_art' then null else teilzeit_art end) is null then null
|
||||
when v_role ? 'teilzeit_bis' then nullif(v_role->>'teilzeit_bis','')::date
|
||||
else teilzeit_bis
|
||||
end,
|
||||
kuendigungsschutz_bis = case
|
||||
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
|
||||
case when v_role ? 'kuendigungsschutz_bis'
|
||||
then nullif(v_role->>'kuendigungsschutz_bis','')::date
|
||||
else kuendigungsschutz_bis end
|
||||
else null
|
||||
end,
|
||||
dienstwagen_art = case
|
||||
when coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen) then
|
||||
coalesce(nullif(v_role->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner')
|
||||
else null
|
||||
end
|
||||
where id = v_employee_id;
|
||||
elsif jsonb_array_length(v_person_changes) > 0 or jsonb_array_length(v_contract_changes) > 0 then
|
||||
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
|
||||
values (v_employee_id, 'contract_change', v_effective_date, payload)
|
||||
returning id into v_pending_id;
|
||||
end if;
|
||||
|
||||
if jsonb_array_length(v_person_changes) > 0 then
|
||||
insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
|
||||
values (v_employee_id, v_effective_date, 'Stammdatenänderung',
|
||||
'Geänderte Felder: ' || app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes, v_pending_id);
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
|
||||
values (app_current_user_id(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id,
|
||||
app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes);
|
||||
end if;
|
||||
|
||||
if jsonb_array_length(v_contract_changes) > 0 then
|
||||
insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
|
||||
values (v_employee_id, v_effective_date, 'Vertragsänderung',
|
||||
'Geänderte Felder: ' || app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes, v_pending_id);
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
|
||||
values (app_current_user_id(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id,
|
||||
app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes);
|
||||
end if;
|
||||
end;
|
||||
$function$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.record_karenz_return(payload jsonb)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $function$
|
||||
declare
|
||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||
v_return_date date := (payload->>'return_date')::date;
|
||||
v_name text;
|
||||
v_employment_type employment_type;
|
||||
v_weekly_hours numeric;
|
||||
v_karenz_start date;
|
||||
v_absence_type text;
|
||||
-- Warum jemand mit weniger Stunden zurückkommt: Wiedereingliederungs-
|
||||
-- oder Elternteilzeit. Nur bedeutsam, wenn überhaupt reduziert wird.
|
||||
v_grund text := nullif(payload->>'reduction_reason', '');
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select first_name || ' ' || last_name, karenz_start_date, absence_type
|
||||
into v_name, v_karenz_start, v_absence_type
|
||||
from employees where id = v_employee_id;
|
||||
|
||||
if v_karenz_start is not null and v_return_date <= v_karenz_start then
|
||||
raise exception 'Das Rückkehrdatum muss nach dem Beginn der Langzeitabwesenheit (%) liegen.', v_karenz_start;
|
||||
end if;
|
||||
|
||||
if payload->>'employment_mode' = 'Vollzeit' then
|
||||
v_employment_type := 'Vollzeit'; v_weekly_hours := 38.5;
|
||||
elsif payload->>'employment_mode' = 'Teilzeit' then
|
||||
v_employment_type := 'Teilzeit'; v_weekly_hours := (payload->>'weekly_hours')::numeric;
|
||||
end if;
|
||||
|
||||
if v_return_date <= current_date then
|
||||
-- Keine Manager-Nachführung mehr nötig: wer aus der Abwesenheit
|
||||
-- zurückkehrt, ist wieder anwesend, und die abgeleitete Berichtslinie
|
||||
-- fällt automatisch von der Vertretung auf ihn zurück.
|
||||
update employees set
|
||||
status = 'Aktiv',
|
||||
karenz_return_date = null,
|
||||
karenz_start_date = null,
|
||||
absence_type = null,
|
||||
employment_type = coalesce(v_employment_type, employment_type),
|
||||
weekly_hours = coalesce(v_weekly_hours, weekly_hours),
|
||||
-- Kehrt jemand reduziert zurück, ist der Grund dafür ein Zustand,
|
||||
-- kein Einmalereignis: danach lässt sich auswerten, wer gerade in
|
||||
-- Eltern- oder Wiedereingliederungsteilzeit ist.
|
||||
teilzeit_art = case when payload->>'employment_mode' = 'Teilzeit' then v_grund else teilzeit_art end,
|
||||
teilzeit_bis = case
|
||||
when payload->>'employment_mode' = 'Teilzeit' and v_grund is not null
|
||||
then nullif(payload->>'teilzeit_bis', '')::date
|
||||
when payload->>'employment_mode' = 'Teilzeit' then null
|
||||
else teilzeit_bis end
|
||||
where id = v_employee_id;
|
||||
else
|
||||
update employees set karenz_return_date = v_return_date where id = v_employee_id;
|
||||
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
|
||||
values (v_employee_id, 'karenz_return', v_return_date,
|
||||
jsonb_build_object('employment_type', v_employment_type, 'weekly_hours', v_weekly_hours,
|
||||
'teilzeit_art', v_grund, 'teilzeit_bis', nullif(payload->>'teilzeit_bis', '')));
|
||||
end if;
|
||||
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_employee_id, v_return_date, 'Rückkehr',
|
||||
'Rückkehr aus ' || coalesce(v_absence_type, 'Langzeitabwesenheit') || ' am ' || v_return_date
|
||||
|| case when payload->>'employment_mode' = 'Teilzeit'
|
||||
then ', reduziert auf ' || (payload->>'weekly_hours') || ' h'
|
||||
|| coalesce(' (' || v_grund || ')', '')
|
||||
else '' end);
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (app_current_user_id(), current_actor_name(), 'Rückkehr', v_name, v_employee_id, 'Rückkehr am ' || v_return_date || coalesce(' — ' || v_grund, ''));
|
||||
end;
|
||||
$function$;
|
||||
|
||||
|
||||
-- Selbstprüfung.
|
||||
do $$
|
||||
declare
|
||||
v_chg text := pg_get_functiondef('public.change_employee_data(jsonb)'::regprocedure);
|
||||
v_ret text := pg_get_functiondef('public.record_karenz_return(jsonb)'::regprocedure);
|
||||
begin
|
||||
if not (app_feld_karte() ? 'Teilzeitvariante' and app_feld_karte() ? 'Teilzeit bis') then
|
||||
raise exception 'Die Feldtabelle kennt die Teilzeitvariante nicht';
|
||||
end if;
|
||||
if v_chg not like '%teilzeit_art%' then
|
||||
raise exception 'change_employee_data schreibt die Teilzeitvariante nicht';
|
||||
end if;
|
||||
if v_chg like '%hours_reason%' then
|
||||
raise exception 'Der alte Anhang am Beschreibungstext steht noch drin';
|
||||
end if;
|
||||
if v_ret not like '%teilzeit_art%' then
|
||||
raise exception 'record_karenz_return schreibt die Teilzeitvariante nicht';
|
||||
end if;
|
||||
end
|
||||
$$;
|
||||
@@ -0,0 +1,119 @@
|
||||
-- Die Teilzeitvariante übersteht auch eine geplante Rückkehr.
|
||||
--
|
||||
-- record_karenz_return legt für ein Rückkehrdatum in der Zukunft eine Zeile
|
||||
-- in pending_org_changes an; der Tageslauf wendet sie an. Diese Zeile trug
|
||||
-- bisher nur Beschäftigungsausmaß und Stunden — die Variante wäre am Stichtag
|
||||
-- verlorengegangen, und jemand käme in Elternteilzeit zurück, ohne dass es
|
||||
-- irgendwo stünde ausser im Beschreibungstext der Historie.
|
||||
--
|
||||
-- Aufgefallen beim Proben: die Probe hatte ein Rückkehrdatum in der Zukunft
|
||||
-- gewählt und lief deshalb in genau diesen Zweig.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.apply_due_pending_changes()
|
||||
RETURNS integer
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $function$
|
||||
declare
|
||||
v_rec record;
|
||||
v_count int := 0;
|
||||
begin
|
||||
for v_rec in
|
||||
select * from pending_org_changes
|
||||
where status = 'pending' and effective_date <= current_date
|
||||
order by effective_date, created_at
|
||||
loop
|
||||
if v_rec.change_type = 'transfer' then
|
||||
update position_assignments set valid_to = v_rec.effective_date
|
||||
where employee_id = v_rec.employee_id and valid_to is null;
|
||||
insert into position_assignments (position_id, employee_id, valid_from)
|
||||
values ((v_rec.payload->>'target_position_id')::uuid, v_rec.employee_id, v_rec.effective_date);
|
||||
update employees set job_title = (
|
||||
select j.title from om_positions p join jobs j on j.id = p.job_id
|
||||
where p.id = (v_rec.payload->>'target_position_id')::uuid
|
||||
) where id = v_rec.employee_id;
|
||||
|
||||
elsif v_rec.change_type = 'promotion' then
|
||||
update employees set
|
||||
job_title = coalesce(v_rec.payload->>'new_title', job_title),
|
||||
paygrade = coalesce((v_rec.payload->>'new_paygrade')::paygrade_type, paygrade)
|
||||
where id = v_rec.employee_id;
|
||||
|
||||
elsif v_rec.change_type = 'karenz_start' then
|
||||
update employees set
|
||||
status = 'Karenz',
|
||||
karenz_return_date = (v_rec.payload->>'planned_return_date')::date,
|
||||
absence_type = coalesce(nullif(v_rec.payload->>'absence_type', ''), absence_type)
|
||||
where id = v_rec.employee_id;
|
||||
|
||||
elsif v_rec.change_type = 'karenz_return' then
|
||||
update employees set
|
||||
status = 'Aktiv',
|
||||
karenz_return_date = null,
|
||||
karenz_start_date = null,
|
||||
absence_type = null,
|
||||
employment_type = coalesce((v_rec.payload->>'employment_type')::employment_type, employment_type),
|
||||
weekly_hours = coalesce((v_rec.payload->>'weekly_hours')::numeric, weekly_hours),
|
||||
-- Auch bei einer *geplanten* Rückkehr: sonst käme jemand am
|
||||
-- Stichtag mit reduzierten Stunden zurück, und der Grund dafür
|
||||
-- wäre verschwunden. Der Nachweis stünde nur in der Historie,
|
||||
-- auswerten liesse sich nichts.
|
||||
teilzeit_art = case when v_rec.payload ? 'teilzeit_art'
|
||||
then nullif(v_rec.payload->>'teilzeit_art', '') else teilzeit_art end,
|
||||
teilzeit_bis = case when v_rec.payload ? 'teilzeit_art'
|
||||
then nullif(v_rec.payload->>'teilzeit_bis', '')::date else teilzeit_bis end
|
||||
where id = v_rec.employee_id;
|
||||
|
||||
elsif v_rec.change_type = 'contract_change' then
|
||||
update employees set
|
||||
first_name = coalesce(v_rec.payload->'person'->>'first_name', first_name),
|
||||
last_name = coalesce(v_rec.payload->'person'->>'last_name', last_name),
|
||||
gender = coalesce((v_rec.payload->'person'->>'gender')::gender_type, gender),
|
||||
birth_date = coalesce((v_rec.payload->'person'->>'birth_date')::date, birth_date),
|
||||
sv_nummer = coalesce(v_rec.payload->'person'->>'sv_nummer', sv_nummer),
|
||||
nationality = coalesce(v_rec.payload->'person'->>'nationality', nationality),
|
||||
address = coalesce(v_rec.payload->'person'->>'address', address),
|
||||
postal_code = coalesce(v_rec.payload->'person'->>'postal_code', postal_code),
|
||||
city = coalesce(v_rec.payload->'person'->>'city', city),
|
||||
address_country = coalesce(v_rec.payload->'person'->>'address_country', address_country),
|
||||
email = coalesce(v_rec.payload->'person'->>'email', email),
|
||||
phone = coalesce(v_rec.payload->'person'->>'phone', phone),
|
||||
employment_type = coalesce((v_rec.payload->'contract'->>'employment_type')::employment_type, employment_type),
|
||||
weekly_hours = coalesce((v_rec.payload->'contract'->>'weekly_hours')::numeric, weekly_hours),
|
||||
contract_type = coalesce((v_rec.payload->'contract'->>'contract_type')::contract_type, contract_type)
|
||||
where id = v_rec.employee_id;
|
||||
|
||||
elsif v_rec.change_type = 'dependent_add' then
|
||||
insert into employee_dependents (employee_id, first_name, last_name, relationship, sv_nummer, birth_date)
|
||||
values (v_rec.employee_id, v_rec.payload->>'first_name', v_rec.payload->>'last_name',
|
||||
(v_rec.payload->>'relationship')::text,
|
||||
nullif(v_rec.payload->>'sv_nummer', ''), (v_rec.payload->>'birth_date')::date);
|
||||
|
||||
elsif v_rec.change_type = 'dependent_remove' then
|
||||
delete from employee_dependents where id = (v_rec.payload->>'dependent_id')::uuid;
|
||||
end if;
|
||||
|
||||
update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id;
|
||||
v_count := v_count + 1;
|
||||
end loop;
|
||||
|
||||
return v_count;
|
||||
end;
|
||||
$function$;
|
||||
|
||||
|
||||
-- Selbstprüfung.
|
||||
do $$
|
||||
declare
|
||||
v_apply text := pg_get_functiondef('public.apply_due_pending_changes()'::regprocedure);
|
||||
v_ret text := pg_get_functiondef('public.record_karenz_return(jsonb)'::regprocedure);
|
||||
begin
|
||||
if v_apply not like '%teilzeit_art%' then
|
||||
raise exception 'Der Tageslauf überträgt die Teilzeitvariante nicht';
|
||||
end if;
|
||||
if v_ret not like '%teilzeit_art%' then
|
||||
raise exception 'record_karenz_return legt die Variante nicht in den Vorgang';
|
||||
end if;
|
||||
end
|
||||
$$;
|
||||
607
supabase/migrations/20260814180000_karenz_ruecknahme.sql
Normal file
607
supabase/migrations/20260814180000_karenz_ruecknahme.sql
Normal file
@@ -0,0 +1,607 @@
|
||||
-- Abwesenheit und Rückkehr lassen sich zurücknehmen und berichtigen.
|
||||
--
|
||||
-- Bisher endete beides bei Stammdaten- und Vertragsänderungen. Eine
|
||||
-- versehentlich erfasste Langzeitabwesenheit liess sich nur durch eine zweite
|
||||
-- Buchung wieder loswerden — und dann standen zwei Einträge in der Akte, von
|
||||
-- denen der erste nie stattgefunden hat.
|
||||
--
|
||||
-- Damit das Zurücknehmen den Stand wirklich wiederherstellt, halten
|
||||
-- start_karenz und record_karenz_return jetzt ihre Vorher-Werte fest, so wie
|
||||
-- change_employee_data es tut: Status, Art der Abwesenheit, Beginn, geplante
|
||||
-- Rückkehr — bei der Rückkehr zusätzlich Beschäftigungsausmaß, Stunden und
|
||||
-- Teilzeitvariante. Erst damit hat die vorhandene Rücknahme etwas, worauf sie
|
||||
-- zurücksetzen kann.
|
||||
--
|
||||
-- ═══ Die Reihenfolge ═══
|
||||
--
|
||||
-- Eine Rückkehr setzt eine Abwesenheit voraus. Bliebe sie stehen, während die
|
||||
-- Abwesenheit verschwindet, stünde in der Akte eine Rückkehr aus dem Nichts,
|
||||
-- und der Status ergäbe sich aus einem Eintrag, dessen Ausgangslage gelöscht
|
||||
-- ist. Deshalb: **eine Abwesenheit lässt sich erst löschen, wenn ihre
|
||||
-- Rückkehr gelöscht ist.** Die Prüfung liegt in der Datenbank, nicht nur in
|
||||
-- der Oberfläche.
|
||||
--
|
||||
-- Zeilen von vor dieser Migration tragen keine Vorher-Werte und bleiben
|
||||
-- deshalb unantastbar — mit derselben Meldung wie bisher.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.app_feld_karte()
|
||||
RETURNS jsonb
|
||||
LANGUAGE sql
|
||||
IMMUTABLE
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $function$
|
||||
select jsonb_build_object(
|
||||
'Vorname', jsonb_build_array('first_name', 'text', 'person'),
|
||||
'Nachname', jsonb_build_array('last_name', 'text', 'person'),
|
||||
'Geschlecht', jsonb_build_array('gender', 'gender_type', 'person'),
|
||||
'Geburtsdatum', jsonb_build_array('birth_date', 'date', 'person'),
|
||||
'SV-Nummer', jsonb_build_array('sv_nummer', 'text', 'person'),
|
||||
'Staatsbürgerschaft', jsonb_build_array('nationality', 'text', 'person'),
|
||||
'Adresse', jsonb_build_array('address', 'text', 'person'),
|
||||
'Postleitzahl', jsonb_build_array('postal_code', 'text', 'person'),
|
||||
'Ort', jsonb_build_array('city', 'text', 'person'),
|
||||
'Land', jsonb_build_array('address_country', 'text', 'person'),
|
||||
'E-Mail', jsonb_build_array('email', 'text', 'person'),
|
||||
'Telefon', jsonb_build_array('phone', 'text', 'person'),
|
||||
'Notfallkontakt', jsonb_build_array('emergency_contact_name', 'text', 'person'),
|
||||
'Notfallkontakt Telefon', jsonb_build_array('emergency_contact_phone', 'text', 'person'),
|
||||
'Notfallkontakt Verhältnis', jsonb_build_array('emergency_contact_relation', 'text', 'person'),
|
||||
'Titel (vorangestellt)', jsonb_build_array('title_prefix', 'liste', 'person'),
|
||||
'Titel (nachgestellt)', jsonb_build_array('title_suffix', 'liste', 'person'),
|
||||
'Beschäftigungsausmaß', jsonb_build_array('employment_type', 'employment_type', 'contract'),
|
||||
'Wochenstunden', jsonb_build_array('weekly_hours', 'numeric', 'contract'),
|
||||
'Vertragsart', jsonb_build_array('contract_type', 'contract_type', 'contract'),
|
||||
'Befristet bis', jsonb_build_array('contract_end_date', 'date', 'contract'),
|
||||
'Angestellte:r/Arbeiter:in', jsonb_build_array('worker_type', 'worker_type', 'role'),
|
||||
'Kollektivvertrag', jsonb_build_array('collective_agreement', 'collective_agreement', 'role'),
|
||||
'Arbeitstage', jsonb_build_array('work_days', 'liste', 'role'),
|
||||
'Betriebsrat', jsonb_build_array('is_betriebsrat', 'boolean', 'role'),
|
||||
'Dienstwagen', jsonb_build_array('has_dienstwagen', 'boolean', 'role'),
|
||||
'Laterale Führung', jsonb_build_array('is_laterale_fuehrung', 'boolean', 'role'),
|
||||
'C-Level', jsonb_build_array('is_c_level', 'boolean', 'role'),
|
||||
'Dienstwagen Antrieb', jsonb_build_array('dienstwagen_art', 'text', 'role'),
|
||||
'Besonderer Kündigungsschutz', jsonb_build_array('has_kuendigungsschutz', 'boolean', 'role'),
|
||||
'Kündigungsschutz bis', jsonb_build_array('kuendigungsschutz_bis', 'date', 'role'),
|
||||
'Teilzeitvariante', jsonb_build_array('teilzeit_art', 'text', 'role'),
|
||||
'Teilzeit bis', jsonb_build_array('teilzeit_bis', 'date', 'role'),
|
||||
-- Die Felder der Abwesenheit. Dritter Eintrag null: sie gehören zu
|
||||
-- keiner Gruppe im payload einer geplanten Änderung, weil Abwesenheit
|
||||
-- und Rückkehr ihre eigenen Vorgänge haben. Wer sie in einer
|
||||
-- geplanten Änderung setzen wollte, hätte keinen Ort dafür — deshalb
|
||||
-- weisen delete_/update_history_entry solche Einträge in der Zukunft
|
||||
-- ab, statt an einer fehlenden Gruppe zu scheitern.
|
||||
'Status', jsonb_build_array('status', 'employment_status', null),
|
||||
'Art der Abwesenheit', jsonb_build_array('absence_type', 'text', null),
|
||||
'Abwesend ab', jsonb_build_array('karenz_start_date', 'date', null),
|
||||
'Geplante Rückkehr', jsonb_build_array('karenz_return_date', 'date', null)
|
||||
);
|
||||
$function$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.start_karenz(payload jsonb)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $function$
|
||||
declare
|
||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||
v_start_date date := (payload->>'karenz_start_date')::date;
|
||||
v_absence_type text := nullif(payload->>'absence_type', '');
|
||||
v_name text;
|
||||
v_old employees%rowtype;
|
||||
-- Ohne Vorher-Werte liesse sich eine irrtümlich erfasste Abwesenheit
|
||||
-- nicht zurücknehmen: es stünde nirgends, was vorher galt.
|
||||
v_changes jsonb := '[]'::jsonb;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select * into v_old from employees where id = v_employee_id;
|
||||
v_name := v_old.first_name || ' ' || v_old.last_name;
|
||||
|
||||
v_changes := app_aenderung(v_changes, 'Status', v_old.status::text,
|
||||
case when v_start_date <= current_date then 'Karenz' else v_old.status::text end);
|
||||
v_changes := app_aenderung(v_changes, 'Art der Abwesenheit', v_old.absence_type, v_absence_type);
|
||||
v_changes := app_aenderung(v_changes, 'Abwesend ab', v_old.karenz_start_date::text, v_start_date::text);
|
||||
v_changes := app_aenderung(v_changes, 'Geplante Rückkehr', v_old.karenz_return_date::text, payload->>'planned_return_date');
|
||||
|
||||
if v_start_date <= current_date then
|
||||
update employees set status = 'Karenz', karenz_start_date = v_start_date,
|
||||
karenz_return_date = (payload->>'planned_return_date')::date,
|
||||
absence_type = v_absence_type
|
||||
where id = v_employee_id;
|
||||
else
|
||||
update employees set karenz_start_date = v_start_date where id = v_employee_id;
|
||||
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
|
||||
values (v_employee_id, 'karenz_start', v_start_date,
|
||||
jsonb_build_object('planned_return_date', payload->>'planned_return_date', 'absence_type', v_absence_type));
|
||||
end if;
|
||||
|
||||
insert into employee_history (employee_id, event_date, event_type, description, changes)
|
||||
values (v_employee_id, v_start_date, 'Karenz',
|
||||
coalesce(v_absence_type, 'Langzeitabwesenheit') || ', geplante Rückkehr am ' || (payload->>'planned_return_date') ||
|
||||
case when payload->>'note' is not null and payload->>'note' <> '' then ' — ' || (payload->>'note') else '' end,
|
||||
v_changes);
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (app_current_user_id(), current_actor_name(), 'Karenz', v_name, v_employee_id,
|
||||
coalesce(v_absence_type, 'Langzeitabwesenheit') || ', geplante Rückkehr ' || (payload->>'planned_return_date'));
|
||||
end;
|
||||
$function$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.record_karenz_return(payload jsonb)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $function$
|
||||
declare
|
||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||
v_return_date date := (payload->>'return_date')::date;
|
||||
v_name text;
|
||||
v_employment_type employment_type;
|
||||
v_weekly_hours numeric;
|
||||
v_karenz_start date;
|
||||
v_absence_type text;
|
||||
v_old employees%rowtype;
|
||||
-- Wie bei der Abwesenheit: ohne Vorher-Werte liesse sich eine
|
||||
-- irrtümlich erfasste Rückkehr nicht zurücknehmen.
|
||||
v_changes jsonb := '[]'::jsonb;
|
||||
-- Warum jemand mit weniger Stunden zurückkommt: Wiedereingliederungs-
|
||||
-- oder Elternteilzeit. Nur bedeutsam, wenn überhaupt reduziert wird.
|
||||
v_grund text := nullif(payload->>'reduction_reason', '');
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select * into v_old from employees where id = v_employee_id;
|
||||
v_name := v_old.first_name || ' ' || v_old.last_name;
|
||||
v_karenz_start := v_old.karenz_start_date;
|
||||
v_absence_type := v_old.absence_type;
|
||||
|
||||
if v_karenz_start is not null and v_return_date <= v_karenz_start then
|
||||
raise exception 'Das Rückkehrdatum muss nach dem Beginn der Langzeitabwesenheit (%) liegen.', v_karenz_start;
|
||||
end if;
|
||||
|
||||
if payload->>'employment_mode' = 'Vollzeit' then
|
||||
v_employment_type := 'Vollzeit'; v_weekly_hours := 38.5;
|
||||
elsif payload->>'employment_mode' = 'Teilzeit' then
|
||||
v_employment_type := 'Teilzeit'; v_weekly_hours := (payload->>'weekly_hours')::numeric;
|
||||
end if;
|
||||
|
||||
if v_return_date <= current_date then
|
||||
-- Keine Manager-Nachführung mehr nötig: wer aus der Abwesenheit
|
||||
-- zurückkehrt, ist wieder anwesend, und die abgeleitete Berichtslinie
|
||||
-- fällt automatisch von der Vertretung auf ihn zurück.
|
||||
update employees set
|
||||
status = 'Aktiv',
|
||||
karenz_return_date = null,
|
||||
karenz_start_date = null,
|
||||
absence_type = null,
|
||||
employment_type = coalesce(v_employment_type, employment_type),
|
||||
weekly_hours = coalesce(v_weekly_hours, weekly_hours),
|
||||
-- Kehrt jemand reduziert zurück, ist der Grund dafür ein Zustand,
|
||||
-- kein Einmalereignis: danach lässt sich auswerten, wer gerade in
|
||||
-- Eltern- oder Wiedereingliederungsteilzeit ist.
|
||||
teilzeit_art = case when payload->>'employment_mode' = 'Teilzeit' then v_grund else teilzeit_art end,
|
||||
teilzeit_bis = case
|
||||
when payload->>'employment_mode' = 'Teilzeit' and v_grund is not null
|
||||
then nullif(payload->>'teilzeit_bis', '')::date
|
||||
when payload->>'employment_mode' = 'Teilzeit' then null
|
||||
else teilzeit_bis end
|
||||
where id = v_employee_id;
|
||||
else
|
||||
update employees set karenz_return_date = v_return_date where id = v_employee_id;
|
||||
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
|
||||
values (v_employee_id, 'karenz_return', v_return_date,
|
||||
jsonb_build_object('employment_type', v_employment_type, 'weekly_hours', v_weekly_hours,
|
||||
'teilzeit_art', v_grund, 'teilzeit_bis', nullif(payload->>'teilzeit_bis', '')));
|
||||
end if;
|
||||
|
||||
if v_return_date <= current_date then
|
||||
v_changes := app_aenderung(v_changes, 'Status', v_old.status::text, 'Aktiv');
|
||||
v_changes := app_aenderung(v_changes, 'Art der Abwesenheit', v_old.absence_type, null);
|
||||
v_changes := app_aenderung(v_changes, 'Abwesend ab', v_old.karenz_start_date::text, null);
|
||||
v_changes := app_aenderung(v_changes, 'Geplante Rückkehr', v_old.karenz_return_date::text, null);
|
||||
if v_employment_type is not null then
|
||||
v_changes := app_aenderung(v_changes, 'Beschäftigungsausmaß', v_old.employment_type::text, v_employment_type::text);
|
||||
v_changes := app_aenderung(v_changes, 'Wochenstunden', v_old.weekly_hours::text, v_weekly_hours::text);
|
||||
end if;
|
||||
if payload->>'employment_mode' = 'Teilzeit' then
|
||||
v_changes := app_aenderung(v_changes, 'Teilzeitvariante', v_old.teilzeit_art, v_grund);
|
||||
v_changes := app_aenderung(v_changes, 'Teilzeit bis', v_old.teilzeit_bis::text,
|
||||
case when v_grund is not null then nullif(payload->>'teilzeit_bis', '') else null end);
|
||||
end if;
|
||||
end if;
|
||||
|
||||
insert into employee_history (employee_id, event_date, event_type, description, changes)
|
||||
values (v_employee_id, v_return_date, 'Rückkehr',
|
||||
'Rückkehr aus ' || coalesce(v_absence_type, 'Langzeitabwesenheit') || ' am ' || v_return_date
|
||||
|| case when payload->>'employment_mode' = 'Teilzeit'
|
||||
then ', reduziert auf ' || (payload->>'weekly_hours') || ' h'
|
||||
|| coalesce(' (' || v_grund || ')', '')
|
||||
else '' end,
|
||||
v_changes);
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (app_current_user_id(), current_actor_name(), 'Rückkehr', v_name, v_employee_id, 'Rückkehr am ' || v_return_date || coalesce(' — ' || v_grund, ''));
|
||||
end;
|
||||
$function$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.delete_history_entry(payload jsonb)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $function$
|
||||
declare
|
||||
v_id uuid := (payload->>'history_id')::uuid;
|
||||
v_eintrag employee_history%rowtype;
|
||||
v_name text;
|
||||
v_karte constant jsonb := app_feld_karte();
|
||||
v_aenderung jsonb;
|
||||
v_feld text;
|
||||
v_wert text;
|
||||
v_spalte text;
|
||||
v_typ text;
|
||||
v_gruppe text;
|
||||
v_spaeter boolean;
|
||||
v_zurueckgesetzt jsonb := '[]'::jsonb;
|
||||
v_setz text[] := '{}';
|
||||
v_plan pending_org_changes%rowtype;
|
||||
v_neuer_payload jsonb;
|
||||
v_leer boolean;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
|
||||
select * into v_eintrag from employee_history where id = v_id;
|
||||
if not found then
|
||||
raise exception 'Historieneintrag nicht gefunden.';
|
||||
end if;
|
||||
|
||||
if v_eintrag.event_type = 'Eintritt' then
|
||||
raise exception 'Der Eintritt lässt sich nicht löschen — er ist der Anfang der Zeitleiste.';
|
||||
end if;
|
||||
|
||||
if v_eintrag.event_type not in ('Stammdatenänderung', 'Vertragsänderung', 'Karenz', 'Rückkehr') then
|
||||
raise exception 'Dieser Vorgang lässt sich hier nicht zurücknehmen. Für % gibt es den passenden Weg.', v_eintrag.event_type;
|
||||
end if;
|
||||
|
||||
-- Die Reihenfolge zählt: eine Rückkehr setzt eine Abwesenheit voraus.
|
||||
-- Bliebe sie stehen, während die Abwesenheit verschwindet, stünde in der
|
||||
-- Akte eine Rückkehr aus dem Nichts — und der Status ergäbe sich aus
|
||||
-- einem Eintrag, dessen Ausgangslage gelöscht ist.
|
||||
if v_eintrag.event_type = 'Karenz' and exists (
|
||||
select 1 from employee_history h
|
||||
where h.employee_id = v_eintrag.employee_id
|
||||
and h.event_type = 'Rückkehr'
|
||||
and (h.event_date, h.created_at) > (v_eintrag.event_date, v_eintrag.created_at)
|
||||
) then
|
||||
raise exception 'Zu dieser Abwesenheit gibt es eine Rückkehr. Sie muss zuerst gelöscht werden.';
|
||||
end if;
|
||||
|
||||
-- Abwesenheit und Rückkehr haben eigene Vorgänge; in einer geplanten
|
||||
-- Änderung haben ihre Felder keinen Ort (siehe app_feld_karte).
|
||||
if v_eintrag.event_type in ('Karenz', 'Rückkehr') and v_eintrag.event_date > current_date then
|
||||
raise exception 'Diese Abwesenheit ist noch nicht wirksam. Sie muss über den Vorgang selbst abgebrochen werden.';
|
||||
end if;
|
||||
|
||||
if v_eintrag.changes is null or jsonb_array_length(v_eintrag.changes) = 0 then
|
||||
raise exception 'Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts, worauf zurückgesetzt werden könnte.';
|
||||
end if;
|
||||
|
||||
select first_name || ' ' || last_name into v_name from employees where id = v_eintrag.employee_id;
|
||||
|
||||
-- ── Noch nicht wirksam: die geplante Änderung entschärfen ──────────
|
||||
if v_eintrag.event_date > current_date then
|
||||
if v_eintrag.pending_id is null then
|
||||
raise exception 'Zu dieser geplanten Änderung ist kein Vorgang hinterlegt. Sie stammt aus der Zeit vor dieser Verknüpfung und lässt sich hier nicht abbrechen.';
|
||||
end if;
|
||||
|
||||
select * into v_plan from pending_org_changes where id = v_eintrag.pending_id for update;
|
||||
if not found or v_plan.status <> 'pending' then
|
||||
raise exception 'Der geplante Vorgang läuft nicht mehr — er wurde bereits angewendet oder abgebrochen.';
|
||||
end if;
|
||||
|
||||
v_neuer_payload := v_plan.payload;
|
||||
for v_aenderung in select * from jsonb_array_elements(v_eintrag.changes) loop
|
||||
v_feld := v_aenderung->>'feld';
|
||||
if not v_karte ? v_feld then
|
||||
continue;
|
||||
end if;
|
||||
v_spalte := v_karte->v_feld->>0;
|
||||
v_gruppe := v_karte->v_feld->>2;
|
||||
if v_neuer_payload ? v_gruppe then
|
||||
v_neuer_payload := jsonb_set(v_neuer_payload, array[v_gruppe], (v_neuer_payload->v_gruppe) - v_spalte);
|
||||
end if;
|
||||
end loop;
|
||||
|
||||
v_leer := coalesce(jsonb_array_length(
|
||||
(select jsonb_agg(k) from jsonb_object_keys(coalesce(v_neuer_payload->'person', '{}'::jsonb)) k)), 0) = 0
|
||||
and coalesce(jsonb_array_length(
|
||||
(select jsonb_agg(k) from jsonb_object_keys(coalesce(v_neuer_payload->'contract', '{}'::jsonb)) k)), 0) = 0
|
||||
and coalesce(jsonb_array_length(
|
||||
(select jsonb_agg(k) from jsonb_object_keys(coalesce(v_neuer_payload->'role', '{}'::jsonb)) k)), 0) = 0;
|
||||
|
||||
if v_leer then
|
||||
update pending_org_changes set status = 'cancelled' where id = v_plan.id;
|
||||
else
|
||||
update pending_org_changes set payload = v_neuer_payload where id = v_plan.id;
|
||||
end if;
|
||||
|
||||
delete from employee_history where id = v_id;
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
|
||||
values (app_current_user_id(), current_actor_name(), 'Geplante Änderung abgebrochen', v_name, v_eintrag.employee_id,
|
||||
v_eintrag.event_type || ' zum ' || v_eintrag.event_date || ' abgebrochen: ' || app_aenderungsfelder(v_eintrag.changes) ||
|
||||
case when v_leer then ' (der Vorgang entfällt ganz)' else ' (der Vorgang läuft mit den übrigen Feldern weiter)' end,
|
||||
v_eintrag.changes);
|
||||
return;
|
||||
end if;
|
||||
|
||||
-- ── Bereits wirksam: Feld für Feld zurücksetzen ────────────────────
|
||||
for v_aenderung in select * from jsonb_array_elements(v_eintrag.changes) loop
|
||||
v_feld := v_aenderung->>'feld';
|
||||
|
||||
if not v_karte ? v_feld then
|
||||
continue;
|
||||
end if;
|
||||
|
||||
select exists (
|
||||
select 1
|
||||
from employee_history h,
|
||||
lateral jsonb_array_elements(coalesce(h.changes, '[]'::jsonb)) a
|
||||
where h.employee_id = v_eintrag.employee_id
|
||||
and h.id <> v_eintrag.id
|
||||
and a->>'feld' = v_feld
|
||||
and (h.event_date, h.created_at) > (v_eintrag.event_date, v_eintrag.created_at)
|
||||
) into v_spaeter;
|
||||
|
||||
if v_spaeter then
|
||||
continue;
|
||||
end if;
|
||||
|
||||
v_spalte := v_karte->v_feld->>0;
|
||||
v_typ := v_karte->v_feld->>1;
|
||||
v_wert := v_aenderung->>'vorher';
|
||||
|
||||
if v_typ = 'liste' then
|
||||
v_setz := v_setz || format('%I = coalesce(string_to_array(%L, '', ''), ''{}'')', v_spalte, nullif(v_wert, ''));
|
||||
else
|
||||
v_setz := v_setz || format('%I = %L::%s', v_spalte, nullif(v_wert, ''), v_typ);
|
||||
end if;
|
||||
|
||||
v_zurueckgesetzt := v_zurueckgesetzt || jsonb_build_object(
|
||||
'feld', v_feld,
|
||||
'vorher', v_aenderung->>'nachher',
|
||||
'nachher', v_wert
|
||||
);
|
||||
end loop;
|
||||
|
||||
-- Alles in einem UPDATE: chk_weekly_hours verknüpft Beschäftigungsausmaß
|
||||
-- und Wochenstunden, und zwischen zwei getrennten Anweisungen stünde
|
||||
-- zwangsläufig ein Zwischenstand, den die Bedingung verbietet.
|
||||
if array_length(v_setz, 1) > 0 then
|
||||
begin
|
||||
execute format('update employees set %s where id = %L', array_to_string(v_setz, ', '), v_eintrag.employee_id);
|
||||
exception when check_violation then
|
||||
raise exception 'Zurücksetzen nicht möglich: die Werte von damals passen nicht mehr zum heutigen Stand (%). Vermutlich wurde ein zusammengehörendes Feld später einzeln geändert.', sqlerrm;
|
||||
end;
|
||||
end if;
|
||||
|
||||
delete from employee_history where id = v_id;
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
|
||||
values (app_current_user_id(), current_actor_name(), 'Historieneintrag gelöscht', v_name, v_eintrag.employee_id,
|
||||
v_eintrag.event_type || ' vom ' || v_eintrag.event_date ||
|
||||
case when jsonb_array_length(v_zurueckgesetzt) = 0
|
||||
then ' gelöscht; keine Werte zurückgesetzt (spätere Änderungen gelten)'
|
||||
else ' gelöscht und zurückgesetzt: ' || app_aenderungsfelder(v_zurueckgesetzt) end,
|
||||
v_zurueckgesetzt);
|
||||
end;
|
||||
$function$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.update_history_entry(payload jsonb)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $function$
|
||||
declare
|
||||
v_id uuid := (payload->>'history_id')::uuid;
|
||||
v_eintrag employee_history%rowtype;
|
||||
v_datum date;
|
||||
v_name text;
|
||||
v_karte constant jsonb := app_feld_karte();
|
||||
v_alt jsonb;
|
||||
v_feld text;
|
||||
v_neuer_wert text;
|
||||
v_neu jsonb := '[]'::jsonb;
|
||||
v_korrektur jsonb := '[]'::jsonb;
|
||||
v_setz text[] := '{}';
|
||||
v_spalte text;
|
||||
v_typ text;
|
||||
v_gruppe text;
|
||||
v_gueltig text;
|
||||
v_plan pending_org_changes%rowtype;
|
||||
v_neuer_payload jsonb;
|
||||
v_war_zukunft boolean;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
|
||||
select * into v_eintrag from employee_history where id = v_id;
|
||||
if not found then
|
||||
raise exception 'Historieneintrag nicht gefunden.';
|
||||
end if;
|
||||
|
||||
if v_eintrag.event_type = 'Eintritt' then
|
||||
raise exception 'Der Eintritt lässt sich hier nicht berichtigen.';
|
||||
end if;
|
||||
|
||||
if v_eintrag.event_type not in ('Stammdatenänderung', 'Vertragsänderung', 'Karenz', 'Rückkehr') then
|
||||
raise exception 'Dieser Vorgang lässt sich hier nicht berichtigen. Für % gibt es den passenden Weg.', v_eintrag.event_type;
|
||||
end if;
|
||||
|
||||
if v_eintrag.event_type in ('Karenz', 'Rückkehr') and v_eintrag.event_date > current_date then
|
||||
raise exception 'Diese Abwesenheit ist noch nicht wirksam. Sie muss über den Vorgang selbst berichtigt werden.';
|
||||
end if;
|
||||
|
||||
if v_eintrag.changes is null or jsonb_array_length(v_eintrag.changes) = 0 then
|
||||
raise exception 'Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts zu berichtigen.';
|
||||
end if;
|
||||
|
||||
v_war_zukunft := v_eintrag.event_date > current_date;
|
||||
v_datum := coalesce(nullif(payload->>'event_date', '')::date, v_eintrag.event_date);
|
||||
|
||||
-- Ein Eintrag bleibt auf seiner Seite der Gegenwart. Beides zu erlauben
|
||||
-- hiesse, eine gelaufene Änderung in eine geplante zu verwandeln (oder
|
||||
-- umgekehrt) — dann müssten Stammdaten und payload gegenläufig angepasst
|
||||
-- werden, und dafür gibt es die fachlichen Vorgänge.
|
||||
if v_war_zukunft and v_datum <= current_date then
|
||||
raise exception 'Eine geplante Änderung lässt sich hier nicht vorziehen. Dafür ist „Daten ändern" der richtige Weg.';
|
||||
end if;
|
||||
if not v_war_zukunft and v_datum > current_date then
|
||||
raise exception 'Eine bereits wirksame Änderung lässt sich nicht in die Zukunft verschieben.';
|
||||
end if;
|
||||
|
||||
select first_name || ' ' || last_name into v_name from employees where id = v_eintrag.employee_id;
|
||||
|
||||
-- Neue Werteliste bauen: Vorher bleibt, Nachher darf ersetzt werden.
|
||||
for v_alt in select * from jsonb_array_elements(v_eintrag.changes) loop
|
||||
v_feld := v_alt->>'feld';
|
||||
select w->>'nachher' into v_neuer_wert
|
||||
from jsonb_array_elements(coalesce(payload->'werte', '[]'::jsonb)) w
|
||||
where w->>'feld' = v_feld;
|
||||
|
||||
if v_neuer_wert is null then
|
||||
v_neu := v_neu || v_alt;
|
||||
else
|
||||
v_neu := v_neu || jsonb_build_object('feld', v_feld, 'vorher', v_alt->>'vorher', 'nachher', nullif(v_neuer_wert, ''));
|
||||
if coalesce(v_alt->>'nachher', '') is distinct from coalesce(nullif(v_neuer_wert, ''), '') then
|
||||
v_korrektur := v_korrektur || jsonb_build_object('feld', v_feld, 'vorher', v_alt->>'nachher', 'nachher', nullif(v_neuer_wert, ''));
|
||||
end if;
|
||||
end if;
|
||||
end loop;
|
||||
|
||||
if jsonb_array_length(v_korrektur) = 0 and v_datum = v_eintrag.event_date then
|
||||
raise exception 'Nichts geändert.';
|
||||
end if;
|
||||
|
||||
update employee_history
|
||||
set changes = v_neu,
|
||||
event_date = v_datum,
|
||||
description = 'Geänderte Felder: ' || app_aenderungsfelder(v_neu) || ', wirksam ab ' || v_datum
|
||||
where id = v_id;
|
||||
|
||||
-- ── Noch nicht wirksam: den geplanten Vorgang nachziehen ───────────
|
||||
if v_war_zukunft then
|
||||
if v_eintrag.pending_id is null then
|
||||
raise exception 'Zu dieser geplanten Änderung ist kein Vorgang hinterlegt. Sie stammt aus der Zeit vor dieser Verknüpfung und lässt sich hier nicht berichtigen.';
|
||||
end if;
|
||||
|
||||
select * into v_plan from pending_org_changes where id = v_eintrag.pending_id for update;
|
||||
if not found or v_plan.status <> 'pending' then
|
||||
raise exception 'Der geplante Vorgang läuft nicht mehr — er wurde bereits angewendet oder abgebrochen.';
|
||||
end if;
|
||||
|
||||
v_neuer_payload := jsonb_set(v_plan.payload, '{effective_date}', to_jsonb(v_datum::text));
|
||||
for v_alt in select * from jsonb_array_elements(v_neu) loop
|
||||
v_feld := v_alt->>'feld';
|
||||
if not v_karte ? v_feld then
|
||||
continue;
|
||||
end if;
|
||||
v_spalte := v_karte->v_feld->>0;
|
||||
v_typ := v_karte->v_feld->>1;
|
||||
v_gruppe := v_karte->v_feld->>2;
|
||||
if not v_neuer_payload ? v_gruppe then
|
||||
v_neuer_payload := jsonb_set(v_neuer_payload, array[v_gruppe], '{}'::jsonb);
|
||||
end if;
|
||||
v_neuer_payload := jsonb_set(
|
||||
v_neuer_payload,
|
||||
array[v_gruppe, v_spalte],
|
||||
case
|
||||
when v_alt->>'nachher' is null then 'null'::jsonb
|
||||
when v_typ = 'liste' then to_jsonb(string_to_array(v_alt->>'nachher', ', '))
|
||||
when v_typ = 'boolean' then to_jsonb((v_alt->>'nachher')::boolean)
|
||||
when v_typ = 'numeric' then to_jsonb((v_alt->>'nachher')::numeric)
|
||||
else to_jsonb(v_alt->>'nachher')
|
||||
end,
|
||||
true);
|
||||
end loop;
|
||||
|
||||
update pending_org_changes
|
||||
set payload = v_neuer_payload, effective_date = v_datum
|
||||
where id = v_plan.id;
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
|
||||
values (app_current_user_id(), current_actor_name(), 'Geplante Änderung berichtigt', v_name, v_eintrag.employee_id,
|
||||
v_eintrag.event_type || ' zum ' || v_eintrag.event_date ||
|
||||
case when v_datum <> v_eintrag.event_date then ' auf ' || v_datum || ' verschoben' else '' end ||
|
||||
case when jsonb_array_length(v_korrektur) > 0 then '; berichtigt: ' || app_aenderungsfelder(v_korrektur) else '' end,
|
||||
v_korrektur);
|
||||
return;
|
||||
end if;
|
||||
|
||||
-- ── Bereits wirksam: Stammdaten nachziehen ─────────────────────────
|
||||
for v_feld in select distinct e->>'feld' from jsonb_array_elements(v_neu) e loop
|
||||
if not v_karte ? v_feld then
|
||||
continue;
|
||||
end if;
|
||||
|
||||
select a->>'nachher' into v_gueltig
|
||||
from employee_history h,
|
||||
lateral jsonb_array_elements(coalesce(h.changes, '[]'::jsonb)) a
|
||||
where h.employee_id = v_eintrag.employee_id
|
||||
and a->>'feld' = v_feld
|
||||
and h.event_date <= current_date
|
||||
order by h.event_date desc, h.created_at desc
|
||||
limit 1;
|
||||
|
||||
v_spalte := v_karte->v_feld->>0;
|
||||
v_typ := v_karte->v_feld->>1;
|
||||
|
||||
if v_typ = 'liste' then
|
||||
v_setz := v_setz || format('%I = coalesce(string_to_array(%L, '', ''), ''{}'')', v_spalte, nullif(v_gueltig, ''));
|
||||
else
|
||||
v_setz := v_setz || format('%I = %L::%s', v_spalte, nullif(v_gueltig, ''), v_typ);
|
||||
end if;
|
||||
end loop;
|
||||
|
||||
if array_length(v_setz, 1) > 0 then
|
||||
begin
|
||||
execute format('update employees set %s where id = %L', array_to_string(v_setz, ', '), v_eintrag.employee_id);
|
||||
exception when check_violation then
|
||||
raise exception 'Der berichtigte Wert passt nicht zum übrigen Stand (%). Zusammengehörende Felder — etwa Beschäftigungsausmaß und Wochenstunden — müssen gemeinsam stimmen.', sqlerrm;
|
||||
end;
|
||||
end if;
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
|
||||
values (app_current_user_id(), current_actor_name(), 'Historieneintrag berichtigt', v_name, v_eintrag.employee_id,
|
||||
v_eintrag.event_type || ' vom ' || v_eintrag.event_date ||
|
||||
case when v_datum <> v_eintrag.event_date then ' auf ' || v_datum || ' umdatiert' else '' end ||
|
||||
case when jsonb_array_length(v_korrektur) > 0
|
||||
then '; berichtigt: ' || app_aenderungsfelder(v_korrektur) else '' end,
|
||||
v_korrektur);
|
||||
end;
|
||||
$function$;
|
||||
|
||||
|
||||
-- Selbstprüfung.
|
||||
do $$
|
||||
declare
|
||||
v_start text := pg_get_functiondef('public.start_karenz(jsonb)'::regprocedure);
|
||||
v_ret text := pg_get_functiondef('public.record_karenz_return(jsonb)'::regprocedure);
|
||||
v_del text := pg_get_functiondef('public.delete_history_entry(jsonb)'::regprocedure);
|
||||
v_upd text := pg_get_functiondef('public.update_history_entry(jsonb)'::regprocedure);
|
||||
begin
|
||||
if v_start not like '%description, changes%' then
|
||||
raise exception 'start_karenz hält keine Vorher-Werte fest';
|
||||
end if;
|
||||
if v_ret not like '%description, changes%' then
|
||||
raise exception 'record_karenz_return hält keine Vorher-Werte fest';
|
||||
end if;
|
||||
if v_del not like '%muss zuerst gelöscht werden%' then
|
||||
raise exception 'Die Reihenfolgeregel fehlt';
|
||||
end if;
|
||||
if v_upd not like '%''Karenz'', ''Rückkehr''%' then
|
||||
raise exception 'update_history_entry lässt die zwei Typen nicht zu';
|
||||
end if;
|
||||
if not (app_feld_karte() ? 'Art der Abwesenheit' and app_feld_karte() ? 'Status') then
|
||||
raise exception 'Die Feldtabelle kennt die Felder der Abwesenheit nicht';
|
||||
end if;
|
||||
end
|
||||
$$;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user